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