返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / widgets / mod.rs
1 pub mod agent_card;
2 pub mod key_hint;
3 pub mod pending_input_preview;
4 mod renderable;
5 pub mod tool_card;
6 pub mod workflow_panel;
7
8 pub use renderable::Renderable;
9
10 use std::borrow::Cow;
11 use std::collections::HashSet;
12 use std::time::Duration;
13
14 use crate::commands;
15 #[cfg(test)]
16 use crate::config::ApiProvider;
17 #[cfg(test)]
18 use crate::provider_lake::all_catalog_models_for_provider;
19 use crate::tui::app::{App, ComposerDensity, ViewportState};
20 use crate::tui::approval::{
21 ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory,
22 };
23 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus};
24 use crate::tui::menu_style;
25 use crate::tui::scrolling::TranscriptLineMeta;
26 use crate::tui::ui_text::{grapheme_display_width, text_display_width};
27 use crate::tui::underwater::ShellPhase;
28 use codewhale_localization::{Locale, MessageId, tr};
29 use codewhale_palette as palette;
30 use ratatui::{
31 buffer::Buffer,
32 layout::Rect,
33 style::{Color, Modifier, Style},
34 text::{Line, Span},
35 widgets::{
36 Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
37 ScrollbarState, StatefulWidget, Widget, Wrap,
38 },
39 };
40 use unicode_segmentation::UnicodeSegmentation;
41 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
42
43 const SEND_FLASH_DURATION: Duration = Duration::from_millis(500);
44 #[cfg(test)]
45 const COMPOSER_PANEL_HEIGHT: u16 = 2;
46 const JUMP_TO_LATEST_BUTTON_WIDTH: u16 = 3;
47 const JUMP_TO_LATEST_BUTTON_HEIGHT: u16 = 3;
48 pub struct ChatWidget {
49 content_area: Rect,
50 /// Scrollable/selectable transcript geometry. When the last prompt is
51 /// pinned, this starts one row below `content_area`; the pinned header is
52 /// intentionally outside transcript hit-testing.
53 transcript_area: Rect,
54 lines: Vec<Line<'static>>,
55 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
56 scrollbar: Option<TranscriptScrollbar>,
57 jump_to_latest_button: Option<Rect>,
58 background: Color,
59 ocean_column: Option<crate::tui::ocean::OceanColumn>,
60 /// Live-activity shape of the ambient scene (thinking/tools/subagents).
61 ocean_activity: crate::tui::ambient_life::AmbientActivity,
62 /// Ink for the selected underwater scene's idle fish/bubbles.
63 ambient_inks: Option<(Color, Color)>,
64 ocean_elapsed_ms: u128,
65 ocean_animated: bool,
66 /// Fixed-point (0..=1000) life presence; see `ocean::life_presence`.
67 life_presence_fixed: u16,
68 fish_flee_elapsed_ms: Option<u128>,
69 ambient_life: bool,
70 scroll_track: Color,
71 scroll_thumb: Color,
72 jump_border: Color,
73 jump_arrow: Color,
74 }
75
76 #[derive(Debug, Clone, Copy)]
77 struct TranscriptScrollbar {
78 top: usize,
79 visible: usize,
80 total: usize,
81 }
82
83 /// A `todo_write` result is a full replacement snapshot, not an incremental
84 /// transcript event. Keep only the newest successful snapshot in the visible
85 /// transcript while retaining every tool result in history for model context
86 /// and persistence.
87 fn superseded_todo_write_indices(
88 history: &[HistoryCell],
89 active_entries: &[HistoryCell],
90 ) -> HashSet<usize> {
91 let mut hidden = HashSet::new();
92 let mut latest = None;
93
94 for (index, cell) in history.iter().chain(active_entries).enumerate() {
95 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
96 continue;
97 };
98 if tool.name != "todo_write" || tool.status != ToolStatus::Success {
99 continue;
100 }
101
102 if let Some(previous) = latest.replace(index) {
103 hidden.insert(previous);
104 }
105 }
106
107 if let Some(index) = latest {
108 let cell = history
109 .get(index)
110 .or_else(|| active_entries.get(index.saturating_sub(history.len())));
111 if cell.is_some_and(todo_write_snapshot_is_empty) {
112 hidden.insert(index);
113 }
114 }
115
116 hidden
117 }
118
119 fn todo_write_snapshot_is_empty(cell: &HistoryCell) -> bool {
120 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
121 return false;
122 };
123 let Some(output) = tool.output.as_deref() else {
124 return false;
125 };
126 let Some(json_start) = output.find('{') else {
127 return false;
128 };
129 let Ok(value) = serde_json::from_str::<serde_json::Value>(&output[json_start..]) else {
130 return false;
131 };
132 value
133 .get("items")
134 .and_then(serde_json::Value::as_array)
135 .is_some_and(Vec::is_empty)
136 }
137
138 fn resolve_transcript_viewport_after_layout(
139 viewport: &mut ViewportState,
140 visible_lines: usize,
141 ) -> (usize, usize, bool) {
142 let total_lines = viewport.transcript_cache.total_lines();
143 let line_meta = viewport.transcript_cache.line_meta();
144 if viewport.pending_scroll_delta != 0 {
145 viewport.transcript_scroll = viewport.transcript_scroll.scrolled_by(
146 viewport.pending_scroll_delta,
147 line_meta,
148 visible_lines,
149 );
150 viewport.pending_scroll_delta = 0;
151 }
152
153 let max_start = total_lines.saturating_sub(visible_lines);
154 // Snapshot tail intent before resolve: clamping an out-of-range fixed
155 // offset can return `to_bottom()`, which must not masquerade as the user's
156 // choice to resume following a streaming tail (v0.8.11).
157 let was_explicit_tail = viewport.transcript_scroll.is_at_tail();
158 let (scroll_state, top) = viewport.transcript_scroll.resolve_top(line_meta, max_start);
159 viewport.transcript_scroll = scroll_state;
160 viewport.last_transcript_top = top;
161 viewport.last_transcript_visible = visible_lines;
162 viewport.last_transcript_total = total_lines;
163 (total_lines, top, was_explicit_tail)
164 }
165
166 impl ChatWidget {
167 pub fn new(app: &mut App, area: Rect) -> Self {
168 // The clamped ambient clock, not raw wall time: sparse draw schedules
169 // advance the scene by at most one small step per frame, so creatures
170 // drift instead of teleporting between distant samples.
171 let ocean_elapsed_ms = app.sample_ambient_clock_ms();
172 Self::new_with_ocean_elapsed(app, area, ocean_elapsed_ms)
173 }
174
175 /// Build one render snapshot from an already sampled ocean clock.
176 ///
177 /// Production samples the monotonic clock in [`Self::new`]. Keeping the
178 /// sampled value as an explicit input here gives render tests a stable
179 /// frame without adding a second clock or freezing the runtime animation.
180 fn new_with_ocean_elapsed(app: &mut App, area: Rect, ocean_elapsed_ms: u128) -> Self {
181 let content_area = area;
182 let background = app.ui_theme.surface_bg;
183 // The ordinary shell inherits its host/theme surface. Underwater life
184 // is earned by the underwater theme, never painted over a user's
185 // terminal simply because the app happens to be active.
186 let underwater_atmosphere = app.theme_id == codewhale_palette::ThemeId::Underwater;
187 let ocean_ramp = underwater_atmosphere
188 .then(|| crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme))
189 .flatten();
190 // Ink hue carries the live activity (reasoning deep-dim, tools bright,
191 // sub-agents seafoam) so the marks read the state at a glance.
192 let ocean_activity = crate::tui::ambient_life::AmbientActivity::from_kind(
193 crate::tui::underwater::LiveActivity::from_app(app).kind(),
194 );
195 let ambient_inks = underwater_atmosphere
196 .then(|| crate::tui::ocean::ambient_inks_for_activity(&app.ui_theme, ocean_activity));
197 // The completion breath is authored decorative motion, so it rides the
198 // same motion gate as everything else in the water. Both the column's
199 // settle flourish and ambient life's presence read this one clock:
200 // the pet needs the settle tail too; the column clips only its light pulse.
201 let completion_life_clock = (underwater_atmosphere
202 && app.motion_policy().allows_decorative())
203 .then_some(())
204 .and(app.ocean_completion_started_at)
205 .map(|started| started.elapsed().as_millis());
206 let completion_elapsed_ms = completion_life_clock
207 .filter(|elapsed| *elapsed < crate::tui::ocean::COMPLETION_SETTLE_MS);
208 let completion_life_active = completion_life_clock
209 .is_some_and(|elapsed| elapsed < crate::tui::ocean::COMPLETION_SETTLE_MS);
210 let render_empty_state = should_render_empty_state(app);
211 let phase = ShellPhase::from_app(app);
212 // Keep the selected underwater scene alive while a turn is doing
213 // work, even after the transcript exists. The ordinary Flat shell
214 // remains entirely still and lets the host terminal lead.
215 let underwater_motion_enabled =
216 underwater_atmosphere && crate::tui::underwater::decorative_shell_motion_enabled(app);
217 let browsing_history = !app.viewport.transcript_scroll.is_at_tail();
218 let ocean_animated = underwater_motion_enabled
219 && (render_empty_state
220 || browsing_history
221 || matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
222 // Life presence eases the animated/static boundary as a pure function
223 // of the monotonic clocks (see ocean::life_presence): bursty fast
224 // streams ramp in, quiet waits settle out, never a hard snap.
225 //
226 // This deliberately takes the *gated* completion clock. Reading
227 // `app.ocean_completion_started_at` raw here let the completion branch
228 // of `life_presence` short-circuit the `!animated` check, so a
229 // reduced-motion session got ~1.4 s of full ambient life after every
230 // successful turn — precisely while the user was reading the result.
231 let life_presence = crate::tui::ocean::life_presence(
232 completion_life_clock,
233 app.turn_started_at
234 .map(|started| started.elapsed().as_millis()),
235 ocean_animated,
236 browsing_history,
237 render_empty_state,
238 );
239 let life_presence_fixed = (life_presence * 1000.0).round().clamp(0.0, 1000.0) as u16;
240 let ocean_column = ocean_ramp.map(|ramp| {
241 let context_percent = crate::tui::phase_strip::context_percent_from_app(app);
242 crate::tui::ocean::OceanColumn::new(
243 ramp,
244 content_area,
245 ocean_elapsed_ms,
246 completion_elapsed_ms,
247 phase,
248 ocean_animated,
249 life_presence_fixed,
250 context_percent,
251 )
252 });
253 let fish_flee_elapsed_ms = underwater_motion_enabled
254 .then_some(())
255 .and(app.turn_started_at)
256 .map(|started| started.elapsed().as_millis())
257 .filter(|elapsed| *elapsed < 800)
258 .filter(|_| matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
259 let scroll_track = app.ui_theme.border;
260 let scroll_thumb = app.ui_theme.status_working;
261 let jump_border = app.ui_theme.border;
262 let jump_arrow = app.ui_theme.status_working;
263 let visible_lines = content_area.height as usize;
264 let mut render_options = app.transcript_render_options();
265 render_options.reasoning_preview_viewport_lines = Some(visible_lines);
266
267 if render_empty_state {
268 let lines = build_empty_state_lines(app, content_area);
269 app.viewport.last_transcript_area = Some(content_area);
270 app.viewport.last_transcript_top = 0;
271 app.viewport.last_transcript_visible = visible_lines;
272 app.viewport.last_transcript_total = 0;
273 app.viewport.last_transcript_padding_top = 0;
274 app.viewport.jump_to_latest_button_area = None;
275 return Self {
276 content_area,
277 transcript_area: content_area,
278 lines,
279 line_links: Vec::new(),
280 scrollbar: None,
281 jump_to_latest_button: None,
282 background,
283 ocean_column,
284 ocean_activity,
285 ambient_inks,
286 ocean_elapsed_ms,
287 ocean_animated,
288 life_presence_fixed,
289 fish_flee_elapsed_ms,
290 // Reduced-motion users still get a quiet, static Deepsea scene;
291 // Flat remains a normal host-owned terminal either way.
292 ambient_life: underwater_atmosphere
293 && !app.attention_hold_active()
294 && matches!(
295 phase,
296 ShellPhase::Idle
297 | ShellPhase::Typing
298 | ShellPhase::Working
299 | ShellPhase::Verifying
300 ),
301 scroll_track,
302 scroll_thumb,
303 jump_border,
304 jump_arrow,
305 };
306 }
307
308 // Reserve the scrollbar's column before wrapping, so painting it cannot
309 // erase the final character of a line. Keep this width stable when the
310 // history starts/stops scrolling; cached lines and copy metadata must
311 // use the same layout on both sides of that transition.
312 let transcript_width = content_area.width.saturating_sub(1).max(1);
313
314 // Per-cell revision caching (fix for issue #78):
315 //
316 // Every committed history cell carries its own revision counter in
317 // `app.history_revisions`. The transcript cache compares each cell's
318 // current revision against the previously rendered one, so unchanged
319 // cells reuse their cached wrapped lines instead of being re-wrapped
320 // every frame. This is the difference between O(history.len()) and
321 // O(changed_cells) per render — and was the root cause of scroll lag
322 // on long transcripts.
323 //
324 // The active in-flight cell (if any) is appended as the last cell so
325 // its mutations show up at the live tail. Each entry inside the
326 // active cell becomes a virtual cell at index `history.len() + i`,
327 // matching `App::cell_at_virtual_index`. Active-cell entries share
328 // the same `active_cell_revision` salt so any mutation in the active
329 // cell forces only those rows to re-render — committed history rows
330 // are unaffected.
331 app.resync_history_revisions();
332 app.viewport.transcript_cache.set_streaming_source_receipt(
333 app.streaming_source_receipt.map(|receipt| {
334 crate::tui::transcript::StreamingSourceReceipt {
335 cell_index: receipt.cell_index,
336 from_revision: history_entry_revision(receipt.from_revision),
337 to_revision: history_entry_revision(receipt.to_revision),
338 content_len: receipt.content_len,
339 }
340 }),
341 );
342 let provisional_action_owner = app.transcript_action_owner();
343 let active_entries: &[HistoryCell] = app
344 .active_cell
345 .as_ref()
346 .map_or(&[], |active| active.entries());
347 let superseded_todos = superseded_todo_write_indices(&app.history, active_entries);
348
349 let history_len = app.history.len();
350 let mut tool_runs = if app.tool_collapse_active() {
351 let cache_key_matches = app.tool_run_cache.history_version == app.history_version
352 && app.tool_run_cache.active_cell_revision == app.active_cell_revision
353 && app.tool_run_cache.active_len == active_entries.len()
354 && app.tool_run_cache.threshold == app.tool_collapse_threshold
355 && app.tool_run_cache.mode == app.tool_collapse_mode
356 && app.tool_run_cache.calm_mode == app.calm_mode;
357 if !cache_key_matches {
358 app.tool_run_cache.runs = crate::tui::history::detect_tool_runs_from_slices(
359 &app.history,
360 active_entries,
361 app.tool_collapse_threshold,
362 );
363 app.tool_run_cache.history_version = app.history_version;
364 app.tool_run_cache.active_cell_revision = app.active_cell_revision;
365 app.tool_run_cache.active_len = active_entries.len();
366 app.tool_run_cache.threshold = app.tool_collapse_threshold;
367 app.tool_run_cache.mode = app.tool_collapse_mode;
368 app.tool_run_cache.calm_mode = app.calm_mode;
369 }
370 app.tool_run_cache.runs.clone()
371 } else {
372 Vec::new()
373 };
374 // A collapsed run that crosses a hidden replacement snapshot would
375 // otherwise lose its summary start or count a row the user cannot
376 // see. Leave only that run expanded; unrelated dense runs still use
377 // the normal collapse path.
378 tool_runs.retain(|run| {
379 !(run.start..run.start.saturating_add(run.count))
380 .any(|index| superseded_todos.contains(&index))
381 });
382 let collapsed_run_starts: HashSet<usize> = tool_runs
383 .iter()
384 .filter_map(|run| (!app.expanded_tool_runs.contains(&run.start)).then_some(run.start))
385 .collect();
386 let mut collapsed_tool_indices: HashSet<usize> = HashSet::new();
387 for run in &tool_runs {
388 if !collapsed_run_starts.contains(&run.start) {
389 continue;
390 }
391 for offset in 1..run.count {
392 collapsed_tool_indices.insert(run.start + offset);
393 }
394 }
395
396 // v0.9.1: do not collapse concurrent sub-agent cards into an Enter-
397 // expand shelf. Count lives in header chrome; full cards stay visible;
398 // sidebar / SubAgents modal are the drill-in surface.
399 let has_collapsed = !app.collapsed_cells.is_empty()
400 || !collapsed_run_starts.is_empty()
401 || !superseded_todos.is_empty();
402
403 // Fast path: no collapsed cells — use original slices directly.
404 if !has_collapsed {
405 let mut cell_revisions: Vec<u64> =
406 Vec::with_capacity(app.history.len() + active_entries.len());
407 cell_revisions.extend(
408 app.history_revisions
409 .iter()
410 .copied()
411 .map(history_entry_revision),
412 );
413 if !active_entries.is_empty() {
414 let active_rev = app.active_cell_revision;
415 for i in 0..active_entries.len() {
416 let salt = (i as u64).wrapping_add(1);
417 cell_revisions.push(active_entry_revision(active_rev, salt));
418 }
419 }
420 // Build identity mapping: filtered index == original index.
421 // Reused across frames; identity maps are rebuilt only when the
422 // row count changes.
423 let row_count = app.history.len() + active_entries.len();
424 if app.collapsed_cell_map.len() != row_count {
425 app.collapsed_cell_map = (0..row_count).collect();
426 }
427
428 let shards: [&[HistoryCell]; 2] = [&app.history, active_entries];
429 app.viewport.transcript_cache.ensure_split(
430 &shards,
431 &cell_revisions,
432 transcript_width,
433 render_options,
434 &app.folded_thinking,
435 None,
436 provisional_action_owner,
437 );
438 } else {
439 // Slow path: borrow non-collapsed cells into a filtered ref list
440 // so collapsed cells are excluded from rendering, and build the
441 // filtered→original index mapping. Collapsed run starts render a
442 // synthetic summary cell; those few summaries are materialized
443 // up front so the ref list can borrow from a stable Vec —
444 // avoiding the per-frame deep clone of every visible cell that
445 // this path used to pay (#3896).
446 let summary_cells: Vec<(usize, HistoryCell)> = tool_runs
447 .iter()
448 .filter(|run| collapsed_run_starts.contains(&run.start))
449 .map(|run| (run.start, tool_run_summary_cell(run)))
450 .collect();
451 let summary_cell_for = |idx: usize| -> Option<&HistoryCell> {
452 summary_cells
453 .iter()
454 .find(|(start, _)| *start == idx)
455 .map(|(_, cell)| cell)
456 };
457
458 let mut filtered_cells: Vec<&HistoryCell> =
459 Vec::with_capacity(history_len + active_entries.len());
460 let mut filtered_revs: Vec<u64> =
461 Vec::with_capacity(history_len + active_entries.len());
462 let mut filtered_to_original: Vec<usize> =
463 Vec::with_capacity(history_len + active_entries.len());
464
465 for (idx, cell) in app.history.iter().enumerate() {
466 if superseded_todos.contains(&idx) {
467 continue;
468 }
469 if app.collapsed_cells.contains(&idx) {
470 continue;
471 }
472 if collapsed_tool_indices.contains(&idx) {
473 continue;
474 }
475 if let Some(run) = tool_runs
476 .iter()
477 .find(|run| run.start == idx && collapsed_run_starts.contains(&idx))
478 {
479 filtered_cells.push(summary_cell_for(idx).expect("summary cell materialized"));
480 filtered_revs.push(tool_run_summary_revision(
481 run,
482 &app.history_revisions,
483 history_len,
484 app.active_cell_revision,
485 ));
486 filtered_to_original.push(idx);
487 continue;
488 }
489 filtered_cells.push(cell);
490 filtered_revs.push(history_entry_revision(app.history_revisions[idx]));
491 filtered_to_original.push(idx);
492 }
493
494 if !active_entries.is_empty() {
495 let active_rev = app.active_cell_revision;
496 for (i, cell) in active_entries.iter().enumerate() {
497 let original_idx = history_len + i;
498 if superseded_todos.contains(&original_idx) {
499 continue;
500 }
501 if app.collapsed_cells.contains(&original_idx) {
502 continue;
503 }
504 if collapsed_tool_indices.contains(&original_idx) {
505 continue;
506 }
507 if let Some(run) = tool_runs.iter().find(|run| {
508 run.start == original_idx && collapsed_run_starts.contains(&original_idx)
509 }) {
510 filtered_cells
511 .push(summary_cell_for(original_idx).expect("summary materialized"));
512 filtered_revs.push(tool_run_summary_revision(
513 run,
514 &app.history_revisions,
515 history_len,
516 active_rev,
517 ));
518 filtered_to_original.push(original_idx);
519 continue;
520 }
521 filtered_cells.push(cell);
522 let salt = (i as u64).wrapping_add(1);
523 filtered_revs.push(active_entry_revision(active_rev, salt));
524 filtered_to_original.push(original_idx);
525 }
526 }
527
528 app.collapsed_cell_map = filtered_to_original;
529
530 app.viewport.transcript_cache.ensure_filtered(
531 &filtered_cells,
532 &filtered_revs,
533 transcript_width,
534 render_options,
535 &app.folded_thinking,
536 Some(&app.collapsed_cell_map),
537 provisional_action_owner,
538 );
539 }
540
541 let (mut total_lines, mut top, mut was_explicit_tail) =
542 resolve_transcript_viewport_after_layout(&mut app.viewport, visible_lines);
543
544 // A sticky prompt is layout chrome, not a synthetic transcript row.
545 // First resolve against the full viewport, then reserve one real row
546 // only when the prompt has actually scrolled above it. Resolving once
547 // more with the smaller body keeps the newest tail line visible.
548 let mut transcript_area = content_area;
549 let pinned_prompt = (app.pin_last_prompt && content_area.height > 1)
550 .then(|| {
551 scrolled_user_prompt_pin(
552 &app.history,
553 app.viewport.transcript_cache.line_meta(),
554 &app.collapsed_cell_map,
555 top,
556 content_area.width,
557 )
558 })
559 .flatten();
560 let visible_lines = if pinned_prompt.is_some() {
561 transcript_area.y = transcript_area.y.saturating_add(1);
562 transcript_area.height = transcript_area.height.saturating_sub(1);
563 let visible = usize::from(transcript_area.height);
564 (total_lines, top, was_explicit_tail) =
565 resolve_transcript_viewport_after_layout(&mut app.viewport, visible);
566 visible
567 } else {
568 visible_lines
569 };
570 let owner = app.transcript_action_owner();
571 let index_map = has_collapsed.then_some(app.collapsed_cell_map.as_slice());
572 app.viewport.transcript_cache.retarget(owner, index_map);
573
574 // The cache has now observed this revision (or the cell was filtered,
575 // in which case a later reveal must cold-render). Start the next append
576 // receipt from the current revision instead of chaining across an
577 // already-consumed proof.
578 if let Some(receipt) = app.streaming_source_receipt.as_mut() {
579 receipt.from_revision = receipt.to_revision;
580 }
581
582 let line_meta = app.viewport.transcript_cache.line_meta();
583
584 // If the user scrolled back to the live tail, the per-stream
585 // "leave me alone" lock is over — new chunks should pin to bottom
586 // again until they explicitly scroll up. Without this clear, content
587 // piles up off-screen below the visible area and the view appears
588 // frozen at the moment they returned to bottom.
589 //
590 // Only clear the lock when the user's INTENT was tail (their
591 // stored state was already `to_bottom()` before resolve), AND
592 // when the transcript actually has scrolling room to talk about
593 // — if everything fits in one screen, "tail" is trivially true
594 // and clearing here would yank the user back to bottom on the
595 // next chunk even though they explicitly scrolled up.
596 if was_explicit_tail && total_lines > visible_lines {
597 app.user_scrolled_during_stream = false;
598 }
599
600 app.viewport.last_transcript_area = Some(transcript_area);
601 app.viewport.last_transcript_padding_top = 0;
602 let detail_target_cell = (!app.viewport.transcript_selection.is_active())
603 .then(|| app.detail_cell_index_for_viewport(top, visible_lines, line_meta))
604 .flatten();
605
606 let end = (top + visible_lines).min(total_lines);
607 let mut lines = if total_lines == 0 {
608 vec![Line::from("")]
609 } else {
610 app.viewport.transcript_cache.lines()[top..end].to_vec()
611 };
612 let mut line_links = if total_lines == 0 {
613 vec![Vec::new()]
614 } else {
615 app.viewport.transcript_cache.line_links()[top..end].to_vec()
616 };
617
618 if !app.low_motion
619 && app.fancy_animations
620 && let (Some(start), Some(started)) = (
621 app.ocean_receipt_settle_start,
622 app.ocean_completion_started_at,
623 )
624 {
625 apply_receipt_settle_cascade(
626 &mut lines,
627 top,
628 line_meta,
629 &app.collapsed_cell_map,
630 &app.history,
631 start,
632 started.elapsed().as_millis(),
633 );
634 }
635
636 // Brief flash highlight on the most recently sent user message. It is
637 // a one-shot transition, so Reduced/Still clear the timestamp instead
638 // of leaving a stale flash waiting for a later state-change redraw.
639 if app.motion_policy().allows_decorative() {
640 if let Some(send_at) = app.last_send_at {
641 if send_at.elapsed() < SEND_FLASH_DURATION {
642 apply_send_flash(
643 &mut lines,
644 top,
645 &app.history,
646 line_meta,
647 &app.collapsed_cell_map,
648 );
649 } else {
650 app.last_send_at = None;
651 }
652 }
653 } else {
654 app.last_send_at = None;
655 }
656
657 if let Some(target_cell) = detail_target_cell {
658 apply_detail_target_highlight(
659 &mut lines,
660 top,
661 target_cell,
662 line_meta,
663 &app.collapsed_cell_map,
664 );
665 }
666
667 apply_selection(&mut lines, top, app);
668
669 if let Some(pin) = pinned_prompt {
670 lines.insert(0, pin);
671 line_links.insert(0, Vec::new());
672 }
673
674 // The HTML contract is a top-first ledger. Bottom-padding the short
675 // transcript made every newly wrapped stream line shift all prior
676 // rows upward, producing repeated thousand-cell repaints and the
677 // visible "slab" motion recorded in live QA. Empty-state centering is
678 // handled separately; active work starts at the top and appends in
679 // place until scrolling is genuinely necessary.
680 app.viewport.last_transcript_padding_top = 0;
681
682 let scrollbar = (total_lines > visible_lines && transcript_area.width > 1).then_some(
683 TranscriptScrollbar {
684 top,
685 visible: visible_lines,
686 total: total_lines,
687 },
688 );
689 let jump_to_latest_button =
690 if app.use_mouse_capture && !app.viewport.transcript_scroll.is_at_tail() {
691 jump_to_latest_button_rect(transcript_area, scrollbar.is_some())
692 } else {
693 None
694 };
695 app.viewport.jump_to_latest_button_area = jump_to_latest_button;
696
697 Self {
698 content_area,
699 transcript_area,
700 lines,
701 line_links,
702 scrollbar,
703 jump_to_latest_button,
704 background,
705 ocean_column,
706 ocean_activity,
707 ambient_inks,
708 ocean_elapsed_ms,
709 ocean_animated,
710 life_presence_fixed,
711 fish_flee_elapsed_ms,
712 // Fish also accompany intentional transcript browsing in the
713 // selected underwater scene. They only occupy blank cells and are
714 // collision-checked, so history stays legible while the ocean
715 // remains playful when scrolling upward.
716 ambient_life: underwater_atmosphere
717 && !app.attention_hold_active()
718 && (browsing_history
719 || matches!(phase, ShellPhase::Working | ShellPhase::Verifying)
720 || completion_life_active),
721 scroll_track,
722 scroll_thumb,
723 jump_border,
724 jump_arrow,
725 }
726 }
727
728 /// Sample the water field against the full terminal instead of restarting
729 /// it at the transcript's first row. Standalone widget callers keep the
730 /// local column, which is useful for previews and focused tests.
731 #[must_use]
732 pub(crate) fn with_ocean_viewport(mut self, viewport: Rect) -> Self {
733 self.ocean_column = self
734 .ocean_column
735 .map(|column| column.with_viewport(viewport));
736 self
737 }
738
739 #[must_use]
740 pub(crate) fn ocean_column(&self) -> Option<crate::tui::ocean::OceanColumn> {
741 self.ocean_column
742 }
743 }
744
745 fn apply_receipt_settle_cascade(
746 lines: &mut [Line<'static>],
747 top: usize,
748 line_meta: &[TranscriptLineMeta],
749 filtered_to_original: &[usize],
750 history: &[HistoryCell],
751 start: usize,
752 elapsed_ms: u128,
753 ) {
754 for (visible_index, line) in lines.iter_mut().enumerate() {
755 let Some((filtered_cell, _)) = line_meta
756 .get(top + visible_index)
757 .and_then(TranscriptLineMeta::cell_line)
758 else {
759 continue;
760 };
761 let original_cell = filtered_to_original
762 .get(filtered_cell)
763 .copied()
764 .unwrap_or(filtered_cell);
765 if original_cell < start
766 || !matches!(
767 history.get(original_cell),
768 Some(HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
769 )
770 || !receipt_is_settling(original_cell - start, elapsed_ms)
771 {
772 continue;
773 }
774 for span in &mut line.spans {
775 span.style = span.style.add_modifier(Modifier::DIM);
776 }
777 }
778 }
779
780 #[must_use]
781 fn receipt_is_settling(receipt_order: usize, elapsed_ms: u128) -> bool {
782 let delay = u128::try_from(receipt_order.min(6)).unwrap_or(6) * 70;
783 elapsed_ms < delay + 140
784 }
785
786 fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell {
787 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
788 name: "activity_group".to_string(),
789 status: ToolStatus::Success,
790 input_summary: Some(crate::tui::history::tool_run_summary(run)),
791 output: None,
792 prompts: None,
793 spillover_path: None,
794 output_summary: None,
795 is_diff: false,
796 }))
797 }
798
799 fn tool_run_summary_revision(
800 run: &ToolRun,
801 revisions: &[u64],
802 history_len: usize,
803 active_rev: u64,
804 ) -> u64 {
805 let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64);
806 for idx in run.start..run.start.saturating_add(run.count) {
807 let cell_revision = revisions
808 .get(idx)
809 .copied()
810 .map(history_entry_revision)
811 .unwrap_or_else(|| {
812 let active_idx = idx.saturating_sub(history_len);
813 active_entry_revision(active_rev, (active_idx as u64).wrapping_add(1))
814 });
815 revision = revision.rotate_left(7) ^ cell_revision;
816 }
817 let extends_into_active = run.start.saturating_add(run.count) > history_len;
818 revision_in_domain(revision, extends_into_active)
819 }
820
821 const ACTIVE_REVISION_DOMAIN: u64 = 1 << 63;
822
823 fn revision_in_domain(revision: u64, active: bool) -> u64 {
824 // The top bit is exclusively a cache-domain tag. Clearing it means raw
825 // counters that differ only by bit 63 can theoretically alias within one
826 // domain after 2^63 updates; that lifetime is acceptable, while active and
827 // committed-history keys must never alias each other.
828 let payload = revision & !ACTIVE_REVISION_DOMAIN;
829 if active {
830 ACTIVE_REVISION_DOMAIN | payload
831 } else {
832 payload
833 }
834 }
835
836 fn history_entry_revision(revision: u64) -> u64 {
837 revision_in_domain(revision, false)
838 }
839
840 pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 {
841 // Active entries and committed history cells can occupy the same
842 // positional cache slot across `flush_active_cell`. Keep their revision
843 // domains distinct so the first active entry (`active_rev = 0`,
844 // `salt = 1`) cannot collide with the first history revision (`1`) and
845 // reuse a stale `running` render after cancellation.
846 let mixed = active_rev
847 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
848 .wrapping_add(salt);
849 revision_in_domain(mixed, true)
850 }
851
852 /// Build the last-user-prompt header when that message is above the resolved
853 /// transcript viewport. The caller owns the one-row layout reservation so
854 /// the header never masquerades as `top` or displaces the newest tail line.
855 fn scrolled_user_prompt_pin(
856 history: &[HistoryCell],
857 line_meta: &[TranscriptLineMeta],
858 collapsed_cell_map: &[usize],
859 top: usize,
860 width: u16,
861 ) -> Option<Line<'static>> {
862 if width == 0 {
863 return None;
864 }
865 let (orig_idx, content) =
866 history
867 .iter()
868 .enumerate()
869 .rev()
870 .find_map(|(idx, cell)| match cell {
871 HistoryCell::User { content } if !content.trim().is_empty() => {
872 Some((idx, content.as_str()))
873 }
874 _ => None,
875 })?;
876 let first_line = line_meta.iter().position(|meta| match meta {
877 TranscriptLineMeta::CellLine {
878 cell_index,
879 line_in_cell,
880 ..
881 } => {
882 let original = collapsed_cell_map
883 .get(*cell_index)
884 .copied()
885 .unwrap_or(*cell_index);
886 original == orig_idx && *line_in_cell == 0
887 }
888 _ => false,
889 });
890 let first_line = first_line?;
891 if first_line >= top {
892 return None;
893 }
894
895 let first = content.lines().next().unwrap_or("").trim();
896 if first.is_empty() {
897 return None;
898 }
899 let budget = usize::from(width.saturating_sub(4)).max(1);
900 let mut shown = String::new();
901 let mut used = 0usize;
902 for ch in first.chars() {
903 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
904 if used + w > budget {
905 break;
906 }
907 shown.push(ch);
908 used += w;
909 }
910 if used < UnicodeWidthStr::width(first) && !shown.is_empty() {
911 shown.push('…');
912 }
913
914 Some(Line::from(vec![
915 Span::styled(
916 format!("{} ", crate::tui::glyphs::USER),
917 Style::default()
918 .fg(palette::WHALE_HUMAN)
919 .add_modifier(Modifier::BOLD),
920 ),
921 Span::styled(shown, Style::default().fg(palette::TEXT_PRIMARY)),
922 ]))
923 }
924
925 impl Renderable for ChatWidget {
926 fn render(&self, _area: Rect, buf: &mut Buffer) {
927 // Use the passed render area, not self.content_area — those can
928 // drift when layout changes (e.g. file-tree pane toggle), and
929 // using the stale self.content_area is the root cause of text
930 // bleed-through (#400). In debug builds, assert the two match to
931 // catch future drift early.
932 debug_assert_eq!(
933 _area, self.content_area,
934 "ChatWidget content_area drifted from render area: \
935 content_area={:?} render_area={:?}",
936 self.content_area, _area
937 );
938
939 let area = _area;
940 // Repaint the full chat area with the codewhale-ink background each
941 // frame. Ratatui's `Paragraph` only writes cells that contain text,
942 // so cells the current frame's paragraph doesn't touch would
943 // otherwise hold the *previous* frame's contents (the `:24Z`
944 // timestamp-tail bleed-through reported in v0.8.5 testing). Using
945 // `Clear` reset cells to terminal default, which read as a brown-
946 // gray on most user setups; an explicit ink fill keeps the chat
947 // area on-brand.
948 Block::default()
949 .style(Style::default().bg(self.background))
950 .render(area, buf);
951
952 let paragraph =
953 Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background));
954 paragraph.render(area, buf);
955
956 self.render_underwater_field(area, buf);
957
958 // Link targets travel beside the wrapped lines, never inside Span
959 // content. Convert relative line columns to absolute viewport regions
960 // for the backend; clip the final column when a scrollbar owns it.
961 let link_area = Rect {
962 width: area
963 .width
964 .saturating_sub(u16::from(self.scrollbar.is_some())),
965 ..area
966 };
967 let regions = crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links);
968 crate::tui::osc8::set_frame_links(regions);
969
970 if let Some(scrollbar) = self.scrollbar {
971 let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
972 let mut state = ScrollbarState::new(scrollable_range)
973 .position(scrollbar.top.min(scrollable_range))
974 .viewport_content_length(scrollbar.visible);
975 Scrollbar::new(ScrollbarOrientation::VerticalRight)
976 .begin_symbol(None)
977 .end_symbol(None)
978 .track_symbol(Some("│"))
979 .track_style(Style::default().fg(self.scroll_track))
980 .thumb_symbol("┃")
981 .thumb_style(Style::default().fg(self.scroll_thumb))
982 .render(self.transcript_area, buf, &mut state);
983 }
984
985 if let Some(button_area) = self.jump_to_latest_button {
986 render_jump_to_latest_button(
987 button_area,
988 buf,
989 self.background,
990 self.jump_border,
991 self.jump_arrow,
992 );
993 }
994
995 // Hover: register OSC-8 link regions (copyable), then apply aura.
996 let link_area = Rect {
997 width: area
998 .width
999 .saturating_sub(u16::from(self.scrollbar.is_some())),
1000 ..area
1001 };
1002 for region in crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links) {
1003 let width = region
1004 .col_end
1005 .saturating_sub(region.col_start)
1006 .saturating_add(1);
1007 let hit = Rect::new(region.col_start, region.row, width, 1);
1008 crate::tui::hover_layer::register_rect(
1009 crate::tui::hover_hit::HoverTargetKind::Link,
1010 hit,
1011 region.target,
1012 true,
1013 );
1014 }
1015 }
1016
1017 fn desired_height(&self, _width: u16) -> u16 {
1018 1
1019 }
1020 }
1021
1022 impl ChatWidget {
1023 /// Paint the explicitly selected underwater field. Flat keeps the theme
1024 /// surface, Solarized Light keeps canonical Base3, and Terminal keeps its
1025 /// inherited background without inherited aquarium decoration.
1026 fn render_underwater_field(&self, area: Rect, buf: &mut Buffer) {
1027 if let Some(column) = self.ocean_column {
1028 // Cache per-row ocean colors; invalidate only on phase/size/breath.
1029 let phase_tag = column.phase_tag();
1030 let fingerprint = column.ramp_fingerprint();
1031 let ramp = crate::tui::ambient_life::frame_ocean_ramp(
1032 &column,
1033 area.height,
1034 area.y,
1035 self.ocean_elapsed_ms,
1036 phase_tag,
1037 fingerprint,
1038 );
1039 for local_y in 0..area.height {
1040 let protected = self
1041 .lines
1042 .get(usize::from(local_y))
1043 .and_then(occupied_text_bounds);
1044 let row_bg = ramp
1045 .get(usize::from(local_y))
1046 .copied()
1047 .unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
1048 for local_x in 0..area.width {
1049 let is_protected = protected.is_some_and(|(start, end)| {
1050 usize::from(local_x) >= start && usize::from(local_x) < end
1051 });
1052 let cell = &mut buf[(area.x + local_x, area.y + local_y)];
1053 // Plain transcript text participates in the water column;
1054 // explicit semantic surfaces (selection, code, warnings)
1055 // retain their own background.
1056 if !is_protected || cell.bg == self.background {
1057 cell.set_bg(row_bg);
1058 }
1059 }
1060 }
1061 }
1062
1063 if self.ambient_life
1064 && let Some(inks) = self.ambient_inks
1065 {
1066 // The scatter has a centre. It used to be column 0 with a row in
1067 // the middle of the field, which is neither where the school
1068 // swims nor anywhere the eye is: the flee proximity test
1069 // (|dy| < 6) could not even fire on a tall field, and when it did
1070 // every fish was to the right of the anchor so the whole school
1071 // slid the same way. Anchored on the composer's centre line and
1072 // the school's own band, a turn beginning reads as the shoal
1073 // parting around the thing that just happened.
1074 let cursor = crate::tui::ambient_life::AmbientCursor {
1075 column: area.x.saturating_add(area.width / 2),
1076 row: area
1077 .y
1078 .saturating_add(crate::tui::ambient_life::school_band_row(area)),
1079 flee_elapsed_ms: self.fish_flee_elapsed_ms,
1080 };
1081 // Whale cameo rides the completion breath clock when present.
1082 let whale = crate::tui::ambient_life::WhaleCameo {
1083 elapsed_ms: self.ocean_column.and_then(|c| c.completion_elapsed_ms()),
1084 anchor_x: area.x.saturating_add(area.width / 2),
1085 anchor_y: area.y.saturating_add(area.height.saturating_mul(2) / 3),
1086 };
1087 // Per-frame budget counters (built/painted/skipped/clipped);
1088 // consumed by ambient-life tests and debug tooling, not by the
1089 // widget itself.
1090 let _ambient_stats = crate::tui::ambient_life::render_ambient_life(
1091 area,
1092 buf,
1093 inks,
1094 &self.lines,
1095 self.ocean_elapsed_ms,
1096 self.ocean_presence_f32(),
1097 cursor,
1098 whale,
1099 self.ocean_activity,
1100 );
1101 if let Some(column) = self.ocean_column {
1102 crate::tui::ambient_life::apply_caustic_shimmer(
1103 area,
1104 buf,
1105 &column,
1106 self.ocean_elapsed_ms,
1107 self.ocean_animated,
1108 &self.lines,
1109 );
1110 }
1111 }
1112 }
1113 }
1114
1115 impl ChatWidget {
1116 /// Life presence as a 0..=1 fraction; drives ambient-life ink fading.
1117 fn ocean_presence_f32(&self) -> f32 {
1118 (f32::from(self.life_presence_fixed) / 1000.0).clamp(0.0, 1.0)
1119 }
1120 }
1121
1122 fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
1123 crate::tui::ambient_life::occupied_text_bounds(line)
1124 }
1125
1126 #[cfg(test)]
1127 fn fish_flee_offset(elapsed_ms: u128) -> u16 {
1128 crate::tui::ambient_life::fish_flee_offset(elapsed_ms)
1129 }
1130
1131 #[cfg(test)]
1132 fn fish_mark(facing_right: bool) -> &'static str {
1133 if facing_right { "><>" } else { "<><" }
1134 }
1135
1136 #[cfg(test)]
1137 fn fish_heading(previous_x: u16, current_x: u16, next_x: u16, fallback_right: bool) -> bool {
1138 if next_x != current_x {
1139 next_x > current_x
1140 } else if current_x != previous_x {
1141 current_x > previous_x
1142 } else {
1143 fallback_right
1144 }
1145 }
1146
1147 fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option<Rect> {
1148 if area.width < JUMP_TO_LATEST_BUTTON_WIDTH + u16::from(has_scrollbar)
1149 || area.height < JUMP_TO_LATEST_BUTTON_HEIGHT
1150 {
1151 return None;
1152 }
1153
1154 let scrollbar_gutter = u16::from(has_scrollbar);
1155 Some(Rect {
1156 x: area
1157 .x
1158 .saturating_add(area.width)
1159 .saturating_sub(scrollbar_gutter)
1160 .saturating_sub(JUMP_TO_LATEST_BUTTON_WIDTH),
1161 y: area
1162 .y
1163 .saturating_add(area.height)
1164 .saturating_sub(JUMP_TO_LATEST_BUTTON_HEIGHT),
1165 width: JUMP_TO_LATEST_BUTTON_WIDTH,
1166 height: JUMP_TO_LATEST_BUTTON_HEIGHT,
1167 })
1168 }
1169
1170 fn render_jump_to_latest_button(
1171 area: Rect,
1172 buf: &mut Buffer,
1173 background: Color,
1174 border: Color,
1175 arrow: Color,
1176 ) {
1177 Block::default()
1178 .borders(Borders::ALL)
1179 .border_type(BorderType::Rounded)
1180 .border_style(Style::default().fg(border))
1181 .style(Style::default().bg(background))
1182 .render(area, buf);
1183
1184 let arrow_x = area.x.saturating_add(1);
1185 let arrow_y = area.y.saturating_add(1);
1186 buf[(arrow_x, arrow_y)]
1187 .set_symbol("↓")
1188 .set_style(Style::default().fg(arrow).add_modifier(Modifier::BOLD));
1189 }
1190
1191 const COMPOSER_PROMPT_GUTTER_WIDTH: u16 = 2;
1192 const COMPOSER_PANEL_MIN_WIDTH: u16 = 12;
1193
1194 /// Whether the active composer should use its full rounded enclosure.
1195 ///
1196 /// `composer_border` is a legacy configuration name, but its compatibility
1197 /// policy is deliberate: the default `true` means the Tideline enclosure;
1198 /// `false` is an explicit compact/quiet opt-out. Keep every layout consumer
1199 /// behind this helper so the reserved floor, measured height, and rendered
1200 /// geometry cannot drift apart.
1201 #[must_use]
1202 pub(crate) fn composer_enclosure_enabled(app: &App) -> bool {
1203 app.composer_border
1204 }
1205
1206 /// Shared `[↵]` submit rect for the live composer, or `None` when the
1207 /// enclosure cannot host the three-cell affordance.
1208 ///
1209 /// The gate is the same `enclosed_composer_panel_fits` predicate the painter
1210 /// uses: a hitbox without the painted panel would be an invisible click
1211 /// target (widths 6–11 rendered a borderless rule while still accepting
1212 /// clicks).
1213 #[must_use]
1214 pub(crate) fn active_composer_submit_rect(app: &App, area: Rect) -> Option<Rect> {
1215 if !enclosed_composer_panel_fits(composer_enclosure_enabled(app), area.width, area.height) {
1216 return None;
1217 }
1218 Some(crate::tui::composer_chrome::tideline_composer_geometry(area).submit)
1219 }
1220
1221 /// Restore rounded corners after the title-bearing top/bottom passes.
1222 ///
1223 /// Ratatui renders a `TOP`-only (or `BOTTOM`-only) block through the corner
1224 /// cells as horizontal line glyphs. The live composer needs those passes for
1225 /// its localized titles and shared focus outline, so put
1226 /// the four rounded joins back afterward rather than replacing its mature
1227 /// input widget with the unfinished translation scaffold.
1228 fn render_composer_panel_corners(
1229 area: Rect,
1230 buf: &mut Buffer,
1231 background: Style,
1232 permission_color: Color,
1233 mode_color: Color,
1234 ) {
1235 let top_style = background.fg(permission_color);
1236 let bottom_style = background.fg(mode_color);
1237 let left = area.left();
1238 let right = area.right().saturating_sub(1);
1239 let top = area.top();
1240 let bottom = area.bottom().saturating_sub(1);
1241
1242 buf[(left, top)].set_symbol("╭").set_style(top_style);
1243 buf[(right, top)].set_symbol("╮").set_style(top_style);
1244 buf[(left, bottom)].set_symbol("╰").set_style(bottom_style);
1245 buf[(right, bottom)].set_symbol("╯").set_style(bottom_style);
1246 }
1247
1248 /// Whether the outer composer rect can carry both semantic border rows.
1249 ///
1250 /// Keep this policy in outer-area coordinates. Input wrapping subtracts the
1251 /// prompt gutter later; using that narrower text width here made 12- and
1252 /// 13-column composers render as panels after reserving only the quiet rule.
1253 fn enclosed_composer_panel_fits(show_panel: bool, area_width: u16, area_height: u16) -> bool {
1254 show_panel && area_height >= 3 && area_width >= COMPOSER_PANEL_MIN_WIDTH
1255 }
1256
1257 /// Border-aware input plane for the active composer.
1258 ///
1259 /// The shared shell's `[↵]` control occupies three cells on the inner row.
1260 /// Keep the text plane to its left, with one blank cell in between, so input
1261 /// wrapping, cursor placement, and pointer mapping cannot claim painted send
1262 /// cells. The outer block still owns the trailing breathing cell before its
1263 /// right rail.
1264 fn composer_inner_area(area: Rect, has_panel: bool) -> Rect {
1265 let inner = if has_panel {
1266 Block::default()
1267 .borders(Borders::ALL)
1268 .border_type(BorderType::Rounded)
1269 .inner(area)
1270 } else if area.height >= 2 {
1271 Block::default().borders(Borders::TOP).inner(area)
1272 } else {
1273 area
1274 };
1275 if !has_panel {
1276 return inner;
1277 }
1278
1279 let shell = crate::tui::composer_chrome::tideline_composer_geometry(area);
1280 Rect {
1281 width: shell.content.right().saturating_sub(inner.x),
1282 ..inner
1283 }
1284 }
1285
1286 /// Canonical horizontal geometry for composer input text.
1287 ///
1288 /// The prompt glyph occupies the first gutter column and the second column is
1289 /// breathing room. Every consumer that wraps or maps input must use
1290 /// `text_area`: rendering and cursor placement, viewport scroll bookkeeping,
1291 /// and mouse hit-to-character conversion. Keeping the inset here prevents the
1292 /// first typed character and exact wrap boundaries from using different
1293 /// effective widths.
1294 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1295 pub(crate) struct ComposerContentGeometry {
1296 pub(crate) text_area: Rect,
1297 pub(crate) prompt_inset: u16,
1298 }
1299
1300 impl ComposerContentGeometry {
1301 #[must_use]
1302 pub(crate) fn text_width(self) -> usize {
1303 usize::from(self.text_area.width.max(1))
1304 }
1305
1306 #[must_use]
1307 fn prompt_padding(self) -> &'static str {
1308 if self.prompt_inset == COMPOSER_PROMPT_GUTTER_WIDTH {
1309 " "
1310 } else {
1311 ""
1312 }
1313 }
1314
1315 #[must_use]
1316 fn prompt_x(self) -> Option<u16> {
1317 (self.prompt_inset > 0).then(|| self.text_area.x.saturating_sub(self.prompt_inset))
1318 }
1319 }
1320
1321 #[must_use]
1322 pub(crate) fn composer_content_geometry(
1323 inner_area: Rect,
1324 history_search_active: bool,
1325 ) -> ComposerContentGeometry {
1326 let prompt_inset = if !history_search_active
1327 && inner_area.width >= COMPOSER_PROMPT_GUTTER_WIDTH.saturating_add(1)
1328 {
1329 COMPOSER_PROMPT_GUTTER_WIDTH
1330 } else {
1331 0
1332 };
1333 ComposerContentGeometry {
1334 text_area: Rect {
1335 x: inner_area.x.saturating_add(prompt_inset),
1336 y: inner_area.y,
1337 width: inner_area.width.saturating_sub(prompt_inset),
1338 height: inner_area.height,
1339 },
1340 prompt_inset,
1341 }
1342 }
1343
1344 pub struct ComposerWidget<'a> {
1345 app: &'a App,
1346 max_height: u16,
1347 slash_menu_entries: &'a [SlashMenuEntry],
1348 mention_menu_entries: &'a [String],
1349 }
1350
1351 impl<'a> ComposerWidget<'a> {
1352 pub fn new(
1353 app: &'a App,
1354 max_height: u16,
1355 slash_menu_entries: &'a [SlashMenuEntry],
1356 mention_menu_entries: &'a [String],
1357 ) -> Self {
1358 Self {
1359 app,
1360 max_height,
1361 slash_menu_entries,
1362 mention_menu_entries,
1363 }
1364 }
1365
1366 /// Number of popup rows below the input. Mention and slash menus are
1367 /// mutually exclusive — the cursor can only sit inside an `@token` OR
1368 /// a `/cmd` token, not both at once. Mention takes precedence because
1369 /// the partial-mention check is positional and stricter than slash's
1370 /// "starts-with-/" check.
1371 fn active_menu_row_count(&self) -> usize {
1372 if self.app.is_history_search_active() {
1373 self.app.history_search_matches().len().max(1)
1374 } else if !self.mention_menu_entries.is_empty() {
1375 self.mention_menu_entries.len()
1376 } else {
1377 self.slash_menu_entries.len()
1378 }
1379 }
1380
1381 /// Row reservation passed to `composer_height`. When the slash- or
1382 /// mention-menu is active we lock the composer to its worst-case
1383 /// envelope so the chat area above doesn't repaint every keystroke
1384 /// as the matched-entry count shrinks. Pure cosmetic: the menu
1385 /// itself still renders its actual entries — the extra rows are
1386 /// just panel padding inside the same Rect.
1387 ///
1388 /// Reported on Windows 10 PowerShell + WSL where the console
1389 /// backend's per-cell write cost makes the layout jitter visible
1390 /// even though the work is tiny on Unix terminals. See user
1391 /// feedback in v0.8.8 polish thread.
1392 pub fn active_menu_reserved_rows(&self) -> usize {
1393 let actual = self.active_menu_row_count();
1394 if actual == 0 {
1395 return 0;
1396 }
1397 if self.app.is_history_search_active() {
1398 return actual;
1399 }
1400 // Slash- and mention-menu are the cases that grow/shrink mid-typing.
1401 // Reserve the composer's panel-max so the layout stays stable
1402 // for the lifetime of the menu session.
1403 actual.max(usize::from(self.max_height_cap()))
1404 }
1405
1406 fn wants_enclosed_panel(&self) -> bool {
1407 composer_enclosure_enabled(self.app)
1408 }
1409
1410 pub(crate) fn has_panel(&self, area: Rect) -> bool {
1411 enclosed_composer_panel_fits(self.wants_enclosed_panel(), area.width, area.height)
1412 }
1413
1414 /// The border- and submit-aware input rectangle shared by rendering,
1415 /// cursor mapping, and the frame's persistent mouse geometry.
1416 pub(crate) fn inner_area(&self, area: Rect) -> Rect {
1417 composer_inner_area(area, self.has_panel(area))
1418 }
1419
1420 fn focus_color(&self) -> Color {
1421 use crate::tui::shell_key_routing::Focus;
1422 let editing = match self.app.focus() {
1423 Focus::Composer => true,
1424 Focus::Launch => self.app.launch.menu_selected.is_none(),
1425 _ => false,
1426 };
1427 if editing {
1428 self.app.ui_theme.accent_primary
1429 } else {
1430 self.app.ui_theme.border
1431 }
1432 }
1433
1434 fn max_height_cap(&self) -> u16 {
1435 composer_max_height(self.app.composer_density)
1436 }
1437 }
1438
1439 impl Renderable for ComposerWidget<'_> {
1440 fn render(&self, area: Rect, buf: &mut Buffer) {
1441 // Slash rows are re-recorded below; clear first so a closed or
1442 // resized menu cannot keep stale hitboxes from the prior frame.
1443 self.app
1444 .viewport
1445 .last_slash_menu_hitboxes
1446 .borrow_mut()
1447 .clear();
1448 let background = Style::default().bg(self.app.ui_theme.composer_bg);
1449 let has_panel = self.has_panel(area);
1450 let inner_area = self.inner_area(area);
1451 let input_text = self.app.composer_display_input();
1452 let input_cursor = self.app.composer_display_cursor();
1453 let history_search_matches = if self.app.is_history_search_active() {
1454 self.app.history_search_matches()
1455 } else {
1456 Vec::new()
1457 };
1458 let menu_lines = self.active_menu_row_count();
1459 // For the layout-budget calculation, treat the menu as if it were
1460 // already at its locked, worst-case height (see
1461 // `active_menu_reserved_rows`). Without this, when the matched-entry
1462 // count drops mid-typing, `top_padding` grows and the input visually
1463 // jumps down inside the panel even though the panel rect stayed put.
1464 let menu_lines_for_budget = self.active_menu_reserved_rows().max(menu_lines);
1465 let input_rows_budget =
1466 composer_input_rows_budget(inner_area.height, menu_lines_for_budget);
1467 // Menu rows span the full inner panel. Input text alone uses the
1468 // prompt-adjusted geometry below.
1469 let content_width = usize::from(inner_area.width.max(1));
1470 let content_geometry =
1471 composer_content_geometry(inner_area, self.app.is_history_search_active());
1472 let input_content_width = content_geometry.text_width();
1473
1474 // Use the extended version that also returns character indices to avoid
1475 // redundant wrapping when rendering text selections (issue #3909).
1476 let (visible_lines, _cursor_row, _cursor_col, _scroll_offset, visible_char_indices) =
1477 layout_input_with_scroll_and_char_indices(
1478 input_text,
1479 input_cursor,
1480 input_content_width,
1481 input_rows_budget,
1482 );
1483 if has_panel {
1484 let hint_line = if self.app.is_history_search_active() {
1485 Some(Line::from(vec![
1486 Span::styled(
1487 format!(
1488 " {} ",
1489 self.app
1490 .tr(codewhale_localization::MessageId::HistoryHintMove)
1491 ),
1492 Style::default().fg(palette::TEXT_MUTED),
1493 ),
1494 Span::styled(
1495 format!(
1496 "{} ",
1497 self.app
1498 .tr(codewhale_localization::MessageId::HistoryHintAccept)
1499 ),
1500 Style::default().fg(palette::TEXT_MUTED),
1501 ),
1502 Span::styled(
1503 self.app
1504 .tr(codewhale_localization::MessageId::HistoryHintRestore),
1505 Style::default().fg(palette::TEXT_MUTED),
1506 ),
1507 ]))
1508 } else if !self.slash_menu_entries.is_empty() {
1509 Some(Line::from(Span::styled(
1510 self.app
1511 .tr(codewhale_localization::MessageId::ComposerSlashMenuHint),
1512 Style::default().fg(self.app.ui_theme.text_hint),
1513 )))
1514 } else if !input_text.trim().is_empty() {
1515 composer_submit_hint(self.app).map(|hint| {
1516 Line::from(vec![Span::styled(
1517 format!(" {} ", hint.text),
1518 Style::default().fg(hint.color),
1519 )])
1520 })
1521 } else {
1522 None
1523 };
1524
1525 // Focus has one outline. Permission and mode remain explicit in
1526 // their footer; repeating both around the input competes with it.
1527 let focus_color = self.focus_color();
1528 Block::default()
1529 .borders(Borders::ALL)
1530 .border_type(BorderType::Rounded)
1531 .border_style(Style::default().fg(focus_color))
1532 .style(background)
1533 .render(area, buf);
1534 let mut top_border = Block::default()
1535 .borders(Borders::TOP)
1536 .border_type(BorderType::Rounded)
1537 .border_style(Style::default().fg(focus_color))
1538 .style(background);
1539 if self.app.is_history_search_active() {
1540 top_border = top_border.title(Line::from(Span::styled(
1541 format!(
1542 " {} ",
1543 self.app
1544 .tr(codewhale_localization::MessageId::HistorySearchTitle)
1545 ),
1546 Style::default().fg(palette::TEXT_MUTED),
1547 )));
1548 }
1549 // Agent focus chip: the composer names the fork it addresses so
1550 // a message never goes to a worker by surprise.
1551 if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
1552 top_border = top_border.title_top(
1553 Line::from(Span::styled(
1554 format!(" {chip} "),
1555 Style::default()
1556 .fg(self.app.ui_theme.accent_action)
1557 .add_modifier(Modifier::BOLD),
1558 ))
1559 .right_aligned(),
1560 );
1561 }
1562 top_border.render(area, buf);
1563
1564 let mut bottom_border = Block::default()
1565 .borders(Borders::BOTTOM)
1566 .border_type(BorderType::Rounded)
1567 .border_style(Style::default().fg(focus_color))
1568 .style(background);
1569 if let Some(hint_line) = hint_line {
1570 bottom_border = bottom_border.title_bottom(hint_line);
1571 }
1572 bottom_border.render(area, buf);
1573 render_composer_panel_corners(area, buf, background, focus_color, focus_color);
1574 } else if area.height >= 2 {
1575 let mut block = Block::default()
1576 .borders(Borders::TOP)
1577 .border_style(Style::default().fg(self.app.ui_theme.border))
1578 .style(background);
1579 if !input_text.trim().is_empty()
1580 && let Some(hint) = composer_submit_hint(self.app)
1581 {
1582 block = block.title(Line::from(Span::styled(
1583 format!(" {} ", hint.text),
1584 Style::default().fg(hint.color),
1585 )));
1586 }
1587 if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
1588 block = block.title_top(
1589 Line::from(Span::styled(
1590 format!(" {chip} "),
1591 Style::default()
1592 .fg(self.app.ui_theme.accent_action)
1593 .add_modifier(Modifier::BOLD),
1594 ))
1595 .right_aligned(),
1596 );
1597 }
1598 block.render(area, buf);
1599 } else {
1600 Block::default().style(background).render(area, buf);
1601 }
1602
1603 let mut input_lines = Vec::new();
1604 if input_text.is_empty() {
1605 let (placeholder, style): (Cow<'_, str>, Style) = if let Some(ref suggestion) =
1606 self.app.prompt_suggestion
1607 && !self.app.is_history_search_active()
1608 {
1609 (
1610 Cow::Borrowed(suggestion.as_str()),
1611 Style::default().fg(palette::TEXT_HINT),
1612 )
1613 } else {
1614 (
1615 composer_empty_hint_text(self.app),
1616 Style::default().fg(self.app.ui_theme.text_soft),
1617 )
1618 };
1619 input_lines.push(Line::from(vec![
1620 Span::raw(content_geometry.prompt_padding()),
1621 Span::styled(placeholder, style),
1622 ]));
1623 } else if let Some((sel_start, sel_end)) = self.app.selection_range() {
1624 // Use the character indices we already computed during layout
1625 // to avoid redundant wrapping (issue #3909).
1626 let line_ranges: Vec<(usize, usize)> = visible_char_indices
1627 .iter()
1628 .map(|(start, text)| (*start, *start + text.chars().count()))
1629 .collect();
1630 for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter())
1631 {
1632 let mut spans = line_spans_with_selection(
1633 line_text,
1634 *line_start,
1635 *line_end,
1636 sel_start,
1637 sel_end,
1638 self.app.ui_theme.selection_bg,
1639 );
1640 if content_geometry.prompt_inset > 0 {
1641 spans.insert(0, Span::raw(content_geometry.prompt_padding()));
1642 }
1643 input_lines.push(Line::from(spans));
1644 }
1645 } else {
1646 for line in &visible_lines {
1647 let mut spans = Vec::new();
1648 if content_geometry.prompt_inset > 0 {
1649 spans.push(Span::raw(content_geometry.prompt_padding()));
1650 }
1651 spans.push(Span::styled(
1652 line.clone(),
1653 Style::default().fg(palette::TEXT_PRIMARY),
1654 ));
1655 input_lines.push(Line::from(spans));
1656 }
1657 }
1658
1659 // For non-empty input, input_lines.len() already reflects wrapping via
1660 // layout_input. For empty input, keep the first row reserved for the
1661 // real terminal cursor so IME preedit text has a clean surface.
1662 let visual_rows = if input_text.is_empty() {
1663 let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
1664 self.app.prompt_suggestion
1665 && !self.app.is_history_search_active()
1666 {
1667 Some(Cow::Borrowed(suggestion.as_str()))
1668 } else {
1669 Some(composer_empty_hint_text(self.app))
1670 };
1671 empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
1672 } else {
1673 input_lines.len()
1674 };
1675 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
1676 let mut lines = Vec::new();
1677 for _ in 0..top_padding {
1678 lines.push(Line::from(""));
1679 }
1680 lines.extend(input_lines);
1681
1682 if self.app.is_history_search_active() {
1683 if history_search_matches.is_empty() {
1684 lines.push(Line::from(Span::styled(
1685 self.app
1686 .tr(codewhale_localization::MessageId::HistoryNoMatches),
1687 Style::default().fg(palette::TEXT_MUTED),
1688 )));
1689 } else {
1690 let selected = self
1691 .app
1692 .history_search_selected_index()
1693 .min(history_search_matches.len().saturating_sub(1));
1694 let menu_visible_rows = inner_area
1695 .height
1696 .saturating_sub(visual_rows as u16)
1697 .saturating_sub(top_padding as u16)
1698 .saturating_sub(1)
1699 .max(1) as usize;
1700 let menu_total = history_search_matches.len();
1701 let menu_top = if menu_total <= menu_visible_rows {
1702 0
1703 } else {
1704 let half = menu_visible_rows / 2;
1705 if selected <= half {
1706 0
1707 } else if selected + half >= menu_total {
1708 menu_total.saturating_sub(menu_visible_rows)
1709 } else {
1710 selected.saturating_sub(half)
1711 }
1712 };
1713 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
1714
1715 for (idx, entry) in history_search_matches
1716 .iter()
1717 .enumerate()
1718 .take(menu_bottom)
1719 .skip(menu_top)
1720 {
1721 let is_selected = idx == selected;
1722 let style = if is_selected {
1723 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
1724 } else {
1725 Style::default().fg(palette::TEXT_MUTED)
1726 };
1727 let marker = crate::tui::glyphs::selection_marker(is_selected);
1728 lines.push(Line::from(vec![
1729 Span::styled(" ", Style::default()),
1730 Span::styled(marker, style),
1731 Span::styled(" ", style),
1732 Span::styled(entry.clone(), style),
1733 ]));
1734 }
1735 }
1736 } else if !self.mention_menu_entries.is_empty() {
1737 let selected = self
1738 .app
1739 .mention_menu_selected
1740 .min(self.mention_menu_entries.len().saturating_sub(1));
1741 let menu_visible_rows = inner_area
1742 .height
1743 .saturating_sub(visual_rows as u16)
1744 .saturating_sub(top_padding as u16)
1745 .saturating_sub(1)
1746 .max(1) as usize;
1747 let menu_total = self.mention_menu_entries.len();
1748 let menu_top = if menu_total <= menu_visible_rows {
1749 0
1750 } else {
1751 let half = menu_visible_rows / 2;
1752 if selected <= half {
1753 0
1754 } else if selected + half >= menu_total {
1755 menu_total.saturating_sub(menu_visible_rows)
1756 } else {
1757 selected.saturating_sub(half)
1758 }
1759 };
1760 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
1761
1762 for (idx, entry) in self
1763 .mention_menu_entries
1764 .iter()
1765 .enumerate()
1766 .take(menu_bottom)
1767 .skip(menu_top)
1768 {
1769 let is_selected = idx == selected;
1770 let style = if is_selected {
1771 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
1772 } else {
1773 Style::default().fg(palette::TEXT_MUTED)
1774 };
1775 let marker = crate::tui::glyphs::selection_marker(is_selected);
1776 lines.push(Line::from(vec![
1777 Span::styled(" ", Style::default()),
1778 Span::styled(marker, style),
1779 Span::styled(" ", style),
1780 Span::styled(format!("@{entry}"), style),
1781 ]));
1782 }
1783 } else if !self.slash_menu_entries.is_empty() {
1784 let selected = self
1785 .app
1786 .slash_menu_selected
1787 .min(self.slash_menu_entries.len().saturating_sub(1));
1788 let menu_visible_rows = inner_area
1789 .height
1790 .saturating_sub(visual_rows as u16)
1791 .saturating_sub(top_padding as u16)
1792 .saturating_sub(1)
1793 .max(1) as usize;
1794 let menu_total = self.slash_menu_entries.len();
1795 let menu_top = if menu_total <= menu_visible_rows {
1796 0
1797 } else {
1798 let half = menu_visible_rows / 2;
1799 if selected <= half {
1800 0
1801 } else if selected + half >= menu_total {
1802 menu_total.saturating_sub(menu_visible_rows)
1803 } else {
1804 selected.saturating_sub(half)
1805 }
1806 };
1807 let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
1808
1809 // Label column width — grows to fit the widest visible name
1810 // (including alias hint like " or /bangzhu") but stays bounded.
1811 let label_width = self
1812 .slash_menu_entries
1813 .iter()
1814 .take(menu_bottom)
1815 .skip(menu_top)
1816 .map(|e| {
1817 if let Some(ref hint) = e.alias_hint {
1818 format!("{} or /{}", e.name, hint).width()
1819 } else {
1820 e.name.width()
1821 }
1822 })
1823 .max()
1824 .unwrap_or(22)
1825 .min(content_width.saturating_sub(4))
1826 .max(8);
1827 for (idx, entry) in self
1828 .slash_menu_entries
1829 .iter()
1830 .enumerate()
1831 .take(menu_bottom)
1832 .skip(menu_top)
1833 {
1834 let is_selected = idx == selected;
1835 let sel_style = if is_selected {
1836 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
1837 } else {
1838 Style::default().fg(palette::TEXT_MUTED)
1839 };
1840 let marker = crate::tui::glyphs::selection_marker(is_selected);
1841
1842 // Name column
1843 let name_style = if entry.is_skill && !is_selected {
1844 Style::default().fg(palette::WHALE_ACTION)
1845 } else {
1846 sel_style
1847 };
1848
1849 // Description column (muted when not selected, secondary when selected)
1850 let desc_style = if is_selected {
1851 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
1852 } else {
1853 Style::default().fg(palette::TEXT_DIM)
1854 };
1855
1856 // Build display name: canonical name, with "or /alias" hint
1857 // when the user typed via a pinyin alias.
1858 let display_name = if let Some(ref hint) = entry.alias_hint {
1859 format!("{} or /{}", entry.name, hint)
1860 } else {
1861 entry.name.clone()
1862 };
1863
1864 let name_was_truncated = display_name.width() > label_width;
1865 let mut name_display =
1866 crate::tui::ui_text::truncate_line_to_width(&display_name, label_width);
1867 while name_display.width() < label_width {
1868 name_display.push(' ');
1869 }
1870
1871 // Skill marker prefix
1872 let skill_prefix = if entry.is_skill { "✦" } else { " " };
1873
1874 // Compute exact prefix display width to avoid Paragraph wrap:
1875 // 1(" ") + 1(marker) + skill_prefix.width() + label_width + 2(" ")
1876 let prefix_display_width = 1 + 1 + skill_prefix.width() + label_width + 2;
1877 let desc_capacity = content_width.saturating_sub(prefix_display_width);
1878 let description_was_truncated = entry.description.width() > desc_capacity;
1879 let desc_display =
1880 crate::tui::ui_text::truncate_line_to_width(&entry.description, desc_capacity);
1881
1882 let row_line_index = lines.len();
1883 lines.push(Line::from(vec![
1884 Span::styled(" ", Style::default()),
1885 Span::styled(marker, sel_style),
1886 Span::styled(skill_prefix, name_style),
1887 Span::styled(name_display, name_style),
1888 Span::styled(" ", desc_style),
1889 Span::styled(desc_display, desc_style),
1890 ]));
1891
1892 let row_y = inner_area
1893 .y
1894 .saturating_add(u16::try_from(row_line_index).unwrap_or(u16::MAX));
1895 if row_y < inner_area.bottom() && inner_area.width > 0 {
1896 self.app
1897 .viewport
1898 .last_slash_menu_hitboxes
1899 .borrow_mut()
1900 .push((idx, Rect::new(inner_area.x, row_y, inner_area.width, 1)));
1901 }
1902
1903 if name_was_truncated || description_was_truncated {
1904 let full_text = if entry.description.trim().is_empty() {
1905 display_name
1906 } else {
1907 format!("{display_name} {}", entry.description)
1908 };
1909 if row_y < inner_area.bottom() {
1910 crate::tui::hover_layer::register_rect(
1911 crate::tui::hover_hit::HoverTargetKind::TruncatedText,
1912 Rect::new(inner_area.x, row_y, inner_area.width, 1),
1913 full_text,
1914 false,
1915 );
1916 }
1917 }
1918 }
1919 }
1920
1921 let paragraph = Paragraph::new(lines)
1922 .style(background)
1923 .wrap(Wrap { trim: false });
1924 paragraph.render(inner_area, buf);
1925
1926 // The prompt is a persistent focus anchor, not empty-state chrome.
1927 // Rendering it on every input row keeps the first character from
1928 // causing a visible leftward jump.
1929 if let Some(prompt_x) = content_geometry.prompt_x()
1930 && let Some((cursor_x, cursor_y)) = self.cursor_pos(area)
1931 {
1932 debug_assert!(cursor_x >= content_geometry.text_area.x);
1933 buf[(prompt_x, cursor_y)]
1934 .set_symbol("❯")
1935 .set_style(Style::default().fg(self.app.ui_theme.accent_primary));
1936 }
1937
1938 // Restore the shared `[↵]` after caller-owned input so a long draft
1939 // cannot erase the one cell target the mouse handler also uses.
1940 if has_panel {
1941 crate::tui::composer_chrome::render_tideline_composer_submit(
1942 area,
1943 buf,
1944 &self.app.ui_theme,
1945 self.app.composer_enter_would_submit(),
1946 crate::tui::color_compat::ascii_safe_enabled(),
1947 );
1948 }
1949 }
1950
1951 fn desired_height(&self, width: u16) -> u16 {
1952 composer_height(
1953 self.app.composer_display_input(),
1954 width,
1955 self.max_height.min(self.max_height_cap()),
1956 self.active_menu_reserved_rows(),
1957 self.app.composer_density,
1958 self.wants_enclosed_panel(),
1959 )
1960 }
1961
1962 fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
1963 let inner_area = self.inner_area(area);
1964 let input_text = self.app.composer_display_input();
1965 let input_cursor = self.app.composer_display_cursor();
1966 let content_geometry =
1967 composer_content_geometry(inner_area, self.app.is_history_search_active());
1968 let input_content_width = content_geometry.text_width();
1969 // Match the render path's locked-budget calculation so the cursor
1970 // lands on the same row the input is drawn on.
1971 let input_rows_budget =
1972 composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows());
1973
1974 let (visible_lines, cursor_row, cursor_col) = layout_input(
1975 input_text,
1976 input_cursor,
1977 input_content_width,
1978 input_rows_budget,
1979 );
1980 let visual_rows = if input_text.is_empty() {
1981 let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
1982 self.app.prompt_suggestion
1983 && !self.app.is_history_search_active()
1984 {
1985 Some(Cow::Borrowed(suggestion.as_str()))
1986 } else {
1987 Some(composer_empty_hint_text(self.app))
1988 };
1989 empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
1990 } else {
1991 visible_lines.len()
1992 };
1993 let top_padding = composer_top_padding(visual_rows, input_rows_budget);
1994
1995 let cursor_x = content_geometry
1996 .text_area
1997 .x
1998 .saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
1999 let cursor_y = inner_area
2000 .y
2001 .saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX));
2002 if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
2003 Some((cursor_x, cursor_y))
2004 } else {
2005 None
2006 }
2007 }
2008 }
2009
2010 /// Compact, bottom-anchored approval card.
2011 ///
2012 /// The widget reads its selected option and locale directly from the
2013 /// [`ApprovalView`]. Rendering preserves transcript context while reserving
2014 /// the complete action set and at least one load-bearing command/preview row
2015 /// on ordinary terminal sizes.
2016 pub struct ApprovalWidget<'a> {
2017 request: &'a ApprovalRequest,
2018 view: &'a ApprovalView,
2019 }
2020
2021 impl<'a> ApprovalWidget<'a> {
2022 pub fn new(request: &'a ApprovalRequest, view: &'a ApprovalView) -> Self {
2023 Self { request, view }
2024 }
2025
2026 /// Build the inline approval content, split into the informational `body`
2027 /// (which may scroll/truncate within its region) and the interactive
2028 /// `controls` (which are always reserved and can never be clipped). Both
2029 /// `render` and `inline_region` use this so the painted band and the
2030 /// dimmed backdrop region always agree.
2031 fn build_inline_content(&self, area: Rect) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
2032 let risk = self.request.risk;
2033 let stakes = self.request.stakes();
2034 let locale = self.view.locale();
2035 let repo_law = self.request.is_repo_law_prompt();
2036 let palette_colors = if repo_law {
2037 repo_law_approval_palette()
2038 } else {
2039 approval_palette(stakes)
2040 };
2041 let critical = matches!(stakes, crate::tui::approval::ApprovalStakes::Critical);
2042
2043 let mut body: Vec<Line<'static>> = Vec::with_capacity(16);
2044 // Header: stakes badge + tool identifier.
2045 body.push(Line::from(vec![
2046 Span::raw(" "),
2047 Span::styled(
2048 format!(
2049 " {} ",
2050 if repo_law {
2051 tr(locale, MessageId::ApprovalRepoLawBadge)
2052 } else {
2053 stakes_badge_text(stakes, locale)
2054 }
2055 ),
2056 Style::default()
2057 .fg(palette::WHALE_BG)
2058 .bg(palette_colors.accent)
2059 .add_modifier(Modifier::BOLD),
2060 ),
2061 Span::raw(" "),
2062 Span::styled(
2063 if repo_law {
2064 format!(
2065 "{} · {}",
2066 tr(locale, MessageId::ApprovalRepoLawTitle),
2067 self.request.tool_name
2068 )
2069 } else {
2070 self.request.tool_name.clone()
2071 },
2072 Style::default()
2073 .fg(palette::WHALE_ACTION)
2074 .add_modifier(Modifier::BOLD),
2075 ),
2076 ]));
2077
2078 if repo_law {
2079 body.push(Line::from(vec![
2080 Span::raw(" "),
2081 Span::styled(
2082 "◆ ",
2083 Style::default()
2084 .fg(palette::STATUS_WARNING)
2085 .add_modifier(Modifier::BOLD),
2086 ),
2087 Span::styled(
2088 tr(locale, MessageId::ApprovalRepoLawWarning),
2089 Style::default()
2090 .fg(palette::WHALE_ERROR)
2091 .add_modifier(Modifier::BOLD),
2092 ),
2093 ]));
2094 body.push(Line::from(vec![
2095 Span::raw(" "),
2096 Span::styled(
2097 tr(locale, MessageId::ApprovalRepoLawRuleLabel),
2098 Style::default().fg(palette::TEXT_HINT),
2099 ),
2100 Span::styled(
2101 self.request.description.clone(),
2102 Style::default().fg(palette::TEXT_SECONDARY),
2103 ),
2104 ]));
2105 }
2106
2107 // Command / change preview FIRST — for an approval the thing being run
2108 // is the load-bearing content, so on a short terminal it is the
2109 // secondary context (about/impacts/category) that scrolls away, never
2110 // the command.
2111 let details = self.request.prominent_detail_items(locale);
2112 if details.is_empty() {
2113 push_params_detail_line(&mut body, self.request, locale, area.width);
2114 } else {
2115 let mut rendered_detail = false;
2116 for detail in details.iter().take(4) {
2117 let is_change_preview = matches!(detail.label.as_str(), "Preview" | "预览");
2118 if let Some(shell_lines) = detail.shell_lines.as_deref() {
2119 let command_width = area.width.saturating_sub(10) as usize;
2120 // A short approval band has room for only one detail row
2121 // before its truncation hint. Project the most useful
2122 // command/change into that row instead of spending it on
2123 // setup (`cd`, `set`) or diff metadata. The complete,
2124 // original-order value remains available in the details
2125 // pager.
2126 let inline_shell_lines = prioritize_inline_shell_lines(
2127 shell_lines,
2128 is_change_preview,
2129 area.height <= 24,
2130 );
2131 // Bound every multi-line preview so one huge command cannot
2132 // grow the band without limit; the details chord opens the rest.
2133 let max_rows = if is_change_preview {
2134 if self.request.intent_summary.is_some() {
2135 Some(3)
2136 } else {
2137 Some(5)
2138 }
2139 } else {
2140 Some(8)
2141 };
2142 push_shell_command_lines(
2143 &mut body,
2144 &detail.label,
2145 &inline_shell_lines,
2146 command_width.max(20),
2147 max_rows,
2148 );
2149 } else {
2150 push_detail_line(&mut body, &detail.label, &detail.value);
2151 }
2152 rendered_detail = true;
2153 }
2154 if !rendered_detail {
2155 push_params_detail_line(&mut body, self.request, locale, area.width);
2156 }
2157 }
2158
2159 // Intent summary ("why this change is needed", #2381).
2160 if let Some(ref summary) = self.request.intent_summary {
2161 let max_width = area.width.saturating_sub(14) as usize;
2162 if max_width > 0 {
2163 let intent_label = tr(locale, MessageId::ApprovalIntentLabel);
2164 let summary_lines: Vec<&str> = summary.lines().collect();
2165 let intent_lines = 3usize;
2166 for (i, sline) in summary_lines.iter().take(intent_lines).enumerate() {
2167 let prefix = if i == 0 {
2168 intent_label.clone()
2169 } else {
2170 Cow::Borrowed(" ")
2171 };
2172 let truncated = crate::utils::truncate_with_ellipsis(sline, max_width, "...");
2173 body.push(Line::from(vec![
2174 Span::raw(" "),
2175 Span::styled(
2176 prefix,
2177 if i == 0 {
2178 Style::default().fg(palette::TEXT_HINT)
2179 } else {
2180 Style::default()
2181 },
2182 ),
2183 Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
2184 ]));
2185 }
2186 if summary_lines.len() > intent_lines {
2187 let more = tr(locale, MessageId::ApprovalMoreLines)
2188 .replace("{count}", &(summary_lines.len() - intent_lines).to_string());
2189 body.push(Line::from(vec![
2190 Span::raw(" "),
2191 Span::styled(more, Style::default().fg(palette::TEXT_HINT)),
2192 ]));
2193 }
2194 }
2195 }
2196
2197 // Destructive policy / cancel semantics — critical stakes only. For
2198 // routine and elevated work the controls speak for themselves; the
2199 // extra policy prose was noise that made every edit read like an
2200 // emergency.
2201 if critical {
2202 push_destructive_approval_semantics(&mut body, locale, false);
2203 }
2204
2205 // Secondary context: what it is and what it touches. Only critical
2206 // prompts carry the full about/impact/category dossier by default —
2207 // everything stays one details chord away in the pager. Keep a single
2208 // About line as fallback context when nothing else was rendered.
2209 if critical || details.is_empty() {
2210 body.push(Line::from(vec![
2211 Span::raw(" "),
2212 Span::styled(label_about(locale), Style::default().fg(palette::TEXT_HINT)),
2213 Span::styled(
2214 self.request.description_for_locale(locale),
2215 Style::default().fg(palette::TEXT_BODY),
2216 ),
2217 ]));
2218 }
2219 if critical {
2220 for impact in self.request.impacts_for_locale(locale).into_iter().take(4) {
2221 body.push(Line::from(vec![
2222 Span::raw(" "),
2223 Span::styled(
2224 label_impact(locale),
2225 Style::default().fg(palette::TEXT_HINT),
2226 ),
2227 Span::styled(impact, Style::default().fg(palette::TEXT_BODY)),
2228 ]));
2229 }
2230 // Category line — localized risk category.
2231 let (cat_label, cat_color) = category_label_for(self.request.category, locale);
2232 body.push(Line::from(vec![
2233 Span::raw(" "),
2234 Span::styled(label_type(locale), Style::default().fg(palette::TEXT_HINT)),
2235 Span::styled(
2236 cat_label,
2237 Style::default().fg(cat_color).add_modifier(Modifier::BOLD),
2238 ),
2239 ]));
2240 }
2241
2242 // Preview the validated persistent-rule candidates. Informational, so
2243 // they live in the scrollable body rather than the action rows.
2244 if let Some(preview) = self.request.ask_rule_save_preview() {
2245 push_permission_rule_save_preview(
2246 &mut body,
2247 &preview,
2248 palette_colors.shortcut,
2249 area.width,
2250 );
2251 }
2252 if let Some(preview) = self.request.allow_rule_save_preview() {
2253 push_permission_rule_save_preview(
2254 &mut body,
2255 &preview,
2256 palette_colors.shortcut,
2257 area.width,
2258 );
2259 }
2260
2261 let controls = build_approval_controls(
2262 self.request,
2263 self.view,
2264 risk,
2265 locale,
2266 palette_colors.accent,
2267 palette_colors.shortcut,
2268 );
2269 (body, controls)
2270 }
2271
2272 /// Bottom-anchored band this inline prompt occupies within `area`. Must
2273 /// match what `render` paints so the backdrop dims exactly this strip.
2274 pub(crate) fn inline_region(&self, area: Rect) -> Rect {
2275 if area.width == 0 || area.height == 0 {
2276 return Rect {
2277 x: area.x,
2278 y: area.y.saturating_add(area.height),
2279 width: 0,
2280 height: 0,
2281 };
2282 }
2283 if self.view.collapsed {
2284 // Collapsed mode is a single banner row pinned to the bottom.
2285 let h = area.height.min(1);
2286 return Rect {
2287 x: area.x,
2288 y: area.y.saturating_add(area.height.saturating_sub(h)),
2289 width: area.width,
2290 height: h,
2291 };
2292 }
2293 let (body, controls) = self.build_inline_content(area);
2294 inline_region_for(area, &body, &controls)
2295 }
2296 }
2297
2298 impl Renderable for ApprovalWidget<'_> {
2299 fn render(&self, area: Rect, buf: &mut Buffer) {
2300 if area.width == 0 || area.height == 0 {
2301 return;
2302 }
2303
2304 // Collapsed mode: a single-line banner at the bottom of the area
2305 // so the user can still see the transcript behind it.
2306 if self.view.collapsed {
2307 self.view.set_mouse_hitboxes(Vec::new());
2308 let bar_y = area.y.saturating_add(area.height.saturating_sub(1));
2309 let bar_area = Rect::new(area.x, bar_y, area.width, 1);
2310 Clear.render(bar_area, buf);
2311
2312 let stakes = self.request.stakes();
2313 let repo_law = self.request.is_repo_law_prompt();
2314 let palette_colors = if repo_law {
2315 repo_law_approval_palette()
2316 } else {
2317 approval_palette(stakes)
2318 };
2319 let summary = format!(
2320 " {} — {} [Tab to expand] ",
2321 if repo_law {
2322 tr(self.view.locale(), MessageId::ApprovalRepoLawTitle)
2323 } else {
2324 Cow::Borrowed(self.request.tool_name.as_str())
2325 },
2326 if repo_law {
2327 tr(self.view.locale(), MessageId::ApprovalRepoLawBadge)
2328 } else {
2329 stakes_badge_text(stakes, self.view.locale())
2330 },
2331 );
2332 let line = Line::from(Span::styled(
2333 summary,
2334 Style::default()
2335 .fg(palette::WHALE_BG)
2336 .bg(palette_colors.accent)
2337 .add_modifier(Modifier::BOLD),
2338 ));
2339 Paragraph::new(line).render(bar_area, buf);
2340 return;
2341 }
2342
2343 // Compute stakes once for this render pass (it runs command_safety
2344 // analysis on shell commands); reuse it for the palette and the
2345 // left-rail gate instead of re-deriving per band.
2346 let stakes = self.request.stakes();
2347 let repo_law = self.request.is_repo_law_prompt();
2348 let palette_colors = if repo_law {
2349 repo_law_approval_palette()
2350 } else {
2351 approval_palette(stakes)
2352 };
2353 let (body, controls) = self.build_inline_content(area);
2354 let region = inline_region_for(area, &body, &controls);
2355 if region.width == 0 || region.height == 0 {
2356 return;
2357 }
2358
2359 // Opaque inline panel anchored to the bottom of the frame. The
2360 // transcript above stays visible; only this band is painted — the
2361 // approval is no longer a full-screen takeover (#3799).
2362 Clear.render(region, buf);
2363 Block::default()
2364 .style(Style::default().bg(palette::WHALE_BG))
2365 .render(region, buf);
2366
2367 // Top separator rule, risk-tinted, so the prompt reads as a distinct
2368 // panel without a heavy full border box.
2369 let rule_glyph = if repo_law { "═" } else { "─" };
2370 let rule: String = rule_glyph.repeat(region.width as usize);
2371 buf.set_string(
2372 region.x,
2373 region.y,
2374 &rule,
2375 Style::default().fg(palette_colors.border),
2376 );
2377
2378 // Reserve the controls FIRST: they take their rows off the bottom of
2379 // the band and can never be clipped, no matter how long the body is.
2380 // The informational body takes whatever remains and shows a pager
2381 // affordance when it does not fit. This is the core #3799 fix — the
2382 // action row is no longer the last thing in a single clipping
2383 // Paragraph.
2384 let inner_top = region.y.saturating_add(1);
2385 let inner_height = region.height.saturating_sub(1);
2386 let control_rows = measure_wrapped_rows(&controls, region.width).min(inner_height);
2387 let body_height = inner_height.saturating_sub(control_rows);
2388
2389 let body_rect = Rect {
2390 x: region.x,
2391 y: inner_top,
2392 width: region.width,
2393 height: body_height,
2394 };
2395 let control_rect = Rect {
2396 x: region.x,
2397 y: inner_top.saturating_add(body_height),
2398 width: region.width,
2399 height: control_rows,
2400 };
2401
2402 let mut hitboxes = Vec::new();
2403 let option_count =
2404 approval_options_for_request(self.request, self.request.risk, self.view.locale()).len();
2405 for index in 0..option_count {
2406 let first_line = 1 + index;
2407 let y_offset = measure_wrapped_rows(&controls[..first_line], region.width);
2408 let next_offset = measure_wrapped_rows(&controls[..first_line + 1], region.width);
2409 let y = control_rect.y.saturating_add(y_offset);
2410 let height = next_offset.saturating_sub(y_offset).min(
2411 control_rect
2412 .y
2413 .saturating_add(control_rect.height)
2414 .saturating_sub(y),
2415 );
2416 if height > 0 {
2417 hitboxes.push(Rect::new(control_rect.x, y, control_rect.width, height));
2418 }
2419 }
2420 self.view.set_mouse_hitboxes(hitboxes);
2421
2422 let body_rows = measure_wrapped_rows(&body, region.width);
2423 if body_rows > body_height && body_height > 0 {
2424 // Body does not fit (short terminal): show as much as we can and
2425 // point at the params pager through the platform-aware details chord.
2426 let shown = body_height.saturating_sub(1);
2427 if shown > 0 {
2428 Paragraph::new(body).wrap(Wrap { trim: false }).render(
2429 Rect {
2430 height: shown,
2431 ..body_rect
2432 },
2433 buf,
2434 );
2435 }
2436 buf.set_string(
2437 region.x,
2438 body_rect.y.saturating_add(shown),
2439 approval_truncation_hint(self.view.locale()),
2440 Style::default().fg(palette::TEXT_HINT),
2441 );
2442 } else {
2443 Paragraph::new(body)
2444 .wrap(Wrap { trim: false })
2445 .render(body_rect, buf);
2446 }
2447
2448 Paragraph::new(controls)
2449 .wrap(Wrap { trim: false })
2450 .render(control_rect, buf);
2451 }
2452
2453 fn desired_height(&self, _width: u16) -> u16 {
2454 1
2455 }
2456 }
2457
2458 /// Bottom-anchored band the inline approval prompt occupies within `area`.
2459 /// Sized to the measured content, capped to half the frame like the compact
2460 /// permission surfaces in peer coding agents, and always tall enough to show
2461 /// the reserved controls (#3799). Full details remain available through the
2462 /// platform-aware details chord.
2463 fn inline_region_for(area: Rect, body: &[Line<'static>], controls: &[Line<'static>]) -> Rect {
2464 if area.width == 0 || area.height == 0 {
2465 return Rect {
2466 x: area.x,
2467 y: area.y.saturating_add(area.height),
2468 width: 0,
2469 height: 0,
2470 };
2471 }
2472 let width = area.width;
2473 let body_rows = measure_wrapped_rows(body, width);
2474 let control_rows = measure_wrapped_rows(controls, width);
2475 // +1 for the top separator rule.
2476 let desired = 1u16.saturating_add(body_rows).saturating_add(control_rows);
2477 // Never shrink below the rule + controls. At normal terminal heights,
2478 // reserve four body rows: header, detail label, at least one command or
2479 // preview row, and the truncation hint. Half a viewport is the preferred
2480 // cap; up to four fifths is allowed only when necessary to retain that
2481 // load-bearing preview on a short frame. The extra permanent-grant row
2482 // needs one more reserved line than the legacy four-action card. Truly
2483 // tiny frames prioritize the complete action set and details chord.
2484 let controls_floor = 1u16.saturating_add(control_rows).min(area.height);
2485 let preview_rows = if area.height >= 16 {
2486 body_rows.min(4)
2487 } else {
2488 0
2489 };
2490 let preview_floor = controls_floor.saturating_add(preview_rows).min(area.height);
2491 let preferred_cap = area.height.div_ceil(2);
2492 let short_frame_cap = area.height.saturating_mul(4).div_ceil(5);
2493 let max_height = preferred_cap
2494 .max(preview_floor.min(short_frame_cap))
2495 .max(controls_floor)
2496 .min(area.height);
2497 let min_height = controls_floor;
2498 let height = desired.clamp(min_height, max_height);
2499 Rect {
2500 x: area.x,
2501 y: area.y.saturating_add(area.height.saturating_sub(height)),
2502 width,
2503 height,
2504 }
2505 }
2506
2507 /// Terminal rows `lines` occupy under the exact ratatui word-wrap used by the
2508 /// renderer. Exact measurement keeps localized controls and their mouse
2509 /// hitboxes aligned without padding the compact approval band.
2510 fn measure_wrapped_rows(lines: &[Line<'_>], width: u16) -> u16 {
2511 if width == 0 {
2512 return lines.len() as u16;
2513 }
2514 let rows = Paragraph::new(lines.to_vec())
2515 .wrap(Wrap { trim: false })
2516 .line_count(width);
2517 u16::try_from(rows).unwrap_or(u16::MAX)
2518 }
2519
2520 /// Build the always-visible approval controls: a "proceed?" prompt, the
2521 /// numbered/selectable options, and the selection hint. Rendered into a region
2522 /// reserved off the bottom of the band so it can never be clipped (#3799).
2523 fn build_approval_controls(
2524 request: &ApprovalRequest,
2525 view: &ApprovalView,
2526 risk: RiskLevel,
2527 locale: Locale,
2528 accent: Color,
2529 shortcut: Color,
2530 ) -> Vec<Line<'static>> {
2531 let mut controls: Vec<Line<'static>> = Vec::with_capacity(6);
2532 controls.push(Line::from(vec![
2533 Span::raw(" "),
2534 Span::styled(
2535 approval_proceed_question(locale),
2536 Style::default()
2537 .fg(palette::TEXT_BODY)
2538 .add_modifier(Modifier::BOLD),
2539 ),
2540 ]));
2541 let options = approval_options_for_request(request, risk, locale);
2542 for (i, opt) in options.iter().enumerate() {
2543 let is_selected = i == view.selected();
2544 let label_color = if opt.dangerous {
2545 accent
2546 } else {
2547 palette::TEXT_BODY
2548 };
2549 let option_style = approval_option_style(is_selected, label_color);
2550 let shortcut_style = approval_option_style(is_selected, shortcut);
2551 // Leading caret marks the row Enter will fire — selection is not
2552 // signalled by background alone.
2553 let lead = if is_selected {
2554 Span::styled("\u{276f} ", approval_selected_style())
2555 } else {
2556 Span::raw(" ")
2557 };
2558 controls.push(Line::from(vec![
2559 lead,
2560 Span::styled(
2561 format!("[{}] ", opt.key_hint),
2562 shortcut_style.add_modifier(Modifier::BOLD),
2563 ),
2564 Span::styled(opt.label.to_string(), option_style),
2565 ]));
2566 }
2567 controls.push(Line::from(vec![
2568 Span::raw(" "),
2569 Span::styled(
2570 footer_controls(locale),
2571 Style::default().fg(palette::TEXT_MUTED),
2572 ),
2573 if request.can_save_ask_rule() {
2574 Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut))
2575 } else {
2576 Span::raw("")
2577 },
2578 ]));
2579 controls
2580 }
2581
2582 fn approval_proceed_question(locale: Locale) -> &'static str {
2583 match locale {
2584 Locale::ZhHans => "是否继续?",
2585 _ => "Do you want to proceed?",
2586 }
2587 }
2588
2589 fn approval_truncation_hint(locale: Locale) -> Cow<'static, str> {
2590 let details = crate::tui::shell_key_routing::tool_details_chord();
2591 Cow::Owned(tr(locale, MessageId::ApprovalTruncationHint).replace("{details}", details.as_ref()))
2592 }
2593
2594 /// Approval palette per risk variant.
2595 struct ApprovalColors {
2596 border: Color,
2597 accent: Color,
2598 shortcut: Color,
2599 }
2600
2601 fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalColors {
2602 use crate::tui::approval::ApprovalStakes;
2603 match stakes {
2604 ApprovalStakes::Routine => ApprovalColors {
2605 border: palette::BORDER_COLOR,
2606 accent: palette::WHALE_HUMAN,
2607 shortcut: palette::WHALE_ACTION,
2608 },
2609 // Ordinary state-touching work: a calm ask, not an alarm.
2610 ApprovalStakes::Elevated => ApprovalColors {
2611 border: palette::WHALE_HUMAN,
2612 accent: palette::WHALE_HUMAN,
2613 shortcut: palette::WHALE_ACTION,
2614 },
2615 ApprovalStakes::Critical => ApprovalColors {
2616 border: palette::WHALE_ERROR,
2617 accent: palette::WHALE_ERROR,
2618 shortcut: palette::STATUS_WARNING,
2619 },
2620 }
2621 }
2622
2623 fn repo_law_approval_palette() -> ApprovalColors {
2624 ApprovalColors {
2625 border: palette::STATUS_WARNING,
2626 accent: palette::WHALE_ERROR,
2627 shortcut: palette::STATUS_WARNING,
2628 }
2629 }
2630
2631 fn approval_selected_style() -> Style {
2632 menu_style::selected_row_style()
2633 }
2634
2635 fn approval_option_style(is_selected: bool, color: Color) -> Style {
2636 if is_selected {
2637 approval_selected_style()
2638 } else {
2639 Style::default().fg(color)
2640 }
2641 }
2642
2643 fn stakes_badge_text(
2644 stakes: crate::tui::approval::ApprovalStakes,
2645 locale: Locale,
2646 ) -> Cow<'static, str> {
2647 use crate::tui::approval::ApprovalStakes;
2648 match stakes {
2649 ApprovalStakes::Routine => tr(locale, MessageId::ApprovalRiskReview),
2650 ApprovalStakes::Elevated => tr(locale, MessageId::ApprovalRiskElevated),
2651 ApprovalStakes::Critical => tr(locale, MessageId::ApprovalRiskDestructive),
2652 }
2653 }
2654
2655 fn category_label_for(category: ToolCategory, locale: Locale) -> (Cow<'static, str>, Color) {
2656 let label = match category {
2657 ToolCategory::Safe => tr(locale, MessageId::ApprovalCategorySafe),
2658 ToolCategory::FileWrite => tr(locale, MessageId::ApprovalCategoryFileWrite),
2659 ToolCategory::Shell => tr(locale, MessageId::ApprovalCategoryShell),
2660 ToolCategory::Network => tr(locale, MessageId::ApprovalCategoryNetwork),
2661 ToolCategory::McpRead => tr(locale, MessageId::ApprovalCategoryMcpRead),
2662 ToolCategory::McpAction => tr(locale, MessageId::ApprovalCategoryMcpAction),
2663 ToolCategory::Agent => tr(locale, MessageId::ApprovalCategoryAgent),
2664 ToolCategory::Unknown => tr(locale, MessageId::ApprovalCategoryUnknown),
2665 };
2666 let color = match category {
2667 ToolCategory::Safe => palette::STATUS_SUCCESS,
2668 ToolCategory::FileWrite => palette::STATUS_WARNING,
2669 ToolCategory::Shell => palette::STATUS_ERROR,
2670 ToolCategory::Network => palette::STATUS_WARNING,
2671 ToolCategory::McpRead => palette::WHALE_ACTION,
2672 ToolCategory::McpAction => palette::STATUS_WARNING,
2673 ToolCategory::Agent => palette::WHALE_ACTION,
2674 ToolCategory::Unknown => palette::STATUS_ERROR,
2675 };
2676 (label, color)
2677 }
2678
2679 fn label_type(locale: Locale) -> Cow<'static, str> {
2680 tr(locale, MessageId::ApprovalFieldType)
2681 }
2682
2683 fn label_about(locale: Locale) -> Cow<'static, str> {
2684 tr(locale, MessageId::ApprovalFieldAbout)
2685 }
2686
2687 fn label_impact(locale: Locale) -> Cow<'static, str> {
2688 tr(locale, MessageId::ApprovalFieldImpact)
2689 }
2690
2691 fn label_params(locale: Locale) -> Cow<'static, str> {
2692 tr(locale, MessageId::ApprovalFieldParams)
2693 }
2694
2695 fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) {
2696 lines.push(Line::from(vec![
2697 Span::raw(" "),
2698 Span::styled(
2699 format!("{label:<7} "),
2700 Style::default()
2701 .fg(palette::WHALE_ACTION)
2702 .add_modifier(Modifier::BOLD),
2703 ),
2704 Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)),
2705 ]));
2706 }
2707
2708 fn push_params_detail_line(
2709 lines: &mut Vec<Line<'static>>,
2710 request: &ApprovalRequest,
2711 locale: Locale,
2712 card_width: u16,
2713 ) {
2714 let params_str = request.params_display();
2715 let params_width = card_width.saturating_sub(14) as usize;
2716 let params_truncated =
2717 crate::utils::truncate_with_ellipsis(&params_str, params_width.max(20), "...");
2718 lines.push(Line::from(vec![
2719 Span::raw(" "),
2720 Span::styled(
2721 label_params(locale),
2722 Style::default().fg(palette::TEXT_HINT),
2723 ),
2724 Span::styled(
2725 params_truncated,
2726 Style::default().fg(palette::TEXT_SECONDARY),
2727 ),
2728 ]));
2729 }
2730
2731 fn push_permission_rule_save_preview(
2732 lines: &mut Vec<Line<'static>>,
2733 preview: &crate::tui::approval::PermissionRuleSavePreview,
2734 shortcut: Color,
2735 card_width: u16,
2736 ) {
2737 lines.push(Line::from(vec![
2738 Span::raw(" "),
2739 Span::styled(
2740 "Save: ",
2741 Style::default().fg(shortcut).add_modifier(Modifier::BOLD),
2742 ),
2743 Span::styled(preview.summary(), Style::default().fg(palette::TEXT_BODY)),
2744 ]));
2745
2746 let entry_width = card_width.saturating_sub(10) as usize;
2747 let entries = preview.entries.join("; ");
2748 let truncated = crate::utils::truncate_with_ellipsis(&entries, entry_width.max(20), "...");
2749 lines.push(Line::from(vec![
2750 Span::raw(" "),
2751 Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
2752 ]));
2753 if preview.omitted > 0 {
2754 lines.push(Line::from(vec![
2755 Span::raw(" "),
2756 Span::styled(
2757 format!("... {} more", preview.omitted),
2758 Style::default().fg(palette::TEXT_HINT),
2759 ),
2760 ]));
2761 }
2762 }
2763
2764 fn push_shell_command_lines(
2765 lines: &mut Vec<Line<'static>>,
2766 label: &str,
2767 command_lines: &[String],
2768 command_width: usize,
2769 max_rows: Option<usize>,
2770 ) {
2771 lines.push(Line::from(vec![
2772 Span::raw(" "),
2773 Span::styled(
2774 format!("{label}:"),
2775 Style::default()
2776 .fg(palette::WHALE_ACTION)
2777 .add_modifier(Modifier::BOLD),
2778 ),
2779 ]));
2780
2781 let mut rendered = 0usize;
2782 for line in command_lines {
2783 for wrapped in wrap_text(line, command_width) {
2784 if max_rows.is_some_and(|limit| rendered >= limit) {
2785 lines.push(Line::from(vec![
2786 Span::raw(" "),
2787 Span::styled(
2788 "...",
2789 Style::default()
2790 .fg(palette::TEXT_HINT)
2791 .add_modifier(Modifier::BOLD),
2792 ),
2793 ]));
2794 return;
2795 }
2796 lines.push(Line::from(vec![
2797 Span::raw(" "),
2798 Span::styled(
2799 wrapped,
2800 Style::default()
2801 .fg(palette::TEXT_BODY)
2802 .add_modifier(Modifier::BOLD),
2803 ),
2804 ]));
2805 rendered += 1;
2806 }
2807 }
2808 }
2809
2810 /// Put one representative command/change first for compact inline rendering.
2811 /// This is a display-only projection: approval parameters and the details
2812 /// pager retain the exact original order.
2813 fn prioritize_inline_shell_lines(
2814 command_lines: &[String],
2815 is_change_preview: bool,
2816 compact: bool,
2817 ) -> Vec<String> {
2818 if !compact || command_lines.len() < 2 {
2819 return command_lines.to_vec();
2820 }
2821
2822 let representative = if is_change_preview {
2823 command_lines
2824 .iter()
2825 .enumerate()
2826 .max_by_key(|(index, line)| (preview_line_priority(line), std::cmp::Reverse(*index)))
2827 .map(|(index, _)| index)
2828 } else {
2829 command_lines
2830 .iter()
2831 .enumerate()
2832 .max_by_key(|(index, line)| (command_line_priority(line), std::cmp::Reverse(*index)))
2833 .map(|(index, _)| index)
2834 };
2835 let Some(representative) = representative.filter(|index| *index > 0) else {
2836 return command_lines.to_vec();
2837 };
2838
2839 let mut projected = Vec::with_capacity(command_lines.len());
2840 projected.push(command_lines[representative].clone());
2841 projected.extend(
2842 command_lines
2843 .iter()
2844 .enumerate()
2845 .filter(|(index, _)| *index != representative)
2846 .map(|(_, line)| line.clone()),
2847 );
2848 projected
2849 }
2850
2851 fn preview_line_priority(line: &str) -> u8 {
2852 let trimmed = line.trim_start();
2853 if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
2854 4
2855 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
2856 3
2857 } else if trimmed.starts_with("@@") {
2858 2
2859 } else if trimmed.starts_with("diff ")
2860 || trimmed.starts_with("---")
2861 || trimmed.starts_with("+++")
2862 {
2863 0
2864 } else {
2865 1
2866 }
2867 }
2868
2869 fn command_line_priority(line: &str) -> u8 {
2870 let trimmed = line.trim();
2871 if trimmed.is_empty() || trimmed.starts_with('#') {
2872 return 0;
2873 }
2874
2875 let tokens = trimmed
2876 .split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '(' | ')'))
2877 .filter(|token| !token.is_empty())
2878 .map(|token| token.rsplit('/').next().unwrap_or(token))
2879 .collect::<Vec<_>>();
2880 if tokens.iter().any(|token| {
2881 matches!(
2882 *token,
2883 "rm" | "rmdir"
2884 | "unlink"
2885 | "mv"
2886 | "dd"
2887 | "chmod"
2888 | "chown"
2889 | "kill"
2890 | "pkill"
2891 | "shutdown"
2892 | "reboot"
2893 | "mkfs"
2894 )
2895 }) || tokens.windows(2).any(|pair| {
2896 matches!(
2897 pair,
2898 ["git", "push"] | ["cargo", "publish"] | ["npm", "publish"]
2899 )
2900 }) || trimmed.contains('>')
2901 {
2902 return 4;
2903 }
2904
2905 let first = tokens.first().copied().unwrap_or_default();
2906 if matches!(
2907 first,
2908 "cd" | "pushd" | "popd" | "set" | "export" | "unset" | "pwd" | ":" | "true"
2909 ) {
2910 1
2911 } else if matches!(first, "echo" | "printf") {
2912 2
2913 } else {
2914 3
2915 }
2916 }
2917
2918 fn push_destructive_approval_semantics(
2919 lines: &mut Vec<Line<'static>>,
2920 locale: Locale,
2921 compact: bool,
2922 ) {
2923 if compact {
2924 let (label, value) = destructive_approval_compact_semantics(locale);
2925 lines.push(Line::from(vec![
2926 Span::raw(" "),
2927 Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
2928 Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
2929 ]));
2930 return;
2931 }
2932
2933 for (label, value) in destructive_approval_semantics(locale) {
2934 lines.push(Line::from(vec![
2935 Span::raw(" "),
2936 Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
2937 Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
2938 ]));
2939 }
2940 }
2941
2942 fn destructive_approval_compact_semantics(locale: Locale) -> (&'static str, &'static str) {
2943 match locale {
2944 Locale::ZhHans => ("规则: ", "批准策略要求确认;拒绝跳过本次,Esc 中止整轮。"),
2945 _ => (
2946 "Policy: ",
2947 "Approval policy requires review; d denies, Esc aborts.",
2948 ),
2949 }
2950 }
2951
2952 fn destructive_approval_semantics(locale: Locale) -> [(&'static str, &'static str); 2] {
2953 match locale {
2954 Locale::ZhHans => [
2955 (
2956 "规则: ",
2957 "当前批准策略、审查规则或显式询问规则要求用户确认。",
2958 ),
2959 ("取消: ", "拒绝只跳过本次工具调用;Esc 会中止整轮。"),
2960 ],
2961 _ => [
2962 (
2963 "Policy: ",
2964 "The active approval policy, a review rule, or an explicit ask-rule requires confirmation.",
2965 ),
2966 (
2967 "Cancel: ",
2968 "Deny rejects only this tool call; Esc aborts the whole turn.",
2969 ),
2970 ],
2971 }
2972 }
2973
2974 fn footer_controls(locale: Locale) -> Cow<'static, str> {
2975 // Platform-aware details chord (⌥V on macOS, Alt+V elsewhere). Bare `v`
2976 // is never advertised as a details shortcut (TUI-DOG-002).
2977 let details = crate::tui::shell_key_routing::tool_details_chord();
2978 Cow::Owned(tr(locale, MessageId::ApprovalControlsHint).replace("{details}", details.as_ref()))
2979 }
2980
2981 fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> {
2982 tr(locale, MessageId::ApprovalSaveAskRuleHint)
2983 }
2984
2985 #[derive(Clone)]
2986 struct ApprovalOptionRow {
2987 label: Cow<'static, str>,
2988 key_hint: &'static str,
2989 dangerous: bool,
2990 }
2991
2992 fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] {
2993 let dangerous = matches!(risk, RiskLevel::Destructive);
2994 [
2995 ApprovalOptionRow {
2996 label: option_approve_once(locale),
2997 key_hint: "1 / y",
2998 dangerous,
2999 },
3000 ApprovalOptionRow {
3001 label: option_approve_always(locale),
3002 key_hint: "2 / a",
3003 dangerous,
3004 },
3005 ApprovalOptionRow {
3006 label: option_deny(locale),
3007 key_hint: "3 / d / n",
3008 dangerous: false,
3009 },
3010 ApprovalOptionRow {
3011 label: option_abort(locale),
3012 key_hint: "Esc",
3013 dangerous: false,
3014 },
3015 ]
3016 }
3017
3018 /// Workflow elevated-plan card options (#4126): Approve / Edit plan / Cancel.
3019 fn workflow_approval_options(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] {
3020 let dangerous = matches!(risk, RiskLevel::Destructive);
3021 [
3022 ApprovalOptionRow {
3023 label: workflow_option_approve(locale),
3024 key_hint: "1 / y",
3025 dangerous,
3026 },
3027 ApprovalOptionRow {
3028 label: workflow_option_edit_plan(locale),
3029 key_hint: "2 / e",
3030 dangerous: false,
3031 },
3032 ApprovalOptionRow {
3033 label: workflow_option_cancel(locale),
3034 key_hint: "3 / Esc",
3035 dangerous: false,
3036 },
3037 ]
3038 }
3039
3040 fn approval_options_for_request(
3041 request: &ApprovalRequest,
3042 risk: RiskLevel,
3043 locale: Locale,
3044 ) -> Vec<ApprovalOptionRow> {
3045 if request.tool_name == "workflow" {
3046 workflow_approval_options(risk, locale).to_vec()
3047 } else {
3048 let mut options = approval_options_for(risk, locale).to_vec();
3049 if request.can_save_allow_rule() {
3050 options.insert(
3051 2,
3052 ApprovalOptionRow {
3053 label: tr(locale, MessageId::ApprovalOptionAllowExactRepo),
3054 key_hint: "p",
3055 dangerous: false,
3056 },
3057 );
3058 }
3059 options
3060 }
3061 }
3062
3063 fn workflow_option_approve(locale: Locale) -> Cow<'static, str> {
3064 match locale {
3065 Locale::ZhHans => Cow::Borrowed("批准"),
3066 _ => Cow::Borrowed("Approve"),
3067 }
3068 }
3069
3070 fn workflow_option_edit_plan(locale: Locale) -> Cow<'static, str> {
3071 match locale {
3072 Locale::ZhHans => Cow::Borrowed("编辑计划"),
3073 _ => Cow::Borrowed("Edit plan"),
3074 }
3075 }
3076
3077 fn workflow_option_cancel(locale: Locale) -> Cow<'static, str> {
3078 match locale {
3079 Locale::ZhHans => Cow::Borrowed("取消"),
3080 _ => Cow::Borrowed("Cancel"),
3081 }
3082 }
3083
3084 fn option_approve_once(locale: Locale) -> Cow<'static, str> {
3085 tr(locale, MessageId::ApprovalOptionApproveOnce)
3086 }
3087
3088 fn option_approve_always(locale: Locale) -> Cow<'static, str> {
3089 tr(locale, MessageId::ApprovalOptionApproveAlways)
3090 }
3091
3092 fn option_deny(locale: Locale) -> Cow<'static, str> {
3093 tr(locale, MessageId::ApprovalOptionDeny)
3094 }
3095
3096 fn option_abort(locale: Locale) -> Cow<'static, str> {
3097 tr(locale, MessageId::ApprovalOptionAbortTurn)
3098 }
3099
3100 pub struct ElevationWidget<'a> {
3101 request: &'a ElevationRequest,
3102 selected: usize,
3103 locale: Locale,
3104 hitboxes: Option<&'a std::cell::RefCell<Vec<Rect>>>,
3105 }
3106
3107 impl<'a> ElevationWidget<'a> {
3108 #[expect(dead_code)]
3109 pub fn new(request: &'a ElevationRequest, selected: usize, locale: Locale) -> Self {
3110 Self {
3111 request,
3112 selected,
3113 locale,
3114 hitboxes: None,
3115 }
3116 }
3117
3118 pub fn new_with_hitboxes(
3119 request: &'a ElevationRequest,
3120 selected: usize,
3121 locale: Locale,
3122 hitboxes: &'a std::cell::RefCell<Vec<Rect>>,
3123 ) -> Self {
3124 Self {
3125 request,
3126 selected,
3127 locale,
3128 hitboxes: Some(hitboxes),
3129 }
3130 }
3131 }
3132
3133 impl Renderable for ElevationWidget<'_> {
3134 fn render(&self, area: Rect, buf: &mut Buffer) {
3135 use codewhale_localization::MessageId;
3136 use codewhale_localization::tr;
3137
3138 let popup_width = 70.min(area.width.saturating_sub(4));
3139
3140 let mut lines = vec![
3141 Line::from(""),
3142 Line::from(vec![Span::styled(
3143 tr(self.locale, MessageId::ElevationTitleSandboxDenied),
3144 Style::default()
3145 .fg(palette::STATUS_ERROR)
3146 .add_modifier(Modifier::BOLD),
3147 )]),
3148 Line::from(""),
3149 Line::from(vec![
3150 Span::raw(tr(self.locale, MessageId::ElevationFieldTool)),
3151 Span::styled(
3152 &self.request.tool_name,
3153 Style::default()
3154 .fg(palette::WHALE_ACTION)
3155 .add_modifier(Modifier::BOLD),
3156 ),
3157 ]),
3158 ];
3159
3160 if let Some(ref command) = self.request.command {
3161 let cmd_display = crate::utils::truncate_with_ellipsis(command, 45, "...");
3162 lines.push(Line::from(vec![
3163 Span::raw(tr(self.locale, MessageId::ElevationFieldCmd)),
3164 Span::styled(cmd_display, Style::default().fg(palette::TEXT_MUTED)),
3165 ]));
3166 }
3167
3168 lines.push(Line::from(""));
3169 lines.push(Line::from(vec![
3170 Span::raw(tr(self.locale, MessageId::ElevationFieldReason)),
3171 Span::styled(
3172 &self.request.denial_reason,
3173 Style::default().fg(palette::STATUS_WARNING),
3174 ),
3175 ]));
3176
3177 lines.push(Line::from(""));
3178 lines.push(Line::from(Span::styled(
3179 tr(self.locale, MessageId::ElevationImpactHeader),
3180 Style::default().fg(palette::TEXT_MUTED),
3181 )));
3182 if self
3183 .request
3184 .options
3185 .iter()
3186 .any(|option| matches!(option, ElevationOption::WithNetwork))
3187 {
3188 lines.push(Line::from(Span::styled(
3189 tr(self.locale, MessageId::ElevationImpactNetwork),
3190 Style::default().fg(palette::TEXT_PRIMARY),
3191 )));
3192 }
3193 if self
3194 .request
3195 .options
3196 .iter()
3197 .any(|option| matches!(option, ElevationOption::WithWriteAccess(_)))
3198 {
3199 lines.push(Line::from(Span::styled(
3200 tr(self.locale, MessageId::ElevationImpactWrite),
3201 Style::default().fg(palette::TEXT_PRIMARY),
3202 )));
3203 }
3204 lines.push(Line::from(Span::styled(
3205 tr(self.locale, MessageId::ElevationImpactFullAccess),
3206 Style::default().fg(palette::TEXT_PRIMARY),
3207 )));
3208 lines.push(Line::from(""));
3209 lines.push(Line::from(Span::styled(
3210 tr(self.locale, MessageId::ElevationPromptProceed),
3211 Style::default().fg(palette::TEXT_MUTED),
3212 )));
3213 lines.push(Line::from(""));
3214
3215 let option_start = lines.len();
3216 for (i, option) in self.request.options.iter().enumerate() {
3217 let is_selected = i == self.selected;
3218 let style = if is_selected {
3219 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
3220 } else {
3221 Style::default()
3222 };
3223
3224 let (key, label_id, desc_id) = match option {
3225 ElevationOption::WithNetwork => (
3226 "n",
3227 MessageId::ElevationOptionNetwork,
3228 MessageId::ElevationOptionNetworkDesc,
3229 ),
3230 ElevationOption::WithWriteAccess(_) => (
3231 "w",
3232 MessageId::ElevationOptionWrite,
3233 MessageId::ElevationOptionWriteDesc,
3234 ),
3235 ElevationOption::FullAccess => (
3236 "f",
3237 MessageId::ElevationOptionFullAccess,
3238 MessageId::ElevationOptionFullAccessDesc,
3239 ),
3240 ElevationOption::Abort => (
3241 "a",
3242 MessageId::ElevationOptionAbort,
3243 MessageId::ElevationOptionAbortDesc,
3244 ),
3245 };
3246
3247 let label_color = match option {
3248 ElevationOption::Abort => palette::TEXT_MUTED,
3249 ElevationOption::FullAccess => palette::STATUS_ERROR,
3250 _ => palette::TEXT_PRIMARY,
3251 };
3252
3253 lines.push(Line::from(vec![
3254 Span::raw(" "),
3255 Span::styled(
3256 format!("[{key}] "),
3257 Style::default().fg(palette::STATUS_SUCCESS),
3258 ),
3259 Span::styled(tr(self.locale, label_id), style.fg(label_color)),
3260 ]));
3261 lines.push(Line::from(vec![
3262 Span::raw(" "),
3263 Span::styled(
3264 tr(self.locale, desc_id),
3265 Style::default().fg(palette::TEXT_MUTED),
3266 ),
3267 ]));
3268 }
3269
3270 // Reserve the options before the explanation. `Abort` is the last row of
3271 // that list, so a card sized to its preamble hides the safe exit with no
3272 // scroll rail and no hint that anything is missing. The denial detail is
3273 // what gets shortened; the choices never do.
3274 //
3275 // `Padding::uniform(1)` inside `Borders::ALL` costs two rows and two
3276 // columns on each axis.
3277 const CHROME: u16 = 4;
3278 let inner_width = popup_width.saturating_sub(CHROME);
3279 let max_inner_height = area.height.saturating_sub(2).saturating_sub(CHROME);
3280
3281 let mut option_lines = lines.split_off(option_start);
3282 // Each option is a label row followed by a description row. On a terminal
3283 // too small for both, the description is chrome and the choice is
3284 // content, so the descriptions go first and every option keeps its row.
3285 let mut rows_per_option = 2usize;
3286 if measure_wrapped_rows(&option_lines, inner_width) > max_inner_height {
3287 option_lines = option_lines
3288 .into_iter()
3289 .enumerate()
3290 .filter_map(|(idx, line)| (idx % 2 == 0).then_some(line))
3291 .collect();
3292 rows_per_option = 1;
3293 }
3294 let option_rows = measure_wrapped_rows(&option_lines, inner_width);
3295 // Trim the denial detail down to the title rather than the option list.
3296 let mut truncated = false;
3297 while lines.len() > 2
3298 && measure_wrapped_rows(&lines, inner_width).saturating_add(option_rows)
3299 > max_inner_height
3300 {
3301 lines.pop();
3302 truncated = true;
3303 }
3304 if truncated {
3305 lines.push(Line::from(Span::styled(
3306 approval_truncation_hint(self.locale),
3307 Style::default().fg(palette::TEXT_MUTED),
3308 )));
3309 }
3310
3311 // Row offsets are measured after wrapping, not counted in source lines:
3312 // a description that wraps used to push every hitbox below it out of
3313 // step with the row the pointer was actually over.
3314 let option_row_offsets = {
3315 let mut offsets = Vec::with_capacity(self.request.options.len());
3316 let mut row = measure_wrapped_rows(&lines, inner_width);
3317 for pair in option_lines.chunks(rows_per_option) {
3318 let height = measure_wrapped_rows(pair, inner_width);
3319 offsets.push((row, height));
3320 row = row.saturating_add(height);
3321 }
3322 offsets
3323 };
3324 lines.extend(option_lines);
3325
3326 let popup_height = measure_wrapped_rows(&lines, inner_width)
3327 .saturating_add(CHROME)
3328 .min(area.height.saturating_sub(2));
3329 let popup_area = Rect {
3330 x: (area.width.saturating_sub(popup_width)) / 2,
3331 y: (area.height.saturating_sub(popup_height)) / 2,
3332 width: popup_width,
3333 height: popup_height,
3334 };
3335
3336 Clear.render(popup_area, buf);
3337
3338 let title = tr(self.locale, MessageId::ElevationTitleRequired);
3339 let block = Block::default()
3340 .title(title)
3341 .borders(Borders::ALL)
3342 .border_style(Style::default().fg(palette::BORDER_COLOR))
3343 .style(Style::default().bg(palette::WHALE_BG))
3344 .padding(Padding::uniform(1));
3345
3346 if let Some(hitboxes) = self.hitboxes {
3347 hitboxes.borrow_mut().clear();
3348 let content = block.inner(popup_area);
3349 let content_bottom = content.y.saturating_add(content.height);
3350 for (offset, rows) in option_row_offsets {
3351 let y = content.y.saturating_add(offset);
3352 let height = rows.min(content_bottom.saturating_sub(y));
3353 if height > 0 {
3354 hitboxes
3355 .borrow_mut()
3356 .push(Rect::new(content.x, y, content.width, height));
3357 }
3358 }
3359 }
3360
3361 let paragraph = Paragraph::new(lines)
3362 .block(block)
3363 .wrap(Wrap { trim: false });
3364
3365 paragraph.render(popup_area, buf);
3366 }
3367
3368 fn desired_height(&self, _width: u16) -> u16 {
3369 1
3370 }
3371 }
3372
3373 fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
3374 let Some((start, end)) = app.viewport.transcript_selection.ordered_endpoints() else {
3375 return;
3376 };
3377
3378 let selection_style = Style::default()
3379 .bg(app.ui_theme.selection_bg)
3380 .fg(palette::SELECTION_TEXT);
3381
3382 for (idx, line) in lines.iter_mut().enumerate() {
3383 let line_index = top + idx;
3384 if line_index < start.line_index || line_index > end.line_index {
3385 continue;
3386 }
3387
3388 let (col_start, col_end) = if start.line_index == end.line_index {
3389 (start.column, end.column)
3390 } else if line_index == start.line_index {
3391 (start.column, usize::MAX)
3392 } else if line_index == end.line_index {
3393 (0, end.column)
3394 } else {
3395 (0, usize::MAX)
3396 };
3397
3398 if col_start == 0 && col_end == usize::MAX {
3399 for span in &mut line.spans {
3400 span.style = span.style.patch(selection_style);
3401 }
3402 continue;
3403 }
3404
3405 line.spans = apply_selection_to_line(line, col_start, col_end, selection_style);
3406 }
3407 }
3408
3409 fn apply_detail_target_highlight(
3410 lines: &mut [Line<'static>],
3411 top: usize,
3412 target_cell: usize,
3413 line_meta: &[TranscriptLineMeta],
3414 original_index_map: &[usize],
3415 ) {
3416 let highlight_bg = Color::Reset;
3417 for (idx, line) in lines.iter_mut().enumerate() {
3418 let line_index = top + idx;
3419 if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
3420 && original_index_map
3421 .get(*cell_index)
3422 .copied()
3423 .unwrap_or(*cell_index)
3424 == target_cell
3425 {
3426 for span in &mut line.spans {
3427 span.style = span.style.bg(highlight_bg);
3428 }
3429 }
3430 }
3431 }
3432
3433 /// Apply a brief background tint to the last user message's visible lines.
3434 fn apply_send_flash(
3435 lines: &mut [Line<'static>],
3436 top: usize,
3437 history: &[HistoryCell],
3438 line_meta: &[TranscriptLineMeta],
3439 original_index_map: &[usize],
3440 ) {
3441 // Find the last User cell index.
3442 let last_user_cell = history
3443 .iter()
3444 .rposition(|cell| matches!(cell, HistoryCell::User { .. }));
3445 let Some(target_cell) = last_user_cell else {
3446 return;
3447 };
3448
3449 let flash_bg = palette::SURFACE_TOOL_ACTIVE; // subtle dark-blue tint
3450
3451 for (idx, line) in lines.iter_mut().enumerate() {
3452 let line_index = top + idx;
3453 if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
3454 && original_index_map
3455 .get(*cell_index)
3456 .copied()
3457 .unwrap_or(*cell_index)
3458 == target_cell
3459 {
3460 for span in &mut line.spans {
3461 span.style = span.style.bg(flash_bg);
3462 }
3463 }
3464 }
3465 }
3466
3467 fn apply_selection_to_line(
3468 line: &Line<'static>,
3469 col_start: usize,
3470 col_end: usize,
3471 selection_style: Style,
3472 ) -> Vec<Span<'static>> {
3473 let mut result = Vec::with_capacity(line.spans.len().saturating_add(2));
3474 let mut current_col = 0usize;
3475
3476 for span in &line.spans {
3477 let span_text: &str = span.content.as_ref();
3478 let span_width = text_display_width(span_text);
3479 let span_end = current_col.saturating_add(span_width);
3480
3481 if span_end <= col_start || current_col >= col_end {
3482 result.push(span.clone());
3483 } else if current_col >= col_start && span_end <= col_end {
3484 result.push(Span::styled(
3485 span.content.clone(),
3486 span.style.patch(selection_style),
3487 ));
3488 } else {
3489 let mut before = String::new();
3490 let mut selected = String::new();
3491 let mut after = String::new();
3492 let mut grapheme_col = current_col;
3493
3494 for grapheme in span_text.graphemes(true) {
3495 let grapheme_width = grapheme_display_width(grapheme);
3496 let grapheme_start = grapheme_col;
3497 let grapheme_end = grapheme_col.saturating_add(grapheme_width);
3498 if grapheme_end <= col_start {
3499 before.push_str(grapheme);
3500 } else if grapheme_start >= col_end {
3501 after.push_str(grapheme);
3502 } else {
3503 selected.push_str(grapheme);
3504 }
3505 grapheme_col = grapheme_end;
3506 }
3507
3508 if !before.is_empty() {
3509 result.push(Span::styled(before, span.style));
3510 }
3511 if !selected.is_empty() {
3512 result.push(Span::styled(selected, span.style.patch(selection_style)));
3513 }
3514 if !after.is_empty() {
3515 result.push(Span::styled(after, span.style));
3516 }
3517 }
3518
3519 current_col = span_end;
3520 }
3521
3522 result
3523 }
3524
3525 /// The "fully idle" predicate: nothing in the transcript, nothing running,
3526 /// nothing pending. It gates the idle ocean, and — because the idle ocean has
3527 /// a row floor the layout has to respect — it also gates how many rows the
3528 /// work rail is allowed to take. Evaluate it *once* per frame in
3529 /// [`crate::tui::ui::render`] and thread the result, so the reservation and
3530 /// the render can never disagree inside a single frame.
3531 pub(crate) fn should_render_empty_state(app: &App) -> bool {
3532 if app.launch.visible && app.launch.return_to_session {
3533 return true;
3534 }
3535 let active_is_empty = app
3536 .active_cell
3537 .as_ref()
3538 .is_none_or(crate::tui::active_cell::ActiveCell::is_empty);
3539 app.history.is_empty()
3540 && active_is_empty
3541 && !app.is_loading
3542 && !app.is_compacting
3543 && !app.is_purging
3544 && !app.attention_hold_active()
3545 && !app
3546 .task_panel
3547 .iter()
3548 .any(|task| task.kind == crate::tui::app::TaskPanelEntryKind::Background)
3549 // Live work suppresses the empty state. On lock contention, treat
3550 // the todo store as non-empty rather than flash the empty ocean.
3551 && !app
3552 .todos
3553 .try_lock()
3554 .map(|todos| !todos.snapshot().is_empty())
3555 .unwrap_or(true)
3556 && app.goal.objective.is_none()
3557 && app.paused_goal_objective.is_none()
3558 }
3559
3560 fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
3561 crate::tui::underwater::empty_state_lines(app, area)
3562 }
3563
3564 pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize {
3565 usize::from(inner_height).saturating_sub(extra_lines).max(1)
3566 }
3567
3568 fn composer_top_padding(content_lines: usize, rows_budget: usize) -> usize {
3569 crate::tui::composer_chrome::top_padding(content_lines, rows_budget)
3570 }
3571
3572 /// Placeholder text shown when the composer input is empty.
3573 #[cfg(test)]
3574 const COMPOSER_PLACEHOLDER: &str = "Write a task or use /.";
3575
3576 /// How many visual rows the empty-input placeholder occupies after wrapping.
3577 #[cfg(test)]
3578 fn placeholder_visual_lines(content_width: usize) -> usize {
3579 placeholder_visual_lines_for(COMPOSER_PLACEHOLDER, content_width)
3580 }
3581
3582 #[cfg(test)]
3583 fn placeholder_visual_lines_for(placeholder: &str, content_width: usize) -> usize {
3584 wrap_text(placeholder, content_width).len().max(1)
3585 }
3586
3587 pub(crate) fn composer_empty_hint_text(app: &App) -> Cow<'static, str> {
3588 if let Some(placeholder) = crate::tui::agent_focus::composer_placeholder(app) {
3589 Cow::Owned(placeholder)
3590 } else if app.is_history_search_active() {
3591 app.tr(MessageId::HistorySearchPlaceholder)
3592 } else if app.is_loading
3593 && !app.offline_mode
3594 && app.queued_draft.is_none()
3595 && !app.queued_messages.is_empty()
3596 {
3597 app.tr(MessageId::ComposerPlaceholderSendNow)
3598 } else if app.is_loading {
3599 app.tr(MessageId::ComposerPlaceholderFollowUp)
3600 } else {
3601 app.tr(MessageId::ComposerPlaceholder)
3602 }
3603 }
3604
3605 /// Live label for what portable bare Enter will do with the current draft.
3606 ///
3607 /// The quiet composer and the enclosed panel share this so the action is
3608 /// visible before submit (#4703) without teaching internal "steer" vocabulary.
3609 #[derive(Debug, Clone, PartialEq, Eq)]
3610 pub(crate) struct ComposerSubmitHint {
3611 pub text: String,
3612 pub color: Color,
3613 }
3614
3615 pub(crate) fn composer_submit_hint(app: &App) -> Option<ComposerSubmitHint> {
3616 use crate::tui::app::{ComposerSubmitAction, ComposerSubmitChord, SubmitDisposition};
3617
3618 let queue_count = app.queued_message_count();
3619 let (text, color) = match app.decide_composer_submit(ComposerSubmitChord::Enter) {
3620 ComposerSubmitAction::Submit(SubmitDisposition::Immediate) => {
3621 if queue_count == 0 {
3622 return None;
3623 }
3624 (
3625 app.tr(MessageId::ComposerHintSendWithQueue)
3626 .replace("{count}", &queue_count.to_string()),
3627 palette::WHALE_ACTION,
3628 )
3629 }
3630 ComposerSubmitAction::Submit(SubmitDisposition::Queue)
3631 | ComposerSubmitAction::Submit(SubmitDisposition::QueueFollowUp) => {
3632 if app.offline_mode {
3633 let id = if app.onboarding_explore_offline {
3634 MessageId::ComposerHintOfflineConnect
3635 } else {
3636 MessageId::ComposerHintOfflineQueue
3637 };
3638 (app.tr(id).into_owned(), palette::STATUS_WARNING)
3639 } else if queue_count > 0 {
3640 (
3641 app.tr(MessageId::ComposerHintQueueWithCount)
3642 .replace("{count}", &queue_count.saturating_add(1).to_string()),
3643 palette::WHALE_ACTION,
3644 )
3645 } else {
3646 (
3647 app.tr(MessageId::ComposerHintQueue).into_owned(),
3648 palette::WHALE_ACTION,
3649 )
3650 }
3651 }
3652 ComposerSubmitAction::Submit(SubmitDisposition::Steer) => (
3653 app.tr(MessageId::ComposerHintSendIntoTurn).into_owned(),
3654 palette::WHALE_ACTION,
3655 ),
3656 ComposerSubmitAction::SendQueuedNow => (
3657 app.tr(MessageId::ComposerHintSendNow).into_owned(),
3658 palette::WHALE_ACTION,
3659 ),
3660 ComposerSubmitAction::Noop => return None,
3661 };
3662 Some(ComposerSubmitHint { text, color })
3663 }
3664
3665 pub(crate) fn empty_composer_visual_rows(
3666 _hint: Option<&str>,
3667 _content_width: usize,
3668 _rows_budget: usize,
3669 ) -> usize {
3670 1
3671 }
3672
3673 fn composer_max_height(density: ComposerDensity) -> u16 {
3674 crate::tui::composer_chrome::ComposerChrome::for_density(density, false).max_total_rows
3675 }
3676
3677 fn composer_height(
3678 input: &str,
3679 area_width: u16,
3680 available_height: u16,
3681 extra_lines: usize,
3682 density: ComposerDensity,
3683 show_panel: bool,
3684 ) -> u16 {
3685 let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height);
3686 // Measure through the same border- and submit-aware plane that rendering,
3687 // cursor placement, the frame viewport, and mouse mapping use. A draft
3688 // that wraps here therefore cannot consume the painted `[↵]` cells later.
3689 let measurement_area = Rect::new(0, 0, area_width, if has_panel { 3 } else { 1 });
3690 let content_width =
3691 composer_content_geometry(composer_inner_area(measurement_area, has_panel), false)
3692 .text_width();
3693 let mut line_count = wrap_input_lines(input, content_width).len();
3694 if line_count == 0 {
3695 line_count = 1;
3696 }
3697 crate::tui::composer_chrome::desired_height(
3698 line_count,
3699 extra_lines,
3700 available_height,
3701 density,
3702 has_panel,
3703 )
3704 }
3705
3706 /// A single entry in the slash-command autocomplete popup.
3707 pub(crate) struct SlashMenuEntry {
3708 pub name: String,
3709 pub description: String,
3710 pub is_skill: bool,
3711 /// Matching pinyin/alias prefix hint, e.g. when user types `/bang` and
3712 /// the command `/help` matches via alias `bangzhu`.
3713 pub alias_hint: Option<String>,
3714 }
3715
3716 /// Check if all characters in `needle` appear in `haystack` in order
3717 /// (subsequence matching — fuzzy filtering).
3718 fn fuzzy_chars_in_order(needle: &str, haystack: &str) -> bool {
3719 let mut chars = needle.chars();
3720 let mut current = match chars.next() {
3721 Some(c) => c,
3722 None => return true,
3723 };
3724 for ch in haystack.chars() {
3725 if ch == current {
3726 if let Some(next) = chars.next() {
3727 current = next;
3728 } else {
3729 return true;
3730 }
3731 }
3732 }
3733 false
3734 }
3735
3736 #[cfg(test)]
3737 pub(crate) fn slash_completion_hints(
3738 input: &str,
3739 limit: usize,
3740 cached_skills: &[(String, String)],
3741 locale: codewhale_localization::Locale,
3742 workspace: Option<&std::path::Path>,
3743 api_provider: ApiProvider,
3744 ) -> Vec<SlashMenuEntry> {
3745 let model_candidates = all_catalog_models_for_provider(api_provider);
3746 slash_completion_hints_with_model_candidates(
3747 input,
3748 limit,
3749 cached_skills,
3750 locale,
3751 workspace,
3752 &model_candidates,
3753 )
3754 }
3755
3756 /// Slash-menu rows for `/<command> ` and `/<command> <partial>`.
3757 ///
3758 /// Once the name is typed the menu used to go blank, so `/workspace
3759 /// worktrees` — the only route to the git worktree manager — was unfindable
3760 /// without already knowing it (#5952). The rows come from the registry's own
3761 /// `usage` string: the usage line itself as the head row, then the literal
3762 /// subcommands that line declares, filtered by what has been typed. There is
3763 /// no second place argument documentation is written down.
3764 fn command_argument_hints(trimmed_input: &str, limit: usize) -> Vec<SlashMenuEntry> {
3765 if limit == 0 {
3766 return Vec::new();
3767 }
3768 let Some((command_token, rest)) = trimmed_input
3769 .trim_start_matches('/')
3770 .split_once(char::is_whitespace)
3771 else {
3772 return Vec::new();
3773 };
3774 let Some(info) = commands::get_command_info(command_token) else {
3775 return Vec::new();
3776 };
3777 if !info.show_in_slash_completion(command_token) {
3778 return Vec::new();
3779 }
3780 // Only the first argument word is a subcommand. Once a second word is
3781 // being typed the usage line has nothing left to offer, so the menu gets
3782 // out of the way exactly as it does today.
3783 let arg_prefix = rest.trim_start();
3784 if arg_prefix.contains(char::is_whitespace) {
3785 return Vec::new();
3786 }
3787 let arg_prefix_lower = arg_prefix.to_ascii_lowercase();
3788
3789 let canonical = format!("/{}", info.name);
3790 let mut entries: Vec<SlashMenuEntry> = Vec::new();
3791 // The head row states what the command accepts. Its name is the command
3792 // itself, so selecting it re-inserts what is already typed — the row can
3793 // be arrowed through without losing the argument being written. It is
3794 // dropped once filtering starts so Tab still completes a single match.
3795 if arg_prefix.is_empty() && info.usage != canonical {
3796 entries.push(SlashMenuEntry {
3797 name: canonical.clone(),
3798 description: info.usage.to_string(),
3799 is_skill: false,
3800 alias_hint: None,
3801 });
3802 }
3803 for subcommand in info.subcommands() {
3804 if !subcommand.starts_with(&arg_prefix_lower) {
3805 continue;
3806 }
3807 entries.push(SlashMenuEntry {
3808 name: format!("{canonical} {subcommand}"),
3809 // The subcommand is the whole row: the command's own description
3810 // is already one row up on the head row, and repeating it beside
3811 // every verb would say the same sentence a dozen times.
3812 description: String::new(),
3813 is_skill: false,
3814 alias_hint: None,
3815 });
3816 }
3817 entries.truncate(limit);
3818 entries
3819 }
3820
3821 pub(crate) fn slash_completion_hints_with_model_candidates(
3822 input: &str,
3823 limit: usize,
3824 cached_skills: &[(String, String)],
3825 locale: codewhale_localization::Locale,
3826 workspace: Option<&std::path::Path>,
3827 model_candidates: &[String],
3828 ) -> Vec<SlashMenuEntry> {
3829 if !super::app::looks_like_slash_command_input(input) {
3830 return Vec::new();
3831 }
3832
3833 let trimmed = input.trim_start();
3834 // `$skillname` mode: only skill completions, prefixed with `$`.
3835 if trimmed.starts_with('$') {
3836 let prefix = trimmed.trim_start_matches('$').to_ascii_lowercase();
3837 let mut entries: Vec<SlashMenuEntry> = Vec::new();
3838 for (skill_name, skill_desc) in cached_skills {
3839 let skill_name_lower = skill_name.to_ascii_lowercase();
3840 if skill_name_lower.starts_with(&prefix)
3841 || skill_name_lower.contains(&prefix)
3842 || fuzzy_chars_in_order(&prefix, &skill_name_lower)
3843 {
3844 entries.push(SlashMenuEntry {
3845 name: format!("${skill_name}"),
3846 description: skill_desc.clone(),
3847 is_skill: true,
3848 alias_hint: None,
3849 });
3850 }
3851 }
3852 entries.sort_by(|a, b| a.name.cmp(&b.name));
3853 entries.dedup_by(|a, b| a.name == b.name);
3854 return entries.into_iter().take(limit).collect();
3855 }
3856
3857 let prefix = input.trim_start_matches('/');
3858 let completing_skill_arg = prefix.strip_prefix("skill ").map(str::trim_start);
3859 let completing_model_arg = prefix.strip_prefix("model ").map(str::trim_start);
3860 if input.contains(char::is_whitespace)
3861 && completing_skill_arg.is_none()
3862 && completing_model_arg.is_none()
3863 {
3864 return command_argument_hints(trimmed, limit);
3865 }
3866 let mut entries: Vec<SlashMenuEntry> = Vec::new();
3867 let prefix_lower = prefix.to_ascii_lowercase();
3868
3869 // ── Phase 1: prefix (starts_with) matches ─────────────────────────
3870 // Highest priority — preserves existing exact-prefix completion.
3871 if completing_skill_arg.is_none() && completing_model_arg.is_none() {
3872 commands::user_registry::with_registry_for_workspace(workspace, |registry| {
3873 let all_user_commands = registry.iter().collect::<Vec<_>>();
3874 let user_commands = all_user_commands
3875 .iter()
3876 .copied()
3877 .filter(|cmd| !cmd.hidden)
3878 .collect::<Vec<_>>();
3879 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3880
3881 for name in
3882 all_command_names_matching_loaded(prefix, &user_commands, &all_user_commands)
3883 {
3884 seen.insert(name.clone());
3885 let command_key = name.trim_start_matches('/');
3886 push_command_entry(
3887 &mut entries,
3888 &name,
3889 command_key,
3890 &prefix_lower,
3891 locale,
3892 &all_user_commands,
3893 );
3894 }
3895
3896 // ── Phase 2: contains (substring) matches ─────────────────────────
3897 // Medium priority — broader catching.
3898 for cmd in commands::command_infos() {
3899 let name = format!("/{}", cmd.name);
3900 if seen.contains(&name) {
3901 continue;
3902 }
3903 let cmd_lower = cmd.name.to_ascii_lowercase();
3904 let name_match = cmd_lower.contains(&prefix_lower);
3905 let alias_matches =
3906 |alias: &str| alias.to_ascii_lowercase().contains(&prefix_lower);
3907 if builtin_visible_for_completion_match(
3908 cmd,
3909 &all_user_commands,
3910 &prefix_lower,
3911 name_match,
3912 alias_matches,
3913 ) {
3914 seen.insert(name.clone());
3915 push_command_entry(
3916 &mut entries,
3917 &name,
3918 cmd.name,
3919 &prefix_lower,
3920 locale,
3921 &all_user_commands,
3922 );
3923 }
3924 }
3925 for cmd in &user_commands {
3926 let name = format!("/{}", cmd.name);
3927 if seen.contains(&name) {
3928 continue;
3929 }
3930 let alias_match = cmd.aliases.iter().any(|a| a.contains(&prefix_lower));
3931 if cmd.name.contains(&prefix_lower) || alias_match {
3932 seen.insert(name.clone());
3933 push_command_entry(
3934 &mut entries,
3935 &name,
3936 &cmd.name,
3937 &prefix_lower,
3938 locale,
3939 &all_user_commands,
3940 );
3941 }
3942 }
3943
3944 // ── Phase 3: fuzzy subsequence matches ────────────────────────────
3945 // Lowest priority — characters in order, not necessarily consecutive.
3946 for cmd in commands::command_infos() {
3947 let name = format!("/{}", cmd.name);
3948 if seen.contains(&name) {
3949 continue;
3950 }
3951 let cmd_lower = cmd.name.to_ascii_lowercase();
3952 let name_match = fuzzy_chars_in_order(&prefix_lower, &cmd_lower);
3953 let alias_matches = |alias: &str| fuzzy_chars_in_order(&prefix_lower, alias);
3954 if builtin_visible_for_completion_match(
3955 cmd,
3956 &all_user_commands,
3957 &prefix_lower,
3958 name_match,
3959 alias_matches,
3960 ) {
3961 seen.insert(name.clone());
3962 push_command_entry(
3963 &mut entries,
3964 &name,
3965 cmd.name,
3966 &prefix_lower,
3967 locale,
3968 &all_user_commands,
3969 );
3970 }
3971 }
3972 for cmd in &user_commands {
3973 let name = format!("/{}", cmd.name);
3974 if seen.contains(&name) {
3975 continue;
3976 }
3977 let alias_match = cmd
3978 .aliases
3979 .iter()
3980 .any(|a| fuzzy_chars_in_order(&prefix_lower, a));
3981 if fuzzy_chars_in_order(&prefix_lower, &cmd.name) || alias_match {
3982 seen.insert(name.clone());
3983 push_command_entry(
3984 &mut entries,
3985 &name,
3986 &cmd.name,
3987 &prefix_lower,
3988 locale,
3989 &all_user_commands,
3990 );
3991 }
3992 }
3993 });
3994 }
3995
3996 // ── Skills (only after user has typed `/skill `) ──────────────────
3997 // `/model <prefix>` is the only slash-argument path that needs the
3998 // provider inventory. Filter it here instead of rebuilding that inventory
3999 // for every generic slash-menu keystroke.
4000 if let Some(model_prefix) = completing_model_arg {
4001 let model_prefix = model_prefix.to_ascii_lowercase();
4002 for model_name in model_candidates {
4003 let lower = model_name.to_ascii_lowercase();
4004 if lower.starts_with(&model_prefix)
4005 || lower.contains(&model_prefix)
4006 || fuzzy_chars_in_order(&model_prefix, &lower)
4007 {
4008 entries.push(SlashMenuEntry {
4009 name: format!("/model {model_name}"),
4010 description: String::from("Switch to this model"),
4011 is_skill: false,
4012 alias_hint: None,
4013 });
4014 }
4015 }
4016 }
4017
4018 let skill_prefix = completing_skill_arg.unwrap_or(prefix).to_ascii_lowercase();
4019 if completing_skill_arg.is_some() {
4020 for (skill_name, skill_desc) in cached_skills {
4021 let skill_name_lower = skill_name.to_ascii_lowercase();
4022 if skill_name_lower.starts_with(&skill_prefix) {
4023 entries.push(SlashMenuEntry {
4024 name: format!("/skill {skill_name}"),
4025 description: skill_desc.clone(),
4026 is_skill: true,
4027 alias_hint: None,
4028 });
4029 }
4030 }
4031 // Skills: contains fuzzy fallback
4032 for (skill_name, skill_desc) in cached_skills {
4033 let skill_name_lower = skill_name.to_ascii_lowercase();
4034 if skill_name_lower.contains(&skill_prefix)
4035 && !entries
4036 .iter()
4037 .any(|e| e.name == format!("/skill {skill_name}"))
4038 {
4039 entries.push(SlashMenuEntry {
4040 name: format!("/skill {skill_name}"),
4041 description: skill_desc.clone(),
4042 is_skill: true,
4043 alias_hint: None,
4044 });
4045 }
4046 }
4047 for (skill_name, skill_desc) in cached_skills {
4048 let skill_name_lower = skill_name.to_ascii_lowercase();
4049 if !skill_name_lower.starts_with(&skill_prefix)
4050 && !skill_name_lower.contains(&skill_prefix)
4051 && fuzzy_chars_in_order(&skill_prefix, &skill_name_lower)
4052 {
4053 entries.push(SlashMenuEntry {
4054 name: format!("/skill {skill_name}"),
4055 description: skill_desc.clone(),
4056 is_skill: true,
4057 alias_hint: None,
4058 });
4059 }
4060 }
4061 }
4062
4063 // Special: /model <name> completions when only /model matches
4064 if entries.iter().any(|e| e.name == "/model") && prefix_lower.eq_ignore_ascii_case("model") {
4065 for model_name in model_candidates {
4066 entries.push(SlashMenuEntry {
4067 name: format!("/model {model_name}"),
4068 description: String::from("Switch to this model"),
4069 is_skill: false,
4070 alias_hint: None,
4071 });
4072 }
4073 }
4074
4075 // A bare slash is an invitation, not a manual — but an invitation you
4076 // cannot walk past is a dead end. The small task-oriented set is sorted
4077 // to the head below (`root_rank`) instead of being the only thing kept,
4078 // so the first six rows are unchanged and arrowing down reaches every
4079 // other command. Founder live-test: "I like how we prioritize the slash
4080 // thing but it should still be able to find all of them."
4081 if prefix_lower.is_empty() {
4082 // Skills are the exception, and for a different reason: they are user
4083 // content with their own triggers (`$name`, `/skill`), and there can
4084 // be hundreds. Commands are what this menu is for.
4085 entries.retain(|entry| !entry.is_skill);
4086 for entry in &mut entries {
4087 if entry.name == "/subagents" {
4088 entry.name = "/agents".to_string();
4089 entry.alias_hint = None;
4090 }
4091 }
4092 }
4093
4094 // Rank exact-alias matches above prefix/alias matches so e.g. typing
4095 // `/q` ranks `/exit` (alias `q` is an exact hit) above `/clear` (alias
4096 // `qingping` only matches by prefix). Inside each tier, fall back to
4097 // alphabetical name order for deterministic display (#1811).
4098 let rank = |entry: &SlashMenuEntry| -> u8 {
4099 if entry.is_skill {
4100 return 3;
4101 }
4102 let command_key = entry.name.trim_start_matches('/');
4103 if command_key.eq_ignore_ascii_case(&prefix_lower) {
4104 return 0;
4105 }
4106 if let Some(info) = commands::get_command_info(command_key)
4107 && info
4108 .aliases
4109 .iter()
4110 .any(|a| a.eq_ignore_ascii_case(&prefix_lower))
4111 {
4112 return 0;
4113 }
4114 if command_key.to_ascii_lowercase().starts_with(&prefix_lower) {
4115 return 1;
4116 }
4117 2
4118 };
4119 // Bare `/` follows the deliberately short task sequence. Typed prefixes
4120 // keep the existing rank/alpha order.
4121 let root_rank = |entry: &SlashMenuEntry| -> usize {
4122 if !prefix_lower.is_empty() {
4123 return 0;
4124 }
4125 let command_key = entry.name.trim_start_matches('/');
4126 commands::traits::bare_slash_discovery_rank(command_key).unwrap_or(usize::MAX)
4127 };
4128 entries.sort_by(|a, b| {
4129 root_rank(a)
4130 .cmp(&root_rank(b))
4131 .then_with(|| rank(a).cmp(&rank(b)))
4132 .then_with(|| a.name.cmp(&b.name))
4133 });
4134 entries.dedup_by(|a, b| a.name == b.name);
4135 entries.into_iter().take(limit).collect()
4136 }
4137
4138 fn all_command_names_matching_loaded(
4139 prefix: &str,
4140 user_commands: &[&commands::user_registry::UserCommandMetadata],
4141 all_user_commands: &[&commands::user_registry::UserCommandMetadata],
4142 ) -> Vec<String> {
4143 let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
4144 let mut result: Vec<String> = commands::command_infos()
4145 .iter()
4146 .filter(|cmd| {
4147 builtin_visible_for_completion_match(
4148 cmd,
4149 all_user_commands,
4150 &prefix,
4151 cmd.name.starts_with(&prefix),
4152 |alias| alias.starts_with(&prefix),
4153 )
4154 })
4155 .map(|cmd| format!("/{}", cmd.name))
4156 .collect();
4157
4158 result.extend(user_commands.iter().filter_map(|command| {
4159 let name_matches = command.name.starts_with(&prefix);
4160 let alias_matches = command
4161 .aliases
4162 .iter()
4163 .any(|alias| alias.starts_with(&prefix));
4164 (name_matches || alias_matches).then(|| format!("/{}", command.name))
4165 }));
4166
4167 result.sort();
4168 result.dedup();
4169 result
4170 }
4171
4172 fn builtin_visible_for_completion_match(
4173 builtin: &commands::CommandInfo,
4174 user_commands: &[&commands::user_registry::UserCommandMetadata],
4175 prefix: &str,
4176 canonical_name_matches: bool,
4177 alias_matches: impl Fn(&str) -> bool,
4178 ) -> bool {
4179 if !builtin.show_in_slash_completion(prefix) {
4180 return false;
4181 }
4182
4183 if commands::discovery::user_command_shadows_builtin_canonical(builtin, user_commands) {
4184 return false;
4185 }
4186
4187 // Keep the canonical built-in visible when the typed text matches the
4188 // canonical name, even if a user command shadows one of the built-in's
4189 // aliases. Example: a user command with alias `/image` must not hide
4190 // canonical `/attach` for `/att`.
4191 if canonical_name_matches {
4192 return true;
4193 }
4194
4195 // If the built-in is visible only through an alias, hide it when that
4196 // specific alias is shadowed by a user command. Example: `/image` should
4197 // complete to the user command, not built-in `/attach` via its `/image`
4198 // alias.
4199 builtin.aliases.iter().any(|alias| {
4200 alias_matches(alias)
4201 && !commands::discovery::user_command_shadows_builtin_alias(alias, user_commands)
4202 })
4203 }
4204
4205 /// Push a built-in command entry to the slash menu, resolving description
4206 /// and alias hints.
4207 fn push_command_entry(
4208 entries: &mut Vec<SlashMenuEntry>,
4209 name: &str,
4210 command_key: &str,
4211 prefix_lower: &str,
4212 locale: codewhale_localization::Locale,
4213 user_commands: &[&commands::user_registry::UserCommandMetadata],
4214 ) {
4215 let user_command = user_commands
4216 .iter()
4217 .find(|command| command.name == command_key);
4218
4219 let (description, alias_hint) = if let Some(command) = user_command {
4220 // User command shadows any built-in — use user metadata.
4221 let mut description = command
4222 .description
4223 .clone()
4224 .unwrap_or_else(|| String::from("User-defined command"));
4225 if let Some(hint) = command.display_usage() {
4226 description.push_str(" ");
4227 description.push_str(hint);
4228 }
4229 let alias_hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
4230 command
4231 .aliases
4232 .iter()
4233 .find(|alias| {
4234 alias.starts_with(prefix_lower)
4235 || alias.contains(prefix_lower)
4236 || fuzzy_chars_in_order(prefix_lower, alias)
4237 })
4238 .cloned()
4239 } else {
4240 None
4241 };
4242 (description, alias_hint)
4243 } else if let Some(info) = commands::get_command_info(command_key) {
4244 let unshadowed_aliases = info
4245 .aliases
4246 .iter()
4247 .copied()
4248 .filter(|alias| {
4249 !commands::discovery::user_command_shadows_builtin_alias(alias, user_commands)
4250 })
4251 .collect::<Vec<_>>();
4252 let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
4253 unshadowed_aliases
4254 .iter()
4255 .copied()
4256 .find(|a| {
4257 a.to_ascii_lowercase().starts_with(prefix_lower)
4258 || a.to_ascii_lowercase().contains(prefix_lower)
4259 || fuzzy_chars_in_order(prefix_lower, &a.to_ascii_lowercase())
4260 })
4261 .map(str::to_string)
4262 } else {
4263 None
4264 };
4265 // Omit aliases already shown in the label (`/clear or /qingping`) so
4266 // the description does not repeat them (#3990).
4267 let remaining_aliases: Vec<&str> = unshadowed_aliases
4268 .into_iter()
4269 .filter(|alias| hint.as_deref() != Some(*alias))
4270 .collect();
4271 let desc = if prefix_lower.is_empty() || remaining_aliases.is_empty() {
4272 info.description_for(locale).to_string()
4273 } else {
4274 format!(
4275 "{} (aliases: {})",
4276 info.description_for(locale),
4277 remaining_aliases
4278 .iter()
4279 .map(|a| format!("/{a}"))
4280 .collect::<Vec<_>>()
4281 .join(", ")
4282 )
4283 };
4284 (desc, hint)
4285 } else {
4286 (String::from("User-defined command"), None)
4287 };
4288 entries.push(SlashMenuEntry {
4289 name: name.to_string(),
4290 description,
4291 is_skill: false,
4292 alias_hint,
4293 });
4294 }
4295
4296 fn layout_input(
4297 input: &str,
4298 cursor: usize,
4299 width: usize,
4300 max_height: usize,
4301 ) -> (Vec<String>, usize, usize) {
4302 let (visible, visible_cursor_row, visible_cursor_col, _) =
4303 layout_input_with_scroll(input, cursor, width, max_height);
4304 (visible, visible_cursor_row, visible_cursor_col)
4305 }
4306
4307 pub fn layout_input_with_scroll(
4308 input: &str,
4309 cursor: usize,
4310 width: usize,
4311 max_height: usize,
4312 ) -> (Vec<String>, usize, usize, usize) {
4313 let mut lines = wrap_input_lines(input, width);
4314 if lines.is_empty() {
4315 lines.push(String::new());
4316 }
4317 let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
4318
4319 let max_height = max_height.max(1);
4320 let mut start = 0usize;
4321 if cursor_row >= max_height {
4322 start = cursor_row + 1 - max_height;
4323 }
4324 if start + max_height > lines.len() {
4325 start = lines.len().saturating_sub(max_height);
4326 }
4327 let visible = lines
4328 .into_iter()
4329 .skip(start)
4330 .take(max_height)
4331 .collect::<Vec<_>>();
4332 let visible_cursor_row = cursor_row.saturating_sub(start);
4333
4334 (
4335 visible,
4336 visible_cursor_row,
4337 cursor_col.min(width.saturating_sub(1)),
4338 start,
4339 )
4340 }
4341
4342 /// Extended version of `layout_input_with_scroll` that also returns character
4343 /// indices for each wrapped line. Used by ComposerWidget to avoid redundant
4344 /// wrapping when rendering text selections.
4345 fn layout_input_with_scroll_and_char_indices(
4346 input: &str,
4347 cursor: usize,
4348 width: usize,
4349 max_height: usize,
4350 ) -> (Vec<String>, usize, usize, usize, Vec<(usize, String)>) {
4351 let (all_lines, all_with_indices) = wrap_input_lines_internal(input, width);
4352
4353 let lines = if all_lines.is_empty() {
4354 vec![String::new()]
4355 } else {
4356 all_lines
4357 };
4358
4359 let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
4360
4361 let max_height = max_height.max(1);
4362 let mut start = 0usize;
4363 if cursor_row >= max_height {
4364 start = cursor_row + 1 - max_height;
4365 }
4366 if start + max_height > lines.len() {
4367 start = lines.len().saturating_sub(max_height);
4368 }
4369 let visible = lines
4370 .into_iter()
4371 .skip(start)
4372 .take(max_height)
4373 .collect::<Vec<_>>();
4374 let visible_cursor_row = cursor_row.saturating_sub(start);
4375
4376 // Also slice the char indices to match visible lines
4377 let visible_with_indices = all_with_indices
4378 .into_iter()
4379 .skip(start)
4380 .take(max_height)
4381 .collect();
4382
4383 (
4384 visible,
4385 visible_cursor_row,
4386 cursor_col.min(width.saturating_sub(1)),
4387 start,
4388 visible_with_indices,
4389 )
4390 }
4391
4392 fn cursor_row_col(input: &str, cursor: usize, width: usize) -> (usize, usize) {
4393 // Derive the cursor's row/col from the SAME wrapped lines the renderer
4394 // draws. An earlier version recomputed wrapping here with hard margin
4395 // breaks while wrap_text broke on word boundaries, so the two disagreed on
4396 // row count: a long paste landed one row short of its marker, and the
4397 // caret drifted behind fast typing. Walking the actual wrapped lines makes
4398 // a desync impossible by construction (regression introduced in ff97641b7).
4399 let (_, lines_with_indices) = wrap_input_lines_internal(input, width.max(1));
4400 cursor_row_col_in_lines(&lines_with_indices, cursor)
4401 }
4402
4403 /// Map a char-index cursor onto wrapped lines tagged with their starting char
4404 /// index, as produced by wrap_input_lines_internal. The row is the line whose
4405 /// char range contains the cursor; the column is the display width of that
4406 /// line up to the cursor. Because wrap_text emits a trailing empty line when a
4407 /// line fills exactly to the width, a cursor at the end of a full line lands
4408 /// on that empty line (row+1, col 0), the display convention callers rely on,
4409 /// without any special case here.
4410 fn cursor_row_col_in_lines(
4411 lines_with_indices: &[(usize, String)],
4412 cursor: usize,
4413 ) -> (usize, usize) {
4414 let mut row = 0usize;
4415 let mut line_start = 0usize;
4416 let mut line: &str = "";
4417 let mut found = false;
4418 for (i, (start, l)) in lines_with_indices.iter().enumerate() {
4419 if *start <= cursor {
4420 row = i;
4421 line_start = *start;
4422 line = l.as_str();
4423 found = true;
4424 } else {
4425 break;
4426 }
4427 }
4428 if !found {
4429 return (0, 0);
4430 }
4431 let offset = cursor.saturating_sub(line_start);
4432 let byte_end = line
4433 .char_indices()
4434 .nth(offset)
4435 .map(|(b, _)| b)
4436 .unwrap_or(line.len());
4437 let col = visible_str_width(&line[..byte_end]);
4438 (row, col)
4439 }
4440
4441 /// Internal helper that returns both wrapped lines and character indices.
4442 /// Used by `wrap_input_lines`, `wrap_input_lines_for_mouse`, and
4443 /// `layout_input_with_scroll` to avoid redundant wrapping computations.
4444 fn wrap_input_lines_internal(input: &str, width: usize) -> (Vec<String>, Vec<(usize, String)>) {
4445 let mut lines = Vec::new();
4446 let mut lines_with_indices = Vec::new();
4447 let mut char_idx = 0usize;
4448
4449 if input.is_empty() {
4450 lines_with_indices.push((0, String::new()));
4451 return (lines, lines_with_indices);
4452 }
4453
4454 for raw_line in input.split('\n') {
4455 if raw_line.is_empty() {
4456 lines.push(String::new());
4457 if width != 0 {
4458 lines_with_indices.push((char_idx, String::new()));
4459 }
4460 char_idx += 1; // the '\n'
4461 continue;
4462 }
4463
4464 let wrapped = wrap_text(raw_line, width);
4465 if wrapped.is_empty() {
4466 lines.push(String::new());
4467 if width != 0 {
4468 lines_with_indices.push((char_idx, String::new()));
4469 }
4470 } else {
4471 for wrapped_line in &wrapped {
4472 let line_char_len: usize = wrapped_line.chars().count();
4473 lines.push(wrapped_line.clone());
4474 if width != 0 {
4475 lines_with_indices.push((char_idx, wrapped_line.clone()));
4476 }
4477 char_idx += line_char_len;
4478 }
4479 }
4480 char_idx += 1; // the '\n'
4481 }
4482
4483 (lines, lines_with_indices)
4484 }
4485
4486 fn wrap_input_lines(input: &str, width: usize) -> Vec<String> {
4487 let (lines, _) = wrap_input_lines_internal(input, width);
4488 lines
4489 }
4490
4491 /// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs
4492 /// matching the wrapping produced by `wrap_input_lines`.
4493 pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> {
4494 if input.is_empty() || width == 0 {
4495 return vec![(0, String::new())];
4496 }
4497
4498 let (_, lines_with_indices) = wrap_input_lines_internal(input, width);
4499 lines_with_indices
4500 }
4501
4502 /// Wrap composer text to `width` display columns, breaking at word boundaries
4503 /// where one is available.
4504 ///
4505 /// This used to break strictly on the grapheme that crossed the margin, so a
4506 /// wrapped sentence split mid-word — `…Write the file onl` / `y after the…`.
4507 /// The text was never lost, but a line ending in a severed word reads exactly
4508 /// like content that was cut off, which is what it was reported as.
4509 ///
4510 /// Two invariants the callers depend on and this must not break:
4511 ///
4512 /// * **Nothing is added or removed.** Concatenating the returned lines
4513 /// reproduces `text` exactly. `wrap_input_lines_internal` walks the wrapped
4514 /// lines accumulating `chars().count()` to map cursor and mouse positions
4515 /// back into the raw buffer, so a dropped break character would silently
4516 /// desynchronise the caret. The space a line breaks on therefore stays at
4517 /// the end of the preceding line rather than being swallowed.
4518 /// * **Every line fits.** A word longer than `width` — a URL, a path, a
4519 /// base64 blob — has no usable break point and still breaks hard.
4520 ///
4521 /// Display width as painted: ratatui strips control characters, so they
4522 /// occupy no cells. Non-control graphemes keep plain unicode width, matching
4523 /// the long-standing wrap/click/caret contract.
4524 pub(crate) fn visible_grapheme_width(grapheme: &str) -> usize {
4525 if grapheme.chars().any(|c| c.is_control()) {
4526 0
4527 } else {
4528 grapheme.width()
4529 }
4530 }
4531
4532 /// Plain unicode width with painted control handling: strip control
4533 /// graphemes first so emoji and wide-glyph measurement keeps the exact
4534 /// [`UnicodeWidthStr`] semantics on the remainder.
4535 fn visible_str_width(text: &str) -> usize {
4536 text.graphemes(true)
4537 .filter(|grapheme| !grapheme.chars().any(|c| c.is_control()))
4538 .collect::<String>()
4539 .width()
4540 }
4541
4542 fn wrap_text(text: &str, width: usize) -> Vec<String> {
4543 if width == 0 {
4544 return vec![text.to_string()];
4545 }
4546 if text.is_empty() {
4547 return vec![String::new()];
4548 }
4549
4550 let mut lines = Vec::new();
4551 let mut current = String::new();
4552 let mut current_width = 0;
4553 // Byte offset in `current` just past the most recent space, and the
4554 // display width up to that point. `None` while the line holds no usable
4555 // break point — a leading space is not one, since breaking there would
4556 // emit an empty line and make no progress.
4557 let mut break_at: Option<(usize, usize)> = None;
4558
4559 // Flush `current` up to its break point (if any), carrying the remainder
4560 // onto the next line.
4561 macro_rules! flush {
4562 () => {{
4563 match break_at.take() {
4564 Some((byte, _)) if byte < current.len() => {
4565 let remainder = current.split_off(byte);
4566 lines.push(std::mem::replace(&mut current, remainder));
4567 current_width = visible_str_width(&current);
4568 }
4569 _ => {
4570 lines.push(std::mem::take(&mut current));
4571 current_width = 0;
4572 }
4573 }
4574 }};
4575 }
4576
4577 for grapheme in text.graphemes(true) {
4578 if grapheme == "\n" {
4579 break_at = None;
4580 lines.push(std::mem::take(&mut current));
4581 current_width = 0;
4582 continue;
4583 }
4584
4585 let grapheme_width = visible_grapheme_width(grapheme);
4586 if current_width + grapheme_width > width && current_width != 0 {
4587 flush!();
4588 }
4589
4590 current.push_str(grapheme);
4591 current_width += grapheme_width;
4592 if grapheme == " " && !current.trim_start().is_empty() {
4593 break_at = Some((current.len(), current_width));
4594 }
4595
4596 if current_width >= width {
4597 flush!();
4598 }
4599 }
4600
4601 lines.push(current);
4602 lines
4603 }
4604
4605 fn line_spans_with_selection<'a>(
4606 line: &'a str,
4607 line_start: usize,
4608 line_end: usize,
4609 sel_start: usize,
4610 sel_end: usize,
4611 highlight_bg: Color,
4612 ) -> Vec<Span<'a>> {
4613 let normal_style = Style::default().fg(palette::TEXT_PRIMARY);
4614 let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg);
4615
4616 // No overlap between this line and the selection
4617 if line_end <= sel_start || line_start >= sel_end {
4618 return vec![Span::styled(line, normal_style)];
4619 }
4620
4621 let local_sel_start = sel_start.saturating_sub(line_start);
4622 let local_sel_end = sel_end.min(line_end).saturating_sub(line_start);
4623
4624 // Build a Vec of byte offsets for each char boundary, plus one past the end.
4625 let mut byte_offsets: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
4626 byte_offsets.push(line.len());
4627
4628 let b0 = byte_offsets
4629 .get(local_sel_start)
4630 .copied()
4631 .unwrap_or(line.len());
4632 let b1 = byte_offsets
4633 .get(local_sel_end)
4634 .copied()
4635 .unwrap_or(line.len());
4636
4637 let mut spans = Vec::with_capacity(3);
4638
4639 // Text before selection
4640 if b0 > 0 {
4641 spans.push(Span::styled(&line[..b0], normal_style));
4642 }
4643 // Selected text
4644 if b1 > b0 {
4645 spans.push(Span::styled(&line[b0..b1], sel_style));
4646 }
4647 // Text after selection
4648 if b1 < line.len() {
4649 spans.push(Span::styled(&line[b1..], normal_style));
4650 }
4651
4652 spans
4653 }
4654
4655 #[cfg(test)]
4656 mod tests {
4657 use super::{
4658 ACTIVE_REVISION_DOMAIN, ApprovalWidget, COMPOSER_PANEL_HEIGHT, COMPOSER_PLACEHOLDER,
4659 ChatWidget, ComposerWidget, Renderable, SlashMenuEntry, active_composer_submit_rect,
4660 active_entry_revision, apply_detail_target_highlight, apply_selection_to_line,
4661 apply_send_flash, approval_palette, approval_truncation_hint, build_empty_state_lines,
4662 composer_content_geometry, composer_empty_hint_text, composer_height, composer_inner_area,
4663 composer_max_height, composer_submit_hint, composer_top_padding, cursor_row_col,
4664 empty_composer_visual_rows, enclosed_composer_panel_fits, fish_flee_offset, fish_heading,
4665 fish_mark, history_entry_revision, layout_input, layout_input_with_scroll,
4666 placeholder_visual_lines, push_command_entry, receipt_is_settling, revision_in_domain,
4667 should_render_empty_state, slash_completion_hints, tool_run_summary_revision,
4668 wrap_input_lines, wrap_input_lines_for_mouse, wrap_text,
4669 };
4670 use crate::config::{ApiProvider, Config};
4671 use crate::tui::active_cell::ActiveCell;
4672 use crate::tui::app::{
4673 App, ComposerDensity, QueuedMessage, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode,
4674 TranscriptSpacing, TuiOptions,
4675 };
4676 use crate::tui::history::{
4677 ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus,
4678 };
4679 use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll};
4680 use codewhale_localization::Locale;
4681 use codewhale_palette as palette;
4682 use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4683 use ratatui::{
4684 buffer::Buffer,
4685 layout::Rect,
4686 style::{Color, Modifier, Style},
4687 text::{Line, Span},
4688 };
4689 use std::{path::PathBuf, time::Instant};
4690 use unicode_width::UnicodeWidthStr;
4691
4692 fn create_test_app() -> App {
4693 let options = TuiOptions {
4694 model: "deepseek-v4-flash".to_string(),
4695 start_in_agent_mode: true,
4696 ..crate::test_support::test_tui_options(PathBuf::from("."))
4697 };
4698 let mut app = App::new(options, &Config::default());
4699 // Widget contracts below exercise the post-Startup conversation
4700 // surface. Startup rendering has its own explicit fixture/tests.
4701 app.launch.visible = false;
4702 app.ui_locale = Locale::En;
4703 app.composer.vim_enabled = false;
4704 // Most widget fixtures exercise the underwater theme's field. Other
4705 // themes keep the terminal-owned shell; keep tests that inspect fish
4706 // and caustics intentional rather than coupled to that choice.
4707 app.theme_id = codewhale_palette::ThemeId::Underwater;
4708 app.ui_theme = palette::UNDERWATER_UI_THEME;
4709 app
4710 }
4711
4712 fn buffer_text(buf: &Buffer, area: Rect) -> String {
4713 let mut text = String::new();
4714 for y in area.y..area.y.saturating_add(area.height) {
4715 for x in area.x..area.x.saturating_add(area.width) {
4716 text.push_str(buf[(x, y)].symbol());
4717 }
4718 text.push('\n');
4719 }
4720 text
4721 }
4722
4723 #[test]
4724 fn approval_palette_reserves_signal_gold_for_human_decisions() {
4725 use crate::tui::approval::ApprovalStakes;
4726
4727 let routine = approval_palette(ApprovalStakes::Routine);
4728 let elevated = approval_palette(ApprovalStakes::Elevated);
4729 let critical = approval_palette(ApprovalStakes::Critical);
4730
4731 assert_eq!(routine.accent, palette::WHALE_HUMAN);
4732 assert_eq!(routine.shortcut, palette::WHALE_ACTION);
4733 assert_eq!(elevated.border, palette::WHALE_HUMAN);
4734 assert_eq!(elevated.accent, palette::WHALE_HUMAN);
4735 assert_eq!(critical.accent, palette::WHALE_ERROR);
4736 }
4737
4738 #[test]
4739 fn first_active_tool_settles_when_flushed_to_history() {
4740 let mut app = create_test_app();
4741 app.clear_history();
4742 app.next_history_revision = 1;
4743 app.active_cell_revision = 0;
4744
4745 let mut active = ActiveCell::new();
4746 active.push_tool("user_shell_1", running_user_shell_cell());
4747 app.active_cell = Some(active);
4748
4749 let area = Rect::new(0, 0, 100, 20);
4750 let mut running_buf = Buffer::empty(area);
4751 ChatWidget::new(&mut app, area).render(area, &mut running_buf);
4752 let running = buffer_text(&running_buf, area);
4753 assert!(running.contains("run running"), "{running}");
4754
4755 app.finalize_active_cell_as_interrupted();
4756 let HistoryCell::Tool(ToolCell::Exec(exec)) = &app.history[0] else {
4757 panic!("expected settled exec history cell")
4758 };
4759 assert_eq!(exec.status, ToolStatus::Failed);
4760
4761 let mut settled_buf = Buffer::empty(area);
4762 ChatWidget::new(&mut app, area).render(area, &mut settled_buf);
4763 let settled = buffer_text(&settled_buf, area);
4764 assert!(
4765 !settled.contains("run running"),
4766 "flushed terminal state reused the active cache entry:\n{settled}"
4767 );
4768 assert!(settled.contains("run issue"), "{settled}");
4769 }
4770
4771 fn render_approval_request(
4772 request: &crate::tui::approval::ApprovalRequest,
4773 area: Rect,
4774 ) -> String {
4775 let view = crate::tui::approval::ApprovalView::new(request.clone());
4776 let widget = ApprovalWidget::new(request, &view);
4777 let mut buf = Buffer::empty(area);
4778 widget.render(area, &mut buf);
4779 buffer_text(&buf, area)
4780 }
4781
4782 fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
4783 let mut text = String::new();
4784 for x in area.x..area.x.saturating_add(area.width) {
4785 text.push_str(buf[(x, row)].symbol());
4786 }
4787 text
4788 }
4789
4790 fn success_tool_cell(name: &str) -> HistoryCell {
4791 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
4792 name: name.to_string(),
4793 status: ToolStatus::Success,
4794 input_summary: Some(format!("path: {name}.txt")),
4795 output: Some(format!("full output from {name}")),
4796 prompts: None,
4797 spillover_path: None,
4798 output_summary: None,
4799 is_diff: false,
4800 }))
4801 }
4802
4803 fn running_user_shell_cell() -> HistoryCell {
4804 HistoryCell::Tool(ToolCell::Exec(ExecCell {
4805 command: "sleep 30".to_string(),
4806 status: ToolStatus::Running,
4807 output: None,
4808 live_output: None,
4809 shell_task_id: None,
4810 owner_agent_id: None,
4811 owner_agent_name: None,
4812 started_at: None,
4813 duration_ms: None,
4814 stale_elapsed_since_output_ms: None,
4815 source: ExecSource::User,
4816 interaction: None,
4817 output_summary: None,
4818 }))
4819 }
4820
4821 fn add_dense_tool_run(app: &mut App) {
4822 app.add_message(success_tool_cell("read_file"));
4823 app.add_message(success_tool_cell("list_dir"));
4824 app.add_message(success_tool_cell("web_search"));
4825 }
4826
4827 fn spacer_rows_after_transcript_cell(app: &App, target_cell: usize) -> usize {
4828 let mut saw_target = false;
4829 let mut spacer_rows = 0;
4830 for meta in app.viewport.transcript_cache.line_meta() {
4831 match meta {
4832 TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
4833 saw_target = true;
4834 spacer_rows = 0;
4835 }
4836 TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
4837 TranscriptLineMeta::CellLine { .. } if saw_target => break,
4838 TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
4839 }
4840 }
4841 spacer_rows
4842 }
4843
4844 #[test]
4845 fn chat_widget_breathes_between_groups_without_padding_tool_rows_at_any_width() {
4846 for (width, height) in [(40, 8), (120, 12)] {
4847 let mut app = create_test_app();
4848 app.low_motion = true;
4849 app.fancy_animations = false;
4850 app.transcript_spacing = TranscriptSpacing::Comfortable;
4851
4852 for turn in 0..4 {
4853 app.add_message(HistoryCell::User {
4854 content: format!("turn {turn}: inspect the release receipts"),
4855 });
4856 app.add_message(HistoryCell::Assistant {
4857 content: format!("I will inspect receipt group {turn}."),
4858 streaming: false,
4859 });
4860 app.add_message(success_tool_cell(&format!("read_{turn}")));
4861 app.add_message(success_tool_cell(&format!("verify_{turn}")));
4862 app.add_message(HistoryCell::Assistant {
4863 content: format!("receipt group {turn} is complete"),
4864 streaming: false,
4865 });
4866 }
4867
4868 let area = Rect::new(0, 0, width, height);
4869 app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
4870 let mut top_buf = Buffer::empty(area);
4871 ChatWidget::new(&mut app, area).render(area, &mut top_buf);
4872
4873 assert_eq!(app.viewport.last_transcript_top, 0, "width={width}");
4874 assert!(
4875 app.viewport.last_transcript_total > usize::from(height),
4876 "fixture must scroll at width={width}"
4877 );
4878 assert_eq!(
4879 spacer_rows_after_transcript_cell(&app, 0),
4880 1,
4881 "the top-level user turn needs a breathing row at width={width}"
4882 );
4883 assert_eq!(
4884 spacer_rows_after_transcript_cell(&app, 1),
4885 1,
4886 "answer to tool-group transition needs a breathing row at width={width}"
4887 );
4888 assert_eq!(
4889 spacer_rows_after_transcript_cell(&app, 2),
4890 0,
4891 "calls inside one tool group must stay compact at width={width}"
4892 );
4893 assert_eq!(
4894 spacer_rows_after_transcript_cell(&app, 3),
4895 1,
4896 "the completed tool group needs a breathing row at width={width}"
4897 );
4898 assert!(
4899 buffer_text(&top_buf, area).contains("turn 0"),
4900 "top scroll source drifted at width={width}"
4901 );
4902
4903 let total = app.viewport.last_transcript_total;
4904 app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
4905 let mut tail_buf = Buffer::empty(area);
4906 ChatWidget::new(&mut app, area).render(area, &mut tail_buf);
4907
4908 assert_eq!(app.viewport.last_transcript_total, total, "width={width}");
4909 assert!(app.viewport.last_transcript_top > 0, "width={width}");
4910 assert!(
4911 buffer_text(&tail_buf, area).contains("receipt group 3 is complete"),
4912 "tail scroll lost the final source-backed cell at width={width}"
4913 );
4914 assert!(
4915 app.viewport
4916 .transcript_cache
4917 .line_meta()
4918 .iter()
4919 .all(|meta| match meta {
4920 TranscriptLineMeta::CellLine { cell_index, .. } => {
4921 *cell_index < app.history.len()
4922 }
4923 TranscriptLineMeta::Spacer { .. } => true,
4924 }),
4925 "spacing rows must not invent source-cell ownership at width={width}"
4926 );
4927 }
4928 }
4929
4930 #[test]
4931 fn send_flash_uses_original_index_map_for_collapsed_rows() {
4932 let history = vec![
4933 success_tool_cell("read_file"),
4934 success_tool_cell("list_dir"),
4935 HistoryCell::User {
4936 content: "sent".to_string(),
4937 },
4938 ];
4939 let mut lines = vec![Line::from("sent")];
4940 let line_meta = vec![TranscriptLineMeta::CellLine {
4941 cell_index: 0,
4942 line_in_cell: 0,
4943 copy_prefix_width: 0,
4944 copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
4945 }];
4946 let original_index_map = vec![2];
4947
4948 apply_send_flash(&mut lines, 0, &history, &line_meta, &original_index_map);
4949
4950 assert_eq!(
4951 lines[0].spans[0].style.bg,
4952 Some(palette::SURFACE_TOOL_ACTIVE)
4953 );
4954 }
4955
4956 #[test]
4957 fn detail_highlight_uses_original_index_map_for_collapsed_rows() {
4958 let mut lines = vec![Line::from("tool group")];
4959 let line_meta = vec![TranscriptLineMeta::CellLine {
4960 cell_index: 0,
4961 line_in_cell: 0,
4962 copy_prefix_width: 0,
4963 copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
4964 }];
4965 let original_index_map = vec![4];
4966
4967 apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map);
4968
4969 assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset));
4970 }
4971
4972 #[test]
4973 fn tool_run_summary_revision_separates_128_entry_history_and_active_alias() {
4974 let active_rev = 17;
4975 let run = ToolRun {
4976 start: 0,
4977 count: 128,
4978 tool_families: Vec::new(),
4979 activity: Default::default(),
4980 };
4981 let history_revisions = (1..=run.count)
4982 .map(|salt| active_entry_revision(active_rev, salt as u64))
4983 .collect::<Vec<_>>();
4984
4985 let history_key =
4986 tool_run_summary_revision(&run, &history_revisions, run.count, active_rev);
4987 let active_key = tool_run_summary_revision(&run, &[], 0, active_rev);
4988
4989 // Rotating by seven over 128 entries cancels the 128 identical domain
4990 // bits, reproducing the old untagged hash alias. The final domain tag
4991 // must still keep the cache keys distinct.
4992 assert_eq!(
4993 history_key & !ACTIVE_REVISION_DOMAIN,
4994 active_key & !ACTIVE_REVISION_DOMAIN,
4995 "fixture must exercise the 128-entry payload alias"
4996 );
4997 assert_eq!(history_key & ACTIVE_REVISION_DOMAIN, 0);
4998 assert_eq!(active_key & ACTIVE_REVISION_DOMAIN, ACTIVE_REVISION_DOMAIN);
4999 assert_ne!(history_key, active_key);
5000 }
5001
5002 #[test]
5003 fn high_bit_raw_revision_remains_distinct_across_history_and_active_domains() {
5004 let raw = ACTIVE_REVISION_DOMAIN | 0x2692;
5005 let history_key = history_entry_revision(raw);
5006 let active_key = revision_in_domain(raw, true);
5007
5008 assert_eq!(history_key, 0x2692);
5009 assert_eq!(active_key, ACTIVE_REVISION_DOMAIN | 0x2692);
5010 assert_ne!(history_key, active_key);
5011 }
5012
5013 #[test]
5014 fn chat_widget_collapses_dense_tool_runs_by_default() {
5015 let mut app = create_test_app();
5016 app.tool_collapse_mode = ToolCollapseMode::Compact;
5017 app.tool_collapse_threshold = 3;
5018 add_dense_tool_run(&mut app);
5019
5020 let area = Rect {
5021 x: 0,
5022 y: 0,
5023 width: 80,
5024 height: 8,
5025 };
5026 let mut buf = Buffer::empty(area);
5027 let widget = ChatWidget::new(&mut app, area);
5028 widget.render(area, &mut buf);
5029 let rendered = buffer_text(&buf, area);
5030
5031 assert_eq!(app.collapsed_cell_map, vec![0]);
5032 assert!(
5033 rendered.contains("Explored 2 files, 1 search"),
5034 "{rendered}"
5035 );
5036 assert!(!rendered.contains("activity_group"), "{rendered}");
5037 assert!(
5038 !rendered.contains("full output from list_dir"),
5039 "{rendered}"
5040 );
5041 }
5042
5043 #[test]
5044 fn chat_widget_collapses_dense_active_tool_runs_by_default() {
5045 let mut app = create_test_app();
5046 app.tool_collapse_mode = ToolCollapseMode::Compact;
5047 app.tool_collapse_threshold = 3;
5048 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
5049 active.push_untracked(success_tool_cell("read_file"));
5050 active.push_untracked(success_tool_cell("list_dir"));
5051 active.push_untracked(success_tool_cell("web_search"));
5052 app.bump_active_cell_revision();
5053
5054 let area = Rect {
5055 x: 0,
5056 y: 0,
5057 width: 80,
5058 height: 8,
5059 };
5060 let mut buf = Buffer::empty(area);
5061 let widget = ChatWidget::new(&mut app, area);
5062 widget.render(area, &mut buf);
5063 let rendered = buffer_text(&buf, area);
5064
5065 assert_eq!(app.collapsed_cell_map, vec![0]);
5066 assert!(
5067 rendered.contains("Explored 2 files, 1 search"),
5068 "{rendered}"
5069 );
5070 assert!(!rendered.contains("activity_group"), "{rendered}");
5071 assert!(
5072 !rendered.contains("full output from list_dir"),
5073 "{rendered}"
5074 );
5075 }
5076
5077 #[test]
5078 fn collapsed_slow_path_does_not_reuse_running_active_cache_after_flush() {
5079 let mut app = create_test_app();
5080 app.tool_collapse_mode = ToolCollapseMode::Compact;
5081 app.tool_collapse_threshold = 3;
5082 add_dense_tool_run(&mut app);
5083
5084 // Force the next committed history revision to have the same raw key
5085 // as active revision 0, salt 1. The prior collapsed run keeps both
5086 // renders on the filtered slow path.
5087 app.next_history_revision = ACTIVE_REVISION_DOMAIN | 1;
5088 app.active_cell_revision = 0;
5089 let mut active = ActiveCell::new();
5090 active.push_tool("user_shell_slow_path", running_user_shell_cell());
5091 app.active_cell = Some(active);
5092
5093 let area = Rect::new(0, 0, 100, 20);
5094 let mut running_buf = Buffer::empty(area);
5095 ChatWidget::new(&mut app, area).render(area, &mut running_buf);
5096 let running = buffer_text(&running_buf, area);
5097 assert!(running.contains("run running"), "{running}");
5098 assert_eq!(app.collapsed_cell_map, vec![0, 3]);
5099
5100 app.finalize_active_cell_as_interrupted();
5101 assert_eq!(
5102 app.history_revisions[3],
5103 ACTIVE_REVISION_DOMAIN | 1,
5104 "fixture must force the old raw-revision collision"
5105 );
5106
5107 let mut settled_buf = Buffer::empty(area);
5108 ChatWidget::new(&mut app, area).render(area, &mut settled_buf);
5109 let settled = buffer_text(&settled_buf, area);
5110 assert!(
5111 !settled.contains("run running"),
5112 "history cell reused the active slow-path cache entry:\n{settled}"
5113 );
5114 assert!(settled.contains("run issue"), "{settled}");
5115 }
5116
5117 #[test]
5118 fn chat_widget_expands_dense_tool_runs_on_demand() {
5119 let mut app = create_test_app();
5120 app.tool_collapse_mode = ToolCollapseMode::Compact;
5121 app.tool_collapse_threshold = 3;
5122 add_dense_tool_run(&mut app);
5123 app.expanded_tool_runs.insert(0);
5124
5125 let area = Rect {
5126 x: 0,
5127 y: 0,
5128 width: 80,
5129 height: 12,
5130 };
5131 let mut buf = Buffer::empty(area);
5132 let widget = ChatWidget::new(&mut app, area);
5133 widget.render(area, &mut buf);
5134 let rendered = buffer_text(&buf, area);
5135
5136 assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
5137 assert!(rendered.contains("read_file.txt"), "{rendered}");
5138 assert!(rendered.contains("list_dir.txt"), "{rendered}");
5139 assert!(rendered.contains("web_search.txt"), "{rendered}");
5140 assert!(
5141 !rendered.contains("full output from list_dir"),
5142 "{rendered}"
5143 );
5144 }
5145
5146 #[test]
5147 fn chat_widget_expanded_mode_leaves_dense_tool_runs_visible() {
5148 let mut app = create_test_app();
5149 app.tool_collapse_mode = ToolCollapseMode::Expanded;
5150 app.tool_collapse_threshold = 3;
5151 add_dense_tool_run(&mut app);
5152
5153 let area = Rect {
5154 x: 0,
5155 y: 0,
5156 width: 80,
5157 height: 12,
5158 };
5159 let _widget = ChatWidget::new(&mut app, area);
5160
5161 assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
5162 }
5163
5164 #[test]
5165 fn chat_widget_collapse_path_stable_across_frames() {
5166 let mut app = create_test_app();
5167 app.tool_collapse_mode = ToolCollapseMode::Compact;
5168 app.tool_collapse_threshold = 3;
5169 add_dense_tool_run(&mut app);
5170 app.add_message(HistoryCell::User {
5171 content: "trailing prompt".to_string(),
5172 });
5173
5174 let area = Rect {
5175 x: 0,
5176 y: 0,
5177 width: 80,
5178 height: 10,
5179 };
5180
5181 let mut first_buf = Buffer::empty(area);
5182 ChatWidget::new(&mut app, area).render(area, &mut first_buf);
5183 let first = buffer_text(&first_buf, area);
5184 let first_map = app.collapsed_cell_map.clone();
5185 let first_total = app.viewport.last_transcript_total;
5186
5187 // Second frame without any app mutation: the borrowed filtered path
5188 // must reproduce the identical output and index map.
5189 let mut second_buf = Buffer::empty(area);
5190 ChatWidget::new(&mut app, area).render(area, &mut second_buf);
5191 let second = buffer_text(&second_buf, area);
5192
5193 assert_eq!(first, second, "collapse path is frame-stable");
5194 assert_eq!(first_map, app.collapsed_cell_map);
5195 assert_eq!(first_total, app.viewport.last_transcript_total);
5196 assert!(first.contains("Explored 2 files, 1 search"), "{first}");
5197 assert!(first.contains("trailing prompt"), "{first}");
5198 }
5199
5200 #[test]
5201 fn chat_widget_collapses_run_spanning_history_and_active_entries() {
5202 let mut app = create_test_app();
5203 app.tool_collapse_mode = ToolCollapseMode::Compact;
5204 app.tool_collapse_threshold = 3;
5205 app.add_message(success_tool_cell("read_file"));
5206 app.add_message(success_tool_cell("list_dir"));
5207 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
5208 active.push_untracked(success_tool_cell("web_search"));
5209 app.bump_active_cell_revision();
5210
5211 let area = Rect {
5212 x: 0,
5213 y: 0,
5214 width: 80,
5215 height: 8,
5216 };
5217 let mut buf = Buffer::empty(area);
5218 ChatWidget::new(&mut app, area).render(area, &mut buf);
5219 let rendered = buffer_text(&buf, area);
5220
5221 assert_eq!(app.collapsed_cell_map, vec![0]);
5222 assert!(
5223 rendered.contains("Explored 2 files, 1 search"),
5224 "run spanning the history/active boundary renders one summary: {rendered}"
5225 );
5226
5227 // Mutating the active tail must re-render the summary (its revision
5228 // folds in the covered active entries).
5229 let rev_before = app.active_cell_revision;
5230 app.bump_active_cell_revision();
5231 assert_ne!(rev_before, app.active_cell_revision);
5232 let mut second_buf = Buffer::empty(area);
5233 ChatWidget::new(&mut app, area).render(area, &mut second_buf);
5234 let second = buffer_text(&second_buf, area);
5235 assert!(second.contains("Explored 2 files, 1 search"), "{second}");
5236 }
5237
5238 // Cursor alignment tests
5239
5240 #[test]
5241 fn cursor_basic_ascii() {
5242 // "hello" with cursor at various positions, width=10
5243 assert_eq!(cursor_row_col("hello", 0, 10), (0, 0));
5244 assert_eq!(cursor_row_col("hello", 3, 10), (0, 3));
5245 assert_eq!(cursor_row_col("hello", 5, 10), (0, 5));
5246 }
5247
5248 #[test]
5249 fn cursor_at_wrap_boundary() {
5250 // "abcde" exactly fills width=5
5251 // Cursor at position 5 (after last char) should wrap to next line
5252 let (row, col) = cursor_row_col("abcde", 5, 5);
5253 assert_eq!(row, 1, "cursor at end of full line should wrap");
5254 assert_eq!(col, 0, "cursor should be at start of next line");
5255 }
5256
5257 #[test]
5258 fn cursor_with_cjk_characters() {
5259 // "中" is a CJK character with width 2
5260 // "a中b" = 1 + 2 + 1 = 4 display width
5261 assert_eq!(cursor_row_col("a中b", 0, 10), (0, 0)); // before 'a'
5262 assert_eq!(cursor_row_col("a中b", 1, 10), (0, 1)); // after 'a', before '中'
5263 assert_eq!(cursor_row_col("a中b", 2, 10), (0, 3)); // after '中', before 'b'
5264 assert_eq!(cursor_row_col("a中b", 3, 10), (0, 4)); // after 'b'
5265 }
5266
5267 #[test]
5268 fn cursor_cjk_at_wrap_boundary() {
5269 // width=5, input "abcd中" (4 + 2 = 6, CJK doesn't fit on line 1)
5270 // CJK should wrap to next line
5271 let lines = wrap_text("abcd中", 5);
5272 assert_eq!(lines, vec!["abcd", "中"]);
5273
5274 // Cursor after CJK should be on row 1, col 2
5275 let (row, col) = cursor_row_col("abcd中", 5, 5);
5276 assert_eq!(row, 1);
5277 assert_eq!(col, 2);
5278 }
5279
5280 /// Composer wrapping breaks between words, not through them. A line
5281 /// ending in a severed word (`…Write the file onl`) reads exactly like
5282 /// content that was cut off, which is how it was reported.
5283 #[test]
5284 fn composer_wraps_on_word_boundaries_without_losing_a_character() {
5285 let text = "Mark inferences as inferences. A short PRD where each \
5286 section decides something beats a long one.";
5287 for width in [20usize, 33, 47, 60, 79] {
5288 let lines = wrap_text(text, width);
5289 assert_eq!(
5290 lines.concat(),
5291 text,
5292 "wrapping must be lossless at width={width}: {lines:?}"
5293 );
5294 for line in &lines {
5295 assert!(
5296 line.width() <= width,
5297 "line exceeds width={width}: {line:?}"
5298 );
5299 }
5300 // No line may end in the middle of a word: either it ends the
5301 // text, or it ends on whitespace.
5302 for line in lines.iter().take(lines.len().saturating_sub(1)) {
5303 assert!(
5304 line.is_empty() || line.ends_with(' '),
5305 "wrapped line broke mid-word at width={width}: {line:?}"
5306 );
5307 }
5308 }
5309 }
5310
5311 /// A token with no break point in it still has to fit the terminal, so it
5312 /// breaks hard. Losslessness holds there too.
5313 #[test]
5314 fn composer_hard_breaks_words_longer_than_the_line() {
5315 let text = "see https://example.com/a/very/long/path/that/never/breaks?x=1 now";
5316 let lines = wrap_text(text, 24);
5317 assert_eq!(lines.concat(), text, "{lines:?}");
5318 for line in &lines {
5319 assert!(line.width() <= 24, "line exceeds width: {line:?}");
5320 }
5321 assert!(
5322 lines.len() > 2,
5323 "an unbreakable token must still be split across lines: {lines:?}"
5324 );
5325 }
5326
5327 /// Wide characters have no spaces to break on; the width accounting must
5328 /// still hold. This repo patches `unicode-width` for CJK, so measure the
5329 /// wrapped output rather than trusting char counts.
5330 #[test]
5331 fn composer_wrapping_respects_wide_character_width() {
5332 let text = "中文字符串没有空格可以换行";
5333 let lines = wrap_text(text, 7);
5334 assert_eq!(lines.concat(), text, "{lines:?}");
5335 for line in &lines {
5336 assert!(line.width() <= 7, "line exceeds width: {line:?}");
5337 }
5338 }
5339
5340 #[test]
5341 fn cursor_with_combining_marks() {
5342 // "e\u0301" is 'e' with combining acute accent (é)
5343 // Display width is 1 (combining mark has width 0)
5344 let input = "e\u{0301}"; // é as e + combining acute
5345 assert_eq!(input.chars().count(), 2);
5346
5347 // Cursor positions:
5348 // 0 = before 'e'
5349 // 1 = after 'e', before combining mark
5350 // 2 = after combining mark
5351 assert_eq!(cursor_row_col(input, 0, 10), (0, 0));
5352 assert_eq!(cursor_row_col(input, 1, 10), (0, 1));
5353 assert_eq!(cursor_row_col(input, 2, 10), (0, 1)); // combining mark has width 0
5354 }
5355
5356 #[test]
5357 fn cursor_with_emoji() {
5358 // Many emojis are double-width
5359 let input = "a😀b";
5360 // Cursor at 2 (after emoji) should account for emoji width
5361 let (_row, col) = cursor_row_col(input, 2, 10);
5362 // Emoji width varies by system, but should be either 1 or 2
5363 assert!((2..=3).contains(&col), "col = {col}, expected 2 or 3");
5364 }
5365
5366 #[test]
5367 fn cursor_with_emoji_zwj_sequence() {
5368 let input = "👨‍👩‍👧‍👦";
5369 let cursor = input.chars().count();
5370 let (row, col) = cursor_row_col(input, cursor, 10);
5371 assert_eq!(row, 0);
5372 assert_eq!(col, input.width());
5373 }
5374
5375 #[test]
5376 fn cursor_with_newlines() {
5377 // "ab\ncd" with cursor moving through
5378 assert_eq!(cursor_row_col("ab\ncd", 0, 10), (0, 0)); // before 'a'
5379 assert_eq!(cursor_row_col("ab\ncd", 2, 10), (0, 2)); // after 'b', before '\n'
5380 assert_eq!(cursor_row_col("ab\ncd", 3, 10), (1, 0)); // after '\n', before 'c'
5381 assert_eq!(cursor_row_col("ab\ncd", 5, 10), (1, 2)); // after 'd'
5382 }
5383
5384 #[test]
5385 fn wrap_input_lines_preserves_empty_lines() {
5386 let lines = wrap_input_lines("a\n\nb", 10);
5387 assert_eq!(lines, vec!["a", "", "b"]);
5388 }
5389
5390 #[test]
5391 fn wrap_and_caret_measure_tabs_as_painted() {
5392 // Ratatui strips control characters, so a tab paints no cells. Wrap
5393 // budgets, caret columns, and click mapping must all agree on zero;
5394 // counting the tab would break the line early and drift the caret
5395 // and clicks one cell per tab.
5396 assert_eq!(wrap_text("\t0123456789", 11), vec!["\t0123456789"]);
5397 assert_eq!(cursor_row_col("a\tb", 3, 80), (0, 2));
5398 assert_eq!(cursor_row_col("\ta", 2, 80), (0, 1));
5399 }
5400
5401 #[test]
5402 fn wrap_input_lines_trailing_newline() {
5403 let lines = wrap_input_lines("a\n", 10);
5404 assert_eq!(lines, vec!["a", ""]);
5405 }
5406
5407 #[test]
5408 fn wrap_input_lines_for_mouse_empty_input() {
5409 // Empty input should return a single empty line at position 0.
5410 // This ensures empty composer mouse selection works correctly (issue #3909).
5411 let result = wrap_input_lines_for_mouse("", 10);
5412 assert_eq!(result, vec![(0, String::new())]);
5413
5414 // Also verify with width=0 edge case
5415 let result_zero = wrap_input_lines_for_mouse("", 0);
5416 assert_eq!(result_zero, vec![(0, String::new())]);
5417 }
5418
5419 #[test]
5420 fn cursor_and_wrap_consistency() {
5421 // Ensure cursor_row_col is consistent with wrap_text
5422 // for various inputs
5423 let test_cases = vec![
5424 ("hello world", 5),
5425 ("abcdefghij", 3),
5426 ("中文测试", 6),
5427 ("a\nb\nc", 10),
5428 ];
5429
5430 for (input, width) in test_cases {
5431 let lines = wrap_input_lines(input, width);
5432 let (cursor_row, _) = cursor_row_col(input, input.chars().count(), width);
5433
5434 // Cursor at end should be on the last line (or wrapped past it)
5435 assert!(
5436 cursor_row <= lines.len(),
5437 "cursor_row={cursor_row} should be <= lines.len()={} for input={input:?}",
5438 lines.len()
5439 );
5440 }
5441 }
5442
5443 #[test]
5444 fn bare_slash_menu_leads_with_the_small_set_and_reaches_the_long_tail() {
5445 let hints = slash_completion_hints("/", 512, &[], Locale::En, None, ApiProvider::Deepseek);
5446 let names: Vec<&str> = hints.iter().map(|hint| hint.name.as_str()).collect();
5447 assert_eq!(
5448 names.iter().take(6).copied().collect::<Vec<_>>(),
5449 ["/help", "/setup", "/model", "/settings", "/resume", "/rc"],
5450 "the small starting set still leads: {names:?}"
5451 );
5452 // Founder ruling: the ranking is welcome, the truncation is not — a
5453 // command you cannot reach from the menu is a command you cannot find.
5454 assert!(
5455 names.len() > 6,
5456 "the long tail follows the starting set: {names:?}"
5457 );
5458 assert!(
5459 slash_completion_hints("/wor", 128, &[], Locale::En, None, ApiProvider::Deepseek)
5460 .iter()
5461 .any(|hint| hint.name == "/workflow")
5462 );
5463 assert!(
5464 slash_completion_hints("/conf", 128, &[], Locale::En, None, ApiProvider::Deepseek)
5465 .iter()
5466 .any(|hint| hint.name == "/config")
5467 );
5468 assert!(
5469 slash_completion_hints("/age", 128, &[], Locale::En, None, ApiProvider::Deepseek)
5470 .iter()
5471 .any(|hint| hint.name == "/subagents")
5472 );
5473 assert!(
5474 slash_completion_hints("/comp", 128, &[], Locale::En, None, ApiProvider::Deepseek)
5475 .iter()
5476 .any(|hint| hint.name == "/compact")
5477 );
5478 }
5479
5480 #[test]
5481 fn slash_completion_hints_rank_exact_alias_above_prefix_alias() {
5482 // `/q` should rank `/exit` (exact alias `q`) above `/clear` (alias
5483 // `qingping` only matches by prefix). Before #1811 the entries were
5484 // sorted alphabetically, so `/clear` shadowed `/exit` even though
5485 // the user typed the exact alias for `/exit`.
5486 let hints = slash_completion_hints("/q", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5487 let names: Vec<&str> = hints.iter().map(|h| h.name.as_str()).collect();
5488 let exit_pos = names
5489 .iter()
5490 .position(|n| *n == "/exit")
5491 .expect("/exit should appear when typing /q (alias `q`)");
5492 let clear_pos = names
5493 .iter()
5494 .position(|n| *n == "/clear")
5495 .expect("/clear should still appear when typing /q (alias `qingping`)");
5496 assert!(
5497 exit_pos < clear_pos,
5498 "expected /exit to rank above /clear for prefix /q, got {names:?}"
5499 );
5500 }
5501
5502 #[test]
5503 fn slash_completion_does_not_repeat_alias_already_in_label() {
5504 // Typing `/p` matches `/clear` via alias `qingping`, so the label
5505 // shows `/clear or /qingping`. The description must not also append
5506 // `(aliases: /qingping)` (#3990).
5507 let hints = slash_completion_hints("/p", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5508 let clear = hints
5509 .iter()
5510 .find(|h| h.name == "/clear")
5511 .expect("/clear should appear for /p via qingping");
5512 assert_eq!(
5513 clear.alias_hint.as_deref(),
5514 Some("qingping"),
5515 "label should surface the matching alias"
5516 );
5517 assert!(
5518 !clear.description.contains("(aliases:"),
5519 "description should omit alias list when the only alias is already in the label: {}",
5520 clear.description
5521 );
5522 assert!(
5523 !clear.description.contains("/qingping"),
5524 "description must not repeat /qingping: {}",
5525 clear.description
5526 );
5527 }
5528
5529 #[test]
5530 fn a_bare_slash_leads_with_the_short_list_and_still_reaches_every_command() {
5531 // Founder live-test: "I like how we prioritize the slash thing but it
5532 // should still be able to find all of them." The curated six stay at
5533 // the head; the rest follow instead of being filtered away, and the
5534 // popup scrolls around the selection to reach them.
5535 let hints = slash_completion_hints("/", 512, &[], Locale::En, None, ApiProvider::Deepseek);
5536 let names: Vec<String> = hints.iter().map(|h| h.name.clone()).collect();
5537
5538 let head: Vec<String> = crate::commands::traits::BARE_SLASH_DISCOVERY_COMMANDS
5539 .iter()
5540 .map(|name| format!("/{name}"))
5541 .collect();
5542 assert_eq!(
5543 names.iter().take(head.len()).cloned().collect::<Vec<_>>(),
5544 head,
5545 "the short task sequence still leads"
5546 );
5547 assert!(
5548 names.len() > head.len(),
5549 "a bare slash must reach past the short list: {} entries",
5550 names.len()
5551 );
5552 // A command deliberately outside the short list must be reachable.
5553 assert!(
5554 names.iter().any(|name| name == "/mcp"),
5555 "every command is findable from a bare slash"
5556 );
5557 }
5558
5559 #[test]
5560 fn slash_completion_hints_keep_prefix_match_alphabetical_within_tier() {
5561 // Within the same rank tier (no exact-alias match), entries fall
5562 // back to alphabetical name order, same as the prior behavior.
5563 let hints =
5564 slash_completion_hints("/co", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5565 let names: Vec<&str> = hints
5566 .iter()
5567 .map(|h| h.name.as_str())
5568 .filter(|n| n.starts_with("/co"))
5569 .collect();
5570 let sorted = {
5571 let mut copy = names.clone();
5572 copy.sort();
5573 copy
5574 };
5575 assert_eq!(
5576 names, sorted,
5577 "tied entries (no exact-alias match) should stay alphabetical"
5578 );
5579 }
5580
5581 #[test]
5582 fn slash_completion_hints_exclude_set_and_deepseek_commands() {
5583 let hints = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5584 assert!(!hints.iter().any(|hint| hint.name == "/set"));
5585 assert!(!hints.iter().any(|hint| hint.name == "/codewhale"));
5586 }
5587
5588 #[test]
5589 fn slash_completion_hints_rank_toolbox_commands_below_the_starting_set() {
5590 let root = slash_completion_hints("/", 512, &[], Locale::En, None, ApiProvider::Deepseek);
5591 let position = |name: &str| root.iter().position(|hint| hint.name == name);
5592 // The task-oriented set leads; the toolbox is reachable behind it
5593 // rather than hidden until guessed at.
5594 assert_eq!(position("/model"), Some(2));
5595 for toolbox in [
5596 "/provider",
5597 "/fleet",
5598 "/config",
5599 "/statusline",
5600 "/rlm",
5601 "/modeldb",
5602 "/models",
5603 "/plugin",
5604 ] {
5605 let rank = position(toolbox)
5606 .unwrap_or_else(|| panic!("{toolbox} must be reachable from a bare slash"));
5607 assert!(rank >= 6, "{toolbox} must rank below the starting set");
5608 }
5609 // `/subagents` is renamed at the root, so its canonical name is the
5610 // one that must not appear.
5611 assert!(position("/subagents").is_none());
5612 assert!(position("/agents").is_some());
5613
5614 let rlm = slash_completion_hints("/rl", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5615 assert!(rlm.iter().any(|hint| hint.name == "/rlm"));
5616
5617 let modeldb =
5618 slash_completion_hints("/modeld", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5619 assert!(modeldb.iter().any(|hint| hint.name == "/modeldb"));
5620
5621 let plugin =
5622 slash_completion_hints("/pl", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5623 assert!(plugin.iter().any(|hint| hint.name == "/plugin"));
5624
5625 let subagents =
5626 slash_completion_hints("/sub", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5627 assert!(subagents.iter().any(|hint| hint.name == "/subagents"));
5628 }
5629
5630 #[test]
5631 fn slash_completion_hints_use_user_command_frontmatter_description() {
5632 let tmp = tempfile::TempDir::new().unwrap();
5633 let commands_dir = tmp.path().join(".deepseek").join("commands");
5634 std::fs::create_dir_all(&commands_dir).unwrap();
5635 std::fs::write(
5636 commands_dir.join("git-scan.md"),
5637 "---\ndescription: Scan nested git repositories\n---\nscan",
5638 )
5639 .unwrap();
5640
5641 let hints = slash_completion_hints(
5642 "/git",
5643 128,
5644 &[],
5645 Locale::En,
5646 Some(tmp.path()),
5647 ApiProvider::Deepseek,
5648 );
5649 let entry = hints
5650 .iter()
5651 .find(|hint| hint.name == "/git-scan")
5652 .expect("custom command should be present");
5653 assert_eq!(entry.description, "Scan nested git repositories");
5654 }
5655
5656 #[test]
5657 fn slash_completion_hints_use_user_command_argument_hint() {
5658 let tmp = tempfile::TempDir::new().unwrap();
5659 let commands_dir = tmp.path().join(".deepseek").join("commands");
5660 std::fs::create_dir_all(&commands_dir).unwrap();
5661 std::fs::write(
5662 commands_dir.join("deploy.md"),
5663 "---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy",
5664 )
5665 .unwrap();
5666
5667 let hints = slash_completion_hints(
5668 "/deploy",
5669 128,
5670 &[],
5671 Locale::En,
5672 Some(tmp.path()),
5673 ApiProvider::Deepseek,
5674 );
5675 let entry = hints
5676 .iter()
5677 .find(|hint| hint.name == "/deploy")
5678 .expect("custom command should be present");
5679 assert_eq!(entry.description, "Deploy target <env>");
5680 }
5681
5682 #[test]
5683 fn slash_completion_uses_frontmatter_name_and_usage() {
5684 let tmp = tempfile::TempDir::new().unwrap();
5685 let commands_dir = tmp.path().join(".codewhale").join("commands");
5686 std::fs::create_dir_all(&commands_dir).unwrap();
5687 std::fs::write(
5688 commands_dir.join("workflow-file.md"),
5689 "---\nname: inspect\ndescription: Inspect target\nusage: /inspect <path>\narguments: <path>\n---\ninspect",
5690 )
5691 .unwrap();
5692
5693 let hints = slash_completion_hints(
5694 "/ins",
5695 128,
5696 &[],
5697 Locale::En,
5698 Some(tmp.path()),
5699 ApiProvider::Deepseek,
5700 );
5701 let entry = hints
5702 .iter()
5703 .find(|hint| hint.name == "/inspect")
5704 .expect("frontmatter name should complete");
5705
5706 assert_eq!(entry.description, "Inspect target /inspect <path>");
5707 assert!(!hints.iter().any(|hint| hint.name == "/workflow-file"));
5708 }
5709
5710 #[test]
5711 fn slash_completion_uses_arguments_when_usage_and_legacy_hint_are_absent() {
5712 let tmp = tempfile::TempDir::new().unwrap();
5713 let commands_dir = tmp.path().join(".codewhale").join("commands");
5714 std::fs::create_dir_all(&commands_dir).unwrap();
5715 std::fs::write(
5716 commands_dir.join("deploy.md"),
5717 "---\ndescription: Deploy target\narguments: <environment>\n---\ndeploy",
5718 )
5719 .unwrap();
5720
5721 let hints = slash_completion_hints(
5722 "/deploy",
5723 128,
5724 &[],
5725 Locale::En,
5726 Some(tmp.path()),
5727 ApiProvider::Deepseek,
5728 );
5729 let entry = hints
5730 .iter()
5731 .find(|hint| hint.name == "/deploy")
5732 .expect("custom command should be present");
5733
5734 assert_eq!(entry.description, "Deploy target <environment>");
5735 }
5736
5737 /// #5952: `/workspace worktrees` is the only route to the git worktree
5738 /// manager, and the menu went blank the moment the space was typed.
5739 #[test]
5740 fn typing_a_command_and_a_space_states_its_usage_and_lists_its_subcommands() {
5741 let hints = slash_completion_hints(
5742 "/workspace ",
5743 128,
5744 &[],
5745 Locale::En,
5746 None,
5747 ApiProvider::Deepseek,
5748 );
5749
5750 let head = hints.first().expect("usage row");
5751 assert_eq!(head.name, "/workspace");
5752 assert_eq!(head.description, "/workspace [path|worktrees]");
5753 assert!(
5754 hints.iter().any(|hint| hint.name == "/workspace worktrees"),
5755 "worktrees must be offered as a subcommand candidate: {:?}",
5756 hints.iter().map(|h| h.name.as_str()).collect::<Vec<_>>()
5757 );
5758 }
5759
5760 /// An alias reaches the same usage line as the canonical name, and the
5761 /// rows it offers are still spelled with the canonical name.
5762 #[test]
5763 fn a_command_alias_states_the_canonical_usage() {
5764 let hints =
5765 slash_completion_hints("/cwd ", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5766 assert_eq!(hints[0].name, "/workspace");
5767 assert_eq!(hints[0].description, "/workspace [path|worktrees]");
5768 }
5769
5770 /// Once filtering starts the usage row steps aside so a single remaining
5771 /// verb is unambiguous — that is what lets Tab complete it.
5772 #[test]
5773 fn a_partial_subcommand_filters_to_the_verbs_that_match() {
5774 let hints = slash_completion_hints(
5775 "/workspace wor",
5776 128,
5777 &[],
5778 Locale::En,
5779 None,
5780 ApiProvider::Deepseek,
5781 );
5782 assert_eq!(
5783 hints
5784 .iter()
5785 .map(|hint| hint.name.as_str())
5786 .collect::<Vec<_>>(),
5787 vec!["/workspace worktrees"]
5788 );
5789
5790 let none = slash_completion_hints(
5791 "/workspace zzz",
5792 128,
5793 &[],
5794 Locale::En,
5795 None,
5796 ApiProvider::Deepseek,
5797 );
5798 assert!(
5799 none.is_empty(),
5800 "{:?}",
5801 none.iter()
5802 .map(|hint| hint.name.as_str())
5803 .collect::<Vec<_>>()
5804 );
5805 }
5806
5807 #[test]
5808 fn a_command_that_takes_no_arguments_still_closes_the_menu() {
5809 let hints =
5810 slash_completion_hints("/copy ", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5811 assert!(hints.is_empty());
5812 }
5813
5814 #[test]
5815 fn a_second_argument_word_and_an_unknown_command_offer_nothing() {
5816 assert!(
5817 slash_completion_hints(
5818 "/workspace worktrees ",
5819 128,
5820 &[],
5821 Locale::En,
5822 None,
5823 ApiProvider::Deepseek,
5824 )
5825 .is_empty()
5826 );
5827 assert!(
5828 slash_completion_hints(
5829 "/nosuchcommand ",
5830 128,
5831 &[],
5832 Locale::En,
5833 None,
5834 ApiProvider::Deepseek,
5835 )
5836 .is_empty()
5837 );
5838 }
5839
5840 /// `/skill ` and `/model ` own their argument menus; the usage rows must
5841 /// not displace them.
5842 #[test]
5843 fn argument_menus_that_already_exist_keep_their_rows() {
5844 let skills = vec![("codereview".to_string(), "Review a diff".to_string())];
5845 let hints = slash_completion_hints(
5846 "/skill ",
5847 128,
5848 &skills,
5849 Locale::En,
5850 None,
5851 ApiProvider::Deepseek,
5852 );
5853 assert!(hints.iter().any(|hint| hint.name == "/skill codereview"));
5854 assert!(hints.iter().all(|hint| hint.name != "/skill"));
5855 }
5856
5857 #[test]
5858 fn slash_completion_hints_exclude_hidden_user_commands() {
5859 let tmp = tempfile::TempDir::new().unwrap();
5860 let commands_dir = tmp.path().join(".codewhale").join("commands");
5861 std::fs::create_dir_all(&commands_dir).unwrap();
5862 std::fs::write(
5863 commands_dir.join("secret.md"),
5864 "---\ndescription: Internal command\nhidden: true\n---\nsecret",
5865 )
5866 .unwrap();
5867
5868 let hints = slash_completion_hints(
5869 "/secret",
5870 128,
5871 &[],
5872 Locale::En,
5873 Some(tmp.path()),
5874 ApiProvider::Deepseek,
5875 );
5876
5877 assert!(!hints.iter().any(|hint| hint.name == "/secret"));
5878 }
5879
5880 #[test]
5881 fn hidden_name_override_filters_shadowed_builtin_from_slash_completion() {
5882 let tmp = tempfile::TempDir::new().unwrap();
5883 let commands_dir = tmp.path().join(".codewhale").join("commands");
5884 std::fs::create_dir_all(&commands_dir).unwrap();
5885 std::fs::write(
5886 commands_dir.join("private-help.md"),
5887 "---\nname: help\nhidden: true\n---\nprivate help",
5888 )
5889 .unwrap();
5890
5891 let hints = slash_completion_hints(
5892 "/help",
5893 128,
5894 &[],
5895 Locale::En,
5896 Some(tmp.path()),
5897 ApiProvider::Deepseek,
5898 );
5899
5900 assert!(!hints.iter().any(|hint| hint.name == "/help"));
5901 }
5902
5903 #[test]
5904 fn slash_completion_hints_match_user_command_aliases() {
5905 let tmp = tempfile::TempDir::new().unwrap();
5906 let commands_dir = tmp.path().join(".codewhale").join("commands");
5907 std::fs::create_dir_all(&commands_dir).unwrap();
5908 std::fs::write(
5909 commands_dir.join("deploy-target.md"),
5910 "---\ndescription: Deploy target\nalias: ship\n---\ndeploy",
5911 )
5912 .unwrap();
5913
5914 let hints = slash_completion_hints(
5915 "/ship",
5916 128,
5917 &[],
5918 Locale::En,
5919 Some(tmp.path()),
5920 ApiProvider::Deepseek,
5921 );
5922 let entry = hints
5923 .iter()
5924 .find(|hint| hint.name == "/deploy-target")
5925 .expect("user command should be matched by alias");
5926
5927 assert_eq!(entry.alias_hint.as_deref(), Some("ship"));
5928 assert_eq!(entry.description, "Deploy target");
5929 }
5930
5931 #[test]
5932 fn slash_completion_offers_no_retired_pod_entry() {
5933 let hints =
5934 slash_completion_hints("/pod", 128, &[], Locale::En, None, ApiProvider::Deepseek);
5935 assert!(
5936 !hints.iter().any(|hint| hint.name == "/pod"),
5937 "the retired /pod spelling must not complete"
5938 );
5939 for entry in hints.iter().filter(|hint| hint.name == "/fleet") {
5940 assert_eq!(
5941 entry.alias_hint, None,
5942 "no alias may point at the retired spelling"
5943 );
5944 }
5945 }
5946
5947 #[test]
5948 fn slash_completion_omits_rejected_user_alias_collisions() {
5949 let tmp = tempfile::TempDir::new().unwrap();
5950 let commands_dir = tmp.path().join(".codewhale").join("commands");
5951 std::fs::create_dir_all(&commands_dir).unwrap();
5952 std::fs::write(
5953 commands_dir.join("alpha.md"),
5954 "---\ndescription: Alpha command\nalias: beta\n---\nalpha",
5955 )
5956 .unwrap();
5957 std::fs::write(
5958 commands_dir.join("beta.md"),
5959 "---\ndescription: Beta command\n---\nbeta",
5960 )
5961 .unwrap();
5962
5963 let hints = slash_completion_hints(
5964 "/bet",
5965 128,
5966 &[],
5967 Locale::En,
5968 Some(tmp.path()),
5969 ApiProvider::Deepseek,
5970 );
5971
5972 assert!(hints.iter().any(|hint| hint.name == "/beta"));
5973 assert!(
5974 !hints.iter().any(|hint| hint.name == "/alpha"),
5975 "a command must not match through an alias rejected by the registry"
5976 );
5977 }
5978
5979 #[test]
5980 fn slash_completion_hints_keep_builtin_canonical_when_only_builtin_alias_is_shadowed() {
5981 let tmp = tempfile::TempDir::new().unwrap();
5982 let commands_dir = tmp.path().join(".codewhale").join("commands");
5983 std::fs::create_dir_all(&commands_dir).unwrap();
5984 std::fs::write(
5985 commands_dir.join("attach-review.md"),
5986 "---\ndescription: Review image\nalias: image\n---\nreview image",
5987 )
5988 .unwrap();
5989
5990 let canonical_hints = slash_completion_hints(
5991 "/att",
5992 128,
5993 &[],
5994 Locale::En,
5995 Some(tmp.path()),
5996 ApiProvider::Deepseek,
5997 );
5998
5999 let attach = canonical_hints
6000 .iter()
6001 .find(|hint| hint.name == "/attach")
6002 .expect(
6003 "canonical /attach should remain visible when only its /image alias is shadowed",
6004 );
6005 assert!(
6006 !attach.description.contains("/image"),
6007 "canonical completion must not advertise a user-shadowed alias"
6008 );
6009
6010 let alias_hints = slash_completion_hints(
6011 "/image",
6012 128,
6013 &[],
6014 Locale::En,
6015 Some(tmp.path()),
6016 ApiProvider::Deepseek,
6017 );
6018
6019 assert!(
6020 alias_hints.iter().any(|hint| hint.name == "/attach-review"),
6021 "user command should complete through its /image alias"
6022 );
6023 assert!(
6024 !alias_hints.iter().any(|hint| hint.name == "/attach"),
6025 "built-in /attach should not complete through shadowed /image alias"
6026 );
6027 }
6028
6029 #[test]
6030 fn slash_completion_accepted_user_alias_claims_builtin_canonical_token() {
6031 // A visible user command whose accepted alias equals a built-in
6032 // canonical token must own that token in completion: the built-in
6033 // suggestion is absent and the user command appears for the alias.
6034 let tmp = tempfile::TempDir::new().unwrap();
6035 let commands_dir = tmp.path().join(".codewhale").join("commands");
6036 std::fs::create_dir_all(&commands_dir).unwrap();
6037 std::fs::write(
6038 commands_dir.join("assistant.md"),
6039 "---\ndescription: My assistant\nalias: help\n---\nassistant",
6040 )
6041 .unwrap();
6042
6043 let hints = slash_completion_hints(
6044 "/help",
6045 128,
6046 &[],
6047 Locale::En,
6048 Some(tmp.path()),
6049 ApiProvider::Deepseek,
6050 );
6051
6052 assert!(
6053 !hints.iter().any(|hint| hint.name == "/help"),
6054 "built-in /help must be absent when a user alias claims the token"
6055 );
6056 assert!(
6057 hints.iter().any(|hint| hint.name == "/assistant"),
6058 "the user command must appear for the claimed token"
6059 );
6060 }
6061
6062 #[test]
6063 fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() {
6064 let tmp = tempfile::TempDir::new().unwrap();
6065 let commands_dir = tmp.path().join(".codewhale").join("commands");
6066 std::fs::create_dir_all(&commands_dir).unwrap();
6067 std::fs::write(
6068 commands_dir.join("help.md"),
6069 "---\ndescription: Custom help workflow\nargument-hint: <topic>\n---\nhelp",
6070 )
6071 .unwrap();
6072
6073 let hints = slash_completion_hints(
6074 "/help",
6075 128,
6076 &[],
6077 Locale::En,
6078 Some(tmp.path()),
6079 ApiProvider::Deepseek,
6080 );
6081 let help_entries: Vec<_> = hints.iter().filter(|hint| hint.name == "/help").collect();
6082
6083 assert_eq!(help_entries.len(), 1);
6084 assert_eq!(help_entries[0].description, "Custom help workflow <topic>");
6085 }
6086
6087 #[test]
6088 fn review_regression_push_command_entry_uses_preloaded_user_command_frontmatter() {
6089 let registry = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![(
6090 "deploy".to_string(),
6091 "---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy".to_string(),
6092 )]);
6093 let user_commands: Vec<_> = registry.iter().collect();
6094 let mut entries = Vec::new();
6095
6096 push_command_entry(
6097 &mut entries,
6098 "/deploy",
6099 "deploy",
6100 "deploy",
6101 Locale::En,
6102 &user_commands,
6103 );
6104
6105 assert_eq!(entries.len(), 1);
6106 assert_eq!(entries[0].name, "/deploy");
6107 assert_eq!(entries[0].description, "Deploy target <env>");
6108 }
6109
6110 #[test]
6111 fn slash_completion_hints_hide_skills_from_top_level_menu() {
6112 let cached_skills = vec![
6113 ("search-files".to_string(), "Search files".to_string()),
6114 ("my-review".to_string(), "Review code".to_string()),
6115 ];
6116 let hints = slash_completion_hints(
6117 "/",
6118 128,
6119 &cached_skills,
6120 Locale::En,
6121 None,
6122 ApiProvider::Deepseek,
6123 );
6124 // Individual skills stay out of the root: they are user content with
6125 // their own triggers (`$name`, `/skill`) and there can be hundreds.
6126 assert!(!hints.iter().any(|hint| hint.is_skill));
6127 // The commands that reach them are commands like any other, and a
6128 // bare slash must be able to find every command.
6129 assert!(hints.iter().any(|hint| hint.name == "/skill"));
6130 }
6131
6132 #[test]
6133 fn slash_completion_hints_hide_skills_from_top_level_prefix() {
6134 let cached_skills = vec![
6135 ("search-files".to_string(), "Search files".to_string()),
6136 ("my-review".to_string(), "Review code".to_string()),
6137 ];
6138 let hints = slash_completion_hints(
6139 "/se",
6140 128,
6141 &cached_skills,
6142 Locale::En,
6143 None,
6144 ApiProvider::Deepseek,
6145 );
6146 assert!(!hints.iter().any(|hint| hint.name == "/skill search-files"));
6147 assert!(!hints.iter().any(|hint| hint.name == "/skill my-review"));
6148 }
6149
6150 #[test]
6151 fn slash_completion_hints_complete_skill_argument_all() {
6152 let cached_skills = vec![
6153 ("search-files".to_string(), "Search files".to_string()),
6154 ("my-review".to_string(), "Review code".to_string()),
6155 ];
6156 let hints = slash_completion_hints(
6157 "/skill ",
6158 128,
6159 &cached_skills,
6160 Locale::En,
6161 None,
6162 ApiProvider::Deepseek,
6163 );
6164 assert_eq!(hints.len(), 2);
6165 assert!(hints.iter().any(|hint| hint.name == "/skill search-files"));
6166 assert!(hints.iter().any(|hint| hint.name == "/skill my-review"));
6167 assert!(hints.iter().all(|hint| hint.is_skill));
6168 }
6169
6170 #[test]
6171 fn slash_completion_hints_complete_skill_argument_prefix() {
6172 let cached_skills = vec![
6173 ("search-files".to_string(), "Search files".to_string()),
6174 ("my-review".to_string(), "Review code".to_string()),
6175 ];
6176 let hints = slash_completion_hints(
6177 "/skill my",
6178 128,
6179 &cached_skills,
6180 Locale::En,
6181 None,
6182 ApiProvider::Deepseek,
6183 );
6184 assert_eq!(hints.len(), 1);
6185 assert_eq!(hints[0].name, "/skill my-review");
6186 assert!(hints[0].is_skill);
6187 }
6188
6189 #[test]
6190 fn slash_completion_hints_model_deepseek_provider_uses_bare_ids() {
6191 let hints =
6192 slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Deepseek);
6193 let names = hints
6194 .iter()
6195 .map(|hint| hint.name.as_str())
6196 .collect::<Vec<_>>();
6197
6198 assert!(names.contains(&"/model deepseek-v4-pro"));
6199 assert!(names.contains(&"/model deepseek-v4-flash"));
6200 assert!(!names.contains(&"/model deepseek-ai/deepseek-v4-pro"));
6201 assert!(!names.contains(&"/model deepseek/deepseek-v4-pro"));
6202 }
6203
6204 #[test]
6205 fn slash_completion_hints_model_provider_uses_provider_specific_ids() {
6206 let hints =
6207 slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::NvidiaNim);
6208 let names = hints
6209 .iter()
6210 .map(|hint| hint.name.as_str())
6211 .collect::<Vec<_>>();
6212
6213 assert!(names.contains(&"/model deepseek-ai/deepseek-v4-pro"));
6214 assert!(!names.contains(&"/model deepseek/deepseek-v4-pro"));
6215 }
6216
6217 #[test]
6218 fn slash_completion_hints_model_ollama_has_no_static_remote_models() {
6219 let hints =
6220 slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Ollama);
6221 let names = hints
6222 .iter()
6223 .map(|hint| hint.name.as_str())
6224 .collect::<Vec<_>>();
6225
6226 assert!(names.contains(&"/model"));
6227 assert!(!names.contains(&"/model deepseek-v4-pro"));
6228 assert!(!names.contains(&"/model deepseek-v4-flash"));
6229 assert!(!names.contains(&"/model deepseek-coder:1.3b"));
6230 }
6231
6232 #[test]
6233 fn truncated_slash_row_registers_its_full_localized_copy_for_hover() {
6234 let mut app = create_test_app();
6235 app.input = "/model".to_string();
6236 app.cursor_position = app.input.len();
6237 let full_name = "/model provider/very-long-model-identifier";
6238 let full_description = "切换到这个模型并保留完整的本地化说明";
6239 let entries = vec![SlashMenuEntry {
6240 name: full_name.to_string(),
6241 description: full_description.to_string(),
6242 is_skill: false,
6243 alias_hint: None,
6244 }];
6245 let area = Rect::new(0, 0, 36, 7);
6246 let mut buf = Buffer::empty(area);
6247
6248 crate::tui::hover_layer::begin_frame();
6249 ComposerWidget::new(&app, area.height, &entries, &[]).render(area, &mut buf);
6250
6251 let targets = crate::tui::hover_layer::registered_targets();
6252 assert_eq!(targets.len(), 1, "targets: {targets:?}");
6253 assert_eq!(
6254 targets[0].kind,
6255 crate::tui::hover_hit::HoverTargetKind::TruncatedText
6256 );
6257 assert!(targets[0].label.contains(full_name));
6258 assert!(targets[0].label.contains(full_description));
6259 }
6260
6261 #[test]
6262 fn complete_slash_row_does_not_register_a_hover_popover() {
6263 let mut app = create_test_app();
6264 app.input = "/help".to_string();
6265 app.cursor_position = app.input.len();
6266 let entries = vec![SlashMenuEntry {
6267 name: "/help".to_string(),
6268 description: "Show help".to_string(),
6269 is_skill: false,
6270 alias_hint: None,
6271 }];
6272 let area = Rect::new(0, 0, 80, 7);
6273 let mut buf = Buffer::empty(area);
6274
6275 crate::tui::hover_layer::begin_frame();
6276 ComposerWidget::new(&app, area.height, &entries, &[]).render(area, &mut buf);
6277
6278 assert!(crate::tui::hover_layer::registered_targets().is_empty());
6279 }
6280
6281 #[test]
6282 fn selection_style_uses_explicit_selection_text_role() {
6283 let line = Line::from(Span::styled(
6284 "hello world",
6285 Style::default().fg(palette::TEXT_PRIMARY),
6286 ));
6287 let selection_style = Style::default()
6288 .bg(palette::SELECTION_BG)
6289 .fg(palette::SELECTION_TEXT);
6290
6291 let styled = apply_selection_to_line(&line, 0, 5, selection_style);
6292 assert_eq!(styled.len(), 2);
6293 assert_eq!(styled[0].content.as_ref(), "hello");
6294 assert_eq!(styled[0].style.fg, Some(palette::SELECTION_TEXT));
6295 assert_eq!(styled[0].style.bg, Some(palette::SELECTION_BG));
6296 assert_eq!(styled[1].content.as_ref(), " world");
6297 }
6298
6299 #[test]
6300 fn selection_keeps_keycap_grapheme_intact() {
6301 let line = Line::from(Span::raw("A1\u{fe0f}\u{20e3}B"));
6302 let selection_style = Style::default().bg(palette::SELECTION_BG);
6303
6304 // Selecting the second display column of the two-column keycap must
6305 // style the complete grapheme, never only FE0F/U+20E3.
6306 let styled = apply_selection_to_line(&line, 2, 3, selection_style);
6307 assert_eq!(styled.len(), 3);
6308 assert_eq!(styled[0].content.as_ref(), "A");
6309 assert_eq!(styled[1].content.as_ref(), "1\u{fe0f}\u{20e3}");
6310 assert_eq!(styled[1].style.bg, Some(palette::SELECTION_BG));
6311 assert_eq!(styled[2].content.as_ref(), "B");
6312 }
6313
6314 #[test]
6315 fn composer_layout_helpers_stay_consistent() {
6316 let input = "line one wraps nicely\nline two wraps as well";
6317 let width = 16;
6318 let available_height = 6;
6319 let menu_lines = 2;
6320
6321 let height = composer_height(
6322 input,
6323 width,
6324 available_height,
6325 menu_lines,
6326 ComposerDensity::Comfortable,
6327 true,
6328 );
6329 let has_panel = enclosed_composer_panel_fits(true, width, available_height);
6330 let chrome_height = if has_panel {
6331 usize::from(COMPOSER_PANEL_HEIGHT)
6332 } else {
6333 1
6334 };
6335 let measurement_area = Rect::new(0, 0, width, if has_panel { 3 } else { 1 });
6336 let content_width =
6337 composer_content_geometry(composer_inner_area(measurement_area, has_panel), false)
6338 .text_width();
6339 let input_height_budget = usize::from(height)
6340 .saturating_sub(menu_lines)
6341 .saturating_sub(chrome_height)
6342 .max(1);
6343 let (visible, cursor_row, cursor_col) = layout_input(
6344 input,
6345 input.chars().count(),
6346 content_width,
6347 input_height_budget,
6348 );
6349
6350 assert!(visible.len().saturating_add(menu_lines) <= usize::from(height));
6351 assert!(!visible.is_empty());
6352 assert!(cursor_row < visible.len());
6353 assert!(cursor_col < content_width.max(1));
6354 assert!(height >= 5);
6355 }
6356
6357 #[test]
6358 fn composer_height_prefers_panel_shape_when_space_allows() {
6359 let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
6360 assert_eq!(height, 4);
6361 }
6362
6363 #[test]
6364 fn composer_panel_height_and_render_policy_agree_at_width_boundary() {
6365 let mut app = create_test_app();
6366 app.composer_border = true;
6367 app.composer_density = ComposerDensity::Comfortable;
6368 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6369 let mention_menu_entries = Vec::<String>::new();
6370 let widget = ComposerWidget::new(&app, 8, &slash_menu_entries, &mention_menu_entries);
6371
6372 for (width, expected_panel, expected_height) in
6373 [(11, false, 3), (12, true, 4), (13, true, 4), (14, true, 4)]
6374 {
6375 let height = widget.desired_height(width);
6376 let area = Rect::new(0, 0, width, height);
6377
6378 assert_eq!(height, expected_height, "width={width}");
6379 assert_eq!(widget.has_panel(area), expected_panel, "width={width}");
6380 assert_eq!(
6381 widget.inner_area(area).height,
6382 2,
6383 "width={width} comfortable composer reserves two input rows plus \
6384 every rendered border row"
6385 );
6386
6387 let mut buf = Buffer::empty(area);
6388 widget.render(area, &mut buf);
6389 assert_eq!(
6390 buf[(1, area.bottom().saturating_sub(1))].symbol() == "\u{2500}",
6391 expected_panel,
6392 "width={width} bottom border disagrees with height policy"
6393 );
6394 if expected_panel {
6395 let shell = crate::tui::composer_chrome::tideline_composer_geometry(area);
6396 assert_eq!(
6397 widget.inner_area(area),
6398 Rect::new(1, 1, shell.content.right().saturating_sub(1), 2,),
6399 "width={width} panel input area must reserve the send control and breathing cell"
6400 );
6401 assert_eq!(buf[(area.left(), area.top())].symbol(), "\u{256d}");
6402 assert_eq!(
6403 buf[(area.right().saturating_sub(1), area.top())].symbol(),
6404 "\u{256e}"
6405 );
6406 assert_eq!(
6407 buf[(area.left(), area.bottom().saturating_sub(1))].symbol(),
6408 "\u{2570}"
6409 );
6410 assert_eq!(
6411 buf[(
6412 area.right().saturating_sub(1),
6413 area.bottom().saturating_sub(1)
6414 )]
6415 .symbol(),
6416 "\u{256f}"
6417 );
6418 assert_eq!(
6419 buf[(area.left(), area.y.saturating_add(1))].symbol(),
6420 "\u{2502}"
6421 );
6422 assert_eq!(
6423 buf[(area.right().saturating_sub(1), area.y.saturating_add(1))].symbol(),
6424 "\u{2502}"
6425 );
6426 } else {
6427 assert_eq!(
6428 widget.inner_area(area),
6429 Rect::new(area.x, area.y.saturating_add(1), area.width, 2),
6430 "width={width} compact fallback must keep its full input width"
6431 );
6432 assert_ne!(buf[(area.left(), area.top())].symbol(), "\u{256d}");
6433 }
6434 }
6435 }
6436
6437 #[test]
6438 fn composer_height_wraps_to_the_rounded_panel_content_width() {
6439 // At the minimum viable panel width, the side rails, prompt gutter,
6440 // shared `[↵]` control, and its breathing cell leave three text
6441 // columns. Measuring against the old width would render extra lines
6442 // without allocating their rows.
6443 let height = composer_height(
6444 "123456789",
6445 super::COMPOSER_PANEL_MIN_WIDTH,
6446 8,
6447 0,
6448 ComposerDensity::Comfortable,
6449 true,
6450 );
6451 assert_eq!(height, 6);
6452 }
6453
6454 #[test]
6455 fn composer_expands_for_multiline_input_and_collapses_again() {
6456 let height_for =
6457 |input| composer_height(input, 40, 12, 0, ComposerDensity::Comfortable, true);
6458
6459 let collapsed = height_for("short");
6460 let expanded = height_for("one\ntwo\nthree\nfour\nfive\nsix");
6461 let collapsed_again = height_for("short");
6462
6463 // Comfortable: two input rows + top/bottom panel borders.
6464 assert_eq!(collapsed, 4);
6465 // Six content rows + two borders, still under the Comfortable cap of 9.
6466 assert_eq!(expanded, 8);
6467 assert!(expanded > collapsed);
6468 assert_eq!(collapsed_again, collapsed);
6469 }
6470
6471 /// Issue #4809 acceptance: the composer auto-fits its content through the
6472 /// real widget path — typed input, `submit_input`, `clear_input` — not just
6473 /// through the pure height helper.
6474 #[test]
6475 fn composer_auto_fits_typed_lines_and_returns_to_density_floor_on_submit_or_clear() {
6476 const WIDTH: u16 = 40;
6477 const AVAILABLE: u16 = 24;
6478
6479 fn measure(app: &App) -> (u16, u16) {
6480 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6481 let mention_menu_entries = Vec::<String>::new();
6482 let widget =
6483 ComposerWidget::new(app, AVAILABLE, &slash_menu_entries, &mention_menu_entries);
6484 let total = widget.desired_height(WIDTH);
6485 let inner = widget.inner_area(Rect::new(0, 0, WIDTH, total)).height;
6486 (total, inner)
6487 }
6488
6489 let mut app = create_test_app();
6490 app.composer_border = true;
6491 app.composer_density = ComposerDensity::Comfortable;
6492
6493 // Empty composer: one input row and one quiet row inside the borders.
6494 assert_eq!(measure(&app), (4, 2), "empty composer");
6495
6496 app.insert_str("one line");
6497 assert_eq!(measure(&app), (4, 2), "single-line composer");
6498
6499 // Typing N lines grows the composer to N input rows while N is under
6500 // the Comfortable cap of 9 total rows (7 input rows + 2 borders).
6501 for n in 2..=7u16 {
6502 app.clear_input();
6503 let text = (1..=n)
6504 .map(|i| format!("line {i}"))
6505 .collect::<Vec<_>>()
6506 .join("\n");
6507 app.insert_str(&text);
6508 assert_eq!(measure(&app), (n + 2, n), "{n} typed lines");
6509 }
6510
6511 // Past the cap the density setting wins, not the content.
6512 app.clear_input();
6513 app.insert_str(&vec!["over"; 40].join("\n"));
6514 let cap = composer_max_height(ComposerDensity::Comfortable);
6515 assert_eq!(measure(&app), (cap, cap - 2), "content beyond the cap");
6516
6517 // Submitting returns the composer to its stable density floor.
6518 assert!(app.submit_input().is_some());
6519 assert_eq!(measure(&app), (4, 2), "after submit");
6520
6521 // So does clearing a fresh multi-line draft.
6522 app.insert_str("a\nb\nc\nd");
6523 assert_eq!(measure(&app), (6, 4), "four-line draft");
6524 app.clear_input();
6525 assert_eq!(measure(&app), (4, 2), "after clear");
6526 }
6527
6528 #[test]
6529 fn composer_height_uses_quiet_rule_when_panel_is_not_needed() {
6530 let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
6531 let without_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, false);
6532
6533 // Quiet composer keeps a single top rule over the comfortable
6534 // input floor; the panel shape adds its bottom border.
6535 assert_eq!(with_border, 4);
6536 assert_eq!(without_border, 3);
6537 assert!(without_border < with_border);
6538 }
6539
6540 #[test]
6541 fn composer_density_changes_height_cap() {
6542 assert!(
6543 composer_max_height(ComposerDensity::Spacious)
6544 > composer_max_height(ComposerDensity::Compact)
6545 );
6546 }
6547
6548 #[test]
6549 fn composer_content_geometry_is_the_single_prompt_adjusted_text_rect() {
6550 let inner = Rect::new(10, 4, 7, 3);
6551 let normal = composer_content_geometry(inner, false);
6552 assert_eq!(normal.prompt_inset, 2);
6553 assert_eq!(normal.text_area, Rect::new(12, 4, 5, 3));
6554 assert_eq!(normal.text_width(), 5);
6555
6556 let history = composer_content_geometry(inner, true);
6557 assert_eq!(history.prompt_inset, 0);
6558 assert_eq!(history.text_area, inner);
6559
6560 let narrow = composer_content_geometry(Rect::new(3, 2, 2, 1), false);
6561 assert_eq!(narrow.prompt_inset, 0);
6562 assert_eq!(narrow.text_area, Rect::new(3, 2, 2, 1));
6563 }
6564
6565 #[test]
6566 fn composer_wrap_boundary_cursor_scroll_and_mouse_lines_share_text_width() {
6567 let geometry = composer_content_geometry(Rect::new(0, 0, 7, 2), false);
6568 let input = "abcde";
6569 let cursor = input.chars().count();
6570 let width = geometry.text_width();
6571
6572 let (absolute_row, absolute_col) = cursor_row_col(input, cursor, width);
6573 let (visible, visible_row, visible_col, scroll_offset) =
6574 layout_input_with_scroll(input, cursor, width, 1);
6575 let mouse_lines = wrap_input_lines_for_mouse(input, width);
6576
6577 assert_eq!((absolute_row, absolute_col), (1, 0));
6578 assert_eq!(scroll_offset, 1);
6579 assert_eq!((visible_row, visible_col), (0, 0));
6580 assert_eq!(visible, vec![String::new()]);
6581 assert_eq!(mouse_lines[scroll_offset], (cursor, String::new()));
6582 }
6583
6584 #[test]
6585 fn empty_composer_keeps_prompt_and_hint_on_one_row() {
6586 let mut app = create_test_app();
6587 // Pin density so the test is independent of any loaded user settings.
6588 app.composer_density = ComposerDensity::Comfortable;
6589 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6590 let mention_menu_entries = Vec::<String>::new();
6591 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6592
6593 // Use a wide area so the placeholder fits on one line (no wrapping).
6594 let area = Rect {
6595 x: 0,
6596 y: 0,
6597 width: 40,
6598 height: 5,
6599 };
6600
6601 // The two border rows carry independent permission/mode signals.
6602 // inner_area: {x:1, y:1, w:38, h:3}
6603 // input_rows_budget = 3
6604 // The prompt and hint share one quiet row.
6605 assert_eq!(
6606 empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 3),
6607 1
6608 );
6609 assert_eq!(widget.cursor_pos(area), Some((3, 2)));
6610 }
6611
6612 #[test]
6613 fn empty_composer_cursor_accounts_for_wrapped_placeholder_hint() {
6614 let mut app = create_test_app();
6615 app.composer_density = ComposerDensity::Comfortable;
6616 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6617 let mention_menu_entries = Vec::<String>::new();
6618 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6619
6620 // Narrow area forces the placeholder to wrap.
6621 let area = Rect {
6622 x: 0,
6623 y: 0,
6624 width: 14,
6625 height: 5,
6626 };
6627
6628 // inner_area: {x:1, y:1, w:12, h:3}
6629 // input_rows_budget = 3
6630 // placeholder_visual_lines(12) = 3
6631 // The narrow fallback still reserves one composer row; Paragraph
6632 // clipping keeps it from growing the shell.
6633 assert_eq!(placeholder_visual_lines(12), 3);
6634 assert_eq!(
6635 empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 3),
6636 1
6637 );
6638 assert_eq!(widget.cursor_pos(area), Some((3, 2)));
6639 }
6640
6641 #[test]
6642 fn empty_composer_renders_prompt_and_hint_on_cursor_row() {
6643 let mut app = create_test_app();
6644 app.composer_density = ComposerDensity::Comfortable;
6645 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6646 let mention_menu_entries = Vec::<String>::new();
6647 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6648 let area = Rect {
6649 x: 0,
6650 y: 0,
6651 width: 40,
6652 height: 5,
6653 };
6654 let mut buf = Buffer::empty(area);
6655
6656 widget.render(area, &mut buf);
6657 let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else {
6658 panic!("empty composer should expose cursor position");
6659 };
6660 let rendered = buffer_text(&buf, area);
6661 let placeholder = composer_empty_hint_text(&app).into_owned();
6662 let first_placeholder_cell = placeholder
6663 .chars()
6664 .next()
6665 .expect("composer placeholder should not be empty")
6666 .to_string();
6667
6668 assert_eq!(buf[(cursor_x, cursor_y)].symbol(), first_placeholder_cell);
6669 assert_eq!(
6670 buf[(cursor_x, cursor_y)].fg,
6671 app.ui_theme.text_soft,
6672 "the idle prompt should use the readable soft-text role"
6673 );
6674 assert!(
6675 !buf[(cursor_x, cursor_y)]
6676 .modifier
6677 .contains(Modifier::ITALIC),
6678 "the idle prompt should remain upright at distance"
6679 );
6680 assert!(
6681 rendered.contains(&placeholder),
6682 "placeholder hint should render on the prompt row: {rendered}"
6683 );
6684 assert!(
6685 row_text(&buf, area, cursor_y).contains(&placeholder),
6686 "prompt and hint should share one row: {rendered}"
6687 );
6688 let inner = widget.inner_area(area);
6689 let quiet_row = cursor_y.saturating_add(1);
6690 // The quiet row hosts exactly one thing: the shared `[↵]` affordance
6691 // on its recorded hitbox cells. Every other cell stays blank.
6692 let submit = active_composer_submit_rect(&app, area).expect("enclosed composer submit");
6693 assert!(
6694 quiet_row < inner.bottom()
6695 && (inner.x..inner.right()).all(|x| {
6696 let on_submit =
6697 submit.y == quiet_row && x >= submit.x && x < submit.x + submit.width;
6698 on_submit || buf[(x, quiet_row)].symbol() == " "
6699 }),
6700 "comfortable composer should keep a quiet content row before the footer, hosting only the shared [↵]: {rendered}"
6701 );
6702 let painted: String = (submit.x..submit.x + submit.width)
6703 .map(|x| buf[(x, submit.y)].symbol().to_string())
6704 .collect();
6705 assert_eq!(
6706 painted, "[·]",
6707 "the empty composer has an inactive send cue"
6708 );
6709 }
6710
6711 #[test]
6712 fn composer_keeps_prompt_anchored_after_first_keystroke() {
6713 let mut app = create_test_app();
6714 app.composer_density = ComposerDensity::Comfortable;
6715 app.input = "hello".to_string();
6716 app.cursor_position = app.input.len();
6717 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6718 let mention_menu_entries = Vec::<String>::new();
6719 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6720 let area = Rect::new(0, 0, 40, 5);
6721 let mut buf = Buffer::empty(area);
6722
6723 widget.render(area, &mut buf);
6724 let (cursor_x, cursor_y) = widget
6725 .cursor_pos(area)
6726 .expect("composer with input should expose a cursor");
6727
6728 assert_eq!(buf[(1, cursor_y)].symbol(), "❯");
6729 assert_eq!(buf[(3, cursor_y)].symbol(), "h");
6730 assert_eq!(cursor_x, 8, "cursor keeps the prompt gutter reserved");
6731 }
6732
6733 fn render_composer(app: &App, width: u16, height: u16) -> String {
6734 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6735 let mention_menu_entries = Vec::<String>::new();
6736 let widget = ComposerWidget::new(app, height, &slash_menu_entries, &mention_menu_entries);
6737 let area = Rect::new(0, 0, width, height);
6738 let mut buf = Buffer::empty(area);
6739 widget.render(area, &mut buf);
6740 buffer_text(&buf, area)
6741 }
6742
6743 #[test]
6744 fn composer_empty_hint_names_a_follow_up_while_a_turn_is_running() {
6745 let mut app = create_test_app();
6746 assert_eq!(composer_empty_hint_text(&app).as_ref(), "Type a message…");
6747
6748 app.is_loading = true;
6749 assert_eq!(composer_empty_hint_text(&app).as_ref(), "Type a follow-up…");
6750
6751 app.queue_message(QueuedMessage::new("later".to_string(), None));
6752 assert_eq!(
6753 composer_empty_hint_text(&app).as_ref(),
6754 "Enter send now · type another"
6755 );
6756 }
6757
6758 #[test]
6759 fn composer_submit_hint_names_send_after_this_turn_without_steer() {
6760 let mut app = create_test_app();
6761 app.input = "keep going".to_string();
6762 app.cursor_position = app.input.chars().count();
6763 assert!(composer_submit_hint(&app).is_none());
6764
6765 app.is_loading = true;
6766 let hint = composer_submit_hint(&app).expect("busy draft should name Enter");
6767 assert_eq!(hint.text, "↵ send after this turn");
6768 assert!(
6769 !hint.text.to_ascii_lowercase().contains("steer"),
6770 "composer hint leaked internal vocabulary: {}",
6771 hint.text
6772 );
6773
6774 app.queue_message(QueuedMessage::new("first".to_string(), None));
6775 let hint = composer_submit_hint(&app).expect("queued count should stay visible");
6776 assert_eq!(hint.text, "↵ send after this turn (2 waiting)");
6777 }
6778
6779 #[test]
6780 fn composer_submit_hint_renders_at_release_floor_widths() {
6781 let mut app = create_test_app();
6782 app.composer_border = true;
6783 app.is_loading = true;
6784 app.input = "keep going".to_string();
6785 app.cursor_position = app.input.chars().count();
6786
6787 for (width, height) in [(40_u16, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
6788 let rendered = render_composer(&app, width, height);
6789 assert!(
6790 rendered.contains("send after this turn"),
6791 "missing queue hint at {width}x{height}:\n{rendered}"
6792 );
6793 assert!(
6794 !rendered.to_ascii_lowercase().contains("steer"),
6795 "steer vocabulary at {width}x{height}:\n{rendered}"
6796 );
6797 }
6798 }
6799
6800 #[test]
6801 fn quiet_composer_still_shows_the_submit_hint() {
6802 let mut app = create_test_app();
6803 app.composer_border = false;
6804 app.is_loading = true;
6805 app.input = "keep going".to_string();
6806 app.cursor_position = app.input.chars().count();
6807 let rendered = render_composer(&app, 80, 4);
6808 assert!(
6809 rendered.contains("send after this turn"),
6810 "quiet composer hid the Enter action:\n{rendered}"
6811 );
6812 }
6813
6814 #[test]
6815 fn composer_border_omits_session_title_chrome() {
6816 // The top-right composer chrome (session title / receipts / vim mode)
6817 // was classic-shell-only; with the classic shell removed the composer
6818 // border never carries it. Session identity lives in the header.
6819 let mut app = create_test_app();
6820 app.composer_density = ComposerDensity::Comfortable;
6821 app.session_title = Some("my-session".to_string());
6822 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6823 let mention_menu_entries = Vec::<String>::new();
6824 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6825 let area = Rect {
6826 x: 0,
6827 y: 0,
6828 width: 96,
6829 height: 5,
6830 };
6831 let mut buf = Buffer::empty(area);
6832
6833 widget.render(area, &mut buf);
6834 let rendered = buffer_text(&buf, area);
6835
6836 assert!(!rendered.contains("Composer"));
6837 assert!(!rendered.contains("my-session"));
6838 }
6839
6840 #[test]
6841 fn composer_border_omits_active_turn_receipt_chrome() {
6842 let mut app = create_test_app();
6843 app.composer_density = ComposerDensity::Comfortable;
6844 app.set_receipt_text("✓ turn completed · 2 tool(s) used");
6845 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6846 let mention_menu_entries = Vec::<String>::new();
6847 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
6848 let area = Rect {
6849 x: 0,
6850 y: 0,
6851 width: 96,
6852 height: 5,
6853 };
6854 let mut buf = Buffer::empty(area);
6855
6856 widget.render(area, &mut buf);
6857 let rendered = buffer_text(&buf, area);
6858
6859 assert!(!rendered.contains("Composer"));
6860 assert!(!rendered.contains("turn completed"));
6861 assert!(!rendered.contains("tool(s) used"));
6862 }
6863
6864 #[test]
6865 fn composer_outline_tracks_focus_without_repeating_permission_or_mode() {
6866 let slash = Vec::<SlashMenuEntry>::new();
6867 let mentions = Vec::<String>::new();
6868 let area = Rect::new(0, 0, 40, 5);
6869 for theme_id in palette::SELECTABLE_THEMES {
6870 let mut app = create_test_app();
6871 app.ui_theme = theme_id.ui_theme();
6872 app.launch.visible = true;
6873 for selected in [None, Some(0)] {
6874 app.launch.menu_selected = selected;
6875 let widget = ComposerWidget::new(&app, 5, &slash, &mentions);
6876 let mut buf = Buffer::empty(area);
6877 widget.render(area, &mut buf);
6878 let expected = if selected.is_none() {
6879 app.ui_theme.accent_primary
6880 } else {
6881 app.ui_theme.border
6882 };
6883 for cell in [(1, 0), (1, 4), (0, 1), (39, 1)] {
6884 assert_eq!(buf[cell].fg, expected, "{} {selected:?}", theme_id.name());
6885 }
6886 }
6887 }
6888 }
6889
6890 #[test]
6891 fn composer_border_keeps_mode_titles_contextual() {
6892 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
6893 let mention_menu_entries = Vec::<String>::new();
6894 let area = Rect {
6895 x: 0,
6896 y: 0,
6897 width: 96,
6898 height: 5,
6899 };
6900
6901 let mut normal_app = create_test_app();
6902 normal_app.composer_density = ComposerDensity::Comfortable;
6903 let normal_widget =
6904 ComposerWidget::new(&normal_app, 5, &slash_menu_entries, &mention_menu_entries);
6905 let mut normal_buf = Buffer::empty(area);
6906 normal_widget.render(area, &mut normal_buf);
6907 let normal_rendered = buffer_text(&normal_buf, area);
6908 assert!(!normal_rendered.contains("Composer"));
6909 assert!(!normal_rendered.contains("Draft"));
6910 assert!(
6911 !normal_rendered
6912 .contains(&*normal_app.tr(codewhale_localization::MessageId::HistorySearchTitle))
6913 );
6914
6915 let mut draft_app = create_test_app();
6916 draft_app.composer_density = ComposerDensity::Comfortable;
6917 draft_app.insert_str("first line\nsecond line");
6918 let draft_widget =
6919 ComposerWidget::new(&draft_app, 5, &slash_menu_entries, &mention_menu_entries);
6920 let mut draft_buf = Buffer::empty(area);
6921 draft_widget.render(area, &mut draft_buf);
6922 // Multi-line drafts no longer announce themselves with a block title;
6923 // the user can see the draft. Only history search keeps its title.
6924 assert!(!buffer_text(&draft_buf, area).contains("Draft"));
6925
6926 let mut search_app = create_test_app();
6927 search_app.composer_density = ComposerDensity::Comfortable;
6928 search_app.start_history_search();
6929 let search_widget =
6930 ComposerWidget::new(&search_app, 5, &slash_menu_entries, &mention_menu_entries);
6931 let mut search_buf = Buffer::empty(area);
6932 search_widget.render(area, &mut search_buf);
6933 assert!(
6934 buffer_text(&search_buf, area)
6935 .contains(&*search_app.tr(codewhale_localization::MessageId::HistorySearchTitle))
6936 );
6937 }
6938
6939 #[test]
6940 fn enclosed_composer_paints_the_shared_send_hitbox() {
6941 let mut app = create_test_app();
6942 app.composer_border = true;
6943 app.input = "ship it".to_string();
6944 app.cursor_position = app.input.chars().count();
6945 for (width, height) in [(40_u16, 12), (60, 16), (80, 24), (100, 32), (120, 32)] {
6946 let rendered = render_composer(&app, width, height);
6947 assert!(
6948 rendered.contains("[↵]"),
6949 "missing send affordance at {width}x{height}:\n{rendered}"
6950 );
6951 assert!(
6952 !rendered.contains("▚△▞"),
6953 "retired crown must stay gone at {width}x{height}:\n{rendered}"
6954 );
6955 }
6956 }
6957
6958 #[test]
6959 fn composer_submit_ink_matches_real_submit_readiness() {
6960 let mut app = create_test_app();
6961 app.composer_border = true;
6962 let slash = Vec::<SlashMenuEntry>::new();
6963 let mentions = Vec::<String>::new();
6964 let area = Rect::new(0, 0, 80, 8);
6965 for draft in ["", " ", "ship it"] {
6966 app.input = draft.to_string();
6967 app.cursor_position = app.input.chars().count();
6968 let widget = ComposerWidget::new(&app, 8, &slash, &mentions);
6969 let mut buf = Buffer::empty(area);
6970 widget.render(area, &mut buf);
6971 let submit = active_composer_submit_rect(&app, area).unwrap();
6972 let ready = app.composer_enter_would_submit();
6973 let painted: String = (submit.x..submit.right())
6974 .map(|x| buf[(x, submit.y)].symbol())
6975 .collect();
6976 assert_eq!(painted, if ready { "[↵]" } else { "[·]" });
6977 assert_eq!(
6978 buf[(submit.x, submit.y)].modifier.contains(Modifier::BOLD),
6979 ready
6980 );
6981 let role = if ready {
6982 codewhale_palette::ChromeInk::Info
6983 } else {
6984 codewhale_palette::ChromeInk::MetadataDim
6985 };
6986 assert_eq!(
6987 buf[(submit.x, submit.y)].fg,
6988 codewhale_palette::chrome_style(&app.ui_theme, role)
6989 .fg
6990 .unwrap()
6991 );
6992 }
6993 }
6994
6995 #[test]
6996 fn enclosed_composer_send_hitbox_matches_painted_cells() {
6997 let mut app = create_test_app();
6998 app.composer_border = true;
6999 app.input = "x".repeat(240);
7000 app.cursor_position = app.input.chars().count();
7001 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
7002 let mention_menu_entries = Vec::<String>::new();
7003 let widget = ComposerWidget::new(&app, 8, &slash_menu_entries, &mention_menu_entries);
7004 let area = Rect::new(0, 0, 80, 8);
7005 let mut buf = Buffer::empty(area);
7006 widget.render(area, &mut buf);
7007 let submit = active_composer_submit_rect(&app, area).expect("enclosed composer submit");
7008 let painted: String = (submit.x..submit.x + submit.width)
7009 .map(|x| buf[(x, submit.y)].symbol().to_string())
7010 .collect();
7011 assert_eq!(painted, "[↵]", "geometry must cover the painted send cells");
7012 }
7013
7014 #[test]
7015 fn enclosed_composer_reserves_submit_cells_for_a_74_character_draft() {
7016 let mut app = create_test_app();
7017 app.composer_border = true;
7018 let draft = "x".repeat(74);
7019 app.input = draft.clone();
7020 app.cursor_position = app.input.chars().count();
7021 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
7022 let mention_menu_entries = Vec::<String>::new();
7023 let widget = ComposerWidget::new(&app, 8, &slash_menu_entries, &mention_menu_entries);
7024 let area = Rect::new(0, 0, 80, 8);
7025 let mut buf = Buffer::empty(area);
7026 widget.render(area, &mut buf);
7027
7028 let submit = active_composer_submit_rect(&app, area).expect("enclosed composer submit");
7029 let input_plane = widget.inner_area(area);
7030 let text_area = composer_content_geometry(input_plane, false).text_area;
7031 assert_eq!(
7032 text_area.right(),
7033 submit.x.saturating_sub(1),
7034 "one blank cell must remain between draft text and submit"
7035 );
7036
7037 let (cursor_x, cursor_y) = widget.cursor_pos(area).expect("draft cursor");
7038 assert!(
7039 cursor_x < submit.x || cursor_x >= submit.right() || cursor_y != submit.y,
7040 "cursor {cursor_x},{cursor_y} must not land in submit {submit:?}"
7041 );
7042 assert_eq!(app.input, draft, "rendering must retain the full draft");
7043
7044 let first_line: String = (text_area.x..text_area.right())
7045 .map(|x| buf[(x, cursor_y.saturating_sub(1))].symbol().to_string())
7046 .collect();
7047 let continuation: String = (text_area.x..text_area.x.saturating_add(3))
7048 .map(|x| buf[(x, cursor_y)].symbol().to_string())
7049 .collect();
7050 assert_eq!(first_line, "x".repeat(71), "first wrapped draft row");
7051 assert_eq!(
7052 continuation, "xxx",
7053 "draft continuation must remain visible"
7054 );
7055 let painted: String = (submit.x..submit.right())
7056 .map(|x| buf[(x, submit.y)].symbol().to_string())
7057 .collect();
7058 assert_eq!(painted, "[↵]", "submit stays intact beside the draft");
7059 }
7060
7061 #[test]
7062 fn composer_send_hitbox_only_exists_where_the_panel_paints() {
7063 let mut app = create_test_app();
7064 app.composer_border = true;
7065 // Widths 6–11 fail COMPOSER_PANEL_MIN_WIDTH: the painter sheds the
7066 // enclosure there, so no invisible hit target may remain.
7067 for width in 6..12_u16 {
7068 let area = Rect::new(0, 0, width, 4);
7069 assert!(
7070 active_composer_submit_rect(&app, area).is_none(),
7071 "no hitbox without the painted panel at width {width}"
7072 );
7073 }
7074 let area = Rect::new(0, 0, 12, 4);
7075 assert!(
7076 active_composer_submit_rect(&app, area).is_some(),
7077 "the minimum panel width hosts the hitbox"
7078 );
7079 // Short composer rows and the quiet opt-out shed the hitbox too.
7080 assert!(active_composer_submit_rect(&app, Rect::new(0, 0, 80, 2)).is_none());
7081 app.composer_border = false;
7082 assert!(active_composer_submit_rect(&app, Rect::new(0, 0, 80, 4)).is_none());
7083 }
7084
7085 #[test]
7086 fn quiet_composer_does_not_paint_a_fake_send_control() {
7087 let mut app = create_test_app();
7088 app.composer_border = false;
7089 app.input = "ship it".to_string();
7090 app.cursor_position = app.input.chars().count();
7091 let rendered = render_composer(&app, 80, 4);
7092 assert!(
7093 !rendered.contains("[↵]"),
7094 "compact composer must shed the send chrome:\n{rendered}"
7095 );
7096 }
7097
7098 #[test]
7099 fn slash_menu_open_locks_composer_height_against_match_count_changes() {
7100 // Repro for the Windows 10 PowerShell + WSL feedback: typing
7101 // through a slash command shrinks the matched-entry list, which
7102 // used to shrink the composer height — and shrinking the
7103 // composer forces the chat area above to repaint every
7104 // keystroke. With the height lock, the desired height returned
7105 // for a 5-match menu and a 1-match menu must be identical so
7106 // the layout stays stable for the lifetime of the slash session.
7107 let mut app = create_test_app();
7108 app.composer_density = ComposerDensity::Comfortable;
7109 app.input = "/skill".to_string();
7110
7111 let many_matches: Vec<SlashMenuEntry> = (0..5)
7112 .map(|i| SlashMenuEntry {
7113 name: format!("/skill{i}"),
7114 description: String::new(),
7115 is_skill: false,
7116 alias_hint: None,
7117 })
7118 .collect();
7119 let one_match = vec![SlashMenuEntry {
7120 name: "/skill".to_string(),
7121 description: String::new(),
7122 is_skill: false,
7123 alias_hint: None,
7124 }];
7125 let no_matches = Vec::<SlashMenuEntry>::new();
7126
7127 let widget_many = ComposerWidget::new(&app, 9, &many_matches, &[]);
7128 let widget_one = ComposerWidget::new(&app, 9, &one_match, &[]);
7129 let widget_none = ComposerWidget::new(&app, 9, &no_matches, &[]);
7130
7131 // Fixed worst-case envelope while the slash menu is open.
7132 let height_many = widget_many.desired_height(40);
7133 let height_one = widget_one.desired_height(40);
7134 assert_eq!(
7135 height_many, height_one,
7136 "slash menu height must not jitter as the matched-entry count changes"
7137 );
7138
7139 // Sanity: closing the slash menu (no matches) lets the panel
7140 // collapse back to a tight composer — we only want to lock
7141 // height *while* the menu is open.
7142 let height_none = widget_none.desired_height(40);
7143 assert!(
7144 height_none < height_many,
7145 "with the menu closed the composer should release the reserved rows; got {height_none} vs locked {height_many}"
7146 );
7147 }
7148
7149 #[test]
7150 fn empty_composer_cursor_follows_idle_prompt_when_border_disabled() {
7151 let mut app = create_test_app();
7152 app.composer_density = ComposerDensity::Comfortable;
7153 app.composer_border = false;
7154 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
7155 let mention_menu_entries = Vec::<String>::new();
7156 let widget = ComposerWidget::new(&app, 3, &slash_menu_entries, &mention_menu_entries);
7157
7158 let area = Rect {
7159 x: 0,
7160 y: 0,
7161 width: 40,
7162 height: 3,
7163 };
7164
7165 assert_eq!(widget.cursor_pos(area), Some((2, 1)));
7166 }
7167
7168 #[test]
7169 fn localized_composer_placeholders_render_at_narrow_widths() {
7170 for locale in [Locale::Ja, Locale::ZhHans, Locale::PtBr] {
7171 let mut app = create_test_app();
7172 app.ui_locale = locale;
7173 app.composer_density = ComposerDensity::Comfortable;
7174 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
7175 let mention_menu_entries = Vec::<String>::new();
7176 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
7177 let area = Rect {
7178 x: 0,
7179 y: 0,
7180 width: 18,
7181 height: 5,
7182 };
7183 let mut buf = Buffer::empty(area);
7184
7185 widget.render(area, &mut buf);
7186 let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else {
7187 panic!("localized composer should expose cursor position");
7188 };
7189
7190 assert!(cursor_x < area.width, "{locale:?} cursor x overflow");
7191 assert!(cursor_y < area.height, "{locale:?} cursor y overflow");
7192 }
7193 }
7194
7195 #[test]
7196 fn composer_top_padding_uses_clamp() {
7197 // content_lines=0 is clamped to 1
7198 assert_eq!(composer_top_padding(0, 3), 1);
7199 // content_lines=1
7200 assert_eq!(composer_top_padding(1, 3), 1);
7201 // content_lines=3 fills the budget
7202 assert_eq!(composer_top_padding(3, 3), 0);
7203 // content_lines > budget is clamped
7204 assert_eq!(composer_top_padding(5, 3), 0);
7205 }
7206
7207 #[test]
7208 fn empty_state_renders_only_without_transcript_activity() {
7209 let mut app = create_test_app();
7210 assert!(should_render_empty_state(&app));
7211 app.add_message(crate::tui::history::HistoryCell::User {
7212 content: "hello".to_string(),
7213 });
7214 assert!(!should_render_empty_state(&app));
7215 }
7216
7217 #[test]
7218 fn durable_tasks_suppress_the_launch_tableau() {
7219 let mut app = create_test_app();
7220 app.task_panel.push(TaskPanelEntry {
7221 id: "shell_1".to_string(),
7222 status: "running".to_string(),
7223 prompt_summary: "cargo test".to_string(),
7224 duration_ms: Some(100),
7225 kind: TaskPanelEntryKind::Background,
7226 stale: false,
7227 elapsed_since_output_ms: None,
7228 owner_agent_id: None,
7229 owner_agent_name: None,
7230 current_tool: None,
7231 role: None,
7232 files_touched: 0,
7233 });
7234
7235 assert!(!should_render_empty_state(&app));
7236 }
7237
7238 #[test]
7239 fn chat_widget_publishes_wrapped_url_regions_without_touching_cells() {
7240 let mut app = create_test_app();
7241 app.low_motion = true;
7242 let target = "https://example.test/a/very/long/path/that/wraps/across/chat/rows";
7243 app.add_message(HistoryCell::Assistant {
7244 content: target.to_string(),
7245 streaming: false,
7246 });
7247
7248 let area = Rect::new(4, 2, 20, 10);
7249 let mut buf = Buffer::empty(area);
7250 let _ = crate::tui::osc8::take_frame_links();
7251 ChatWidget::new(&mut app, area).render(area, &mut buf);
7252 let regions = crate::tui::osc8::take_frame_links();
7253
7254 assert!(regions.len() > 1, "narrow chat should wrap: {regions:?}");
7255 assert!(regions.iter().all(|region| region.target == target));
7256 assert!(regions.iter().all(|region| {
7257 area.contains(ratatui::layout::Position {
7258 x: region.col_start,
7259 y: region.row,
7260 }) && area.contains(ratatui::layout::Position {
7261 x: region.col_end,
7262 y: region.row,
7263 })
7264 }));
7265 assert!((area.y..area.bottom()).all(|y| {
7266 (area.x..area.right()).all(|x| {
7267 let symbol = buf[(x, y)].symbol();
7268 !symbol.contains('\x1b') && !symbol.contains("]8;;")
7269 })
7270 }));
7271 }
7272
7273 #[test]
7274 fn waiting_state_freezes_the_whole_ocean_field() {
7275 let mut app = create_test_app();
7276 app.low_motion = false;
7277 app.fancy_animations = true;
7278 app.view_stack
7279 .push(crate::tui::views::HelpView::new_for_locale(app.ui_locale));
7280
7281 let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
7282
7283 assert!(!widget.ocean_animated);
7284 assert!(!widget.ambient_life);
7285 assert!(!should_render_empty_state(&app));
7286 }
7287
7288 #[test]
7289 fn reduced_motion_gets_no_ambient_life_through_the_completion_breath() {
7290 // The completion branch of `life_presence` runs before its `!animated`
7291 // check, so feeding it an ungated clock flashed a full field of fish
7292 // and jellyfish for ~1.4 s after every successful turn even with
7293 // `low_motion = true`. Reduced motion means reduced motion.
7294 for (low_motion, fancy_animations) in [(true, true), (false, false)] {
7295 let mut app = create_test_app();
7296 app.low_motion = low_motion;
7297 app.fancy_animations = fancy_animations;
7298 app.ocean_completion_started_at = Some(Instant::now());
7299
7300 let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
7301
7302 assert_eq!(
7303 widget.life_presence_fixed, 0,
7304 "low_motion={low_motion} fancy={fancy_animations} leaked ambient life"
7305 );
7306 }
7307
7308 let mut full = create_test_app();
7309 full.low_motion = false;
7310 full.fancy_animations = true;
7311 full.ocean_completion_started_at = Some(Instant::now());
7312 let widget = ChatWidget::new(&mut full, Rect::new(0, 0, 100, 20));
7313 assert!(
7314 widget.life_presence_fixed > 0,
7315 "full motion should still get the completion breath"
7316 );
7317 }
7318
7319 #[test]
7320 fn dot_whale_gets_the_motion_gated_completion_settle_clock() {
7321 let mut app = create_test_app();
7322 app.low_motion = false;
7323 app.fancy_animations = true;
7324 app.ocean_completion_started_at =
7325 Some(Instant::now() - std::time::Duration::from_millis(900));
7326 let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 24));
7327 let age = widget
7328 .ocean_column
7329 .and_then(|column| column.completion_elapsed_ms());
7330 assert!(
7331 age.is_some_and(|age| (800..1_400).contains(&age)),
7332 "{age:?}"
7333 );
7334 assert!(widget.life_presence_fixed > 0 && widget.life_presence_fixed < 1_000);
7335
7336 app.low_motion = true;
7337 let still = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 24));
7338 assert_eq!(
7339 still
7340 .ocean_column
7341 .and_then(|column| column.completion_elapsed_ms()),
7342 None
7343 );
7344 assert_eq!(still.life_presence_fixed, 0);
7345 }
7346
7347 #[test]
7348 fn reduced_and_still_modes_clear_the_one_shot_send_flash() {
7349 for (low_motion, fancy_animations) in [(true, true), (false, false)] {
7350 let mut app = create_test_app();
7351 app.low_motion = low_motion;
7352 app.fancy_animations = fancy_animations;
7353 app.last_send_at = Some(Instant::now());
7354 app.add_message(HistoryCell::User {
7355 content: "semantic receipt".to_string(),
7356 });
7357
7358 let _widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
7359 assert!(
7360 app.last_send_at.is_none(),
7361 "non-full motion must not retain a time-based flash"
7362 );
7363 }
7364
7365 let mut full = create_test_app();
7366 full.low_motion = false;
7367 full.fancy_animations = true;
7368 full.last_send_at = Some(Instant::now());
7369 full.add_message(HistoryCell::User {
7370 content: "animated receipt".to_string(),
7371 });
7372 let _widget = ChatWidget::new(&mut full, Rect::new(0, 0, 100, 20));
7373 assert!(
7374 full.last_send_at.is_some(),
7375 "full motion should retain the active send-flash window"
7376 );
7377 }
7378
7379 #[test]
7380 fn empty_state_shows_startup_context() {
7381 let mut app = create_test_app();
7382 app.onboarding_needs_api_key = false;
7383 app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
7384 app.mcp_configured_count = 2;
7385
7386 let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
7387 let rendered = lines
7388 .iter()
7389 .map(|line| {
7390 line.spans
7391 .iter()
7392 .map(|span| span.content.as_ref())
7393 .collect::<String>()
7394 })
7395 .collect::<Vec<_>>()
7396 .join("\n");
7397
7398 assert!(rendered.contains("codewhale"));
7399 assert!(rendered.contains("/tmp/codewhale-test-workspace · no git · mcp 2"));
7400 assert!(rendered.contains("What do you want to accomplish?"));
7401 assert!(!rendered.contains("/workflow /goal /auto"));
7402 }
7403
7404 #[test]
7405 fn empty_state_centers_startup_block_by_actual_text_width() {
7406 let mut app = create_test_app();
7407 app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
7408
7409 let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
7410 let text_lines = lines
7411 .iter()
7412 .map(|line| {
7413 line.spans
7414 .iter()
7415 .map(|span| span.content.as_ref())
7416 .collect::<String>()
7417 })
7418 .collect::<Vec<_>>();
7419 let context = "/tmp/codewhale-test-workspace · no git · mcp 0";
7420 let context_line = text_lines
7421 .iter()
7422 .find(|line| line.trim_start() == context)
7423 .expect("context line");
7424 let expected_padding = (100usize - UnicodeWidthStr::width(context)) / 2;
7425 let actual_padding = context_line.chars().take_while(|ch| *ch == ' ').count();
7426
7427 assert_eq!(actual_padding, expected_padding);
7428 }
7429
7430 #[test]
7431 fn underwater_launch_is_visibly_deep_and_preserves_text_cells() {
7432 let mut app = create_test_app();
7433 // App::new reads persisted presentation settings. Other tests swap the
7434 // isolated settings home in parallel, so this visual contract must pin
7435 // the theme it is actually asserting instead of inheriting a transient
7436 // non-underwater choice from the process.
7437 app.theme_id = codewhale_palette::ThemeId::Underwater;
7438 app.ui_theme = palette::UNDERWATER_UI_THEME;
7439 app.low_motion = false;
7440 app.fancy_animations = true;
7441 app.workspace = PathBuf::from("codewhale-test-workspace");
7442 app.model = "deepseek-v4-pro".to_string();
7443
7444 let area = Rect::new(0, 0, 100, 20);
7445 let base = app.ui_theme.surface_bg;
7446 let context = format!("{} · no git · mcp 0", app.workspace.display());
7447 let mut buf = Buffer::empty(area);
7448 // Sample one known point in the live motion path. The old test raced
7449 // the scheduler between App construction and rendering, which could
7450 // move the school off-screen on slower Windows runners.
7451 ChatWidget::new_with_ocean_elapsed(&mut app, area, 0).render(area, &mut buf);
7452
7453 assert_ne!(buf[(0, 0)].bg, buf[(0, 19)].bg);
7454 let rendered = buffer_text(&buf, area);
7455 // One loose wedge school: an eyed lead plus plain members, all
7456 // facing the same way (facing equals travel by construction).
7457 let rightward = rendered.matches("><>").count() + rendered.matches("><o>").count();
7458 let leftward = rendered.matches("<><").count() + rendered.matches("<o><").count();
7459 assert!(
7460 rightward == 0 || leftward == 0,
7461 "one school shares one direction:\n{rendered}"
7462 );
7463 let fish_count = rightward + leftward;
7464 assert!(
7465 (4..=7).contains(&fish_count),
7466 "wide idle water should show one cohesive wedge school (got {fish_count}):\n{rendered}"
7467 );
7468 let leads = rendered.matches("><o>").count() + rendered.matches("<o><").count();
7469 assert_eq!(leads, 1, "exactly one eyed lead fish:\n{rendered}");
7470
7471 let context_x = ((100usize - UnicodeWidthStr::width(context.as_str())) / 2) as u16;
7472 let context_cell = (0..area.height)
7473 .find_map(|y| (buf[(context_x, y)].symbol() == "c").then_some((context_x, y)))
7474 .expect("context line");
7475 assert_eq!(
7476 buf[context_cell].bg,
7477 buf[(0, context_cell.1)].bg,
7478 "ordinary transcript text must share its row's water color"
7479 );
7480 assert_ne!(
7481 buf[context_cell].bg, base,
7482 "the water column should continue behind ordinary text"
7483 );
7484 }
7485
7486 #[test]
7487 fn terminal_owned_theme_keeps_theme_surface_without_ambient_life() {
7488 let mut app = create_test_app();
7489 app.theme_id = codewhale_palette::ThemeId::Whale;
7490 app.ui_theme = palette::UI_THEME;
7491 app.low_motion = false;
7492 app.fancy_animations = true;
7493 let area = Rect::new(0, 0, 100, 20);
7494 let base = app.ui_theme.surface_bg;
7495 let mut buf = Buffer::empty(area);
7496 let widget = ChatWidget::new(&mut app, area);
7497 assert!(!widget.ambient_life);
7498 widget.render(area, &mut buf);
7499
7500 assert_eq!(buf[(0, 0)].bg, base);
7501 assert_eq!(buf[(0, 19)].bg, base, "flat keeps the plain theme surface");
7502 let rendered = buffer_text(&buf, area);
7503 assert!(
7504 !rendered.contains("><>") && !rendered.contains("<><"),
7505 "terminal-owned themes must keep a normal shell without decorative fish:\n{rendered}"
7506 );
7507 }
7508
7509 #[test]
7510 fn solarized_light_keeps_canonical_surface_without_a_field() {
7511 let mut app = create_test_app();
7512 app.theme_id = codewhale_palette::ThemeId::SolarizedLight;
7513 app.ui_theme = codewhale_palette::SOLARIZED_LIGHT_UI_THEME;
7514 app.low_motion = false;
7515 app.fancy_animations = true;
7516 // The old cyan-tinted ramp produced the reported #e1e9da at row 16
7517 // of a common 30-row viewport.
7518 let area = Rect::new(0, 0, 100, 30);
7519 let canonical_base3 = Color::Rgb(0xfd, 0xf6, 0xe3);
7520 let mut buf = Buffer::empty(area);
7521 ChatWidget::new(&mut app, area).render(area, &mut buf);
7522
7523 assert_eq!(buf[(0, 0)].bg, canonical_base3);
7524 assert_eq!(
7525 buf[(0, 16)].bg,
7526 canonical_base3,
7527 "Solarized Light must not regress to the reported #e1e9da tint"
7528 );
7529 assert_eq!(
7530 buf[(0, 29)].bg,
7531 canonical_base3,
7532 "Solarized Light must keep canonical Base3 through the viewport"
7533 );
7534 let rendered = buffer_text(&buf, area);
7535 assert!(
7536 !rendered.contains("><>") && !rendered.contains("<><"),
7537 "a theme with no painted field earns no ambient life:\n{rendered}"
7538 );
7539 }
7540
7541 #[test]
7542 fn underwater_custom_background_keeps_field_depth() {
7543 let mut app = create_test_app();
7544 let custom = Color::Rgb(0x1a, 0x1b, 0x26);
7545 app.theme_id = codewhale_palette::ThemeId::Underwater;
7546 app.ui_theme = palette::UNDERWATER_UI_THEME.with_background_color(custom);
7547
7548 let area = Rect::new(0, 0, 100, 30);
7549 let mut buf = Buffer::empty(area);
7550 ChatWidget::new(&mut app, area).render(area, &mut buf);
7551
7552 assert_ne!(buf[(0, 0)].bg, custom);
7553 assert_ne!(
7554 buf[(0, 0)].bg,
7555 buf[(0, 29)].bg,
7556 "custom backgrounds must not flatten the underwater field"
7557 );
7558 }
7559
7560 #[test]
7561 fn terminal_owned_background_stays_visually_quiet_without_deepsea() {
7562 let mut app = create_test_app();
7563 app.theme_id = codewhale_palette::ThemeId::Terminal;
7564 app.ui_theme = codewhale_palette::TERMINAL_UI_THEME;
7565 app.low_motion = false;
7566 app.fancy_animations = true;
7567 let area = Rect::new(0, 0, 100, 20);
7568 let mut buf = Buffer::empty(area);
7569 let widget = ChatWidget::new(&mut app, area);
7570 assert!(!widget.ambient_life);
7571 widget.render(area, &mut buf);
7572
7573 assert!(
7574 (0..area.height).all(|y| (0..area.width).all(|x| buf[(x, y)].bg == Color::Reset)),
7575 "the Terminal treatment must never paint a background"
7576 );
7577 let rendered = buffer_text(&buf, area);
7578 assert!(
7579 !rendered.contains("><>") && !rendered.contains("<><"),
7580 "Terminal must remain a quiet host-owned shell without the selected Deepsea scene:\n{rendered}"
7581 );
7582 }
7583
7584 /// #4208: `CODEWHALE_ASCII_SAFE=1` must narrow every CodeWhale-authored
7585 /// decorative glyph — whale mark, fish, bubble, context meter, borders,
7586 /// braille state markers — across real rendered surfaces, not a
7587 /// hand-picked symbol list.
7588 #[test]
7589 fn ascii_safe_tier_covers_whole_rendered_surfaces() {
7590 let mut app = create_test_app();
7591 app.low_motion = false;
7592 app.fancy_animations = true;
7593
7594 // Idle empty water at a size that earns the whale, fish, and bubble.
7595 let transcript_area = Rect::new(0, 0, 100, 32);
7596 let mut transcript = Buffer::empty(transcript_area);
7597 ChatWidget::new(&mut app, transcript_area).render(transcript_area, &mut transcript);
7598
7599 // The opening screen is the ordinary idle transcript now, so its
7600 // content comes from the same empty-state builder every other screen
7601 // uses rather than a second surface.
7602 app.launch.visible = true;
7603 let launch_area = Rect::new(0, 0, 100, 32);
7604 let launch_lines = crate::tui::underwater::empty_state_lines(&app, launch_area);
7605 let mut launch = Buffer::empty(launch_area);
7606 for (row, line) in launch_lines.iter().enumerate() {
7607 if let Ok(y) = u16::try_from(row)
7608 && y < launch_area.height
7609 {
7610 ratatui::widgets::Widget::render(
7611 ratatui::widgets::Paragraph::new(line.clone()),
7612 Rect::new(0, y, launch_area.width, 1),
7613 &mut launch,
7614 );
7615 }
7616 }
7617 app.launch.visible = false;
7618
7619 // The info line (the shell's bottom row since the placement move)
7620 // owns the route facts and the block context meter.
7621 let info_area = Rect::new(0, 0, 100, 1);
7622 let mut info_buf = Buffer::empty(info_area);
7623 {
7624 let segments = crate::tui::ui::frame::info_segments(&app, info_area.width);
7625 let help_hint = crate::tui::shell_key_routing::info_help_hint(app.ui_locale);
7626 let info = crate::tui::infoline::InfoLine::new(&app.ui_theme, &help_hint, &segments)
7627 .ascii_safe(true);
7628 use ratatui::widgets::Widget;
7629 Widget::render(info, info_area, &mut info_buf);
7630 }
7631
7632 for (surface, buf, rect) in [
7633 ("idle transcript", &transcript, transcript_area),
7634 ("launch", &launch, launch_area),
7635 ("info line", &info_buf, info_area),
7636 ] {
7637 for y in rect.y..rect.bottom() {
7638 for x in rect.x..rect.right() {
7639 let mut cell = buf[(x, y)].clone();
7640 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
7641 assert!(
7642 cell.symbol().is_ascii(),
7643 "{surface} cell ({x},{y}) {:?} lacks an ASCII-safe alternative",
7644 buf[(x, y)].symbol()
7645 );
7646 }
7647 }
7648 }
7649 }
7650
7651 #[test]
7652 fn reduced_motion_freezes_the_ocean_without_removing_depth() {
7653 let mut app = create_test_app();
7654 app.theme_id = codewhale_palette::ThemeId::Underwater;
7655 app.ui_theme = palette::UNDERWATER_UI_THEME;
7656 app.low_motion = true;
7657 app.fancy_animations = true;
7658 let area = Rect::new(0, 0, 100, 20);
7659 // Drive the sampled clock directly: the freeze must hold even across
7660 // a 9-second animation-clock jump.
7661 let mut first = Buffer::empty(area);
7662 ChatWidget::new_with_ocean_elapsed(&mut app, area, 2_000).render(area, &mut first);
7663
7664 let mut second = Buffer::empty(area);
7665 ChatWidget::new_with_ocean_elapsed(&mut app, area, 11_000).render(area, &mut second);
7666
7667 assert_ne!(first[(0, 0)].bg, first[(0, 19)].bg);
7668 assert_eq!(first[(0, 0)].bg, second[(0, 0)].bg);
7669 assert_eq!(first[(11, 14)].symbol(), second[(11, 14)].symbol());
7670 }
7671
7672 #[test]
7673 fn pin_helper_returns_header_when_user_line_is_above_viewport() {
7674 let history = vec![
7675 HistoryCell::User {
7676 content: "remember this prompt".into(),
7677 },
7678 HistoryCell::Assistant {
7679 content: "ok".into(),
7680 streaming: false,
7681 },
7682 ];
7683 let meta = vec![
7684 TranscriptLineMeta::CellLine {
7685 cell_index: 0,
7686 line_in_cell: 0,
7687 copy_prefix_width: 0,
7688 copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None,
7689 },
7690 TranscriptLineMeta::CellLine {
7691 cell_index: 1,
7692 line_in_cell: 0,
7693 copy_prefix_width: 0,
7694 copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None,
7695 },
7696 ];
7697 let map = vec![0, 1];
7698 let pin = super::scrolled_user_prompt_pin(&history, &meta, &map, 1, 40)
7699 .expect("scrolled user prompt should yield a pinned header");
7700 let text: String = pin.spans.iter().map(|span| span.content.as_ref()).collect();
7701 assert!(
7702 text.contains("remember this prompt"),
7703 "expected pinned user text, got {text:?}"
7704 );
7705 }
7706
7707 #[test]
7708 fn pin_helper_is_idle_when_user_line_is_visible() {
7709 let history = vec![HistoryCell::User {
7710 content: "still on screen".into(),
7711 }];
7712 let meta = vec![TranscriptLineMeta::CellLine {
7713 cell_index: 0,
7714 line_in_cell: 0,
7715 copy_prefix_width: 0,
7716 copy_separator_after: crate::tui::ui_text::CopyLineSeparator::None,
7717 }];
7718 let map = vec![0];
7719 assert!(super::scrolled_user_prompt_pin(&history, &meta, &map, 0, 40).is_none());
7720 }
7721
7722 #[test]
7723 fn pinned_prompt_reserves_header_without_hiding_tail_or_shifting_mouse_mapping() {
7724 let mut app = create_test_app();
7725 app.pin_last_prompt = true;
7726 app.add_message(HistoryCell::User {
7727 content: "keep this goal visible".into(),
7728 });
7729 for index in 0..8 {
7730 app.add_message(HistoryCell::Assistant {
7731 content: format!("answer {index}"),
7732 streaming: false,
7733 });
7734 }
7735
7736 let area = Rect::new(2, 5, 48, 5);
7737 let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0);
7738 let transcript_area = app
7739 .viewport
7740 .last_transcript_area
7741 .expect("transcript geometry recorded");
7742 assert_eq!(transcript_area, Rect::new(2, 6, 48, 4));
7743 assert_eq!(widget.transcript_area, transcript_area);
7744 assert_eq!(app.viewport.last_transcript_visible, 4);
7745 assert_eq!(
7746 app.viewport.last_transcript_top + app.viewport.last_transcript_visible,
7747 app.viewport.last_transcript_total,
7748 "reserving the header must still resolve the real transcript to its newest tail"
7749 );
7750 let last_rendered: String = widget
7751 .lines
7752 .last()
7753 .expect("tail line rendered")
7754 .spans
7755 .iter()
7756 .map(|span| span.content.as_ref())
7757 .collect();
7758 let last_cached: String = app
7759 .viewport
7760 .transcript_cache
7761 .lines()
7762 .last()
7763 .expect("tail line cached")
7764 .spans
7765 .iter()
7766 .map(|span| span.content.as_ref())
7767 .collect();
7768 assert_eq!(last_rendered, last_cached);
7769
7770 let pinned_row = MouseEvent {
7771 kind: MouseEventKind::Down(MouseButton::Right),
7772 column: area.x,
7773 row: area.y,
7774 modifiers: KeyModifiers::NONE,
7775 };
7776 assert!(
7777 crate::tui::mouse_ui::selection_point_from_mouse(&app, pinned_row).is_none(),
7778 "the sticky header must not impersonate transcript line `top`"
7779 );
7780
7781 let meta = app.viewport.transcript_cache.line_meta();
7782 let (line_offset, expected_cell) = meta[app.viewport.last_transcript_top..]
7783 .iter()
7784 .take(app.viewport.last_transcript_visible)
7785 .enumerate()
7786 .find_map(|(offset, meta)| meta.cell_line().map(|(cell, _)| (offset, cell)))
7787 .expect("visible transcript contains a cell row");
7788 let body_row = MouseEvent {
7789 kind: MouseEventKind::Down(MouseButton::Right),
7790 column: transcript_area.x,
7791 row: transcript_area.y + u16::try_from(line_offset).unwrap(),
7792 modifiers: KeyModifiers::NONE,
7793 };
7794 assert_eq!(
7795 crate::tui::mouse_ui::transcript_cell_index_from_mouse(&app, body_row),
7796 Some(expected_cell),
7797 "click, drag, selection, and right-click must share the actual body geometry"
7798 );
7799 }
7800
7801 #[test]
7802 fn fish_glyph_always_matches_screen_direction() {
7803 assert_eq!(fish_mark(true), "><>");
7804 assert_eq!(fish_mark(false), "<><");
7805 assert!(fish_heading(8, 9, 10, false));
7806 assert!(!fish_heading(10, 9, 8, true));
7807 assert!(fish_heading(8, 9, 9, false));
7808 assert!(!fish_heading(10, 9, 9, true));
7809
7810 // Mirrored tracks are the regression case: a forward path flag can
7811 // correspond to decreasing screen x. Heading follows x, not the flag.
7812 assert!(!fish_heading(74, 73, 72, true));
7813 }
7814
7815 /// Render a chat field carrying `rows` of history and return its rows.
7816 fn history_field_rows(rows: usize) -> Vec<String> {
7817 let mut app = create_test_app();
7818 app.low_motion = false;
7819 app.fancy_animations = true;
7820 for index in 0..rows {
7821 app.add_message(HistoryCell::Assistant {
7822 content: format!("history row {index}"),
7823 streaming: false,
7824 });
7825 }
7826 app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
7827 let area = Rect::new(0, 0, 100, 20);
7828 let widget = ChatWidget::new(&mut app, area);
7829 assert!(widget.ambient_life);
7830 assert!(widget.ocean_animated);
7831 let mut buf = Buffer::empty(area);
7832 widget.render(area, &mut buf);
7833 buffer_text(&buf, area)
7834 .lines()
7835 .map(str::to_string)
7836 .collect()
7837 }
7838
7839 #[test]
7840 fn browsing_history_keeps_fish_in_available_water() {
7841 // Short transcript rows own their text plus a quiet gutter, not the
7842 // entire width. Browsing still holds the school in the clear water.
7843 let rows = history_field_rows(4);
7844 let rendered = rows.join("\n");
7845 assert!(
7846 rendered.contains("><>") || rendered.contains("<><"),
7847 "open water below the transcript should hold fish:\n{rendered}"
7848 );
7849 for index in 0..4 {
7850 assert!(
7851 rendered.contains(&format!("history row {index}")),
7852 "ambient life damaged history row {index}:\n{rendered}"
7853 );
7854 }
7855 }
7856
7857 #[test]
7858 fn active_tail_keeps_fish_after_message_submit() {
7859 let mut app = create_test_app();
7860 app.low_motion = false;
7861 app.fancy_animations = true;
7862 for index in 0..18 {
7863 app.add_message(HistoryCell::Assistant {
7864 content: format!("release check {index:02}"),
7865 streaming: false,
7866 });
7867 }
7868 app.is_loading = true;
7869 app.runtime_turn_status = Some("in_progress".to_string());
7870 app.turn_started_at = Some(
7871 Instant::now()
7872 .checked_sub(std::time::Duration::from_millis(900))
7873 .expect("recent turn start"),
7874 );
7875 let area = Rect::new(0, 0, 80, 24);
7876 let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0);
7877 assert!(widget.ambient_life);
7878 assert!(widget.ocean_animated);
7879 let mut buf = Buffer::empty(area);
7880 widget.render(area, &mut buf);
7881 let rendered = buffer_text(&buf, area);
7882 assert!(
7883 rendered.contains("><") || rendered.contains("<o"),
7884 "submitting a message must not empty the ocean:\n{rendered}"
7885 );
7886 assert!(rendered.contains("release check 17"), "{rendered}");
7887 }
7888
7889 #[test]
7890 fn completed_turn_keeps_bounded_ocean_settle() {
7891 let mut app = create_test_app();
7892 app.low_motion = false;
7893 app.fancy_animations = true;
7894 app.add_message(HistoryCell::Assistant {
7895 content: "release receipt".to_string(),
7896 streaming: false,
7897 });
7898 app.runtime_turn_status = Some("completed".to_string());
7899 app.ocean_completion_started_at = Some(Instant::now());
7900 let area = Rect::new(0, 0, 80, 24);
7901 let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0);
7902 assert!(widget.ambient_life);
7903 let mut buf = Buffer::empty(area);
7904 widget.render(area, &mut buf);
7905 let rendered = buffer_text(&buf, area);
7906 assert!(
7907 rendered.contains("><") || rendered.contains("<o"),
7908 "the completion settle must not snap the ocean empty:\n{rendered}"
7909 );
7910 assert!(rendered.contains("release receipt"), "{rendered}");
7911 }
7912
7913 #[test]
7914 fn a_field_full_of_transcript_holds_no_fish() {
7915 // Full-width prose really does claim the whole field; short status
7916 // lines no longer impersonate this fixture.
7917 let mut app = create_test_app();
7918 app.low_motion = false;
7919 app.fancy_animations = true;
7920 for _ in 0..30 {
7921 app.add_message(HistoryCell::Assistant {
7922 content: "X".repeat(100),
7923 streaming: false,
7924 });
7925 }
7926 app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
7927 let area = Rect::new(0, 0, 100, 20);
7928 let widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0);
7929 let mut buf = Buffer::empty(area);
7930 widget.render(area, &mut buf);
7931 let rendered = buffer_text(&buf, area);
7932 assert!(
7933 !rendered.contains("><>") && !rendered.contains("<><"),
7934 "a full transcript is not an aquarium:\n{rendered}"
7935 );
7936 }
7937
7938 fn todo_write_cell(item: Option<&str>) -> HistoryCell {
7939 let items = item.map_or_else(
7940 || "[]".to_string(),
7941 |content| format!(r#"[{{"id":1,"content":"{content}","status":"pending"}}]"#),
7942 );
7943 let count = usize::from(item.is_some());
7944 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
7945 name: "todo_write".to_string(),
7946 status: ToolStatus::Success,
7947 input_summary: Some(format!("todos: <{count} items>")),
7948 output: Some(format!(
7949 "Todo list updated ({count} items, 0% settled)\n{{\"items\":{items},\"completion_pct\":0}}"
7950 )),
7951 prompts: None,
7952 spillover_path: None,
7953 output_summary: None,
7954 is_diff: false,
7955 }))
7956 }
7957
7958 #[test]
7959 fn todo_write_renders_only_the_latest_successful_snapshot() {
7960 let mut app = create_test_app();
7961 app.add_message(todo_write_cell(Some("stale task")));
7962 app.add_message(HistoryCell::Assistant {
7963 content: "working".to_string(),
7964 streaming: false,
7965 });
7966 app.add_message(todo_write_cell(Some("current task")));
7967
7968 let area = Rect::new(0, 0, 80, 20);
7969 let mut buf = Buffer::empty(area);
7970 ChatWidget::new(&mut app, area).render(area, &mut buf);
7971 let rendered = buffer_text(&buf, area);
7972
7973 assert!(!rendered.contains("stale task"), "{rendered}");
7974 assert!(rendered.contains("current task"), "{rendered}");
7975 assert_eq!(app.collapsed_cell_map, vec![1, 2]);
7976 }
7977
7978 #[test]
7979 fn empty_todo_write_hides_the_previous_snapshot() {
7980 let mut app = create_test_app();
7981 app.add_message(todo_write_cell(Some("finished task")));
7982 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
7983 active.push_untracked(todo_write_cell(None));
7984 app.bump_active_cell_revision();
7985
7986 let area = Rect::new(0, 0, 80, 20);
7987 let mut buf = Buffer::empty(area);
7988 ChatWidget::new(&mut app, area).render(area, &mut buf);
7989 let rendered = buffer_text(&buf, area);
7990
7991 assert!(!rendered.contains("finished task"), "{rendered}");
7992 assert!(!rendered.contains("todo_write"), "{rendered}");
7993 assert!(app.collapsed_cell_map.is_empty());
7994 }
7995
7996 #[test]
7997 fn todo_replacement_preserves_tool_runs_and_full_transcript_history() {
7998 use crate::tui::{live_transcript::LiveTranscriptOverlay, views::ModalView};
7999
8000 let mut app = create_test_app();
8001 app.low_motion = true;
8002 app.show_tool_details = true;
8003 app.tool_collapse_mode = ToolCollapseMode::Compact;
8004 app.tool_collapse_threshold = 3;
8005 add_dense_tool_run(&mut app);
8006 app.add_message(HistoryCell::Assistant {
8007 content: "checkpoint".to_string(),
8008 streaming: false,
8009 });
8010 app.add_message(todo_write_cell(Some("stale task")));
8011 let active = app.active_cell.get_or_insert_with(ActiveCell::new);
8012 active.push_untracked(success_tool_cell("read_file"));
8013 active.push_untracked(success_tool_cell("web_search"));
8014 app.bump_active_cell_revision();
8015
8016 let area = Rect::new(0, 0, 100, 40);
8017 let mut buf = Buffer::empty(area);
8018 ChatWidget::new(&mut app, area).render(area, &mut buf);
8019 assert_eq!(app.collapsed_cell_map, vec![0, 3, 4]);
8020
8021 app.active_cell
8022 .as_mut()
8023 .unwrap()
8024 .push_untracked(todo_write_cell(Some("current task")));
8025 app.bump_active_cell_revision();
8026 let mut buf = Buffer::empty(area);
8027 ChatWidget::new(&mut app, area).render(area, &mut buf);
8028 let rendered = buffer_text(&buf, area);
8029 assert_eq!(app.collapsed_cell_map, vec![0, 3, 5, 6, 7]);
8030 assert!(
8031 rendered.contains("Explored 2 files, 1 search"),
8032 "{rendered}"
8033 );
8034 assert!(rendered.contains("read_file.txt"), "{rendered}");
8035 assert!(rendered.contains("web_search.txt"), "{rendered}");
8036 assert!(rendered.contains("current task"), "{rendered}");
8037 assert!(!rendered.contains("stale task"), "{rendered}");
8038
8039 let mut repeated = Buffer::empty(area);
8040 ChatWidget::new(&mut app, area).render(area, &mut repeated);
8041 assert_eq!(buffer_text(&repeated, area), rendered);
8042 assert_eq!(app.history.len(), 5);
8043 assert_eq!(app.active_cell.as_ref().unwrap().entries().len(), 3);
8044
8045 let mut overlay = LiveTranscriptOverlay::new();
8046 overlay.refresh_from_app(&mut app);
8047 let area = Rect::new(0, 0, 100, 60);
8048 let mut buf = Buffer::empty(area);
8049 ModalView::render(&overlay, area, &mut buf);
8050 let full_history = buffer_text(&buf, area);
8051 assert!(full_history.contains("stale task"), "{full_history}");
8052 assert!(full_history.contains("current task"), "{full_history}");
8053 }
8054
8055 /// Probe: confirm `cell.lines_with_motion` returns no Line whose total
8056 /// visual width exceeds the requested area width, even for pathological
8057 /// long single-line tool results.
8058 #[test]
8059 fn long_tool_result_lines_fit_requested_width() {
8060 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
8061 name: "todo_write".to_string(),
8062 status: ToolStatus::Success,
8063 input_summary: Some("items: <2 items>".to_string()),
8064 output: Some("hello world ".repeat(420)),
8065 prompts: None,
8066 spillover_path: None,
8067 output_summary: None,
8068 is_diff: false,
8069 }));
8070 for width in [40u16, 80, 111, 165] {
8071 let lines = cell.lines(width);
8072 for (idx, line) in lines.iter().enumerate() {
8073 let visual: usize = line
8074 .spans
8075 .iter()
8076 .map(|s| UnicodeWidthStr::width(s.content.as_ref()))
8077 .sum();
8078 // Card-rail prefix (╭/│/╰ + space) adds 2 chars.
8079 let rail_adjust = if line.spans.first().is_some_and(|s| {
8080 let c = s.content.as_ref();
8081 c == "\u{256D} " || c == "\u{2502} " || c == "\u{2570} "
8082 }) {
8083 2usize
8084 } else {
8085 0
8086 };
8087 assert!(
8088 visual.saturating_sub(rail_adjust) <= usize::from(width),
8089 "line {idx} at width {width} has visual width {visual} > {width}"
8090 );
8091 }
8092 }
8093 }
8094
8095 /// Regression: a long single-line tool result must not write any cells
8096 /// outside the chat content area (issue #36 — sidebar gutter bleed).
8097 ///
8098 /// We render `ChatWidget` into a buffer that is wider than the chat area
8099 /// (simulating the sidebar split) and assert every cell to the right of
8100 /// `chat_area` is still the default empty cell.
8101 #[test]
8102 fn chat_widget_does_not_bleed_into_sidebar_for_long_tool_result() {
8103 // Reproduces the actual `todo_write` output shape: a status line,
8104 // a newline, then a pretty-printed JSON payload with long string
8105 // values. Run at several widths since the leak in the issue was
8106 // observed at ~165 cols.
8107 let cases: Vec<(u16, u16)> = vec![(80, 50), (120, 80), (165, 111), (200, 140)];
8108 for (total_width, chat_width) in cases {
8109 let mut app = create_test_app();
8110 let long_value: String = "hello world ".repeat(420);
8111 let json_payload = format!(
8112 "{{\n \"items\": [\n {{ \"id\": 1, \"content\": \"{long_value}\", \"status\": \"pending\" }}\n ]\n}}"
8113 );
8114 let output = format!("Todo list updated (1 items, 0% complete)\n{json_payload}");
8115 app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
8116 name: "todo_write".to_string(),
8117 status: ToolStatus::Success,
8118 input_summary: Some("todos: <1 items>".to_string()),
8119 output: Some(output),
8120 prompts: None,
8121 spillover_path: None,
8122 output_summary: None,
8123 is_diff: false,
8124 })));
8125
8126 let height: u16 = 30;
8127 let chat_area = Rect {
8128 x: 0,
8129 y: 0,
8130 width: chat_width,
8131 height,
8132 };
8133 let full_area = Rect {
8134 x: 0,
8135 y: 0,
8136 width: total_width,
8137 height,
8138 };
8139 let mut buf = Buffer::empty(full_area);
8140
8141 let widget = ChatWidget::new(&mut app, chat_area);
8142 widget.render(chat_area, &mut buf);
8143
8144 // Every cell outside chat_area should remain at default. If the
8145 // widget bled, we'll see leftover symbols.
8146 let default_symbol = " ";
8147 for y in 0..height {
8148 for x in chat_width..total_width {
8149 let cell = &buf[(x, y)];
8150 let sym = cell.symbol();
8151 assert!(
8152 sym == default_symbol || sym.is_empty(),
8153 "[{total_width}x{height}, chat={chat_width}] cell ({x},{y}) leaked content {sym:?} outside chat_area"
8154 );
8155 }
8156 }
8157 }
8158 }
8159
8160 #[test]
8161 fn chat_widget_uses_configured_surface_background() {
8162 let mut app = create_test_app();
8163 let custom = ratatui::style::Color::Rgb(26, 27, 38);
8164 app.theme_id = codewhale_palette::ThemeId::Whale;
8165 app.ui_theme = palette::UI_THEME.with_background_color(custom);
8166 app.add_message(HistoryCell::Assistant {
8167 content: "ready".to_string(),
8168 streaming: false,
8169 });
8170
8171 let area = Rect {
8172 x: 0,
8173 y: 0,
8174 width: 30,
8175 height: 5,
8176 };
8177 let mut buf = Buffer::empty(area);
8178 let widget = ChatWidget::new(&mut app, area);
8179 widget.render(area, &mut buf);
8180
8181 assert_eq!(buf[(area.x, area.y)].bg, custom);
8182 assert_eq!(
8183 buf[(area.x + area.width - 1, area.y + area.height - 1)].bg,
8184 custom
8185 );
8186 }
8187
8188 #[test]
8189 fn chat_widget_does_not_render_turn_receipt_as_transcript_content() {
8190 let mut app = create_test_app();
8191 for i in 0..8 {
8192 app.add_message(HistoryCell::Assistant {
8193 content: format!("assistant line {i}"),
8194 streaming: false,
8195 });
8196 }
8197 app.set_receipt_text("✓ turn completed · 2 tool(s) used");
8198
8199 let area = Rect {
8200 x: 0,
8201 y: 0,
8202 width: 48,
8203 height: 6,
8204 };
8205 let mut buf = Buffer::empty(area);
8206 let widget = ChatWidget::new(&mut app, area);
8207 widget.render(area, &mut buf);
8208 let rendered = buffer_text(&buf, area);
8209
8210 assert!(!rendered.contains("turn completed"));
8211 assert!(
8212 rendered.contains("assistant line 7"),
8213 "receipt should not displace the latest transcript line: {rendered:?}"
8214 );
8215 }
8216
8217 /// Regression: when the transcript scrollbar is visible, the rightmost
8218 /// content column must remain readable (the scrollbar gets its own
8219 /// 1-column gutter rather than overdrawing chat content).
8220 #[test]
8221 fn chat_widget_reserves_scrollbar_gutter_when_scrollbar_visible() {
8222 let content_hash = "0123456789abcdef".repeat(4);
8223 let capability_hash = "fedcba9876543210".repeat(4);
8224 // System continuations paint a left rail as well as the right scrollbar.
8225 // Neither decoration is part of a wrapped trust token.
8226 let token_text = |text: &str| -> String {
8227 text.chars()
8228 .filter(|ch| !ch.is_whitespace() && !matches!(ch, '│' | '┃' | '\u{258f}'))
8229 .collect()
8230 };
8231 for filtered in [false, true] {
8232 let mut app = create_test_app();
8233 app.low_motion = true;
8234 app.fancy_animations = false;
8235 app.use_mouse_capture = false;
8236 for i in 0..20 {
8237 app.add_message(HistoryCell::User {
8238 content: format!("user message {i}"),
8239 });
8240 }
8241 app.add_message(HistoryCell::System {
8242 content: format!(
8243 "Content hash:\n{content_hash}\nCapability hash:\n{capability_hash}"
8244 ),
8245 });
8246 if filtered {
8247 app.collapsed_cells.insert(0);
8248 }
8249
8250 // Reuse the cache across scrollbar appearance, narrow-pane resizes,
8251 // and disappearance. Both ordinary and filtered histories must keep
8252 // every trust-token character in the painted terminal cells.
8253 for (width, height) in [
8254 (40, 100),
8255 (40, 16),
8256 (58, 16),
8257 (60, 16),
8258 (80, 16),
8259 (40, 16),
8260 (40, 100),
8261 ] {
8262 let area = Rect::new(2, 1, width, height);
8263 let mut buf = Buffer::empty(area);
8264 let widget = ChatWidget::new(&mut app, area);
8265 assert_eq!(widget.scrollbar.is_some(), height == 16);
8266 widget.render(area, &mut buf);
8267
8268 let rendered = buffer_text(&buf, area);
8269 let joined = token_text(&rendered);
8270 for hash in [&content_hash, &capability_hash] {
8271 assert!(
8272 joined.contains(hash.as_str()),
8273 "lost trust-token characters at {width}x{height}, filtered={filtered}: {rendered:?}"
8274 );
8275 // The decoration filter must still reject actual overpaint:
8276 // replace the last hex cell on this token's first row with
8277 // a scrollbar, as in the reported narrow-pane failure.
8278 let y = (area.y..area.bottom())
8279 .find(|&y| {
8280 buffer_text(&buf, Rect::new(area.x, y, width, 1)).contains(&hash[..16])
8281 })
8282 .expect("the first token segment is visible");
8283 let x = (area.x..area.right())
8284 .rev()
8285 .find(|&x| {
8286 let symbol = buf[(x, y)].symbol();
8287 symbol.len() == 1 && symbol.as_bytes()[0].is_ascii_hexdigit()
8288 })
8289 .expect("the token row contains hex cells");
8290 let mut overpainted = buf.clone();
8291 overpainted[(x, y)].set_symbol("│");
8292 assert!(
8293 !token_text(&buffer_text(&overpainted, area)).contains(hash.as_str()),
8294 "the token check must reject a scrollbar-erased hex cell"
8295 );
8296 }
8297 if widget.scrollbar.is_some() {
8298 for y in widget.transcript_area.y..widget.transcript_area.bottom() {
8299 assert!(matches!(buf[(area.right() - 1, y)].symbol(), "│" | "┃"));
8300 assert!(!matches!(buf[(area.right() - 2, y)].symbol(), "│" | "┃"));
8301 }
8302 }
8303 }
8304 }
8305 }
8306
8307 #[test]
8308 fn chat_widget_shows_jump_to_latest_button_when_scrolled_up() {
8309 let mut app = create_test_app();
8310 app.use_mouse_capture = true;
8311 for i in 0..80 {
8312 app.add_message(HistoryCell::User {
8313 content: format!("user message {i}"),
8314 });
8315 }
8316 app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
8317
8318 let area = Rect {
8319 x: 0,
8320 y: 0,
8321 width: 80,
8322 height: 8,
8323 };
8324 let mut buf = Buffer::empty(area);
8325 let widget = ChatWidget::new(&mut app, area);
8326 widget.render(area, &mut buf);
8327
8328 let button = app
8329 .viewport
8330 .jump_to_latest_button_area
8331 .expect("button appears when transcript is not at tail");
8332 assert_eq!(button.width, 3);
8333 assert_eq!(button.height, 3);
8334 assert_eq!(buf[(button.x + 1, button.y + 1)].symbol(), "↓");
8335 }
8336
8337 #[test]
8338 fn chat_widget_uses_light_theme_scroll_chrome() {
8339 let mut app = create_test_app();
8340 app.ui_theme = palette::LIGHT_UI_THEME;
8341 app.use_mouse_capture = true;
8342 for i in 0..120 {
8343 app.add_message(HistoryCell::User {
8344 content: format!("user message {i}"),
8345 });
8346 }
8347 app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
8348
8349 let area = Rect {
8350 x: 0,
8351 y: 0,
8352 width: 80,
8353 height: 8,
8354 };
8355 let mut buf = Buffer::empty(area);
8356 let widget = ChatWidget::new(&mut app, area);
8357 widget.render(area, &mut buf);
8358
8359 let mut saw_track = false;
8360 let mut saw_thumb = false;
8361 for y in 0..area.height {
8362 let cell = &buf[(area.width - 1, y)];
8363 match cell.symbol() {
8364 "│" => {
8365 saw_track = true;
8366 assert_eq!(cell.fg, palette::LIGHT_UI_THEME.border);
8367 }
8368 "┃" => {
8369 saw_thumb = true;
8370 assert_eq!(cell.fg, palette::LIGHT_UI_THEME.status_working);
8371 }
8372 _ => {}
8373 }
8374 }
8375 assert!(saw_track, "scrollbar track should render");
8376 assert!(saw_thumb, "scrollbar thumb should render");
8377
8378 let button = app
8379 .viewport
8380 .jump_to_latest_button_area
8381 .expect("button appears when transcript is not at tail");
8382 assert_eq!(
8383 buf[(button.x + 1, button.y + 1)].fg,
8384 palette::LIGHT_UI_THEME.status_working
8385 );
8386 }
8387
8388 #[test]
8389 fn chat_widget_hides_jump_to_latest_button_at_tail() {
8390 let mut app = create_test_app();
8391 app.use_mouse_capture = true;
8392 for i in 0..80 {
8393 app.add_message(HistoryCell::User {
8394 content: format!("user message {i}"),
8395 });
8396 }
8397 app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
8398
8399 let area = Rect {
8400 x: 0,
8401 y: 0,
8402 width: 80,
8403 height: 8,
8404 };
8405 let _widget = ChatWidget::new(&mut app, area);
8406 assert!(
8407 app.viewport.jump_to_latest_button_area.is_none(),
8408 "button should hide while following the live tail"
8409 );
8410 assert!(app.viewport.transcript_scroll.is_at_tail());
8411 }
8412
8413 /// Regression for issue #582: a resize event during a long task must not
8414 /// leave the chat widget with an empty viewport. The actual ConHost
8415 /// size-stale fix lives in `tui::ui::run_tui`.
8416 #[test]
8417 fn chat_widget_renders_cleanly_after_resize_during_long_task() {
8418 let mut app = create_test_app();
8419 for i in 0..30 {
8420 app.add_message(HistoryCell::User {
8421 content: format!("user message {i} during a long-running task"),
8422 });
8423 }
8424
8425 // Drive the same shrink-then-grow cycle that maximize→windowed
8426 // transitions produce on Windows.
8427 for (width, height) in [(140u16, 40u16), (90, 28), (60, 20), (140, 40)] {
8428 app.handle_resize(width, height);
8429 let area = Rect {
8430 x: 0,
8431 y: 0,
8432 width,
8433 height,
8434 };
8435 let mut buf = Buffer::empty(area);
8436 let widget = ChatWidget::new(&mut app, area);
8437 widget.render(area, &mut buf);
8438
8439 let mut non_empty = 0usize;
8440 for y in 0..height {
8441 for x in 0..width {
8442 let sym = buf[(x, y)].symbol();
8443 if sym != " " && !sym.is_empty() {
8444 non_empty += 1;
8445 }
8446 }
8447 }
8448 assert!(
8449 non_empty > 0,
8450 "resize at {width}x{height} produced an empty buffer (#582)"
8451 );
8452 }
8453 }
8454
8455 #[test]
8456 fn approval_inline_band_stays_within_short_terminal() {
8457 let request = crate::tui::approval::ApprovalRequest::new(
8458 "approval-1",
8459 "exec_shell",
8460 "Run git commit",
8461 &serde_json::json!({ "command": "git commit -m fix" }),
8462 "exec_shell:git commit",
8463 );
8464 let view = crate::tui::approval::ApprovalView::new(request.clone());
8465 let widget = ApprovalWidget::new(&request, &view);
8466
8467 for area in [Rect::new(0, 0, 162, 17), Rect::new(0, 0, 39, 17)] {
8468 let region = widget.inline_region(area);
8469 // Band never addresses cells outside the frame.
8470 assert!(region.x >= area.x);
8471 assert!(region.right() <= area.right());
8472 assert!(region.bottom() <= area.bottom());
8473 // Inline prompt is anchored to the bottom of the frame.
8474 assert_eq!(
8475 region.bottom(),
8476 area.bottom(),
8477 "approval band must be bottom-anchored at {area:?}"
8478 );
8479
8480 let mut buf = Buffer::empty(area);
8481 widget.render(area, &mut buf);
8482 }
8483 }
8484
8485 #[test]
8486 fn approval_inline_band_caps_at_half_the_viewport_and_keeps_actions_visible() {
8487 let command = (0..24)
8488 .map(|index| format!("printf command-{index}"))
8489 .collect::<Vec<_>>()
8490 .join("\n");
8491 let request = crate::tui::approval::ApprovalRequest::new(
8492 "approval-long",
8493 "exec_shell",
8494 "Run a long shell command",
8495 &serde_json::json!({ "command": command }),
8496 "exec_shell:long",
8497 );
8498 let view = crate::tui::approval::ApprovalView::new(request.clone());
8499 let widget = ApprovalWidget::new(&request, &view);
8500 let area = Rect::new(0, 0, 100, 30);
8501 let region = widget.inline_region(area);
8502
8503 assert_eq!(region.bottom(), area.bottom());
8504 assert!(region.height <= area.height.div_ceil(2), "{region:?}");
8505
8506 let mut buf = Buffer::empty(area);
8507 widget.render(area, &mut buf);
8508 let rendered = buffer_text(&buf, area);
8509 assert!(rendered.contains("[1 / y]"), "{rendered}");
8510 assert!(rendered.contains("[Esc]"), "{rendered}");
8511 assert!(rendered.contains("truncated"), "{rendered}");
8512 }
8513
8514 #[test]
8515 fn approval_compact_tiers_preserve_command_before_falling_back_to_details() {
8516 let request = crate::tui::approval::ApprovalRequest::new(
8517 "approval-tiers",
8518 "exec_shell",
8519 "Print a localized verification marker",
8520 &serde_json::json!({ "command": "printf '安全確認'" }),
8521 "exec_shell:printf",
8522 );
8523 let view = crate::tui::approval::ApprovalView::new(request.clone());
8524 let widget = ApprovalWidget::new(&request, &view);
8525
8526 for area in [Rect::new(0, 0, 80, 24), Rect::new(0, 0, 60, 16)] {
8527 let region = widget.inline_region(area);
8528 assert_eq!(region.bottom(), area.bottom());
8529 assert!(region.height < area.height, "{area:?}: {region:?}");
8530
8531 let mut buf = Buffer::empty(area);
8532 widget.render(area, &mut buf);
8533 let rendered = buffer_text(&buf, area);
8534 assert!(rendered.contains("Command:"), "{area:?}: {rendered}");
8535 for marker in ['安', '全', '確', '認'] {
8536 assert!(rendered.contains(marker), "{area:?}: {rendered}");
8537 }
8538 assert!(rendered.contains("[1 / y]"), "{area:?}: {rendered}");
8539 assert!(rendered.contains("[Esc]"), "{area:?}: {rendered}");
8540 }
8541
8542 let tiny = Rect::new(0, 0, 40, 12);
8543 let mut buf = Buffer::empty(tiny);
8544 widget.render(tiny, &mut buf);
8545 let rendered = buffer_text(&buf, tiny);
8546 assert!(rendered.contains("[1 / y]"), "{rendered}");
8547 assert!(rendered.contains("[Esc]"), "{rendered}");
8548 assert!(
8549 rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
8550 "{rendered}"
8551 );
8552 }
8553
8554 #[test]
8555 fn approval_truncation_hint_uses_platform_details_chord_in_every_locale() {
8556 let details = crate::tui::shell_key_routing::tool_details_chord();
8557 for locale in Locale::shipped() {
8558 let hint = approval_truncation_hint(*locale);
8559 assert!(hint.contains(details.as_ref()), "{locale:?}: {hint}");
8560 assert!(!hint.contains("[v]"), "{locale:?}: {hint}");
8561 }
8562 }
8563
8564 #[test]
8565 fn repo_law_approval_has_distinct_authority_grammar() {
8566 let request = crate::tui::approval::ApprovalRequest::new(
8567 "approval-law",
8568 "edit_file",
8569 "Repo law holds this write: \"manifest review\" protects Cargo.toml (matched Cargo.toml, .codewhale/constitution.json)",
8570 &serde_json::json!({ "path": "Cargo.toml", "old": "a", "new": "b" }),
8571 "edit_file:Cargo.toml",
8572 );
8573 assert!(request.is_repo_law_prompt());
8574 let view = crate::tui::approval::ApprovalView::new(request.clone());
8575 let widget = ApprovalWidget::new(&request, &view);
8576 let area = Rect::new(0, 0, 120, 30);
8577 let mut buf = Buffer::empty(area);
8578
8579 widget.render(area, &mut buf);
8580 let rendered = buffer_text(&buf, area);
8581 assert!(rendered.contains("REPO LAW"), "{rendered}");
8582 assert!(rendered.contains("Repository constitution"), "{rendered}");
8583 assert!(rendered.contains("approval-gated postures"), "{rendered}");
8584 assert!(rendered.contains("Cargo.toml"), "{rendered}");
8585 assert!((0..area.height).any(|y| {
8586 let cell = &buf[(1, y)];
8587 cell.symbol() == "═" && cell.fg == palette::STATUS_WARNING
8588 }));
8589 }
8590
8591 #[test]
8592 fn approval_selected_destructive_option_uses_contrasting_highlight() {
8593 let request = crate::tui::approval::ApprovalRequest::new(
8594 "approval-1",
8595 "exec_shell",
8596 "Run git commit",
8597 &serde_json::json!({ "command": "git commit -m fix" }),
8598 "exec_shell:git commit",
8599 );
8600 let view = crate::tui::approval::ApprovalView::new(request.clone());
8601 let widget = ApprovalWidget::new(&request, &view);
8602 let area = Rect::new(0, 0, 100, 30);
8603 let mut buf = Buffer::empty(area);
8604
8605 widget.render(area, &mut buf);
8606
8607 let selected_row = (area.y..area.y.saturating_add(area.height))
8608 .find(|&y| {
8609 (area.x..area.x.saturating_add(area.width))
8610 .any(|x| buf[(x, y)].bg == palette::SELECTION_BG)
8611 })
8612 .expect("selected approval row should use selection background");
8613 let highlighted_cells = (area.x..area.x.saturating_add(area.width))
8614 .filter(|&x| {
8615 let cell = &buf[(x, selected_row)];
8616 !cell.symbol().trim().is_empty()
8617 && cell.bg == palette::SELECTION_BG
8618 && cell.fg == palette::SELECTION_TEXT
8619 })
8620 .count();
8621
8622 assert!(
8623 highlighted_cells >= 4,
8624 "selected destructive option should render visible selection text"
8625 );
8626 }
8627
8628 #[test]
8629 fn approval_inline_marks_selected_row_and_separator_rule() {
8630 let request = crate::tui::approval::ApprovalRequest::new(
8631 "approval-1",
8632 "exec_shell",
8633 "Run git commit",
8634 &serde_json::json!({ "command": "git commit -m fix" }),
8635 "exec_shell:git commit",
8636 );
8637 let view = crate::tui::approval::ApprovalView::new(request.clone());
8638 let widget = ApprovalWidget::new(&request, &view);
8639 let area = Rect::new(0, 0, 100, 30);
8640 let mut buf = Buffer::empty(area);
8641
8642 widget.render(area, &mut buf);
8643 let rendered = buffer_text(&buf, area);
8644
8645 assert!(
8646 rendered.contains('\u{276f}'),
8647 "selected option row should show a caret:\n{rendered}"
8648 );
8649 assert!(
8650 rendered.contains('\u{2500}'),
8651 "inline prompt should show a top separator rule:\n{rendered}"
8652 );
8653 }
8654
8655 #[test]
8656 fn approval_inline_keeps_action_row_and_leaves_transcript_visible() {
8657 // The #3799 repro: a destructive approval with a long multi-line command
8658 // and long intent text. Across narrow, normal, and short terminals the
8659 // action row must stay visible, the band must never address cells
8660 // outside the frame, and on a tall terminal the band must not fill the
8661 // whole frame (transcript stays visible — no full-screen takeover).
8662 let request = crate::tui::approval::ApprovalRequest::new_with_intent(
8663 "approval-1",
8664 "exec_shell",
8665 "Run shell command",
8666 &serde_json::json!({
8667 "command": "rm -rf ./build && find . -name '*.tmp' -delete && cargo clean && echo done",
8668 }),
8669 "exec_shell:cleanup",
8670 Some(
8671 "Clearing stale build artifacts and temp files before a fresh run so the next build is reproducible.",
8672 ),
8673 std::path::Path::new("/tmp/project"),
8674 );
8675 let view = crate::tui::approval::ApprovalView::new(request.clone());
8676 let widget = ApprovalWidget::new(&request, &view);
8677
8678 for (w, h) in [(40u16, 14u16), (80, 24), (100, 50), (60, 10)] {
8679 let area = Rect::new(0, 0, w, h);
8680 let mut buf = Buffer::empty(area);
8681 widget.render(area, &mut buf);
8682 let rendered = buffer_text(&buf, area);
8683
8684 // Action row is always present (reserved off the bottom of the band).
8685 assert!(
8686 rendered.contains("[1 / y]") && rendered.contains("[3 / d / n]"),
8687 "action row must stay visible at {w}x{h}:\n{rendered}"
8688 );
8689
8690 // Band stays inside the frame and is anchored to the bottom.
8691 let region = widget.inline_region(area);
8692 assert!(region.right() <= area.right() && region.bottom() <= area.bottom());
8693 assert_eq!(
8694 region.bottom(),
8695 area.bottom(),
8696 "band must be bottom-anchored at {w}x{h}"
8697 );
8698
8699 // Tall terminal with content that fits: transcript above stays
8700 // visible — the prompt is not a full-screen takeover.
8701 if h >= 40 {
8702 assert!(
8703 region.y > area.y,
8704 "tall frame must leave transcript visible above the band at {w}x{h}"
8705 );
8706 }
8707 }
8708 }
8709
8710 #[test]
8711 fn approval_option_two_reads_as_session_scoped_not_always() {
8712 // #3766: option 2 / `a` maps to ReviewDecision::ApprovedForSession, so
8713 // neither the full option rows nor the compact controls may tell the
8714 // user that particular option is "always"/permanent. The distinct
8715 // `[p]` row may use that word for an exact repo-scoped grant.
8716 let request = crate::tui::approval::ApprovalRequest::new(
8717 "approval-1",
8718 "exec_shell",
8719 "Run git commit",
8720 &serde_json::json!({ "command": "git commit -m fix" }),
8721 "exec_shell:git commit",
8722 );
8723
8724 // Full card (tall): full option rows render the session-scoped label.
8725 let full = render_approval_request(&request, Rect::new(0, 0, 100, 30));
8726 let full_session_option = full
8727 .lines()
8728 .find(|line| line.contains("[2 / a]"))
8729 .expect("full approval card should render the session option");
8730 assert!(
8731 full_session_option.to_lowercase().contains("this session")
8732 && !full_session_option.to_lowercase().contains("always"),
8733 "full approval option must state session scope without saying always:\n{full}"
8734 );
8735
8736 // Short terminal: the reserved controls still render the session-scoped
8737 // option `[2 / a]` without calling that option "always".
8738 let compact = render_approval_request(&request, Rect::new(0, 0, 60, 17));
8739 let compact_session_option = compact
8740 .lines()
8741 .find(|line| line.contains("[2 / a]"))
8742 .expect("short approval card should render the session option");
8743 assert!(
8744 compact_session_option.to_lowercase().contains("session")
8745 && !compact_session_option.to_lowercase().contains("always"),
8746 "short-terminal controls must label [2 / a] as session-scoped:\n{compact}"
8747 );
8748 }
8749
8750 #[test]
8751 fn approval_shell_command_detects_printf_write_file_preview() {
8752 let request = crate::tui::approval::ApprovalRequest::new(
8753 "approval-1",
8754 "exec_shell",
8755 "Run shell command",
8756 &serde_json::json!({
8757 "command": "printf '%s\\n' 'alpha' 'beta' > src/generated.txt",
8758 "cwd": "/tmp/project",
8759 }),
8760 "exec_shell:printf",
8761 );
8762 let view = crate::tui::approval::ApprovalView::new(request.clone());
8763 let widget = ApprovalWidget::new(&request, &view);
8764 let area = Rect::new(0, 0, 110, 32);
8765 let mut buf = Buffer::empty(area);
8766
8767 widget.render(area, &mut buf);
8768 let rendered = buffer_text(&buf, area);
8769
8770 assert!(rendered.contains("Command:"), "{rendered}");
8771 assert!(
8772 rendered.contains("printf > src/generated.txt"),
8773 "{rendered}"
8774 );
8775 assert!(rendered.contains("alpha"), "{rendered}");
8776 assert!(rendered.contains("beta"), "{rendered}");
8777 assert!(rendered.contains("Dir"), "{rendered}");
8778 assert!(rendered.contains("/tmp/project"), "{rendered}");
8779 }
8780
8781 #[test]
8782 fn approval_card_renders_shell_ask_rule_save_preview() {
8783 let request = crate::tui::approval::ApprovalRequest::new(
8784 "approval-1",
8785 "exec_shell",
8786 "Run shell command",
8787 &serde_json::json!({ "command": "cargo test --workspace" }),
8788 "exec_shell:cargo-test",
8789 );
8790
8791 let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
8792
8793 assert!(
8794 rendered.contains("s allow once + always ask exact rule"),
8795 "{rendered}"
8796 );
8797 assert!(
8798 rendered.contains("Always allow this exact rule in this repo"),
8799 "{rendered}"
8800 );
8801 assert!(rendered.contains("Save:"), "{rendered}");
8802 assert!(rendered.contains("1 ask rule"), "{rendered}");
8803 assert!(rendered.contains("1 allow rule"), "{rendered}");
8804 assert!(
8805 rendered.contains("tool=exec_shell command=cargo test --workspace"),
8806 "{rendered}"
8807 );
8808 assert!(rendered.contains("command_exact=true"), "{rendered}");
8809 assert!(rendered.contains("workspace=/workspace"), "{rendered}");
8810 }
8811
8812 #[test]
8813 fn approval_card_renders_file_ask_rule_save_previews() {
8814 let cases = [
8815 (
8816 "write_file",
8817 serde_json::json!({
8818 "path": "src/main.rs",
8819 "content": "fn main() {}\n",
8820 }),
8821 "tool=write_file path=src/main.rs",
8822 ),
8823 (
8824 "edit_file",
8825 serde_json::json!({
8826 "path": "/workspace/src/lib.rs",
8827 "old_string": "old",
8828 "new_string": "new",
8829 }),
8830 "tool=edit_file path=src/lib.rs",
8831 ),
8832 ];
8833
8834 for (tool_name, params, expected_rule) in cases {
8835 let request = crate::tui::approval::ApprovalRequest::new(
8836 "approval-1",
8837 tool_name,
8838 "Modify a file",
8839 &params,
8840 &format!("{tool_name}:src"),
8841 );
8842
8843 let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
8844
8845 assert!(rendered.contains("Save:"), "{tool_name}:\n{rendered}");
8846 assert!(rendered.contains("1 ask rule"), "{tool_name}:\n{rendered}");
8847 assert!(
8848 rendered.contains("1 allow rule"),
8849 "{tool_name}:\n{rendered}"
8850 );
8851 assert!(
8852 rendered.contains(expected_rule),
8853 "{tool_name} should preview {expected_rule}:\n{rendered}"
8854 );
8855 }
8856 }
8857
8858 #[test]
8859 fn approval_card_renders_apply_patch_multi_rule_save_preview() {
8860 let patch = "diff --git a/src/a.rs b/src/a.rs\n\
8861 --- a/src/a.rs\n\
8862 +++ b/src/a.rs\n\
8863 @@ -1,1 +1,1 @@\n\
8864 -old\n\
8865 +new\n\
8866 diff --git a/src/b.rs b/src/b.rs\n\
8867 --- a/src/b.rs\n\
8868 +++ b/src/b.rs\n\
8869 @@ -1,1 +1,1 @@\n\
8870 -old\n\
8871 +new\n";
8872 let request = crate::tui::approval::ApprovalRequest::new(
8873 "approval-1",
8874 "apply_patch",
8875 "Apply a patch",
8876 &serde_json::json!({ "patch": patch }),
8877 "apply_patch:multi",
8878 );
8879
8880 let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
8881
8882 assert!(rendered.contains("Save:"), "{rendered}");
8883 assert!(rendered.contains("2 ask rules"), "{rendered}");
8884 assert!(rendered.contains("2 allow rules"), "{rendered}");
8885 assert!(
8886 rendered.contains("tool=apply_patch path=src/a.rs"),
8887 "{rendered}"
8888 );
8889 assert!(
8890 rendered.contains("tool=apply_patch path=src/b.rs"),
8891 "{rendered}"
8892 );
8893 }
8894
8895 #[test]
8896 fn approval_card_truncates_apply_patch_ask_rule_save_preview() {
8897 let request = crate::tui::approval::ApprovalRequest::new(
8898 "approval-1",
8899 "apply_patch",
8900 "Apply a patch",
8901 &serde_json::json!({
8902 "replace": [
8903 { "path": "src/a.rs", "content": "a" },
8904 { "path": "src/b.rs", "content": "b" },
8905 { "path": "src/c.rs", "content": "c" },
8906 { "path": "src/d.rs", "content": "d" },
8907 { "path": "src/e.rs", "content": "e" }
8908 ]
8909 }),
8910 "apply_patch:many",
8911 );
8912
8913 let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
8914
8915 assert!(rendered.contains("5 ask rules"), "{rendered}");
8916 assert!(
8917 rendered.contains("tool=apply_patch path=src/a.rs"),
8918 "{rendered}"
8919 );
8920 assert!(rendered.contains("... 1 more"), "{rendered}");
8921 assert!(
8922 !rendered.contains("tool=apply_patch path=src/e.rs"),
8923 "truncated rule should not render directly:\n{rendered}"
8924 );
8925 }
8926
8927 #[test]
8928 fn approval_card_omits_ask_rule_save_preview_when_rule_is_unavailable() {
8929 let unsafe_path = crate::tui::approval::ApprovalRequest::new(
8930 "approval-1",
8931 "write_file",
8932 "Write a file",
8933 &serde_json::json!({
8934 "path": "../escape.rs",
8935 "content": "unsafe\n",
8936 }),
8937 "write_file:escape",
8938 );
8939 let preflight_failed = crate::tui::approval::ApprovalRequest::new(
8940 "approval-2",
8941 "apply_patch",
8942 "Apply a patch",
8943 &serde_json::json!({ "patch": "@@ -1 +1 @@\n-old\n+new\n" }),
8944 "apply_patch:invalid",
8945 );
8946
8947 for request in [unsafe_path, preflight_failed] {
8948 let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
8949
8950 assert!(
8951 !rendered.contains("s allow once + always ask exact rule"),
8952 "S shortcut should stay hidden:\n{rendered}"
8953 );
8954 assert!(
8955 !rendered.contains("Save:"),
8956 "save preview should stay hidden:\n{rendered}"
8957 );
8958 assert!(
8959 !rendered.contains("ask rule"),
8960 "ask-rule details should stay hidden:\n{rendered}"
8961 );
8962 }
8963 }
8964
8965 #[test]
8966 fn approval_file_write_modal_renders_proposed_change_preview() {
8967 let request = crate::tui::approval::ApprovalRequest::new(
8968 "approval-1",
8969 "write_file",
8970 "Write a file",
8971 &serde_json::json!({
8972 "path": "src/main.rs",
8973 "content": "fn main() {\n println!(\"visible before approval\");\n}\n",
8974 }),
8975 "write_file:src/main.rs",
8976 );
8977 let view = crate::tui::approval::ApprovalView::new(request.clone());
8978 let widget = ApprovalWidget::new(&request, &view);
8979 let area = Rect::new(0, 0, 120, 34);
8980 let mut buf = Buffer::empty(area);
8981
8982 widget.render(area, &mut buf);
8983 let rendered = buffer_text(&buf, area);
8984
8985 assert!(rendered.contains("Preview:"), "{rendered}");
8986 assert!(rendered.contains("+ fn main() {"), "{rendered}");
8987 assert!(
8988 rendered.contains("visible before approval"),
8989 "approval modal should show proposed file content before approval:\n{rendered}"
8990 );
8991 }
8992
8993 #[test]
8994 fn apply_patch_approval_shows_preview_and_reserved_controls_on_short_terminal() {
8995 let request = crate::tui::approval::ApprovalRequest::new(
8996 "approval-1",
8997 "apply_patch",
8998 "Apply a patch",
8999 &serde_json::json!({
9000 "patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n",
9001 }),
9002 "apply_patch:src/lib.rs",
9003 );
9004 let view = crate::tui::approval::ApprovalView::new(request.clone());
9005 let widget = ApprovalWidget::new(&request, &view);
9006 let area = Rect::new(0, 0, 80, 20);
9007 let mut buf = Buffer::empty(area);
9008
9009 widget.render(area, &mut buf);
9010 let rendered = buffer_text(&buf, area);
9011
9012 // At 20 rows the compact band preserves both a load-bearing preview
9013 // row and the complete action set.
9014 assert!(rendered.contains("Preview:"), "{rendered}");
9015 assert!(rendered.contains("+new"), "{rendered}");
9016 assert!(rendered.contains("truncated"), "{rendered}");
9017 assert!(
9018 rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
9019 "{rendered}"
9020 );
9021 assert!(rendered.contains("[1 / y]"), "{rendered}");
9022 assert!(rendered.contains("[3 / d / n]"), "{rendered}");
9023 }
9024
9025 #[test]
9026 fn approval_intent_summary_still_renders_with_shell_details() {
9027 let request = crate::tui::approval::ApprovalRequest::new_with_intent(
9028 "approval-1",
9029 "exec_shell",
9030 "Run shell command",
9031 &serde_json::json!({
9032 "command": "cargo build || echo fallback",
9033 "cwd": "/tmp/project",
9034 }),
9035 "exec_shell:cargo",
9036 Some("Need to verify the fallback build path before editing files."),
9037 std::path::Path::new("/tmp/project"),
9038 );
9039 let view = crate::tui::approval::ApprovalView::new(request.clone());
9040 let widget = ApprovalWidget::new(&request, &view);
9041 let area = Rect::new(0, 0, 120, 34);
9042 let mut buf = Buffer::empty(area);
9043
9044 widget.render(area, &mut buf);
9045 let rendered = buffer_text(&buf, area);
9046
9047 assert!(rendered.contains("Intent:"), "{rendered}");
9048 assert!(rendered.contains("fallback build path"), "{rendered}");
9049 assert!(rendered.contains("Command:"), "{rendered}");
9050 assert!(rendered.contains("cargo build ||"), "{rendered}");
9051 assert!(rendered.contains("echo fallback"), "{rendered}");
9052 }
9053
9054 #[test]
9055 fn approval_shell_modal_stays_useful_on_short_terminals() {
9056 let request = crate::tui::approval::ApprovalRequest::new_with_intent(
9057 "approval-1",
9058 "exec_shell",
9059 "Built-in safety gate requires approval: destructive background/headless actions cannot auto-approve",
9060 &serde_json::json!({
9061 "command": "cd /Volumes/VIXinSSD/codewhale; cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings 2>&1 | tee /tmp/codewhale-clippy.log",
9062 "cwd": "/Volumes/VIXinSSD/codewhale",
9063 }),
9064 "exec_shell:cargo-clippy",
9065 Some("Confirmed - passes in isolation, so this is the documentation gate."),
9066 std::path::Path::new("/Volumes/VIXinSSD/codewhale"),
9067 );
9068 let view = crate::tui::approval::ApprovalView::new(request.clone());
9069 let widget = ApprovalWidget::new(&request, &view);
9070 let area = Rect::new(0, 0, 80, 20);
9071 let mut buf = Buffer::empty(area);
9072
9073 widget.render(area, &mut buf);
9074 let rendered = buffer_text(&buf, area);
9075
9076 assert!(
9077 !rendered.contains("Built-in safety gate requires approval"),
9078 "policy internals should not be the modal summary:\n{rendered}"
9079 );
9080 assert!(
9081 !rendered.contains("Impact: Command"),
9082 "command should only render in the command block:\n{rendered}"
9083 );
9084 // The compact band keeps the transcript visible without hiding the
9085 // load-bearing command; full content remains one details chord away.
9086 assert!(rendered.contains("Command:"), "{rendered}");
9087 assert!(rendered.contains("cargo clippy"), "{rendered}");
9088 assert!(rendered.contains("truncated"), "{rendered}");
9089 assert!(
9090 rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
9091 "{rendered}"
9092 );
9093 // Action row is reserved off the bottom and always visible (#3799).
9094 assert!(rendered.contains("[1 / y]"), "{rendered}");
9095 assert!(rendered.contains("[2 / a]"), "{rendered}");
9096 assert!(rendered.contains("[3 / d / n]"), "{rendered}");
9097 }
9098
9099 /// Regression for issue #65: after `App::handle_resize`, the chat widget
9100 /// must produce a clean render at the new width — no stale wrapping,
9101 /// no panic, no content exceeding the requested width. Cycling through
9102 /// several widths (shrinks and grows) flushes any cached layout that
9103 /// fails to invalidate on resize.
9104 #[test]
9105 fn chat_widget_renders_cleanly_after_resize_cycle() {
9106 let mut app = create_test_app();
9107 // Add some long content that wraps differently at different widths.
9108 for i in 0..40 {
9109 app.add_message(HistoryCell::User {
9110 content: format!("user message {i} with enough text to wrap at 30 columns easily"),
9111 });
9112 }
9113
9114 let widths_to_cycle = [120u16, 80, 40, 60, 100, 30];
9115 let height: u16 = 20;
9116 for width in widths_to_cycle {
9117 // Caller-side: simulate the resize handler invalidating caches.
9118 app.handle_resize(width, height);
9119 let area = Rect {
9120 x: 0,
9121 y: 0,
9122 width,
9123 height,
9124 };
9125 let mut buf = Buffer::empty(area);
9126 let widget = ChatWidget::new(&mut app, area);
9127 widget.render(area, &mut buf);
9128
9129 // The render must produce at least some non-empty content for a
9130 // populated history at any reasonable width. This catches a class
9131 // of resize regressions where stale layout state leaves a blank
9132 // viewport after a width change.
9133 let mut non_empty = 0usize;
9134 for y in 0..height {
9135 for x in 0..width {
9136 let sym = buf[(x, y)].symbol();
9137 if sym != " " && !sym.is_empty() {
9138 non_empty += 1;
9139 }
9140 }
9141 }
9142 assert!(
9143 non_empty > 0,
9144 "render at {width}x{height} produced an empty buffer after resize"
9145 );
9146 }
9147 }
9148
9149 /// Regression for issue #65: the transcript view cache must invalidate
9150 /// when width changes, so the same `App.history` re-wraps to the new
9151 /// width on the very next `ChatWidget::new` call.
9152 #[test]
9153 fn transcript_cache_invalidates_on_width_change() {
9154 let mut app = create_test_app();
9155 for i in 0..10 {
9156 app.add_message(HistoryCell::User {
9157 content: format!("a fairly long user message number {i} that needs to wrap"),
9158 });
9159 }
9160
9161 let area_wide = Rect {
9162 x: 0,
9163 y: 0,
9164 width: 120,
9165 height: 20,
9166 };
9167 let area_narrow = Rect {
9168 x: 0,
9169 y: 0,
9170 width: 30,
9171 height: 20,
9172 };
9173 let mut buf_wide = Buffer::empty(area_wide);
9174 let widget_wide = ChatWidget::new(&mut app, area_wide);
9175 widget_wide.render(area_wide, &mut buf_wide);
9176 let wide_total_lines = app.viewport.transcript_cache.total_lines();
9177
9178 // Without an explicit resize call, just shrinking the render area
9179 // should still trigger a cache rebuild because the cache keys on width.
9180 let mut buf_narrow = Buffer::empty(area_narrow);
9181 let widget_narrow = ChatWidget::new(&mut app, area_narrow);
9182 widget_narrow.render(area_narrow, &mut buf_narrow);
9183 let narrow_total_lines = app.viewport.transcript_cache.total_lines();
9184
9185 assert!(
9186 narrow_total_lines > wide_total_lines,
9187 "narrow render should produce more wrapped lines (got {narrow_total_lines}, wide={wide_total_lines})"
9188 );
9189 }
9190
9191 // ── Ghost-text prompt suggestion rendering ────────────────────────
9192
9193 #[test]
9194 fn ghost_text_renders_when_suggestion_set_and_input_empty() {
9195 let mut app = create_test_app();
9196 app.prompt_suggestion = Some("What about error handling?".to_string());
9197 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
9198 let mention_menu_entries = Vec::<String>::new();
9199 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
9200 let area = Rect {
9201 x: 0,
9202 y: 0,
9203 width: 80,
9204 height: 5,
9205 };
9206 let mut buf = Buffer::empty(area);
9207 widget.render(area, &mut buf);
9208
9209 let rendered: String = buf
9210 .content
9211 .iter()
9212 .map(|c| c.symbol())
9213 .collect::<Vec<_>>()
9214 .join("");
9215 assert!(
9216 rendered.contains("What about error handling?"),
9217 "ghost text should render the suggestion. Got: {rendered}"
9218 );
9219 }
9220
9221 #[test]
9222 fn ghost_text_hidden_when_input_not_empty() {
9223 let mut app = create_test_app();
9224 app.prompt_suggestion = Some("A suggestion".to_string());
9225 app.input = "hello".to_string();
9226 app.cursor_position = 5;
9227 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
9228 let mention_menu_entries = Vec::<String>::new();
9229 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
9230 let area = Rect {
9231 x: 0,
9232 y: 0,
9233 width: 80,
9234 height: 5,
9235 };
9236 let mut buf = Buffer::empty(area);
9237 widget.render(area, &mut buf);
9238
9239 let has_suggestion = buf
9240 .content
9241 .iter()
9242 .any(|c| c.symbol().contains("A suggestion"));
9243 assert!(
9244 !has_suggestion,
9245 "suggestion should not render when input is non-empty"
9246 );
9247 }
9248
9249 #[test]
9250 fn ghost_text_hidden_when_no_suggestion() {
9251 let mut app = create_test_app();
9252 app.prompt_suggestion = None;
9253 let slash_menu_entries = Vec::<SlashMenuEntry>::new();
9254 let mention_menu_entries = Vec::<String>::new();
9255 let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
9256 let area = Rect {
9257 x: 0,
9258 y: 0,
9259 width: 80,
9260 height: 5,
9261 };
9262 let mut buf = Buffer::empty(area);
9263 widget.render(area, &mut buf);
9264
9265 // When no suggestion and input is empty, placeholder text should appear
9266 // instead. The exact placeholder text is locale-dependent, so we check
9267 // that the suggestion text is NOT present.
9268 let has_placeholder_like_text = buf.content.iter().any(|c| !c.symbol().trim().is_empty());
9269 assert!(
9270 has_placeholder_like_text,
9271 "some non-empty text should render as placeholder"
9272 );
9273 }
9274
9275 #[test]
9276 fn receipt_settle_cascade_is_bounded_and_ordered() {
9277 assert!(receipt_is_settling(0, 0));
9278 assert!(!receipt_is_settling(0, 140));
9279 assert!(receipt_is_settling(1, 140));
9280 assert!(!receipt_is_settling(6, 560));
9281 assert!(!receipt_is_settling(60, 560));
9282 }
9283
9284 #[test]
9285 fn fish_flee_is_one_shot_and_returns_to_ambient_origin() {
9286 assert_eq!(fish_flee_offset(0), 0);
9287 assert!(fish_flee_offset(400) >= 8);
9288 assert_eq!(fish_flee_offset(800), 0);
9289 assert_eq!(fish_flee_offset(8_000), 0);
9290 }
9291 }
9292
9292 lines RUST