返回 CodeWhale
transcript_legacy.rs
根目录 / crates / tui / src / tui / widgets / transcript_legacy.rs
1 //! Frozen mounted transcript rendering counterpart from the Composer-inherited base.
2 //! Unused ambient wrappers and the wall-clock constructor are omitted; the
3 //! retained rendering and selection fragments stay unchanged except for the
4 //! explicitly adopted viewport-relative prompt pin and its row reservation.
5 use super::*;
6 use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget};
7 const JUMP_TO_LATEST_BUTTON_WIDTH: u16 = 3;
8 const JUMP_TO_LATEST_BUTTON_HEIGHT: u16 = 3;
9
10 pub struct ChatWidget {
11 content_area: Rect,
12 /// Scrollable/selectable transcript geometry. When the last prompt is
13 /// pinned, this starts one row below `content_area`; the pinned header is
14 /// intentionally outside transcript hit-testing.
15 transcript_area: Rect,
16 lines: Vec<Line<'static>>,
17 line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
18 scrollbar: Option<TranscriptScrollbar>,
19 jump_to_latest_button: Option<Rect>,
20 background: Color,
21 ocean_column: Option<crate::tui::ocean::OceanColumn>,
22 /// Live-activity shape of the ambient scene (thinking/tools/subagents).
23 ocean_activity: crate::tui::ambient_life::AmbientActivity,
24 /// Ink for the selected underwater scene's idle fish/bubbles.
25 ambient_inks: Option<(Color, Color)>,
26 ocean_elapsed_ms: u128,
27 ocean_animated: bool,
28 /// Fixed-point (0..=1000) life presence; see `ocean::life_presence`.
29 life_presence_fixed: u16,
30 fish_flee_elapsed_ms: Option<u128>,
31 ambient_life: bool,
32 scroll_track: Color,
33 scroll_thumb: Color,
34 jump_border: Color,
35 jump_arrow: Color,
36 }
37
38 #[derive(Debug, Clone, Copy)]
39 struct TranscriptScrollbar {
40 top: usize,
41 visible: usize,
42 total: usize,
43 }
44
45 /// A `todo_write` result is a full replacement snapshot, not an incremental
46 /// transcript event. Keep only the newest successful snapshot in the visible
47 /// transcript while retaining every tool result in history for model context
48 /// and persistence.
49 fn superseded_todo_write_indices(
50 history: &[HistoryCell],
51 active_entries: &[HistoryCell],
52 ) -> HashSet<usize> {
53 let mut hidden = HashSet::new();
54 let mut latest = None;
55
56 for (index, cell) in history.iter().chain(active_entries).enumerate() {
57 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
58 continue;
59 };
60 if tool.name != "todo_write" || tool.status != ToolStatus::Success {
61 continue;
62 }
63
64 if let Some(previous) = latest.replace(index) {
65 hidden.insert(previous);
66 }
67 }
68
69 if let Some(index) = latest {
70 let cell = history
71 .get(index)
72 .or_else(|| active_entries.get(index.saturating_sub(history.len())));
73 if cell.is_some_and(todo_write_snapshot_is_empty) {
74 hidden.insert(index);
75 }
76 }
77
78 hidden
79 }
80
81 fn todo_write_snapshot_is_empty(cell: &HistoryCell) -> bool {
82 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
83 return false;
84 };
85 let Some(output) = tool.output.as_deref() else {
86 return false;
87 };
88 let Some(json_start) = output.find('{') else {
89 return false;
90 };
91 let Ok(value) = serde_json::from_str::<serde_json::Value>(&output[json_start..]) else {
92 return false;
93 };
94 value
95 .get("items")
96 .and_then(serde_json::Value::as_array)
97 .is_some_and(Vec::is_empty)
98 }
99
100 fn resolve_transcript_viewport_after_layout(
101 viewport: &mut ViewportState,
102 visible_lines: usize,
103 ) -> (usize, usize, bool) {
104 let total_lines = viewport.transcript_cache.total_lines();
105 let line_meta = viewport.transcript_cache.line_meta();
106 if viewport.pending_scroll_delta != 0 {
107 viewport.transcript_scroll = viewport.transcript_scroll.scrolled_by(
108 viewport.pending_scroll_delta,
109 line_meta,
110 visible_lines,
111 );
112 viewport.pending_scroll_delta = 0;
113 }
114
115 let max_start = total_lines.saturating_sub(visible_lines);
116 // Snapshot tail intent before resolve: clamping an out-of-range fixed
117 // offset can return `to_bottom()`, which must not masquerade as the user's
118 // choice to resume following a streaming tail (v0.8.11).
119 let was_explicit_tail = viewport.transcript_scroll.is_at_tail();
120 let (scroll_state, top) = viewport.transcript_scroll.resolve_top(line_meta, max_start);
121 viewport.transcript_scroll = scroll_state;
122 viewport.last_transcript_top = top;
123 viewport.last_transcript_visible = visible_lines;
124 viewport.last_transcript_total = total_lines;
125 (total_lines, top, was_explicit_tail)
126 }
127
128 impl ChatWidget {
129 /// Build one render snapshot from an already sampled ocean clock.
130 fn new_with_ocean_elapsed(app: &mut App, area: Rect, ocean_elapsed_ms: u128) -> Self {
131 let content_area = area;
132 let background = app.ui_theme.surface_bg;
133 // The ordinary shell inherits its host/theme surface. Underwater life
134 // is earned by the underwater theme, never painted over a user's
135 // terminal simply because the app happens to be active.
136 let underwater_atmosphere = app.theme_id == codewhale_palette::ThemeId::Underwater;
137 let ocean_ramp = underwater_atmosphere
138 .then(|| crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme))
139 .flatten();
140 // Ink hue carries the live activity (reasoning deep-dim, tools bright,
141 // sub-agents seafoam) so the marks read the state at a glance.
142 let ocean_activity = crate::tui::ambient_life::AmbientActivity::from_kind(
143 crate::tui::underwater::LiveActivity::from_app(app).kind(),
144 );
145 let ambient_inks = underwater_atmosphere
146 .then(|| crate::tui::ocean::ambient_inks_for_activity(&app.ui_theme, ocean_activity));
147 // The completion breath is authored decorative motion, so it rides the
148 // same motion gate as everything else in the water. Both the column's
149 // settle flourish and ambient life's presence read this one clock:
150 // the pet needs the settle tail too; the column clips only its light pulse.
151 let completion_life_clock = (underwater_atmosphere
152 && app.motion_policy().allows_decorative())
153 .then_some(())
154 .and(app.ocean_completion_started_at)
155 .map(|started| started.elapsed().as_millis());
156 let completion_elapsed_ms = completion_life_clock
157 .filter(|elapsed| *elapsed < crate::tui::ocean::COMPLETION_SETTLE_MS);
158 let completion_life_active = completion_life_clock
159 .is_some_and(|elapsed| elapsed < crate::tui::ocean::COMPLETION_SETTLE_MS);
160 let render_empty_state = should_render_empty_state(app);
161 let phase = ShellPhase::from_app(app);
162 // Keep the selected underwater scene alive while a turn is doing
163 // work, even after the transcript exists. The ordinary Flat shell
164 // remains entirely still and lets the host terminal lead.
165 let underwater_motion_enabled =
166 underwater_atmosphere && crate::tui::underwater::decorative_shell_motion_enabled(app);
167 let browsing_history = !app.viewport.transcript_scroll.is_at_tail();
168 let ocean_animated = underwater_motion_enabled
169 && (render_empty_state
170 || browsing_history
171 || matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
172 // Life presence eases the animated/static boundary as a pure function
173 // of the monotonic clocks (see ocean::life_presence): bursty fast
174 // streams ramp in, quiet waits settle out, never a hard snap.
175 //
176 // This deliberately takes the *gated* completion clock. Reading
177 // `app.ocean_completion_started_at` raw here let the completion branch
178 // of `life_presence` short-circuit the `!animated` check, so a
179 // reduced-motion session got ~1.4 s of full ambient life after every
180 // successful turn — precisely while the user was reading the result.
181 let life_presence = crate::tui::ocean::life_presence(
182 completion_life_clock,
183 app.turn_started_at
184 .map(|started| started.elapsed().as_millis()),
185 ocean_animated,
186 browsing_history,
187 render_empty_state,
188 );
189 let life_presence_fixed = (life_presence * 1000.0).round().clamp(0.0, 1000.0) as u16;
190 let ocean_column = ocean_ramp.map(|ramp| {
191 let context_percent = crate::tui::phase_strip::context_percent_from_app(app);
192 crate::tui::ocean::OceanColumn::new(
193 ramp,
194 content_area,
195 ocean_elapsed_ms,
196 completion_elapsed_ms,
197 phase,
198 ocean_animated,
199 life_presence_fixed,
200 context_percent,
201 )
202 });
203 let fish_flee_elapsed_ms = underwater_motion_enabled
204 .then_some(())
205 .and(app.turn_started_at)
206 .map(|started| started.elapsed().as_millis())
207 .filter(|elapsed| *elapsed < 800)
208 .filter(|_| matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
209 let scroll_track = app.ui_theme.border;
210 let scroll_thumb = app.ui_theme.status_working;
211 let jump_border = app.ui_theme.border;
212 let jump_arrow = app.ui_theme.status_working;
213 let visible_lines = content_area.height as usize;
214 let render_options = app.transcript_render_options();
215
216 if render_empty_state {
217 let lines = build_empty_state_lines(app, content_area);
218 app.viewport.last_transcript_area = Some(content_area);
219 app.viewport.last_transcript_top = 0;
220 app.viewport.last_transcript_visible = visible_lines;
221 app.viewport.last_transcript_total = 0;
222 app.viewport.last_transcript_padding_top = 0;
223 app.viewport.jump_to_latest_button_area = None;
224 return Self {
225 content_area,
226 transcript_area: content_area,
227 lines,
228 line_links: Vec::new(),
229 scrollbar: None,
230 jump_to_latest_button: None,
231 background,
232 ocean_column,
233 ocean_activity,
234 ambient_inks,
235 ocean_elapsed_ms,
236 ocean_animated,
237 life_presence_fixed,
238 fish_flee_elapsed_ms,
239 // Reduced-motion users still get a quiet, static Deepsea scene;
240 // Flat remains a normal host-owned terminal either way.
241 ambient_life: underwater_atmosphere
242 && !app.attention_hold_active()
243 && matches!(
244 phase,
245 ShellPhase::Idle
246 | ShellPhase::Typing
247 | ShellPhase::Working
248 | ShellPhase::Verifying
249 ),
250 scroll_track,
251 scroll_thumb,
252 jump_border,
253 jump_arrow,
254 };
255 }
256
257 // Reserve the scrollbar's column before wrapping, so painting it cannot
258 // erase the final character of a line. Keep this width stable when the
259 // history starts/stops scrolling; cached lines and copy metadata must
260 // use the same layout on both sides of that transition.
261 let transcript_width = content_area.width.saturating_sub(1).max(1);
262
263 // Per-cell revision caching (fix for issue #78):
264 //
265 // Every committed history cell carries its own revision counter in
266 // `app.history_revisions`. The transcript cache compares each cell's
267 // current revision against the previously rendered one, so unchanged
268 // cells reuse their cached wrapped lines instead of being re-wrapped
269 // every frame. This is the difference between O(history.len()) and
270 // O(changed_cells) per render — and was the root cause of scroll lag
271 // on long transcripts.
272 //
273 // The active in-flight cell (if any) is appended as the last cell so
274 // its mutations show up at the live tail. Each entry inside the
275 // active cell becomes a virtual cell at index `history.len() + i`,
276 // matching `App::cell_at_virtual_index`. Active-cell entries share
277 // the same `active_cell_revision` salt so any mutation in the active
278 // cell forces only those rows to re-render — committed history rows
279 // are unaffected.
280 app.resync_history_revisions();
281 app.viewport.transcript_cache.set_streaming_source_receipt(
282 app.streaming_source_receipt.map(|receipt| {
283 crate::tui::transcript::StreamingSourceReceipt {
284 cell_index: receipt.cell_index,
285 from_revision: history_entry_revision(receipt.from_revision),
286 to_revision: history_entry_revision(receipt.to_revision),
287 content_len: receipt.content_len,
288 }
289 }),
290 );
291 let provisional_action_owner = app.transcript_action_owner();
292 let active_entries: &[HistoryCell] = app
293 .active_cell
294 .as_ref()
295 .map_or(&[], |active| active.entries());
296 let history_len = app.history.len();
297 // Cache the group projection, not the whole frame: filtered refs and
298 // transcript bookkeeping below still walk the retained history.
299 let cache_key_matches = app.tool_run_cache.history_version == app.history_version
300 && app.tool_run_cache.history_len == history_len
301 && app.tool_run_cache.active_cell_revision == app.active_cell_revision
302 && app.tool_run_cache.active_len == active_entries.len()
303 && app.tool_run_cache.threshold == app.tool_collapse_threshold
304 && app.tool_run_cache.mode == app.tool_collapse_mode
305 && app.tool_run_cache.calm_mode == app.calm_mode
306 && app.tool_run_cache.expanded_runs == app.expanded_tool_runs;
307 if !cache_key_matches {
308 let superseded_todos = superseded_todo_write_indices(&app.history, active_entries);
309 let runs = if app.tool_collapse_active() {
310 crate::tui::history::detect_tool_runs_from_slices(
311 &app.history,
312 active_entries,
313 app.tool_collapse_threshold,
314 )
315 } else {
316 Vec::new()
317 };
318 let cache = &mut app.tool_run_cache;
319 #[cfg(test)]
320 {
321 cache.projection_builds += 1;
322 }
323 cache.generation = cache.generation.wrapping_add(1);
324 cache.summaries.clear();
325 cache.hidden_indices.clear();
326 for run in &runs {
327 // A hidden replacement snapshot cannot own a group summary.
328 if app.expanded_tool_runs.contains(&run.start)
329 || (run.start..run.start.saturating_add(run.count))
330 .any(|index| superseded_todos.contains(&index))
331 {
332 continue;
333 }
334 cache.summaries.insert(
335 run.start,
336 (
337 tool_run_summary_cell(run),
338 tool_run_summary_revision(
339 run,
340 &app.history_revisions,
341 history_len,
342 app.active_cell_revision,
343 ),
344 ),
345 );
346 cache
347 .hidden_indices
348 .extend(run.start + 1..run.start + run.count);
349 }
350 cache.superseded_todos = superseded_todos;
351 cache.history_version = app.history_version;
352 cache.history_len = history_len;
353 cache.active_cell_revision = app.active_cell_revision;
354 cache.active_len = active_entries.len();
355 cache.threshold = app.tool_collapse_threshold;
356 cache.mode = app.tool_collapse_mode;
357 cache.calm_mode = app.calm_mode;
358 cache.expanded_runs.clone_from(&app.expanded_tool_runs);
359 }
360 // v0.9.1: do not collapse concurrent sub-agent cards into an Enter-
361 // expand shelf. Count lives in header chrome; full cards stay visible;
362 // sidebar / SubAgents modal are the drill-in surface.
363 let has_collapsed = !app.collapsed_cells.is_empty()
364 || !app.tool_run_cache.summaries.is_empty()
365 || !app.tool_run_cache.superseded_todos.is_empty();
366 if has_collapsed {
367 app.tool_run_cache
368 .refresh_filtered(history_len + active_entries.len(), &app.collapsed_cells);
369 }
370 let summary_cells = &app.tool_run_cache.summaries;
371
372 // Fast path: no collapsed cells — use original slices directly.
373 if !has_collapsed {
374 let mut cell_revisions: Vec<u64> =
375 Vec::with_capacity(app.history.len() + active_entries.len());
376 cell_revisions.extend(
377 app.history_revisions
378 .iter()
379 .copied()
380 .map(history_entry_revision),
381 );
382 if !active_entries.is_empty() {
383 let active_rev = app.active_cell_revision;
384 for i in 0..active_entries.len() {
385 let salt = (i as u64).wrapping_add(1);
386 cell_revisions.push(active_entry_revision(active_rev, salt));
387 }
388 }
389 // Build identity mapping: filtered index == original index.
390 // Reused across frames; identity maps are rebuilt only when the
391 // row count changes.
392 let row_count = app.history.len() + active_entries.len();
393 if app.collapsed_cell_map.len() != row_count {
394 app.collapsed_cell_map = (0..row_count).collect();
395 }
396
397 let shards: [&[HistoryCell]; 2] = [&app.history, active_entries];
398 app.viewport.transcript_cache.ensure_split(
399 &shards,
400 &cell_revisions,
401 transcript_width,
402 render_options,
403 &app.thinking_folds,
404 None,
405 provisional_action_owner,
406 );
407 } else {
408 // Slow path: borrow non-collapsed cells into a filtered ref list
409 // so collapsed cells are excluded from rendering, and build the
410 // filtered→original index mapping. Collapsed run starts render a
411 // synthetic summary cell borrowed from the generation cache.
412 // No history cells or summary bodies are cloned on scroll frames.
413 // Which rows survive is cached per projection generation; only
414 // the revisions are gathered fresh (#6652).
415 let filtered = &app.tool_run_cache.filtered;
416 let active_rev = app.active_cell_revision;
417 let mut filtered_cells: Vec<&HistoryCell> = Vec::with_capacity(filtered.original.len());
418 let mut filtered_revs: Vec<u64> = Vec::with_capacity(filtered.original.len());
419 for &original in &filtered.original {
420 if original < history_len {
421 filtered_cells.push(&app.history[original]);
422 filtered_revs.push(history_entry_revision(app.history_revisions[original]));
423 } else {
424 let active_index = original - history_len;
425 filtered_cells.push(&active_entries[active_index]);
426 filtered_revs.push(active_entry_revision(
427 active_rev,
428 (active_index as u64).wrapping_add(1),
429 ));
430 }
431 }
432 for &slot in &filtered.summary_slots {
433 if let Some((summary, revision)) = summary_cells.get(&filtered.original[slot]) {
434 filtered_cells[slot] = summary;
435 filtered_revs[slot] = *revision;
436 }
437 }
438 app.collapsed_cell_map.clone_from(&filtered.original);
439
440 app.viewport.transcript_cache.ensure_filtered(
441 &filtered_cells,
442 &filtered_revs,
443 transcript_width,
444 render_options,
445 &app.thinking_folds,
446 Some(&app.collapsed_cell_map),
447 provisional_action_owner,
448 );
449 }
450
451 let (mut total_lines, mut top, mut was_explicit_tail) =
452 resolve_transcript_viewport_after_layout(&mut app.viewport, visible_lines);
453
454 // A sticky prompt is layout chrome, not a synthetic transcript row.
455 // First resolve against the full viewport, then reserve one real row
456 // only when the prompt has actually scrolled above it. Resolving once
457 // more with the smaller body keeps the newest tail line visible.
458 let mut transcript_area = content_area;
459 let mut pinned_prompt = (app.pin_last_prompt && content_area.height > 1)
460 .then(|| {
461 scrolled_user_prompt_pin(
462 &app.history,
463 app.viewport.transcript_cache.line_meta(),
464 &app.collapsed_cell_map,
465 top,
466 content_area.width,
467 )
468 })
469 .flatten();
470 let visible_lines = if pinned_prompt.is_some() {
471 transcript_area.y = transcript_area.y.saturating_add(1);
472 transcript_area.height = transcript_area.height.saturating_sub(1);
473 let visible = usize::from(transcript_area.height);
474 (total_lines, top, was_explicit_tail) =
475 resolve_transcript_viewport_after_layout(&mut app.viewport, visible);
476 // The approved pin follows the final viewport after its row is
477 // reserved, including a newer prompt that was at the old top.
478 pinned_prompt = scrolled_user_prompt_pin(
479 &app.history,
480 app.viewport.transcript_cache.line_meta(),
481 &app.collapsed_cell_map,
482 top,
483 content_area.width,
484 );
485 visible
486 } else {
487 visible_lines
488 };
489 let owner = app.transcript_action_owner();
490 let index_map = has_collapsed.then_some(app.collapsed_cell_map.as_slice());
491 app.viewport.transcript_cache.retarget(owner, index_map);
492
493 // The cache has now observed this revision (or the cell was filtered,
494 // in which case a later reveal must cold-render). Start the next append
495 // receipt from the current revision instead of chaining across an
496 // already-consumed proof.
497 if let Some(receipt) = app.streaming_source_receipt.as_mut() {
498 receipt.from_revision = receipt.to_revision;
499 }
500
501 let line_meta = app.viewport.transcript_cache.line_meta();
502
503 // If the user scrolled back to the live tail, the per-stream
504 // "leave me alone" lock is over — new chunks should pin to bottom
505 // again until they explicitly scroll up. Without this clear, content
506 // piles up off-screen below the visible area and the view appears
507 // frozen at the moment they returned to bottom.
508 //
509 // Only clear the lock when the user's INTENT was tail (their
510 // stored state was already `to_bottom()` before resolve), AND
511 // when the transcript actually has scrolling room to talk about
512 // — if everything fits in one screen, "tail" is trivially true
513 // and clearing here would yank the user back to bottom on the
514 // next chunk even though they explicitly scrolled up.
515 if was_explicit_tail && total_lines > visible_lines {
516 app.user_scrolled_during_stream = false;
517 }
518
519 app.viewport.last_transcript_area = Some(transcript_area);
520 app.viewport.last_transcript_padding_top = 0;
521
522 let end = (top + visible_lines).min(total_lines);
523 let mut lines = if total_lines == 0 {
524 vec![Line::from("")]
525 } else {
526 app.viewport.transcript_cache.lines()[top..end].to_vec()
527 };
528 let mut line_links = if total_lines == 0 {
529 vec![Vec::new()]
530 } else {
531 app.viewport.transcript_cache.line_links()[top..end].to_vec()
532 };
533
534 if !app.low_motion
535 && app.fancy_animations
536 && let (Some(start), Some(started)) = (
537 app.ocean_receipt_settle_start,
538 app.ocean_completion_started_at,
539 )
540 {
541 apply_receipt_settle_cascade(
542 &mut lines,
543 top,
544 line_meta,
545 &app.collapsed_cell_map,
546 &app.history,
547 start,
548 started.elapsed().as_millis(),
549 );
550 }
551
552 // Brief flash highlight on the most recently sent user message. It is
553 // a one-shot transition, so Reduced/Still clear the timestamp instead
554 // of leaving a stale flash waiting for a later state-change redraw.
555 if app.motion_policy().allows_decorative() {
556 if let Some(send_at) = app.last_send_at {
557 if send_at.elapsed() < SEND_FLASH_DURATION {
558 apply_send_flash(
559 &mut lines,
560 top,
561 &app.history,
562 line_meta,
563 &app.collapsed_cell_map,
564 );
565 } else {
566 app.last_send_at = None;
567 }
568 }
569 } else {
570 app.last_send_at = None;
571 }
572
573 // No background "highlight" for the Alt+V detail target: it used to
574 // paint the target cell's spans `Color::Reset`, which is invisible on
575 // terminal-owned themes and a black hole on painted surfaces
576 // (Underwater, Shoreline): the cell's text sat on the terminal's own
577 // background, and CJK trailing columns left black remnants when the
578 // cell scrolled (#6704). The footer's `Alt+V:details` hint names it.
579 apply_selection(&mut lines, top, app);
580
581 if let Some(pin) = pinned_prompt {
582 lines.insert(0, pin);
583 line_links.insert(0, Vec::new());
584 }
585
586 // The HTML contract is a top-first ledger. Bottom-padding the short
587 // transcript made every newly wrapped stream line shift all prior
588 // rows upward, producing repeated thousand-cell repaints and the
589 // visible "slab" motion recorded in live QA. Empty-state centering is
590 // handled separately; active work starts at the top and appends in
591 // place until scrolling is genuinely necessary.
592 app.viewport.last_transcript_padding_top = 0;
593
594 let scrollbar = (total_lines > visible_lines && transcript_area.width > 1).then_some(
595 TranscriptScrollbar {
596 top,
597 visible: visible_lines,
598 total: total_lines,
599 },
600 );
601 let jump_to_latest_button =
602 if app.use_mouse_capture && !app.viewport.transcript_scroll.is_at_tail() {
603 jump_to_latest_button_rect(transcript_area, scrollbar.is_some())
604 } else {
605 None
606 };
607 app.viewport.jump_to_latest_button_area = jump_to_latest_button;
608
609 Self {
610 content_area,
611 transcript_area,
612 lines,
613 line_links,
614 scrollbar,
615 jump_to_latest_button,
616 background,
617 ocean_column,
618 ocean_activity,
619 ambient_inks,
620 ocean_elapsed_ms,
621 ocean_animated,
622 life_presence_fixed,
623 fish_flee_elapsed_ms,
624 // Fish also accompany intentional transcript browsing in the
625 // selected underwater scene. They only occupy blank cells and are
626 // collision-checked, so history stays legible while the ocean
627 // remains playful when scrolling upward.
628 ambient_life: underwater_atmosphere
629 && !app.attention_hold_active()
630 && (browsing_history
631 || matches!(phase, ShellPhase::Working | ShellPhase::Verifying)
632 || completion_life_active),
633 scroll_track,
634 scroll_thumb,
635 jump_border,
636 jump_arrow,
637 }
638 }
639 }
640
641 fn apply_receipt_settle_cascade(
642 lines: &mut [Line<'static>],
643 top: usize,
644 line_meta: &[TranscriptLineMeta],
645 filtered_to_original: &[usize],
646 history: &[HistoryCell],
647 start: usize,
648 elapsed_ms: u128,
649 ) {
650 for (visible_index, line) in lines.iter_mut().enumerate() {
651 let Some((filtered_cell, _)) = line_meta
652 .get(top + visible_index)
653 .and_then(TranscriptLineMeta::cell_line)
654 else {
655 continue;
656 };
657 let original_cell = filtered_to_original
658 .get(filtered_cell)
659 .copied()
660 .unwrap_or(filtered_cell);
661 if original_cell < start
662 || !matches!(
663 history.get(original_cell),
664 Some(HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
665 )
666 || !receipt_is_settling(original_cell - start, elapsed_ms)
667 {
668 continue;
669 }
670 for span in &mut line.spans {
671 span.style = span.style.add_modifier(Modifier::DIM);
672 }
673 }
674 }
675
676 #[must_use]
677 fn receipt_is_settling(receipt_order: usize, elapsed_ms: u128) -> bool {
678 let delay = u128::try_from(receipt_order.min(6)).unwrap_or(6) * 70;
679 elapsed_ms < delay + 140
680 }
681
682 fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell {
683 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
684 name: "activity_group".to_string(),
685 status: ToolStatus::Success,
686 input_summary: Some(crate::tui::history::tool_run_summary(run)),
687 output: None,
688 prompts: None,
689 spillover_path: None,
690 output_summary: Some(format!("+{}", run.count)),
691 is_diff: false,
692 }))
693 }
694
695 fn tool_run_summary_revision(
696 run: &ToolRun,
697 revisions: &[u64],
698 history_len: usize,
699 active_rev: u64,
700 ) -> u64 {
701 let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64);
702 for idx in run.start..run.start.saturating_add(run.count) {
703 let cell_revision = revisions
704 .get(idx)
705 .copied()
706 .map(history_entry_revision)
707 .unwrap_or_else(|| {
708 let active_idx = idx.saturating_sub(history_len);
709 active_entry_revision(active_rev, (active_idx as u64).wrapping_add(1))
710 });
711 revision = revision.rotate_left(7) ^ cell_revision;
712 }
713 let extends_into_active = run.start.saturating_add(run.count) > history_len;
714 revision_in_domain(revision, extends_into_active)
715 }
716
717 const ACTIVE_REVISION_DOMAIN: u64 = 1 << 63;
718
719 fn revision_in_domain(revision: u64, active: bool) -> u64 {
720 // The top bit is exclusively a cache-domain tag. Clearing it means raw
721 // counters that differ only by bit 63 can theoretically alias within one
722 // domain after 2^63 updates; that lifetime is acceptable, while active and
723 // committed-history keys must never alias each other.
724 let payload = revision & !ACTIVE_REVISION_DOMAIN;
725 if active {
726 ACTIVE_REVISION_DOMAIN | payload
727 } else {
728 payload
729 }
730 }
731
732 fn history_entry_revision(revision: u64) -> u64 {
733 revision_in_domain(revision, false)
734 }
735
736 pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 {
737 // Active entries and committed history cells can occupy the same
738 // positional cache slot across `flush_active_cell`. Keep their revision
739 // domains distinct so the first active entry (`active_rev = 0`,
740 // `salt = 1`) cannot collide with the first history revision (`1`) and
741 // reuse a stale `running` render after cancellation.
742 let mixed = active_rev
743 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
744 .wrapping_add(salt);
745 revision_in_domain(mixed, true)
746 }
747
748 /// Build the last-user-prompt header when that message is above the resolved
749 /// transcript viewport. The caller owns the one-row layout reservation so
750 /// the header never masquerades as `top` or displaces the newest tail line.
751 fn scrolled_user_prompt_pin(
752 history: &[HistoryCell],
753 line_meta: &[TranscriptLineMeta],
754 collapsed_cell_map: &[usize],
755 top: usize,
756 width: u16,
757 ) -> Option<Line<'static>> {
758 if width == 0 || top == 0 {
759 return None;
760 }
761 // Freeze the approved viewport-relative pin semantics while retaining
762 // this renderer's independent full-buffer and layout comparison.
763 let user_first_line = |meta: &TranscriptLineMeta| -> Option<usize> {
764 let TranscriptLineMeta::CellLine {
765 cell_index,
766 line_in_cell: 0,
767 ..
768 } = meta
769 else {
770 return None;
771 };
772 let original = collapsed_cell_map
773 .get(*cell_index)
774 .copied()
775 .unwrap_or(*cell_index);
776 let content = match history.get(original) {
777 Some(HistoryCell::User { content }) => content,
778 _ => return None,
779 };
780 if content.lines().next().unwrap_or("").trim().is_empty() {
781 return None;
782 }
783 Some(original)
784 };
785 let orig_idx = line_meta.iter().take(top).rev().find_map(user_first_line)?;
786 let content = match history.get(orig_idx) {
787 Some(HistoryCell::User { content }) => content,
788 _ => return None,
789 };
790
791 let first = content.lines().next().unwrap_or("").trim();
792 if first.is_empty() {
793 return None;
794 }
795 let budget = usize::from(width.saturating_sub(4)).max(1);
796 let mut shown = String::new();
797 let mut used = 0usize;
798 for ch in first.chars() {
799 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
800 if used + w > budget {
801 break;
802 }
803 shown.push(ch);
804 used += w;
805 }
806 if used < UnicodeWidthStr::width(first) && !shown.is_empty() {
807 shown.push('…');
808 }
809
810 Some(Line::from(vec![
811 Span::styled(
812 format!("{} ", crate::tui::glyphs::USER),
813 Style::default()
814 .fg(palette::WHALE_HUMAN)
815 .add_modifier(Modifier::BOLD),
816 ),
817 Span::styled(shown, Style::default().fg(palette::TEXT_PRIMARY)),
818 ]))
819 }
820
821 impl Renderable for ChatWidget {
822 fn render(&self, _area: Rect, buf: &mut Buffer) {
823 // Use the passed render area, not self.content_area — those can
824 // drift when layout changes (e.g. file-tree pane toggle), and
825 // using the stale self.content_area is the root cause of text
826 // bleed-through (#400). In debug builds, assert the two match to
827 // catch future drift early.
828 debug_assert_eq!(
829 _area, self.content_area,
830 "ChatWidget content_area drifted from render area: \
831 content_area={:?} render_area={:?}",
832 self.content_area, _area
833 );
834
835 let area = _area;
836 // Repaint the full chat area with the codewhale-ink background each
837 // frame. Ratatui's `Paragraph` only writes cells that contain text,
838 // so cells the current frame's paragraph doesn't touch would
839 // otherwise hold the *previous* frame's contents (the `:24Z`
840 // timestamp-tail bleed-through reported in v0.8.5 testing). Using
841 // `Clear` reset cells to terminal default, which read as a brown-
842 // gray on most user setups; an explicit ink fill keeps the chat
843 // area on-brand.
844 Block::default()
845 .style(Style::default().bg(self.background))
846 .render(area, buf);
847
848 let paragraph =
849 Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background));
850 paragraph.render(area, buf);
851
852 self.render_underwater_field(area, buf);
853
854 // Link targets travel beside the wrapped lines, never inside Span
855 // content. Convert relative line columns to absolute viewport regions
856 // for the backend; clip the final column when a scrollbar owns it.
857 let link_area = Rect {
858 width: area
859 .width
860 .saturating_sub(u16::from(self.scrollbar.is_some())),
861 ..area
862 };
863 let regions = crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links);
864 crate::tui::osc8::set_frame_links(regions);
865
866 if let Some(scrollbar) = self.scrollbar {
867 let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
868 let mut state = ScrollbarState::new(scrollable_range)
869 .position(scrollbar.top.min(scrollable_range))
870 .viewport_content_length(scrollbar.visible);
871 Scrollbar::new(ScrollbarOrientation::VerticalRight)
872 .begin_symbol(None)
873 .end_symbol(None)
874 .track_symbol(Some("│"))
875 .track_style(Style::default().fg(self.scroll_track))
876 .thumb_symbol("┃")
877 .thumb_style(Style::default().fg(self.scroll_thumb))
878 .render(self.transcript_area, buf, &mut state);
879 }
880
881 if let Some(button_area) = self.jump_to_latest_button {
882 render_jump_to_latest_button(
883 button_area,
884 buf,
885 self.background,
886 self.jump_border,
887 self.jump_arrow,
888 );
889 }
890
891 // Hover: register OSC-8 link regions (copyable), then apply aura.
892 let link_area = Rect {
893 width: area
894 .width
895 .saturating_sub(u16::from(self.scrollbar.is_some())),
896 ..area
897 };
898 for region in crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links) {
899 let width = region
900 .col_end
901 .saturating_sub(region.col_start)
902 .saturating_add(1);
903 let hit = Rect::new(region.col_start, region.row, width, 1);
904 crate::tui::hover_layer::register_rect(
905 crate::tui::hover_hit::HoverTargetKind::Link,
906 hit,
907 region.target,
908 true,
909 );
910 }
911 }
912
913 fn desired_height(&self, _width: u16) -> u16 {
914 1
915 }
916 }
917
918 impl ChatWidget {
919 /// Paint the explicitly selected underwater field. Flat keeps the theme
920 /// surface, Solarized Light keeps canonical Base3, and Terminal keeps its
921 /// inherited background without inherited aquarium decoration.
922 fn render_underwater_field(&self, area: Rect, buf: &mut Buffer) {
923 if let Some(column) = self.ocean_column {
924 // Cache per-row ocean colors; invalidate only on phase/size/breath.
925 let phase_tag = column.phase_tag();
926 let fingerprint = column.ramp_fingerprint();
927 let ramp = crate::tui::ambient_life::frame_ocean_ramp(
928 &column,
929 area.height,
930 area.y,
931 self.ocean_elapsed_ms,
932 phase_tag,
933 fingerprint,
934 );
935 for local_y in 0..area.height {
936 let protected = self
937 .lines
938 .get(usize::from(local_y))
939 .and_then(occupied_text_bounds);
940 let row_bg = ramp
941 .get(usize::from(local_y))
942 .copied()
943 .unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
944 for local_x in 0..area.width {
945 let is_protected = protected.is_some_and(|(start, end)| {
946 usize::from(local_x) >= start && usize::from(local_x) < end
947 });
948 let cell = &mut buf[(area.x + local_x, area.y + local_y)];
949 // Plain transcript text participates in the water column;
950 // explicit semantic surfaces (selection, code, warnings)
951 // retain their own background.
952 if !is_protected || cell.bg == self.background {
953 cell.set_bg(row_bg);
954 }
955 }
956 }
957 }
958
959 if self.ambient_life
960 && let Some(inks) = self.ambient_inks
961 {
962 // The scatter has a centre. It used to be column 0 with a row in
963 // the middle of the field, which is neither where the school
964 // swims nor anywhere the eye is: the flee proximity test
965 // (|dy| < 6) could not even fire on a tall field, and when it did
966 // every fish was to the right of the anchor so the whole school
967 // slid the same way. Anchored on the composer's centre line and
968 // the school's own band, a turn beginning reads as the shoal
969 // parting around the thing that just happened.
970 let cursor = crate::tui::ambient_life::AmbientCursor {
971 column: area.x.saturating_add(area.width / 2),
972 row: area
973 .y
974 .saturating_add(crate::tui::ambient_life::school_band_row(area)),
975 flee_elapsed_ms: self.fish_flee_elapsed_ms,
976 };
977 // Whale cameo rides the completion breath clock when present.
978 let whale = crate::tui::ambient_life::WhaleCameo {
979 elapsed_ms: self.ocean_column.and_then(|c| c.completion_elapsed_ms()),
980 anchor_x: area.x.saturating_add(area.width / 2),
981 anchor_y: area.y.saturating_add(area.height.saturating_mul(2) / 3),
982 };
983 // Per-frame budget counters (built/painted/skipped/clipped);
984 // consumed by ambient-life tests and debug tooling, not by the
985 // widget itself.
986 let _ambient_stats = crate::tui::ambient_life::render_ambient_life(
987 area,
988 buf,
989 inks,
990 &self.lines,
991 self.ocean_elapsed_ms,
992 self.ocean_presence_f32(),
993 cursor,
994 whale,
995 self.ocean_activity,
996 );
997 if let Some(column) = self.ocean_column {
998 crate::tui::ambient_life::apply_caustic_shimmer(
999 area,
1000 buf,
1001 &column,
1002 self.ocean_elapsed_ms,
1003 self.ocean_animated,
1004 &self.lines,
1005 );
1006 }
1007 }
1008 }
1009 }
1010
1011 impl ChatWidget {
1012 /// Life presence as a 0..=1 fraction; drives ambient-life ink fading.
1013 fn ocean_presence_f32(&self) -> f32 {
1014 (f32::from(self.life_presence_fixed) / 1000.0).clamp(0.0, 1.0)
1015 }
1016 }
1017
1018 fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
1019 crate::tui::ambient_life::occupied_text_bounds(line)
1020 }
1021
1022 fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option<Rect> {
1023 if area.width < JUMP_TO_LATEST_BUTTON_WIDTH + u16::from(has_scrollbar)
1024 || area.height < JUMP_TO_LATEST_BUTTON_HEIGHT
1025 {
1026 return None;
1027 }
1028
1029 let scrollbar_gutter = u16::from(has_scrollbar);
1030 Some(Rect {
1031 x: area
1032 .x
1033 .saturating_add(area.width)
1034 .saturating_sub(scrollbar_gutter)
1035 .saturating_sub(JUMP_TO_LATEST_BUTTON_WIDTH),
1036 y: area
1037 .y
1038 .saturating_add(area.height)
1039 .saturating_sub(JUMP_TO_LATEST_BUTTON_HEIGHT),
1040 width: JUMP_TO_LATEST_BUTTON_WIDTH,
1041 height: JUMP_TO_LATEST_BUTTON_HEIGHT,
1042 })
1043 }
1044
1045 fn render_jump_to_latest_button(
1046 area: Rect,
1047 buf: &mut Buffer,
1048 background: Color,
1049 border: Color,
1050 arrow: Color,
1051 ) {
1052 Block::default()
1053 .borders(Borders::ALL)
1054 .border_type(BorderType::Rounded)
1055 .border_style(Style::default().fg(border))
1056 .style(Style::default().bg(background))
1057 .render(area, buf);
1058
1059 let arrow_x = area.x.saturating_add(1);
1060 let arrow_y = area.y.saturating_add(1);
1061 buf[(arrow_x, arrow_y)]
1062 .set_symbol("↓")
1063 .set_style(Style::default().fg(arrow).add_modifier(Modifier::BOLD));
1064 }
1065
1066 fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
1067 let Some((start, end)) = app.viewport.transcript_selection.ordered_endpoints() else {
1068 return;
1069 };
1070
1071 let selection_style = Style::default()
1072 .bg(app.ui_theme.selection_bg)
1073 .fg(palette::SELECTION_TEXT);
1074
1075 for (idx, line) in lines.iter_mut().enumerate() {
1076 let line_index = top + idx;
1077 if line_index < start.line_index || line_index > end.line_index {
1078 continue;
1079 }
1080
1081 let (col_start, col_end) = if start.line_index == end.line_index {
1082 (start.column, end.column)
1083 } else if line_index == start.line_index {
1084 (start.column, usize::MAX)
1085 } else if line_index == end.line_index {
1086 (0, end.column)
1087 } else {
1088 (0, usize::MAX)
1089 };
1090
1091 if col_start == 0 && col_end == usize::MAX {
1092 for span in &mut line.spans {
1093 span.style = span.style.patch(selection_style);
1094 }
1095 continue;
1096 }
1097
1098 line.spans = apply_selection_to_line(line, col_start, col_end, selection_style);
1099 }
1100 }
1101
1102 /// Apply a brief background tint to the last user message's visible lines.
1103 fn apply_send_flash(
1104 lines: &mut [Line<'static>],
1105 top: usize,
1106 history: &[HistoryCell],
1107 line_meta: &[TranscriptLineMeta],
1108 original_index_map: &[usize],
1109 ) {
1110 // Find the last User cell index.
1111 let last_user_cell = history
1112 .iter()
1113 .rposition(|cell| matches!(cell, HistoryCell::User { .. }));
1114 let Some(target_cell) = last_user_cell else {
1115 return;
1116 };
1117
1118 let flash_bg = palette::SURFACE_TOOL_ACTIVE; // subtle dark-blue tint
1119
1120 for (idx, line) in lines.iter_mut().enumerate() {
1121 let line_index = top + idx;
1122 if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
1123 && original_index_map
1124 .get(*cell_index)
1125 .copied()
1126 .unwrap_or(*cell_index)
1127 == target_cell
1128 {
1129 for span in &mut line.spans {
1130 span.style = span.style.bg(flash_bg);
1131 }
1132 }
1133 }
1134 }
1135
1136 fn apply_selection_to_line(
1137 line: &Line<'static>,
1138 col_start: usize,
1139 col_end: usize,
1140 selection_style: Style,
1141 ) -> Vec<Span<'static>> {
1142 let mut result = Vec::with_capacity(line.spans.len().saturating_add(2));
1143 let mut current_col = 0usize;
1144
1145 for span in &line.spans {
1146 let span_text: &str = span.content.as_ref();
1147 let span_width = text_display_width(span_text);
1148 let span_end = current_col.saturating_add(span_width);
1149
1150 if span_end <= col_start || current_col >= col_end {
1151 result.push(span.clone());
1152 } else if current_col >= col_start && span_end <= col_end {
1153 result.push(Span::styled(
1154 span.content.clone(),
1155 span.style.patch(selection_style),
1156 ));
1157 } else {
1158 let mut before = String::new();
1159 let mut selected = String::new();
1160 let mut after = String::new();
1161 let mut grapheme_col = current_col;
1162
1163 for grapheme in span_text.graphemes(true) {
1164 let grapheme_width = grapheme_display_width(grapheme);
1165 let grapheme_start = grapheme_col;
1166 let grapheme_end = grapheme_col.saturating_add(grapheme_width);
1167 if grapheme_end <= col_start {
1168 before.push_str(grapheme);
1169 } else if grapheme_start >= col_end {
1170 after.push_str(grapheme);
1171 } else {
1172 selected.push_str(grapheme);
1173 }
1174 grapheme_col = grapheme_end;
1175 }
1176
1177 if !before.is_empty() {
1178 result.push(Span::styled(before, span.style));
1179 }
1180 if !selected.is_empty() {
1181 result.push(Span::styled(selected, span.style.patch(selection_style)));
1182 }
1183 if !after.is_empty() {
1184 result.push(Span::styled(after, span.style));
1185 }
1186 }
1187
1188 current_col = span_end;
1189 }
1190
1191 result
1192 }
1193
1194 // Frozen source boundary.
1195
1196 pub(super) fn selected_spans(
1197 line: &Line<'static>,
1198 start: usize,
1199 end: usize,
1200 style: Style,
1201 ) -> Vec<Span<'static>> {
1202 apply_selection_to_line(line, start, end, style)
1203 }
1204
1205 // Public test adapter outside the byte-exact frozen production fragments.
1206 pub(super) fn snapshot(app: &mut App, area: Rect, elapsed_ms: u128) -> ChatWidget {
1207 ChatWidget::new_with_ocean_elapsed(app, area, elapsed_ms)
1208 }
1209
1209 lines RUST