diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 3cec88b04e..bea0eee568 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -204,6 +204,7 @@ CLI-P1 应提供: - TUI 只维护 composer 文本、候选框和结构化引用位置。按 Session 绑定的搜索、路径/类型/行范围校验、持久化与历史恢复由 Agent Runtime 端口和 Core owner 负责;Core 不提前读取文件内容,Agent 仍通过既有 Read/Glob 工具进入权限、取消和审计路径。远程工作区当前返回明确的不可用原因,不回退到本地文件系统。 - OpenCode 对齐的 `/editor`、`/copy`、`/export` 只提供这些命令名,不增加 alias 或默认快捷键。三者从 TUI 当前 `ChatState`/composer 做本地投影,不新增 Runtime operation、IPC 消息或通用 Export/Process port。`/editor` 与 export 的 editor 路径仅使用 `VISUAL` 后 `EDITOR`,由用户提供阻塞等待参数;CLI 在主循环渲染 loading 状态后临时释放 terminal guard,编辑结束后完整重建 raw mode、alternate screen、mouse 与 bracketed paste。编辑失败、非零退出或空文件不替换原草稿;重接管失败时退出而不在半初始化 TUI 中继续。结构化 `@` 引用只在标记唯一或重复标记数量精确匹配时重定位,否则降级为普通文本。 - `/copy` 固定排除 reasoning、工具输入/结果和 TUI 本地 System 通知,并限制在 Idle,避免慢剪贴板 helper 阻塞活动 Turn 的事件流;`/export` 的专用表单可显式加入 reasoning/工具详情、选择保存及 editor review。导出根目录是 CLI 客户端启动目录,不使用 Remote/Shared Runtime 的 workspace;空值、绝对路径、Windows root/prefix 与 `..` 被拒绝。首次发布使用原子 create-new,竞争出现的文件会回到二次确认,确认后才使用 `services-core` 的严格 UTF-8 原子替换。剪贴板复用本地系统 provider 的平台诊断。Shared TUI 仅共享已有 transcript 事实,文件、剪贴板、临时文件与 editor 进程始终留在客户端。 +- OpenCode 对齐的 `/timeline` 只列出当前 `ChatState` 中的用户消息并按最新优先展示,方向键移动时预览对应位置,Enter 保留跳转结果;不复制 `/fork`、`/undo` 或 `/copy` 的业务动作。时间线与 fork 复用同一个会话点选择器,跳转与常规滚动复用逐消息 render cache 和行布局,不新增 transcript 副本、Runtime operation 或 IPC 消息。`/timestamps`、`/toggle-timestamps`、`/thinking`、`/toggle-thinking` 严格沿用 OpenCode 入口;工具详情只通过 command palette 切换,不增加 slash alias。三项默认展示策略由 CLI 本地配置持久化,View 仅消费配置投影;单个 thinking/tool block 使用同一套相对默认值的 disclosure override。Embedded 与 Shared TUI 行为一致,活动 Turn 中仍可只读导航和切换展示。 - 图片附件沿用 OpenCode 的 paste 入口和 `[Image N]` 占位符,不新增 slash command、alias 或默认快捷键。Startup 与 Chat 共享同一个 composer draft、图片读取器和 source-range 协调器;PNG/JPEG/GIF/WebP 在粘贴时读取、按真实格式解码并快照为不可变字节,单张上限 20 MiB、单次最多 5 张,只保留文件名而不把绝对路径交给 Runtime。文件读取与剪贴板 PNG 编码在写入期间受硬上限约束,文件解码分配上限为 64 MiB;系统剪贴板 provider 在返回前已完成平台格式解码,BitFun 只能在返回后校验像素尺寸并限制 PNG 编码结果,不能把该 provider 的预解码宣称为受自身上限控制。提交时才投影到已有 `AgentInputAttachment::remote_image`,不新增 Runtime operation、图片专用会话 owner 或第二套发送路径。本地历史、删除重编号、`/editor` 和同一 TUI 进程内按 Session 隔离的成功提交 undo/redo 栈复用原草稿字节,切换 Session 不会混用或丢失关联;输入历史与成功提交栈各自全局最多保留 50 条草稿,并共享 200 MiB 历史图片预算。占位符被删除、变得歧义、超出历史预算或无法与成功提交栈安全关联时只丢弃对应结构化元数据。Shared IPC 的请求帧有界,因此 Shared TUI 在 paste 和提交两层显式拒绝图片并保留草稿,不把 data URL 填入 IPC。 - 对话 checkpoint 与工作区 checkpoint 的独立事实;rewind 必须明确选择只回退对话、只回退工作区或两者。 - 后台 Agent/工具/工作流的状态、取消和结果回收,不允许无结果的隐式 detached task。 diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 4489de284c..1697dc16fc 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -90,6 +90,9 @@ The Embedded and Shared TUI use the same session command names: - `/fork` opens an OpenCode-compatible fork dialog. `Full session` copies through the latest persisted turn; choosing a previous user prompt forks immediately before that turn and copies the prompt into the composer without sending it. Forking requires an idle session. +- `/timeline` opens the OpenCode-compatible user-message timeline, newest first. Moving the + selection previews that location in the current transcript; Enter keeps the selected message in + view. Timeline navigation is read-only and remains available while a turn is running. - `/status` opens a transient view of current session, runtime, workspace, approval, and latest primary-model request facts observed by this TUI. It is not a cumulative usage report; use `/usage` for cumulative session usage in Embedded TUI. @@ -102,6 +105,12 @@ The Embedded and Shared TUI use the same session command names: leave the existing draft unchanged; structured `@` references are retained only when their edited markers remain unambiguous. A terminal reacquisition failure exits instead of continuing in a partially initialized TUI. +- `/timestamps` (`/toggle-timestamps`) and `/thinking` (`/toggle-thinking`) use OpenCode's command + names to toggle message timestamps and reasoning-block visibility. Tool-detail visibility is a + command-palette action only, matching OpenCode rather than introducing another slash command. + Timestamps and thinking default to hidden; tool details default to shown. These client-local + preferences are saved in CLI configuration, while clicking an individual reasoning or tool block + still overrides its current presentation. - `/copy` copies a safe Markdown snapshot of the visible User/Assistant transcript. Reasoning and tool payloads are excluded by default; local System notices are never exported. Copy is idle-only so a slow platform clipboard helper cannot stall an active Turn's event stream. diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index bf40142c92..de420642a3 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -74,6 +74,7 @@ pub(crate) enum ActionHandler { AddModel, NewSession, Sessions, + Timeline, ForkSession, UndoSession, RedoSession, @@ -92,6 +93,9 @@ pub(crate) enum ActionHandler { CompactSession, Usage, Editor, + ToggleTimestamps, + ToggleThinking, + ToggleToolDetails, CopyTranscript, ExportTranscript, ToggleAutoApprove, @@ -127,7 +131,7 @@ pub(crate) fn shared_tui_image_attachment_error() -> String { format!("Image attachments are unavailable in Shared TUI. {SHARED_TUI_EMBEDDED_HANDOFF}.") } pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/timeline` to navigate user messages, `/fork` to branch the current idle Session, `/rename ` to rename it, `/compact` to compact its context, `/diff` to review workspace changes, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { @@ -138,6 +142,7 @@ impl ActionHandler { | Self::SelectTheme | Self::NewSession | Self::Sessions + | Self::Timeline | Self::ForkSession | Self::UndoSession | Self::RedoSession @@ -148,6 +153,9 @@ impl ActionHandler { | Self::WorkspaceDiff | Self::CompactSession | Self::Editor + | Self::ToggleTimestamps + | Self::ToggleThinking + | Self::ToggleToolDetails | Self::CopyTranscript | Self::ExportTranscript | Self::ToggleAutoApprove @@ -378,6 +386,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: true, }, + ActionSpec { + id: "timeline", + name: "Timeline", + aliases: &["/timeline"], + description: "Jump to a user message in the current session", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::Timeline, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "rename_session", name: "Rename session", @@ -648,6 +671,51 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "toggle_timestamps", + name: "Timestamps", + aliases: &["/timestamps", "/toggle-timestamps"], + description: "Toggle message timestamps", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ToggleTimestamps, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "toggle_thinking", + name: "Thinking blocks", + aliases: &["/thinking", "/toggle-thinking"], + description: "Toggle thinking block visibility", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ToggleThinking, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "toggle_tool_details", + name: "Tool details", + aliases: &[], + description: "Toggle tool detail visibility", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ToggleToolDetails, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "copy_transcript", name: "Copy transcript", @@ -2904,4 +2972,36 @@ mod tests { assert!(copy.handler.available_in_shared_tui(ActionContext::Chat)); assert!(export.handler.available_in_shared_tui(ActionContext::Chat)); } + + #[test] + fn opencode_navigation_and_presentation_actions_keep_exact_entrypoints() { + let timeline = + action_for_alias("/timeline", ActionContext::Chat).expect("/timeline action"); + let timestamps = + action_for_alias("/timestamps", ActionContext::Chat).expect("/timestamps action"); + let timestamps_alias = action_for_alias("/toggle-timestamps", ActionContext::Chat) + .expect("/toggle-timestamps action"); + let thinking = + action_for_alias("/thinking", ActionContext::Chat).expect("/thinking action"); + let thinking_alias = action_for_alias("/toggle-thinking", ActionContext::Chat) + .expect("/toggle-thinking action"); + let tool_details = action_by_id("toggle_tool_details", ActionContext::Chat) + .expect("tool details palette action"); + + assert_eq!(timeline.id, "timeline"); + assert_eq!(timeline.aliases, ["/timeline"]); + assert_eq!(timestamps.id, "toggle_timestamps"); + assert_eq!(timestamps.aliases, ["/timestamps", "/toggle-timestamps"]); + assert_eq!(timestamps.id, timestamps_alias.id); + assert_eq!(thinking.id, "toggle_thinking"); + assert_eq!(thinking.aliases, ["/thinking", "/toggle-thinking"]); + assert_eq!(thinking.id, thinking_alias.id); + assert!(tool_details.aliases.is_empty()); + for action in [timeline, timestamps, thinking, tool_details] { + assert_eq!(action.palette.map(|palette| palette.group), Some("Session")); + assert!(action.available(ActionState::chat(false, false))); + assert!(action.available(ActionState::chat(true, false))); + assert!(action.handler.available_in_shared_tui(ActionContext::Chat)); + } + } } diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 935fc1fd1a..ae93619dbc 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -294,6 +294,25 @@ pub(crate) struct SessionForkPoint { pub timestamp: SystemTime, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SessionTimelinePoint { + pub message_id: String, + pub prompt: String, + pub timestamp: SystemTime, +} + +fn visible_message_text(message: &ChatMessage) -> String { + message + .flow_items + .iter() + .filter_map(|item| match item { + FlowItem::Text { content, .. } => Some(content.as_str()), + FlowItem::Thinking { .. } | FlowItem::Tool { .. } => None, + }) + .collect::>() + .join("\n") +} + // ============ Chat Metadata ============ /// Statistics for the current chat session @@ -483,15 +502,7 @@ impl ChatState { .filter(|message| message.role == MessageRole::User) .filter_map(|message| { let turn_id = message.turn_id.clone()?; - let prompt = message - .flow_items - .iter() - .filter_map(|item| match item { - FlowItem::Text { content, .. } => Some(content.as_str()), - FlowItem::Thinking { .. } | FlowItem::Tool { .. } => None, - }) - .collect::>() - .join("\n"); + let prompt = visible_message_text(message); (!prompt.is_empty()).then_some(SessionForkPoint { message_id: message.id.clone(), turn_id, @@ -502,6 +513,23 @@ impl ChatState { .collect() } + /// User messages eligible for OpenCode-compatible `/timeline`, newest first. + pub(crate) fn session_timeline_points(&self) -> Vec { + self.messages + .iter() + .rev() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + let prompt = visible_message_text(message); + (!prompt.is_empty()).then_some(SessionTimelinePoint { + message_id: message.id.clone(), + prompt, + timestamp: message.timestamp, + }) + }) + .collect() + } + pub(crate) fn latest_user_message_id(&self) -> Option { self.messages .iter() @@ -1967,6 +1995,54 @@ mod tests { assert_eq!(points[1].prompt, "First prompt"); } + #[test] + fn timeline_points_match_opencode_user_message_order_without_needing_turn_ids() { + let transcript = SessionTranscript { + session_id: "session-1".to_string(), + messages: vec![ + TranscriptMessage { + id: Some("user-1".to_string()), + role: "user".to_string(), + turn_id: None, + timestamp_ms: Some(1_000), + content: TranscriptContent::Text("First\nprompt".to_string()), + }, + TranscriptMessage { + id: Some("assistant-1".to_string()), + role: "assistant".to_string(), + turn_id: Some("turn-1".to_string()), + timestamp_ms: Some(2_000), + content: TranscriptContent::Text("Answer".to_string()), + }, + TranscriptMessage { + id: Some("user-2".to_string()), + role: "user".to_string(), + turn_id: Some("turn-2".to_string()), + timestamp_ms: Some(3_000), + content: TranscriptContent::Multimodal { + text: "Second prompt".to_string(), + image_count: 1, + }, + }, + ], + }; + let state = ChatState::from_session_transcript( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + &transcript, + ); + + let points = state.session_timeline_points(); + + assert_eq!(points.len(), 2); + assert_eq!(points[0].message_id, "user-2"); + assert_eq!(points[0].prompt, "Second prompt"); + assert_eq!(points[1].message_id, "user-1"); + assert_eq!(points[1].prompt, "First\nprompt"); + } + #[test] fn transcript_history_merges_tool_results_into_the_rendered_tool_card() { let transcript = SessionTranscript { diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index fee7109935..37c48caeda 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -4,9 +4,11 @@ /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) use anyhow::Result; use bitfun_core::infrastructure::try_get_path_manager_arc; +use fs2::FileExt; use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig @@ -36,6 +38,25 @@ pub(crate) struct UiConfig { pub animation: bool, /// Color scheme pub color_scheme: String, + /// Show timestamps below user messages. + pub timestamps: bool, + /// Default presentation for reasoning blocks. + pub thinking: ThinkingMode, + /// Show tool-card details by default. + pub tool_details: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ThinkingMode { + Show, + Hide, +} + +impl Default for ThinkingMode { + fn default() -> Self { + Self::Hide + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,6 +103,9 @@ impl Default for UiConfig { show_tips: true, animation: true, color_scheme: "default".to_string(), + timestamps: false, + thinking: ThinkingMode::Hide, + tool_details: true, } } } @@ -154,33 +178,94 @@ impl CliConfig { /// Load configuration pub(crate) fn load() -> Result { let config_path = Self::config_path()?; - + let config = Self::load_at(&config_path)?; if !config_path.exists() { tracing::info!("Config file not found, using defaults"); - return Ok(Self::default()); + } else { + tracing::info!("Loaded config: {:?}", config_path); } - - let content = fs::read_to_string(&config_path)?; - let mut config: Self = toml::from_str(&content)?; - config.normalize_legacy_shortcuts(); - tracing::info!("Loaded config: {:?}", config_path); Ok(config) } /// Save configuration pub(crate) fn save(&self) -> Result<()> { let config_path = Self::config_path()?; + Self::with_config_lock(&config_path, || Self::write_at(&config_path, self))?; + tracing::info!("Saved config: {:?}", config_path); + Ok(()) + } - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent)?; + /// Apply a focused mutation to the latest on-disk snapshot. + /// + /// Shared TUI clients keep independent in-memory snapshots, so live settings + /// must merge under the config lock instead of rewriting a stale full copy. + pub(crate) fn update(&mut self, update: F) -> Result<()> + where + F: FnOnce(&mut Self), + { + let config_path = Self::config_path()?; + let latest = Self::update_at(&config_path, update)?; + *self = latest; + tracing::info!("Updated config: {:?}", config_path); + Ok(()) + } + + fn update_at(config_path: &Path, update: F) -> Result + where + F: FnOnce(&mut Self), + { + Self::with_config_lock(config_path, || { + let mut latest = Self::load_at(config_path)?; + update(&mut latest); + Self::write_at(config_path, &latest)?; + Ok(latest) + }) + } + + fn load_at(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(Self::default()); } + let content = fs::read_to_string(config_path)?; + let mut config: Self = toml::from_str(&content)?; + config.normalize_legacy_shortcuts(); + Ok(config) + } - let content = toml::to_string_pretty(self)?; - fs::write(&config_path, content)?; - tracing::info!("Saved config: {:?}", config_path); + fn write_at(config_path: &Path, config: &Self) -> Result<()> { + let parent = config_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Config path has no parent directory"))?; + fs::create_dir_all(parent)?; + let content = toml::to_string_pretty(config)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(content.as_bytes())?; + temporary.as_file_mut().sync_all()?; + temporary + .persist(config_path) + .map_err(|error| error.error)?; Ok(()) } + fn with_config_lock(config_path: &Path, operation: impl FnOnce() -> Result) -> Result { + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + let lock_path = config_path.with_extension("toml.lock"); + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(lock_path)?; + lock_file.lock_exclusive()?; + let result = operation(); + let unlock_result = FileExt::unlock(&lock_file); + result.and_then(|value| { + unlock_result?; + Ok(value) + }) + } + /// Get configuration directory pub(crate) fn config_dir() -> Result { let config_dir = Self::resolve_config_dir()?; @@ -192,11 +277,13 @@ impl CliConfig { #[cfg(test)] mod tests { - use super::CliConfig; + use super::{CliConfig, ThinkingMode}; + use std::fs; #[test] fn cli_config_default_composes_owner_defaults() { let config = CliConfig::default(); + let serialized = toml::to_string(&config).unwrap(); assert_eq!(config.ui.theme, "dark"); assert_eq!(config.ui.theme_id, "bitfun-dark"); @@ -215,6 +302,19 @@ mod tests { assert_eq!(config.shortcuts.send_message, None); assert_eq!(config.shortcuts.interrupt, None); assert_eq!(config.shortcuts.menu, None); + assert!(serialized.contains("timestamps = false"), "{serialized}"); + assert!(serialized.contains("thinking = \"hide\""), "{serialized}"); + assert!(serialized.contains("tool_details = true"), "{serialized}"); + } + + #[test] + fn missing_transcript_presentation_fields_keep_opencode_compatible_defaults() { + let config: CliConfig = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap(); + let serialized = toml::to_string(&config).unwrap(); + + assert!(serialized.contains("timestamps = false"), "{serialized}"); + assert!(serialized.contains("thinking = \"hide\""), "{serialized}"); + assert!(serialized.contains("tool_details = true"), "{serialized}"); } #[test] @@ -267,4 +367,21 @@ mod tests { assert_eq!(config.shortcuts.interrupt.as_deref(), Some("Ctrl+X")); assert_eq!(config.shortcuts.menu.as_deref(), Some("Alt+M")); } + + #[test] + fn targeted_updates_merge_with_the_latest_config_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let mut initial = CliConfig::default(); + initial.ui.theme_id = "custom-theme".to_string(); + fs::write(&path, toml::to_string_pretty(&initial).unwrap()).unwrap(); + + CliConfig::update_at(&path, |latest| latest.ui.timestamps = true).unwrap(); + CliConfig::update_at(&path, |latest| latest.ui.thinking = ThinkingMode::Show).unwrap(); + + let merged: CliConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(merged.ui.timestamps); + assert_eq!(merged.ui.thinking, ThinkingMode::Show); + assert_eq!(merged.ui.theme_id, "custom-theme"); + } } diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs index 64fe651c6e..539f9247c6 100644 --- a/src/apps/cli/src/modes/chat/account.rs +++ b/src/apps/cli/src/modes/chat/account.rs @@ -188,6 +188,7 @@ impl ChatMode { || chat_view.agent_selector_visible() || chat_view.session_selector_visible() || chat_view.fork_selector_visible() + || chat_view.timeline_selector_visible() || chat_view.export_dialog_visible() || chat_view.skill_selector_visible() || chat_view.subagent_selector_visible() @@ -213,6 +214,7 @@ impl ChatMode { chat_view.hide_agent_selector(); chat_view.hide_session_selector(); chat_view.hide_fork_selector(); + chat_view.hide_timeline_selector(); chat_view.hide_export_dialog(); chat_view.hide_skill_selector(); chat_view.hide_subagent_selector(); @@ -240,6 +242,7 @@ impl ChatMode { crate::ui::chat::PopupType::AgentSelector => chat_view.hide_agent_selector(), crate::ui::chat::PopupType::SessionSelector => chat_view.hide_session_selector(), crate::ui::chat::PopupType::ForkSelector => chat_view.hide_fork_selector(), + crate::ui::chat::PopupType::TimelineSelector => chat_view.hide_timeline_selector(), crate::ui::chat::PopupType::ExportDialog => chat_view.hide_export_dialog(), crate::ui::chat::PopupType::SkillSelector => chat_view.hide_skill_selector(), crate::ui::chat::PopupType::SubagentSelector => chat_view.hide_subagent_selector(), @@ -268,6 +271,9 @@ impl ChatMode { chat_view.reshow_session_selector() } crate::ui::chat::PopupType::ForkSelector => chat_view.reshow_fork_selector(), + crate::ui::chat::PopupType::TimelineSelector => { + chat_view.reshow_timeline_selector() + } crate::ui::chat::PopupType::ExportDialog => {} crate::ui::chat::PopupType::SkillSelector => chat_view.reshow_skill_selector(), crate::ui::chat::PopupType::SubagentSelector => { diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 4771c78a70..075461963e 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -125,10 +125,13 @@ fn builtin_arguments_error( match handler { ActionHandler::CompactSession => Some("Usage: /compact"), ActionHandler::ForkSession => Some("Usage: /fork"), + ActionHandler::Timeline => Some("Usage: /timeline"), ActionHandler::UndoSession => Some("Usage: /undo"), ActionHandler::RedoSession => Some("Usage: /redo"), ActionHandler::WorkspaceDiff => Some("Usage: /diff"), ActionHandler::Editor => Some("Usage: /editor"), + ActionHandler::ToggleTimestamps => Some("Usage: /timestamps"), + ActionHandler::ToggleThinking => Some("Usage: /thinking"), ActionHandler::CopyTranscript => Some("Usage: /copy"), ActionHandler::ExportTranscript => Some("Usage: /export"), _ => None, @@ -188,13 +191,15 @@ fn session_command_help_note() -> String { .expect("current session rename action must remain registered"); let fork = action_for_alias("/fork", ActionContext::Chat) .expect("current session fork action must remain registered"); + let timeline = action_for_alias("/timeline", ActionContext::Chat) + .expect("current session timeline action must remain registered"); let undo = action_for_alias("/undo", ActionContext::Chat) .expect("current session undo action must remain registered"); let redo = action_for_alias("/redo", ActionContext::Chat) .expect("current session redo action must remain registered"); format!( - "Session Commands\n /fork - {}\n /rename - {}\n /undo - {}\n /redo - {}", - fork.description, rename.description, undo.description, redo.description + "Session Commands\n /timeline - {}\n /fork - {}\n /rename - {}\n /undo - {}\n /redo - {}", + timeline.description, fork.description, rename.description, undo.description, redo.description ) } @@ -923,6 +928,20 @@ impl ChatMode { } } + fn persist_presentation_preference( + &mut self, + chat_view: &mut ChatView, + status: &str, + update: impl FnOnce(&mut crate::config::CliConfig), + ) { + match self.config.update(update) { + Ok(()) => chat_view.set_status(Some(status.to_string())), + Err(error) => chat_view.set_status(Some(format!( + "{status} for this run, but the preference could not be saved: {error}" + ))), + } + } + fn dispatch_action( &mut self, action: &'static ActionSpec, @@ -999,6 +1018,16 @@ impl ChatMode { ActionHandler::Sessions => { self.show_session_selector(chat_view, chat_state, rt_handle); } + ActionHandler::Timeline => { + let points = chat_state.session_timeline_points(); + if points.is_empty() { + chat_view.set_status(Some( + "No user messages are available in the current timeline".to_string(), + )); + } else { + chat_view.show_timeline_selector(points); + } + } ActionHandler::ForkSession => { self.show_fork_selector(chat_view, chat_state); } @@ -1083,6 +1112,41 @@ impl ChatMode { } Err(error) => chat_view.set_status(Some(format!("Editor unavailable: {error}"))), }, + ActionHandler::ToggleTimestamps => { + let visible = chat_view.toggle_timestamps(); + self.persist_presentation_preference( + chat_view, + if visible { + "Message timestamps shown" + } else { + "Message timestamps hidden" + }, + |config| config.ui.timestamps = visible, + ); + } + ActionHandler::ToggleThinking => { + let mode = chat_view.toggle_thinking(); + self.persist_presentation_preference( + chat_view, + match mode { + crate::config::ThinkingMode::Show => "Thinking blocks shown", + crate::config::ThinkingMode::Hide => "Thinking blocks hidden", + }, + |config| config.ui.thinking = mode, + ); + } + ActionHandler::ToggleToolDetails => { + let visible = chat_view.toggle_tool_details(); + self.persist_presentation_preference( + chat_view, + if visible { + "Tool details shown" + } else { + "Tool details hidden" + }, + |config| config.ui.tool_details = visible, + ); + } ActionHandler::CopyTranscript => { let markdown = transcript::render_session_markdown( chat_state, diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index ebc1467b2e..6239042e7d 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -276,6 +276,23 @@ impl ChatMode { return Ok(None); } + if chat_view.timeline_selector_visible() { + match chat_view.timeline_selector_handle_key(key) { + crate::ui::timeline_selector::TimelineAction::Move(message_id) => { + chat_view.scroll_to_message(chat_state, &message_id); + } + crate::ui::timeline_selector::TimelineAction::Select(message_id) => { + chat_view.commit_message_jump(chat_state, &message_id); + self.navigate_back(chat_view); + } + crate::ui::timeline_selector::TimelineAction::Close => { + self.navigate_back(chat_view); + } + crate::ui::timeline_selector::TimelineAction::None => {} + } + return Ok(None); + } + if chat_view.skill_selector_visible() { match key.code { KeyCode::Up => chat_view.skill_selector_up(), diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index c7947c4398..d6fb2a9821 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -341,6 +341,7 @@ impl ChatMode { let theme = self.resolve_configured_theme(base, appearance, scheme); let shortcut_hints = self.keymap.compact_hints(self.action_state(false, false)); let mut chat_view = ChatView::new(theme, shortcut_hints); + chat_view.apply_presentation_config(&self.config.ui); // Create or restore core session let rt_handle = tokio::runtime::Handle::current(); diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 74c7f6a9d9..084cfeab09 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -374,8 +374,10 @@ impl ChatMode { (false, EffectiveColorScheme::Truecolor) => Theme::dark(), }; - self.config.ui.theme_id = theme.id.clone(); - if let Err(e) = self.config.save() { + if let Err(e) = self + .config + .update(|config| config.ui.theme_id = theme.id.clone()) + { chat_view.set_status(Some(format!("Failed to save config: {}", e))); } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index eed43cbfff..b2ab767765 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -1199,6 +1199,10 @@ mod tests { ), Some("Usage: /fork") ); + assert_eq!( + builtin_arguments_error(CommandRoute::Builtin, ActionHandler::Timeline, "unexpected"), + Some("Usage: /timeline") + ); assert_eq!( builtin_arguments_error( CommandRoute::Builtin, @@ -1219,6 +1223,22 @@ mod tests { builtin_arguments_error(CommandRoute::Builtin, ActionHandler::Editor, "unexpected"), Some("Usage: /editor") ); + assert_eq!( + builtin_arguments_error( + CommandRoute::Builtin, + ActionHandler::ToggleTimestamps, + "unexpected" + ), + Some("Usage: /timestamps") + ); + assert_eq!( + builtin_arguments_error( + CommandRoute::Builtin, + ActionHandler::ToggleThinking, + "unexpected" + ), + Some("Usage: /thinking") + ); assert_eq!( builtin_arguments_error( CommandRoute::Builtin, @@ -2083,6 +2103,7 @@ mod tests { assert!(help.contains("Session Commands")); assert!(help.contains(rename.description)); assert!(help.contains("/rename ")); + assert!(help.contains("/timeline")); assert!(help.contains("/undo")); assert!(help.contains("/redo")); } diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index d31d3d8db1..c297b60d73 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -31,6 +31,11 @@ impl ChatView { pub(crate) fn handle_mouse_event(&mut self, mouse: &crossterm::event::MouseEvent) -> bool { // Popups take priority when visible + if self.fork_selector.is_visible() || self.timeline_selector.is_visible() { + // These conversation-point dialogs are keyboard-driven. Do not let mouse gestures + // mutate the transcript hidden underneath them. + return true; + } if self.model_selector.captures_mouse(mouse) { self.model_selector.handle_mouse_event(mouse); return true; @@ -353,12 +358,7 @@ impl ChatView { for (block_id, y_start, y_end) in &self.thinking_regions { if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { let block_id = block_id.clone(); - if self.collapsed_thinking.contains(&block_id) { - self.collapsed_thinking.remove(&block_id); - } else { - self.collapsed_thinking.insert(block_id.clone()); - } - self.thinking_user_overrides.insert(block_id); + self.thinking_disclosures.toggle(&block_id); self.invalidate_render_cache(); self.hovered_thinking_block_id = None; return; @@ -369,11 +369,7 @@ impl ChatView { for (tool_id, y_start, y_end) in &self.block_tool_regions { if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { let tool_id = tool_id.clone(); - if self.collapsed_tools.contains(&tool_id) { - self.collapsed_tools.remove(&tool_id); - } else { - self.collapsed_tools.insert(tool_id.clone()); - } + self.tool_disclosures.toggle(&tool_id); self.focused_block_tool = Some(tool_id); self.invalidate_render_cache(); break; diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index df4e32f143..0318cdd41a 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -524,6 +524,7 @@ impl ChatView { // ============ Fork selector methods ============ pub(crate) fn show_fork_selector(&mut self, points: Vec) { + self.clear_committed_message_anchor(); self.fork_selector.show(points); self.popup_stack.push(PopupType::ForkSelector); } @@ -547,6 +548,36 @@ impl ChatView { self.fork_selector.handle_key_event(key) } + // ============ Timeline selector methods ============ + + pub(crate) fn show_timeline_selector( + &mut self, + points: Vec, + ) { + self.clear_committed_message_anchor(); + self.timeline_selector.show(points); + self.popup_stack.push(PopupType::TimelineSelector); + } + + pub(crate) fn timeline_selector_visible(&self) -> bool { + self.timeline_selector.is_visible() + } + + pub(crate) fn hide_timeline_selector(&mut self) { + self.timeline_selector.hide(); + } + + pub(crate) fn reshow_timeline_selector(&mut self) { + self.timeline_selector.reshow(); + } + + pub(crate) fn timeline_selector_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> TimelineAction { + self.timeline_selector.handle_key_event(key) + } + // ============ Provider selector methods (add model step 1) ============ pub(crate) fn show_provider_selector(&mut self) { diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index fb39d85e74..0b070bb401 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -100,6 +100,7 @@ impl ChatView { self.render_agent_selector(frame, chunks[1]); self.render_session_selector(frame, chunks[1]); self.render_fork_selector(frame, chunks[1]); + self.render_timeline_selector(frame, chunks[1]); self.export_dialog.render(frame, size, &self.theme); self.render_skill_selector(frame, chunks[1]); self.render_subagent_selector(frame, chunks[1]); @@ -243,75 +244,29 @@ impl ChatView { } else { let visible_lines = inner.height as usize; - // ── Step 1: Ensure all messages are in the render cache and collect line counts ── - let mut msg_line_counts: Vec = Vec::with_capacity(chat_state.messages.len()); - for msg in &chat_state.messages { - if msg.is_streaming { - // Streaming messages: always re-render - let rendered = self.render_message(msg, available_width); - let lc = rendered.items.len(); - self.render_cache.insert( - msg.id.clone(), - MessageRenderEntry { - items: rendered.items, - line_count: lc, - version: msg.version, - width: available_width, - plain_lines: rendered.plain_lines, - tool_regions: rendered.tool_regions, - thinking_regions: rendered.thinking_regions, - }, - ); - msg_line_counts.push(lc); - } else { - let cache_valid = self - .render_cache - .get(&msg.id) - .map(|e| e.version == msg.version && e.width == available_width) - .unwrap_or(false); - - if cache_valid { - msg_line_counts.push(self.render_cache.get(&msg.id).unwrap().line_count); - } else { - let rendered = self.render_message(msg, available_width); - let lc = rendered.items.len(); - self.render_cache.insert( - msg.id.clone(), - MessageRenderEntry { - items: rendered.items, - line_count: lc, - version: msg.version, - width: available_width, - plain_lines: rendered.plain_lines, - tool_regions: rendered.tool_regions, - thinking_regions: rendered.thinking_regions, - }, - ); - msg_line_counts.push(lc); - } + // Share one render-cache-backed layout for drawing, scrolling, and timeline jumps. + let layout = self.ensure_message_layout(chat_state, available_width); + let total_lines = layout.total_lines; + + // Keep the highlighted timeline point pinned while a running turn grows below it. + if let Some(message_id) = self.timeline_selector.selected_message_id() { + if let Some(message_index) = chat_state + .messages + .iter() + .position(|message| message.id == message_id) + { + self.apply_message_jump(&layout.prefix_sum, message_index, visible_lines); } + } else { + self.maintain_committed_message_anchor(chat_state, &layout, visible_lines); } - // ── Step 2: Build prefix sum for line counts ── - let total_lines: usize = msg_line_counts.iter().sum(); - - // Update line count cache - self.cached_total_lines = total_lines; - self.cached_msg_count = chat_state.messages.len(); - self.cached_width = available_width; - self.lines_cache_dirty = false; - if total_lines == 0 { return; } - // prefix_sum[i] = total lines of messages 0..i (exclusive end) - // prefix_sum[0] = 0, prefix_sum[1] = msg_line_counts[0], etc. - let mut prefix_sum: Vec = Vec::with_capacity(msg_line_counts.len() + 1); - prefix_sum.push(0); - for &lc in &msg_line_counts { - prefix_sum.push(prefix_sum.last().unwrap() + lc); - } + // prefix_sum[i] = total lines of messages 0..i (exclusive end). + let prefix_sum = layout.prefix_sum; // ── Step 3: Determine visible line range ── let view_start_line = if self.browse_mode { @@ -670,22 +625,13 @@ impl ChatView { user_border_style, ); // Render thinking block with distinct style. - // Use trailing marker to auto-collapse once thinking is complete. let trimmed = content.trim_end(); - let has_end_marker = trimmed.ends_with(""); let clean_content = trimmed.trim_end_matches("").trim_end(); - let thinking_ended = has_end_marker || !message.is_streaming; - if thinking_ended - && !self.thinking_user_overrides.contains(&thinking_block_id) - && !self.thinking_auto_collapsed.contains(&thinking_block_id) - { - self.collapsed_thinking.insert(thinking_block_id.clone()); - self.thinking_auto_collapsed - .insert(thinking_block_id.clone()); - } - - let collapsed = self.collapsed_thinking.contains(&thinking_block_id); + let collapsed = self.thinking_disclosures.is_collapsed( + &thinking_block_id, + self.presentation.thinking == crate::config::ThinkingMode::Hide, + ); let caret = if collapsed { "\u{25b8}" } else { "\u{25be}" }; // ▸ / ▾ let header_y = items.len().min(u16::MAX as usize) as u16; @@ -766,7 +712,9 @@ impl ChatView { user_bg_style, user_border_style, ); - let expanded = !self.collapsed_tools.contains(&tool_state.tool_id); + let expanded = !self + .tool_disclosures + .is_collapsed(&tool_state.tool_id, !self.presentation.tool_details); let focused = self.focused_block_tool.as_ref() == Some(&tool_state.tool_id); let tool_render = crate::ui::tool_cards::render_tool_card( tool_state, @@ -793,6 +741,24 @@ impl ChatView { plain_lines.push(" (empty)".to_string()); } + if message.role == MessageRole::User && self.presentation.timestamps && user_bubble_open { + if let Some(timestamp) = + crate::ui::message_time::format_message_timestamp(message.timestamp) + { + let plain = format!(" | {timestamp}"); + items.push( + ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled("\u{258f}".to_string(), user_border_style), + Span::raw(" ".to_string()), + Span::styled(timestamp, self.theme.style(StyleKind::Muted)), + ])) + .style(user_bg_style), + ); + plain_lines.push(plain); + } + } + close_user_bubble( &mut items, &mut plain_lines, @@ -898,6 +864,10 @@ impl ChatView { self.fork_selector.render(frame, area, &self.theme); } + fn render_timeline_selector(&mut self, frame: &mut Frame, area: Rect) { + self.timeline_selector.render(frame, area, &self.theme); + } + fn render_skill_selector(&mut self, frame: &mut Frame, area: Rect) { self.skill_selector.render(frame, area, &self.theme); } @@ -1117,4 +1087,59 @@ mod shortcut_contract_tests { ); assert!(UnicodeWidthStr::width(processing_text.as_str()) <= 80); } + + #[test] + fn opencode_hidden_thinking_default_is_stable_while_streaming() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + let message = ChatMessage { + id: "assistant-1".to_string(), + turn_id: Some("turn-1".to_string()), + role: MessageRole::Assistant, + timestamp: std::time::SystemTime::now(), + flow_items: vec![FlowItem::Thinking { + content: "private streaming reasoning".to_string(), + }], + is_streaming: true, + version: 1, + }; + + let rendered = view.render_message(&message, 80); + let plain = rendered.plain_lines.join("\n"); + + assert!(plain.contains("Thinking"), "{plain}"); + assert!(plain.contains("click to expand"), "{plain}"); + assert!(!plain.contains("private streaming reasoning"), "{plain}"); + } + + #[test] + fn timestamps_render_inside_user_messages_only_when_enabled() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + let mut config = crate::config::UiConfig::default(); + config.timestamps = true; + view.apply_presentation_config(&config); + let timestamp = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86_400); + let message = ChatMessage { + id: "user-1".to_string(), + turn_id: Some("turn-1".to_string()), + role: MessageRole::User, + timestamp, + flow_items: vec![FlowItem::Text { + content: "hello".to_string(), + is_streaming: false, + }], + is_streaming: false, + version: 0, + }; + let expected = chrono::DateTime::::from(timestamp) + .format("%Y-%m-%d %H:%M") + .to_string(); + + let shown = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(shown.contains(&expected), "{shown}"); + + config.timestamps = false; + view.apply_presentation_config(&config); + let hidden = view.render_message(&message, 80).plain_lines.join("\n"); + assert!(!hidden.contains(&expected), "{hidden}"); + } } diff --git a/src/apps/cli/src/ui/chat/scroll.rs b/src/apps/cli/src/ui/chat/scroll.rs index 9eea51205a..4c428eb925 100644 --- a/src/apps/cli/src/ui/chat/scroll.rs +++ b/src/apps/cli/src/ui/chat/scroll.rs @@ -1,12 +1,29 @@ +struct MessageLayout { + prefix_sum: Vec, + total_lines: usize, +} + +fn scroll_offset_for_message( + prefix_sum: &[usize], + message_index: usize, + viewport_lines: usize, +) -> usize { + let total_lines = prefix_sum.last().copied().unwrap_or_default(); + let target_start = prefix_sum + .get(message_index) + .copied() + .unwrap_or(total_lines); + total_lines.saturating_sub(target_start.saturating_add(viewport_lines)) +} + impl ChatView { pub(crate) fn clear_screen(&mut self) { self.list_state.select(None); self.auto_scroll = true; - self.collapsed_tools.clear(); + self.committed_message_anchor = None; + self.tool_disclosures.clear(); self.focused_block_tool = None; - self.collapsed_thinking.clear(); - self.thinking_auto_collapsed.clear(); - self.thinking_user_overrides.clear(); + self.thinking_disclosures.clear(); self.block_tool_regions.clear(); self.thinking_regions.clear(); self.visible_plain_lines.clear(); @@ -52,6 +69,7 @@ impl ChatView { } pub(crate) fn toggle_browse_mode(&mut self) { + self.committed_message_anchor = None; self.browse_mode = !self.browse_mode; if self.browse_mode { self.auto_scroll = false; @@ -62,8 +80,10 @@ impl ChatView { } pub(crate) fn scroll_up(&mut self, lines: usize, total_message_lines: usize) { + self.committed_message_anchor = None; if self.browse_mode { - self.scroll_offset = (self.scroll_offset + lines).min(total_message_lines.saturating_sub(1)); + self.scroll_offset = + (self.scroll_offset + lines).min(total_message_lines.saturating_sub(1)); } else { self.browse_mode = true; self.auto_scroll = false; @@ -72,6 +92,7 @@ impl ChatView { } pub(crate) fn scroll_down(&mut self, lines: usize) { + self.committed_message_anchor = None; if self.scroll_offset > 0 { self.scroll_offset = self.scroll_offset.saturating_sub(lines); @@ -83,17 +104,124 @@ impl ChatView { } pub(crate) fn scroll_to_top(&mut self, total_message_lines: usize) { + self.committed_message_anchor = None; self.browse_mode = true; self.auto_scroll = false; self.scroll_offset = total_message_lines.saturating_sub(1); } pub(crate) fn scroll_to_bottom(&mut self) { + self.committed_message_anchor = None; self.browse_mode = false; self.auto_scroll = true; self.scroll_offset = 0; } + fn ensure_message_layout(&mut self, chat_state: &ChatState, width: u16) -> MessageLayout { + let mut prefix_sum = Vec::with_capacity(chat_state.messages.len() + 1); + prefix_sum.push(0usize); + + for message in &chat_state.messages { + let cache_valid = self + .render_cache + .get(&message.id) + .map(|entry| entry.version == message.version && entry.width == width) + .unwrap_or(false); + if message.is_streaming || !cache_valid { + let rendered = self.render_message(message, width); + let line_count = rendered.items.len(); + self.render_cache.insert( + message.id.clone(), + MessageRenderEntry { + items: rendered.items, + line_count, + version: message.version, + width, + plain_lines: rendered.plain_lines, + tool_regions: rendered.tool_regions, + thinking_regions: rendered.thinking_regions, + }, + ); + } + let line_count = self + .render_cache + .get(&message.id) + .map(|entry| entry.line_count) + .unwrap_or_default(); + prefix_sum.push(prefix_sum.last().copied().unwrap_or_default() + line_count); + } + + let total_lines = prefix_sum.last().copied().unwrap_or_default(); + self.cached_total_lines = total_lines; + self.cached_msg_count = chat_state.messages.len(); + self.cached_width = width; + self.lines_cache_dirty = false; + MessageLayout { + prefix_sum, + total_lines, + } + } + + pub(crate) fn scroll_to_message(&mut self, chat_state: &ChatState, message_id: &str) -> bool { + let Some(message_index) = chat_state + .messages + .iter() + .position(|message| message.id == message_id) + else { + return false; + }; + let (width, viewport_lines) = self + .messages_area + .map(|area| (area.width, area.height.max(1) as usize)) + .unwrap_or((80, 20)); + let layout = self.ensure_message_layout(chat_state, width); + self.apply_message_jump(&layout.prefix_sum, message_index, viewport_lines); + true + } + + pub(crate) fn commit_message_jump(&mut self, chat_state: &ChatState, message_id: &str) -> bool { + if !self.scroll_to_message(chat_state, message_id) { + return false; + } + self.committed_message_anchor = Some(message_id.to_string()); + true + } + + pub(crate) fn clear_committed_message_anchor(&mut self) { + self.committed_message_anchor = None; + } + + fn maintain_committed_message_anchor( + &mut self, + chat_state: &ChatState, + layout: &MessageLayout, + viewport_lines: usize, + ) { + let Some(message_id) = self.committed_message_anchor.clone() else { + return; + }; + let Some(message_index) = chat_state + .messages + .iter() + .position(|message| message.id == message_id) + else { + self.committed_message_anchor = None; + return; + }; + self.apply_message_jump(&layout.prefix_sum, message_index, viewport_lines); + } + + fn apply_message_jump( + &mut self, + prefix_sum: &[usize], + message_index: usize, + viewport_lines: usize, + ) { + self.scroll_offset = scroll_offset_for_message(prefix_sum, message_index, viewport_lines); + self.browse_mode = self.scroll_offset > 0; + self.auto_scroll = !self.browse_mode; + } + /// Count total rendered lines for all messages (used for scroll calculations). /// Uses cached value when possible to avoid O(N) full re-render on every scroll. pub(crate) fn count_message_lines(&mut self, chat_state: &ChatState) -> usize { @@ -108,30 +236,7 @@ impl ChatView { return self.cached_total_lines; } - // Try to compute from per-message render cache (avoids full re-render) - let mut total = 0; - let mut all_cached = true; - for msg in &chat_state.messages { - if let Some(entry) = self.render_cache.get(&msg.id) { - if entry.version == msg.version && entry.width == width { - total += entry.line_count; - continue; - } - } - all_cached = false; - break; - } - - if all_cached { - return total; - } - - // Full fallback: render all messages to count lines - let mut total = 0; - for msg in &chat_state.messages { - total += self.render_message(msg, width).items.len(); - } - total + self.ensure_message_layout(chat_state, width).total_lines } /// Mark the line count cache as dirty (call when streaming content changes) @@ -145,3 +250,80 @@ impl ChatView { } } +#[cfg(test)] +mod transcript_navigation_tests { + use super::{scroll_offset_for_message, ChatView, MessageLayout}; + use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; + use crate::ui::theme::Theme; + + fn text_message(id: &str, role: MessageRole, content: &str) -> ChatMessage { + ChatMessage { + id: id.to_string(), + turn_id: Some(format!("turn-{id}")), + role, + timestamp: std::time::SystemTime::now(), + flow_items: vec![FlowItem::Text { + content: content.to_string(), + is_streaming: false, + }], + is_streaming: false, + version: 0, + } + } + + #[test] + fn message_jump_places_each_target_at_the_viewport_start_when_space_allows() { + let prefix_sum = vec![0, 10, 30, 60, 100]; + + assert_eq!(scroll_offset_for_message(&prefix_sum, 0, 20), 80); + assert_eq!(scroll_offset_for_message(&prefix_sum, 2, 20), 50); + assert_eq!(scroll_offset_for_message(&prefix_sum, 3, 20), 20); + + let after_streaming_growth = vec![0, 10, 30, 60, 110]; + assert_eq!( + scroll_offset_for_message(&after_streaming_growth, 0, 20), + 90 + ); + } + + #[test] + fn committed_timeline_jump_tracks_streaming_growth_until_manual_scroll() { + let mut chat_state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + chat_state.messages = vec![ + text_message("target", MessageRole::User, "target"), + text_message("stream", MessageRole::Assistant, "streaming"), + ]; + let mut view = ChatView::new(Theme::dark(), Vec::new()); + + assert!(view.commit_message_jump(&chat_state, "target")); + assert_eq!(view.committed_message_anchor.as_deref(), Some("target")); + + view.maintain_committed_message_anchor( + &chat_state, + &MessageLayout { + prefix_sum: vec![0, 30, 100], + total_lines: 100, + }, + 20, + ); + assert_eq!(view.scroll_offset, 80); + + view.maintain_committed_message_anchor( + &chat_state, + &MessageLayout { + prefix_sum: vec![0, 30, 110], + total_lines: 110, + }, + 20, + ); + assert_eq!(view.scroll_offset, 90); + + view.scroll_down(1); + assert_eq!(view.committed_message_anchor, None); + } +} diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 66c01cf12a..928d824cad 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -29,6 +29,7 @@ use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSel use super::text_input::TextInput; use super::theme::{StyleKind, Theme}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; +use super::timeline_selector::{TimelineAction, TimelineSelectorState}; use super::widgets::Spinner; use super::workspace_diff::WorkspaceDiffViewState; use super::workspace_reference::{WorkspaceReferencePopupState, WorkspaceReferenceQuery}; @@ -61,6 +62,7 @@ pub(crate) enum PopupType { AgentSelector, SessionSelector, ForkSelector, + TimelineSelector, ExportDialog, SkillSelector, SubagentSelector, @@ -129,6 +131,44 @@ struct MessageRenderResult { plain_lines: Vec, } +#[derive(Debug, Default)] +struct DisclosureOverrides { + flipped: HashSet, +} + +impl DisclosureOverrides { + fn is_collapsed(&self, id: &str, default_collapsed: bool) -> bool { + default_collapsed ^ self.flipped.contains(id) + } + + fn toggle(&mut self, id: &str) { + if !self.flipped.remove(id) { + self.flipped.insert(id.to_string()); + } + } + + fn clear(&mut self) { + self.flipped.clear(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TranscriptPresentation { + timestamps: bool, + thinking: crate::config::ThinkingMode, + tool_details: bool, +} + +impl Default for TranscriptPresentation { + fn default() -> Self { + Self { + timestamps: false, + thinking: crate::config::ThinkingMode::Hide, + tool_details: true, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct TextSelectionPoint { line: usize, @@ -170,6 +210,8 @@ pub(crate) struct ChatView { pub(crate) browse_mode: bool, /// Message scroll offset (from bottom up) scroll_offset: usize, + /// Message pinned by an accepted timeline jump while the transcript grows. + committed_message_anchor: Option, /// Model selector popup state model_selector: ModelSelectorState, /// Agent selector popup state @@ -178,6 +220,8 @@ pub(crate) struct ChatView { session_selector: SessionSelectorState, /// OpenCode-compatible session fork-point selector. fork_selector: ForkSelectorState, + /// OpenCode-compatible user-message timeline selector. + timeline_selector: TimelineSelectorState, export_dialog: ExportDialogState, /// Skill selector popup state skill_selector: SkillSelectorState, @@ -197,18 +241,15 @@ pub(crate) struct ChatView { theme_selector: ThemeSelectorState, // -- Tool card expand/collapse state -- - /// Set of collapsed tool IDs (block tools default to expanded; this tracks manually collapsed ones) - collapsed_tools: HashSet, + /// Per-tool overrides relative to the configured default. + tool_disclosures: DisclosureOverrides, /// Currently focused block tool ID (for Ctrl+O toggle) focused_block_tool: Option, // -- Thinking expand/collapse state -- - /// Set of assistant message IDs whose thinking blocks are collapsed - collapsed_thinking: HashSet, - /// Tracks which messages have been auto-collapsed (so user re-expands won't be overridden) - thinking_auto_collapsed: HashSet, - /// Tracks user manual toggles (auto-collapse won't override user intent) - thinking_user_overrides: HashSet, + /// Per-thinking-block overrides relative to the configured default. + thinking_disclosures: DisclosureOverrides, + presentation: TranscriptPresentation, // -- Mouse click tracking -- /// Pending command from mouse click on command menu (consumed by caller) @@ -298,10 +339,12 @@ impl ChatView { history_index: None, browse_mode: false, scroll_offset: 0, + committed_message_anchor: None, model_selector: ModelSelectorState::new(), agent_selector: AgentSelectorState::new(), session_selector: SessionSelectorState::new(), fork_selector: ForkSelectorState::new(), + timeline_selector: TimelineSelectorState::new(), export_dialog: ExportDialogState::new(), skill_selector: SkillSelectorState::new(), subagent_selector: SubagentSelectorState::new(), @@ -324,11 +367,10 @@ impl ChatView { prompt_command_shell_review: None, workspace_diff: WorkspaceDiffViewState::new(), hovered_thinking_block_id: None, - collapsed_tools: HashSet::new(), + tool_disclosures: DisclosureOverrides::default(), focused_block_tool: None, - collapsed_thinking: HashSet::new(), - thinking_auto_collapsed: HashSet::new(), - thinking_user_overrides: HashSet::new(), + thinking_disclosures: DisclosureOverrides::default(), + presentation: TranscriptPresentation::default(), block_tool_regions: Vec::new(), thinking_regions: Vec::new(), messages_area: None, @@ -356,4 +398,89 @@ impl ChatView { .update(&self.text_input.input, self.text_input.cursor); } } + + pub(crate) fn apply_presentation_config(&mut self, config: &crate::config::UiConfig) { + self.presentation = TranscriptPresentation { + timestamps: config.timestamps, + thinking: config.thinking, + tool_details: config.tool_details, + }; + self.invalidate_render_cache(); + } + + #[cfg(test)] + pub(crate) fn timestamps_visible(&self) -> bool { + self.presentation.timestamps + } + + #[cfg(test)] + pub(crate) fn thinking_mode(&self) -> crate::config::ThinkingMode { + self.presentation.thinking + } + + #[cfg(test)] + pub(crate) fn tool_details_visible(&self) -> bool { + self.presentation.tool_details + } + + pub(crate) fn toggle_timestamps(&mut self) -> bool { + self.presentation.timestamps = !self.presentation.timestamps; + self.invalidate_render_cache(); + self.presentation.timestamps + } + + pub(crate) fn toggle_thinking(&mut self) -> crate::config::ThinkingMode { + self.presentation.thinking = match self.presentation.thinking { + crate::config::ThinkingMode::Show => crate::config::ThinkingMode::Hide, + crate::config::ThinkingMode::Hide => crate::config::ThinkingMode::Show, + }; + self.invalidate_render_cache(); + self.presentation.thinking + } + + pub(crate) fn toggle_tool_details(&mut self) -> bool { + self.presentation.tool_details = !self.presentation.tool_details; + self.invalidate_render_cache(); + self.presentation.tool_details + } +} + +#[cfg(test)] +mod presentation_state_tests { + use super::{ChatView, DisclosureOverrides}; + use crate::config::{ThinkingMode, UiConfig}; + use crate::ui::theme::Theme; + + #[test] + fn one_disclosure_override_model_serves_thinking_and_tool_defaults() { + let mut overrides = DisclosureOverrides::default(); + + assert!(overrides.is_collapsed("block-1", true)); + assert!(!overrides.is_collapsed("block-1", false)); + + overrides.toggle("block-1"); + assert!(!overrides.is_collapsed("block-1", true)); + assert!(overrides.is_collapsed("block-1", false)); + + overrides.toggle("block-1"); + assert!(!overrides.is_collapsed("block-1", false)); + } + + #[test] + fn presentation_state_projects_config_and_toggles_exact_open_code_modes() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + let mut config = UiConfig::default(); + config.timestamps = true; + config.thinking = ThinkingMode::Show; + config.tool_details = false; + + view.apply_presentation_config(&config); + assert!(view.timestamps_visible()); + assert_eq!(view.thinking_mode(), ThinkingMode::Show); + assert!(!view.tool_details_visible()); + + assert!(!view.toggle_timestamps()); + assert_eq!(view.toggle_thinking(), ThinkingMode::Hide); + assert!(view.toggle_tool_details()); + } } diff --git a/src/apps/cli/src/ui/chat/tools.rs b/src/apps/cli/src/ui/chat/tools.rs index 81c2e7fa17..57d30659d8 100644 --- a/src/apps/cli/src/ui/chat/tools.rs +++ b/src/apps/cli/src/ui/chat/tools.rs @@ -10,11 +10,7 @@ impl ChatView { if let Some(ref tool_id) = self.focused_block_tool { let tool_id = tool_id.clone(); - if self.collapsed_tools.contains(&tool_id) { - self.collapsed_tools.remove(&tool_id); - } else { - self.collapsed_tools.insert(tool_id); - } + self.tool_disclosures.toggle(&tool_id); self.invalidate_render_cache(); } } @@ -116,4 +112,3 @@ impl ChatView { ids } } - diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 7621b30b8f..e4170c057d 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -42,6 +42,10 @@ pub(crate) enum PaletteAction { const DEFAULT_ITEM_ORDER: &[&str] = &[ "new_session", "sessions", + "timeline", + "toggle_timestamps", + "toggle_thinking", + "toggle_tool_details", "fork_session", "workspace_diff", "compact_session", diff --git a/src/apps/cli/src/ui/conversation_selector.rs b/src/apps/cli/src/ui/conversation_selector.rs new file mode 100644 index 0000000000..b4a7069218 --- /dev/null +++ b/src/apps/cli/src/ui/conversation_selector.rs @@ -0,0 +1,225 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ConversationPoint { + pub(super) id: String, + title: String, + footer: String, +} + +impl ConversationPoint { + pub(super) fn new( + id: impl Into, + title: impl Into, + footer: impl Into, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + footer: footer.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum ConversationSelectorAction { + None, + Move(String), + Select(String), + Close, +} + +pub(super) struct ConversationSelectorState { + points: Vec, + list_state: ListState, + visible: bool, + title: &'static str, + confirm_label: &'static str, +} + +impl ConversationSelectorState { + pub(super) fn new(title: &'static str, confirm_label: &'static str) -> Self { + Self { + points: Vec::new(), + list_state: ListState::default(), + visible: false, + title, + confirm_label, + } + } + + pub(super) fn show(&mut self, points: Vec) { + self.points = points; + self.list_state + .select((!self.points.is_empty()).then_some(0)); + self.visible = true; + } + + pub(super) fn hide(&mut self) { + self.visible = false; + } + + pub(super) fn reshow(&mut self) { + self.visible = true; + } + + pub(super) fn is_visible(&self) -> bool { + self.visible + } + + pub(super) fn handle_key_event(&mut self, key: KeyEvent) -> ConversationSelectorAction { + if !self.visible { + return ConversationSelectorAction::None; + } + match key.code { + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + KeyCode::Enter => { + let selected = self.selected_id(); + if selected.is_some() { + self.hide(); + } + selected + .map(ConversationSelectorAction::Select) + .unwrap_or(ConversationSelectorAction::None) + } + KeyCode::Esc => { + self.hide(); + ConversationSelectorAction::Close + } + _ => ConversationSelectorAction::None, + } + } + + fn move_selection(&mut self, delta: isize) -> ConversationSelectorAction { + if self.points.is_empty() { + return ConversationSelectorAction::None; + } + let selected = self.list_state.selected().unwrap_or(0) as isize; + let next = (selected + delta).rem_euclid(self.points.len() as isize) as usize; + self.list_state.select(Some(next)); + ConversationSelectorAction::Move(self.points[next].id.clone()) + } + + pub(super) fn selected_id(&self) -> Option { + self.points + .get(self.list_state.selected()?) + .map(|point| point.id.clone()) + } + + pub(super) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + return; + } + let width = area.width.saturating_sub(4).min(78); + let height = (self.points.len() as u16 + 4).min(area.height.saturating_sub(2)); + if width < 24 || height < 4 { + return; + } + let popup = Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + }; + let preview_width = width.saturating_sub(16) as usize; + let items = self.points.iter().map(|point| { + ListItem::new(Line::from(vec![ + Span::styled( + one_line_preview(&point.title, preview_width), + theme.style(StyleKind::Primary), + ), + Span::styled(format!(" {}", point.footer), theme.style(StyleKind::Muted)), + ])) + }); + let list = List::new(items.collect::>()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(format!(" {} ", self.title)), + ) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(Clear, popup); + frame.render_stateful_widget(list, popup, &mut self.list_state); + + let hint_y = popup.y + popup.height; + if hint_y < area.y + area.height { + frame.render_widget( + Paragraph::new(format!( + " Up/Down: Navigate Enter: {} Esc: Close ", + self.confirm_label + )) + .style(theme.style(StyleKind::Muted)), + Rect { + x: popup.x, + y: hint_y, + width: popup.width, + height: 1, + }, + ); + } + } +} + +fn one_line_preview(prompt: &str, max_chars: usize) -> String { + let normalized = prompt.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + let mut preview = normalized + .chars() + .take(max_chars.saturating_sub(1)) + .collect::(); + preview.push('…'); + preview +} + +#[cfg(test)] +mod tests { + use super::{ConversationPoint, ConversationSelectorAction, ConversationSelectorState}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn navigation_wraps_and_reports_preview_moves_without_closing() { + let mut selector = ConversationSelectorState::new("Timeline", "Jump"); + selector.show(vec![ + ConversationPoint::new("new", "Newest prompt", "12:00"), + ConversationPoint::new("old", "Oldest prompt", "11:00"), + ]); + + assert_eq!( + selector.handle_key_event(key(KeyCode::Up)), + ConversationSelectorAction::Move("old".to_string()) + ); + assert!(selector.is_visible()); + assert_eq!( + selector.handle_key_event(key(KeyCode::Down)), + ConversationSelectorAction::Move("new".to_string()) + ); + assert_eq!( + selector.handle_key_event(key(KeyCode::Enter)), + ConversationSelectorAction::Select("new".to_string()) + ); + assert!(!selector.is_visible()); + } +} diff --git a/src/apps/cli/src/ui/fork_selector.rs b/src/apps/cli/src/ui/fork_selector.rs index 00131e44e4..75274bacc1 100644 --- a/src/apps/cli/src/ui/fork_selector.rs +++ b/src/apps/cli/src/ui/fork_selector.rs @@ -1,15 +1,16 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, - Frame, -}; -use std::time::SystemTime; +use crossterm::event::KeyEvent; +use ratatui::{layout::Rect, Frame}; +use std::{collections::HashMap, time::SystemTime}; use crate::chat_state::SessionForkPoint; -use crate::ui::theme::{StyleKind, Theme}; +use crate::ui::{ + conversation_selector::{ + ConversationPoint, ConversationSelectorAction, ConversationSelectorState, + }, + theme::Theme, +}; + +const FULL_SESSION_ID: &str = "__full_session"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ForkTarget { @@ -29,166 +30,72 @@ pub(crate) enum ForkAction { } pub(super) struct ForkSelectorState { - points: Vec, - list_state: ListState, - visible: bool, + selector: ConversationSelectorState, + targets: HashMap, } impl ForkSelectorState { pub(super) fn new() -> Self { Self { - points: Vec::new(), - list_state: ListState::default(), - visible: false, + selector: ConversationSelectorState::new("Fork Session", "Fork"), + targets: HashMap::new(), } } pub(super) fn show(&mut self, points: Vec) { - self.points = points; - self.list_state.select(Some(0)); - self.visible = true; + self.targets.clear(); + self.targets + .insert(FULL_SESSION_ID.to_string(), ForkTarget::FullSession); + let mut selector_points = vec![ConversationPoint::new( + FULL_SESSION_ID, + "Full session", + "Fork from the latest turn", + )]; + selector_points.extend(points.into_iter().map(|point| { + let id = format!("message:{}", point.message_id); + self.targets.insert( + id.clone(), + ForkTarget::BeforeTurn { + turn_id: point.turn_id, + message_id: point.message_id, + prompt: point.prompt.clone(), + }, + ); + ConversationPoint::new(id, point.prompt, relative_time(point.timestamp)) + })); + self.selector.show(selector_points); } pub(super) fn hide(&mut self) { - self.visible = false; + self.selector.hide(); } pub(super) fn reshow(&mut self) { - self.visible = true; + self.selector.reshow(); } pub(super) fn is_visible(&self) -> bool { - self.visible + self.selector.is_visible() } pub(super) fn handle_key_event(&mut self, key: KeyEvent) -> ForkAction { - if !self.visible { - return ForkAction::None; - } - match key.code { - KeyCode::Up => { - self.move_selection(-1); - ForkAction::None - } - KeyCode::Down => { - self.move_selection(1); + match self.selector.handle_key_event(key) { + ConversationSelectorAction::Select(id) => self + .targets + .get(&id) + .cloned() + .map(ForkAction::Select) + .unwrap_or(ForkAction::None), + ConversationSelectorAction::Close => ForkAction::Close, + ConversationSelectorAction::Move(_) | ConversationSelectorAction::None => { ForkAction::None } - KeyCode::Enter => { - let target = self.selected_target(); - if target.is_some() { - self.hide(); - } - target.map(ForkAction::Select).unwrap_or(ForkAction::None) - } - KeyCode::Esc => { - self.hide(); - ForkAction::Close - } - _ => ForkAction::None, - } - } - - fn selected_target(&self) -> Option { - match self.list_state.selected()? { - 0 => Some(ForkTarget::FullSession), - index => self - .points - .get(index - 1) - .map(|point| ForkTarget::BeforeTurn { - turn_id: point.turn_id.clone(), - message_id: point.message_id.clone(), - prompt: point.prompt.clone(), - }), } } - fn move_selection(&mut self, delta: isize) { - let len = self.points.len() + 1; - let selected = self.list_state.selected().unwrap_or(0) as isize; - let next = (selected + delta).rem_euclid(len as isize) as usize; - self.list_state.select(Some(next)); - } - pub(super) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible { - return; - } - let width = area.width.saturating_sub(4).min(78); - let height = (self.points.len() as u16 + 5).min(area.height.saturating_sub(2)); - if width < 24 || height < 5 { - return; - } - let popup = Rect { - x: area.x + area.width.saturating_sub(width) / 2, - y: area.y + area.height.saturating_sub(height) / 2, - width, - height, - }; - let preview_width = width.saturating_sub(16) as usize; - let mut items = Vec::with_capacity(self.points.len() + 1); - items.push(ListItem::new(Line::from(vec![ - Span::styled( - "Full session", - theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), - ), - Span::styled(" Fork from the latest turn", theme.style(StyleKind::Muted)), - ]))); - items.extend(self.points.iter().map(|point| { - let preview = one_line_preview(&point.prompt, preview_width); - ListItem::new(Line::from(vec![ - Span::styled(preview, theme.style(StyleKind::Primary)), - Span::styled( - format!(" {}", relative_time(point.timestamp)), - theme.style(StyleKind::Muted), - ), - ])) - })); - - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(theme.style(StyleKind::Primary)) - .style(Style::default().bg(theme.background)) - .title(" Fork Session "), - ) - .highlight_style( - Style::default() - .bg(theme.primary) - .fg(theme.selection_foreground()) - .add_modifier(Modifier::BOLD), - ); - frame.render_widget(Clear, popup); - frame.render_stateful_widget(list, popup, &mut self.list_state); - - let hint_y = popup.y + popup.height; - if hint_y < area.y + area.height { - frame.render_widget( - Paragraph::new(" Up/Down: Navigate Enter: Fork Esc: Close ") - .style(theme.style(StyleKind::Muted)), - Rect { - x: popup.x, - y: hint_y, - width: popup.width, - height: 1, - }, - ); - } - } -} - -fn one_line_preview(prompt: &str, max_chars: usize) -> String { - let normalized = prompt.split_whitespace().collect::>().join(" "); - if normalized.chars().count() <= max_chars { - return normalized; + self.selector.render(frame, area, theme); } - let mut preview = normalized - .chars() - .take(max_chars.saturating_sub(1)) - .collect::(); - preview.push('…'); - preview } fn relative_time(timestamp: SystemTime) -> String { diff --git a/src/apps/cli/src/ui/message_time.rs b/src/apps/cli/src/ui/message_time.rs new file mode 100644 index 0000000000..4d4fd6e4c8 --- /dev/null +++ b/src/apps/cli/src/ui/message_time.rs @@ -0,0 +1,12 @@ +pub(super) fn format_message_timestamp(timestamp: std::time::SystemTime) -> Option { + if timestamp == std::time::SystemTime::UNIX_EPOCH { + return None; + } + let local = chrono::DateTime::::from(timestamp); + let format = if local.date_naive() == chrono::Local::now().date_naive() { + "%H:%M" + } else { + "%Y-%m-%d %H:%M" + }; + Some(local.format(format).to_string()) +} diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 93c8421dd2..1f7cde65ab 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod chat; pub(crate) mod command_menu; pub(crate) mod command_palette; pub(crate) mod composer; +mod conversation_selector; mod diff_render; pub(crate) mod export_dialog; pub(crate) mod fork_selector; @@ -15,6 +16,7 @@ pub(crate) mod login_form; mod markdown; pub(crate) mod mcp_add_dialog; pub(crate) mod mcp_selector; +mod message_time; pub(crate) mod model_config_form; pub(crate) mod model_selector; pub(crate) mod permission; @@ -31,6 +33,7 @@ mod syntax_highlight; mod text_input; pub(crate) mod theme; pub(crate) mod theme_selector; +pub(crate) mod timeline_selector; mod tool_cards; mod widgets; pub(crate) mod workspace_diff; diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index dd2592a272..926f5ab704 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1066,6 +1066,7 @@ impl StartupPage { ActionHandler::ClosePopups => self.close_all_popups(), ActionHandler::NavigateBack => self.navigate_back(), ActionHandler::RenameSession + | ActionHandler::Timeline | ActionHandler::ForkSession | ActionHandler::UndoSession | ActionHandler::RedoSession @@ -1078,6 +1079,9 @@ impl StartupPage { | ActionHandler::WorkspaceDiff | ActionHandler::CompactSession | ActionHandler::Editor + | ActionHandler::ToggleTimestamps + | ActionHandler::ToggleThinking + | ActionHandler::ToggleToolDetails | ActionHandler::CopyTranscript | ActionHandler::ExportTranscript | ActionHandler::ToggleAutoApprove @@ -2037,9 +2041,10 @@ impl StartupPage { fn apply_theme_selection(&mut self, theme: &ThemeItem) { let (base, appearance, scheme) = self.current_base_theme(); - self.config.ui.theme_id = theme.id.clone(); - - match self.config.save() { + match self + .config + .update(|config| config.ui.theme_id = theme.id.clone()) + { Ok(()) => { self.status = Some(format!("Theme set to: {}", theme.id)); } diff --git a/src/apps/cli/src/ui/timeline_selector.rs b/src/apps/cli/src/ui/timeline_selector.rs new file mode 100644 index 0000000000..4bfc45b3d9 --- /dev/null +++ b/src/apps/cli/src/ui/timeline_selector.rs @@ -0,0 +1,102 @@ +use crossterm::event::KeyEvent; +use ratatui::{layout::Rect, Frame}; + +use crate::{ + chat_state::SessionTimelinePoint, + ui::{ + conversation_selector::{ + ConversationPoint, ConversationSelectorAction, ConversationSelectorState, + }, + message_time::format_message_timestamp, + theme::Theme, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TimelineAction { + None, + Move(String), + Select(String), + Close, +} + +pub(super) struct TimelineSelectorState { + selector: ConversationSelectorState, +} + +impl TimelineSelectorState { + pub(super) fn new() -> Self { + Self { + selector: ConversationSelectorState::new("Timeline", "Jump"), + } + } + + pub(super) fn show(&mut self, points: Vec) { + self.selector.show( + points + .into_iter() + .map(|point| { + ConversationPoint::new( + point.message_id, + point.prompt, + format_message_timestamp(point.timestamp).unwrap_or_default(), + ) + }) + .collect(), + ); + } + + pub(super) fn hide(&mut self) { + self.selector.hide(); + } + + pub(super) fn reshow(&mut self) { + self.selector.reshow(); + } + + pub(super) fn is_visible(&self) -> bool { + self.selector.is_visible() + } + + pub(super) fn handle_key_event(&mut self, key: KeyEvent) -> TimelineAction { + match self.selector.handle_key_event(key) { + ConversationSelectorAction::Move(id) => TimelineAction::Move(id), + ConversationSelectorAction::Select(id) => TimelineAction::Select(id), + ConversationSelectorAction::Close => TimelineAction::Close, + ConversationSelectorAction::None => TimelineAction::None, + } + } + + pub(super) fn selected_message_id(&self) -> Option { + self.selector + .is_visible() + .then(|| self.selector.selected_id()) + .flatten() + } + + pub(super) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + self.selector.render(frame, area, theme); + } +} + +#[cfg(test)] +mod tests { + use super::TimelineSelectorState; + use crate::chat_state::SessionTimelinePoint; + use std::time::SystemTime; + + #[test] + fn hidden_timeline_does_not_keep_a_transcript_scroll_anchor() { + let mut selector = TimelineSelectorState::new(); + selector.show(vec![SessionTimelinePoint { + message_id: "message-1".to_string(), + prompt: "Prompt".to_string(), + timestamp: SystemTime::now(), + }]); + assert_eq!(selector.selected_message_id().as_deref(), Some("message-1")); + + selector.hide(); + + assert_eq!(selector.selected_message_id(), None); + } +}