Skip to main content

thndrs_lib/cli/renderer/
view.rs

1//! Pure renderer view projection.
2//!
3//! [`RendererView`] is a data-only staging area built from [`App`] plus
4//! terminal dimensions. It contains no crossterm types and performs no terminal
5//! writes. The view separates semantic row construction from viewport policy so
6//! that [`super::alternate::AlternateViewport`] can focus on viewport policy,
7//! projection caching, and frame composition.
8
9#[cfg(test)]
10mod tests;
11
12use crate::app::{
13    App, CONTEXT_INSPECTION_MAX_ITEMS, ChatGptOAuthMethod, Entry, FilePickerSource, FirstRunRecovery, Mode,
14    PromptAccessory, RecoveryStage, RunState, ToolStatus,
15};
16use crate::cli::commands::setup::SetupProviderArg;
17use crate::renderer::row::{CursorCoord, Row};
18use crate::renderer::transcript::TranscriptRowContext;
19use crate::tools::shell::redact_secrets;
20use crate::utils;
21
22/// Renderer adapter for iocraft-backed or other bounded surfaces.
23pub trait SurfaceRenderer {
24    fn render_surface(&mut self, input: SurfaceRenderInput<'_>) -> Vec<Row>;
25}
26
27/// A backend-neutral theme role.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum ThemeRole {
30    Text,
31    Muted,
32    Selected,
33    Warning,
34    Error,
35    DiffAdded,
36    DiffRemoved,
37}
38
39/// A transcript row family.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum TranscriptRowKind {
42    User,
43    Assistant,
44    Reasoning,
45    Tool,
46    Edit,
47    Diff,
48    Status,
49    Error,
50    Notice,
51    Cancelled,
52}
53
54impl TranscriptRowKind {
55    fn build_row(self, stable: bool, primary: String) -> TranscriptRowView {
56        TranscriptRowView { kind: self, stable, primary, tool: None, edit: None, diff: None }
57    }
58}
59
60/// Prompt suggestion family.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum PromptSuggestionKind {
63    Command,
64    FileMention,
65}
66
67/// Prompt input mode.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum PromptModeView {
70    Prompt,
71    Command,
72}
73
74/// Exact prompt states from the UI contract.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum PromptStatusView {
77    Idle,
78    Drafting,
79    Suggesting,
80    Running,
81    Queued,
82    Failed,
83    Retryable,
84    Cancelled,
85}
86
87/// Width fallback policy for semantic status/orientation fields.
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub enum TruncationPolicy {
90    Hide,
91    EllipsizeMiddle,
92    EllipsizeEnd,
93}
94
95/// Focused bounded surface semantic state.
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum FocusedSurfaceView {
98    None,
99    Permission(PermissionView),
100    CommandPicker(PickerView),
101    FilePicker(PickerView),
102    Help(HelpView),
103    ToolDetail(ToolDetailView),
104    DiffDetail(DiffDetailView),
105    TranscriptLens {
106        selected_entry: Option<usize>,
107        scroll: usize,
108    },
109    SetupForm(SetupFormView),
110    StructuredTable(TableView),
111}
112
113impl From<&App> for FocusedSurfaceView {
114    fn from(app: &App) -> Self {
115        if let Some(permission) = app.pending_permission.as_ref() {
116            return FocusedSurfaceView::Permission(PermissionView {
117                title: permission.title.clone(),
118                scope: "local user · active tool only · no TUI sandbox".to_string(),
119                selected: permission.selected,
120                options: permission
121                    .options
122                    .iter()
123                    .map(|option| PermissionOptionView {
124                        label: option.name.clone(),
125                        kind: option.kind.label().to_string(),
126                    })
127                    .collect(),
128            });
129        }
130        if let Some(form) = app.render_setup_form_view() {
131            return FocusedSurfaceView::SetupForm(form);
132        }
133        if app.detail_pane.open
134            && let Some(Entry::Tool { name, status, output, .. }) = app.transcript.get(app.detail_pane.entry_index)
135        {
136            return FocusedSurfaceView::ToolDetail(ToolDetailView {
137                entry_index: app.detail_pane.entry_index,
138                title: name.clone(),
139                status: *status,
140                scroll: app.detail_pane.scroll,
141                output: output.clone(),
142            });
143        }
144        match app.prompt_accessory {
145            PromptAccessory::Help => {
146                FocusedSurfaceView::Help(HelpView { queue_target_toggle: matches!(app.run_state, RunState::Working) })
147            }
148            PromptAccessory::Commands { selected } => {
149                let items = crate::app::command_suggestions_for_app(app)
150                    .into_iter()
151                    .map(|(label, detail)| PickerItemView { label: label.to_string(), detail: detail.to_string() })
152                    .collect();
153                FocusedSurfaceView::CommandPicker(PickerView {
154                    title: "commands".to_string(),
155                    query: app.input.text(),
156                    selected,
157                    items,
158                })
159            }
160            PromptAccessory::Files(_) => app
161                .render_picker_surface("files")
162                .map_or(FocusedSurfaceView::None, FocusedSurfaceView::FilePicker),
163            PromptAccessory::Models => app
164                .render_picker_surface("models")
165                .map_or(FocusedSurfaceView::None, FocusedSurfaceView::CommandPicker),
166            PromptAccessory::ReasoningEffort => app
167                .render_picker_surface("reasoning effort")
168                .map_or(FocusedSurfaceView::None, FocusedSurfaceView::CommandPicker),
169            PromptAccessory::Skills => app
170                .render_picker_surface("skills")
171                .map_or(FocusedSurfaceView::None, FocusedSurfaceView::CommandPicker),
172            PromptAccessory::Context => FocusedSurfaceView::StructuredTable(app.render_context_table()),
173            _ => FocusedSurfaceView::None,
174        }
175    }
176}
177
178/// A semantic ACP permission decision surface.
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct PermissionView {
181    pub title: String,
182    pub scope: String,
183    pub options: Vec<PermissionOptionView>,
184    pub selected: usize,
185}
186
187/// A single ACP permission choice after provider-specific kinds are lowered to
188/// display-safe semantic text.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct PermissionOptionView {
191    pub label: String,
192    pub kind: String,
193}
194
195/// Semantic help state for the one context-sensitive keyboard binding.
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct HelpView {
198    pub queue_target_toggle: bool,
199}
200
201/// Table column alignment.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum ColumnAlignment {
204    Left,
205    Right,
206    Center,
207}
208
209/// Table column width policy.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum ColumnWidthPolicy {
212    Fixed(usize),
213    Percent(u8),
214    Flexible,
215}
216
217/// Complete view of what the renderer should draw this tick.
218pub struct RendererView {
219    /// Semantic, backend-independent view records for renderer-owned UI state.
220    pub semantic: SemanticUiView,
221    pub transcript: TranscriptView,
222    pub live: LiveView,
223    pub width: usize,
224    #[allow(dead_code)]
225    pub height: usize,
226}
227
228impl RendererView {
229    /// Build a pure data view from app state and terminal dimensions.
230    pub fn build(app: &App, width: usize, height: usize) -> Self {
231        let semantic = SemanticUiView::from(app);
232        let transcript = TranscriptView::build(app, width);
233        let live = LiveView::build(app, width, height, &transcript, &semantic);
234        Self { semantic, transcript, live, width, height }
235    }
236}
237
238/// Transcript portion of the view: committed banner rows, stable rows, and rows
239/// that are still mutable (streaming or running).
240#[derive(Clone)]
241pub struct TranscriptView {
242    /// Every transcript row in chronological application order.
243    ///
244    /// Unlike the stable/live partitions below, this projection never moves a
245    /// settled entry ahead of an earlier mutable entry. Full-screen renderers
246    /// should use this sequence as their source of truth.
247    pub rows: Vec<Row>,
248    /// Banner rows shown before the first transcript entry is committed.
249    pub banner_rows: Vec<Row>,
250    /// Settled rows retained for live-tail classification and compatibility
251    /// snapshots.
252    pub stable_rows: Vec<Row>,
253    /// Rows that remain mutable until their entry settles.
254    pub live_rows: Vec<Row>,
255}
256
257/// Project one transcript entry at a specific width.
258///
259/// Alternate-screen caching uses this boundary to invalidate a changing entry
260/// without rebuilding settled entries above it.
261pub fn project_transcript_entry(app: &App, entry_index: usize, width: usize) -> (Vec<Row>, Vec<Row>) {
262    let Some(entry) = app.transcript.get(entry_index) else {
263        return (Vec::new(), Vec::new());
264    };
265    let previous_was_tool = entry_index
266        .checked_sub(1)
267        .and_then(|index| app.transcript.get(index))
268        .is_some_and(|entry| matches!(entry, Entry::Tool { .. }));
269    TranscriptRowContext {
270        user_label: &app.user_label,
271        cwd: &app.cwd,
272        width,
273        entry_index: Some(entry_index),
274        tool_group_start: !previous_was_tool,
275    }
276    .rows_for_entry_stable_and_live_rows(entry)
277}
278
279impl TranscriptView {
280    fn build(app: &App, width: usize) -> Self {
281        let banner_rows = app.render_banner_rows(width);
282
283        if app.transcript.is_empty() {
284            return Self { rows: banner_rows.clone(), banner_rows, stable_rows: Vec::new(), live_rows: Vec::new() };
285        }
286
287        let mut rows = banner_rows.clone();
288        let mut stable_rows = Vec::new();
289        let mut live_rows = Vec::new();
290        stable_rows.extend(banner_rows);
291
292        let ctx = TranscriptRowContext {
293            user_label: &app.user_label,
294            cwd: &app.cwd,
295            width,
296            entry_index: None,
297            tool_group_start: true,
298        };
299
300        let mut previous_was_tool = false;
301        for (index, entry) in app.transcript.iter().enumerate() {
302            let mut entry_ctx = ctx.clone();
303            entry_ctx.entry_index = Some(index);
304            entry_ctx.tool_group_start = !previous_was_tool;
305            let (entry_stable, entry_live) = entry_ctx.rows_for_entry_stable_and_live_rows(entry);
306            rows.extend(entry_stable.iter().cloned());
307            rows.extend(entry_live.iter().cloned());
308            if entry_stable.is_empty() {
309                live_rows.extend(entry_live);
310            } else {
311                stable_rows.extend(entry_stable);
312                live_rows.extend(entry_live);
313            }
314            previous_was_tool = matches!(entry, Entry::Tool { .. });
315        }
316
317        Self { rows, banner_rows: Vec::new(), stable_rows, live_rows }
318    }
319}
320
321/// Renderer-owned semantic view data.
322#[derive(Clone, Debug, Eq, PartialEq)]
323pub struct SemanticUiView {
324    pub transcript: SemanticTranscriptView,
325    pub prompt: PromptSurfaceView,
326    pub orientation: OrientationBandView,
327    pub focused_surface: FocusedSurfaceView,
328}
329
330impl From<&App> for SemanticUiView {
331    fn from(app: &App) -> Self {
332        Self {
333            transcript: SemanticTranscriptView { rows: app.transcript.iter().map(TranscriptRowView::from).collect() },
334            prompt: PromptSurfaceView::from(app),
335            orientation: OrientationBandView::from(app),
336            focused_surface: FocusedSurfaceView::from(app),
337        }
338    }
339}
340
341/// Semantic transcript records before terminal wrapping and styling.
342#[derive(Clone, Debug, Default, Eq, PartialEq)]
343pub struct SemanticTranscriptView {
344    pub rows: Vec<TranscriptRowView>,
345}
346
347/// A semantic transcript row.
348#[derive(Clone, Debug, Eq, PartialEq)]
349pub struct TranscriptRowView {
350    pub kind: TranscriptRowKind,
351    pub stable: bool,
352    pub primary: String,
353    pub tool: Option<ToolStateView>,
354    pub edit: Option<EditSummaryView>,
355    pub diff: Option<DiffSummaryView>,
356}
357
358impl From<&Entry> for TranscriptRowView {
359    fn from(entry: &Entry) -> Self {
360        match entry {
361            Entry::User { text } => TranscriptRowKind::User.build_row(true, text.clone()),
362            Entry::Agent { text, streaming } => TranscriptRowKind::Assistant.build_row(!streaming, text.clone()),
363            Entry::Reasoning { text, streaming } => TranscriptRowKind::Reasoning.build_row(!streaming, text.clone()),
364            Entry::Status { text } if text == "cancelled" => TranscriptRowKind::Cancelled.build_row(true, text.clone()),
365            Entry::Status { text } => TranscriptRowKind::Status.build_row(true, text.clone()),
366            Entry::Error { text } => TranscriptRowKind::Error.build_row(true, text.clone()),
367            Entry::Tool { name, arguments, status, output } => {
368                let diff = DiffSummaryView::build(output);
369                let edit = EditSummaryView::build(name, output, *status);
370                let kind = if diff.is_some() {
371                    TranscriptRowKind::Diff
372                } else if edit.is_some() {
373                    TranscriptRowKind::Edit
374                } else if *status == ToolStatus::Cancelled {
375                    TranscriptRowKind::Cancelled
376                } else {
377                    TranscriptRowKind::Tool
378                };
379                TranscriptRowView {
380                    kind,
381                    stable: *status != ToolStatus::Running,
382                    primary: name.clone(),
383                    tool: Some(ToolStateView {
384                        name: name.clone(),
385                        arguments: arguments.clone(),
386                        status: *status,
387                        output_lines: output.len(),
388                        truncated_preview: output.len() > 6,
389                    }),
390                    edit,
391                    diff,
392                }
393            }
394        }
395    }
396}
397
398/// Tool execution state represented in renderer data.
399#[derive(Clone, Debug, Eq, PartialEq)]
400pub struct ToolStateView {
401    pub name: String,
402    pub arguments: String,
403    pub status: ToolStatus,
404    pub output_lines: usize,
405    pub truncated_preview: bool,
406}
407
408/// File edit summary inferred from write-capable tool entries.
409#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct EditSummaryView {
411    pub path: Option<String>,
412    pub operation: Option<String>,
413    pub status: ToolStatus,
414}
415
416impl EditSummaryView {
417    fn build(name: &str, output: &[String], status: ToolStatus) -> Option<EditSummaryView> {
418        let is_write_tool = ["create_file", "replace_range", "write_patch"]
419            .iter()
420            .any(|tool| name.starts_with(tool));
421        if !is_write_tool
422            && !output
423                .iter()
424                .any(|line| line.contains("wrote") || line.contains("replaced"))
425        {
426            return None;
427        }
428        Some(EditSummaryView {
429            path: output.iter().find_map(|line| {
430                line.rsplit_once(": ").map(|(_, path)| path.to_string()).or_else(|| {
431                    line.split_whitespace()
432                        .last()
433                        .filter(|part| part.contains('/'))
434                        .map(str::to_string)
435                })
436            }),
437            operation: name.split('#').next().map(str::to_string),
438            status,
439        })
440    }
441}
442
443/// Diff summary inferred from tool output when unified-style diff lines exist.
444#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct DiffSummaryView {
446    pub files: Vec<String>,
447    pub added: usize,
448    pub removed: usize,
449}
450
451impl DiffSummaryView {
452    fn build(output: &[String]) -> Option<Self> {
453        let mut added = 0usize;
454        let mut removed = 0usize;
455        let mut files = Vec::new();
456        for line in output {
457            if let Some(path) = line.strip_prefix("+++ ") {
458                files.push(path.trim_start_matches("b/").to_string());
459            } else if line.starts_with('+') && !line.starts_with("+++") {
460                added += 1;
461            } else if line.starts_with('-') && !line.starts_with("---") {
462                removed += 1;
463            }
464        }
465        if added == 0 && removed == 0 && files.is_empty() {
466            None
467        } else {
468            files.sort();
469            files.dedup();
470            Some(Self { files, added, removed })
471        }
472    }
473}
474
475/// Prompt surface semantic data.
476#[derive(Clone, Debug, Eq, PartialEq)]
477pub struct PromptSurfaceView {
478    pub draft: String,
479    pub mode: PromptModeView,
480    pub status: PromptStatusView,
481    pub queued: Option<QueuedSummaryView>,
482    pub suggestions: Vec<PromptSuggestionView>,
483}
484
485impl From<&App> for PromptSurfaceView {
486    fn from(app: &App) -> Self {
487        let queued = app.render_queued_summary_view();
488        let suggestions = app.render_prompt_suggestions();
489        let has_draft = !app.input.is_empty();
490        let status = match (&app.run_state, queued.is_some(), suggestions.is_empty(), has_draft) {
491            (RunState::Error(_), _, _, true) => PromptStatusView::Retryable,
492            (RunState::Error(_), _, _, false) => PromptStatusView::Failed,
493            (RunState::Working, true, _, _) => PromptStatusView::Queued,
494            (RunState::Working, false, _, _) | (RunState::Stopping, _, _, _) => PromptStatusView::Running,
495            (_, _, false, _) => PromptStatusView::Suggesting,
496            (_, _, _, true) => PromptStatusView::Drafting,
497            _ => match app.transcript.last() {
498                Some(Entry::Status { text }) if text == "cancelled" => PromptStatusView::Cancelled,
499                _ => PromptStatusView::Idle,
500            },
501        };
502        PromptSurfaceView {
503            draft: app.input.text(),
504            mode: match app.mode {
505                Mode::Command => PromptModeView::Command,
506                _ => PromptModeView::Prompt,
507            },
508            status,
509            queued,
510            suggestions,
511        }
512    }
513}
514
515/// Queued steering/follow-up summary.
516#[derive(Clone, Debug, Eq, PartialEq)]
517pub struct QueuedSummaryView {
518    pub steering_count: usize,
519    pub followup_count: usize,
520    pub target: String,
521}
522
523/// Prompt suggestion projected from command and file mention state.
524#[derive(Clone, Debug, Eq, PartialEq)]
525pub struct PromptSuggestionView {
526    pub label: String,
527    pub detail: String,
528    pub selected: bool,
529    pub kind: PromptSuggestionKind,
530}
531
532/// Compact orientation/status band semantic data.
533#[derive(Clone, Debug, Default, Eq, PartialEq)]
534pub struct OrientationBandView {
535    pub fields: Vec<OrientationFieldView>,
536}
537
538impl From<&App> for OrientationBandView {
539    fn from(app: &App) -> Self {
540        let mut fields = vec![
541            OrientationFieldView {
542                label: "workspace".to_string(),
543                value: app.cwd.display().to_string(),
544                priority: 20,
545                truncate: TruncationPolicy::EllipsizeMiddle,
546            },
547            OrientationFieldView {
548                label: "model".to_string(),
549                value: app.model.clone(),
550                priority: 10,
551                truncate: TruncationPolicy::EllipsizeEnd,
552            },
553            OrientationFieldView {
554                label: "run".to_string(),
555                value: app.status_label().to_string(),
556                priority: 0,
557                truncate: TruncationPolicy::Hide,
558            },
559            OrientationFieldView {
560                label: "session".to_string(),
561                value: if app.session_id.is_empty() { "thndrs".to_string() } else { app.session_id.clone() },
562                priority: 15,
563                truncate: TruncationPolicy::EllipsizeEnd,
564            },
565            OrientationFieldView {
566                label: "trust".to_string(),
567                value: "local user · workspace-contained tools · no TUI sandbox".to_string(),
568                priority: 40,
569                truncate: TruncationPolicy::Hide,
570            },
571        ];
572        if app.ttft.is_pending() {
573            fields.push(OrientationFieldView {
574                label: "ttft".to_string(),
575                value: "pending".to_string(),
576                priority: 30,
577                truncate: TruncationPolicy::Hide,
578            });
579        }
580        OrientationBandView { fields }
581    }
582}
583
584/// A truncatable orientation field.
585#[derive(Clone, Debug, Eq, PartialEq)]
586pub struct OrientationFieldView {
587    pub label: String,
588    pub value: String,
589    pub priority: u8,
590    pub truncate: TruncationPolicy,
591}
592
593/// Semantic picker/list surface.
594#[derive(Clone, Debug, Eq, PartialEq)]
595pub struct PickerView {
596    pub title: String,
597    pub query: String,
598    pub selected: usize,
599    pub items: Vec<PickerItemView>,
600}
601
602/// Semantic picker row.
603#[derive(Clone, Debug, Eq, PartialEq)]
604pub struct PickerItemView {
605    pub label: String,
606    pub detail: String,
607}
608
609/// Full tool-output detail surface.
610#[derive(Clone, Debug, Eq, PartialEq)]
611pub struct ToolDetailView {
612    pub entry_index: usize,
613    pub title: String,
614    pub status: ToolStatus,
615    pub scroll: usize,
616    pub output: Vec<String>,
617}
618
619/// Unified-diff detail surface.
620#[derive(Clone, Debug, Eq, PartialEq)]
621pub struct DiffDetailView {
622    pub summary: DiffSummaryView,
623    pub lines: Vec<String>,
624}
625
626/// Setup/recovery form semantic state.
627#[derive(Clone, Debug, Eq, PartialEq)]
628pub struct SetupFormView {
629    pub title: String,
630    pub stage: String,
631    pub status: String,
632    pub details: Vec<String>,
633    pub fields: Vec<SetupFieldView>,
634    pub focus_index: usize,
635    pub actions: Vec<PickerItemView>,
636    pub selected: usize,
637    pub validation_errors: Vec<String>,
638    pub submit_label: String,
639    pub cancel_label: String,
640    pub complete: bool,
641}
642
643/// A setup/recovery form field.
644#[derive(Clone, Debug, Eq, PartialEq)]
645pub struct SetupFieldView {
646    pub label: String,
647    pub value: String,
648    pub focused: bool,
649    pub secret: bool,
650    pub multiline: bool,
651    pub error: Option<String>,
652}
653
654/// Structured table semantic state.
655#[derive(Clone, Debug, Eq, PartialEq)]
656pub struct TableView {
657    pub header: Vec<TableCellView>,
658    pub rows: Vec<Vec<TableCellView>>,
659    pub selected_row: Option<usize>,
660    pub narrow_fallback: Vec<String>,
661}
662
663/// A table cell with alignment and width policy.
664#[derive(Clone, Debug, Eq, PartialEq)]
665pub struct TableCellView {
666    pub text: String,
667    pub alignment: ColumnAlignment,
668    pub width: ColumnWidthPolicy,
669}
670
671/// Adapter input for a bounded focused surface renderer.
672pub struct SurfaceRenderInput<'a> {
673    pub surface: &'a FocusedSurfaceView,
674    pub theme: &'a SurfaceThemeView,
675    pub width: usize,
676    pub height: usize,
677}
678
679impl<'a> SurfaceRenderInput<'a> {
680    pub fn new(surface: &'a FocusedSurfaceView, theme: &'a SurfaceThemeView, width: usize, height: usize) -> Self {
681        Self { surface, theme, width, height }
682    }
683}
684
685/// Semantic theme roles available to bounded surface adapters.
686#[derive(Clone, Debug, Eq, PartialEq)]
687pub struct SurfaceThemeView {
688    pub text: ThemeRole,
689    pub muted: ThemeRole,
690    pub selected: ThemeRole,
691    pub warning: ThemeRole,
692    pub error: ThemeRole,
693    pub diff_added: ThemeRole,
694    pub diff_removed: ThemeRole,
695}
696
697impl Default for SurfaceThemeView {
698    fn default() -> Self {
699        Self::new()
700    }
701}
702
703impl SurfaceThemeView {
704    pub fn new() -> Self {
705        SurfaceThemeView {
706            text: ThemeRole::Text,
707            muted: ThemeRole::Muted,
708            selected: ThemeRole::Selected,
709            warning: ThemeRole::Warning,
710            error: ThemeRole::Error,
711            diff_added: ThemeRole::DiffAdded,
712            diff_removed: ThemeRole::DiffRemoved,
713        }
714    }
715}
716
717/// Live chrome portion of the view: prompt, status, and accessory rows.
718pub struct LiveView {
719    /// Clipped mutable transcript tail rows.
720    pub live_tail: Vec<Row>,
721    /// Prompt input rows.
722    pub prompt_rows: Vec<Row>,
723    /// Cursor coordinate relative to the first prompt row.
724    pub prompt_cursor: Option<CursorCoord>,
725    /// Optional accessory rows (help, commands, file picker, etc.).
726    pub accessory_rows: Vec<Row>,
727    /// Summary row for queued steering/follow-up prompts, shown when non-empty.
728    pub queued_summary: Option<Row>,
729    /// Scrollable detail pane rows for the expanded tool entry.
730    pub detail_pane: Vec<Row>,
731    /// Static status row below the prompt.
732    pub static_status: Row,
733}
734
735impl LiveView {
736    pub fn build(
737        app: &App, width: usize, _height: usize, transcript: &TranscriptView, semantic: &SemanticUiView,
738    ) -> LiveView {
739        let live_tail = transcript.live_rows.clone();
740        let (prompt_rows, prompt_cursor) = super::live::prompt_rows_for(app, width);
741        let prompt_body_budget = super::live::MAX_PROMPT_ROWS.saturating_sub(super::live::composer_frame_height(width));
742        let (prompt_rows, prompt_cursor) =
743            clip_prompt_rows_around_cursor(prompt_rows, prompt_cursor, prompt_body_budget);
744        let (prompt_rows, prompt_cursor) = super::live::frame_prompt_rows(app, width, prompt_rows, prompt_cursor);
745
746        let accessory_rows = match &semantic.focused_surface {
747            FocusedSurfaceView::ToolDetail(_)
748            | FocusedSurfaceView::DiffDetail(_)
749            | FocusedSurfaceView::TranscriptLens { .. } => Vec::new(),
750            _ => super::live::accessory_rows(app, width, super::live::MAX_ACCESSORY_ROWS),
751        };
752        let detail_pane = match &semantic.focused_surface {
753            FocusedSurfaceView::ToolDetail(_)
754            | FocusedSurfaceView::DiffDetail(_)
755            | FocusedSurfaceView::TranscriptLens { .. } => super::adapter::render_surface(&SurfaceRenderInput::new(
756                &semantic.focused_surface,
757                &SurfaceThemeView::new(),
758                width,
759                super::live::MAX_ACCESSORY_ROWS,
760            )),
761            _ => Vec::new(),
762        };
763
764        LiveView {
765            live_tail,
766            prompt_rows,
767            prompt_cursor,
768            accessory_rows,
769            queued_summary: super::live::queued_summary_row(app, width),
770            detail_pane,
771            static_status: super::live::static_status_row(app, width),
772        }
773    }
774}
775
776impl App {
777    fn render_queued_summary_view(&self) -> Option<QueuedSummaryView> {
778        let steering_count = self.queued_steering.len();
779        let followup_count = self.queued_followups.len();
780        if steering_count == 0 && followup_count == 0 {
781            None
782        } else {
783            Some(QueuedSummaryView { steering_count, followup_count, target: self.queue_target.label().to_string() })
784        }
785    }
786
787    fn render_prompt_suggestions(&self) -> Vec<PromptSuggestionView> {
788        match self.prompt_accessory {
789            PromptAccessory::Commands { selected } => crate::app::command_suggestions_for_app(self)
790                .into_iter()
791                .enumerate()
792                .map(|(index, (label, detail))| PromptSuggestionView {
793                    label: label.to_string(),
794                    detail: detail.to_string(),
795                    selected: index == selected,
796                    kind: PromptSuggestionKind::Command,
797                })
798                .collect(),
799            PromptAccessory::Files(FilePickerSource::Mention { .. }) => {
800                self.render_picker_suggestions(PromptSuggestionKind::FileMention)
801            }
802            _ => Vec::new(),
803        }
804    }
805
806    fn render_picker_suggestions(&self, kind: PromptSuggestionKind) -> Vec<PromptSuggestionView> {
807        self.picker
808            .as_ref()
809            .map(|picker| {
810                picker
811                    .matches
812                    .iter()
813                    .enumerate()
814                    .map(|(index, item)| PromptSuggestionView {
815                        label: item.label.clone(),
816                        detail: item.detail.clone(),
817                        selected: index == picker.selected,
818                        kind,
819                    })
820                    .collect()
821            })
822            .unwrap_or_default()
823    }
824
825    fn render_picker_surface(&self, title: &str) -> Option<PickerView> {
826        let picker = self.picker.as_ref()?;
827        Some(PickerView {
828            title: title.to_string(),
829            query: picker.query.clone(),
830            selected: picker.selected,
831            items: picker
832                .matches
833                .iter()
834                .map(|item| PickerItemView { label: item.label.clone(), detail: item.detail.clone() })
835                .collect(),
836        })
837    }
838
839    fn render_setup_form_view(&self) -> Option<SetupFormView> {
840        let recovery = self.first_run_recovery.as_ref()?;
841        let (label, value, secret) = setup_field(recovery);
842        let provider = recovery
843            .provider
844            .map(|provider| provider.label().to_string())
845            .unwrap_or_else(|| "advanced / ACP".to_string());
846        let stage = recovery.stage.label().to_string();
847        let status = format!("{provider} · {stage}");
848        let details = setup_details(recovery);
849        Some(SetupFormView {
850            title: "setup".to_string(),
851            stage,
852            status,
853            details,
854            fields: vec![SetupFieldView { label, value, focused: true, secret, multiline: false, error: None }],
855            focus_index: 0,
856            actions: setup_actions(recovery),
857            selected: recovery.selected,
858            validation_errors: Vec::new(),
859            submit_label: if recovery.stage == RecoveryStage::EnterKey {
860                "submit".to_string()
861            } else {
862                "continue".to_string()
863            },
864            cancel_label: "cancel".to_string(),
865            complete: false,
866        })
867    }
868
869    /// Project the context ledger into bounded table data owned by the renderer.
870    pub fn render_context_table(&self) -> TableView {
871        let Some(ledger) = &self.context_ledger else {
872            return TableView {
873                header: vec![TableCellView {
874                    text: "context".to_string(),
875                    alignment: ColumnAlignment::Left,
876                    width: ColumnWidthPolicy::Flexible,
877                }],
878                rows: vec![vec![TableCellView {
879                    text: "no ledger".to_string(),
880                    alignment: ColumnAlignment::Left,
881                    width: ColumnWidthPolicy::Flexible,
882                }]],
883                selected_row: None,
884                narrow_fallback: vec!["context unavailable".to_string()],
885            };
886        };
887
888        let review = self.last_compaction_review.map(|a| a.label()).unwrap_or("none");
889        let counts = ledger.counts();
890        let mut rows = vec![
891            context_table_row(
892                "budget",
893                &format!("{} / {}", ledger.budget.used, ledger.budget.target),
894                "tokens",
895                "target",
896            ),
897            context_table_row(
898                "source",
899                ledger.budget.limits.source.label(),
900                ledger.budget.limits.confidence.label(),
901                "limits",
902            ),
903            context_table_row(
904                "compaction",
905                &format!("{} / {}", self.effective_compaction_policy().mode.label(), review),
906                &counts.visible.to_string(),
907                "review",
908            ),
909        ];
910        rows.extend(
911            ledger
912                .items
913                .iter()
914                .take(crate::app::CONTEXT_INSPECTION_MAX_ITEMS)
915                .map(|item| {
916                    let details = crate::context::export::export_item(item);
917                    vec![
918                        TableCellView {
919                            text: redact_context_display(&item.id),
920                            alignment: ColumnAlignment::Left,
921                            width: ColumnWidthPolicy::Percent(34),
922                        },
923                        TableCellView {
924                            text: format!(
925                                "{} / {} reason:{} prot:{} rec:{} repl:{}",
926                                item.kind.label(),
927                                item.visibility.label(),
928                                details.reason_code,
929                                yes_no(details.protected),
930                                yes_no(details.recovery_available),
931                                details.replacement.as_deref().unwrap_or("none")
932                            ),
933                            alignment: ColumnAlignment::Left,
934                            width: ColumnWidthPolicy::Percent(26),
935                        },
936                        TableCellView {
937                            text: item.token_estimate.to_string(),
938                            alignment: ColumnAlignment::Right,
939                            width: ColumnWidthPolicy::Fixed(9),
940                        },
941                        TableCellView {
942                            text: redact_context_display(&item.label),
943                            alignment: ColumnAlignment::Left,
944                            width: ColumnWidthPolicy::Flexible,
945                        },
946                    ]
947                }),
948        );
949
950        let mut narrow_fallback = vec![
951            format!("budget {} / {} tokens", ledger.budget.used, ledger.budget.target),
952            format!(
953                "limits {} ({})",
954                ledger.budget.limits.source.label(),
955                ledger.budget.limits.confidence.label()
956            ),
957            format!(
958                "compaction {} review {}",
959                self.effective_compaction_policy().mode.label(),
960                review
961            ),
962            format!(
963                "items {} visible {} pinned {} dropped {} archived {} blocked {}",
964                ledger.items.len(),
965                counts.visible,
966                counts.pinned,
967                counts.dropped,
968                counts.archived,
969                counts.blocked
970            ),
971        ];
972        narrow_fallback.extend(ledger.items.iter().take(CONTEXT_INSPECTION_MAX_ITEMS).map(|item| {
973            let details = crate::context::export::export_item(item);
974            format!(
975                "{} state {} reason {} protected {} recovery {} replacement {}",
976                redact_context_display(&item.id),
977                item.visibility.label(),
978                details.reason_code,
979                yes_no(details.protected),
980                yes_no(details.recovery_available),
981                details.replacement.as_deref().unwrap_or("none")
982            )
983        }));
984        TableView {
985            header: vec![
986                TableCellView {
987                    text: "context".to_string(),
988                    alignment: ColumnAlignment::Left,
989                    width: ColumnWidthPolicy::Percent(34),
990                },
991                TableCellView {
992                    text: "state / reason / protection / recovery".to_string(),
993                    alignment: ColumnAlignment::Left,
994                    width: ColumnWidthPolicy::Percent(26),
995                },
996                TableCellView {
997                    text: "tokens".to_string(),
998                    alignment: ColumnAlignment::Right,
999                    width: ColumnWidthPolicy::Fixed(9),
1000                },
1001                TableCellView {
1002                    text: "label".to_string(),
1003                    alignment: ColumnAlignment::Left,
1004                    width: ColumnWidthPolicy::Flexible,
1005                },
1006            ],
1007            rows,
1008            selected_row: None,
1009            narrow_fallback,
1010        }
1011    }
1012}
1013
1014fn setup_details(recovery: &FirstRunRecovery) -> Vec<String> {
1015    let mut details = Vec::new();
1016    match recovery.stage {
1017        RecoveryStage::ChooseProvider => {
1018            details.push("Choose a provider before a model; no provider or model is assumed by setup.".to_string())
1019        }
1020        RecoveryStage::ModelSelection => details.push("Choose the model available for this provider.".to_string()),
1021        RecoveryStage::ModelConfigScope => {
1022            details.push("Optionally save the selected model to project or global config.".to_string())
1023        }
1024        RecoveryStage::MissingCredential => match recovery.provider {
1025            Some(SetupProviderArg::ChatgptCodex) => details.push(
1026                "Browser PKCE is the default. Device code is an explicit headless route; neither asks for an API key."
1027                    .to_string(),
1028            ),
1029            Some(SetupProviderArg::Umans) => details.push(
1030                "Create a Umans Code API key at app.umans.ai, enter it hidden, then choose global or project storage."
1031                    .to_string(),
1032            ),
1033            _ => details
1034                .push("The credential stays hidden and is written only after an explicit scope choice.".to_string()),
1035        },
1036        RecoveryStage::EnterKey => match recovery.provider {
1037            Some(SetupProviderArg::Umans) => details.push(
1038                "Input is hidden. Enter stores the Umans Code key at the chosen scope; Esc preserves the draft."
1039                    .to_string(),
1040            ),
1041            _ => details.push("Input is hidden. Enter continues; Esc preserves the draft.".to_string()),
1042        },
1043        RecoveryStage::ConfirmStore => details.push("Choose where the credential may be stored.".to_string()),
1044        RecoveryStage::Instructions => details.push(setup_instruction(recovery).to_string()),
1045        RecoveryStage::ChatGptOAuthRequesting => {
1046            details.push("Starting the selected ChatGPT OAuth method.".to_string())
1047        }
1048        RecoveryStage::ChatGptOAuthPolling => match recovery.chatgpt_oauth.as_ref() {
1049            Some(oauth) => {
1050                match oauth.method {
1051                    ChatGptOAuthMethod::Browser => {
1052                        details.push("Open or copy this authorization URL:".to_string());
1053                        if let Some(url) = oauth.authorization_url.as_deref() {
1054                            details.push(url.to_string());
1055                        }
1056                    }
1057                    _ => {
1058                        if let Some(code) = oauth.code.as_ref() {
1059                            let uri = code
1060                                .verification_uri
1061                                .as_deref()
1062                                .unwrap_or("https://auth.openai.com/codex/device");
1063                            details.push(format!("Open {uri} and enter code {}.", code.user_code));
1064                        }
1065                    }
1066                };
1067                details.push(oauth.status.clone());
1068            }
1069            None => details.push("Waiting for ChatGPT OAuth.".to_string()),
1070        },
1071        RecoveryStage::ChatGptOAuthPasteRedirect => {
1072            details.push("Paste the full browser redirect URL. Input is hidden.".to_string())
1073        }
1074        RecoveryStage::ChatGptOAuthFailed => details.push(
1075            recovery
1076                .chatgpt_oauth
1077                .as_ref()
1078                .map(|oauth| oauth.status.clone())
1079                .unwrap_or_else(|| "ChatGPT OAuth failed.".to_string()),
1080        ),
1081        RecoveryStage::LogoutConfirm => details.push("Remove the credential from the selected store.".to_string()),
1082        RecoveryStage::AcpMissing => {
1083            details.push("ACP models use ACP agent config, not provider API keys.".to_string())
1084        }
1085    }
1086    details
1087}
1088
1089fn setup_actions(recovery: &FirstRunRecovery) -> Vec<PickerItemView> {
1090    let labels: Vec<String> = match recovery.stage {
1091        RecoveryStage::ChooseProvider => vec![
1092            "ChatGPT Codex".to_string(),
1093            "Umans".to_string(),
1094            "show setup instructions".to_string(),
1095        ],
1096        RecoveryStage::ModelSelection => recovery
1097            .provider
1098            .map(crate::app::setup_model_options)
1099            .unwrap_or_default()
1100            .into_iter()
1101            .map(|item| item.label)
1102            .collect(),
1103        RecoveryStage::ModelConfigScope => vec![
1104            "project config".to_string(),
1105            "global config".to_string(),
1106            "skip model config".to_string(),
1107            "cancel setup".to_string(),
1108        ],
1109        RecoveryStage::MissingCredential => {
1110            if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
1111                vec![
1112                    "start browser PKCE login".to_string(),
1113                    "use headless device code".to_string(),
1114                    "switch model/provider".to_string(),
1115                    "show setup instructions".to_string(),
1116                    "continue without setup".to_string(),
1117                    "quit".to_string(),
1118                ]
1119            } else {
1120                vec![
1121                    match recovery.provider {
1122                        Some(SetupProviderArg::Umans) => "enter Umans API key".to_string(),
1123                        _ => "enter API key".to_string(),
1124                    },
1125                    "switch model/provider".to_string(),
1126                    "show setup instructions".to_string(),
1127                    "continue without setup".to_string(),
1128                    "quit".to_string(),
1129                ]
1130            }
1131        }
1132        RecoveryStage::EnterKey => Vec::new(),
1133        RecoveryStage::ConfirmStore | RecoveryStage::LogoutConfirm => vec![
1134            "global credentials".to_string(),
1135            "project credentials".to_string(),
1136            "cancel".to_string(),
1137        ],
1138        RecoveryStage::Instructions => vec!["back".to_string(), "close".to_string()],
1139        RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPasteRedirect => vec!["cancel".to_string()],
1140        RecoveryStage::ChatGptOAuthPolling => {
1141            if recovery
1142                .chatgpt_oauth
1143                .as_ref()
1144                .is_some_and(|oauth| oauth.method == ChatGptOAuthMethod::Browser)
1145            {
1146                vec!["cancel".to_string(), "paste full redirect URL".to_string()]
1147            } else {
1148                vec!["cancel".to_string()]
1149            }
1150        }
1151        RecoveryStage::ChatGptOAuthFailed => vec![
1152            "retry browser PKCE".to_string(),
1153            "use headless device code".to_string(),
1154            "back".to_string(),
1155        ],
1156        RecoveryStage::AcpMissing => vec![
1157            "switch model/provider".to_string(),
1158            "show ACP setup".to_string(),
1159            "continue without setup".to_string(),
1160            "quit".to_string(),
1161        ],
1162    };
1163    labels
1164        .into_iter()
1165        .map(|label| PickerItemView { detail: String::new(), label })
1166        .collect()
1167}
1168
1169fn setup_instruction(recovery: &FirstRunRecovery) -> &'static str {
1170    match recovery.provider {
1171        Some(arg) => match arg {
1172            SetupProviderArg::ChatgptCodex => {
1173                "Run `thndrs setup --provider chatgpt-codex` or `thndrs login chatgpt-codex` outside the TUI."
1174            }
1175            SetupProviderArg::Umans => {
1176                "Create a key at app.umans.ai, then run `thndrs login umans`; thndrs stores it in credentials.env, never TOML."
1177            }
1178            _ => "Run `thndrs setup` or `thndrs login <provider>` outside the TUI.",
1179        },
1180        None => "Advanced providers remain available through `thndrs setup` or ACP configuration.",
1181    }
1182}
1183
1184fn setup_field(recovery: &FirstRunRecovery) -> (String, String, bool) {
1185    match recovery.stage {
1186        RecoveryStage::ChooseProvider => ("provider".to_string(), "choose provider".to_string(), false),
1187        RecoveryStage::ModelSelection => (
1188            "model".to_string(),
1189            recovery
1190                .provider
1191                .map(crate::app::setup_model_options)
1192                .and_then(|options| options.get(recovery.selected).map(|item| item.label.clone()))
1193                .unwrap_or_else(|| "choose model".to_string()),
1194            false,
1195        ),
1196        RecoveryStage::ModelConfigScope => (
1197            "config".to_string(),
1198            match recovery.selected {
1199                0 => "project config".to_string(),
1200                1 => "global config".to_string(),
1201                2 => "skip model config".to_string(),
1202                _ => "cancel setup".to_string(),
1203            },
1204            false,
1205        ),
1206        RecoveryStage::EnterKey => (
1207            recovery
1208                .provider
1209                .map(|provider| format!("{} API key", provider.label()))
1210                .unwrap_or_else(|| "API key".to_string()),
1211            if recovery.secret_input.is_empty() { String::new() } else { "[hidden]".to_string() },
1212            true,
1213        ),
1214        RecoveryStage::MissingCredential => (
1215            "provider".to_string(),
1216            recovery
1217                .provider
1218                .map_or_else(|| "advanced / ACP".to_string(), |provider| provider.label().to_string()),
1219            false,
1220        ),
1221        RecoveryStage::ConfirmStore => (
1222            "credential scope".to_string(),
1223            match recovery.selected {
1224                0 => "global credentials".to_string(),
1225                1 => "project credentials".to_string(),
1226                _ => "cancel".to_string(),
1227            },
1228            false,
1229        ),
1230        RecoveryStage::Instructions => ("next".to_string(), "follow setup instructions".to_string(), false),
1231        RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPolling => {
1232            ("provider".to_string(), "ChatGPT OAuth".to_string(), false)
1233        }
1234        RecoveryStage::ChatGptOAuthPasteRedirect => (
1235            "redirect URL".to_string(),
1236            if recovery.secret_input.is_empty() { String::new() } else { "[hidden]".to_string() },
1237            true,
1238        ),
1239        RecoveryStage::ChatGptOAuthFailed => ("provider".to_string(), "ChatGPT OAuth failed".to_string(), false),
1240        RecoveryStage::LogoutConfirm => (
1241            "credential scope".to_string(),
1242            match recovery.selected {
1243                0 => "global credentials",
1244                1 => "project credentials",
1245                _ => "cancel",
1246            }
1247            .to_string(),
1248            false,
1249        ),
1250        RecoveryStage::AcpMissing => ("provider".to_string(), "ACP agent config".to_string(), false),
1251    }
1252}
1253
1254fn context_table_row(name: &str, state: &str, tokens: &str, label: &str) -> Vec<TableCellView> {
1255    vec![
1256        TableCellView {
1257            text: name.to_string(),
1258            alignment: ColumnAlignment::Left,
1259            width: ColumnWidthPolicy::Percent(34),
1260        },
1261        TableCellView {
1262            text: state.to_string(),
1263            alignment: ColumnAlignment::Left,
1264            width: ColumnWidthPolicy::Percent(26),
1265        },
1266        TableCellView {
1267            text: tokens.to_string(),
1268            alignment: ColumnAlignment::Right,
1269            width: ColumnWidthPolicy::Fixed(9),
1270        },
1271        TableCellView { text: label.to_string(), alignment: ColumnAlignment::Left, width: ColumnWidthPolicy::Flexible },
1272    ]
1273}
1274
1275fn redact_context_display(value: &str) -> String {
1276    utils::truncate_ellipsis(&redact_secrets(value), 160)
1277}
1278
1279fn yes_no(value: bool) -> &'static str {
1280    if value { "yes" } else { "no" }
1281}
1282
1283fn clip_prompt_rows_around_cursor(
1284    rows: Vec<Row>, cursor: Option<CursorCoord>, max_rows: usize,
1285) -> (Vec<Row>, Option<CursorCoord>) {
1286    if rows.len() <= max_rows || max_rows == 0 {
1287        return (rows, cursor);
1288    }
1289
1290    let cursor_row = cursor.map_or_else(
1291        || rows.len().saturating_sub(1),
1292        |cursor| cursor.row.min(rows.len().saturating_sub(1)),
1293    );
1294    let start = cursor_row.saturating_add(1).saturating_sub(max_rows);
1295    let clipped_rows = rows.into_iter().skip(start).take(max_rows).collect();
1296    let clipped_cursor = cursor.map(|mut cursor| {
1297        cursor.row = cursor.row.saturating_sub(start);
1298        cursor
1299    });
1300
1301    (clipped_rows, clipped_cursor)
1302}