Skip to main content

thndrs_lib/cli/renderer/
live.rs

1//! Live prompt and focused-surface row builders.
2//!
3//! The live chrome is rebuilt after dirty updates and composed into the full
4//! viewport by [`super::alternate::AlternateViewport`].
5
6#[cfg(test)]
7mod tests;
8
9use crate::app::{App, Entry, Mode, PromptState, RecoveryStage, ToolStatus};
10use crate::providers::{codex, umans};
11use crate::renderer::cursor::{prompt_cursor, prompt_rows};
12use crate::renderer::row::{CursorCoord, Row};
13use crate::renderer::style::{CellStyle, Color, Span};
14use crate::renderer::transcript::GUTTER;
15use crate::renderer::view::{FocusedSurfaceView, SurfaceRenderInput, SurfaceThemeView};
16use crate::{renderer, utils};
17
18/// Maximum rows the prompt input can occupy before scrolling within the live region.
19pub const MAX_PROMPT_ROWS: usize = 8;
20
21/// Maximum accessory rows (help/commands/files) shown in the live region.
22pub const MAX_ACCESSORY_ROWS: usize = 8;
23
24const LIVE_INSET: usize = 2;
25const COMPOSER_MIN_CONTENT_WIDTH: usize = 8;
26
27/// Build prompt input rows from app state.
28///
29/// Returns the rows and the cursor coordinate (relative to the first row).
30pub fn prompt_rows_for(app: &App, width: usize) -> (Vec<Row>, Option<CursorCoord>) {
31    let p = super::style::palette();
32    let surface = p.panel_bg;
33    let prompt_state = app.prompt_state();
34
35    let (prompt_color, icon) = match prompt_state {
36        PromptState::Editable => (p.yellow, "❯"),
37        PromptState::Submitted => (p.teal, "»"),
38        PromptState::Streaming | PromptState::RunningTool => (p.teal, "»"),
39        PromptState::Stopped => (p.teal, "○"),
40        PromptState::Errored => (p.red, "✕"),
41    };
42
43    let prefix_width = if app.mode == Mode::Command { 4 } else { 3 };
44    let row_body_width = super::layout::content_width(width);
45    let framed = composer_frame_height(width) > 0;
46    let horizontal_chrome = if framed { 4 } else { LIVE_INSET };
47    let body_width = row_body_width.saturating_sub(horizontal_chrome + prefix_width).max(1);
48    let cursor_indent = width.min(2) + LIVE_INSET + prefix_width;
49    let hidden_entry_active = app.first_run_recovery.as_ref().is_some_and(|recovery| {
50        matches!(
51            recovery.stage,
52            RecoveryStage::EnterKey | RecoveryStage::ChatGptOAuthPasteRedirect
53        )
54    });
55    let hidden_display = String::from("credential: [hidden]");
56    let input_text = if hidden_entry_active { hidden_display.as_str() } else { app.input.as_str() };
57    let cursor_pos = if hidden_entry_active { input_text.len() } else { app.input.cursor() };
58
59    let visual_rows = prompt_rows(input_text, body_width);
60    let cursor = prompt_cursor(input_text, cursor_pos, body_width, cursor_indent);
61
62    let text_style = CellStyle::new().fg(p.text).bg(surface);
63    let mention_style = CellStyle::new().fg(p.accent).bg(surface).bold();
64
65    let border_style = CellStyle::new().fg(prompt_color).bg(surface);
66    let mut rows = Vec::with_capacity(visual_rows.len());
67    for (idx, line) in visual_rows.into_iter().enumerate() {
68        let mut spans: Vec<Span> = if framed && idx == 0 {
69            let mut s = vec![
70                Span::styled("│", border_style),
71                Span::styled(" ", CellStyle::new().bg(surface)),
72                Span::styled(icon, CellStyle::new().fg(prompt_color).bg(surface)),
73                Span::styled("  ", CellStyle::new().bg(surface)),
74            ];
75            if app.mode == Mode::Command {
76                s.push(Span::styled(":", CellStyle::new().fg(p.accent).bg(surface)));
77            }
78            s
79        } else if framed {
80            vec![
81                Span::styled("│", border_style),
82                Span::styled(" ".repeat(1 + prefix_width), CellStyle::new().bg(surface)),
83            ]
84        } else if idx == 0 {
85            let mut s = vec![
86                Span::styled(" ".repeat(LIVE_INSET), CellStyle::new().bg(surface)),
87                Span::styled(icon, CellStyle::new().fg(prompt_color).bg(surface)),
88                Span::styled("  ", CellStyle::new().bg(surface)),
89            ];
90            if app.mode == Mode::Command {
91                s.push(Span::styled(":", CellStyle::new().fg(p.accent).bg(surface)));
92            }
93            s
94        } else {
95            vec![Span::styled(
96                " ".repeat(LIVE_INSET + prefix_width),
97                CellStyle::new().bg(surface),
98            )]
99        };
100
101        if !line.is_empty() {
102            spans.extend(mention_styled_spans(&line, text_style, mention_style, surface));
103        }
104        if framed {
105            let used = super::layout::spans_width(&spans);
106            let fill = row_body_width.saturating_sub(used + 1);
107            spans.push(Span::styled(" ".repeat(fill), CellStyle::new().bg(surface)));
108            spans.push(Span::styled("│", border_style));
109        }
110        rows.push(Row::padded(spans, width, CellStyle::new().bg(surface)));
111    }
112
113    if rows.is_empty() {
114        let mut spans = if framed {
115            vec![
116                Span::styled("│", border_style),
117                Span::styled(" ", CellStyle::new().bg(surface)),
118                Span::styled(icon, CellStyle::new().fg(prompt_color).bg(surface)),
119                Span::styled("  ", CellStyle::new().bg(surface)),
120            ]
121        } else {
122            vec![
123                Span::styled(" ".repeat(LIVE_INSET), CellStyle::new().bg(surface)),
124                Span::styled(icon, CellStyle::new().fg(prompt_color).bg(surface)),
125                Span::styled("  ", CellStyle::new().bg(surface)),
126            ]
127        };
128        if app.mode == Mode::Command {
129            spans.push(Span::styled(":", CellStyle::new().fg(p.accent).bg(surface)));
130        }
131        if framed {
132            let used = super::layout::spans_width(&spans);
133            let fill = row_body_width.saturating_sub(used + 1);
134            spans.push(Span::styled(" ".repeat(fill), CellStyle::new().bg(surface)));
135            spans.push(Span::styled("│", border_style));
136        }
137        rows.push(Row::padded(spans, width, CellStyle::new().bg(surface)));
138    }
139
140    (rows, if hidden_entry_active { None } else { Some(cursor) })
141}
142
143/// Number of horizontal frame rows reserved by the composer at this width.
144pub fn composer_frame_height(width: usize) -> usize {
145    usize::from(super::layout::content_width(width) >= COMPOSER_MIN_CONTENT_WIDTH) * 2
146}
147
148/// Add the horizontal frame around already-wrapped prompt body rows.
149pub fn frame_prompt_rows(
150    app: &App, width: usize, rows: Vec<Row>, cursor: Option<CursorCoord>,
151) -> (Vec<Row>, Option<CursorCoord>) {
152    if composer_frame_height(width) == 0 {
153        return (rows, cursor);
154    }
155
156    let p = super::style::palette();
157    let bg = p.panel_bg;
158    let border_color = match app.prompt_state() {
159        PromptState::Editable => p.yellow,
160        PromptState::Submitted | PromptState::Streaming | PromptState::RunningTool | PromptState::Stopped => p.teal,
161        PromptState::Errored => p.red,
162    };
163    let border_style = CellStyle::new().fg(border_color).bg(bg);
164    let label_style = border_style.bold();
165    let content_width = super::layout::content_width(width);
166
167    let left = "╭─";
168    let session = if app.session_id.is_empty() { "thndrs" } else { &app.session_id };
169    let session_budget = content_width.saturating_sub(5).max(1);
170    let label = format!(" {} ", utils::truncate_ellipsis(session, session_budget));
171    let status_label = app.status_label();
172    let status = (status_label != "idle").then(|| {
173        let icon = super::style::status_icon(
174            status_label,
175            super::style::spinner_tick(app.ui_tick, app.cli.tick_rate_ms),
176        );
177        format!(" {icon} {status_label} ")
178    });
179    let status_width = status.as_deref().map_or(0, utils::text_width);
180    let fixed = utils::text_width(left) + utils::text_width(&label) + status_width + 1;
181    let top_spans = if fixed + 2 <= content_width {
182        let mut spans = vec![
183            Span::styled(left, border_style),
184            Span::styled(label, label_style),
185            Span::styled("─".repeat(content_width - fixed), border_style),
186        ];
187        if let Some(status) = status {
188            spans.push(Span::styled(
189                status,
190                CellStyle::new().fg(super::style::status_color(status_label)).bg(bg),
191            ));
192        }
193        spans.push(Span::styled("╮", border_style));
194        spans
195    } else if utils::text_width(left) + utils::text_width(&label) < content_width {
196        let fixed = utils::text_width(left) + utils::text_width(&label) + 1;
197        vec![
198            Span::styled(left, border_style),
199            Span::styled(label, label_style),
200            Span::styled("─".repeat(content_width - fixed), border_style),
201            Span::styled("╮", border_style),
202        ]
203    } else {
204        vec![Span::styled(
205            format!("╭{}╮", "─".repeat(content_width.saturating_sub(2))),
206            border_style,
207        )]
208    };
209
210    let mut framed = Vec::with_capacity(rows.len() + 2);
211    framed.push(Row::padded(top_spans, width, CellStyle::new().bg(bg)));
212    framed.extend(rows);
213    framed.push(Row::padded(
214        vec![Span::styled(
215            format!("╰{}╯", "─".repeat(content_width.saturating_sub(2))),
216            border_style,
217        )],
218        width,
219        CellStyle::new().bg(bg),
220    ));
221
222    let cursor = cursor.map(|mut cursor| {
223        cursor.row += 1;
224        cursor
225    });
226    (framed, cursor)
227}
228
229/// Build accessory rows (help, commands, or file picker) if active.
230///
231/// Returns an empty vec when no accessory is visible.
232pub fn accessory_rows(app: &App, width: usize, max_height: usize) -> Vec<Row> {
233    if max_height == 0 {
234        return Vec::new();
235    }
236
237    let focused_surface = FocusedSurfaceView::from(app);
238    if !matches!(focused_surface, FocusedSurfaceView::None) {
239        return super::adapter::render_surface(&SurfaceRenderInput {
240            surface: &focused_surface,
241            theme: &SurfaceThemeView::new(),
242            width,
243            height: max_height,
244        });
245    }
246
247    Vec::new()
248}
249
250/// Build a queued-prompt summary row when steering or follow-up prompts are pending.
251///
252/// Returns `None` when the queue is empty or the agent is idle.
253pub fn queued_summary_row(app: &App, width: usize) -> Option<Row> {
254    let steering = app.queued_steering.len();
255    let followups = app.queued_followups.len();
256    if steering == 0 && followups == 0 {
257        return None;
258    }
259
260    let p = super::style::palette();
261    let bg = Color::Reset;
262    let label_style = CellStyle::new().fg(p.peach).bg(bg).bold();
263    let muted_style = CellStyle::new().fg(p.subtext0).bg(bg);
264    let mut spans = vec![
265        Span::styled(" ".repeat(LIVE_INSET), CellStyle::new().bg(bg)),
266        Span::styled("queued", label_style),
267    ];
268
269    if steering > 0 {
270        spans.push(Span::styled(format!("  {steering} steering"), muted_style));
271    }
272    if followups > 0 {
273        spans.push(Span::styled(format!("  {followups} follow-up"), muted_style));
274    }
275    Some(Row::padded(spans, width, CellStyle::new().bg(bg)))
276}
277
278/// Build detail pane rows for the expanded tool entry.
279///
280/// Shows a title bar with the tool name and status, then the full output
281/// wrapped into visual rows. The scroll offset is applied to those rendered
282/// rows so long lines scroll by visible terminal row rather than by raw output
283/// line.
284pub fn detail_pane_rows(app: &App, width: usize, max_height: usize) -> Vec<Row> {
285    if max_height == 0 {
286        return Vec::new();
287    }
288
289    let Some(entry) = app.transcript.get(app.detail_pane.entry_index) else {
290        return Vec::new();
291    };
292    let Entry::Tool { name, arguments, status, output } = entry else {
293        return Vec::new();
294    };
295
296    let p = super::style::palette();
297    let bg = p.surface0;
298    let title_style = CellStyle::new().fg(p.accent).bg(bg).bold();
299    let status_color = match status {
300        ToolStatus::Running => p.peach,
301        ToolStatus::Ok => p.green,
302        ToolStatus::Failed => p.red,
303        ToolStatus::Cancelled => p.peach,
304    };
305    let status_style = CellStyle::new().fg(status_color).bg(bg);
306    let muted_style = CellStyle::new().fg(p.subtext0).bg(bg);
307    let body_style = CellStyle::new().fg(p.text).bg(bg);
308    let gutter_style = CellStyle::new().fg(p.overlay0).bg(bg);
309
310    let status_label = match status {
311        ToolStatus::Running => "running",
312        ToolStatus::Ok => "ok",
313        ToolStatus::Failed => "failed",
314        ToolStatus::Cancelled => "cancelled",
315    };
316
317    let base_name = name.split('#').next().unwrap_or(name);
318    let mut title_spans = vec![
319        Span::styled(" ".repeat(LIVE_INSET), CellStyle::new().bg(bg)),
320        Span::styled(base_name.to_string(), title_style),
321        Span::styled(format!(" [{status_label}]"), status_style),
322    ];
323
324    let args_summary = renderer::transcript::summarize_tool_invocation(base_name, arguments, &app.cwd);
325    if !args_summary.is_empty() {
326        title_spans.push(Span::styled("  ", CellStyle::new().bg(bg)));
327        title_spans.push(Span::styled(args_summary, muted_style));
328    }
329
330    let body_width = super::layout::content_width(width).saturating_sub(utils::text_width(GUTTER));
331    let mut body_rows = Vec::new();
332
333    for line in output {
334        let line = renderer::path_display::transcript_line(line, &app.cwd);
335        for wrapped in super::layout::wrap_text(&line, body_width) {
336            let spans = vec![Span::styled(GUTTER, gutter_style), Span::styled(wrapped, body_style)];
337            body_rows.push(Row::padded(spans, width, CellStyle::new().bg(bg)));
338        }
339    }
340
341    let mut rows = Vec::with_capacity(max_height);
342    let scroll = app.detail_pane.scroll.min(body_rows.len().saturating_sub(1));
343    let body_budget = max_height.saturating_sub(1);
344    let hidden_above = scroll;
345    let hidden_below = body_rows.len().saturating_sub(scroll + body_budget);
346
347    rows.push(Row::padded(title_spans, width, CellStyle::new().bg(bg)));
348    rows.extend(body_rows.into_iter().skip(scroll).take(body_budget));
349    if (hidden_above > 0 || hidden_below > 0)
350        && let Some(row) = rows.last_mut()
351    {
352        *row = clipped_detail_indicator_row(width, bg, muted_style, hidden_above, hidden_below);
353    }
354    rows
355}
356
357/// Build the static status row (model/reasoning/search/tokens/cwd) below the prompt.
358///
359/// Width-aware clipping hides segments that don't fit.
360pub fn static_status_row(app: &App, width: usize) -> Row {
361    let p = super::style::palette();
362    let bg = p.panel_bg;
363    let subtext = CellStyle::new().fg(p.subtext0).bg(bg);
364    let trust_text = "local user · workspace-contained tools · no TUI sandbox";
365    let muted = CellStyle::new().fg(p.overlay0).bg(bg);
366    let model_label = format!("model: {}", codex::display_model_id(&app.model));
367    let reasoning_text =
368        supports_reasoning_status(&app.model).then(|| format!("reasoning: {}", app.cli.reasoning_effort.label()));
369    let search_label = app.websearch.label();
370    let search_text = format!("search: {search_label}");
371    let token_text = compact_token_status(app);
372    let ttft_text = ttft_status_text(app);
373    let git_text = app.git_status.as_ref().map(|summary| summary.display());
374    let token_style = CellStyle::new().fg(p.peach).bg(bg);
375    let ttft_style = CellStyle::new().fg(p.teal).bg(bg);
376    let git_style = CellStyle::new().fg(p.green).bg(bg);
377
378    let (show_model, show_reasoning, show_search, show_tokens, show_ttft, show_git, show_cwd, show_trust) = match width
379    {
380        w if w < 24 => (false, false, false, false, false, false, false, false),
381        w if w < 42 => (true, false, false, false, false, false, false, false),
382        w if w < 56 => (true, false, true, false, false, false, false, false),
383        w if w < 72 => (true, false, true, true, false, false, false, false),
384        w if w < 88 => (true, true, true, true, false, true, false, false),
385        w if w < 97 => (true, true, true, true, true, true, false, false),
386        w if w < 160 => (true, true, true, true, true, true, true, false),
387        _ => (true, true, true, true, true, true, true, true),
388    };
389
390    let mut spans: Vec<Span> = Vec::new();
391    let mut used = LIVE_INSET;
392    spans.push(Span::styled(" ".repeat(LIVE_INSET), CellStyle::new().bg(bg)));
393
394    let mut push_segment = |text: &str, style: CellStyle, used: &mut usize| {
395        let segment_len = utils::text_width(text);
396        if *used == LIVE_INSET {
397            *used = used.saturating_add(segment_len);
398            spans.push(Span::styled(text.to_string(), style));
399            return;
400        }
401
402        let separator_len = 3;
403        if *used + separator_len + segment_len > width {
404            return;
405        }
406
407        spans.push(Span::styled("   ", CellStyle::new().bg(bg)));
408        spans.push(Span::styled(text.to_string(), style));
409        *used = used.saturating_add(separator_len + segment_len);
410    };
411
412    if show_model {
413        push_segment(&model_label, subtext, &mut used);
414    }
415    if show_reasoning && let Some(reasoning_text) = reasoning_text.as_deref() {
416        push_segment(reasoning_text, CellStyle::new().fg(p.mauve).bg(bg), &mut used);
417    }
418    if show_search {
419        push_segment(&search_text, subtext, &mut used);
420    }
421    if show_tokens {
422        push_segment(&token_text, token_style, &mut used);
423    }
424    if show_ttft && let Some(ttft_text) = ttft_text {
425        push_segment(&ttft_text, ttft_style, &mut used);
426    }
427    if show_git && let Some(git_text) = git_text {
428        push_segment(&git_text, git_style, &mut used);
429    }
430    if show_trust {
431        push_segment(trust_text, subtext, &mut used);
432    }
433    if show_cwd {
434        let cwd_display = super::path_display::footer_segment(&app.cwd, width, used + 3);
435        push_segment(&cwd_display, muted, &mut used);
436    }
437
438    Row::padded(spans, width, CellStyle::new().bg(bg))
439}
440
441fn clipped_detail_indicator_row(
442    width: usize, bg: Color, style: CellStyle, hidden_above: usize, hidden_below: usize,
443) -> Row {
444    let text = match (hidden_above, hidden_below) {
445        (0, below) => format!("   │ … {below} rows below"),
446        (above, 0) => format!("   │ … {above} rows above"),
447        (above, below) => format!("   │ … {above} rows above, {below} below"),
448    };
449    Row::padded(vec![Span::styled(text, style)], width, CellStyle::new().bg(bg))
450}
451
452fn ttft_status_text(app: &App) -> Option<String> {
453    if app.ttft.is_pending() {
454        return Some(String::from("ttft: pending"));
455    }
456
457    app.ttft.last_completed().map(|duration| {
458        let millis = duration.as_millis();
459        if millis < 1_000 {
460            format!("ttft: {millis}ms")
461        } else {
462            format!("ttft: {:.1}s", millis as f64 / 1_000.0)
463        }
464    })
465}
466
467fn compact_token_status(app: &App) -> String {
468    let Some(accounting) = &app.last_request_accounting else {
469        return format!("tok: ↑{} ↓{}", app.session_tokens_in, app.session_tokens_out);
470    };
471    let estimate = accounting
472        .estimated_input_tokens
473        .value
474        .map_or_else(|| "?".to_string(), |value| value.to_string());
475    let provider = accounting
476        .provider_usage
477        .as_ref()
478        .and_then(|usage| usage.inclusive_input_tokens.value)
479        .map_or_else(|| "?".to_string(), |value| value.to_string());
480    let cache = accounting
481        .provider_usage
482        .as_ref()
483        .and_then(|usage| usage.components.cache_read_input_tokens)
484        .map_or_else(|| "?".to_string(), |value| value.to_string());
485    format!("tok est:{estimate} prov:{provider} cache:{cache}")
486}
487
488fn supports_reasoning_status(model: &str) -> bool {
489    codex::supports_reasoning_effort(model) || umans::reasoning_options(model).len() > 1
490}
491
492fn mention_styled_spans(line: &str, text_style: CellStyle, mention_style: CellStyle, _bg: Color) -> Vec<Span> {
493    let mut spans = Vec::new();
494    let mut current = String::new();
495    let chars: Vec<char> = line.chars().collect();
496    let mut i = 0;
497
498    while i < chars.len() {
499        if chars[i] == '@' {
500            if !current.is_empty() {
501                spans.push(Span::styled(std::mem::take(&mut current), text_style));
502            }
503
504            let mut mention = String::from('@');
505            i += 1;
506            while i < chars.len() && is_mention_char(chars[i]) {
507                mention.push(chars[i]);
508                i += 1;
509            }
510
511            if mention.len() > 1 {
512                spans.push(Span::styled(mention, mention_style));
513            } else {
514                spans.push(Span::styled("@", text_style));
515            }
516        } else {
517            current.push(chars[i]);
518            i += 1;
519        }
520    }
521
522    if !current.is_empty() {
523        spans.push(Span::styled(current, text_style));
524    }
525
526    if spans.is_empty() {
527        spans.push(Span::styled(line.to_string(), text_style));
528    }
529
530    spans
531}
532
533/// Whether a character is valid in a file mention path after `@`.
534fn is_mention_char(ch: char) -> bool {
535    ch.is_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | '~')
536}