From 4de726abc53f25cce6f1d159cc41c637ffca2e99 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 8 May 2026 14:13:50 -0500 Subject: [PATCH] feat(tui): compact live thinking by default --- crates/tui/src/commands/config.rs | 25 +++++++++++++++ crates/tui/src/commands/mod.rs | 23 ++++++++++++++ crates/tui/src/localization.rs | 6 ++++ crates/tui/src/tui/app.rs | 3 ++ crates/tui/src/tui/history.rs | 53 ++++++++++++++++++++++++++----- 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index a61699b8..da940bbc 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -180,6 +180,31 @@ pub fn status_line(_app: &mut App) -> CommandResult { CommandResult::action(AppAction::OpenStatusPicker) } +/// Toggle whether the live transcript renders full thinking detail. +pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult { + let next = match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => !app.verbose_transcript, + Some(raw) => match raw.to_ascii_lowercase().as_str() { + "on" | "true" | "1" | "yes" => true, + "off" | "false" | "0" | "no" => false, + "toggle" => !app.verbose_transcript, + _ => { + return CommandResult::error( + "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.", + ); + } + }, + }; + + app.verbose_transcript = next; + app.mark_history_updated(); + CommandResult::message(if next { + "Verbose transcript on: live thinking renders in full." + } else { + "Verbose transcript off: live thinking stays compact." + }) +} + /// Persist `tui.status_items` to `~/.deepseek/config.toml` without disturbing /// the rest of the file. We round-trip through `toml::Value` so any keys we /// don't know about (provider blocks, MCP, etc.) survive the write diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f391d593..67d118c7 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -347,6 +347,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/theme", description_id: MessageId::CmdThemeDescription, }, + CommandInfo { + name: "verbose", + aliases: &[], + usage: "/verbose [on|off]", + description_id: MessageId::CmdVerboseDescription, + }, CommandInfo { name: "trust", aliases: &[], @@ -542,6 +548,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "agent" => config::agent_mode(app), "plan" => config::plan_mode(app), "theme" => config::theme(app), + "verbose" => config::verbose(app, arg), "trust" => config::trust(app, arg), "logout" => config::logout(app), @@ -922,6 +929,22 @@ mod tests { assert!(matches!(result.action, Some(AppAction::OpenConfigView))); } + #[test] + fn execute_verbose_toggles_live_transcript_detail() { + let mut app = create_test_app(); + assert!(!app.verbose_transcript); + + let result = execute("/verbose on", &mut app); + assert!(!result.is_error); + assert!(app.verbose_transcript); + assert!(result.message.unwrap().contains("on")); + + let result = execute("/verbose off", &mut app); + assert!(!result.is_error); + assert!(!app.verbose_transcript); + assert!(result.message.unwrap().contains("off")); + } + #[test] fn execute_links_and_aliases_return_links_message() { let mut app = create_test_app(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 30799572..9ffff0c9 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -270,6 +270,7 @@ pub enum MessageId { CmdLspDescription, CmdShareDescription, CmdUndoDescription, + CmdVerboseDescription, CmdYoloDescription, CmdCacheAdvice, CmdCacheFootnote, @@ -460,6 +461,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdLspDescription, MessageId::CmdShareDescription, MessageId::CmdUndoDescription, + MessageId::CmdVerboseDescription, MessageId::CmdYoloDescription, MessageId::CmdCacheAdvice, MessageId::CmdCacheFootnote, @@ -800,6 +802,7 @@ fn english(id: MessageId) -> &'static str { "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" } MessageId::CmdUndoDescription => "Remove last message pair", + MessageId::CmdVerboseDescription => "Toggle full live thinking in the transcript", MessageId::CmdYoloDescription => "Enable YOLO mode (shell + trust + auto-approve)", MessageId::CmdCacheAdvice => { "Hit/miss ratios over ~70% after the third turn indicate a stable cache prefix; \n\ @@ -1086,6 +1089,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { "ワークスペースの信頼設定とパス別許可リストを管理(`/trust add `、`/trust list`、`/trust on|off`)" } MessageId::CmdUndoDescription => "最後のメッセージ対を削除", + MessageId::CmdVerboseDescription => "ライブ思考表示の詳細モードを切り替え", MessageId::CmdYoloDescription => "YOLO モードを有効化(shell + 信頼 + 自動承認)", MessageId::CmdCacheAdvice => { "3 ターン目以降にヒット率が ~70% 以上で安定していれば、プレフィックスキャッシュは健全。\n\ @@ -1344,6 +1348,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { "管理工作区信任与按路径的白名单(`/trust add `、`/trust list`、`/trust on|off`)" } MessageId::CmdUndoDescription => "移除最后一组消息对", + MessageId::CmdVerboseDescription => "切换实时思考内容的完整显示", MessageId::CmdYoloDescription => "启用 YOLO 模式(shell + 信任 + 自动批准)", MessageId::CmdCacheAdvice => { "第 3 轮起命中率稳定在 ~70% 以上即表示前缀缓存稳定;\n\ @@ -1618,6 +1623,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add `, `/trust list`, `/trust on|off`)" } MessageId::CmdUndoDescription => "Remover o último par de mensagens", + MessageId::CmdVerboseDescription => "Alternar pensamento ao vivo completo no transcript", MessageId::CmdYoloDescription => { "Ativar o modo YOLO (shell + confiança + aprovação automática)" } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 78460b16..f753eefb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -724,6 +724,7 @@ pub struct App { #[allow(dead_code)] pub fancy_animations: bool, pub show_thinking: bool, + pub verbose_transcript: bool, pub show_tool_details: bool, pub ui_locale: Locale, pub cost_currency: CostCurrency, @@ -1308,6 +1309,7 @@ impl App { low_motion, fancy_animations, show_thinking, + verbose_transcript: false, show_tool_details, ui_locale, cost_currency, @@ -2438,6 +2440,7 @@ impl App { pub fn transcript_render_options(&self) -> TranscriptRenderOptions { TranscriptRenderOptions { show_thinking: self.show_thinking, + verbose: self.verbose_transcript, show_tool_details: self.show_tool_details, calm_mode: self.calm_mode, low_motion: self.low_motion, diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 0b964b6e..8e9952f4 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; -use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use serde_json::Value; use unicode_width::UnicodeWidthStr; @@ -149,6 +149,7 @@ impl SubAgentCell { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TranscriptRenderOptions { pub show_thinking: bool, + pub verbose: bool, pub show_tool_details: bool, pub calm_mode: bool, pub low_motion: bool, @@ -159,6 +160,7 @@ impl Default for TranscriptRenderOptions { fn default() -> Self { Self { show_thinking: true, + verbose: false, show_tool_details: true, calm_mode: false, low_motion: false, @@ -239,7 +241,7 @@ impl HistoryCell { width, *streaming, *duration_secs, - !*streaming, + !options.verbose, options.low_motion, ), HistoryCell::Tool(cell) if !options.show_tool_details => { @@ -2081,12 +2083,18 @@ fn render_thinking( lines.push(Line::from(header_spans)); let content_width = width.saturating_sub(3).max(1); - let body_text = if collapsed { + let body_text = if collapsed && streaming { + String::new() + } else if collapsed { extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string()) } else { content.to_string() }; - let mut rendered = markdown_render::render_markdown(&body_text, content_width, body_style); + let mut rendered = if body_text.trim().is_empty() { + Vec::new() + } else { + markdown_render::render_markdown(&body_text, content_width, body_style) + }; let mut truncated = false; if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT { rendered.truncate(THINKING_SUMMARY_LINE_LIMIT); @@ -2098,10 +2106,7 @@ fn render_thinking( if rendered.is_empty() && streaming { let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)]; - spans.push(Span::styled( - "reasoning in progress...", - body_style.italic(), - )); + spans.push(Span::styled("thinking...", body_style.italic())); if !low_motion { spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style)); } @@ -3910,6 +3915,38 @@ mod tests { ); } + #[test] + fn streaming_thinking_live_collapses_unless_verbose() { + let cell = HistoryCell::Thinking { + content: "private step one\nprivate step two".to_string(), + streaming: true, + duration_secs: None, + }; + + let compact = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + let compact_text = lines_text(&compact); + assert!(compact_text.contains("thinking...")); + assert!(!compact_text.contains("private step one")); + + let verbose = cell.lines_with_options( + 80, + TranscriptRenderOptions { + verbose: true, + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + let verbose_text = lines_text(&verbose); + assert!(verbose_text.contains("private step one")); + assert!(verbose_text.contains("private step two")); + } + // === Theme parity tests === // // These lock the visible color/style choices for one plan cell and one