1#[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
18pub const MAX_PROMPT_ROWS: usize = 8;
20
21pub const MAX_ACCESSORY_ROWS: usize = 8;
23
24const LIVE_INSET: usize = 2;
25const COMPOSER_MIN_CONTENT_WIDTH: usize = 8;
26
27pub 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
143pub fn composer_frame_height(width: usize) -> usize {
145 usize::from(super::layout::content_width(width) >= COMPOSER_MIN_CONTENT_WIDTH) * 2
146}
147
148pub 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
229pub 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
250pub 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
278pub 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
357pub 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
533fn is_mention_char(ch: char) -> bool {
535 ch.is_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | '~')
536}