1#[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
22pub trait SurfaceRenderer {
24 fn render_surface(&mut self, input: SurfaceRenderInput<'_>) -> Vec<Row>;
25}
26
27#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum PromptSuggestionKind {
63 Command,
64 FileMention,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum PromptModeView {
70 Prompt,
71 Command,
72}
73
74#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub enum TruncationPolicy {
90 Hide,
91 EllipsizeMiddle,
92 EllipsizeEnd,
93}
94
95#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct PermissionOptionView {
191 pub label: String,
192 pub kind: String,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct HelpView {
198 pub queue_target_toggle: bool,
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum ColumnAlignment {
204 Left,
205 Right,
206 Center,
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum ColumnWidthPolicy {
212 Fixed(usize),
213 Percent(u8),
214 Flexible,
215}
216
217pub struct RendererView {
219 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 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#[derive(Clone)]
241pub struct TranscriptView {
242 pub rows: Vec<Row>,
248 pub banner_rows: Vec<Row>,
250 pub stable_rows: Vec<Row>,
253 pub live_rows: Vec<Row>,
255}
256
257pub 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#[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#[derive(Clone, Debug, Default, Eq, PartialEq)]
343pub struct SemanticTranscriptView {
344 pub rows: Vec<TranscriptRowView>,
345}
346
347#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
604pub struct PickerItemView {
605 pub label: String,
606 pub detail: String,
607}
608
609#[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#[derive(Clone, Debug, Eq, PartialEq)]
621pub struct DiffDetailView {
622 pub summary: DiffSummaryView,
623 pub lines: Vec<String>,
624}
625
626#[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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
665pub struct TableCellView {
666 pub text: String,
667 pub alignment: ColumnAlignment,
668 pub width: ColumnWidthPolicy,
669}
670
671pub 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#[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
717pub struct LiveView {
719 pub live_tail: Vec<Row>,
721 pub prompt_rows: Vec<Row>,
723 pub prompt_cursor: Option<CursorCoord>,
725 pub accessory_rows: Vec<Row>,
727 pub queued_summary: Option<Row>,
729 pub detail_pane: Vec<Row>,
731 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 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}