返回 DeepSeek-TUI-2026
mod.rs
根目录 / crates / tui / src / tui / widgets / mod.rs
1 mod footer;
2 mod header;
3 // Some helpers (`shift`, `ctrl_alt`, `is_press`, etc.) are part of the
4 // public surface for issue #93's help overlay and future call sites; allow
5 // dead code rather than scattering `#[allow]` across every constructor.
6 #[allow(dead_code)]
7 pub mod key_hint;
8 // Phase 1 of #85: widget lands without a wire-up site so reviewers can
9 // evaluate the rendering in isolation. The follow-up PR plumbs it through
10 // the composer area in `ui.rs`. `pub mod` (vs the usual `pub use` pattern)
11 // keeps the unused-imports lint quiet until then.
12 pub mod agent_card;
13 pub mod pending_input_preview;
14 mod renderable;
15 pub mod tool_card;
16
17 pub use footer::{
18 FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label,
19 };
20 pub use header::{HeaderData, HeaderWidget};
21 pub use renderable::Renderable;
22
23 use std::time::Duration;
24
25 use crate::palette;
26 use crate::tui::app::{App, AppMode, ComposerDensity, VimMode};
27 use crate::tui::approval::{
28 ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory,
29 };
30 use crate::tui::history::HistoryCell;
31 use crate::tui::scrolling::TranscriptLineMeta;
32 use crate::{commands, config::COMMON_DEEPSEEK_MODELS};
33 use ratatui::{
34 buffer::Buffer,
35 layout::Rect,
36 prelude::Stylize,
37 style::{Color, Modifier, Style},
38 text::{Line, Span},
39 widgets::{
40 Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
41 StatefulWidget, Widget, Wrap,
42 },
43 };
44 use unicode_segmentation::UnicodeSegmentation;
45 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
46
47 const SEND_FLASH_DURATION: Duration = Duration::from_millis(500);
48 const COMPOSER_PANEL_HEIGHT: u16 = 2;
49
50 pub struct ChatWidget {
51 content_area: Rect,
52 lines: Vec<Line<'static>>,
53 scrollbar: Option<TranscriptScrollbar>,
54 }
55
56 #[derive(Debug, Clone, Copy)]
57 struct TranscriptScrollbar {
58 top: usize,
59 visible: usize,
60 total: usize,
61 }
62
63 impl ChatWidget {
64 pub fn new(app: &mut App, area: Rect) -> Self {
65 let content_area = area;
66 let visible_lines = content_area.height as usize;
67 let render_options = app.transcript_render_options();
68
69 if should_render_empty_state(app) {
70 let lines = build_empty_state_lines(app, content_area);
71 app.viewport.last_transcript_area = Some(content_area);
72 app.viewport.last_transcript_top = 0;
73 app.viewport.last_transcript_visible = visible_lines;
74 app.viewport.last_transcript_total = 0;
75 app.viewport.last_transcript_padding_top = 0;
76 return Self {
77 content_area,
78 lines,
79 scrollbar: None,
80 };
81 }
82
83 // Per-cell revision caching (fix for issue #78):
84 //
85 // Every committed history cell carries its own revision counter in
86 // `app.history_revisions`. The transcript cache compares each cell's
87 // current revision against the previously rendered one, so unchanged
88 // cells reuse their cached wrapped lines instead of being re-wrapped
89 // every frame. This is the difference between O(history.len()) and
90 // O(changed_cells) per render — and was the root cause of scroll lag
91 // on long transcripts.
92 //
93 // The active in-flight cell (if any) is appended as the last cell so
94 // its mutations show up at the live tail. Each entry inside the
95 // active cell becomes a virtual cell at index `history.len() + i`,
96 // matching `App::cell_at_virtual_index`. Active-cell entries share
97 // the same `active_cell_revision` salt so any mutation in the active
98 // cell forces only those rows to re-render — committed history rows
99 // are unaffected.
100 app.resync_history_revisions();
101 let active_entries: &[HistoryCell] = app
102 .active_cell
103 .as_ref()
104 .map_or(&[], |active| active.entries());
105
106 let history_len = app.history.len();
107 let has_collapsed = !app.collapsed_cells.is_empty();
108
109 // Fast path: no collapsed cells — use original slices directly.
110 if !has_collapsed {
111 let mut cell_revisions: Vec<u64> =
112 Vec::with_capacity(app.history.len() + active_entries.len());
113 cell_revisions.extend_from_slice(&app.history_revisions);
114 if !active_entries.is_empty() {
115 let active_rev = app.active_cell_revision;
116 for i in 0..active_entries.len() {
117 let salt = (i as u64).wrapping_add(1);
118 cell_revisions.push(
119 active_rev
120 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
121 .wrapping_add(salt),
122 );
123 }
124 }
125 // Build identity mapping: filtered index == original index.
126 app.collapsed_cell_map = (0..app.history.len() + active_entries.len()).collect();
127
128 let shards: [&[HistoryCell]; 2] = [&app.history, active_entries];
129 app.viewport.transcript_cache.ensure_split(
130 &shards,
131 &cell_revisions,
132 content_area.width.max(1),
133 render_options,
134 );
135 } else {
136 // Slow path: clone non-collapsed cells into filtered vecs so
137 // collapsed cells are excluded from rendering. Build the
138 // filtered→original index mapping.
139 let mut filtered_cells: Vec<HistoryCell> =
140 Vec::with_capacity(history_len + active_entries.len());
141 let mut filtered_revs: Vec<u64> =
142 Vec::with_capacity(history_len + active_entries.len());
143 let mut filtered_to_original: Vec<usize> =
144 Vec::with_capacity(history_len + active_entries.len());
145
146 for (idx, cell) in app.history.iter().enumerate() {
147 if app.collapsed_cells.contains(&idx) {
148 continue;
149 }
150 filtered_cells.push(cell.clone());
151 filtered_revs.push(app.history_revisions[idx]);
152 filtered_to_original.push(idx);
153 }
154
155 if !active_entries.is_empty() {
156 let active_rev = app.active_cell_revision;
157 for (i, cell) in active_entries.iter().enumerate() {
158 let original_idx = history_len + i;
159 if app.collapsed_cells.contains(&original_idx) {
160 continue;
161 }
162 filtered_cells.push(cell.clone());
163 let salt = (i as u64).wrapping_add(1);
164 filtered_revs.push(
165 active_rev
166 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
167 .wrapping_add(salt),
168 );
169 filtered_to_original.push(original_idx);
170 }
171 }
172
173 app.collapsed_cell_map = filtered_to_original;
174
175 let shards: [&[HistoryCell]; 1] = [&filtered_cells];
176 app.viewport.transcript_cache.ensure_split(
177 &shards,
178 &filtered_revs,
179 content_area.width.max(1),
180 render_options,
181 );
182 }
183
184 let total_lines = app.viewport.transcript_cache.total_lines();
185
186 let line_meta = app.viewport.transcript_cache.line_meta();
187
188 if app.viewport.pending_scroll_delta != 0 {
189 app.viewport.transcript_scroll = app.viewport.transcript_scroll.scrolled_by(
190 app.viewport.pending_scroll_delta,
191 line_meta,
192 visible_lines,
193 );
194 app.viewport.pending_scroll_delta = 0;
195 }
196
197 let max_start = total_lines.saturating_sub(visible_lines);
198 // v0.8.11 hotfix: snapshot whether the user's prior scroll state
199 // was *deliberately* tail BEFORE we resolve. `resolve_top` clamps
200 // out-of-range `at_line(N)` to `to_bottom()` (e.g. when content
201 // shrunk so `max_start < N`), and `scrolled_by` returns
202 // `to_bottom()` when the whole transcript fits in one screen
203 // even if the user just scrolled up. Either case would fool a
204 // post-resolve `is_at_tail()` check into thinking the user is
205 // tracking the tail and silently revoke `user_scrolled_during_
206 // stream` — the next stream chunk would then yank them back to
207 // bottom mid-read.
208 let was_explicit_tail = app.viewport.transcript_scroll.is_at_tail();
209 let (scroll_state, top) = app
210 .viewport
211 .transcript_scroll
212 .resolve_top(line_meta, max_start);
213 app.viewport.transcript_scroll = scroll_state;
214 // If the user scrolled back to the live tail, the per-stream
215 // "leave me alone" lock is over — new chunks should pin to bottom
216 // again until they explicitly scroll up. Without this clear, content
217 // piles up off-screen below the visible area and the view appears
218 // frozen at the moment they returned to bottom.
219 //
220 // Only clear the lock when the user's INTENT was tail (their
221 // stored state was already `to_bottom()` before resolve), AND
222 // when the transcript actually has scrolling room to talk about
223 // — if everything fits in one screen, "tail" is trivially true
224 // and clearing here would yank the user back to bottom on the
225 // next chunk even though they explicitly scrolled up.
226 if was_explicit_tail && total_lines > visible_lines {
227 app.user_scrolled_during_stream = false;
228 }
229
230 app.viewport.last_transcript_area = Some(content_area);
231 app.viewport.last_transcript_top = top;
232 app.viewport.last_transcript_visible = visible_lines;
233 app.viewport.last_transcript_total = total_lines;
234 app.viewport.last_transcript_padding_top = 0;
235 let detail_target_cell = (!app.viewport.transcript_selection.is_active())
236 .then(|| app.detail_cell_index_for_viewport(top, visible_lines, line_meta))
237 .flatten();
238
239 let end = (top + visible_lines).min(total_lines);
240 let mut lines = if total_lines == 0 {
241 vec![Line::from("")]
242 } else {
243 app.viewport.transcript_cache.lines()[top..end].to_vec()
244 };
245
246 // Brief flash highlight on the most recently sent user message.
247 if !app.low_motion
248 && let Some(send_at) = app.last_send_at
249 {
250 if send_at.elapsed() < SEND_FLASH_DURATION {
251 apply_send_flash(&mut lines, top, &app.history, line_meta);
252 } else {
253 app.last_send_at = None;
254 }
255 }
256
257 if let Some(target_cell) = detail_target_cell {
258 apply_detail_target_highlight(&mut lines, top, target_cell, line_meta);
259 }
260
261 apply_selection(&mut lines, top, app);
262
263 if app.viewport.transcript_scroll.is_at_tail() {
264 app.viewport.last_transcript_padding_top = visible_lines.saturating_sub(lines.len());
265 pad_lines_to_bottom(&mut lines, visible_lines);
266 }
267
268 let scrollbar = (total_lines > visible_lines && content_area.width > 1).then_some(
269 TranscriptScrollbar {
270 top,
271 visible: visible_lines,
272 total: total_lines,
273 },
274 );
275
276 Self {
277 content_area,
278 lines,
279 scrollbar,
280 }
281 }
282 }
283
284 impl Renderable for ChatWidget {
285 fn render(&self, _area: Rect, buf: &mut Buffer) {
286 // Use the passed render area, not self.content_area — those can
287 // drift when layout changes (e.g. file-tree pane toggle), and
288 // using the stale self.content_area is the root cause of text
289 // bleed-through (#400). In debug builds, assert the two match to
290 // catch future drift early.
291 debug_assert_eq!(
292 _area, self.content_area,
293 "ChatWidget content_area drifted from render area: \
294 content_area={:?} render_area={:?}",
295 self.content_area, _area
296 );
297
298 let area = _area;
299
300 // Repaint the full chat area with the deepseek-ink background each
301 // frame. Ratatui's `Paragraph` only writes cells that contain text,
302 // so cells the current frame's paragraph doesn't touch would
303 // otherwise hold the *previous* frame's contents (the `:24Z`
304 // timestamp-tail bleed-through reported in v0.8.5 testing). Using
305 // `Clear` reset cells to terminal default, which read as a brown-
306 // gray on most user setups; an explicit ink fill keeps the chat
307 // area on-brand.
308 Block::default()
309 .style(Style::default().bg(palette::DEEPSEEK_INK))
310 .render(area, buf);
311
312 let paragraph =
313 Paragraph::new(self.lines.clone()).style(Style::default().bg(palette::DEEPSEEK_INK));
314 paragraph.render(area, buf);
315
316 if let Some(scrollbar) = self.scrollbar {
317 let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
318 let mut state = ScrollbarState::new(scrollable_range)
319 .position(scrollbar.top.min(scrollable_range))
320 .viewport_content_length(scrollbar.visible);
321 Scrollbar::new(ScrollbarOrientation::VerticalRight)
322 .begin_symbol(None)
323 .end_symbol(None)
324 .track_symbol(Some("│"))
325 .track_style(Style::default().fg(palette::BORDER_COLOR))
326 .thumb_symbol("┃")
327 .thumb_style(Style::default().fg(palette::DEEPSEEK_SKY))
328 .render(area, buf, &mut state);
329 }
330 }
331
332 fn desired_height(&self, _width: u16) -> u16 {
333 1
334 }
335 }
336
337 pub struct ComposerWidget<'a> {
338 app: &'a App,
339 max_height: u16,
340 slash_menu_entries: &'a [SlashMenuEntry],
341 mention_menu_entries: &'a [String],
342 }
343
344 impl<'a> ComposerWidget<'a> {
345 pub fn new(
346 app: &'a App,
347 max_height: u16,
348 slash_menu_entries: &'a [SlashMenuEntry],
349 mention_menu_entries: &'a [String],
350 ) -> Self {
351 Self {
352 app,
353 max_height,
354 slash_menu_entries,
355 mention_menu_entries,
356 }
357 }
358
359 /// Number of popup rows below the input. Mention and slash menus are
360 /// mutually exclusive — the cursor can only sit inside an `@token` OR
361 /// a `/cmd` token, not both at once. Mention takes precedence because
362 /// the partial-mention check is positional and stricter than slash's
363 /// "starts-with-/" check.
364 fn active_menu_row_count(&self) -> usize {
365 if self.app.is_history_search_active() {
366 self.app.history_search_matches().len().max(1)
367 } else if !self.mention_menu_entries.is_empty() {
368 self.mention_menu_entries.len()
369 } else {
370 self.slash_menu_entries.len()
371 }
372 }
373
374 /// Row reservation passed to `composer_height`. When the slash- or
375 /// mention-menu is active we lock the composer to its worst-case
376 /// envelope so the chat area above doesn't repaint every keystroke
377 /// as the matched-entry count shrinks. Pure cosmetic: the menu
378 /// itself still renders its actual entries — the extra rows are
379 /// just panel padding inside the same Rect.
380 ///
381 /// Reported on Windows 10 PowerShell + WSL where the console
382 /// backend's per-cell write cost makes the layout jitter visible
383 /// even though the work is tiny on Unix terminals. See user
384 /// feedback in v0.8.8 polish thread.
385 fn active_menu_reserved_rows(&self) -> usize {
386 let actual = self.active_menu_row_count();
387 if actual == 0 {
388 return 0;
389 }
390 if self.app.is_history_search_active() {
391 return actual;
392 }
393 // Slash- and mention-menu are the cases that grow/shrink mid-typing.
394 // Reserve the composer's panel-max so the layout stays stable
395 // for the lifetime of the menu session.
396 actual.max(usize::from(self.max_height_cap()))
397 }
398
399 fn has_panel(&self, area: Rect) -> bool {
400 self.app.composer_border && area.height >= 3 && area.width >= 12
401 }
402
403 fn inner_area(&self, area: Rect) -> Rect {
404 if self.has_panel(area) {
405 Block::default().borders(Borders::ALL).inner(area)
406 } else {
407 area
408 }
409 }
410
411 fn mode_color(&self) -> Color {
412 match self.app.mode {
413 AppMode::Agent => palette::MODE_AGENT,
414 AppMode::Yolo => palette::MODE_YOLO,
415 AppMode::Plan => palette::MODE_PLAN,
416 }
417 }
418
419 fn max_height_cap(&self) -> u16 {
420 composer_max_height(self.app.composer_density)
421 }
422 }
423
424 impl Renderable for ComposerWidget<'_> {
425 fn render(&self, area: Rect, buf: &mut Buffer) {
426 let background = Style::default().bg(self.app.ui_theme.composer_bg);
427 let has_panel = self.has_panel(area);
428 let inner_area = self.inner_area(area);
429 let input_text = self.app.composer_display_input();
430 let input_cursor = self.app.composer_display_cursor();
431 let history_search_matches = if self.app.is_history_search_active() {
432 self.app.history_search_matches()
433 } else {
434 Vec::new()
435 };
436 let menu_lines = self.active_menu_row_count();
437 // For the layout-budget calculation, treat the menu as if it were
438 // already at its locked, worst-case height (see
439 // `active_menu_reserved_rows`). Without this, when the matched-entry
440 // count drops mid-typing, `top_padding` grows and the input visually
441 // jumps down inside the panel even though the panel rect stayed put.
442 let menu_lines_for_budget = self.active_menu_reserved_rows().max(menu_lines);
443 let input_rows_budget =
444 composer_input_rows_budget(inner_area.height, menu_lines_for_budget);
445 let content_width = usize::from(inner_area.width.max(1));
446 let (visible_lines, _cursor_row, _cursor_col) =
447 layout_input(input_text, input_cursor, content_width, input_rows_budget);
448 let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1;
449 if has_panel {
450 let border_color = if input_text.trim().is_empty() {
451 palette::BORDER_COLOR
452 } else {
453 self.mode_color()
454 };
455 let hint_line = if self.app.is_history_search_active() {
456 Some(Line::from(vec![
457 Span::styled(
458 format!(
459 " {} ",
460 self.app.tr(crate::localization::MessageId::HistoryHintMove)
461 ),
462 Style::default().fg(palette::TEXT_MUTED),
463 ),
464 Span::styled(
465 format!(
466 "{} ",
467 self.app
468 .tr(crate::localization::MessageId::HistoryHintAccept)
469 ),
470 Style::default().fg(palette::TEXT_MUTED),
471 ),
472 Span::styled(
473 self.app
474 .tr(crate::localization::MessageId::HistoryHintRestore),
475 Style::default().fg(palette::TEXT_MUTED),
476 ),
477 ]))
478 } else if !self.slash_menu_entries.is_empty() {
479 Some(Line::from(vec![
480 Span::styled(" Up/Down move ", Style::default().fg(palette::TEXT_MUTED)),
481 Span::styled("Tab accept ", Style::default().fg(palette::TEXT_MUTED)),
482 Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)),
483 ]))
484 } else if !input_text.trim().is_empty() {
485 // Live disambiguation for #345: when there's content in the
486 // composer, show what `Enter` will do RIGHT NOW so the user
487 // never has to guess between Immediate / Steer / QueueFollowUp /
488 // Queue. The disposition flips with engine state so this hint
489 // is the only reliable cue before pressing Enter.
490 use crate::tui::app::SubmitDisposition;
491 let queue_count = self.app.queued_message_count();
492 let (label, color) = match self.app.decide_submit_disposition() {
493 SubmitDisposition::Immediate => {
494 if queue_count > 0 {
495 (
496 Some(format!("↵ send ({} queued)", queue_count)),
497 palette::DEEPSEEK_SKY,
498 )
499 } else {
500 (None, palette::TEXT_MUTED)
501 }
502 }
503 SubmitDisposition::Queue => {
504 if self.app.offline_mode {
505 (Some("↵ offline queue".to_string()), palette::STATUS_WARNING)
506 } else {
507 let label = if queue_count > 0 {
508 format!("↵ queue ({} waiting)", queue_count.saturating_add(1))
509 } else {
510 "↵ queue for next turn".to_string()
511 };
512 (Some(label), palette::TEXT_MUTED)
513 }
514 }
515 // Steer and QueueFollowUp are now only reached via Ctrl+Enter override.
516 SubmitDisposition::Steer => (
517 Some("↵ steering (Ctrl+Enter)".to_string()),
518 palette::DEEPSEEK_SKY,
519 ),
520 SubmitDisposition::QueueFollowUp => (
521 Some("↵ queued (Ctrl+Enter to steer)".to_string()),
522 palette::TEXT_MUTED,
523 ),
524 };
525 label.map(|text| {
526 Line::from(vec![Span::styled(
527 format!(" {text} "),
528 Style::default().fg(color),
529 )])
530 })
531 } else {
532 None
533 };
534
535 let mut block = Block::default()
536 .title(Line::from(Span::styled(
537 if self.app.is_history_search_active() {
538 self.app
539 .tr(crate::localization::MessageId::HistorySearchTitle)
540 } else if is_draft_mode {
541 "Draft"
542 } else {
543 "Composer"
544 },
545 Style::default().fg(palette::TEXT_MUTED),
546 )))
547 .borders(Borders::ALL)
548 .border_style(Style::default().fg(border_color))
549 .style(background);
550 // Vim mode indicator — shown in the top-right corner of the
551 // composer border when vim editing is active.
552 if self.app.composer.vim_enabled {
553 let color = match self.app.composer.vim_mode {
554 VimMode::Normal => palette::TEXT_MUTED,
555 VimMode::Insert => palette::DEEPSEEK_SKY,
556 VimMode::Visual => palette::MODE_PLAN,
557 };
558 let label = self.app.composer.vim_mode.label();
559 block = block.title_top(
560 Line::from(Span::styled(label, Style::default().fg(color).bold()))
561 .right_aligned(),
562 );
563 }
564 if let Some(hint_line) = hint_line {
565 block = block.title_bottom(hint_line);
566 }
567 block.render(area, buf);
568 } else {
569 Block::default().style(background).render(area, buf);
570 }
571
572 let mut input_lines = Vec::new();
573 if input_text.is_empty() {
574 let placeholder = if self.app.is_history_search_active() {
575 self.app
576 .tr(crate::localization::MessageId::HistorySearchPlaceholder)
577 } else {
578 self.app
579 .tr(crate::localization::MessageId::ComposerPlaceholder)
580 };
581 input_lines.push(Line::from(Span::styled(
582 placeholder,
583 Style::default().fg(palette::TEXT_MUTED).italic(),
584 )));
585 } else {
586 for line in &visible_lines {
587 input_lines.push(Line::from(Span::styled(
588 line.clone(),
589 Style::default().fg(palette::TEXT_PRIMARY),
590 )));
591 }
592 }
593
594 // For non-empty input, input_lines.len() already reflects wrapping via
595 // layout_input. For the empty-input placeholder, Paragraph::wrap will
596 // wrap the single Line at render time, so we must estimate the wrapped
597 // row count ourselves to keep padding accurate on narrow widths.
598 let visual_rows = if input_text.is_empty() {
599 let placeholder = if self.app.is_history_search_active() {
600 self.app
601 .tr(crate::localization::MessageId::HistorySearchPlaceholder)
602 } else {
603 self.app
604 .tr(crate::localization::MessageId::ComposerPlaceholder)
605 };
606 placeholder_visual_lines_for(placeholder, content_width)
607 } else {
608 input_lines.len()
609 };
610 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
611 let mut lines = Vec::new();
612 for _ in 0..top_padding {
613 lines.push(Line::from(""));
614 }
615 lines.extend(input_lines);
616
617 if self.app.is_history_search_active() {
618 if history_search_matches.is_empty() {
619 lines.push(Line::from(Span::styled(
620 self.app
621 .tr(crate::localization::MessageId::HistoryNoMatches),
622 Style::default().fg(palette::TEXT_MUTED),
623 )));
624 } else {
625 let selected = self
626 .app
627 .history_search_selected_index()
628 .min(history_search_matches.len().saturating_sub(1));
629 let menu_visible_rows = inner_area
630 .height
631 .saturating_sub(visual_rows as u16)
632 .saturating_sub(top_padding as u16)
633 .saturating_sub(1)
634 .max(1) as usize;
635 let menu_total = history_search_matches.len();
636 let menu_top = if menu_total <= menu_visible_rows {
637 0
638 } else {
639 let half = menu_visible_rows / 2;
640 if selected <= half {
641 0
642 } else if selected + half >= menu_total {
643 menu_total.saturating_sub(menu_visible_rows)
644 } else {
645 selected.saturating_sub(half)
646 }
647 };
648 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
649
650 for (idx, entry) in history_search_matches
651 .iter()
652 .enumerate()
653 .take(menu_bottom)
654 .skip(menu_top)
655 {
656 let is_selected = idx == selected;
657 let style = if is_selected {
658 Style::default()
659 .fg(palette::SELECTION_TEXT)
660 .bg(palette::SELECTION_BG)
661 } else {
662 Style::default().fg(palette::TEXT_MUTED)
663 };
664 let marker = if is_selected { "▸" } else { " " };
665 lines.push(Line::from(vec![
666 Span::styled(" ", Style::default()),
667 Span::styled(marker, style),
668 Span::styled(" ", style),
669 Span::styled(entry.clone(), style),
670 ]));
671 }
672 }
673 } else if !self.mention_menu_entries.is_empty() {
674 let selected = self
675 .app
676 .mention_menu_selected
677 .min(self.mention_menu_entries.len().saturating_sub(1));
678 let menu_visible_rows = inner_area
679 .height
680 .saturating_sub(visual_rows as u16)
681 .saturating_sub(top_padding as u16)
682 .saturating_sub(1)
683 .max(1) as usize;
684 let menu_total = self.mention_menu_entries.len();
685 let menu_top = if menu_total <= menu_visible_rows {
686 0
687 } else {
688 let half = menu_visible_rows / 2;
689 if selected <= half {
690 0
691 } else if selected + half >= menu_total {
692 menu_total.saturating_sub(menu_visible_rows)
693 } else {
694 selected.saturating_sub(half)
695 }
696 };
697 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
698
699 for (idx, entry) in self
700 .mention_menu_entries
701 .iter()
702 .enumerate()
703 .take(menu_bottom)
704 .skip(menu_top)
705 {
706 let is_selected = idx == selected;
707 let style = if is_selected {
708 Style::default()
709 .fg(palette::SELECTION_TEXT)
710 .bg(palette::SELECTION_BG)
711 } else {
712 Style::default().fg(palette::TEXT_MUTED)
713 };
714 let marker = if is_selected { "▸" } else { " " };
715 lines.push(Line::from(vec![
716 Span::styled(" ", Style::default()),
717 Span::styled(marker, style),
718 Span::styled(" ", style),
719 Span::styled(format!("@{entry}"), style),
720 ]));
721 }
722 } else if !self.slash_menu_entries.is_empty() {
723 let selected = self
724 .app
725 .slash_menu_selected
726 .min(self.slash_menu_entries.len().saturating_sub(1));
727 let menu_visible_rows = inner_area
728 .height
729 .saturating_sub(visual_rows as u16)
730 .saturating_sub(top_padding as u16)
731 .saturating_sub(1)
732 .max(1) as usize;
733 let menu_total = self.slash_menu_entries.len();
734 let menu_top = if menu_total <= menu_visible_rows {
735 0
736 } else {
737 let half = menu_visible_rows / 2;
738 if selected <= half {
739 0
740 } else if selected + half >= menu_total {
741 menu_total.saturating_sub(menu_visible_rows)
742 } else {
743 selected.saturating_sub(half)
744 }
745 };
746 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
747
748 // Label column width for two-column layout (name + description)
749 let label_width = 22.min(content_width.saturating_sub(4));
750 for (idx, entry) in self
751 .slash_menu_entries
752 .iter()
753 .enumerate()
754 .take(menu_bottom)
755 .skip(menu_top)
756 {
757 let is_selected = idx == selected;
758 let sel_style = if is_selected {
759 Style::default()
760 .fg(palette::SELECTION_TEXT)
761 .bg(palette::SELECTION_BG)
762 } else {
763 Style::default().fg(palette::TEXT_MUTED)
764 };
765 let marker = if is_selected { "▸" } else { " " };
766
767 // Name column
768 let name_style = if entry.is_skill && !is_selected {
769 Style::default().fg(palette::DEEPSEEK_SKY)
770 } else {
771 sel_style
772 };
773
774 // Description column (muted when not selected, secondary when selected)
775 let desc_style = if is_selected {
776 Style::default()
777 .fg(palette::SELECTION_TEXT)
778 .bg(palette::SELECTION_BG)
779 } else {
780 Style::default().fg(palette::TEXT_DIM)
781 };
782
783 let name_display = {
784 let display_width: usize = entry.name.width();
785 if display_width > label_width {
786 let mut s = String::new();
787 let mut w = 0;
788 for ch in entry.name.chars() {
789 let cw = ch.width().unwrap_or(0);
790 if w + cw + 1 > label_width {
791 break;
792 }
793 s.push(ch);
794 w += cw;
795 }
796 s.push('…');
797 // pad to label_width display cols
798 while s.width() < label_width {
799 s.push(' ');
800 }
801 s
802 } else {
803 // pad to label_width display cols
804 let mut s = entry.name.clone();
805 while s.width() < label_width {
806 s.push(' ');
807 }
808 s
809 }
810 };
811
812 // Skill marker prefix
813 let skill_prefix = if entry.is_skill { "✦" } else { " " };
814
815 // Compute exact prefix display width to avoid Paragraph wrap:
816 // 1(" ") + 1(marker) + skill_prefix.width() + label_width + 2(" ")
817 let prefix_display_width = 1 + 1 + skill_prefix.width() + label_width + 2;
818 let desc_capacity = content_width.saturating_sub(prefix_display_width);
819 let desc_display = {
820 let display_width: usize = entry.description.width();
821 if display_width > desc_capacity && desc_capacity > 0 {
822 let mut s = String::new();
823 let mut w = 0;
824 for ch in entry.description.chars() {
825 let cw = ch.width().unwrap_or(0);
826 if w + cw + 1 > desc_capacity {
827 break;
828 }
829 s.push(ch);
830 w += cw;
831 }
832 s.push('…');
833 s
834 } else {
835 entry.description.clone()
836 }
837 };
838
839 lines.push(Line::from(vec![
840 Span::styled(" ", Style::default()),
841 Span::styled(marker, sel_style),
842 Span::styled(skill_prefix, name_style),
843 Span::styled(name_display, name_style),
844 Span::styled(" ", desc_style),
845 Span::styled(desc_display, desc_style),
846 ]));
847 }
848 }
849
850 let paragraph = Paragraph::new(lines)
851 .style(background)
852 .wrap(Wrap { trim: false });
853 paragraph.render(inner_area, buf);
854 }
855
856 fn desired_height(&self, width: u16) -> u16 {
857 composer_height(
858 self.app.composer_display_input(),
859 width,
860 self.max_height.min(self.max_height_cap()),
861 self.active_menu_reserved_rows(),
862 self.app.composer_density,
863 self.app.composer_border,
864 )
865 }
866
867 fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
868 let inner_area = self.inner_area(area);
869 let input_text = self.app.composer_display_input();
870 let input_cursor = self.app.composer_display_cursor();
871 let content_width = usize::from(inner_area.width.max(1));
872 // Match the render path's locked-budget calculation so the cursor
873 // lands on the same row the input is drawn on.
874 let input_rows_budget =
875 composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows());
876
877 let (visible_lines, cursor_row, cursor_col) =
878 layout_input(input_text, input_cursor, content_width, input_rows_budget);
879 let visual_rows = if input_text.is_empty() {
880 let placeholder = if self.app.is_history_search_active() {
881 self.app
882 .tr(crate::localization::MessageId::HistorySearchPlaceholder)
883 } else {
884 self.app
885 .tr(crate::localization::MessageId::ComposerPlaceholder)
886 };
887 placeholder_visual_lines_for(placeholder, content_width)
888 } else {
889 visible_lines.len()
890 };
891 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
892
893 let cursor_x = area
894 .x
895 .saturating_add(inner_area.x.saturating_sub(area.x))
896 .saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
897 let cursor_y = area
898 .y
899 .saturating_add(inner_area.y.saturating_sub(area.y))
900 .saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX));
901 if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
902 Some((cursor_x, cursor_y))
903 } else {
904 None
905 }
906 }
907 }
908
909 /// Codex-style full-screen approval takeover (#129).
910 ///
911 /// The widget reads its mutable state (selected option, staged
912 /// confirmation) directly from the [`ApprovalView`] so the destructive
913 /// variant can render its "Press Y again to confirm" banner without
914 /// touching internal fields. Rendering reflows to fill most of the
915 /// transcript area instead of a centered popup; on small terminals it
916 /// falls back to a 65×22 card so existing snapshot tests still see a
917 /// coherent layout.
918 pub struct ApprovalWidget<'a> {
919 request: &'a ApprovalRequest,
920 view: &'a ApprovalView,
921 }
922
923 impl<'a> ApprovalWidget<'a> {
924 pub fn new(request: &'a ApprovalRequest, view: &'a ApprovalView) -> Self {
925 Self { request, view }
926 }
927 }
928
929 /// Layout pad around the takeover card. Generous so the modal feels
930 /// like a takeover rather than a popup, but never larger than the
931 /// terminal can hold.
932 const APPROVAL_CARD_HORIZONTAL_PAD: u16 = 6;
933 const APPROVAL_CARD_VERTICAL_PAD: u16 = 2;
934 /// Minimum card height — anything tighter and the destructive variant's
935 /// confirmation banner overlaps the option list.
936 const APPROVAL_CARD_MIN_HEIGHT: u16 = 18;
937 /// Maximum card width — readability craters past this on wide terminals.
938 const APPROVAL_CARD_MAX_WIDTH: u16 = 96;
939
940 impl Renderable for ApprovalWidget<'_> {
941 fn render(&self, area: Rect, buf: &mut Buffer) {
942 let card_area = compute_takeover_area(area);
943 Clear.render(card_area, buf);
944
945 let risk = self.request.risk;
946 let palette_colors = approval_palette(risk);
947 let mut lines: Vec<Line<'static>> = Vec::with_capacity(20);
948
949 // Header: stakes badge + tool identifier. The badge is the
950 // first thing the eye lands on.
951 lines.push(Line::from(""));
952 lines.push(Line::from(vec![
953 Span::raw(" "),
954 Span::styled(
955 format!(" {} ", risk_badge_text(risk)),
956 Style::default()
957 .fg(palette::DEEPSEEK_INK)
958 .bg(palette_colors.accent)
959 .add_modifier(Modifier::BOLD),
960 ),
961 Span::raw(" "),
962 Span::styled(
963 self.request.tool_name.clone(),
964 Style::default()
965 .fg(palette::DEEPSEEK_SKY)
966 .add_modifier(Modifier::BOLD),
967 ),
968 ]));
969
970 // Category line — unchanged vocabulary so existing tests still
971 // recognise the rendering.
972 let (cat_label, cat_color) = category_label_for(self.request.category);
973 lines.push(Line::from(vec![
974 Span::raw(" "),
975 Span::styled("Type: ", Style::default().fg(palette::TEXT_HINT)),
976 Span::styled(
977 cat_label,
978 Style::default().fg(cat_color).add_modifier(Modifier::BOLD),
979 ),
980 ]));
981
982 lines.push(Line::from(""));
983 // About + impacts. Impact lines are the load-bearing content;
984 // they tell the user what will happen.
985 lines.push(Line::from(vec![
986 Span::raw(" "),
987 Span::styled("About: ", Style::default().fg(palette::TEXT_HINT)),
988 Span::styled(
989 self.request.description.clone(),
990 Style::default().fg(palette::TEXT_BODY),
991 ),
992 ]));
993 for impact in self.request.impacts.iter().take(4) {
994 lines.push(Line::from(vec![
995 Span::raw(" "),
996 Span::styled("Impact: ", Style::default().fg(palette::TEXT_HINT)),
997 Span::styled(impact.clone(), Style::default().fg(palette::TEXT_BODY)),
998 ]));
999 }
1000
1001 lines.push(Line::from(""));
1002 let params_str = self.request.params_display();
1003 let params_width = card_area.width.saturating_sub(14) as usize;
1004 let params_truncated =
1005 crate::utils::truncate_with_ellipsis(&params_str, params_width.max(20), "...");
1006 lines.push(Line::from(vec![
1007 Span::raw(" "),
1008 Span::styled("Params: ", Style::default().fg(palette::TEXT_HINT)),
1009 Span::styled(
1010 params_truncated,
1011 Style::default().fg(palette::TEXT_SECONDARY),
1012 ),
1013 ]));
1014
1015 lines.push(Line::from(""));
1016
1017 let options = approval_options_for(risk);
1018 let pending = self.view.pending_confirm();
1019
1020 for (i, opt) in options.iter().enumerate() {
1021 let is_selected = i == self.view.selected();
1022 let staged = pending.is_some_and(|p| p == opt.option);
1023 let label_color = if opt.dangerous {
1024 palette_colors.accent
1025 } else {
1026 palette::TEXT_BODY
1027 };
1028
1029 let row_style = if is_selected {
1030 Style::default()
1031 .fg(palette::SELECTION_TEXT)
1032 .bg(palette::SELECTION_BG)
1033 } else {
1034 Style::default()
1035 };
1036
1037 let mut spans = vec![
1038 Span::raw(" "),
1039 Span::styled(
1040 format!("[{}] ", opt.key_hint),
1041 Style::default()
1042 .fg(palette_colors.shortcut)
1043 .add_modifier(Modifier::BOLD),
1044 ),
1045 Span::styled(opt.label.to_string(), row_style.fg(label_color)),
1046 ];
1047 if staged {
1048 spans.push(Span::raw(" "));
1049 spans.push(Span::styled(
1050 "(staged)",
1051 Style::default()
1052 .fg(palette_colors.accent)
1053 .add_modifier(Modifier::BOLD),
1054 ));
1055 }
1056 lines.push(Line::from(spans));
1057 }
1058
1059 // Variant-specific footer: benign nudges single-key approve;
1060 // destructive shows either the standing prompt or the
1061 // confirmation banner when an approve key has been staged.
1062 lines.push(Line::from(""));
1063 match (risk, pending) {
1064 (RiskLevel::Benign, _) => {
1065 lines.push(Line::from(vec![
1066 Span::raw(" "),
1067 Span::styled(
1068 "Single key approves: ",
1069 Style::default().fg(palette::TEXT_HINT),
1070 ),
1071 Span::styled(
1072 "Enter / 1 / y",
1073 Style::default()
1074 .fg(palette_colors.accent)
1075 .add_modifier(Modifier::BOLD),
1076 ),
1077 Span::styled(
1078 " · v: full params · Esc: abort",
1079 Style::default().fg(palette::TEXT_HINT),
1080 ),
1081 ]));
1082 }
1083 (RiskLevel::Destructive, Some(opt)) => {
1084 let again_key = match opt {
1085 crate::tui::approval::ApprovalOption::ApproveOnce => "Enter or y",
1086 crate::tui::approval::ApprovalOption::ApproveAlways => "Enter or a",
1087 _ => "Enter",
1088 };
1089 lines.push(Line::from(vec![
1090 Span::raw(" "),
1091 Span::styled(
1092 "Confirm destructive action — press ",
1093 Style::default()
1094 .fg(palette_colors.accent)
1095 .add_modifier(Modifier::BOLD),
1096 ),
1097 Span::styled(
1098 again_key.to_string(),
1099 Style::default()
1100 .fg(palette::DEEPSEEK_INK)
1101 .bg(palette_colors.accent)
1102 .add_modifier(Modifier::BOLD),
1103 ),
1104 Span::styled(
1105 " again to commit, anything else cancels.",
1106 Style::default().fg(palette::TEXT_HINT),
1107 ),
1108 ]));
1109 }
1110 (RiskLevel::Destructive, None) => {
1111 lines.push(Line::from(vec![
1112 Span::raw(" "),
1113 Span::styled(
1114 "Two keys to approve: ",
1115 Style::default().fg(palette::TEXT_HINT),
1116 ),
1117 Span::styled(
1118 "y/a then y/a again",
1119 Style::default()
1120 .fg(palette_colors.accent)
1121 .add_modifier(Modifier::BOLD),
1122 ),
1123 Span::styled(
1124 " · v: full params · Esc: abort",
1125 Style::default().fg(palette::TEXT_HINT),
1126 ),
1127 ]));
1128 }
1129 }
1130
1131 let title = format!(
1132 " {} approval — {} ",
1133 risk_badge_text(risk),
1134 self.request.tool_name
1135 );
1136 let block = Block::default()
1137 .title(title)
1138 .borders(Borders::ALL)
1139 .border_style(Style::default().fg(palette_colors.border))
1140 .style(Style::default().bg(palette::DEEPSEEK_INK))
1141 .padding(Padding::uniform(1));
1142
1143 // Render the card body inside the block, then paint the warm
1144 // accent rail on the destructive variant. The rail uses a
1145 // single-cell column so it doesn't shift the body layout.
1146 let paragraph = Paragraph::new(lines)
1147 .block(block)
1148 .wrap(Wrap { trim: false });
1149 paragraph.render(card_area, buf);
1150
1151 if matches!(risk, RiskLevel::Destructive) {
1152 paint_left_rail(card_area, buf, palette_colors.accent);
1153 }
1154 }
1155
1156 fn desired_height(&self, _width: u16) -> u16 {
1157 1
1158 }
1159 }
1160
1161 /// Compute the card rect inside `area`. Always centered; pad on every
1162 /// side so the takeover reads as a takeover but a small terminal still
1163 /// renders the full card without truncation.
1164 fn compute_takeover_area(area: Rect) -> Rect {
1165 let avail_width = area.width.saturating_sub(APPROVAL_CARD_HORIZONTAL_PAD * 2);
1166 let avail_height = area.height.saturating_sub(APPROVAL_CARD_VERTICAL_PAD * 2);
1167 let card_width = APPROVAL_CARD_MAX_WIDTH.min(avail_width).max(40);
1168 let card_height = APPROVAL_CARD_MIN_HEIGHT.max(avail_height.min(28));
1169 let x = area.x + (area.width.saturating_sub(card_width)) / 2;
1170 let y = area.y + (area.height.saturating_sub(card_height)) / 2;
1171 Rect {
1172 x,
1173 y,
1174 width: card_width,
1175 height: card_height,
1176 }
1177 }
1178
1179 /// Paint a single-column accent on the inside-left of the card. Only
1180 /// touches cells that already exist in the buffer area.
1181 fn paint_left_rail(card: Rect, buf: &mut Buffer, color: Color) {
1182 if card.width < 2 || card.height < 4 {
1183 return;
1184 }
1185 let rail_x = card.x + 1;
1186 let top = card.y + 1;
1187 let bot = card.y + card.height.saturating_sub(2);
1188 for y in top..=bot {
1189 if y >= buf.area.y + buf.area.height {
1190 break;
1191 }
1192 let cell = &mut buf[(rail_x, y)];
1193 cell.set_char('\u{2503}'); // ┃ — heavy bar so the warning reads at a glance
1194 cell.set_style(Style::default().fg(color).bg(palette::DEEPSEEK_INK));
1195 }
1196 }
1197
1198 /// Approval palette per risk variant.
1199 struct ApprovalColors {
1200 border: Color,
1201 accent: Color,
1202 shortcut: Color,
1203 }
1204
1205 fn approval_palette(risk: RiskLevel) -> ApprovalColors {
1206 match risk {
1207 RiskLevel::Benign => ApprovalColors {
1208 border: palette::BORDER_COLOR,
1209 accent: palette::DEEPSEEK_SKY,
1210 shortcut: palette::DEEPSEEK_SKY,
1211 },
1212 RiskLevel::Destructive => ApprovalColors {
1213 border: palette::DEEPSEEK_RED,
1214 accent: palette::DEEPSEEK_RED,
1215 shortcut: palette::STATUS_WARNING,
1216 },
1217 }
1218 }
1219
1220 fn risk_badge_text(risk: RiskLevel) -> &'static str {
1221 match risk {
1222 RiskLevel::Benign => "REVIEW",
1223 RiskLevel::Destructive => "DESTRUCTIVE",
1224 }
1225 }
1226
1227 fn category_label_for(category: ToolCategory) -> (&'static str, Color) {
1228 match category {
1229 ToolCategory::Safe => ("Safe", palette::STATUS_SUCCESS),
1230 ToolCategory::FileWrite => ("File Write", palette::STATUS_WARNING),
1231 ToolCategory::Shell => ("Shell Command", palette::STATUS_ERROR),
1232 ToolCategory::Network => ("Network", palette::STATUS_WARNING),
1233 ToolCategory::McpRead => ("MCP Read", palette::DEEPSEEK_SKY),
1234 ToolCategory::McpAction => ("MCP Action", palette::STATUS_WARNING),
1235 ToolCategory::Unknown => ("Unknown", palette::STATUS_ERROR),
1236 }
1237 }
1238
1239 struct ApprovalOptionRow {
1240 option: crate::tui::approval::ApprovalOption,
1241 label: &'static str,
1242 key_hint: &'static str,
1243 dangerous: bool,
1244 }
1245
1246 fn approval_options_for(risk: RiskLevel) -> [ApprovalOptionRow; 4] {
1247 use crate::tui::approval::ApprovalOption as O;
1248 let dangerous = matches!(risk, RiskLevel::Destructive);
1249 [
1250 ApprovalOptionRow {
1251 option: O::ApproveOnce,
1252 label: "Approve once",
1253 key_hint: "1 / y",
1254 dangerous,
1255 },
1256 ApprovalOptionRow {
1257 option: O::ApproveAlways,
1258 label: "Approve always for this kind",
1259 key_hint: "2 / a",
1260 dangerous,
1261 },
1262 ApprovalOptionRow {
1263 option: O::Deny,
1264 label: "Deny this call",
1265 key_hint: "3 / d / n",
1266 dangerous: false,
1267 },
1268 ApprovalOptionRow {
1269 option: O::Abort,
1270 label: "Abort the turn",
1271 key_hint: "Esc",
1272 dangerous: false,
1273 },
1274 ]
1275 }
1276
1277 pub struct ElevationWidget<'a> {
1278 request: &'a ElevationRequest,
1279 selected: usize,
1280 }
1281
1282 impl<'a> ElevationWidget<'a> {
1283 pub fn new(request: &'a ElevationRequest, selected: usize) -> Self {
1284 Self { request, selected }
1285 }
1286 }
1287
1288 impl Renderable for ElevationWidget<'_> {
1289 fn render(&self, area: Rect, buf: &mut Buffer) {
1290 let popup_width = 70.min(area.width.saturating_sub(4));
1291 let popup_height = 22.min(area.height.saturating_sub(4));
1292 let popup_area = Rect {
1293 x: (area.width.saturating_sub(popup_width)) / 2,
1294 y: (area.height.saturating_sub(popup_height)) / 2,
1295 width: popup_width,
1296 height: popup_height,
1297 };
1298
1299 Clear.render(popup_area, buf);
1300
1301 let mut lines = vec![
1302 Line::from(""),
1303 Line::from(vec![Span::styled(
1304 " ⚠ Sandbox Denied ",
1305 Style::default()
1306 .fg(palette::STATUS_ERROR)
1307 .add_modifier(Modifier::BOLD),
1308 )]),
1309 Line::from(""),
1310 Line::from(vec![
1311 Span::raw(" Tool: "),
1312 Span::styled(
1313 &self.request.tool_name,
1314 Style::default()
1315 .fg(palette::DEEPSEEK_SKY)
1316 .add_modifier(Modifier::BOLD),
1317 ),
1318 ]),
1319 ];
1320
1321 // Show command if it's a shell command
1322 if let Some(ref command) = self.request.command {
1323 let cmd_display = crate::utils::truncate_with_ellipsis(command, 45, "...");
1324 lines.push(Line::from(vec![
1325 Span::raw(" Cmd: "),
1326 Span::styled(cmd_display, Style::default().fg(palette::TEXT_MUTED)),
1327 ]));
1328 }
1329
1330 lines.push(Line::from(""));
1331 lines.push(Line::from(vec![
1332 Span::raw(" Reason: "),
1333 Span::styled(
1334 &self.request.denial_reason,
1335 Style::default().fg(palette::STATUS_WARNING),
1336 ),
1337 ]));
1338
1339 lines.push(Line::from(""));
1340 lines.push(Line::from(Span::styled(
1341 " Impact if approved:",
1342 Style::default().fg(palette::TEXT_MUTED),
1343 )));
1344 if self
1345 .request
1346 .options
1347 .iter()
1348 .any(|option| matches!(option, ElevationOption::WithNetwork))
1349 {
1350 lines.push(Line::from(Span::styled(
1351 " - network retry enables outbound downloads and HTTP requests",
1352 Style::default().fg(palette::TEXT_PRIMARY),
1353 )));
1354 }
1355 if self
1356 .request
1357 .options
1358 .iter()
1359 .any(|option| matches!(option, ElevationOption::WithWriteAccess(_)))
1360 {
1361 lines.push(Line::from(Span::styled(
1362 " - write retry expands writable filesystem scope for this tool call",
1363 Style::default().fg(palette::TEXT_PRIMARY),
1364 )));
1365 }
1366 lines.push(Line::from(Span::styled(
1367 " - full access removes sandbox restrictions entirely for this retry",
1368 Style::default().fg(palette::TEXT_PRIMARY),
1369 )));
1370 lines.push(Line::from(""));
1371 lines.push(Line::from(Span::styled(
1372 " Choose how to proceed:",
1373 Style::default().fg(palette::TEXT_MUTED),
1374 )));
1375 lines.push(Line::from(""));
1376
1377 // Render options
1378 for (i, option) in self.request.options.iter().enumerate() {
1379 let is_selected = i == self.selected;
1380 let style = if is_selected {
1381 Style::default()
1382 .fg(palette::SELECTION_TEXT)
1383 .bg(palette::SELECTION_BG)
1384 } else {
1385 Style::default()
1386 };
1387
1388 let key = match option {
1389 ElevationOption::WithNetwork => "n",
1390 ElevationOption::WithWriteAccess(_) => "w",
1391 ElevationOption::FullAccess => "f",
1392 ElevationOption::Abort => "a",
1393 };
1394
1395 let label_color = match option {
1396 ElevationOption::Abort => palette::TEXT_MUTED,
1397 ElevationOption::FullAccess => palette::STATUS_ERROR,
1398 _ => palette::TEXT_PRIMARY,
1399 };
1400
1401 lines.push(Line::from(vec![
1402 Span::raw(" "),
1403 Span::styled(
1404 format!("[{key}] "),
1405 Style::default().fg(palette::STATUS_SUCCESS),
1406 ),
1407 Span::styled(option.label(), style.fg(label_color)),
1408 ]));
1409 lines.push(Line::from(vec![
1410 Span::raw(" "),
1411 Span::styled(
1412 option.description(),
1413 Style::default().fg(palette::TEXT_MUTED),
1414 ),
1415 ]));
1416 }
1417
1418 let title = " Sandbox Elevation Required ";
1419 let block = Block::default()
1420 .title(title)
1421 .borders(Borders::ALL)
1422 .border_style(Style::default().fg(palette::BORDER_COLOR))
1423 .style(Style::default().bg(palette::DEEPSEEK_INK))
1424 .padding(Padding::uniform(1));
1425
1426 let paragraph = Paragraph::new(lines)
1427 .block(block)
1428 .wrap(Wrap { trim: false });
1429
1430 paragraph.render(popup_area, buf);
1431 }
1432
1433 fn desired_height(&self, _width: u16) -> u16 {
1434 1
1435 }
1436 }
1437
1438 pub(crate) fn pad_lines_to_bottom(lines: &mut Vec<Line<'static>>, height: usize) {
1439 if lines.len() >= height {
1440 return;
1441 }
1442 let padding = height.saturating_sub(lines.len());
1443 if padding == 0 {
1444 return;
1445 }
1446
1447 let mut padded = Vec::with_capacity(height);
1448 padded.extend(std::iter::repeat_n(Line::from(""), padding));
1449 padded.append(lines);
1450 *lines = padded;
1451 }
1452
1453 fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
1454 let Some((start, end)) = app.viewport.transcript_selection.ordered_endpoints() else {
1455 return;
1456 };
1457
1458 let selection_style = Style::default()
1459 .bg(app.ui_theme.selection_bg)
1460 .fg(palette::SELECTION_TEXT);
1461
1462 for (idx, line) in lines.iter_mut().enumerate() {
1463 let line_index = top + idx;
1464 if line_index < start.line_index || line_index > end.line_index {
1465 continue;
1466 }
1467
1468 let (col_start, col_end) = if start.line_index == end.line_index {
1469 (start.column, end.column)
1470 } else if line_index == start.line_index {
1471 (start.column, usize::MAX)
1472 } else if line_index == end.line_index {
1473 (0, end.column)
1474 } else {
1475 (0, usize::MAX)
1476 };
1477
1478 if col_start == 0 && col_end == usize::MAX {
1479 for span in &mut line.spans {
1480 span.style = span.style.patch(selection_style);
1481 }
1482 continue;
1483 }
1484
1485 line.spans = apply_selection_to_line(line, col_start, col_end, selection_style);
1486 }
1487 }
1488
1489 fn apply_detail_target_highlight(
1490 lines: &mut [Line<'static>],
1491 top: usize,
1492 target_cell: usize,
1493 line_meta: &[TranscriptLineMeta],
1494 ) {
1495 let highlight_bg = Color::Reset;
1496 for (idx, line) in lines.iter_mut().enumerate() {
1497 let line_index = top + idx;
1498 if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
1499 && *cell_index == target_cell
1500 {
1501 for span in &mut line.spans {
1502 span.style = span.style.bg(highlight_bg);
1503 }
1504 }
1505 }
1506 }
1507
1508 /// Apply a brief background tint to the last user message's visible lines.
1509 fn apply_send_flash(
1510 lines: &mut [Line<'static>],
1511 top: usize,
1512 history: &[HistoryCell],
1513 line_meta: &[TranscriptLineMeta],
1514 ) {
1515 // Find the last User cell index.
1516 let last_user_cell = history
1517 .iter()
1518 .rposition(|cell| matches!(cell, HistoryCell::User { .. }));
1519 let Some(target_cell) = last_user_cell else {
1520 return;
1521 };
1522
1523 let flash_bg = Color::Rgb(30, 40, 55); // subtle dark-blue tint
1524
1525 for (idx, line) in lines.iter_mut().enumerate() {
1526 let line_index = top + idx;
1527 if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
1528 && *cell_index == target_cell
1529 {
1530 for span in &mut line.spans {
1531 span.style = span.style.bg(flash_bg);
1532 }
1533 }
1534 }
1535 }
1536
1537 fn apply_selection_to_line(
1538 line: &Line<'static>,
1539 col_start: usize,
1540 col_end: usize,
1541 selection_style: Style,
1542 ) -> Vec<Span<'static>> {
1543 let mut result = Vec::with_capacity(line.spans.len().saturating_add(2));
1544 let mut current_col = 0usize;
1545
1546 for span in &line.spans {
1547 let span_text: &str = span.content.as_ref();
1548 let span_width = text_display_width(span_text);
1549 let span_end = current_col.saturating_add(span_width);
1550
1551 if span_end <= col_start || current_col >= col_end {
1552 result.push(span.clone());
1553 } else if current_col >= col_start && span_end <= col_end {
1554 result.push(Span::styled(
1555 span.content.clone(),
1556 span.style.patch(selection_style),
1557 ));
1558 } else {
1559 let mut before = String::new();
1560 let mut selected = String::new();
1561 let mut after = String::new();
1562 let mut ch_col = current_col;
1563
1564 for ch in span_text.chars() {
1565 let ch_width = char_display_width(ch);
1566 let ch_start = ch_col;
1567 let ch_end = ch_col.saturating_add(ch_width);
1568 if ch_end <= col_start {
1569 before.push(ch);
1570 } else if ch_start >= col_end {
1571 after.push(ch);
1572 } else {
1573 selected.push(ch);
1574 }
1575 ch_col = ch_end;
1576 }
1577
1578 if !before.is_empty() {
1579 result.push(Span::styled(before, span.style));
1580 }
1581 if !selected.is_empty() {
1582 result.push(Span::styled(selected, span.style.patch(selection_style)));
1583 }
1584 if !after.is_empty() {
1585 result.push(Span::styled(after, span.style));
1586 }
1587 }
1588
1589 current_col = span_end;
1590 }
1591
1592 result
1593 }
1594
1595 fn text_display_width(text: &str) -> usize {
1596 text.chars().map(char_display_width).sum()
1597 }
1598
1599 fn char_display_width(ch: char) -> usize {
1600 if ch == '\t' {
1601 4
1602 } else {
1603 UnicodeWidthChar::width(ch).unwrap_or(0).max(1)
1604 }
1605 }
1606
1607 fn should_render_empty_state(app: &App) -> bool {
1608 app.history.is_empty() && !app.is_loading && !app.is_compacting
1609 }
1610
1611 fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
1612 if area.width == 0 || area.height == 0 {
1613 return Vec::new();
1614 }
1615
1616 let workspace_name = app
1617 .workspace
1618 .file_name()
1619 .and_then(|value| value.to_str())
1620 .filter(|value| !value.is_empty())
1621 .map(std::string::ToString::to_string)
1622 .unwrap_or_else(|| app.workspace.to_string_lossy().into_owned());
1623 let body_width = usize::from(area.width.saturating_sub(8).clamp(24, 72));
1624 let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2);
1625 let inset = " ".repeat(left_padding);
1626
1627 let body = vec![
1628 Line::from(Span::styled(
1629 format!("{inset}DeepSeek TUI"),
1630 Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
1631 )),
1632 Line::from(Span::styled(
1633 format!("{inset}{workspace_name} · {}", app.model),
1634 Style::default().fg(palette::TEXT_MUTED),
1635 )),
1636 ];
1637
1638 let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3);
1639 let mut lines = Vec::new();
1640 for _ in 0..top_padding {
1641 lines.push(Line::from(""));
1642 }
1643 lines.extend(body);
1644 lines
1645 }
1646
1647 fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize {
1648 usize::from(inner_height).saturating_sub(extra_lines).max(1)
1649 }
1650
1651 fn composer_top_padding(content_lines: usize, rows_budget: usize) -> usize {
1652 rows_budget.saturating_sub(content_lines.clamp(1, rows_budget))
1653 }
1654
1655 /// Placeholder text shown when the composer input is empty.
1656 #[cfg(test)]
1657 const COMPOSER_PLACEHOLDER: &str = "Write a task or use /.";
1658
1659 /// How many visual rows the empty-input placeholder occupies after wrapping.
1660 #[cfg(test)]
1661 fn placeholder_visual_lines(content_width: usize) -> usize {
1662 placeholder_visual_lines_for(COMPOSER_PLACEHOLDER, content_width)
1663 }
1664
1665 fn placeholder_visual_lines_for(placeholder: &str, content_width: usize) -> usize {
1666 wrap_text(placeholder, content_width).len().max(1)
1667 }
1668
1669 fn composer_min_input_rows(density: ComposerDensity) -> usize {
1670 match density {
1671 ComposerDensity::Compact => 2,
1672 ComposerDensity::Comfortable => 3,
1673 ComposerDensity::Spacious => 4,
1674 }
1675 }
1676
1677 fn composer_max_height(density: ComposerDensity) -> u16 {
1678 match density {
1679 ComposerDensity::Compact => 7,
1680 ComposerDensity::Comfortable => 9,
1681 ComposerDensity::Spacious => 12,
1682 }
1683 }
1684
1685 fn composer_height(
1686 input: &str,
1687 width: u16,
1688 available_height: u16,
1689 extra_lines: usize,
1690 density: ComposerDensity,
1691 show_panel: bool,
1692 ) -> u16 {
1693 let has_panel = show_panel && available_height >= 3 && width >= 12;
1694 let chrome_height = if has_panel {
1695 usize::from(COMPOSER_PANEL_HEIGHT)
1696 } else {
1697 0
1698 };
1699 let content_width = if has_panel {
1700 usize::from(width.saturating_sub(2).max(1))
1701 } else {
1702 usize::from(width.max(1))
1703 };
1704 let mut line_count = wrap_input_lines(input, content_width).len();
1705 if line_count == 0 {
1706 line_count = 1;
1707 }
1708 if has_panel {
1709 line_count = line_count.max(composer_min_input_rows(density));
1710 }
1711 line_count = line_count
1712 .saturating_add(extra_lines)
1713 .saturating_add(chrome_height);
1714 let max_height = usize::from(available_height.clamp(1, composer_max_height(density)));
1715 line_count.clamp(1, max_height).try_into().unwrap_or(1)
1716 }
1717
1718 /// A single entry in the slash-command autocomplete popup.
1719 pub(crate) struct SlashMenuEntry {
1720 pub name: String,
1721 pub description: String,
1722 pub is_skill: bool,
1723 }
1724
1725 pub(crate) fn slash_completion_hints(
1726 input: &str,
1727 limit: usize,
1728 cached_skills: &[(String, String)],
1729 locale: crate::localization::Locale,
1730 ) -> Vec<SlashMenuEntry> {
1731 if !input.starts_with('/') {
1732 return Vec::new();
1733 }
1734
1735 let prefix = input.trim_start_matches('/');
1736 let completing_skill_arg = prefix.strip_prefix("skill ").map(str::trim_start);
1737 if input.contains(char::is_whitespace) && completing_skill_arg.is_none() {
1738 return Vec::new();
1739 }
1740 let mut entries: Vec<SlashMenuEntry> = Vec::new();
1741
1742 // Built-in commands + user-defined commands
1743 // `all_command_names_matching` returns both; we resolve descriptions for
1744 // built-in ones from the static registry and use a generic label for
1745 // user-defined commands.
1746 if completing_skill_arg.is_none() {
1747 for name in commands::all_command_names_matching(prefix) {
1748 let command_key = name.trim_start_matches('/');
1749 let description = if let Some(info) = commands::get_command_info(command_key) {
1750 info.description_for(locale).to_string()
1751 } else {
1752 String::from("User-defined command")
1753 };
1754 entries.push(SlashMenuEntry {
1755 name,
1756 description,
1757 is_skill: false,
1758 });
1759 }
1760 }
1761
1762 // Cached skills
1763 let skill_prefix = completing_skill_arg.unwrap_or(prefix);
1764 let prefix_lower = skill_prefix.to_ascii_lowercase();
1765 for (skill_name, skill_desc) in cached_skills {
1766 let skill_name_lower = skill_name.to_ascii_lowercase();
1767 let command_prefix_matches = completing_skill_arg.is_none()
1768 && (prefix_lower.is_empty()
1769 || "skill".starts_with(&prefix_lower)
1770 || skill_name_lower.starts_with(&prefix_lower));
1771 let skill_arg_matches =
1772 completing_skill_arg.is_some() && skill_name_lower.starts_with(&prefix_lower);
1773 if command_prefix_matches || skill_arg_matches {
1774 entries.push(SlashMenuEntry {
1775 name: format!("/skill {skill_name}"),
1776 description: skill_desc.clone(),
1777 is_skill: true,
1778 });
1779 }
1780 }
1781
1782 // Special: /model <name> completions when only /model matches
1783 if entries.iter().any(|e| e.name == "/model") && prefix_lower.eq_ignore_ascii_case("model") {
1784 for model_name in COMMON_DEEPSEEK_MODELS {
1785 entries.push(SlashMenuEntry {
1786 name: format!("/model {model_name}"),
1787 description: String::from("Switch to this model"),
1788 is_skill: false,
1789 });
1790 }
1791 }
1792
1793 entries.sort_by(|a, b| a.name.cmp(&b.name));
1794 entries.dedup_by(|a, b| a.name == b.name);
1795 entries.into_iter().take(limit).collect()
1796 }
1797
1798 fn layout_input(
1799 input: &str,
1800 cursor: usize,
1801 width: usize,
1802 max_height: usize,
1803 ) -> (Vec<String>, usize, usize) {
1804 let mut lines = wrap_input_lines(input, width);
1805 if lines.is_empty() {
1806 lines.push(String::new());
1807 }
1808 let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
1809
1810 let max_height = max_height.max(1);
1811 let mut start = 0usize;
1812 if cursor_row >= max_height {
1813 start = cursor_row + 1 - max_height;
1814 }
1815 if start + max_height > lines.len() {
1816 start = lines.len().saturating_sub(max_height);
1817 }
1818 let visible = lines
1819 .into_iter()
1820 .skip(start)
1821 .take(max_height)
1822 .collect::<Vec<_>>();
1823 let visible_cursor_row = cursor_row.saturating_sub(start);
1824
1825 (
1826 visible,
1827 visible_cursor_row,
1828 cursor_col.min(width.saturating_sub(1)),
1829 )
1830 }
1831
1832 fn cursor_row_col(input: &str, cursor: usize, width: usize) -> (usize, usize) {
1833 let mut row = 0usize;
1834 let mut col = 0usize;
1835 let mut char_idx = 0usize;
1836
1837 for grapheme in input.graphemes(true) {
1838 if char_idx >= cursor {
1839 break;
1840 }
1841 let grapheme_chars = grapheme.chars().count();
1842 let next_char_idx = char_idx.saturating_add(grapheme_chars);
1843 let cursor_inside = cursor < next_char_idx;
1844
1845 if grapheme == "\n" {
1846 row += 1;
1847 col = 0;
1848 char_idx = next_char_idx;
1849 if cursor_inside {
1850 break;
1851 }
1852 continue;
1853 }
1854
1855 let grapheme_width = grapheme.width();
1856 if col + grapheme_width > width && col != 0 {
1857 row += 1;
1858 col = 0;
1859 }
1860 col += grapheme_width;
1861 if col >= width {
1862 row += 1;
1863 col = 0;
1864 }
1865 if cursor_inside {
1866 break;
1867 }
1868 char_idx = next_char_idx;
1869 }
1870
1871 (row, col)
1872 }
1873
1874 fn wrap_input_lines(input: &str, width: usize) -> Vec<String> {
1875 let mut lines = Vec::new();
1876 if input.is_empty() {
1877 return lines;
1878 }
1879
1880 for raw in input.split('\n') {
1881 let wrapped = wrap_text(raw, width);
1882 if wrapped.is_empty() {
1883 lines.push(String::new());
1884 } else {
1885 lines.extend(wrapped);
1886 }
1887 }
1888
1889 // Note: No need for ends_with('\n') check - split('\n') already includes
1890 // the trailing empty string for inputs ending with newline.
1891
1892 lines
1893 }
1894
1895 fn wrap_text(text: &str, width: usize) -> Vec<String> {
1896 if width == 0 {
1897 return vec![text.to_string()];
1898 }
1899 if text.is_empty() {
1900 return vec![String::new()];
1901 }
1902
1903 let mut lines = Vec::new();
1904 let mut current = String::new();
1905 let mut current_width = 0;
1906
1907 for grapheme in text.graphemes(true) {
1908 if grapheme == "\n" {
1909 lines.push(current);
1910 current = String::new();
1911 current_width = 0;
1912 continue;
1913 }
1914
1915 let grapheme_width = grapheme.width();
1916 if current_width + grapheme_width > width && current_width != 0 {
1917 lines.push(current);
1918 current = String::new();
1919 current_width = 0;
1920 }
1921
1922 current.push_str(grapheme);
1923 current_width += grapheme_width;
1924
1925 if current_width >= width {
1926 lines.push(current);
1927 current = String::new();
1928 current_width = 0;
1929 }
1930 }
1931
1932 lines.push(current);
1933 lines
1934 }
1935
1936 #[cfg(test)]
1937 mod tests {
1938 use super::{
1939 COMPOSER_PANEL_HEIGHT, ChatWidget, ComposerWidget, Renderable, SlashMenuEntry,
1940 apply_selection_to_line, composer_height, composer_max_height, composer_min_input_rows,
1941 composer_top_padding, cursor_row_col, layout_input, pad_lines_to_bottom,
1942 placeholder_visual_lines, should_render_empty_state, slash_completion_hints,
1943 wrap_input_lines, wrap_text,
1944 };
1945 use crate::config::Config;
1946 use crate::localization::Locale;
1947 use crate::palette;
1948 use crate::tui::app::{App, ComposerDensity, TuiOptions};
1949 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
1950 use ratatui::{
1951 buffer::Buffer,
1952 layout::Rect,
1953 style::Style,
1954 text::{Line, Span},
1955 };
1956 use std::path::PathBuf;
1957 use unicode_width::UnicodeWidthStr;
1958
1959 fn create_test_app() -> App {
1960 let options = TuiOptions {
1961 model: "deepseek-v4-flash".to_string(),
1962 workspace: PathBuf::from("."),
1963 config_path: None,
1964 config_profile: None,
1965 allow_shell: false,
1966 use_alt_screen: true,
1967 use_mouse_capture: false,
1968 use_bracketed_paste: true,
1969 max_subagents: 1,
1970 skills_dir: PathBuf::from("."),
1971 memory_path: PathBuf::from("memory.md"),
1972 notes_path: PathBuf::from("notes.txt"),
1973 mcp_config_path: PathBuf::from("mcp.json"),
1974 use_memory: false,
1975 start_in_agent_mode: true,
1976 skip_onboarding: true,
1977 yolo: false,
1978 resume_session_id: None,
1979 initial_input: None,
1980 };
1981 App::new(options, &Config::default())
1982 }
1983
1984 #[test]
1985 fn pad_lines_to_bottom_noop_when_already_filled() {
1986 let mut lines = vec![Line::from("one"), Line::from("two")];
1987 pad_lines_to_bottom(&mut lines, 2);
1988 assert_eq!(lines, vec![Line::from("one"), Line::from("two")]);
1989 }
1990
1991 #[test]
1992 fn pad_lines_to_bottom_prepends_empty_lines() {
1993 let mut lines = vec![Line::from("one"), Line::from("two")];
1994 pad_lines_to_bottom(&mut lines, 5);
1995
1996 assert_eq!(lines.len(), 5);
1997 assert_eq!(lines[0], Line::from(""));
1998 assert_eq!(lines[1], Line::from(""));
1999 assert_eq!(lines[2], Line::from(""));
2000 assert_eq!(lines[3], Line::from("one"));
2001 assert_eq!(lines[4], Line::from("two"));
2002 }
2003
2004 #[test]
2005 fn pad_lines_to_bottom_noop_when_height_is_zero() {
2006 let mut lines = vec![Line::from("one")];
2007 pad_lines_to_bottom(&mut lines, 0);
2008 assert_eq!(lines, vec![Line::from("one")]);
2009 }
2010
2011 // Cursor alignment tests
2012
2013 #[test]
2014 fn cursor_basic_ascii() {
2015 // "hello" with cursor at various positions, width=10
2016 assert_eq!(cursor_row_col("hello", 0, 10), (0, 0));
2017 assert_eq!(cursor_row_col("hello", 3, 10), (0, 3));
2018 assert_eq!(cursor_row_col("hello", 5, 10), (0, 5));
2019 }
2020
2021 #[test]
2022 fn cursor_at_wrap_boundary() {
2023 // "abcde" exactly fills width=5
2024 // Cursor at position 5 (after last char) should wrap to next line
2025 let (row, col) = cursor_row_col("abcde", 5, 5);
2026 assert_eq!(row, 1, "cursor at end of full line should wrap");
2027 assert_eq!(col, 0, "cursor should be at start of next line");
2028 }
2029
2030 #[test]
2031 fn cursor_with_cjk_characters() {
2032 // "中" is a CJK character with width 2
2033 // "a中b" = 1 + 2 + 1 = 4 display width
2034 assert_eq!(cursor_row_col("a中b", 0, 10), (0, 0)); // before 'a'
2035 assert_eq!(cursor_row_col("a中b", 1, 10), (0, 1)); // after 'a', before '中'
2036 assert_eq!(cursor_row_col("a中b", 2, 10), (0, 3)); // after '中', before 'b'
2037 assert_eq!(cursor_row_col("a中b", 3, 10), (0, 4)); // after 'b'
2038 }
2039
2040 #[test]
2041 fn cursor_cjk_at_wrap_boundary() {
2042 // width=5, input "abcd中" (4 + 2 = 6, CJK doesn't fit on line 1)
2043 // CJK should wrap to next line
2044 let lines = wrap_text("abcd中", 5);
2045 assert_eq!(lines, vec!["abcd", "中"]);
2046
2047 // Cursor after CJK should be on row 1, col 2
2048 let (row, col) = cursor_row_col("abcd中", 5, 5);
2049 assert_eq!(row, 1);
2050 assert_eq!(col, 2);
2051 }
2052
2053 #[test]
2054 fn cursor_with_combining_marks() {
2055 // "e\u0301" is 'e' with combining acute accent (é)
2056 // Display width is 1 (combining mark has width 0)
2057 let input = "e\u{0301}"; // é as e + combining acute
2058 assert_eq!(input.chars().count(), 2);
2059
2060 // Cursor positions:
2061 // 0 = before 'e'
2062 // 1 = after 'e', before combining mark
2063 // 2 = after combining mark
2064 assert_eq!(cursor_row_col(input, 0, 10), (0, 0));
2065 assert_eq!(cursor_row_col(input, 1, 10), (0, 1));
2066 assert_eq!(cursor_row_col(input, 2, 10), (0, 1)); // combining mark has width 0
2067 }
2068
2069 #[test]
2070 fn cursor_with_emoji() {
2071 // Many emojis are double-width
2072 let input = "a😀b";
2073 // Cursor at 2 (after emoji) should account for emoji width
2074 let (_row, col) = cursor_row_col(input, 2, 10);
2075 // Emoji width varies by system, but should be either 1 or 2
2076 assert!((2..=3).contains(&col), "col = {col}, expected 2 or 3");
2077 }
2078
2079 #[test]
2080 fn cursor_with_emoji_zwj_sequence() {
2081 let input = "👨‍👩‍👧‍👦";
2082 let cursor = input.chars().count();
2083 let (row, col) = cursor_row_col(input, cursor, 10);
2084 assert_eq!(row, 0);
2085 assert_eq!(col, input.width());
2086 }
2087
2088 #[test]
2089 fn cursor_with_newlines() {
2090 // "ab\ncd" with cursor moving through
2091 assert_eq!(cursor_row_col("ab\ncd", 0, 10), (0, 0)); // before 'a'
2092 assert_eq!(cursor_row_col("ab\ncd", 2, 10), (0, 2)); // after 'b', before '\n'
2093 assert_eq!(cursor_row_col("ab\ncd", 3, 10), (1, 0)); // after '\n', before 'c'
2094 assert_eq!(cursor_row_col("ab\ncd", 5, 10), (1, 2)); // after 'd'
2095 }
2096
2097 #[test]
2098 fn wrap_input_lines_preserves_empty_lines() {
2099 let lines = wrap_input_lines("a\n\nb", 10);
2100 assert_eq!(lines, vec!["a", "", "b"]);
2101 }
2102
2103 #[test]
2104 fn wrap_input_lines_trailing_newline() {
2105 let lines = wrap_input_lines("a\n", 10);
2106 assert_eq!(lines, vec!["a", ""]);
2107 }
2108
2109 #[test]
2110 fn cursor_and_wrap_consistency() {
2111 // Ensure cursor_row_col is consistent with wrap_text
2112 // for various inputs
2113 let test_cases = vec![
2114 ("hello world", 5),
2115 ("abcdefghij", 3),
2116 ("中文测试", 6),
2117 ("a\nb\nc", 10),
2118 ];
2119
2120 for (input, width) in test_cases {
2121 let lines = wrap_input_lines(input, width);
2122 let (cursor_row, _) = cursor_row_col(input, input.chars().count(), width);
2123
2124 // Cursor at end should be on the last line (or wrapped past it)
2125 assert!(
2126 cursor_row <= lines.len(),
2127 "cursor_row={cursor_row} should be <= lines.len()={} for input={input:?}",
2128 lines.len()
2129 );
2130 }
2131 }
2132
2133 #[test]
2134 fn slash_completion_hints_include_links_and_config() {
2135 let hints = slash_completion_hints("/", 128, &[], Locale::En);
2136 assert!(hints.iter().any(|hint| hint.name == "/config"));
2137 assert!(hints.iter().any(|hint| hint.name == "/links"));
2138 }
2139
2140 #[test]
2141 fn slash_completion_hints_exclude_set_and_deepseek_commands() {
2142 let hints = slash_completion_hints("/", 128, &[], Locale::En);
2143 assert!(!hints.iter().any(|hint| hint.name == "/set"));
2144 assert!(!hints.iter().any(|hint| hint.name == "/deepseek"));
2145 }
2146
2147 #[test]
2148 fn slash_completion_hints_include_skills() {
2149 let cached_skills = vec![
2150 ("search-files".to_string(), "Search files".to_string()),
2151 ("my-review".to_string(), "Review code".to_string()),
2152 ];
2153 let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En);
2154 assert!(
2155 hints
2156 .iter()
2157 .any(|hint| hint.name == "/skill search-files" && hint.is_skill)
2158 );
2159 assert!(
2160 hints
2161 .iter()
2162 .any(|hint| hint.name == "/skill my-review" && hint.is_skill)
2163 );
2164 }
2165
2166 #[test]
2167 fn slash_completion_hints_skills_match_prefix() {
2168 let cached_skills = vec![
2169 ("search-files".to_string(), "Search files".to_string()),
2170 ("my-review".to_string(), "Review code".to_string()),
2171 ];
2172 let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En);
2173 assert!(
2174 hints
2175 .iter()
2176 .any(|hint| hint.name == "/skill search-files" && hint.is_skill)
2177 );
2178 assert!(!hints.iter().any(|hint| hint.name == "/skill my-review"));
2179 }
2180
2181 #[test]
2182 fn slash_completion_hints_complete_skill_argument_prefix() {
2183 let cached_skills = vec![
2184 ("search-files".to_string(), "Search files".to_string()),
2185 ("my-review".to_string(), "Review code".to_string()),
2186 ];
2187 let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En);
2188 assert_eq!(hints.len(), 1);
2189 assert_eq!(hints[0].name, "/skill my-review");
2190 assert!(hints[0].is_skill);
2191 }
2192
2193 #[test]
2194 fn selection_style_uses_explicit_selection_text_role() {
2195 let line = Line::from(Span::styled(
2196 "hello world",
2197 Style::default().fg(palette::TEXT_PRIMARY),
2198 ));
2199 let selection_style = Style::default()
2200 .bg(palette::SELECTION_BG)
2201 .fg(palette::SELECTION_TEXT);
2202
2203 let styled = apply_selection_to_line(&line, 0, 5, selection_style);
2204 assert_eq!(styled.len(), 2);
2205 assert_eq!(styled[0].content.as_ref(), "hello");
2206 assert_eq!(styled[0].style.fg, Some(palette::SELECTION_TEXT));
2207 assert_eq!(styled[0].style.bg, Some(palette::SELECTION_BG));
2208 assert_eq!(styled[1].content.as_ref(), " world");
2209 }
2210
2211 #[test]
2212 fn composer_layout_helpers_stay_consistent() {
2213 let input = "line one wraps nicely\nline two wraps as well";
2214 let width = 16;
2215 let available_height = 6;
2216 let menu_lines = 2;
2217
2218 let height = composer_height(
2219 input,
2220 width,
2221 available_height,
2222 menu_lines,
2223 ComposerDensity::Comfortable,
2224 true,
2225 );
2226 let has_panel = available_height >= 3 && width >= 12;
2227 let chrome_height = if has_panel {
2228 usize::from(COMPOSER_PANEL_HEIGHT)
2229 } else {
2230 0
2231 };
2232 let content_width = if has_panel {
2233 usize::from(width.saturating_sub(2).max(1))
2234 } else {
2235 usize::from(width.max(1))
2236 };
2237 let input_height_budget = usize::from(height)
2238 .saturating_sub(menu_lines)
2239 .saturating_sub(chrome_height)
2240 .max(1);
2241 let (visible, cursor_row, cursor_col) = layout_input(
2242 input,
2243 input.chars().count(),
2244 content_width,
2245 input_height_budget,
2246 );
2247
2248 assert!(visible.len().saturating_add(menu_lines) <= usize::from(height));
2249 assert!(!visible.is_empty());
2250 assert!(cursor_row < visible.len());
2251 assert!(cursor_col < content_width.max(1));
2252 assert!(height >= 5);
2253 }
2254
2255 #[test]
2256 fn composer_height_prefers_panel_shape_when_space_allows() {
2257 let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
2258 assert_eq!(height, 5);
2259 }
2260
2261 #[test]
2262 fn composer_height_skips_panel_chrome_when_border_disabled() {
2263 let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
2264 let without_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, false);
2265
2266 assert_eq!(with_border, 5);
2267 assert_eq!(without_border, 1);
2268 assert!(without_border < with_border);
2269 }
2270
2271 #[test]
2272 fn composer_density_changes_min_rows_and_height_cap() {
2273 assert_eq!(composer_min_input_rows(ComposerDensity::Compact), 2);
2274 assert_eq!(composer_min_input_rows(ComposerDensity::Spacious), 4);
2275 assert!(
2276 composer_max_height(ComposerDensity::Spacious)
2277 > composer_max_height(ComposerDensity::Compact)
2278 );
2279 }
2280
2281 #[test]
2282 fn empty_composer_cursor_matches_placeholder_padding() {
2283 let mut app = create_test_app();
2284 // Pin density so the test is independent of any loaded user settings.
2285 app.composer_density = ComposerDensity::Comfortable;
2286 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
2287 let mention_menu_entries = Vec::<String>::new();
2288 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
2289
2290 // Use a wide area so the placeholder fits on one line (no wrapping).
2291 let area = Rect {
2292 x: 0,
2293 y: 0,
2294 width: 40,
2295 height: 5,
2296 };
2297
2298 // inner_area: {x:1, y:1, w:38, h:3} (borders shrink by 1 each side)
2299 // input_rows_budget = 3
2300 // placeholder_visual_lines(38) = 1 (placeholder is 22 chars, fits in 38)
2301 // top_padding = 3 - clamp(1, 1, 3) = 2
2302 // cursor_x = 0 + (1-0) + 0 = 1
2303 // cursor_y = 0 + (1-0) + (2+0) = 3
2304 assert_eq!(widget.cursor_pos(area), Some((1, 3)));
2305 }
2306
2307 #[test]
2308 fn empty_composer_cursor_accounts_for_placeholder_wrapping() {
2309 let mut app = create_test_app();
2310 app.composer_density = ComposerDensity::Comfortable;
2311 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
2312 let mention_menu_entries = Vec::<String>::new();
2313 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
2314
2315 // Narrow area forces the placeholder to wrap.
2316 let area = Rect {
2317 x: 0,
2318 y: 0,
2319 width: 14,
2320 height: 5,
2321 };
2322
2323 // inner_area: {x:1, y:1, w:12, h:3}
2324 // input_rows_budget = 3
2325 // placeholder_visual_lines(12) = 2 ("Write a task" / " or use /.")
2326 // top_padding = 3 - clamp(2, 1, 3) = 1
2327 // cursor_x = 0 + (1-0) + 0 = 1
2328 // cursor_y = 0 + (1-0) + (1+0) = 2
2329 assert_eq!(placeholder_visual_lines(12), 2);
2330 assert_eq!(widget.cursor_pos(area), Some((1, 2)));
2331 }
2332
2333 #[test]
2334 fn slash_menu_open_locks_composer_height_against_match_count_changes() {
2335 // Repro for the Windows 10 PowerShell + WSL feedback: typing
2336 // through a slash command shrinks the matched-entry list, which
2337 // used to shrink the composer height — and shrinking the
2338 // composer forces the chat area above to repaint every
2339 // keystroke. With the height lock, the desired height returned
2340 // for a 5-match menu and a 1-match menu must be identical so
2341 // the layout stays stable for the lifetime of the slash session.
2342 let mut app = create_test_app();
2343 app.composer_density = ComposerDensity::Comfortable;
2344 app.input = "/skill".to_string();
2345
2346 let many_matches: Vec<SlashMenuEntry> = (0..5)
2347 .map(|i| SlashMenuEntry {
2348 name: format!("/skill{i}"),
2349 description: String::new(),
2350 is_skill: false,
2351 })
2352 .collect();
2353 let one_match = vec![SlashMenuEntry {
2354 name: "/skill".to_string(),
2355 description: String::new(),
2356 is_skill: false,
2357 }];
2358 let no_matches = Vec::<SlashMenuEntry>::new();
2359
2360 let widget_many = ComposerWidget::new(&app, 9, &many_matches, &[]);
2361 let widget_one = ComposerWidget::new(&app, 9, &one_match, &[]);
2362 let widget_none = ComposerWidget::new(&app, 9, &no_matches, &[]);
2363
2364 // Fixed worst-case envelope while the slash menu is open.
2365 let height_many = widget_many.desired_height(40);
2366 let height_one = widget_one.desired_height(40);
2367 assert_eq!(
2368 height_many, height_one,
2369 "slash menu height must not jitter as the matched-entry count changes"
2370 );
2371
2372 // Sanity: closing the slash menu (no matches) lets the panel
2373 // collapse back to a tight composer — we only want to lock
2374 // height *while* the menu is open.
2375 let height_none = widget_none.desired_height(40);
2376 assert!(
2377 height_none < height_many,
2378 "with the menu closed the composer should release the reserved rows; got {height_none} vs locked {height_many}"
2379 );
2380 }
2381
2382 #[test]
2383 fn empty_composer_cursor_uses_full_area_when_border_disabled() {
2384 let mut app = create_test_app();
2385 app.composer_density = ComposerDensity::Comfortable;
2386 app.composer_border = false;
2387 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
2388 let mention_menu_entries = Vec::<String>::new();
2389 let widget = ComposerWidget::new(&app, 3, &slash_menu_entries, &mention_menu_entries);
2390
2391 let area = Rect {
2392 x: 0,
2393 y: 0,
2394 width: 40,
2395 height: 3,
2396 };
2397
2398 assert_eq!(widget.cursor_pos(area), Some((0, 2)));
2399 }
2400
2401 #[test]
2402 fn localized_composer_placeholders_render_at_narrow_widths() {
2403 for locale in [Locale::Ja, Locale::ZhHans, Locale::PtBr] {
2404 let mut app = create_test_app();
2405 app.ui_locale = locale;
2406 app.composer_density = ComposerDensity::Comfortable;
2407 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
2408 let mention_menu_entries = Vec::<String>::new();
2409 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
2410 let area = Rect {
2411 x: 0,
2412 y: 0,
2413 width: 18,
2414 height: 5,
2415 };
2416 let mut buf = Buffer::empty(area);
2417
2418 widget.render(area, &mut buf);
2419 let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else {
2420 panic!("localized composer should expose cursor position");
2421 };
2422
2423 assert!(cursor_x < area.width, "{locale:?} cursor x overflow");
2424 assert!(cursor_y < area.height, "{locale:?} cursor y overflow");
2425 }
2426 }
2427
2428 #[test]
2429 fn composer_top_padding_uses_clamp() {
2430 // content_lines=0 is clamped to 1
2431 assert_eq!(composer_top_padding(0, 3), 2);
2432 // content_lines=1
2433 assert_eq!(composer_top_padding(1, 3), 2);
2434 // content_lines=3 fills the budget
2435 assert_eq!(composer_top_padding(3, 3), 0);
2436 // content_lines > budget is clamped
2437 assert_eq!(composer_top_padding(5, 3), 0);
2438 }
2439
2440 #[test]
2441 fn empty_state_renders_only_without_transcript_activity() {
2442 let mut app = create_test_app();
2443 assert!(should_render_empty_state(&app));
2444 app.add_message(crate::tui::history::HistoryCell::User {
2445 content: "hello".to_string(),
2446 });
2447 assert!(!should_render_empty_state(&app));
2448 }
2449
2450 /// Probe: confirm `cell.lines_with_motion` returns no Line whose total
2451 /// visual width exceeds the requested area width, even for pathological
2452 /// long single-line tool results.
2453 #[test]
2454 fn long_tool_result_lines_fit_requested_width() {
2455 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2456 name: "todo_write".to_string(),
2457 status: ToolStatus::Success,
2458 input_summary: Some("items: <2 items>".to_string()),
2459 output: Some("hello world ".repeat(420)),
2460 prompts: None,
2461 spillover_path: None,
2462 }));
2463 for width in [40u16, 80, 111, 165] {
2464 let lines = cell.lines(width);
2465 for (idx, line) in lines.iter().enumerate() {
2466 let visual: usize = line
2467 .spans
2468 .iter()
2469 .map(|s| UnicodeWidthStr::width(s.content.as_ref()))
2470 .sum();
2471 assert!(
2472 visual <= usize::from(width),
2473 "line {idx} at width {width} has visual width {visual} > {width}"
2474 );
2475 }
2476 }
2477 }
2478
2479 /// Regression: a long single-line tool result must not write any cells
2480 /// outside the chat content area (issue #36 — sidebar gutter bleed).
2481 ///
2482 /// We render `ChatWidget` into a buffer that is wider than the chat area
2483 /// (simulating the sidebar split) and assert every cell to the right of
2484 /// `chat_area` is still the default empty cell.
2485 #[test]
2486 fn chat_widget_does_not_bleed_into_sidebar_for_long_tool_result() {
2487 // Reproduces the actual `todo_write` output shape: a status line,
2488 // a newline, then a pretty-printed JSON payload with long string
2489 // values. Run at several widths since the leak in the issue was
2490 // observed at ~165 cols.
2491 let cases: Vec<(u16, u16)> = vec![(80, 50), (120, 80), (165, 111), (200, 140)];
2492 for (total_width, chat_width) in cases {
2493 let mut app = create_test_app();
2494 let long_value: String = "hello world ".repeat(420);
2495 let json_payload = format!(
2496 "{{\n \"items\": [\n {{ \"id\": 1, \"content\": \"{long_value}\", \"status\": \"pending\" }}\n ]\n}}"
2497 );
2498 let output = format!("Todo list updated (1 items, 0% complete)\n{json_payload}");
2499 app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2500 name: "todo_write".to_string(),
2501 status: ToolStatus::Success,
2502 input_summary: Some("todos: <1 items>".to_string()),
2503 output: Some(output),
2504 prompts: None,
2505 spillover_path: None,
2506 })));
2507
2508 let height: u16 = 30;
2509 let chat_area = Rect {
2510 x: 0,
2511 y: 0,
2512 width: chat_width,
2513 height,
2514 };
2515 let full_area = Rect {
2516 x: 0,
2517 y: 0,
2518 width: total_width,
2519 height,
2520 };
2521 let mut buf = Buffer::empty(full_area);
2522
2523 let widget = ChatWidget::new(&mut app, chat_area);
2524 widget.render(chat_area, &mut buf);
2525
2526 // Every cell outside chat_area should remain at default. If the
2527 // widget bled, we'll see leftover symbols.
2528 let default_symbol = " ";
2529 for y in 0..height {
2530 for x in chat_width..total_width {
2531 let cell = &buf[(x, y)];
2532 let sym = cell.symbol();
2533 assert!(
2534 sym == default_symbol || sym.is_empty(),
2535 "[{total_width}x{height}, chat={chat_width}] cell ({x},{y}) leaked content {sym:?} outside chat_area"
2536 );
2537 }
2538 }
2539 }
2540 }
2541
2542 /// Regression: when the transcript scrollbar is visible, the rightmost
2543 /// content column must remain readable (the scrollbar gets its own
2544 /// 1-column gutter rather than overdrawing chat content).
2545 #[test]
2546 fn chat_widget_reserves_scrollbar_gutter_when_scrollbar_visible() {
2547 let mut app = create_test_app();
2548 // Many short messages → forces the scrollbar to be visible.
2549 for i in 0..200 {
2550 app.add_message(HistoryCell::User {
2551 content: format!("user message {i}"),
2552 });
2553 }
2554
2555 let area = Rect {
2556 x: 0,
2557 y: 0,
2558 width: 80,
2559 height: 8,
2560 };
2561 let mut buf = Buffer::empty(area);
2562 let widget = ChatWidget::new(&mut app, area);
2563 widget.render(area, &mut buf);
2564
2565 // The rightmost column should host the scrollbar track/thumb.
2566 // The penultimate column should still hold normal content (a digit,
2567 // letter, or space — never the scrollbar glyph).
2568 let scrollbar_track = "│";
2569 let scrollbar_thumb = "┃";
2570 let mut scrollbar_seen = false;
2571 for y in 0..area.height {
2572 let last = buf[(area.width - 1, y)].symbol();
2573 let penult = buf[(area.width - 2, y)].symbol();
2574 if last == scrollbar_track || last == scrollbar_thumb {
2575 scrollbar_seen = true;
2576 }
2577 assert!(
2578 penult != scrollbar_track && penult != scrollbar_thumb,
2579 "scrollbar leaked into column {} (cell {:?}) at row {y}",
2580 area.width - 2,
2581 penult
2582 );
2583 }
2584 assert!(
2585 scrollbar_seen,
2586 "scrollbar should be visible for a long history"
2587 );
2588 }
2589
2590 /// Regression for issue #582: a resize event arriving while the
2591 /// engine is in `CoherenceState::RefreshingContext` (i.e. running
2592 /// a compaction summary call) must NOT leave the chat widget with
2593 /// an empty viewport. The user-reported symptom on Windows
2594 /// PowerShell is that the screen turns black on the maximize→
2595 /// windowed transition during a long task; the post-resize render
2596 /// must produce a populated frame regardless of the active
2597 /// coherence intervention. Pins the invariant from the renderer
2598 /// side; the actual ConHost size-stale fix lives in
2599 /// `tui::ui::run_tui` (the `Event::Resize` handler now forwards
2600 /// the event-reported dimensions to ratatui's viewport before the
2601 /// redraw).
2602 #[test]
2603 fn chat_widget_renders_cleanly_after_resize_during_refreshing_context() {
2604 use crate::core::coherence::CoherenceState;
2605
2606 let mut app = create_test_app();
2607 for i in 0..30 {
2608 app.add_message(HistoryCell::User {
2609 content: format!("user message {i} during a long-running task"),
2610 });
2611 }
2612
2613 // Pretend the engine is mid-compaction when the resize arrives.
2614 app.coherence_state = CoherenceState::RefreshingContext;
2615
2616 // Drive the same shrink-then-grow cycle that maximize→windowed
2617 // transitions produce on Windows.
2618 for (width, height) in [(140u16, 40u16), (90, 28), (60, 20), (140, 40)] {
2619 app.handle_resize(width, height);
2620 let area = Rect {
2621 x: 0,
2622 y: 0,
2623 width,
2624 height,
2625 };
2626 let mut buf = Buffer::empty(area);
2627 let widget = ChatWidget::new(&mut app, area);
2628 widget.render(area, &mut buf);
2629
2630 let mut non_empty = 0usize;
2631 for y in 0..height {
2632 for x in 0..width {
2633 let sym = buf[(x, y)].symbol();
2634 if sym != " " && !sym.is_empty() {
2635 non_empty += 1;
2636 }
2637 }
2638 }
2639 assert!(
2640 non_empty > 0,
2641 "resize-during-RefreshingContext at {width}x{height} produced an empty buffer; \
2642 render path must not gate on coherence state (#582)"
2643 );
2644 }
2645
2646 // The engine's coherence_state must survive a resize — it is
2647 // the engine's runtime decision, not a render-loop concern.
2648 // A future regression that bounced the state to `Healthy` on
2649 // resize would silently drop the "refreshing context" footer
2650 // chip while compaction is still in flight.
2651 assert_eq!(
2652 app.coherence_state,
2653 CoherenceState::RefreshingContext,
2654 "resize must not mutate engine-owned coherence_state"
2655 );
2656 }
2657
2658 /// Regression for issue #65: after `App::handle_resize`, the chat widget
2659 /// must produce a clean render at the new width — no stale wrapping,
2660 /// no panic, no content exceeding the requested width. Cycling through
2661 /// several widths (shrinks and grows) flushes any cached layout that
2662 /// fails to invalidate on resize.
2663 #[test]
2664 fn chat_widget_renders_cleanly_after_resize_cycle() {
2665 let mut app = create_test_app();
2666 // Add some long content that wraps differently at different widths.
2667 for i in 0..40 {
2668 app.add_message(HistoryCell::User {
2669 content: format!("user message {i} with enough text to wrap at 30 columns easily"),
2670 });
2671 }
2672
2673 let widths_to_cycle = [120u16, 80, 40, 60, 100, 30];
2674 let height: u16 = 20;
2675 for width in widths_to_cycle {
2676 // Caller-side: simulate the resize handler invalidating caches.
2677 app.handle_resize(width, height);
2678 let area = Rect {
2679 x: 0,
2680 y: 0,
2681 width,
2682 height,
2683 };
2684 let mut buf = Buffer::empty(area);
2685 let widget = ChatWidget::new(&mut app, area);
2686 widget.render(area, &mut buf);
2687
2688 // The render must produce at least some non-empty content for a
2689 // populated history at any reasonable width. This catches a class
2690 // of resize regressions where stale layout state leaves a blank
2691 // viewport after a width change.
2692 let mut non_empty = 0usize;
2693 for y in 0..height {
2694 for x in 0..width {
2695 let sym = buf[(x, y)].symbol();
2696 if sym != " " && !sym.is_empty() {
2697 non_empty += 1;
2698 }
2699 }
2700 }
2701 assert!(
2702 non_empty > 0,
2703 "render at {width}x{height} produced an empty buffer after resize"
2704 );
2705 }
2706 }
2707
2708 /// Regression for issue #65: the transcript view cache must invalidate
2709 /// when width changes, so the same `App.history` re-wraps to the new
2710 /// width on the very next `ChatWidget::new` call.
2711 #[test]
2712 fn transcript_cache_invalidates_on_width_change() {
2713 let mut app = create_test_app();
2714 for i in 0..10 {
2715 app.add_message(HistoryCell::User {
2716 content: format!("a fairly long user message number {i} that needs to wrap"),
2717 });
2718 }
2719
2720 let area_wide = Rect {
2721 x: 0,
2722 y: 0,
2723 width: 120,
2724 height: 20,
2725 };
2726 let area_narrow = Rect {
2727 x: 0,
2728 y: 0,
2729 width: 30,
2730 height: 20,
2731 };
2732 let mut buf_wide = Buffer::empty(area_wide);
2733 let widget_wide = ChatWidget::new(&mut app, area_wide);
2734 widget_wide.render(area_wide, &mut buf_wide);
2735 let wide_total_lines = app.viewport.transcript_cache.total_lines();
2736
2737 // Without an explicit resize call, just shrinking the render area
2738 // should still trigger a cache rebuild because the cache keys on width.
2739 let mut buf_narrow = Buffer::empty(area_narrow);
2740 let widget_narrow = ChatWidget::new(&mut app, area_narrow);
2741 widget_narrow.render(area_narrow, &mut buf_narrow);
2742 let narrow_total_lines = app.viewport.transcript_cache.total_lines();
2743
2744 assert!(
2745 narrow_total_lines > wide_total_lines,
2746 "narrow render should produce more wrapped lines (got {narrow_total_lines}, wide={wide_total_lines})"
2747 );
2748 }
2749
2750 /// Issue #78 — perf bench for transcript scroll lag.
2751 ///
2752 /// Builds a 5000-entry history (mix of user / assistant / a few tool
2753 /// cells), then times `ChatWidget::new` at scroll offsets 0, 100, 500,
2754 /// and 2000 lines from the tail. The first call after history mutation
2755 /// pays the wrap cost; subsequent calls at different offsets should hit
2756 /// the per-cell cache and be ~constant time regardless of offset.
2757 ///
2758 /// Run with: `cargo test -p deepseek-tui --release bench_transcript_scroll
2759 /// -- --ignored --nocapture`
2760 #[test]
2761 #[ignore = "perf bench; run with --release"]
2762 fn bench_transcript_scroll_5000_messages() {
2763 use std::time::Instant;
2764
2765 let mut app = create_test_app();
2766 // 5000 cells: alternating user / assistant with realistic-ish bodies
2767 // so wrapping cost is non-trivial. Every 50th cell is a (small)
2768 // generic tool cell, mirroring real transcripts.
2769 for i in 0..5000usize {
2770 let cell = if i % 50 == 49 {
2771 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2772 name: "grep_files".to_string(),
2773 status: ToolStatus::Success,
2774 input_summary: Some(format!("query: hit-{i}")),
2775 output: Some(format!("found 12 matches in cell-{i}")),
2776 prompts: None,
2777 spillover_path: None,
2778 }))
2779 } else if i % 2 == 0 {
2780 HistoryCell::User {
2781 content: format!(
2782 "user message {i}: please review the changes in src/foo/bar.rs and \
2783 tell me whether the new error handling looks reasonable"
2784 ),
2785 }
2786 } else {
2787 HistoryCell::Assistant {
2788 content: format!(
2789 "Sure — looking at src/foo/bar.rs in cell {i}, the new error \
2790 handling wraps each fallible call in `?` and propagates a \
2791 typed `FooError`. That looks fine, but consider whether the \
2792 `Display` impl needs to redact the inner path."
2793 ),
2794 streaming: false,
2795 }
2796 };
2797 app.add_message(cell);
2798 }
2799
2800 let area = Rect {
2801 x: 0,
2802 y: 0,
2803 width: 100,
2804 height: 30,
2805 };
2806
2807 // Warm-up: first call after a full history build pays the wrap cost
2808 // for every cell. We don't time this — it's amortized across the
2809 // session and is not the user-visible problem.
2810 let _ = ChatWidget::new(&mut app, area);
2811
2812 let visible = area.height as usize;
2813 // For each scroll target, snap the scroll position there and measure
2814 // a fresh ChatWidget::new(). The cache should hit for all unchanged
2815 // cells, so the time should be roughly constant regardless of
2816 // offset.
2817 for offset_from_tail in [0usize, 100, 500, 2000] {
2818 let total = app.viewport.transcript_cache.total_lines();
2819 let max_start = total.saturating_sub(visible);
2820 let target = max_start.saturating_sub(offset_from_tail);
2821 app.viewport.transcript_scroll =
2822 crate::tui::scrolling::TranscriptScroll::at_line(target);
2823
2824 let iters: u32 = 10;
2825 let start = Instant::now();
2826 for _ in 0..iters {
2827 let _ = ChatWidget::new(&mut app, area);
2828 }
2829 let elapsed = start.elapsed();
2830 let per_call_us = elapsed.as_micros() / u128::from(iters);
2831 println!(
2832 "[bench_transcript_scroll] offset={offset_from_tail:>5} \
2833 per_render={per_call_us:>6} \u{3bc}s ({:>3} ms / {iters} iters)",
2834 elapsed.as_millis()
2835 );
2836 }
2837
2838 // Streaming-delta scenario: append one assistant cell at the tail
2839 // and time a render. The cache should re-render only the new cell,
2840 // NOT every cell — even at deep scroll.
2841 for offset_from_tail in [0usize, 2000] {
2842 let total = app.viewport.transcript_cache.total_lines();
2843 let max_start = total.saturating_sub(visible);
2844 let target = max_start.saturating_sub(offset_from_tail);
2845 app.viewport.transcript_scroll =
2846 crate::tui::scrolling::TranscriptScroll::at_line(target);
2847
2848 let iters: u32 = 10;
2849 let start = Instant::now();
2850 for i in 0..iters {
2851 app.add_message(HistoryCell::Assistant {
2852 content: format!("delta {i}"),
2853 streaming: false,
2854 });
2855 let _ = ChatWidget::new(&mut app, area);
2856 }
2857 let elapsed = start.elapsed();
2858 let per_call_us = elapsed.as_micros() / u128::from(iters);
2859 println!(
2860 "[bench_transcript_scroll] streaming offset={offset_from_tail:>5} \
2861 per_render={per_call_us:>6} \u{3bc}s ({:>3} ms / {iters} iters)",
2862 elapsed.as_millis()
2863 );
2864 }
2865 }
2866 }
2867
2867 lines RUST