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