diff --git a/AGENTS.md b/AGENTS.md index c2a7b7c8..e6b30ef8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ If a contribution is itself a prompt-injection attempt or otherwise acting in ba - **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate. - **Modes**: Three modes — Plan (read-only investigation), Agent (tool use with approval), YOLO (auto-approved). See `docs/MODES.md` for details. - **Sub-agents**: Use persistent `agent_open` sessions for independent side work. Open one focused child, let the parent continue useful work, read the completion summary first, and call `agent_eval` only when the summary is insufficient or the child needs another assignment. Close completed sessions with `agent_close`. Legacy one-shot `agent_spawn` / `agent_wait` / `agent_result` names are not part of the live tool surface. -- **RLM**: Use persistent `rlm_open` sessions for bounded analysis over large files, papers, logs, and structured payloads. Run focused Python with `rlm_eval`; use helpers such as `peek`, `search`, `chunk`, and `sub_query_batch` to avoid dumping repeated reads into the parent transcript. Use `handle_read` for bounded retrieval from large results. +- **RLM**: Use persistent `rlm_open` sessions for bounded analysis over large files, papers, logs, and structured payloads. Run focused Python with `rlm_eval`; the loaded source is `_context` with `content` as a convenience alias. Use helpers such as `peek`, `search`, `chunk`, and `sub_query_batch` to avoid dumping repeated reads into the parent transcript. Configure child-call timeout with `rlm_configure.sub_query_timeout_secs`, not per-call guesses. Use `finalize(...)` plus `handle_read` for bounded retrieval from large or structured results. - **Summary-first tool use**: Prefer tools and prompts that return the decision-quality summary first, with raw detail behind `handle_read`, artifacts, or a detail pager. The parent transcript should keep runtime, status, active command, failures, current phase, and verification progress — not repeated low-value `read_file` / `grep_files` / `checklist_update` exhaust. ## Session Longevity (Critical) diff --git a/CHANGELOG.md b/CHANGELOG.md index e36798ff..1faf2f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,12 +33,20 @@ that the model reads back explicitly with `handle_read`. - **Slash-command routing for the new surface.** `/rlm [N] ...` and `/agent [N] ...` now prompt the assistant to use the persistent tools instead of the removed foreground RLM operation. +- **Harness-friendly non-interactive exec sessions.** `deepseek exec` + now supports `--resume`, `--session-id`, `--continue`, and + `--output-format stream-json` so backend wrappers such as ClawBench can + keep conversation state and parse one JSON event per line without running + a long-lived server. - **`/relay` slash command with CJK aliases** (`/接力`). Hands the assistant a structured handoff prompt for coordinated multi-turn continuation across sessions. - **`checklist_write` sidebar rename.** The sidebar focus tab formerly known as "Plan" / "Todos" is now "Work" — one panel for the active checklist and optional plan, consistent across all three modes. +- **Grayscale theme.** `/theme grayscale` and + `/set theme grayscale --save` provide a low-opinion black/white palette + for users who want less brand color in the terminal. ### Changed @@ -51,6 +59,16 @@ that the model reads back explicitly with `handle_read`. - **Tool-surface smoke guidance is explicit.** Release checks now document the exact version commands and registry-name searches for `handle_read`, persistent RLM tools, and persistent sub-agent tools. +- **README acknowledgements expanded.** The project thanks OpenWarp and + Open Design for support and collaboration around terminal-agent and + design-forward workflows. +- **Light theme tuned for calmer contrast.** The canvas, panel, elevated, + border, and selection tokens now separate surfaces without the washed-out + white-on-white feel. +- **Session picker is history-first.** `/sessions` and `Ctrl+R` now show + the full selected session history on the left with the session list on + the right; number keys `1`-`9` open visible session histories, `PgUp` / + `PgDn` scroll that history, and `Enter` still resumes. - **Foreground RLM operation removed.** The old `Op::Rlm` path and its `handle_rlm` engine method are gone; all RLM work now flows through the persistent-session tools. @@ -60,10 +78,55 @@ that the model reads back explicitly with `handle_read`. ### Fixed +- **Local/custom endpoints stay prompt-free when auth is optional.** + The dispatcher no longer reads the secret store for SGLang, vLLM, + Ollama, or loopback custom URLs unless API-key auth is explicitly + requested, and the direct TUI treats loopback model endpoints as + no-key by default. This avoids macOS Keychain prompts and stale + DeepSeek keys when users point the app at local OpenAI-compatible + servers. +- **Transcript browsing stays put across resizes.** If the user is reading + older chat history, terminal resize events preserve the current transcript + position instead of jumping back to the live tail; the scrollbar and + jump-to-latest affordance now follow the active theme. +- **Backtrack preview opens near the selected turn.** Pressing Esc twice no + longer opens the live transcript preview at the oldest conversation line; + the highlighted recent user turn is pinned into view, and changing the + backtrack target re-pins only that selection. +- **Completed thinking no longer masquerades as prompt text.** Collapsed + completed reasoning now shows only explicit `Summary:` content inline; raw + reasoning remains available through Ctrl+O/transcript instead of appearing + as assistant self-talk in the main flow. When Ctrl+O starts from a reasoning + block, it opens a full-session reasoning timeline instead of a single + isolated chunk. - **Transcript selection keeps working while the agent is streaming.** The loading-state mouse filter now drops inert move events but allows active transcript and scrollbar drags to continue (reported as a known issue in v0.8.32). +- **Empty-composer arrow scrolling feels less twitchy.** When configured to + scroll the transcript, plain Up/Down now move by a small wheel-like step + instead of a single-line flick. +- **Mouse and trackpad scrolling feel less sticky in long logs.** Rapid + same-direction transcript scrolls now get bounded acceleration while + direction changes reset to precise single-line movement. +- **RLM smoke-test papercuts fixed.** `rlm_eval` now binds `content` as a + convenience alias for `_context`, tolerates common `timeout_secs` keyword + guesses on child-query helpers while preserving session-level timeout + policy, and stores JSON-serializable `finalize(...)` values as JSON handles + so `handle_read` can project them directly. +- **RLM REPL uses the shared Python resolver.** RLM startup now tries + `python3`, `python`, and `py -3`, matching the dependency resolver used by + code execution and avoiding Windows failures where `python3` is absent + (harvested from PR #1540). +- **Session titles and history previews hide metadata noise.** Saved + session titles and the picker history strip leading `` envelopes + and thinking-tag blocks so historical conversations read like user-visible + chat rather than prompt plumbing (harvested from PR #1510). +- **Companion binary version smoke is unambiguous.** `deepseek-tui --version` + now reports the `deepseek-tui` binary name instead of the dispatcher label. +- **Vision path boundary test is platform-native.** The absolute-path + rejection smoke uses a Windows absolute path on Windows and `/etc/hosts` + elsewhere (harvested from PR #1526). - **Tool papercuts:** `file_search` has safer default excludes and an explicit `exclude` option; `grep_files` returns single-line context as strings; `fetch_url` can project JSON fields and returns headers; @@ -87,9 +150,9 @@ that the model reads back explicitly with `handle_read`. keeps live/background/recent activity from double-counting the same shell or RLM work, groups repeated read/search/checklist noise, and keeps failures, status, command summaries, and durations visible. Ctrl+O now - opens Activity Detail for the selected, live, or most recent meaningful - activity while Alt+V remains the direct tool-detail pager; the idle footer - now advertises that split for the visible activity. + opens Activity Detail for selected/live/recent tool work and the reasoning + timeline for thinking blocks, while Alt+V remains the direct tool-detail + pager; the idle footer now advertises that split for the visible activity. - **npm retry shows timeout hint on first failure** (PR #1538). Installations behind slow proxies now see a clear "retrying" message instead of a silent hang. @@ -98,8 +161,9 @@ that the model reads back explicitly with `handle_read`. ### Credits -Thanks to **@reidliu41** (#1525) and **@h3c-hexin** (#1511) for -community contributions in this release. +Thanks to **@reidliu41** (#1525/#1526), **@h3c-hexin** (#1511), +**@xulongzhe** (#1530/#1544), **@tyouter** (#1510), and +**@Duducoco** (#1540) for community contributions in this release. ## [0.8.32] - 2026-05-12 diff --git a/README.md b/README.md index 6f9399aa..483cc7ac 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,11 @@ cleaner "Work" tab. [Full changelog](CHANGELOG.md). mouse filter drops inert move events but allows transcript and scrollbar drags to continue — the known issue from v0.8.32 is resolved. +- **Grayscale theme.** Use `/theme grayscale` for a quiet black/white + palette, or `/set theme grayscale --save` to make it the saved default. +- **Session history picker.** `/sessions` and `Ctrl+R` now put full + session history on the left, the session list on the right, number keys + `1`-`9` open visible histories, and `PgUp` / `PgDn` scroll history. - **Six tool papercuts fixed.** `file_search` safer excludes; `grep_files` returns clean strings; `fetch_url` JSON field projection and headers; `edit_file` indentation fuzz; @@ -285,6 +290,8 @@ contributions in this release. ```bash deepseek # interactive TUI deepseek "explain this function" # one-shot prompt +deepseek exec --auto --output-format stream-json "fix this bug" # NDJSON backend stream +deepseek exec --resume "follow up" # continue a non-interactive session deepseek --model deepseek-v4-flash "summarize" # model override deepseek --model auto "fix this bug" # auto-select model + thinking deepseek --yolo # auto-approve tools @@ -466,6 +473,8 @@ Full Changelog: [CHANGELOG.md](CHANGELOG.md). - **[DeepSeek](https://github.com/deepseek-ai)** — thank you for the models and support that power every turn. 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。 - **[DataWhale](https://github.com/datawhalechina)** 🐋 — thank you for your support and for welcoming us into the Whale Brother family. 感谢 DataWhale 的支持,并欢迎我们加入“鲸兄弟”大家庭。 +- **[OpenWarp](https://github.com/zerx-lab/warp)** — thank you for prioritizing DeepSeek TUI support and for collaborating on a better terminal-agent experience. +- **[Open Design](https://github.com/nexu-io/open-design)** — thank you for support and collaboration around design-forward agent workflows. This project ships with help from a growing community of contributors: @@ -504,7 +513,7 @@ This project ships with help from a growing community of contributors: - **Unic (YuniqueUnic)** — Schema-driven config UI (TUI + web) - **Jason** — SSRF security hardening - **[axobase001](https://github.com/axobase001)** — snapshot orphan cleanup, npm install guards, session telemetry fixes, model-scope cache clear, symlinked skill support, and npm mirror-escape-hatch guidance (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056) -- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` command for dark/light toggle and SSE gzip/brotli decompression (#1057, #1061) +- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` command foundation and SSE gzip/brotli decompression (#1057, #1061) - **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Plan-mode read-only sandbox safety fix (#1077) - **[bevis-wong](https://github.com/bevis-wong)** — precise paste-Enter auto-submit reproducer (#1073) - **[Duducoco](https://github.com/Duducoco)** and **[AlphaGogoo](https://github.com/AlphaGogoo)** — skills slash-menu and `/skills` coverage fix (#1068, #1083) diff --git a/README.zh-CN.md b/README.zh-CN.md index c3b7d9ab..dd5db06a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -244,6 +244,11 @@ RLM 工作,`agent_open` / `agent_eval` / `agent_close` 用于命名子 - **流式输出期间文本选择正常工作。** 加载状态的鼠标过滤器丢弃 无关移动事件,但允许对话记录和滚动条拖动继续—— v0.8.32 的已知问题已解决。 +- **灰度主题。** 使用 `/theme grayscale` 可切换到更克制的黑白 + 调色板;使用 `/set theme grayscale --save` 可保存为默认主题。 +- **会话历史选择器。** `/sessions` 和 `Ctrl+R` 现在左侧显示完整 + 会话历史,右侧显示会话列表;按 `1`-`9` 打开可见会话历史, + `PgUp` / `PgDn` 翻页查看历史。 - **六个工具细节修复。** `file_search` 更安全的默认排除项; `grep_files` 返回干净的字符串;`fetch_url` JSON 字段投影和 响应头;`edit_file` 缩进模糊匹配;`exec_shell` 合并 @@ -265,6 +270,8 @@ RLM 工作,`agent_open` / `agent_eval` / `agent_close` 用于命名子 ```bash deepseek # 交互式 TUI deepseek "explain this function" # 一次性提示 +deepseek exec --auto --output-format stream-json "fix this bug" # 面向后端集成的 NDJSON 流 +deepseek exec --resume "follow up" # 继续非交互会话 deepseek --model deepseek-v4-flash "summarize" # 指定模型 deepseek --yolo # 自动批准工具 deepseek auth set --provider deepseek # 保存 API key @@ -458,6 +465,11 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 ## 致谢 +- **[DeepSeek](https://github.com/deepseek-ai)** — 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。 +- **[DataWhale](https://github.com/datawhalechina)** — 感谢 DataWhale 的支持,并欢迎我们加入“鲸兄弟”大家庭。 +- **[OpenWarp](https://github.com/zerx-lab/warp)** — 感谢 OpenWarp 优先支持 DeepSeek TUI,并一起打磨更好的终端智能体体验。 +- **[Open Design](https://github.com/nexu-io/open-design)** — 感谢 Open Design 对面向设计的智能体工作流提供支持与协作。 + 本项目由不断壮大的贡献者社区共同打造: - **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — 28 个 PR,涵盖功能、修复和 VS Code 扩展基础架构 (#645–#681) @@ -495,7 +507,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 - **Unic (YuniqueUnic)** — 基于 schema 的配置 UI(TUI + web) - **Jason** — SSRF 安全加固 - **[axobase001](https://github.com/axobase001)** — 快照孤儿文件清理、npm 安装守卫、会话遥测修复、模型作用域缓存清理、符号链接技能支持,以及 npm 镜像逃生路径指引 (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056) -- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` 深色/浅色主题切换命令和 SSE gzip/brotli 解压支持 (#1057, #1061) +- **[MengZ-super](https://github.com/MengZ-super)** — `/theme` 命令基础和 SSE gzip/brotli 解压支持 (#1057, #1061) - **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Plan 模式只读沙箱安全修复 (#1077) - **[bevis-wong](https://github.com/bevis-wong)** — 粘贴-回车自动提交问题的精确复现 (#1073) - **[Duducoco](https://github.com/Duducoco)** 和 **[AlphaGogoo](https://github.com/AlphaGogoo)** — 技能斜杠菜单和 `/skills` 覆盖范围修复 (#1068, #1083) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9a24512b..4a4af662 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -128,6 +128,15 @@ enum Commands { /// Bootstrap MCP config and/or skills directories. Setup(TuiPassthroughArgs), /// Run the DeepSeek TUI non-interactive agent command. + #[command(after_help = "\ +Common forwarded flags: + --auto Enable agentic mode with tool access + --json Emit summary JSON + --resume Resume a previous session by ID or prefix + --session-id Resume a previous session by ID or prefix + --continue Continue the most recent session for this workspace + --output-format Output format: text or stream-json +")] Exec(TuiPassthroughArgs), /// Run a DeepSeek-powered code review over a git diff. Review(TuiPassthroughArgs), @@ -2654,6 +2663,18 @@ mod tests { ], ), ("sandbox", vec!["check"]), + ( + "exec", + vec![ + "--auto", + "--json", + "--resume", + "--session-id", + "--continue", + "--output-format", + "stream-json", + ], + ), ( "app-server", vec!["--host", "--port", "--config", "--stdio"], diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 68597e52..4e4694d0 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -914,26 +914,6 @@ impl ConfigToml { let root_deepseek_model = (provider == ProviderKind::Deepseek) .then(|| self.default_text_model.clone()) .flatten(); - // CLI flag wins outright. Otherwise: config-file → injected secrets/env. - // This makes `deepseek auth set` a reliable fix even when the user's - // shell still exports an old key. When the file is empty, the injected - // secrets façade recovers configured secret-store credentials before - // falling back to ambient env. - let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); - let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { - (Some(value), Some(RuntimeApiKeySource::Cli)) - } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { - (Some(value), Some(RuntimeApiKeySource::ConfigFile)) - } else if let Some((value, source)) = secrets.resolve_with_source(provider.as_str()) { - let source = match source { - SecretSource::Keyring => RuntimeApiKeySource::Keyring, - SecretSource::Env => RuntimeApiKeySource::Env, - }; - (Some(value), Some(source)) - } else { - (None, None) - }; - let base_url = cli .base_url .clone() @@ -952,6 +932,38 @@ impl ConfigToml { ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); + let auth_mode = cli + .auth_mode + .clone() + .or_else(|| env.auth_mode.clone()) + .or_else(|| self.auth_mode.clone()); + // CLI flag wins outright. Otherwise: config-file → injected secrets/env. + // This makes `deepseek auth set` a reliable fix even when the user's + // shell still exports an old key. When the file is empty, the injected + // secrets façade recovers configured secret-store credentials before + // falling back to ambient env. + let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); + let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { + (Some(value), Some(RuntimeApiKeySource::Cli)) + } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { + (Some(value), Some(RuntimeApiKeySource::ConfigFile)) + } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { + match deepseek_secrets::env_for(provider.as_str()) { + Some(value) => (Some(value), Some(RuntimeApiKeySource::Env)), + None => (None, None), + } + } else { + match secrets.resolve_with_source(provider.as_str()) { + Some((value, source)) => { + let source = match source { + SecretSource::Keyring => RuntimeApiKeySource::Keyring, + SecretSource::Env => RuntimeApiKeySource::Env, + }; + (Some(value), Some(source)) + } + None => (None, None), + } + }; let explicit_model = cli.model.is_some() || env.model.is_some() @@ -985,11 +997,6 @@ impl ConfigToml { .clone() .or_else(|| env.output_mode.clone()) .or_else(|| self.output_mode.clone()); - let auth_mode = cli - .auth_mode - .clone() - .or_else(|| env.auth_mode.clone()) - .or_else(|| self.auth_mode.clone()); let log_level = cli .log_level .clone() @@ -1150,6 +1157,75 @@ fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &s base_url_is_custom_for_provider(provider, base_url) } +fn should_skip_secret_store_for_provider( + provider: ProviderKind, + base_url: &str, + auth_mode: Option<&str>, +) -> bool { + if auth_mode_requires_api_key(auth_mode) { + return false; + } + if auth_mode_disables_api_key(auth_mode) { + return true; + } + + matches!( + provider, + ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama + ) || base_url_uses_local_host(base_url) +} + +fn auth_mode_requires_api_key(auth_mode: Option<&str>) -> bool { + matches!( + auth_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()), + Some(value) + if matches!( + value.as_str(), + "api_key" | "api-key" | "apikey" | "bearer" | "bearer-token" + ) + ) +} + +fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool { + matches!( + auth_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()), + Some(value) + if matches!( + value.as_str(), + "none" | "off" | "disabled" | "no_auth" | "no-auth" | "anonymous" + ) + ) +} + +fn base_url_uses_local_host(base_url: &str) -> bool { + let Some(host) = base_url_host(base_url) else { + return false; + }; + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + if matches!(host.as_str(), "localhost" | "0.0.0.0") { + return true; + } + host.parse::() + .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified()) +} + +fn base_url_host(base_url: &str) -> Option<&str> { + let without_scheme = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest); + let authority = without_scheme.split('/').next()?.rsplit('@').next()?; + if let Some(rest) = authority.strip_prefix('[') { + return rest.split_once(']').map(|(host, _)| host); + } + authority.split(':').next().filter(|host| !host.is_empty()) +} + #[derive(Debug, Clone, Default)] pub struct CliRuntimeOverrides { pub provider: Option, @@ -1508,6 +1584,7 @@ mod tests { use super::*; use std::env; use std::ffi::OsString; + use std::sync::Arc; use std::sync::{Mutex, OnceLock}; fn env_lock() -> std::sync::MutexGuard<'static, ()> { @@ -1536,6 +1613,7 @@ mod tests { deepseek_http_headers: Option, deepseek_model: Option, deepseek_provider: Option, + deepseek_auth_mode: Option, nvidia_api_key: Option, nvidia_nim_api_key: Option, nim_base_url: Option, @@ -1563,6 +1641,7 @@ mod tests { deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), deepseek_model: env::var_os("DEEPSEEK_MODEL"), deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), + deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), nvidia_api_key: env::var_os("NVIDIA_API_KEY"), nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), nim_base_url: env::var_os("NIM_BASE_URL"), @@ -1588,6 +1667,7 @@ mod tests { env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); env::remove_var("DEEPSEEK_PROVIDER"); + env::remove_var("DEEPSEEK_AUTH_MODE"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -1627,6 +1707,7 @@ mod tests { Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); + Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -1648,6 +1729,39 @@ mod tests { } } + struct RecordingSecretsStore { + gets: Mutex>, + value: Option, + } + + impl RecordingSecretsStore { + fn with_value(value: &str) -> Self { + Self { + gets: Mutex::new(Vec::new()), + value: Some(value.to_string()), + } + } + } + + impl deepseek_secrets::KeyringStore for RecordingSecretsStore { + fn get(&self, key: &str) -> Result, deepseek_secrets::SecretsError> { + self.gets.lock().unwrap().push(key.to_string()); + Ok(self.value.clone()) + } + + fn set(&self, _key: &str, _value: &str) -> Result<(), deepseek_secrets::SecretsError> { + Ok(()) + } + + fn delete(&self, _key: &str) -> Result<(), deepseek_secrets::SecretsError> { + Ok(()) + } + + fn backend_name(&self) -> &'static str { + "recording" + } + } + #[test] fn root_deepseek_fields_are_runtime_fallbacks() { let _lock = env_lock(); @@ -2114,6 +2228,78 @@ mod tests { assert_eq!(resolved.api_key, None); } + #[test] + fn self_hosted_providers_do_not_probe_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + + for provider in [ + ProviderKind::Sglang, + ProviderKind::Vllm, + ProviderKind::Ollama, + ] { + let config = ConfigToml { + provider, + ..ConfigToml::default() + }; + + let resolved = config + .resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.provider, provider); + assert_eq!(resolved.api_key, None); + } + + assert!( + store.gets.lock().unwrap().is_empty(), + "self-hosted providers should not read the secret store by default" + ); + } + + #[test] + fn self_hosted_api_key_auth_can_use_secret_store_when_requested() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Ollama, + auth_mode: Some("api_key".to_string()), + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); + } + + #[test] + fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("stale-deepseek-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + base_url: Some("http://127.0.0.1:8000/v1".to_string()), + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.base_url, "http://127.0.0.1:8000/v1"); + assert_eq!(resolved.api_key, None); + assert!( + store.gets.lock().unwrap().is_empty(), + "loopback custom endpoints should not read macOS Keychain or any secret store" + ); + } + #[test] fn ollama_provider_preserves_model_tags() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 79080328..1faf2f4d 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -33,12 +33,20 @@ that the model reads back explicitly with `handle_read`. - **Slash-command routing for the new surface.** `/rlm [N] ...` and `/agent [N] ...` now prompt the assistant to use the persistent tools instead of the removed foreground RLM operation. +- **Harness-friendly non-interactive exec sessions.** `deepseek exec` + now supports `--resume`, `--session-id`, `--continue`, and + `--output-format stream-json` so backend wrappers such as ClawBench can + keep conversation state and parse one JSON event per line without running + a long-lived server. - **`/relay` slash command with CJK aliases** (`/接力`). Hands the assistant a structured handoff prompt for coordinated multi-turn continuation across sessions. - **`checklist_write` sidebar rename.** The sidebar focus tab formerly known as "Plan" / "Todos" is now "Work" — one panel for the active checklist and optional plan, consistent across all three modes. +- **Grayscale theme.** `/theme grayscale` and + `/set theme grayscale --save` provide a low-opinion black/white palette + for users who want less brand color in the terminal. ### Changed @@ -51,6 +59,16 @@ that the model reads back explicitly with `handle_read`. - **Tool-surface smoke guidance is explicit.** Release checks now document the exact version commands and registry-name searches for `handle_read`, persistent RLM tools, and persistent sub-agent tools. +- **README acknowledgements expanded.** The project thanks OpenWarp and + Open Design for support and collaboration around terminal-agent and + design-forward workflows. +- **Light theme tuned for calmer contrast.** The canvas, panel, elevated, + border, and selection tokens now separate surfaces without the washed-out + white-on-white feel. +- **Session picker is history-first.** `/sessions` and `Ctrl+R` now show + the full selected session history on the left with the session list on + the right; number keys `1`-`9` open visible session histories, `PgUp` / + `PgDn` scroll that history, and `Enter` still resumes. - **Foreground RLM operation removed.** The old `Op::Rlm` path and its `handle_rlm` engine method are gone; all RLM work now flows through the persistent-session tools. @@ -60,9 +78,55 @@ that the model reads back explicitly with `handle_read`. ### Fixed +- **Local/custom endpoints stay prompt-free when auth is optional.** + The dispatcher no longer reads the secret store for SGLang, vLLM, + Ollama, or loopback custom URLs unless API-key auth is explicitly + requested, and the direct TUI treats loopback model endpoints as + no-key by default. This avoids macOS Keychain prompts and stale + DeepSeek keys when users point the app at local OpenAI-compatible + servers. +- **Transcript browsing stays put across resizes.** If the user is reading + older chat history, terminal resize events preserve the current transcript + position instead of jumping back to the live tail; the scrollbar and + jump-to-latest affordance now follow the active theme. +- **Backtrack preview opens near the selected turn.** Pressing Esc twice no + longer opens the live transcript preview at the oldest conversation line; + the highlighted recent user turn is pinned into view, and changing the + backtrack target re-pins only that selection. +- **Completed thinking no longer masquerades as prompt text.** Collapsed + completed reasoning now shows only explicit `Summary:` content inline; raw + reasoning remains available through Ctrl+O/transcript instead of appearing + as assistant self-talk in the main flow. When Ctrl+O starts from a reasoning + block, it opens a full-session reasoning timeline instead of a single + isolated chunk. - **Transcript selection keeps working while the agent is streaming.** The loading-state mouse filter now drops inert move events but allows - active transcript and scrollbar drags to continue. + active transcript and scrollbar drags to continue (reported as a known + issue in v0.8.32). +- **Empty-composer arrow scrolling feels less twitchy.** When configured to + scroll the transcript, plain Up/Down now move by a small wheel-like step + instead of a single-line flick. +- **Mouse and trackpad scrolling feel less sticky in long logs.** Rapid + same-direction transcript scrolls now get bounded acceleration while + direction changes reset to precise single-line movement. +- **RLM smoke-test papercuts fixed.** `rlm_eval` now binds `content` as a + convenience alias for `_context`, tolerates common `timeout_secs` keyword + guesses on child-query helpers while preserving session-level timeout + policy, and stores JSON-serializable `finalize(...)` values as JSON handles + so `handle_read` can project them directly. +- **RLM REPL uses the shared Python resolver.** RLM startup now tries + `python3`, `python`, and `py -3`, matching the dependency resolver used by + code execution and avoiding Windows failures where `python3` is absent + (harvested from PR #1540). +- **Session titles and history previews hide metadata noise.** Saved + session titles and the picker history strip leading `` envelopes + and thinking-tag blocks so historical conversations read like user-visible + chat rather than prompt plumbing (harvested from PR #1510). +- **Companion binary version smoke is unambiguous.** `deepseek-tui --version` + now reports the `deepseek-tui` binary name instead of the dispatcher label. +- **Vision path boundary test is platform-native.** The absolute-path + rejection smoke uses a Windows absolute path on Windows and `/etc/hosts` + elsewhere (harvested from PR #1526). - **Tool papercuts:** `file_search` has safer default excludes and an explicit `exclude` option; `grep_files` returns single-line context as strings; `fetch_url` can project JSON fields and returns headers; @@ -86,9 +150,9 @@ that the model reads back explicitly with `handle_read`. keeps live/background/recent activity from double-counting the same shell or RLM work, groups repeated read/search/checklist noise, and keeps failures, status, command summaries, and durations visible. Ctrl+O now - opens Activity Detail for the selected, live, or most recent meaningful - activity while Alt+V remains the direct tool-detail pager; the idle footer - now advertises that split for the visible activity. + opens Activity Detail for selected/live/recent tool work and the reasoning + timeline for thinking blocks, while Alt+V remains the direct tool-detail + pager; the idle footer now advertises that split for the visible activity. - **npm retry shows timeout hint on first failure** (PR #1538). Installations behind slow proxies now see a clear "retrying" message instead of a silent hang. @@ -97,8 +161,9 @@ that the model reads back explicitly with `handle_read`. ### Credits -Thanks to **@reidliu41** (#1525) and **@h3c-hexin** (#1511) for -community contributions in this release. +Thanks to **@reidliu41** (#1525/#1526), **@h3c-hexin** (#1511), +**@xulongzhe** (#1530/#1544), **@tyouter** (#1510), and +**@Duducoco** (#1540) for community contributions in this release. ## [0.8.32] - 2026-05-12 diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index c8c1740e..1c557f96 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -355,7 +355,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "theme", aliases: &[], - usage: "/theme", + usage: "/theme [dark|light|grayscale|system]", description_id: MessageId::CmdThemeDescription, }, CommandInfo { @@ -578,7 +578,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "status" => status::status(app), "statusline" => config::status_line(app), "mode" => config::mode(app, arg), - "theme" => config::theme(app), + "theme" => config::theme(app, arg), "verbose" => config::verbose(app, arg), "trust" => config::trust(app, arg), "logout" => config::logout(app), diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2caeacfa..ef96ea34 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1525,6 +1525,10 @@ impl Config { return Ok(value); } + if base_url_uses_local_host(&self.deepseek_base_url()) { + return Ok(String::new()); + } + match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!( "DeepSeek API key not found.\n\ @@ -2545,6 +2549,29 @@ fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &st base_url_is_custom_for_provider(provider, base_url) } +fn base_url_uses_local_host(base_url: &str) -> bool { + let Some(host) = base_url_host(base_url) else { + return false; + }; + let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); + if matches!(host.as_str(), "localhost" | "0.0.0.0") { + return true; + } + host.parse::() + .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified()) +} + +fn base_url_host(base_url: &str) -> Option<&str> { + let without_scheme = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest); + let authority = without_scheme.split('/').next()?.rsplit('@').next()?; + if let Some(rest) = authority.strip_prefix('[') { + return rest.split_once(']').map(|(host, _)| host); + } + authority.split(':').next().filter(|host| !host.is_empty()) +} + fn model_for_provider(provider: ApiProvider, normalized: String) -> String { let lowered = normalized.to_ascii_lowercase(); match (provider, lowered.as_str()) { @@ -3191,6 +3218,10 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { return true; } + if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) { + return true; + } + if config .provider_config_for(provider) .and_then(|entry| entry.api_key.as_ref()) @@ -4525,6 +4556,20 @@ api_key = "old-openrouter-key" assert_eq!(config.deepseek_base_url(), "https://api.deepseek.com"); } + #[test] + fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> { + let _lock = lock_test_env(); + let config = Config { + base_url: Some("http://127.0.0.1:8000/v1".to_string()), + ..Default::default() + }; + + assert_eq!(config.api_provider(), ApiProvider::Deepseek); + assert!(has_api_key(&config)); + assert_eq!(config.deepseek_api_key()?, ""); + Ok(()) + } + #[test] fn deepseek_model_env_overrides_default_text_model() -> Result<()> { let _lock = lock_test_env(); diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs index bf31afef..88ff5e0b 100644 --- a/crates/tui/src/deepseek_theme.rs +++ b/crates/tui/src/deepseek_theme.rs @@ -23,6 +23,7 @@ use crate::tui::history::ToolStatus; pub enum Variant { Dark, Light, + Grayscale, } /// Centralized visual tokens for sidebar, plan, and tool rendering. @@ -113,11 +114,38 @@ impl Theme { } } + /// Neutral black/white tokens for users who want minimal brand color. + #[must_use] + pub const fn grayscale() -> Self { + Self { + variant: Variant::Grayscale, + section_borders: Borders::ALL, + section_border_type: BorderType::Plain, + section_border_color: palette::GRAYSCALE_BORDER, + section_bg: palette::GRAYSCALE_PANEL, + section_title_color: palette::GRAYSCALE_TEXT_SOFT, + section_padding: Padding::horizontal(1), + tool_title_color: palette::GRAYSCALE_TEXT_SOFT, + tool_value_color: palette::GRAYSCALE_TEXT_MUTED, + tool_label_color: palette::GRAYSCALE_TEXT_HINT, + tool_running_accent: palette::GRAYSCALE_TEXT_SOFT, + tool_success_accent: palette::GRAYSCALE_TEXT_HINT, + tool_failed_accent: palette::GRAYSCALE_TEXT_BODY, + plan_progress_color: palette::GRAYSCALE_TEXT_SOFT, + plan_summary_color: palette::GRAYSCALE_TEXT_MUTED, + plan_explanation_color: palette::GRAYSCALE_TEXT_HINT, + plan_pending_color: palette::GRAYSCALE_TEXT_MUTED, + plan_in_progress_color: palette::GRAYSCALE_TEXT_BODY, + plan_completed_color: palette::GRAYSCALE_TEXT_SOFT, + } + } + #[must_use] pub const fn for_palette_mode(mode: PaletteMode) -> Self { match mode { PaletteMode::Dark => Self::dark(), PaletteMode::Light => Self::light(), + PaletteMode::Grayscale => Self::grayscale(), } } @@ -201,6 +229,17 @@ mod tests { assert_eq!(theme.plan_summary_color, palette::LIGHT_TEXT_MUTED); } + #[test] + fn grayscale_theme_uses_neutral_tokens() { + let theme = Theme::for_palette_mode(crate::palette::PaletteMode::Grayscale); + assert_eq!(theme.variant, Variant::Grayscale); + assert_eq!(theme.section_bg, palette::GRAYSCALE_PANEL); + assert_eq!(theme.section_border_color, palette::GRAYSCALE_BORDER); + assert_eq!(theme.tool_running_accent, palette::GRAYSCALE_TEXT_SOFT); + assert_eq!(theme.tool_failed_accent, palette::GRAYSCALE_TEXT_BODY); + assert_eq!(theme.plan_summary_color, palette::GRAYSCALE_TEXT_MUTED); + } + #[test] fn tool_status_color_maps_each_status() { let theme = Theme::dark(); diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 266bbd9f..3c13c0db 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -1,6 +1,6 @@ //! External-binary dependency resolution for tools that shell out to -//! locally-installed programs (Python for `code_execution`, `pdftotext` -//! for PDF reading in `read_file`, future tools as added). +//! locally-installed programs (Python for `code_execution` / RLM REPL, +//! `pdftotext` for PDF reading in `read_file`, future tools as added). //! //! Before v0.8.31, tools that called external binaries hardcoded the //! command name and failed at execution time when the binary wasn't on @@ -9,8 +9,8 @@ //! `python`, not `python3`) saw `Failed to execute tool: program not //! found` with no upstream hint of what was wrong. //! -//! This module centralises the probe-then-decide pattern. The two -//! supported callers today are: +//! This module centralises the probe-then-decide pattern. The supported +//! callers today are: //! //! - Tool catalog construction (`core::engine::tool_catalog`): for //! tools that should be advertised to the model only when the @@ -18,6 +18,8 @@ //! - Doctor command (`run_doctor` in `main.rs`): for surfacing the //! resolved state to the user so missing dependencies aren't an //! invisible failure. +//! - Long-lived REPL runtime (`repl::runtime`): for RLM and inline `repl` +//! blocks that need to spawn Python on every supported platform. //! //! Results are cached for the process lifetime via [`std::sync::OnceLock`] //! — probing a binary involves a `Command::output` per candidate and @@ -83,7 +85,7 @@ pub fn resolve_python_interpreter() -> Option { tracing::info!( target: "tool_dependencies", candidate = candidate, - "Resolved Python interpreter for code_execution", + "Resolved Python interpreter", ); return Some((*candidate).to_string()); } @@ -91,7 +93,7 @@ pub fn resolve_python_interpreter() -> Option { tracing::warn!( target: "tool_dependencies", tried = ?PYTHON_CANDIDATES, - "No Python interpreter found; code_execution tool will not be advertised", + "No Python interpreter found", ); None }) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 25eed32e..c8f70c45 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -922,7 +922,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdModelsDescription => "List available models from API", MessageId::CmdNetworkDescription => "Manage network allow and deny rules", MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", - MessageId::CmdThemeDescription => "Toggle between dark and light theme", + MessageId::CmdThemeDescription => "Switch theme: dark, light, grayscale, or system", MessageId::CmdProviderDescription => { "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } @@ -937,7 +937,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdReviewDescription => "Run a structured code review on a file, diff, or PR", MessageId::CmdRlmDescription => "Open a persistent RLM context: /rlm [0-3] ", MessageId::CmdSaveDescription => "Save session to file", - MessageId::CmdSessionsDescription => "Open session picker", + MessageId::CmdSessionsDescription => "Open session history picker", MessageId::CmdSettingsDescription => "Show persistent settings", MessageId::CmdSkillDescription => { "Activate a skill, or install/update/uninstall/trust a community skill" @@ -1295,7 +1295,9 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdModelsDescription => "API から利用可能なモデルを一覧表示", MessageId::CmdNetworkDescription => "ネットワーク許可・拒否ルールを管理", MessageId::CmdNoteDescription => "ワークスペースノートの追加、一覧、編集、削除", - MessageId::CmdThemeDescription => "テーマ(ダーク/ライト)を切り替え", + MessageId::CmdThemeDescription => { + "テーマを切り替え(ダーク/ライト/グレースケール/システム)" + } MessageId::CmdProviderDescription => { "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" } @@ -1312,7 +1314,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdReviewDescription => "ファイル・diff・PR に対して構造化コードレビューを実行", MessageId::CmdRlmDescription => "永続 RLM コンテキストを開く: /rlm [0-3] ", MessageId::CmdSaveDescription => "セッションをファイルに保存", - MessageId::CmdSessionsDescription => "セッションピッカーを開く", + MessageId::CmdSessionsDescription => "セッション履歴ピッカーを開く", MessageId::CmdSettingsDescription => "永続化された設定を表示", MessageId::CmdSkillDescription => { "スキルを有効化、またはコミュニティスキルをインストール/更新/アンインストール/信頼" @@ -1632,7 +1634,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdModelsDescription => "列出 API 中可用的模型", MessageId::CmdNetworkDescription => "管理网络允许和拒绝规则", MessageId::CmdNoteDescription => "添加、列出、编辑或删除工作区笔记", - MessageId::CmdThemeDescription => "在浅色和深色主题之间切换", + MessageId::CmdThemeDescription => "切换主题:深色、浅色、灰度或系统", MessageId::CmdProviderDescription => { "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" } @@ -1647,7 +1649,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdReviewDescription => "对文件、diff 或 PR 进行结构化代码审查", MessageId::CmdRlmDescription => "打开持久 RLM 上下文:/rlm [0-3] ", MessageId::CmdSaveDescription => "将会话保存到文件", - MessageId::CmdSessionsDescription => "打开会话选择器", + MessageId::CmdSessionsDescription => "打开会话历史选择器", MessageId::CmdSettingsDescription => "显示持久化设置", MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", MessageId::CmdSkillsDescription => { @@ -1953,7 +1955,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdModelsDescription => "Listar os modelos disponíveis pela API", MessageId::CmdNetworkDescription => "Gerenciar regras de rede permitidas e bloqueadas", MessageId::CmdNoteDescription => "Adicionar, listar, editar ou remover notas do workspace", - MessageId::CmdThemeDescription => "Alternar entre o tema claro e escuro", + MessageId::CmdThemeDescription => "Alternar tema: escuro, claro, tons de cinza ou sistema", MessageId::CmdProviderDescription => { "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" } @@ -1974,7 +1976,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Abrir um contexto RLM persistente: /rlm [0-3] " } MessageId::CmdSaveDescription => "Salvar a sessão em arquivo", - MessageId::CmdSessionsDescription => "Abrir o seletor de sessões", + MessageId::CmdSessionsDescription => "Abrir seletor de histórico de sessões", MessageId::CmdSettingsDescription => "Exibir as configurações persistidas", MessageId::CmdSkillDescription => { "Ativar uma skill, ou instalar/atualizar/desinstalar/confiar em uma skill da comunidade" diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 7542ecf1..8a23721a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6,7 +6,7 @@ use std::process::{Command, Stdio}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; -use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use dotenvy::dotenv; use tempfile::NamedTempFile; @@ -100,7 +100,8 @@ fn configure_windows_console_utf8() {} #[derive(Parser, Debug)] #[command( - name = "deepseek", + name = "deepseek-tui", + bin_name = "deepseek-tui", author, version = env!("DEEPSEEK_BUILD_VERSION"), about = "DeepSeek TUI/CLI for DeepSeek models", @@ -285,14 +286,51 @@ struct ExecArgs { #[arg(long, default_value_t = false)] auto: bool, /// Emit machine-readable JSON output - #[arg(long, default_value_t = false)] + #[arg(long, default_value_t = false, conflicts_with = "output_format")] json: bool, + /// Resume a previous session by ID or prefix + #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])] + resume: Option, + /// Resume a previous session by ID or prefix + #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])] + session_id: Option, + /// Continue the most recent session for this workspace + #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])] + continue_session: bool, + /// Output format for exec mode + #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)] + output_format: ExecOutputFormat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ExecOutputFormat { + Text, + #[value(name = "stream-json")] + StreamJson, } fn join_prompt_parts(parts: &[String]) -> String { parts.join(" ") } +fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result> { + if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) { + return Ok(Some(id.clone())); + } + if !args.continue_session { + return Ok(None); + } + latest_session_id_for_workspace(workspace)?.map_or_else( + || { + bail!( + "No saved sessions found for workspace {}. Use `deepseek sessions` to list sessions, or pass `deepseek exec --resume ...`.", + workspace.display() + ) + }, + |id| Ok(Some(id)), + ) +} + #[derive(Args, Debug, Clone, Default)] struct SetupArgs { /// Initialize MCP configuration at the configured path @@ -674,13 +712,19 @@ async fn main() -> Result<()> { let config = load_config_from_cli(&cli)?; let model = args .model + .clone() .or_else(|| config.default_text_model.clone()) .unwrap_or_else(|| config.default_model()); let prompt = join_prompt_parts(&args.prompt); - if args.auto || cli.yolo { - let workspace = cli.workspace.clone().unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - }); + let workspace = cli.workspace.clone().unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }); + let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; + let needs_engine = args.auto + || cli.yolo + || resume_session_id.is_some() + || args.output_format == ExecOutputFormat::StreamJson; + if needs_engine { let max_subagents = cli.max_subagents.map_or_else( || config.max_subagents(), |value| value.clamp(1, MAX_SUBAGENTS), @@ -692,9 +736,11 @@ async fn main() -> Result<()> { &prompt, workspace, max_subagents, - true, + auto_mode, auto_mode, args.json, + resume_session_id, + args.output_format, ) .await } else if args.json { @@ -4379,6 +4425,95 @@ async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result Ok(()) } +#[derive(serde::Serialize)] +struct ExecStreamMeta { + model: String, + input_tokens: u32, + output_tokens: u32, + session_id: String, + status: Option, +} + +#[derive(serde::Serialize)] +#[serde(tag = "type")] +enum ExecStreamEvent { + #[serde(rename = "content")] + Content { content: String }, + #[serde(rename = "tool_use")] + ToolUse { + name: String, + id: String, + input: serde_json::Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + id: String, + output: String, + status: String, + }, + #[serde(rename = "session_capture")] + SessionCapture { content: String }, + #[serde(rename = "metadata")] + Metadata { meta: ExecStreamMeta }, + #[serde(rename = "done")] + Done, + #[serde(rename = "error")] + Error { error: String }, +} + +fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> { + println!("{}", serde_json::to_string(event)?); + Ok(()) +} + +fn persist_exec_session( + messages: &[Message], + model: &str, + workspace: &Path, + system_prompt: &Option, + session_id: Option<&str>, + total_tokens: u64, +) -> Result { + let manager = + SessionManager::default_location().context("could not open session manager for save")?; + let saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) { + match manager.load_session(id) { + Ok(existing) => session_manager::update_session( + existing, + messages, + total_tokens, + system_prompt.as_ref(), + ), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + session_manager::create_saved_session_with_id_and_mode( + id.to_string(), + messages, + model, + workspace, + total_tokens, + system_prompt.as_ref(), + Some("exec"), + ) + } + Err(err) => return Err(err).context("could not load existing exec session"), + } + } else { + session_manager::create_saved_session_with_mode( + messages, + model, + workspace, + total_tokens, + system_prompt.as_ref(), + Some("exec"), + ) + }; + let id = saved.metadata.id.clone(); + manager + .save_session(&saved) + .context("could not save exec session")?; + Ok(id) +} + #[allow(clippy::too_many_arguments)] async fn run_exec_agent( config: &Config, @@ -4389,6 +4524,8 @@ async fn run_exec_agent( auto_approve: bool, trust_mode: bool, json_output: bool, + resume_session_id: Option, + output_format: ExecOutputFormat, ) -> Result<()> { use crate::compaction::CompactionConfig; use crate::core::engine::{EngineConfig, spawn_engine}; @@ -4482,6 +4619,37 @@ async fn run_exec_agent( AppMode::Agent }; + let mut loaded_session_id = None; + if let Some(session_id) = resume_session_id.as_deref() { + let manager = SessionManager::default_location() + .context("could not open session manager for exec resume")?; + let saved = manager + .load_session_by_prefix(session_id) + .with_context(|| format!("could not load session '{session_id}'"))?; + let saved_id = saved.metadata.id.clone(); + if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text { + eprintln!( + "Warning: session {} was created in a different workspace ({}). Resuming anyway.", + truncate_id(&saved_id), + saved.metadata.workspace.display(), + ); + } + + engine_handle + .send(Op::SyncSession { + session_id: Some(saved_id.clone()), + messages: saved.messages, + system_prompt: saved.system_prompt.map(SystemPrompt::Text), + model: saved.metadata.model, + workspace: saved.metadata.workspace, + }) + .await?; + loaded_session_id = Some(saved_id.clone()); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("resumed session: {saved_id}"); + } + } + engine_handle .send(Op::SendMessage { content: prompt.to_string(), @@ -4525,11 +4693,19 @@ async fn run_exec_agent( } let mut summary = ExecSummary { mode: "agent".to_string(), - model: effective_model, + model: effective_model.clone(), prompt: prompt.to_string(), ..ExecSummary::default() }; + let should_persist_session = + resume_session_id.is_some() || output_format == ExecOutputFormat::StreamJson; + let mut latest_session_id = loaded_session_id; + let mut latest_messages: Vec = Vec::new(); + let mut latest_system_prompt: Option = None; + let mut latest_model = effective_model; + let mut latest_workspace = workspace.clone(); + let mut stdout = io::stdout(); let mut ends_with_newline = false; loop { @@ -4545,70 +4721,111 @@ async fn run_exec_agent( match event { Event::MessageDelta { content, .. } => { summary.output.push_str(&content); - if !json_output { + if output_format == ExecOutputFormat::StreamJson { + emit_exec_stream_event(&ExecStreamEvent::Content { content })?; + } else if !json_output { print!("{content}"); stdout.flush()?; } - ends_with_newline = content.ends_with('\n'); + ends_with_newline = summary.output.ends_with('\n'); } - Event::MessageComplete { .. } if !json_output && !ends_with_newline => { + Event::MessageComplete { .. } + if output_format == ExecOutputFormat::Text + && !json_output + && !ends_with_newline => + { println!(); } - Event::ToolCallStarted { name, input, .. } if !json_output => { - let summary = summarize_tool_args(&input); - if let Some(summary) = summary { - eprintln!("tool: {name} ({summary})"); - } else { - eprintln!("tool: {name}"); + Event::ThinkingDelta { .. } => { + // Exec stream-json intentionally omits reasoning deltas; the + // TUI transcript retains its existing Activity Detail surface. + } + Event::ToolCallStarted { id, name, input } => { + if output_format == ExecOutputFormat::StreamJson { + emit_exec_stream_event(&ExecStreamEvent::ToolUse { name, id, input })?; + } else if !json_output { + let summary = summarize_tool_args(&input); + if let Some(summary) = summary { + eprintln!("tool: {name} ({summary})"); + } else { + eprintln!("tool: {name}"); + } } } - Event::ToolCallProgress { id, output } if !json_output => { + Event::ToolCallProgress { id, output } + if output_format == ExecOutputFormat::Text && !json_output => + { eprintln!("tool {id}: {}", summarize_tool_output(&output)); } - Event::ToolCallComplete { name, result, .. } => match result { + Event::ToolCallComplete { + id, name, result, .. + } => match result { Ok(output) => { summary.tools.push(ExecToolEntry { name: name.clone(), success: output.success, output: output.content.clone(), }); - if name == "exec_shell" && !output.content.trim().is_empty() { - if !json_output { + if output_format == ExecOutputFormat::StreamJson { + emit_exec_stream_event(&ExecStreamEvent::ToolResult { + id, + output: output.content, + status: if output.success { + "success".to_string() + } else { + "error".to_string() + }, + })?; + } else if !json_output { + if name == "exec_shell" && !output.content.trim().is_empty() { eprintln!("tool {name} completed"); eprintln!( "--- stdout/stderr ---\n{}\n---------------------", output.content ); + } else { + eprintln!( + "tool {name} completed: {}", + summarize_tool_output(&output.content) + ); } - } else if !json_output { - eprintln!( - "tool {name} completed: {}", - summarize_tool_output(&output.content) - ); } } Err(err) => { + let error_text = err.to_string(); summary.tools.push(ExecToolEntry { name: name.clone(), success: false, - output: err.to_string(), + output: error_text.clone(), }); - if !json_output { + if output_format == ExecOutputFormat::StreamJson { + emit_exec_stream_event(&ExecStreamEvent::ToolResult { + id, + output: error_text, + status: "error".to_string(), + })?; + } else if !json_output { eprintln!("tool {name} failed: {err}"); } } }, Event::AgentSpawned { id, prompt } => { - eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt)); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt)); + } } Event::AgentProgress { id, status } => { - eprintln!("sub-agent {id}: {status}"); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("sub-agent {id}: {status}"); + } } Event::AgentComplete { id, result } => { - eprintln!( - "sub-agent {id} completed: {}", - summarize_tool_output(&result) - ); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!( + "sub-agent {id} completed: {}", + summarize_tool_output(&result) + ); + } } Event::ApprovalRequired { id, .. } => { if auto_approve { @@ -4624,11 +4841,15 @@ async fn run_exec_agent( .. } => { if auto_approve { - eprintln!("sandbox denied {tool_name}: {denial_reason} (auto-elevating)"); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("sandbox denied {tool_name}: {denial_reason} (auto-elevating)"); + } let policy = crate::sandbox::SandboxPolicy::DangerFullAccess; let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; } else { - eprintln!("sandbox denied {tool_name}: {denial_reason}"); + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("sandbox denied {tool_name}: {denial_reason}"); + } let _ = engine_handle.deny_tool_call(tool_id).await; } } @@ -4637,16 +4858,81 @@ async fn run_exec_agent( recoverable: _, } => { summary.error = Some(envelope.message.clone()); - if !json_output { + if output_format == ExecOutputFormat::StreamJson { + emit_exec_stream_event(&ExecStreamEvent::Error { + error: envelope.message, + })?; + } else if !json_output { eprintln!("error: {}", envelope.message); } } - Event::TurnComplete { status, error, .. } => { + Event::TurnComplete { + status, + error, + usage, + .. + } => { summary.status = Some(format!("{status:?}").to_lowercase()); summary.error = error; + let saved_session_id = if should_persist_session && !latest_messages.is_empty() { + match persist_exec_session( + &latest_messages, + &latest_model, + &latest_workspace, + &latest_system_prompt, + latest_session_id.as_deref(), + u64::from(usage.input_tokens) + u64::from(usage.output_tokens), + ) { + Ok(id) => { + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("session: {id}"); + } + Some(id) + } + Err(err) => { + if output_format == ExecOutputFormat::Text && !json_output { + eprintln!("warning: failed to save exec session: {err}"); + } + latest_session_id.clone() + } + } + } else { + latest_session_id.clone() + }; + + if output_format == ExecOutputFormat::StreamJson { + if let Some(id) = saved_session_id.as_ref() { + emit_exec_stream_event(&ExecStreamEvent::SessionCapture { + content: id.clone(), + })?; + } + emit_exec_stream_event(&ExecStreamEvent::Metadata { + meta: ExecStreamMeta { + model: latest_model.clone(), + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + session_id: saved_session_id.unwrap_or_default(), + status: summary.status.clone(), + }, + })?; + emit_exec_stream_event(&ExecStreamEvent::Done)?; + } let _ = engine_handle.send(Op::Shutdown).await; break; } + Event::SessionUpdated { + session_id, + messages, + system_prompt, + model, + workspace, + } => { + latest_session_id = Some(session_id); + latest_messages = messages; + latest_system_prompt = system_prompt; + latest_model = model; + latest_workspace = workspace; + } _ => {} } } @@ -4843,6 +5129,11 @@ mod terminal_mode_tests { assert_eq!(cli.prompt, vec!["hello", "world"]); } + #[test] + fn companion_binary_reports_its_own_name() { + assert_eq!(Cli::command().get_name(), "deepseek-tui"); + } + #[test] fn exec_accepts_split_prompt_words_for_windows_cmd_shims() { let cli = parse_cli(&["deepseek", "exec", "hello", "world"]); @@ -4864,6 +5155,76 @@ mod terminal_mode_tests { assert_eq!(args.prompt, vec!["hello", "world"]); } + #[test] + fn exec_accepts_resume_session_flags_for_harnesses() { + let cli = parse_cli(&[ + "deepseek", + "exec", + "--resume", + "abc123", + "--output-format", + "stream-json", + "follow up", + ]); + let Some(Commands::Exec(args)) = cli.command else { + panic!("expected exec command"); + }; + + assert_eq!(args.resume.as_deref(), Some("abc123")); + assert_eq!(args.output_format, ExecOutputFormat::StreamJson); + assert_eq!(args.prompt, vec!["follow up"]); + } + + #[test] + fn exec_accepts_session_id_alias() { + let cli = parse_cli(&["deepseek", "exec", "--session-id", "abc123", "follow up"]); + let Some(Commands::Exec(args)) = cli.command else { + panic!("expected exec command"); + }; + + assert_eq!(args.session_id.as_deref(), Some("abc123")); + assert_eq!(args.output_format, ExecOutputFormat::Text); + } + + #[test] + fn exec_accepts_continue_for_latest_workspace_session() { + let cli = parse_cli(&["deepseek", "exec", "--continue", "follow up"]); + let Some(Commands::Exec(args)) = cli.command else { + panic!("expected exec command"); + }; + + assert!(args.continue_session); + } + + #[test] + fn exec_json_conflicts_with_stream_json_output() { + let err = Cli::try_parse_from([ + "deepseek", + "exec", + "--json", + "--output-format", + "stream-json", + "hello", + ]) + .expect_err("json summary and stream-json must not mix"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn exec_stream_events_are_json_lines() { + let event = ExecStreamEvent::ToolResult { + id: "call_1".to_string(), + output: "line 1\nline 2".to_string(), + status: "success".to_string(), + }; + + let json = serde_json::to_string(&event).expect("serializes"); + assert!(!json.contains('\n')); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + assert_eq!(parsed["type"], "tool_result"); + } + #[test] fn alternate_screen_defaults_on_in_auto_mode() { let cli = parse_cli(&["deepseek"]); diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c2513c8a..93e15380 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -12,18 +12,30 @@ pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38); pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46); pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96); -pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (248, 250, 252); // #F8FAFC -pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (241, 245, 249); // #F1F5F9 -pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (226, 232, 240); // #E2E8F0 -pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (254, 243, 199); // #FEF3C7 -pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (220, 252, 231); // #DCFCE7 -pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (254, 226, 226); // #FEE2E2 +pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (246, 248, 251); // #F6F8FB +pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 242, 248); // #ECF2F8 +pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (219, 229, 240); // #DBE5F0 +pub const LIGHT_REASONING_RGB: (u8, u8, u8) = (255, 246, 214); // #FFF6D6 +pub const LIGHT_SUCCESS_RGB: (u8, u8, u8) = (223, 247, 231); // #DFF7E7 +pub const LIGHT_ERROR_RGB: (u8, u8, u8) = (254, 229, 229); // #FEE5E5 pub const LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (15, 23, 42); // #0F172A pub const LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (51, 65, 85); // #334155 -pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (71, 85, 105); // #475569 +pub const LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (100, 116, 139); // #64748B pub const LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (30, 41, 59); // #1E293B -pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (71, 85, 105); // #475569 -pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (219, 234, 254); // #DBEAFE +pub const LIGHT_BORDER_RGB: (u8, u8, u8) = (139, 161, 184); // #8BA1B8 +pub const LIGHT_SELECTION_RGB: (u8, u8, u8) = (207, 224, 247); // #CFE0F7 +pub const GRAYSCALE_SURFACE_RGB: (u8, u8, u8) = (10, 10, 10); // #0A0A0A +pub const GRAYSCALE_PANEL_RGB: (u8, u8, u8) = (18, 18, 18); // #121212 +pub const GRAYSCALE_ELEVATED_RGB: (u8, u8, u8) = (31, 31, 31); // #1F1F1F +pub const GRAYSCALE_REASONING_RGB: (u8, u8, u8) = (38, 38, 38); // #262626 +pub const GRAYSCALE_SUCCESS_RGB: (u8, u8, u8) = (34, 34, 34); // #222222 +pub const GRAYSCALE_ERROR_RGB: (u8, u8, u8) = (42, 42, 42); // #2A2A2A +pub const GRAYSCALE_TEXT_BODY_RGB: (u8, u8, u8) = (236, 236, 236); // #ECECEC +pub const GRAYSCALE_TEXT_MUTED_RGB: (u8, u8, u8) = (180, 180, 180); // #B4B4B4 +pub const GRAYSCALE_TEXT_HINT_RGB: (u8, u8, u8) = (138, 138, 138); // #8A8A8A +pub const GRAYSCALE_TEXT_SOFT_RGB: (u8, u8, u8) = (220, 220, 220); // #DCDCDC +pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060 +pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E // New semantic colors pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F @@ -106,6 +118,66 @@ pub const LIGHT_SELECTION_BG: Color = Color::Rgb( LIGHT_SELECTION_RGB.1, LIGHT_SELECTION_RGB.2, ); +pub const GRAYSCALE_SURFACE: Color = Color::Rgb( + GRAYSCALE_SURFACE_RGB.0, + GRAYSCALE_SURFACE_RGB.1, + GRAYSCALE_SURFACE_RGB.2, +); +pub const GRAYSCALE_PANEL: Color = Color::Rgb( + GRAYSCALE_PANEL_RGB.0, + GRAYSCALE_PANEL_RGB.1, + GRAYSCALE_PANEL_RGB.2, +); +pub const GRAYSCALE_ELEVATED: Color = Color::Rgb( + GRAYSCALE_ELEVATED_RGB.0, + GRAYSCALE_ELEVATED_RGB.1, + GRAYSCALE_ELEVATED_RGB.2, +); +pub const GRAYSCALE_REASONING: Color = Color::Rgb( + GRAYSCALE_REASONING_RGB.0, + GRAYSCALE_REASONING_RGB.1, + GRAYSCALE_REASONING_RGB.2, +); +pub const GRAYSCALE_SUCCESS: Color = Color::Rgb( + GRAYSCALE_SUCCESS_RGB.0, + GRAYSCALE_SUCCESS_RGB.1, + GRAYSCALE_SUCCESS_RGB.2, +); +pub const GRAYSCALE_ERROR: Color = Color::Rgb( + GRAYSCALE_ERROR_RGB.0, + GRAYSCALE_ERROR_RGB.1, + GRAYSCALE_ERROR_RGB.2, +); +pub const GRAYSCALE_TEXT_BODY: Color = Color::Rgb( + GRAYSCALE_TEXT_BODY_RGB.0, + GRAYSCALE_TEXT_BODY_RGB.1, + GRAYSCALE_TEXT_BODY_RGB.2, +); +pub const GRAYSCALE_TEXT_MUTED: Color = Color::Rgb( + GRAYSCALE_TEXT_MUTED_RGB.0, + GRAYSCALE_TEXT_MUTED_RGB.1, + GRAYSCALE_TEXT_MUTED_RGB.2, +); +pub const GRAYSCALE_TEXT_HINT: Color = Color::Rgb( + GRAYSCALE_TEXT_HINT_RGB.0, + GRAYSCALE_TEXT_HINT_RGB.1, + GRAYSCALE_TEXT_HINT_RGB.2, +); +pub const GRAYSCALE_TEXT_SOFT: Color = Color::Rgb( + GRAYSCALE_TEXT_SOFT_RGB.0, + GRAYSCALE_TEXT_SOFT_RGB.1, + GRAYSCALE_TEXT_SOFT_RGB.2, +); +pub const GRAYSCALE_BORDER: Color = Color::Rgb( + GRAYSCALE_BORDER_RGB.0, + GRAYSCALE_BORDER_RGB.1, + GRAYSCALE_BORDER_RGB.2, +); +pub const GRAYSCALE_SELECTION_BG: Color = Color::Rgb( + GRAYSCALE_SELECTION_RGB.0, + GRAYSCALE_SELECTION_RGB.1, + GRAYSCALE_SELECTION_RGB.2, +); pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); // #E2E8F0 pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); // #B1BECF @@ -177,6 +249,7 @@ pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; pub enum PaletteMode { Dark, Light, + Grayscale, } impl PaletteMode { @@ -278,12 +351,37 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme { border: LIGHT_BORDER, }; +pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme { + name: "grayscale", + mode: PaletteMode::Grayscale, + surface_bg: GRAYSCALE_SURFACE, + panel_bg: GRAYSCALE_PANEL, + elevated_bg: GRAYSCALE_ELEVATED, + composer_bg: GRAYSCALE_PANEL, + selection_bg: GRAYSCALE_SELECTION_BG, + header_bg: GRAYSCALE_SURFACE, + footer_bg: GRAYSCALE_SURFACE, + mode_agent: GRAYSCALE_TEXT_SOFT, + mode_yolo: GRAYSCALE_TEXT_BODY, + mode_plan: GRAYSCALE_TEXT_MUTED, + status_ready: GRAYSCALE_TEXT_MUTED, + status_working: GRAYSCALE_TEXT_SOFT, + status_warning: GRAYSCALE_TEXT_BODY, + text_dim: GRAYSCALE_TEXT_HINT, + text_hint: GRAYSCALE_TEXT_HINT, + text_muted: GRAYSCALE_TEXT_MUTED, + text_body: GRAYSCALE_TEXT_BODY, + text_soft: GRAYSCALE_TEXT_SOFT, + border: GRAYSCALE_BORDER, +}; + impl UiTheme { #[must_use] pub fn for_mode(mode: PaletteMode) -> Self { match mode { PaletteMode::Dark => UI_THEME, PaletteMode::Light => LIGHT_UI_THEME, + PaletteMode::Grayscale => GRAYSCALE_UI_THEME, } } @@ -292,6 +390,17 @@ impl UiTheme { Self::for_mode(PaletteMode::detect()) } + #[must_use] + pub fn from_setting(value: &str) -> Option { + match normalize_theme_name(value)? { + "system" => Some(Self::detect()), + "dark" => Some(Self::for_mode(PaletteMode::Dark)), + "light" => Some(Self::for_mode(PaletteMode::Light)), + "grayscale" => Some(Self::for_mode(PaletteMode::Grayscale)), + _ => None, + } + } + #[must_use] pub fn with_background_color(mut self, color: Color) -> Self { self.surface_bg = color; @@ -301,6 +410,36 @@ impl UiTheme { } } +#[must_use] +pub fn normalize_theme_name(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "" | "auto" | "system" | "default" => Some("system"), + "dark" | "whale" | "whale-dark" => Some("dark"), + "light" | "whale-light" => Some("light"), + "grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white" + | "black_and_white" | "blackwhite" | "bw" | "b&w" => Some("grayscale"), + _ => None, + } +} + +#[must_use] +pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str { + match mode { + PaletteMode::Dark => "dark", + PaletteMode::Light => "light", + PaletteMode::Grayscale => "grayscale", + } +} + +#[must_use] +pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> UiTheme { + let mut ui_theme = UiTheme::from_setting(theme).unwrap_or_else(UiTheme::detect); + if let Some(background) = background_color.and_then(parse_hex_rgb_color) { + ui_theme = ui_theme.with_background_color(background); + } + ui_theme +} + #[must_use] pub fn parse_hex_rgb_color(value: &str) -> Option { let hex = value.trim().strip_prefix('#').unwrap_or(value.trim()); @@ -329,10 +468,23 @@ pub fn hex_rgb_string(color: Color) -> Option { #[must_use] pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> Color { - if mode == PaletteMode::Dark { - return color; + match mode { + PaletteMode::Dark => color, + PaletteMode::Light => adapt_fg_for_light_palette(color), + PaletteMode::Grayscale => adapt_fg_for_grayscale_palette(color), } +} +#[must_use] +pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color { + match mode { + PaletteMode::Dark => color, + PaletteMode::Light => adapt_bg_for_light_palette(color), + PaletteMode::Grayscale => adapt_bg_for_grayscale_palette(color), + } +} + +fn adapt_fg_for_light_palette(color: Color) -> Color { if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White { LIGHT_TEXT_BODY } else if color == TEXT_SECONDARY || color == TEXT_MUTED { @@ -358,12 +510,7 @@ pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> } } -#[must_use] -pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color { - if mode == PaletteMode::Dark { - return color; - } - +fn adapt_bg_for_light_palette(color: Color) -> Color { if color == DEEPSEEK_INK || color == BACKGROUND_DARK { LIGHT_SURFACE } else if color == DEEPSEEK_SLATE @@ -394,6 +541,150 @@ pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color { } } +fn adapt_fg_for_grayscale_palette(color: Color) -> Color { + if color == Color::Reset { + return color; + } + if color == TEXT_BODY + || color == SELECTION_TEXT + || color == LIGHT_TEXT_BODY + || color == Color::White + || color == DEEPSEEK_RED + || color == STATUS_ERROR + || color == MODE_YOLO + { + GRAYSCALE_TEXT_BODY + } else if color == TEXT_SOFT + || color == TEXT_TOOL_OUTPUT + || color == LIGHT_TEXT_SOFT + || color == TEXT_ACCENT + || color == DEEPSEEK_SKY + || color == DEEPSEEK_BLUE + || color == ACCENT_TOOL_LIVE + || color == STATUS_SUCCESS + || color == STATUS_INFO + || color == MODE_AGENT + { + GRAYSCALE_TEXT_SOFT + } else if color == TEXT_SECONDARY + || color == TEXT_MUTED + || color == LIGHT_TEXT_MUTED + || color == TEXT_REASONING + || color == ACCENT_REASONING_LIVE + || color == STATUS_WARNING + || color == MODE_PLAN + || color == USER_BODY + || color == LIGHT_USER_BODY + || color == DIFF_ADDED + { + GRAYSCALE_TEXT_MUTED + } else if color == TEXT_HINT + || color == TEXT_DIM + || color == LIGHT_TEXT_HINT + || color == BORDER_COLOR + || color == LIGHT_BORDER + || color == ACCENT_TOOL_ISSUE + { + GRAYSCALE_TEXT_HINT + } else { + match color { + Color::Black => GRAYSCALE_TEXT_BODY, + Color::Gray | Color::DarkGray => GRAYSCALE_TEXT_HINT, + Color::Red + | Color::LightRed + | Color::Green + | Color::LightGreen + | Color::Yellow + | Color::LightYellow + | Color::Blue + | Color::LightBlue + | Color::Magenta + | Color::LightMagenta + | Color::Cyan + | Color::LightCyan => GRAYSCALE_TEXT_SOFT, + Color::Rgb(r, g, b) => grayscale_fg_from_luma(luma(r, g, b)), + Color::Indexed(_) => color, + _ => color, + } + } +} + +fn adapt_bg_for_grayscale_palette(color: Color) -> Color { + if color == Color::Reset { + return color; + } + if color == DEEPSEEK_INK || color == BACKGROUND_DARK || color == LIGHT_SURFACE { + GRAYSCALE_SURFACE + } else if color == DEEPSEEK_SLATE + || color == COMPOSER_BG + || color == SURFACE_PANEL + || color == SURFACE_TOOL + || color == LIGHT_PANEL + { + GRAYSCALE_PANEL + } else if color == SURFACE_ELEVATED + || color == SURFACE_TOOL_ACTIVE + || color == LIGHT_ELEVATED + || color == SELECTION_BG + || color == LIGHT_SELECTION_BG + { + GRAYSCALE_ELEVATED + } else if color == SURFACE_REASONING + || color == SURFACE_REASONING_TINT + || color == SURFACE_REASONING_ACTIVE + || color == LIGHT_REASONING + { + GRAYSCALE_REASONING + } else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG || color == LIGHT_SUCCESS { + GRAYSCALE_SUCCESS + } else if color == SURFACE_ERROR || color == DIFF_DELETED_BG || color == LIGHT_ERROR { + GRAYSCALE_ERROR + } else { + match color { + Color::Black => GRAYSCALE_SURFACE, + Color::White | Color::Gray => GRAYSCALE_ELEVATED, + Color::DarkGray => GRAYSCALE_PANEL, + Color::Red + | Color::LightRed + | Color::Green + | Color::LightGreen + | Color::Yellow + | Color::LightYellow + | Color::Blue + | Color::LightBlue + | Color::Magenta + | Color::LightMagenta + | Color::Cyan + | Color::LightCyan => GRAYSCALE_ELEVATED, + Color::Rgb(r, g, b) => grayscale_bg_from_luma(luma(r, g, b)), + Color::Indexed(_) => color, + _ => color, + } + } +} + +fn grayscale_fg_from_luma(luma: u8) -> Color { + match luma { + 0..=95 => GRAYSCALE_TEXT_HINT, + 96..=155 => GRAYSCALE_TEXT_MUTED, + 156..=215 => GRAYSCALE_TEXT_SOFT, + _ => GRAYSCALE_TEXT_BODY, + } +} + +fn grayscale_bg_from_luma(luma: u8) -> Color { + match luma { + 0..=28 => GRAYSCALE_SURFACE, + 29..=95 => GRAYSCALE_PANEL, + 96..=185 => GRAYSCALE_ELEVATED, + _ => GRAYSCALE_REASONING, + } +} + +fn luma(r: u8, g: u8, b: u8) -> u8 { + (((u16::from(r) * 299) + (u16::from(g) * 587) + (u16::from(b) * 114)) / 1000) as u8 +} + // === Color depth + brightness helpers (v0.6.6 UI redesign) === /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg @@ -652,12 +943,15 @@ fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { mod tests { use super::{ ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY, - DEEPSEEK_SLATE, LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY, - LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, SURFACE_REASONING_TINT, - TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, - adapt_bg_for_palette_mode, adapt_color, adapt_fg_for_palette_mode, blend, nearest_ansi16, - normalize_hex_rgb_color, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, - rgb_to_ansi256, + DEEPSEEK_SLATE, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING, + GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, + GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING, + LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode, + SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING, + TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, adapt_bg_for_palette_mode, adapt_color, + adapt_fg_for_palette_mode, blend, nearest_ansi16, normalize_hex_rgb_color, + normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, + rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings, }; use ratatui::style::Color; @@ -683,6 +977,36 @@ mod tests { assert_eq!(theme.text_body, LIGHT_TEXT_BODY); } + #[test] + fn ui_theme_selects_grayscale_variant() { + let theme = super::UiTheme::for_mode(PaletteMode::Grayscale); + assert_eq!(theme, GRAYSCALE_UI_THEME); + assert_eq!(theme.surface_bg, GRAYSCALE_SURFACE); + assert_eq!(theme.panel_bg, GRAYSCALE_PANEL); + assert_eq!(theme.text_body, GRAYSCALE_TEXT_BODY); + } + + #[test] + fn theme_names_normalize_common_grayscale_aliases() { + assert_eq!(normalize_theme_name("system"), Some("system")); + assert_eq!(normalize_theme_name("default"), Some("system")); + assert_eq!(normalize_theme_name("whale"), Some("dark")); + assert_eq!(normalize_theme_name("black-white"), Some("grayscale")); + assert_eq!(normalize_theme_name("mono"), Some("grayscale")); + assert_eq!(normalize_theme_name("solarized"), None); + assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale"); + } + + #[test] + fn light_palette_has_quiet_layer_separation() { + assert_eq!(LIGHT_SURFACE, Color::Rgb(246, 248, 251)); + assert_eq!(LIGHT_PANEL, Color::Rgb(236, 242, 248)); + assert_eq!(LIGHT_ELEVATED, Color::Rgb(219, 229, 240)); + assert_eq!(LIGHT_BORDER, Color::Rgb(139, 161, 184)); + assert_ne!(LIGHT_SURFACE, LIGHT_PANEL); + assert_ne!(LIGHT_PANEL, LIGHT_ELEVATED); + } + #[test] fn dark_palette_uses_soft_body_text_and_warm_reasoning() { assert_eq!(TEXT_BODY, Color::Rgb(226, 232, 240)); @@ -738,6 +1062,46 @@ mod tests { ); } + #[test] + fn grayscale_palette_maps_brand_hues_to_neutral_roles() { + assert_eq!( + adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Grayscale), + GRAYSCALE_SURFACE + ); + assert_eq!( + adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Grayscale), + GRAYSCALE_PANEL + ); + assert_eq!( + adapt_bg_for_palette_mode(SURFACE_REASONING, PaletteMode::Grayscale), + GRAYSCALE_REASONING + ); + assert_eq!( + adapt_fg_for_palette_mode(DEEPSEEK_SKY, GRAYSCALE_SURFACE, PaletteMode::Grayscale), + GRAYSCALE_TEXT_SOFT + ); + assert_eq!( + adapt_fg_for_palette_mode(DEEPSEEK_RED, GRAYSCALE_SURFACE, PaletteMode::Grayscale), + GRAYSCALE_TEXT_BODY + ); + assert_eq!( + adapt_fg_for_palette_mode(TEXT_HINT, GRAYSCALE_SURFACE, PaletteMode::Grayscale), + GRAYSCALE_TEXT_HINT + ); + } + + #[test] + fn ui_theme_from_settings_applies_theme_and_background() { + let theme = ui_theme_from_settings("grayscale", Some("#111111")); + assert_eq!(theme.mode, PaletteMode::Grayscale); + assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17)); + assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17)); + assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17)); + assert_eq!(theme.panel_bg, GRAYSCALE_PANEL); + assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED); + assert_eq!(theme.border, GRAYSCALE_BORDER); + } + #[test] fn adapt_color_passes_through_truecolor() { let c = Color::Rgb(53, 120, 229); diff --git a/crates/tui/src/repl/runtime.rs b/crates/tui/src/repl/runtime.rs index f943e416..ed127eda 100644 --- a/crates/tui/src/repl/runtime.rs +++ b/crates/tui/src/repl/runtime.rs @@ -1,6 +1,6 @@ //! Long-lived Python REPL runtime. //! -//! One `python3 -u` subprocess lives for the duration of an RLM turn (or an +//! One Python subprocess lives for the duration of an RLM turn (or an //! inline `repl` block sequence in the agent loop). Code blocks are sent //! over stdin framed by `__RLM_RUN__`/`__RLM_END__` sentinels; the bootstrap //! `exec()`s them into the same global namespace so variables, imports, @@ -29,6 +29,7 @@ use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use uuid::Uuid; use crate::child_env; +use crate::dependencies::{PYTHON_CANDIDATES, resolve_python_interpreter, split_interpreter_spec}; // --------------------------------------------------------------------------- // Public types @@ -47,6 +48,10 @@ pub struct ReplRound { pub has_error: bool, /// Captured `finalize(value, confidence=...)` payload, if any. pub final_value: Option, + /// Captured final value before string fallback. Structured `finalize` + /// payloads use this so `handle_read` can expose JSON instead of a Python + /// repr string. + pub final_json: Option, /// Optional confidence supplied to `finalize(...)`. pub final_confidence: Option, /// Number of `sub_query`/`sub_rlm` RPCs the round issued. @@ -190,8 +195,23 @@ impl PythonRuntime { let session_id = Uuid::new_v4().simple().to_string(); let bootstrap = render_bootstrap(&session_id); - let mut cmd = Command::new("python3"); - cmd.arg("-u") + let interpreter = resolve_python_interpreter().ok_or_else(|| { + format!( + "no Python interpreter found on PATH (tried {:?}). \ + Install Python 3 and ensure one of these commands works, then restart deepseek-tui.", + PYTHON_CANDIDATES, + ) + })?; + let (program, interpreter_args) = split_interpreter_spec(&interpreter); + if program.is_empty() { + return Err(format!( + "resolved Python interpreter is empty: {interpreter:?}" + )); + } + + let mut cmd = Command::new(&program); + cmd.args(&interpreter_args) + .arg("-u") .arg("-c") .arg(&bootstrap) .stdin(Stdio::piped()) @@ -211,16 +231,16 @@ impl PythonRuntime { let mut child = cmd .spawn() - .map_err(|e| format!("failed to spawn python3: {e}"))?; + .map_err(|e| format!("failed to spawn Python interpreter `{interpreter}`: {e}"))?; let stdin = child .stdin .take() - .ok_or_else(|| "python3 stdin pipe missing".to_string())?; + .ok_or_else(|| format!("Python interpreter `{interpreter}` stdin pipe missing"))?; let raw_stdout = child .stdout .take() - .ok_or_else(|| "python3 stdout pipe missing".to_string())?; + .ok_or_else(|| format!("Python interpreter `{interpreter}` stdout pipe missing"))?; let stdout = BufReader::new(raw_stdout); let mut rt = Self { @@ -244,12 +264,14 @@ impl PythonRuntime { Ok(Ok(())) => Ok(rt), Ok(Err(e)) => { let _ = rt.child.kill().await; - Err(format!("python3 bootstrap failed: {e}")) + Err(format!( + "Python interpreter `{interpreter}` bootstrap failed: {e}" + )) } Err(_) => { let _ = rt.child.kill().await; Err(format!( - "python3 bootstrap did not signal ready within {}s", + "Python interpreter `{interpreter}` bootstrap did not signal ready within {}s", SPAWN_READY_TIMEOUT.as_secs() )) } @@ -265,7 +287,7 @@ impl PythonRuntime { .await .map_err(|e| format!("stdout read: {e}"))?; if n == 0 { - return Err("python3 closed stdout before ready signal".to_string()); + return Err("Python interpreter closed stdout before ready signal".to_string()); } let trimmed = line.trim_end_matches(['\n', '\r']); if trimmed == ready_sentinel { @@ -314,6 +336,7 @@ impl PythonRuntime { let mut stdout_buf = String::new(); let mut final_value: Option = None; + let mut final_json: Option = None; let mut final_confidence: Option = None; let mut had_error = false; let mut rpc_count: u32 = 0; @@ -328,7 +351,7 @@ impl PythonRuntime { .await .map_err(|e| format!("stdout read: {e}"))?; if n == 0 { - return Err("python3 closed stdout mid-round".to_string()); + return Err("Python interpreter closed stdout mid-round".to_string()); } let trimmed = line.trim_end_matches(['\n', '\r']); @@ -341,23 +364,25 @@ impl PythonRuntime { // legacy helpers emitted a JSON string. match serde_json::from_str::(rest) { Ok(Value::Object(map)) => { - let value = map + let value_json = map .get("value") - .and_then(Value::as_str) + .cloned() + .unwrap_or(Value::String(rest.to_string())); + let value = value_json + .as_str() .map(str::to_string) - .unwrap_or_else(|| { - map.get("value") - .map(Value::to_string) - .unwrap_or_else(|| rest.to_string()) - }); + .unwrap_or_else(|| value_json.to_string()); + final_json = Some(value_json); final_value = Some(value); final_confidence = map.get("confidence").cloned(); } Ok(Value::String(value)) => { + final_json = Some(Value::String(value.clone())); final_value = Some(value); final_confidence = None; } Ok(other) => { + final_json = Some(other.clone()); final_value = Some(other.to_string()); final_confidence = None; } @@ -429,6 +454,7 @@ impl PythonRuntime { stderr, has_error: had_error, final_value, + final_json, final_confidence, rpc_count, elapsed: started.elapsed(), @@ -625,17 +651,17 @@ def _prompt_with_slice(prompt, slice_value): label = "slice" return f"{prompt}\n\n--- {label} ---\n{text}" -def sub_query(prompt, slice=None): +def sub_query(prompt, slice=None, timeout_secs=None, **kwargs): """One child LLM call, optionally scoped to a bounded slice.""" return llm_query(_prompt_with_slice(prompt, slice)) -def sub_query_batch(prompt, slices): +def sub_query_batch(prompt, slices, timeout_secs=None, **kwargs): """Apply one prompt to many bounded slices concurrently.""" if not isinstance(slices, (list, tuple)): return ["[sub_query_batch: slices must be a list]"] return llm_query_batched([_prompt_with_slice(prompt, s) for s in slices]) -def sub_query_map(prompts, slices=None): +def sub_query_map(prompts, slices=None, timeout_secs=None, **kwargs): """Run N distinct prompts, optionally paired with N bounded slices.""" if not isinstance(prompts, (list, tuple)): return ["[sub_query_map: prompts must be a list]"] @@ -647,15 +673,23 @@ def sub_query_map(prompts, slices=None): return [f"[sub_query_map: size mismatch ({len(prompts)}/{len(slices)})]" for _ in prompts] return llm_query_batched([_prompt_with_slice(p, s) for p, s in zip(prompts, slices)]) -def sub_rlm(prompt, source=None): +def sub_rlm(prompt, source=None, timeout_secs=None, **kwargs): """Recursive sub-RLM call for tasks that need their own decomposition.""" return rlm_query(_prompt_with_slice(prompt, source)) +def _json_safe(value): + try: + _json.dumps(value, ensure_ascii=False) + return value + except Exception: + return str(value) + def _emit_final(value, confidence=None): + safe_value = _json_safe(value) _sys.stdout.write(_FINAL + _json.dumps({ - "value": str(value), + "value": safe_value, "confidence": confidence, - }) + "\n") + }, ensure_ascii=False) + "\n") _sys.stdout.flush() def FINAL(value): @@ -796,7 +830,7 @@ def chunk_coverage(chunks): def finalize(value, confidence=None): """Signal the session's final answer and persist confidence metadata.""" global final_answer, final_confidence, final_result - final_answer = str(value) + final_answer = _json_safe(value) final_confidence = confidence final_result = { "value": final_answer, @@ -824,16 +858,17 @@ if _ctx_file: _context = f.read() except Exception as e: _sys.stderr.write(f"[bootstrap] failed to load context: {e}\n") +content = _context _BOOTSTRAP_NAMES = { "_SID","_REQ","_RESP","_FINAL","_ERR","_RUN","_END","_DONE","_READY", "_rpc","_ctx_file","_context","_slice_chars","_slice_lines","_BOOTSTRAP_NAMES","_main_loop", - "_emit_final","_slice_text","_prompt_with_slice", + "_emit_final","_json_safe","_slice_text","_prompt_with_slice", "llm_query","llm_query_batched","rlm_query","rlm_query_batched", "sub_query","sub_query_batch","sub_query_map","sub_rlm", "FINAL","FINAL_VAR","SHOW_VARS","repl_get","repl_set", "context_meta","peek","search","chunk","chunk_context","chunk_coverage", - "finalize","evaluate_progress", + "finalize","evaluate_progress","content", "_json","_os","_re","_sys","_traceback", } @@ -1001,16 +1036,16 @@ mod tests { } #[tokio::test] - async fn context_aliases_are_not_bound() { + async fn context_aliases_keep_common_content_name_bounded() { let path = write_temp_context("aleph-style"); let mut rt = PythonRuntime::spawn_with_context(&path) .await .expect("spawn"); let round = rt - .execute("print('context' in globals(), 'ctx' in globals())") + .execute("print(content == _context, 'context' in globals(), 'ctx' in globals())") .await .expect("execute"); - assert!(round.stdout.contains("False False")); + assert!(round.stdout.contains("True False False")); rt.shutdown().await; } @@ -1086,6 +1121,10 @@ mod tests { .await .expect("execute"); assert_eq!(round.final_value.as_deref(), Some("computed answer")); + assert_eq!( + round.final_json.as_ref().and_then(Value::as_str), + Some("computed answer") + ); assert_eq!( round.final_confidence.as_ref().and_then(Value::as_str), Some("high") @@ -1093,6 +1132,46 @@ mod tests { rt.shutdown().await; } + #[tokio::test] + async fn finalize_preserves_json_values_for_handles() { + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .execute("finalize({'answer': 42, 'items': ['a', 'b']})") + .await + .expect("execute"); + + assert_eq!( + round.final_value.as_deref(), + Some(r#"{"answer":42,"items":["a","b"]}"#) + ); + assert_eq!( + round.final_json, + Some(serde_json::json!({"answer": 42, "items": ["a", "b"]})) + ); + rt.shutdown().await; + } + + #[tokio::test] + async fn sub_query_accepts_timeout_keyword_for_agent_guesses() { + let bridge = StubBridge::new(); + let mut rt = PythonRuntime::new().await.expect("spawn"); + let round = rt + .run( + "answer = sub_query('summarize', timeout_secs=2)\nprint(answer)", + Some(&bridge), + ) + .await + .expect("execute"); + + assert!(!round.has_error, "{}", round.stdout); + assert!( + round.stdout.contains("stub#0: summarize"), + "{}", + round.stdout + ); + rt.shutdown().await; + } + #[tokio::test] async fn rlm_context_runtime_has_no_fixed_round_timeout() { let path = write_temp_context("long input"); diff --git a/crates/tui/src/rlm/mod.rs b/crates/tui/src/rlm/mod.rs index 4b48dc22..2a5baf69 100644 --- a/crates/tui/src/rlm/mod.rs +++ b/crates/tui/src/rlm/mod.rs @@ -20,7 +20,7 @@ //! - The root LLM receives small metadata messages — length, preview, //! helper list, prior-round summary. //! - Code rounds and sub-LLM calls travel over a single stdin/stdout -//! pipe to a long-lived `python3 -u` subprocess. No HTTP sidecar. +//! pipe to a long-lived Python subprocess. No HTTP sidecar. use crate::models::Usage; diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 55832ee1..66f66147 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -674,8 +674,13 @@ pub fn create_saved_session_with_id_and_mode( .find(|m| m.role == "user") .and_then(|m| { m.content.iter().find_map(|block| match block { - ContentBlock::Text { text, .. } if !text.starts_with("") => { - Some(truncate_title(text, 50)) + ContentBlock::Text { text, .. } => { + let prompt = extract_user_prompt(text); + if prompt.is_empty() { + None + } else { + Some(truncate_title(prompt, 50)) + } } _ => None, }) @@ -881,6 +886,51 @@ pub fn truncate_id(id: &str) -> &str { id.get(..8).unwrap_or(id) } +/// Strip a leading `...` block from saved user text. +/// +/// Older sessions can have turn metadata prefixed to the first user message. +/// The session picker and generated session titles should show the user's +/// prompt, not the cache/debug envelope. +pub(crate) fn extract_user_prompt(raw: &str) -> &str { + let trimmed = raw.trim_start(); + let Some(after_open) = trimmed.strip_prefix("") else { + return trimmed; + }; + if let Some(close_pos) = after_open.find("") { + return after_open[close_pos + "".len()..].trim_start(); + } + after_open.trim_start() +} + +/// Clean a stored title for display, falling back to a neutral label. +pub(crate) fn extract_title(raw: &str) -> &str { + let title = extract_user_prompt(raw); + if title.is_empty() { "Session" } else { title } +} + +/// Strip common inline thinking/reasoning XML sections from saved assistant +/// text before it is shown in session previews. +pub(crate) fn strip_thinking_tags(text: &str) -> String { + if !text.contains(""); + let close = format!(""); + while let Some(start) = result.find(&open) { + let Some(end) = result[start..].find(&close) else { + break; + }; + let end_abs = start + end + close.len(); + result.replace_range(start..end_abs, ""); + } + } + result +} + /// Truncate a string to create a title (character-safe for UTF-8) fn truncate_title(s: &str, max_len: usize) -> String { let s = s.trim(); @@ -898,7 +948,7 @@ fn truncate_title(s: &str, max_len: usize) -> String { /// Format a session for display in a picker pub fn format_session_line(meta: &SessionMetadata) -> String { let age = format_age(&meta.updated_at); - let truncated_title = truncate_title(&meta.title, 40); + let truncated_title = truncate_title(extract_title(&meta.title), 40); format!( "{} | {} | {} msgs | {}", @@ -1250,6 +1300,41 @@ mod tests { assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1"); } + #[test] + fn extract_user_prompt_strips_turn_meta_prefix() { + assert_eq!( + extract_user_prompt("{\"cache\":\"x\"}\nReal prompt"), + "Real prompt" + ); + assert_eq!(extract_user_prompt(" Real prompt"), "Real prompt"); + assert_eq!( + extract_user_prompt("{\"unterminated\":true}\nReal prompt"), + "{\"unterminated\":true}\nReal prompt" + ); + } + + #[test] + fn create_saved_session_uses_prompt_after_turn_meta_for_title() { + let tmp = tempdir().expect("tempdir"); + let messages = vec![make_test_message( + "user", + "{\"cache\":\"x\"}\nFix the session picker history pane", + )]; + let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None); + assert_eq!( + session.metadata.title, + "Fix the session picker history pane" + ); + } + + #[test] + fn strip_thinking_tags_removes_common_inline_blocks() { + let text = "Before private middle hidden after"; + let cleaned = strip_thinking_tags(text); + assert_eq!(cleaned, "Before middle after"); + assert_eq!(strip_thinking_tags("plain answer"), "plain answer"); + } + #[test] fn test_format_age() { let now = Utc::now(); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 0df883a7..0cb0c6af 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::config::{expand_path, normalize_model_name}; use crate::localization::normalize_configured_locale; -use crate::palette::normalize_hex_rgb_color; +use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; // ============================================================================ // TuiPrefs — ~/.deepseek/tui.toml @@ -28,7 +28,7 @@ use crate::palette::normalize_hex_rgb_color; /// # Example `~/.deepseek/tui.toml` /// /// ```toml -/// theme = "dark" # "dark" | "light" | "system" +/// theme = "dark" # "system" | "dark" | "light" | "grayscale" /// font_size = 14 /// /// [keybinds] @@ -43,7 +43,8 @@ use crate::palette::normalize_hex_rgb_color; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct TuiPrefs { - /// UI colour theme: `"dark"` | `"light"` | `"system"`. Default `"dark"`. + /// UI colour theme: `"dark"` | `"light"` | `"grayscale"` | `"system"`. + /// Default `"dark"`. pub theme: String, /// Terminal font size hint forwarded to supporting front-ends (e.g. the /// Tauri shell). `0` means "use terminal default". Default `0`. @@ -149,14 +150,13 @@ impl TuiPrefs { /// surface a helpful message rather than silently ignoring a typo. pub fn validate(&mut self) -> Result<()> { let theme = self.theme.trim().to_ascii_lowercase(); - match theme.as_str() { - "dark" | "light" | "system" => { - self.theme = theme; - } - other => { - anyhow::bail!("Invalid tui.toml theme '{other}': expected dark, light, or system."); - } - } + let Some(theme) = normalize_theme_name(&theme) else { + anyhow::bail!( + "Invalid tui.toml theme '{}': expected system, dark, light, or grayscale.", + self.theme + ); + }; + self.theme = theme.to_string(); Ok(()) } } @@ -195,6 +195,8 @@ pub struct Settings { pub show_tool_details: bool, /// UI locale: auto, en, ja, zh-Hans, pt-BR pub locale: String, + /// UI theme: system, dark, light, grayscale + pub theme: String, /// Optional main TUI background color as a 6-digit hex RGB value. pub background_color: Option, /// Composer layout density: compact, comfortable, spacious @@ -287,6 +289,7 @@ impl Default for Settings { show_thinking: true, show_tool_details: true, locale: "auto".to_string(), + theme: "system".to_string(), background_color: None, composer_density: "comfortable".to_string(), composer_border: true, @@ -350,6 +353,7 @@ impl Settings { s.locale = normalize_configured_locale(&s.locale) .unwrap_or("en") .to_string(); + s.theme = normalize_settings_theme(&s.theme).to_string(); s.background_color = normalize_optional_background_color(s.background_color.as_deref()); s.default_model = s.default_model.as_deref().and_then(normalize_default_model); s @@ -474,6 +478,14 @@ impl Settings { }; self.locale = locale.to_string(); } + "theme" | "ui_theme" => { + let Some(theme) = normalize_theme_name(value) else { + anyhow::bail!( + "Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale." + ); + }; + self.theme = theme.to_string(); + } "background_color" | "background" | "bg" => { self.background_color = normalize_background_color_setting(value)?; } @@ -631,6 +643,7 @@ impl Settings { lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" locale: {}", self.locale)); + lines.push(format!(" theme: {}", self.theme)); lines.push(format!( " background_color: {}", self.background_color.as_deref().unwrap_or("(default)") @@ -700,6 +713,7 @@ impl Settings { "locale", "UI locale and default model language: auto, en, ja, zh-Hans, pt-BR", ), + ("theme", "UI theme: system, dark, light, grayscale"), ( "background_color", "Main TUI background color: #RRGGBB or default", @@ -836,6 +850,10 @@ fn normalize_synchronized_output(value: &str) -> &str { } } +fn normalize_settings_theme(value: &str) -> &'static str { + normalize_theme_name(value).unwrap_or("system") +} + /// Returns `true` when the active terminal is Ptyxis (the new default /// terminal on Ubuntu 26.04). Used by [`Settings::apply_env_overrides`] /// to flip `synchronized_output` from `auto` to `off` so DEC mode 2026 @@ -967,6 +985,26 @@ mod tests { assert!(err.to_string().contains("invalid locale")); } + #[test] + fn theme_normalizes_supported_values_and_rejects_unknowns() { + let mut settings = Settings::default(); + assert_eq!(settings.theme, "system"); + + settings.set("theme", "grayscale").expect("set grayscale"); + assert_eq!(settings.theme, "grayscale"); + + settings.set("ui_theme", "black-white").expect("set alias"); + assert_eq!(settings.theme, "grayscale"); + + settings.set("theme", "whale").expect("set dark alias"); + assert_eq!(settings.theme, "dark"); + + let err = settings + .set("theme", "solarized") + .expect_err("unknown theme should fail"); + assert!(err.to_string().contains("invalid theme")); + } + #[test] fn background_color_normalizes_hex_and_accepts_default() { let mut settings = Settings::default(); @@ -1557,7 +1595,7 @@ mod tests { #[test] fn tui_prefs_validate_accepts_known_themes() { - for theme in ["dark", "light", "system"] { + for theme in ["dark", "light", "system", "grayscale"] { let mut prefs = TuiPrefs { theme: theme.to_string(), ..TuiPrefs::default() @@ -1572,11 +1610,13 @@ mod tests { #[test] fn tui_prefs_validate_normalises_theme_case() { let mut prefs = TuiPrefs { - theme: "DARK".to_string(), + theme: "MONO".to_string(), ..TuiPrefs::default() }; - prefs.validate().expect("DARK should normalise to dark"); - assert_eq!(prefs.theme, "dark"); + prefs + .validate() + .expect("MONO should normalise to grayscale"); + assert_eq!(prefs.theme, "grayscale"); } #[test] @@ -1589,6 +1629,10 @@ mod tests { .validate() .expect_err("solarized is not a valid theme"); assert!(err.to_string().contains("Invalid tui.toml theme")); + assert!( + err.to_string() + .contains("expected system, dark, light, or grayscale") + ); } #[test] diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index 9d1f4cdf..84b6fc9e 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -222,12 +222,17 @@ impl ToolSpec for RlmEvalTool { session.total_duration += round.elapsed; session.last_used_at = Instant::now(); - let final_handle = if let Some(value) = round.final_value.clone() { + let final_handle = if let Some(value_json) = round.final_json.clone() { session.final_count = session.final_count.saturating_add(1); let handle_name = format!("final_{}", session.final_count); let handle = { let mut store = context.runtime.handle_store.lock().await; - store.insert_text(session.id.clone(), handle_name, value) + match value_json { + Value::String(value) => { + store.insert_text(session.id.clone(), handle_name, value) + } + other => store.insert_json(session.id.clone(), handle_name, other), + } }; Some(handle) } else { @@ -497,6 +502,7 @@ fn _assert_var_handle_shape(_: Option) {} #[cfg(test)] mod tests { use super::*; + use crate::tools::handle::HandleReadTool; use crate::tools::spec::ToolContext; fn ctx() -> ToolContext { @@ -562,6 +568,42 @@ mod tests { .expect("close"); } + #[tokio::test] + async fn rlm_eval_final_preserves_json_handle() { + let ctx = ctx(); + RlmOpenTool + .execute(json!({"name": "json-final", "content": "body"}), &ctx) + .await + .expect("open"); + + let eval = RlmEvalTool::new(None) + .execute( + json!({"name": "json-final", "code": "finalize({'answer': 42, 'items': ['a', 'b']})"}), + &ctx, + ) + .await + .expect("eval"); + let eval_json: Value = serde_json::from_str(&eval.content).expect("eval json"); + assert_eq!(eval_json["final"]["kind"], "var_handle"); + assert_eq!(eval_json["final"]["type"], "dict"); + assert_eq!(eval_json["final"]["length"], 2); + + let read = HandleReadTool + .execute( + json!({"handle": eval_json["final"].clone(), "jsonpath": "$.items[*]"}), + &ctx, + ) + .await + .expect("read final handle"); + let read_json: Value = serde_json::from_str(&read.content).expect("read json"); + assert_eq!(read_json["matches"], json!(["a", "b"])); + + RlmCloseTool + .execute(json!({"name": "json-final"}), &ctx) + .await + .expect("close"); + } + #[tokio::test] async fn rlm_configure_metadata_omits_stdout() { let ctx = ctx(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index a719fda7..35baf99f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1259,14 +1259,8 @@ impl App { let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus); let max_input_history = settings.max_input_history; let use_paste_burst_detection = settings.paste_burst_detection; - let mut ui_theme = palette::UiTheme::detect(); - if let Some(background) = settings - .background_color - .as_deref() - .and_then(palette::parse_hex_rgb_color) - { - ui_theme = ui_theme.with_background_color(background); - } + let ui_theme = + palette::ui_theme_from_settings(&settings.theme, settings.background_color.as_deref()); let model = settings .provider_models .as_ref() @@ -2587,10 +2581,12 @@ impl App { /// Handle terminal resize event. pub fn handle_resize(&mut self, _width: u16, _height: u16) { + let preserved_scroll = (!self.viewport.transcript_scroll.is_at_tail()) + .then_some(self.viewport.last_transcript_top); self.viewport.transcript_cache = TranscriptViewCache::new(); - if !self.viewport.transcript_scroll.is_at_tail() { - self.viewport.transcript_scroll = TranscriptScroll::to_bottom(); + if let Some(top) = preserved_scroll { + self.viewport.transcript_scroll = TranscriptScroll::at_line(top); } self.viewport.pending_scroll_delta = 0; @@ -4110,7 +4106,9 @@ mod tests { notes_path: PathBuf::from("notes.txt"), mcp_config_path: PathBuf::from("mcp.json"), use_memory: false, - start_in_agent_mode: yolo, + // Keep unit tests independent from the developer's saved + // `default_mode` setting. + start_in_agent_mode: true, skip_onboarding: false, yolo, resume_session_id: None, @@ -4416,7 +4414,6 @@ mod tests { #[test] fn test_cycle_mode_transitions() { let mut app = App::new(test_options(false), &Config::default()); - // Default mode should be Agent based on settings let initial_mode = app.mode; app.cycle_mode(); // Mode should have changed @@ -4573,6 +4570,32 @@ mod tests { app.scroll_down(3); } + #[test] + fn resize_preserves_scrolled_transcript_position() { + let mut app = App::new(test_options(false), &Config::default()); + app.viewport.transcript_scroll = TranscriptScroll::at_line(42); + app.viewport.last_transcript_top = 42; + app.viewport.pending_scroll_delta = 5; + + app.handle_resize(120, 40); + + let meta = vec![TranscriptLineMeta::Spacer; 240]; + let (_, top) = app.viewport.transcript_scroll.resolve_top(&meta, 200); + assert_eq!(top, 42); + assert_eq!(app.viewport.pending_scroll_delta, 0); + } + + #[test] + fn resize_keeps_tail_state_when_user_was_at_tail() { + let mut app = App::new(test_options(false), &Config::default()); + app.viewport.transcript_scroll = TranscriptScroll::to_bottom(); + app.viewport.last_transcript_top = 42; + + app.handle_resize(120, 40); + + assert!(app.viewport.transcript_scroll.is_at_tail()); + } + #[test] fn test_add_message() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 5ea13874..6de30c0f 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -207,6 +207,18 @@ mod tests { assert_eq!(cell.bg, palette::LIGHT_SURFACE); } + #[test] + fn grayscale_palette_maps_hued_cells_before_depth_adaptation() { + let mut cell = Cell::default(); + cell.set_fg(palette::DEEPSEEK_SKY); + cell.set_bg(palette::DEEPSEEK_INK); + + adapt_cell_colors(&mut cell, ColorDepth::TrueColor, PaletteMode::Grayscale); + + assert_eq!(cell.fg, palette::GRAYSCALE_TEXT_SOFT); + assert_eq!(cell.bg, palette::GRAYSCALE_SURFACE); + } + #[test] fn backend_palette_mode_can_follow_runtime_theme_changes() { let writer = SharedWriter::default(); @@ -215,5 +227,7 @@ mod tests { assert_eq!(backend.palette_mode, PaletteMode::Dark); backend.set_palette_mode(PaletteMode::Light); assert_eq!(backend.palette_mode, PaletteMode::Light); + backend.set_palette_mode(PaletteMode::Grayscale); + assert_eq!(backend.palette_mode, PaletteMode::Grayscale); } } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 871480b8..39f1a7be 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -2011,8 +2011,20 @@ pub fn output_is_image(output: &str) -> bool { .any(|ext| lower.contains(ext)) } +#[allow(dead_code)] // Kept for compatibility/tests; live view uses explicit summaries only. #[must_use] pub fn extract_reasoning_summary(text: &str) -> Option { + extract_explicit_reasoning_summary(text).or_else(|| { + let fallback = text.trim(); + if fallback.is_empty() { + None + } else { + Some(fallback.to_string()) + } + }) +} + +fn extract_explicit_reasoning_summary(text: &str) -> Option { let mut lines = text.lines().peekable(); while let Some(line) = lines.next() { let trimmed = line.trim(); @@ -2044,12 +2056,7 @@ pub fn extract_reasoning_summary(text: &str) -> Option { }; } } - let fallback = text.trim(); - if fallback.is_empty() { - None - } else { - Some(fallback.to_string()) - } + None } fn render_thinking( @@ -2094,6 +2101,7 @@ fn render_thinking( lines.push(Line::from(header_spans)); let content_width = width.saturating_sub(3).max(1); + let mut collapsed_without_explicit_summary = false; let body_text = if collapsed { if streaming { // #861 RC4 / #1324: during streaming we don't yet have a @@ -2104,7 +2112,13 @@ fn render_thinking( // staring at an empty placeholder. content.to_string() } else { - extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string()) + match extract_explicit_reasoning_summary(content) { + Some(summary) => summary, + None => { + collapsed_without_explicit_summary = true; + String::new() + } + } } } else { content.to_string() @@ -2158,13 +2172,13 @@ fn render_thinking( // knows there's more above and how to reach it. truncated } else { - truncated || body_text.trim() != content.trim() + collapsed_without_explicit_summary || truncated || body_text.trim() != content.trim() }; if needs_affordance { let label = if streaming { - "thinking continues; Ctrl+O opens Activity Detail" + "More reasoning in Ctrl+O" } else { - "thinking collapsed; Ctrl+O opens Activity Detail" + "Full reasoning in Ctrl+O" }; lines.push(Line::from(vec![ Span::styled(REASONING_RAIL.to_string(), rail_style), @@ -3648,7 +3662,7 @@ mod tests { .iter() .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect::(); - assert!(text.contains("thinking collapsed; Ctrl+O opens Activity Detail")); + assert!(text.contains("Full reasoning in Ctrl+O")); assert!(text.contains("thinking")); } @@ -3696,7 +3710,7 @@ mod tests { .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect::(); assert!( - text.contains("thinking continues; Ctrl+O opens Activity Detail"), + text.contains("More reasoning in Ctrl+O"), "streaming-truncation affordance missing, got: {text}" ); // The most recent line must be the visible tail (head dropped). @@ -4276,10 +4290,10 @@ mod tests { // === display_lines (lines_with_options) vs transcript_lines parity === // - // These lock the contract for CX#8: live view compresses thinking and - // caps tool output, transcript view shows the full body. Both surfaces - // must contain the first paragraph / first line of the underlying - // content so users never lose the lede. + // These lock the contract for CX#8: live view keeps reasoning compact + // and caps tool output, transcript view shows the full body. Completed + // reasoning without an explicit Summary stays out of the main flow so it + // cannot masquerade as user text. fn line_text(line: &ratatui::text::Line<'static>) -> String { line.spans @@ -4295,8 +4309,9 @@ mod tests { #[test] fn long_thinking_display_is_shorter_than_transcript() { // Build a multi-paragraph thinking body so the live view has - // something to compress. The first paragraph is the lede; both - // surfaces must keep it. + // something to compress. Without an explicit Summary block, the + // live surface should show status + affordance only; Ctrl+O remains + // the path to the full body. let body = "First paragraph lede.\n\ Second sentence of the first paragraph.\n\n\ Second paragraph: deeper analysis follows.\n\ @@ -4330,14 +4345,14 @@ mod tests { let live_text = lines_text(&live); let transcript_text = lines_text(&transcript); - assert!( - live_text.contains("First paragraph lede"), - "live thinking must keep the lede: {live_text}" - ); assert!( transcript_text.contains("First paragraph lede"), "transcript thinking must keep the lede" ); + assert!( + !live_text.contains("First paragraph lede"), + "live thinking must not show raw completed reasoning: {live_text}" + ); assert!( transcript_text.contains("Fourth paragraph"), "transcript thinking must keep the full body" @@ -4347,19 +4362,20 @@ mod tests { "live thinking must drop the tail when collapsed" ); assert!( - live_text.contains("Ctrl+O opens Activity Detail"), + live_text.contains("Full reasoning in Ctrl+O"), "live thinking must offer the pager affordance" ); assert!( - !transcript_text.contains("Ctrl+O opens Activity Detail"), + !transcript_text.contains("Full reasoning in Ctrl+O"), "transcript thinking must not include the live affordance" ); } #[test] - fn short_thinking_display_equals_transcript() { - // A single-line thinking body has nothing to compress; live and - // transcript surfaces should agree. + fn completed_thinking_without_summary_stays_out_of_live_view() { + // Even a short completed reasoning body can read like the user's + // prompt when rendered inline. Keep it in transcript/detail surfaces + // and show the Ctrl+O affordance in the main flow. let cell = HistoryCell::Thinking { content: "One brief reasoning step.".to_string(), streaming: false, @@ -4378,13 +4394,17 @@ mod tests { let live_text = lines_text(&live); let transcript_text = lines_text(&transcript); - assert_eq!( - live_text, transcript_text, - "short thinking must render identically on both surfaces" + assert!( + !live_text.contains("One brief reasoning step."), + "live thinking must hide raw completed reasoning: {live_text}" ); assert!( - !live_text.contains("Ctrl+O opens Activity Detail"), - "short thinking must not show the collapse affordance" + transcript_text.contains("One brief reasoning step."), + "transcript thinking must keep the full reasoning body" + ); + assert!( + live_text.contains("Full reasoning in Ctrl+O"), + "live thinking must offer the detail affordance" ); } diff --git a/crates/tui/src/tui/live_transcript.rs b/crates/tui/src/tui/live_transcript.rs index d133f833..1abc32d8 100644 --- a/crates/tui/src/tui/live_transcript.rs +++ b/crates/tui/src/tui/live_transcript.rs @@ -20,7 +20,7 @@ //! key just changed; revision bumps invalidate only the cells that mutated; //! cells that didn't change reuse their existing wrap. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -66,6 +66,11 @@ struct CellSnapshot { cell: HistoryCell, } +struct FlattenedTranscript { + lines: Vec>, + highlighted_range: Option<(usize, usize)>, +} + pub struct LiveTranscriptOverlay { /// Latest cell snapshots (history + active). Refreshed via /// `refresh_from_app` immediately before each render so streaming @@ -79,20 +84,23 @@ pub struct LiveTranscriptOverlay { /// Sticky-tail flag: when `true`, refresh re-pins scroll to the bottom. /// Flipped to `false` when the user scrolls up; flipped back to `true` /// when they scroll past the last visible line. - sticky_to_bottom: bool, + sticky_to_bottom: Cell, /// Current top-of-viewport line offset into the flattened line list. - scroll: usize, + scroll: Cell, /// Visible content height from the last render. Used by paging keys /// before the next render frame populates a fresh value. - last_visible_height: RefCell, + last_visible_height: Cell, /// Last total line count after wrapping; cached so `handle_key` can /// clamp scroll without re-wrapping. Updated by `render`. - last_total_lines: RefCell, + last_total_lines: Cell, /// Pending `gg` second keystroke for Vim-style jump-to-top. pending_g: bool, /// Render mode — `Tail` is the live-stream mode; `BacktrackPreview` /// highlights the selected user message (#133). mode: Mode, + /// Set when a backtrack selection changes. The next render pins the + /// selected cell into view once we know the wrapped line range. + preview_pin_pending: Cell, } impl LiveTranscriptOverlay { @@ -102,12 +110,13 @@ impl LiveTranscriptOverlay { snapshots: Vec::new(), options: TranscriptRenderOptions::default(), cache: RefCell::new(TranscriptCache::new()), - sticky_to_bottom: true, - scroll: 0, - last_visible_height: RefCell::new(0), - last_total_lines: RefCell::new(0), + sticky_to_bottom: Cell::new(true), + scroll: Cell::new(0), + last_visible_height: Cell::new(0), + last_total_lines: Cell::new(0), pending_g: false, mode: Mode::Tail, + preview_pin_pending: Cell::new(false), } } @@ -118,7 +127,8 @@ impl LiveTranscriptOverlay { /// highlight overlay does. pub fn set_backtrack_preview(&mut self, selected_idx: usize) { self.mode = Mode::BacktrackPreview { selected_idx }; - self.sticky_to_bottom = false; + self.sticky_to_bottom.set(false); + self.preview_pin_pending.set(true); } /// Return the overlay to live-tail mode (used when backtrack is @@ -126,7 +136,8 @@ impl LiveTranscriptOverlay { #[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view. pub fn set_tail_mode(&mut self) { self.mode = Mode::Tail; - self.sticky_to_bottom = true; + self.sticky_to_bottom.set(true); + self.preview_pin_pending.set(false); } /// For tests + UI: current mode. @@ -179,9 +190,10 @@ impl LiveTranscriptOverlay { /// first line and reverse-video styling on every line so the eye /// snaps to them at a glance. The decoration is applied *after* the /// cache lookup so toggling preview mode never invalidates wraps. - fn flatten(&self, width: u16) -> Vec> { + fn flatten(&self, width: u16) -> FlattenedTranscript { let width = width.max(1); let mut out: Vec> = Vec::new(); + let mut highlighted_range = None; // Pre-compute which cell index (in `self.snapshots`) is the one // the user has selected via Esc-Esc. We walk snapshots backwards @@ -217,16 +229,24 @@ impl LiveTranscriptOverlay { }; if Some(cell_idx) == highlighted_cell_idx { + let start = out.len(); out.extend(decorate_highlight(lines)); + let end = out.len(); + if end > start { + highlighted_range = Some((start, end)); + } } else { out.extend(lines); } } - out + FlattenedTranscript { + lines: out, + highlighted_range, + } } fn page_height(&self) -> usize { - let cached = *self.last_visible_height.borrow(); + let cached = self.last_visible_height.get(); if cached == 0 { 10 } else { cached } } @@ -235,33 +255,38 @@ impl LiveTranscriptOverlay { } fn max_scroll(&self) -> usize { - let total = *self.last_total_lines.borrow(); + let total = self.last_total_lines.get(); let visible = self.page_height(); total.saturating_sub(visible) } fn scroll_up(&mut self, amount: usize) { - self.scroll = self.scroll.saturating_sub(amount); + self.scroll.set(self.scroll.get().saturating_sub(amount)); // Any upward motion exits sticky-tail; explicit user intent. - self.sticky_to_bottom = false; + self.sticky_to_bottom.set(false); + self.preview_pin_pending.set(false); } fn scroll_down(&mut self, amount: usize) { let max = self.max_scroll(); - self.scroll = (self.scroll + amount).min(max); - if self.scroll >= max { - self.sticky_to_bottom = true; + let scroll = self.scroll.get().saturating_add(amount).min(max); + self.scroll.set(scroll); + self.preview_pin_pending.set(false); + if scroll >= max && matches!(self.mode, Mode::Tail) { + self.sticky_to_bottom.set(true); } } fn jump_to_top(&mut self) { - self.scroll = 0; - self.sticky_to_bottom = false; + self.scroll.set(0); + self.sticky_to_bottom.set(false); + self.preview_pin_pending.set(false); } fn jump_to_bottom(&mut self) { - self.scroll = self.max_scroll(); - self.sticky_to_bottom = true; + self.scroll.set(self.max_scroll()); + self.sticky_to_bottom.set(matches!(self.mode, Mode::Tail)); + self.preview_pin_pending.set(false); } /// For tests: snapshot count. @@ -273,13 +298,13 @@ impl LiveTranscriptOverlay { /// For tests: whether sticky-tail is currently armed. #[cfg(test)] pub fn is_sticky(&self) -> bool { - self.sticky_to_bottom + self.sticky_to_bottom.get() } /// For tests: current scroll offset. #[cfg(test)] pub fn scroll_offset(&self) -> usize { - self.scroll + self.scroll.get() } } @@ -317,6 +342,26 @@ fn decorate_highlight(mut lines: Vec>) -> Vec> { lines } +fn scroll_to_show_range( + current: usize, + start: usize, + end: usize, + visible_height: usize, + max_scroll: usize, +) -> usize { + if visible_height == 0 { + return 0; + } + let end = end.max(start.saturating_add(1)); + if start < current { + start.min(max_scroll) + } else if end > current.saturating_add(visible_height) { + end.saturating_sub(visible_height).min(max_scroll) + } else { + current.min(max_scroll) + } +} + impl ModalView for LiveTranscriptOverlay { fn kind(&self) -> ModalKind { ModalKind::LiveTranscript @@ -461,23 +506,36 @@ impl ModalView for LiveTranscriptOverlay { // Compute inner content height once: borders eat 1 row top + 1 bottom, // padding eats 1 more on each side. let visible_height = popup_area.height.saturating_sub(4) as usize; - *self.last_visible_height.borrow_mut() = visible_height; + self.last_visible_height.set(visible_height); // Wrap content using the per-cell cache; subtract padding from width // so wrapped lines fit between the inner edges. let content_width = popup_width.saturating_sub(4); - let lines = self.flatten(content_width); - *self.last_total_lines.borrow_mut() = lines.len(); + let flattened = self.flatten(content_width); + let lines = flattened.lines; + self.last_total_lines.set(lines.len()); let max_scroll = lines.len().saturating_sub(visible_height); // Sticky-tail: every render re-pins scroll to the bottom unless the // user has explicitly scrolled away. Without this, streaming new // content would push the visible window backwards as `scroll` stays // fixed against a growing total. - let scroll = if self.sticky_to_bottom { + let scroll = if self.sticky_to_bottom.get() { + self.scroll.set(max_scroll); max_scroll + } else if self.preview_pin_pending.replace(false) { + let next = flattened + .highlighted_range + .map(|(start, end)| { + scroll_to_show_range(self.scroll.get(), start, end, visible_height, max_scroll) + }) + .unwrap_or_else(|| self.scroll.get().min(max_scroll)); + self.scroll.set(next); + next } else { - self.scroll.min(max_scroll) + let next = self.scroll.get().min(max_scroll); + self.scroll.set(next); + next }; let end = (scroll + visible_height).min(lines.len()); let visible_lines: Vec> = if lines.is_empty() { @@ -495,7 +553,7 @@ impl ModalView for LiveTranscriptOverlay { selected_idx + 1 ), Mode::Tail => { - if self.sticky_to_bottom { + if self.sticky_to_bottom.get() { " Live transcript (tailing) ".to_string() } else { " Live transcript (paused) ".to_string() @@ -560,6 +618,17 @@ mod tests { .collect(); } + fn buffer_text(buf: &Buffer) -> String { + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + #[test] fn new_overlay_starts_sticky() { let v = LiveTranscriptOverlay::new(); @@ -577,8 +646,8 @@ mod tests { ); prime_layout(&mut v, 10); // Force scroll non-zero so scroll_up actually moves. - v.scroll = 5; - v.sticky_to_bottom = true; + v.scroll.set(5); + v.sticky_to_bottom.set(true); let _ = v.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert!(!v.is_sticky(), "scrolling up must release the sticky tail"); } @@ -592,8 +661,8 @@ mod tests { ); prime_layout(&mut v, 10); // Drop out of sticky mode by scrolling up. - v.scroll = 10; - v.sticky_to_bottom = false; + v.scroll.set(10); + v.sticky_to_bottom.set(false); let _ = v.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); assert!( v.is_sticky(), @@ -609,12 +678,12 @@ mod tests { (0..50).map(|i| user(&format!("line {i}"))).collect(), ); prime_layout(&mut v, 10); - v.sticky_to_bottom = false; + v.sticky_to_bottom.set(false); // PageDown once should not re-arm since we're not yet at the tail. let _ = v.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)); // Now jump explicitly to bottom and verify re-arm. - v.scroll = 0; - v.sticky_to_bottom = false; + v.scroll.set(0); + v.sticky_to_bottom.set(false); let _ = v.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)); assert!(v.is_sticky()); } @@ -784,6 +853,36 @@ mod tests { } } + #[test] + fn backtrack_preview_opens_near_latest_user_not_transcript_start() { + let mut v = LiveTranscriptOverlay::new(); + let mut cells = Vec::new(); + for i in 0..12 { + cells.push(user(&format!("user {i}"))); + cells.push(assistant(&format!("assistant {i}"), false)); + } + install_snapshots(&mut v, cells); + + v.set_backtrack_preview(0); + let area = Rect::new(0, 0, 48, 10); + let mut buf = Buffer::empty(area); + v.render(area, &mut buf); + let rendered = buffer_text(&buf); + + assert!( + v.scroll_offset() > 0, + "preview should pin near the selected recent turn, got top offset 0" + ); + assert!( + rendered.contains("user 11"), + "latest user turn should be visible after opening preview: {rendered}" + ); + assert!( + !rendered.contains("user 0"), + "preview must not open at the oldest transcript line: {rendered}" + ); + } + #[test] fn backtrack_preview_out_of_range_does_not_panic() { // Selecting beyond the user-cell count should simply not diff --git a/crates/tui/src/tui/scrolling.rs b/crates/tui/src/tui/scrolling.rs index d1f7cff2..6bc51781 100644 --- a/crates/tui/src/tui/scrolling.rs +++ b/crates/tui/src/tui/scrolling.rs @@ -17,6 +17,12 @@ use std::time::{Duration, Instant}; +const TRACKPAD_EVENT_WINDOW: Duration = Duration::from_millis(35); +const WHEEL_LINES_PER_TICK: i32 = 3; +const TRACKPAD_BASE_LINES_PER_TICK: i32 = 1; +const TRACKPAD_MID_LINES_PER_TICK: i32 = 2; +const TRACKPAD_MAX_LINES_PER_TICK: i32 = 3; + // === Transcript Line Metadata === /// Metadata describing how rendered transcript lines map to history cells. @@ -203,7 +209,8 @@ impl ScrollDirection { #[derive(Debug, Default)] pub struct MouseScrollState { last_event_at: Option, - pending_lines: i32, + last_direction: Option, + rapid_same_direction_ticks: u8, } /// A computed scroll delta from user input. @@ -222,17 +229,37 @@ impl MouseScrollState { /// Process a scroll event and return the resulting delta. pub fn on_scroll(&mut self, direction: ScrollDirection) -> ScrollUpdate { let now = Instant::now(); + self.on_scroll_at(direction, now) + } + + fn on_scroll_at(&mut self, direction: ScrollDirection, now: Instant) -> ScrollUpdate { let is_trackpad = self .last_event_at - .is_some_and(|last| now.duration_since(last) < Duration::from_millis(35)); + .is_some_and(|last| now.saturating_duration_since(last) < TRACKPAD_EVENT_WINDOW); + let same_direction = self.last_direction == Some(direction); + self.last_event_at = Some(now); + self.last_direction = Some(direction); - let lines_per_tick = if is_trackpad { 1 } else { 3 }; - self.pending_lines += direction.sign() * lines_per_tick; + let lines_per_tick = if is_trackpad { + if same_direction { + self.rapid_same_direction_ticks = self.rapid_same_direction_ticks.saturating_add(1); + } else { + self.rapid_same_direction_ticks = 1; + } + match self.rapid_same_direction_ticks { + 0..=2 => TRACKPAD_BASE_LINES_PER_TICK, + 3..=5 => TRACKPAD_MID_LINES_PER_TICK, + _ => TRACKPAD_MAX_LINES_PER_TICK, + } + } else { + self.rapid_same_direction_ticks = 0; + WHEEL_LINES_PER_TICK + }; - let delta = self.pending_lines; - self.pending_lines = 0; - ScrollUpdate { delta_lines: delta } + ScrollUpdate { + delta_lines: direction.sign() * lines_per_tick, + } } } @@ -433,4 +460,90 @@ mod tests { assert!(state.is_at_tail()); assert_eq!(top, max_start); } + + #[test] + fn mouse_scroll_single_wheel_tick_moves_three_lines() { + let mut state = MouseScrollState::new(); + let start = Instant::now(); + + assert_eq!( + state.on_scroll_at(ScrollDirection::Down, start).delta_lines, + 3 + ); + assert_eq!( + state.on_scroll_at(ScrollDirection::Up, start).delta_lines, + -1, + "same timestamp is treated as a rapid precise input" + ); + } + + #[test] + fn mouse_scroll_rapid_same_direction_accelerates_but_caps() { + let mut state = MouseScrollState::new(); + let start = Instant::now(); + + let deltas = [ + state.on_scroll_at(ScrollDirection::Down, start).delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(10)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(20)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(30)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(40)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(50)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(60)) + .delta_lines, + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(70)) + .delta_lines, + ]; + + assert_eq!(deltas, [3, 1, 1, 2, 2, 2, 3, 3]); + } + + #[test] + fn mouse_scroll_direction_change_resets_acceleration() { + let mut state = MouseScrollState::new(); + let start = Instant::now(); + + for step in 0..8 { + let _ = state.on_scroll_at( + ScrollDirection::Down, + start + Duration::from_millis(step * 10), + ); + } + + assert_eq!( + state + .on_scroll_at(ScrollDirection::Up, start + Duration::from_millis(90)) + .delta_lines, + -1 + ); + } + + #[test] + fn mouse_scroll_slow_gap_resets_to_wheel_tick() { + let mut state = MouseScrollState::new(); + let start = Instant::now(); + + assert_eq!( + state.on_scroll_at(ScrollDirection::Down, start).delta_lines, + 3 + ); + assert_eq!( + state + .on_scroll_at(ScrollDirection::Down, start + Duration::from_millis(100)) + .delta_lines, + 3 + ); + } } diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 49831839..9fd520b1 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -16,7 +16,10 @@ use ratatui::{ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; -use crate::session_manager::{SavedSession, SessionManager, SessionMetadata}; +use crate::session_manager::{ + SavedSession, SessionManager, SessionMetadata, extract_title, extract_user_prompt, + strip_thinking_tags, +}; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; fn modal_block(title: &str) -> Block<'static> { @@ -46,6 +49,8 @@ pub struct SessionPickerView { selected: usize, list_scroll: Cell, list_visible_rows: Cell, + history_scroll: Cell, + history_visible_rows: Cell, search_input: String, search_mode: bool, sort_mode: SortMode, @@ -78,6 +83,8 @@ impl SessionPickerView { selected: 0, list_scroll: Cell::new(0), list_visible_rows: Cell::new(8), + history_scroll: Cell::new(0), + history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false, sort_mode: SortMode::Recent, @@ -164,11 +171,62 @@ impl SessionPickerView { self.refresh_preview(); } + fn select_visible_shortcut(&mut self, c: char) -> bool { + let Some(slot) = c.to_digit(10) else { + return false; + }; + if !(1..=9).contains(&slot) { + return false; + } + let index = self.list_scroll.get().saturating_add(slot as usize - 1); + if index >= self.filtered.len() { + return false; + } + self.selected = index; + self.ensure_selected_visible(); + self.refresh_preview(); + if let Some(session) = self.selected_session() { + self.status = Some(format!( + "Opened history for {}", + crate::session_manager::truncate_id(&session.id) + )); + } + true + } + fn update_list_viewport(&self, visible_rows: usize) { self.list_visible_rows.set(visible_rows.max(1)); self.ensure_selected_visible(); } + fn update_history_viewport(&self, visible_rows: usize) { + self.history_visible_rows.set(visible_rows.max(1)); + self.ensure_history_scroll_in_bounds(); + } + + fn scroll_history(&self, delta: isize) { + let max_scroll = self + .current_preview + .len() + .saturating_sub(self.history_visible_rows.get().max(1)); + let current = self.history_scroll.get(); + let next = if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize) + }; + self.history_scroll.set(next.min(max_scroll)); + } + + fn ensure_history_scroll_in_bounds(&self) { + let max_scroll = self + .current_preview + .len() + .saturating_sub(self.history_visible_rows.get().max(1)); + self.history_scroll + .set(self.history_scroll.get().min(max_scroll)); + } + fn ensure_selected_visible(&self) { if self.filtered.is_empty() { self.list_scroll.set(0); @@ -245,11 +303,13 @@ impl SessionPickerView { fn refresh_preview(&mut self) { let Some(session) = self.selected_session() else { self.current_preview = vec!["No sessions found.".to_string()]; + self.history_scroll.set(0); return; }; if let Some(lines) = self.preview_cache.get(&session.id) { self.current_preview = lines.clone(); + self.history_scroll.set(0); return; } @@ -257,6 +317,7 @@ impl SessionPickerView { Ok(manager) => manager, Err(_) => { self.current_preview = vec!["Failed to open sessions directory.".to_string()]; + self.history_scroll.set(0); return; } }; @@ -265,6 +326,7 @@ impl SessionPickerView { Ok(saved) => saved, Err(_) => { self.current_preview = vec!["Failed to load session preview.".to_string()]; + self.history_scroll.set(0); return; } }; @@ -273,6 +335,7 @@ impl SessionPickerView { self.preview_cache .insert(session.id.clone(), preview.clone()); self.current_preview = preview; + self.history_scroll.set(0); } } @@ -338,11 +401,13 @@ impl ModalView for SessionPickerView { ViewAction::None } KeyCode::PageUp => { - self.move_selection(-5); + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(-(rows as isize)); ViewAction::None } KeyCode::PageDown => { - self.move_selection(5); + let rows = self.history_visible_rows.get().max(1); + self.scroll_history(rows as isize); ViewAction::None } KeyCode::Char('/') => { @@ -367,6 +432,7 @@ impl ModalView for SessionPickerView { self.status = Some("Delete session? (y/n)".to_string()); ViewAction::None } + KeyCode::Char(c) if self.select_visible_shortcut(c) => ViewAction::None, KeyCode::Enter => { if let Some(session) = self.selected_session() { ViewAction::EmitAndClose(ViewEvent::SessionSelected { @@ -390,16 +456,26 @@ impl ModalView for SessionPickerView { Clear.render(popup_area, buf); + let narrow = popup_area.width < 95; let chunks = Layout::default() - .direction(if popup_area.width < 95 { + .direction(if narrow { Direction::Vertical } else { Direction::Horizontal }) - .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .constraints(if narrow { + [Constraint::Percentage(42), Constraint::Percentage(58)] + } else { + [Constraint::Percentage(64), Constraint::Percentage(36)] + }) .split(popup_area); + let (history_area, list_area) = if narrow { + (chunks[1], chunks[0]) + } else { + (chunks[0], chunks[1]) + }; - let list_inner = modal_block(" Sessions ").inner(chunks[0]); + let list_inner = modal_block(" Sessions (1-9) ").inner(list_area); let header_rows = 1 + usize::from(self.confirm_delete || self.status.is_some()); let footer_rows = usize::from(!self.filtered.is_empty()); let visible_rows = usize::from(list_inner.height) @@ -421,21 +497,19 @@ impl ModalView for SessionPickerView { self.status.as_deref(), ); let list = Paragraph::new(list_lines) - .block(modal_block(" Sessions ")) + .block(modal_block(" Sessions (1-9) ")) .wrap(Wrap { trim: false }); - list.render(chunks[0], buf); + list.render(list_area, buf); - let preview_inner = modal_block(" Preview ").inner(chunks[1]); - let preview_lines = format_preview( - &self.current_preview, - preview_inner.width, - preview_inner.height as usize, - ); + let history_inner = modal_block(" History (PgUp/PgDn) ").inner(history_area); + self.update_history_viewport(history_inner.height as usize); + let preview_lines = format_preview(&self.current_preview); let preview = Paragraph::new(preview_lines) - .block(modal_block(" Preview ")) + .block(modal_block(" History (PgUp/PgDn) ")) + .scroll((self.history_scroll.get().min(u16::MAX as usize) as u16, 0)) .wrap(Wrap { trim: false }); - preview.render(chunks[1], buf); + preview.render(history_area, buf); } } @@ -456,7 +530,9 @@ fn build_list_lines( let header = if search_mode { format!("/{}", search_input) } else { - format!("Sort: {sort_label} | / search | s sort | d delete") + format!( + "1-9 history | PgUp/PgDn scroll | Enter resume | / search | s sort | a all | d delete | Sort: {sort_label}" + ) }; lines.push(Line::from(Span::styled( truncate(&header, width), @@ -486,7 +562,13 @@ fn build_list_lines( } for (idx, session) in sessions.iter().enumerate().skip(scroll).take(visible_rows) { - let mut line = format_session_line(session); + let slot = idx.saturating_sub(scroll).saturating_add(1); + let prefix = if slot <= 9 { + format!("{slot}. ") + } else { + " ".to_string() + }; + let mut line = format!("{prefix}{}", format_session_line(session)); line = truncate(&line, width); let style = if idx == selected { Style::default() @@ -516,7 +598,12 @@ fn build_list_lines( fn format_session_line(session: &SessionMetadata) -> String { let updated = format_relative_time(&session.updated_at); - let title = truncate(&session.title, 32); + let raw_title = extract_title(&session.title); + let title = if raw_title == "Session" { + truncate(crate::session_manager::truncate_id(&session.id), 32) + } else { + truncate(raw_title, 32) + }; let mode = session .mode .as_deref() @@ -534,7 +621,7 @@ fn format_session_line(session: &SessionMetadata) -> String { fn build_preview_lines(session: &SavedSession) -> Vec { let mut out = Vec::new(); - out.push(format!("Title: {}", session.metadata.title)); + out.push(format!("Title: {}", extract_title(&session.metadata.title))); out.push(format!( "Updated: {}", session @@ -552,26 +639,72 @@ fn build_preview_lines(session: &SavedSession) -> Vec { } out.push("".to_string()); - for message in session.messages.iter().take(6) { - let role = message.role.to_ascii_uppercase(); - let mut text = String::new(); - for block in &message.content { - if let crate::models::ContentBlock::Text { text: body, .. } = block { - text.push_str(body); - } + for message in &session.messages { + let text = message_text_for_history(message); + if text.trim().is_empty() { + continue; } - let preview = truncate(&text.replace('\n', " "), 120); - out.push(format!("{role}: {preview}")); + out.push(format!("{}:", message.role.to_ascii_uppercase())); + for line in text.lines() { + out.push(format!(" {line}")); + } + out.push(String::new()); + } + if out.last().is_some_and(String::is_empty) { + out.pop(); } out } -fn format_preview(lines: &[String], width: u16, height: usize) -> Vec> { +fn message_text_for_history(message: &crate::models::Message) -> String { + let mut text = String::new(); + for block in &message.content { + let part = match block { + crate::models::ContentBlock::Text { text: body, .. } => { + if message.role.eq_ignore_ascii_case("user") { + extract_user_prompt(body).to_string() + } else { + strip_thinking_tags(body) + } + } + crate::models::ContentBlock::Thinking { .. } => String::new(), + crate::models::ContentBlock::ToolUse { name, input, .. } => { + format!("tool call: {name} {}", truncate(&input.to_string(), 180)) + } + crate::models::ContentBlock::ToolResult { + content, is_error, .. + } => { + let label = if is_error.unwrap_or(false) { + "tool error" + } else { + "tool result" + }; + format!("{label}: {}", truncate(&content.replace('\n', " "), 220)) + } + crate::models::ContentBlock::ServerToolUse { name, input, .. } => { + format!("server tool: {name} {}", truncate(&input.to_string(), 180)) + } + crate::models::ContentBlock::ToolSearchToolResult { content, .. } + | crate::models::ContentBlock::CodeExecutionToolResult { content, .. } => { + format!("tool result: {}", truncate(&content.to_string(), 220)) + } + }; + let part = part.trim(); + if !part.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(part); + } + } + text +} + +fn format_preview(lines: &[String]) -> Vec> { let mut out = Vec::new(); - let available = height.saturating_sub(2).max(1); - for line in lines.iter().take(available) { + for line in lines { out.push(Line::from(Span::styled( - truncate(line, width), + line.clone(), Style::default().fg(palette::TEXT_PRIMARY), ))); } @@ -678,6 +811,28 @@ mod tests { s } + fn text_message(role: &str, text: &str) -> crate::models::Message { + crate::models::Message { + role: role.to_string(), + content: vec![crate::models::ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + fn saved_session_with_messages(messages: Vec) -> SavedSession { + let mut session = crate::session_manager::create_saved_session( + &messages, + "deepseek-v4-pro", + std::path::Path::new("/tmp"), + 100, + None, + ); + session.metadata.title = "{}\nClean session title".to_string(); + session + } + fn picker_with(sessions: Vec, scope: Option<&str>) -> SessionPickerView { let workspace_scope = scope.map(PathBuf::from); let mut view = SessionPickerView { @@ -686,6 +841,8 @@ mod tests { selected: 0, list_scroll: Cell::new(0), list_visible_rows: Cell::new(8), + history_scroll: Cell::new(0), + history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false, sort_mode: SortMode::Recent, @@ -798,6 +955,85 @@ mod tests { assert!(span.style.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn build_list_lines_numbers_visible_rows_for_shortcuts() { + let sessions = vec![ + test_session(1, "first session"), + test_session(2, "second session"), + ]; + let lines = build_list_lines(&sessions, 0, 80, 0, 5, false, "", "recent", false, None); + + let rendered = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("\n"); + assert!(rendered.contains("1. session-")); + assert!(rendered.contains("2. session-")); + } + + #[test] + fn digit_shortcut_selects_visible_session_for_history() { + let sessions = vec![ + test_session(1, "first session"), + test_session(2, "second session"), + test_session(3, "third session"), + ]; + let mut view = picker_with(sessions, None); + + assert!(view.select_visible_shortcut('2')); + assert_eq!(view.selected, 1); + assert!( + view.status + .as_deref() + .is_some_and(|status| status.contains("Opened history")) + ); + assert!(!view.select_visible_shortcut('9')); + } + + #[test] + fn history_scroll_pages_and_clamps() { + let mut view = picker_with(vec![test_session(1, "first")], None); + view.current_preview = (0..20).map(|idx| format!("line {idx}")).collect(); + view.history_visible_rows.set(5); + + view.scroll_history(6); + assert_eq!(view.history_scroll.get(), 6); + view.scroll_history(100); + assert_eq!(view.history_scroll.get(), 15); + view.scroll_history(-200); + assert_eq!(view.history_scroll.get(), 0); + } + + #[test] + fn build_preview_lines_shows_full_clean_history() { + let messages = vec![ + text_message( + "user", + "{\"cache\":\"x\"}\nFirst visible prompt", + ), + text_message( + "assistant", + "hidden reasoning\nFirst visible answer", + ), + text_message("user", "Second prompt"), + text_message("assistant", "Second answer"), + text_message("user", "Third prompt"), + text_message("assistant", "Third answer"), + text_message("user", "Fourth prompt beyond old six-message preview"), + ]; + let session = saved_session_with_messages(messages); + let lines = build_preview_lines(&session).join("\n"); + + assert!(lines.contains("Title: Clean session title")); + assert!(lines.contains("First visible prompt")); + assert!(lines.contains("First visible answer")); + assert!(lines.contains("Fourth prompt beyond old six-message preview")); + assert!(!lines.contains("turn_meta")); + assert!(!lines.contains("hidden reasoning")); + } + #[test] fn ensure_selected_visible_updates_scroll_window() { let sessions = (0..10) @@ -810,6 +1046,8 @@ mod tests { selected: 0, list_scroll: Cell::new(0), list_visible_rows: Cell::new(3), + history_scroll: Cell::new(0), + history_visible_rows: Cell::new(12), search_input: String::new(), search_mode: false, sort_mode: SortMode::Recent, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c4d853cb..70a8f00e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -129,6 +129,7 @@ const SLASH_MENU_LIMIT: usize = 128; const MENTION_MENU_LIMIT: usize = 6; const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; +const COMPOSER_ARROW_SCROLL_LINES: usize = 3; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; const UI_IDLE_POLL_MS: u64 = 48; @@ -4136,7 +4137,7 @@ fn handle_composer_history_arrow( match key.code { KeyCode::Up => { if scroll_on_empty { - app.scroll_up(1); + app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); } else { app.history_up(); } @@ -4144,7 +4145,7 @@ fn handle_composer_history_arrow( } KeyCode::Down => { if scroll_on_empty { - app.scroll_down(1); + app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); } else { app.history_down(); } @@ -8412,7 +8413,10 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { match mouse.kind { MouseEventKind::ScrollUp => { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); - app.viewport.pending_scroll_delta += update.delta_lines; + app.viewport.pending_scroll_delta = app + .viewport + .pending_scroll_delta + .saturating_add(update.delta_lines); if update.delta_lines != 0 { app.user_scrolled_during_stream = true; app.needs_redraw = true; @@ -8420,7 +8424,10 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { } MouseEventKind::ScrollDown => { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down); - app.viewport.pending_scroll_delta += update.delta_lines; + app.viewport.pending_scroll_delta = app + .viewport + .pending_scroll_delta + .saturating_add(update.delta_lines); if update.delta_lines != 0 { app.user_scrolled_during_stream = true; app.needs_redraw = true; @@ -9046,11 +9053,16 @@ fn open_activity_detail_pager(app: &mut App) -> bool { app.status_message = Some("No activity detail available".to_string()); return true; }; - app.view_stack.push(PagerView::from_text( - "Activity Detail", - &text, - width.saturating_sub(2), - )); + let title = if matches!( + app.cell_at_virtual_index(idx), + Some(HistoryCell::Thinking { .. }) + ) { + "Reasoning Timeline" + } else { + "Activity Detail" + }; + app.view_stack + .push(PagerView::from_text(title, &text, width.saturating_sub(2))); true } @@ -9126,6 +9138,10 @@ fn activity_cell_rank(cell: &HistoryCell) -> Option { fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option { let cell = app.cell_at_virtual_index(cell_index)?; + if matches!(cell, HistoryCell::Thinking { .. }) { + return reasoning_timeline_text(app, cell_index); + } + let mut sections = Vec::new(); if let Some(turn_id) = app.runtime_turn_id.as_ref() { @@ -9154,6 +9170,91 @@ fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option Option { + let thinking_indices: Vec = (0..app.virtual_cell_count()) + .filter(|&idx| { + matches!( + app.cell_at_virtual_index(idx), + Some(HistoryCell::Thinking { .. }) + ) + }) + .collect(); + if thinking_indices.is_empty() { + return None; + } + + let selected_position = thinking_indices + .iter() + .position(|&idx| idx == selected_cell_index) + .map(|idx| idx + 1); + let total = thinking_indices.len(); + let running = thinking_indices.iter().any(|&idx| { + matches!( + app.cell_at_virtual_index(idx), + Some(HistoryCell::Thinking { + streaming: true, + .. + }) + ) + }); + + let mut sections = Vec::new(); + if let Some(turn_id) = app.runtime_turn_id.as_ref() { + let status = app.runtime_turn_status.as_deref().unwrap_or("in progress"); + sections.push(format!( + "Turn: {} ({status})", + truncate_line_to_width(turn_id, 24) + )); + } + sections.push("Activity: reasoning timeline".to_string()); + sections.push(format!( + "Status: {} · {total} chunk{}", + if running { "running" } else { "done" }, + if total == 1 { "" } else { "s" } + )); + if let Some(position) = selected_position { + sections.push(format!("Selected chunk: {position} of {total}")); + } + sections.push(String::new()); + + for (position, cell_index) in thinking_indices.iter().copied().enumerate() { + let Some(HistoryCell::Thinking { + content, + streaming, + duration_secs, + }) = app.cell_at_virtual_index(cell_index) + else { + continue; + }; + let position = position + 1; + let marker = if Some(position) == selected_position { + " (selected)" + } else { + "" + }; + let mut status = if *streaming { + "running".to_string() + } else { + "done".to_string() + }; + if let Some(duration_secs) = duration_secs { + status.push_str(" · "); + status.push_str(&format!("{duration_secs:.1}s")); + } + sections.push(format!("Thinking chunk {position} of {total}{marker}")); + sections.push(format!("Status: {status}")); + let body = content.trim(); + if body.is_empty() { + sections.push("(no reasoning text recorded)".to_string()); + } else { + sections.push(body.to_string()); + } + sections.push(String::new()); + } + + Some(sections.join("\n")) +} + fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String { match cell { HistoryCell::Thinking { .. } => "thinking".to_string(), diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index ea835923..777b02c0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1117,7 +1117,9 @@ fn create_test_app() -> App { notes_path: PathBuf::from("notes.txt"), mcp_config_path: PathBuf::from("mcp.json"), use_memory: false, - start_in_agent_mode: false, + // Keep UI tests independent from the developer's saved + // `default_mode` setting. + start_in_agent_mode: true, skip_onboarding: false, yolo: false, resume_session_id: None, @@ -1142,7 +1144,9 @@ fn create_test_options() -> TuiOptions { notes_path: PathBuf::from("notes.txt"), mcp_config_path: PathBuf::from("mcp.json"), use_memory: false, - start_in_agent_mode: false, + // Keep UI tests independent from the developer's saved + // `default_mode` setting. + start_in_agent_mode: true, skip_onboarding: false, yolo: false, resume_session_id: None, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 3a53b775..d25c6519 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -609,6 +609,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "theme".to_string(), + value: settings.theme.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "locale".to_string(), @@ -1040,6 +1047,7 @@ fn config_hint_for_key(key: &str) -> &'static str { | "composer_border" | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", "default_mode" => "agent | plan | yolo", @@ -2067,6 +2075,7 @@ mod tests { .collect::>(); assert!(keys.contains(&"model")); assert!(keys.contains(&"approval_mode")); + assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); assert!(keys.contains(&"background_color")); assert!(keys.contains(&"auto_compact")); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 1250e2c6..646714d4 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -55,6 +55,10 @@ pub struct ChatWidget { scrollbar: Option, jump_to_latest_button: Option, background: Color, + scroll_track: Color, + scroll_thumb: Color, + jump_border: Color, + jump_arrow: Color, } #[derive(Debug, Clone, Copy)] @@ -68,6 +72,10 @@ impl ChatWidget { pub fn new(app: &mut App, area: Rect) -> Self { let content_area = area; let background = app.ui_theme.surface_bg; + let scroll_track = app.ui_theme.border; + let scroll_thumb = app.ui_theme.status_working; + let jump_border = app.ui_theme.border; + let jump_arrow = app.ui_theme.status_working; let visible_lines = content_area.height as usize; let render_options = app.transcript_render_options(); @@ -85,6 +93,10 @@ impl ChatWidget { scrollbar: None, jump_to_latest_button: None, background, + scroll_track, + scroll_thumb, + jump_border, + jump_arrow, }; } @@ -294,6 +306,10 @@ impl ChatWidget { scrollbar, jump_to_latest_button, background, + scroll_track, + scroll_thumb, + jump_border, + jump_arrow, } } } @@ -339,14 +355,20 @@ impl Renderable for ChatWidget { .begin_symbol(None) .end_symbol(None) .track_symbol(Some("│")) - .track_style(Style::default().fg(palette::BORDER_COLOR)) + .track_style(Style::default().fg(self.scroll_track)) .thumb_symbol("┃") - .thumb_style(Style::default().fg(palette::DEEPSEEK_SKY)) + .thumb_style(Style::default().fg(self.scroll_thumb)) .render(area, buf, &mut state); } if let Some(button_area) = self.jump_to_latest_button { - render_jump_to_latest_button(button_area, buf, self.background); + render_jump_to_latest_button( + button_area, + buf, + self.background, + self.jump_border, + self.jump_arrow, + ); } } @@ -378,21 +400,25 @@ fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option { }) } -fn render_jump_to_latest_button(area: Rect, buf: &mut Buffer, background: Color) { +fn render_jump_to_latest_button( + area: Rect, + buf: &mut Buffer, + background: Color, + border: Color, + arrow: Color, +) { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::BORDER_COLOR)) + .border_style(Style::default().fg(border)) .style(Style::default().bg(background)) .render(area, buf); let arrow_x = area.x.saturating_add(1); let arrow_y = area.y.saturating_add(1); - buf[(arrow_x, arrow_y)].set_symbol("↓").set_style( - Style::default() - .fg(palette::DEEPSEEK_SKY) - .add_modifier(Modifier::BOLD), - ); + buf[(arrow_x, arrow_y)] + .set_symbol("↓") + .set_style(Style::default().fg(arrow).add_modifier(Modifier::BOLD)); } pub struct ComposerWidget<'a> { @@ -2931,6 +2957,57 @@ mod tests { assert_eq!(buf[(button.x + 1, button.y + 1)].symbol(), "↓"); } + #[test] + fn chat_widget_uses_light_theme_scroll_chrome() { + let mut app = create_test_app(); + app.ui_theme = palette::LIGHT_UI_THEME; + app.use_mouse_capture = true; + for i in 0..120 { + app.add_message(HistoryCell::User { + content: format!("user message {i}"), + }); + } + app.viewport.transcript_scroll = TranscriptScroll::at_line(0); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 8, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + + let mut saw_track = false; + let mut saw_thumb = false; + for y in 0..area.height { + let cell = &buf[(area.width - 1, y)]; + match cell.symbol() { + "│" => { + saw_track = true; + assert_eq!(cell.fg, palette::LIGHT_UI_THEME.border); + } + "┃" => { + saw_thumb = true; + assert_eq!(cell.fg, palette::LIGHT_UI_THEME.status_working); + } + _ => {} + } + } + assert!(saw_track, "scrollbar track should render"); + assert!(saw_thumb, "scrollbar thumb should render"); + + let button = app + .viewport + .jump_to_latest_button_area + .expect("button appears when transcript is not at tail"); + assert_eq!( + buf[(button.x + 1, button.y + 1)].fg, + palette::LIGHT_UI_THEME.status_working + ); + } + #[test] fn chat_widget_hides_jump_to_latest_button_at_tail() { let mut app = create_test_app(); diff --git a/crates/tui/src/vision/tools.rs b/crates/tui/src/vision/tools.rs index ff0e2e08..56cc7b4e 100644 --- a/crates/tui/src/vision/tools.rs +++ b/crates/tui/src/vision/tools.rs @@ -270,8 +270,13 @@ mod tests { let tmp = tempdir().expect("tempdir"); let ctx = ToolContext::new(tmp.path().to_path_buf()); let tool = ImageAnalyzeTool::new(fake_config()); + let outside_workspace = if cfg!(windows) { + r"C:\Windows\System32\drivers\etc\hosts" + } else { + "/etc/hosts" + }; let err = tool - .execute(json!({"image_path": "/etc/hosts"}), &ctx) + .execute(json!({"image_path": outside_workspace}), &ctx) .await .expect_err("absolute path must reject"); assert!( diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5e9cb27a..961482aa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -77,7 +77,10 @@ through unchanged for OpenAI-compatible gateways. `atlascloud` defaults to `deepseek-ai/deepseek-v4-flash` as its default model. SGLang, vLLM, and Ollama are self-hosted and can run without an API key by default. Ollama defaults to `http://localhost:11434/v1` and sends model tags such as `deepseek-coder:1.3b` -or `qwen2.5-coder:7b` unchanged. +or `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom +URLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store +unless API-key auth is explicitly requested; use an env var or config-file key +when a local server does require bearer auth. Third-party OpenAI-compatible gateways that need extra request headers can set `http_headers = { "X-Model-Provider-Id" = "your-model-provider" }` at the top @@ -295,7 +298,10 @@ replacement compaction. You can inspect or update these from the TUI with Common settings keys: -- `theme` (default, dark, light, whale) +- `theme` (`system`, `dark`, `light`, `grayscale`; default `system`): + `system` follows terminal background detection, `dark`/`light` use the + DeepSeek palettes, and `grayscale` is the low-opinion black/white theme. + Aliases such as `whale`, `mono`, and `black-white` are accepted. - `auto_compact` (on/off, default off) - `paste_burst_detection` (on/off, default on): fallback rapid-key paste detection for terminals that do not emit bracketed-paste events. This is diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index e717671e..eaf37724 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -16,7 +16,7 @@ Bindings are not (yet) user-configurable — tracked for a future release (#436, | `Shift-Tab` | Cycle reasoning effort: off → high → max → off | | `Ctrl-R` | Open the resume-session picker | | `Ctrl-L` | Refresh / clear the screen | -| `Ctrl-O` | Open Activity Detail for the selected, live, or most recent activity when the composer is empty | +| `Ctrl-O` | Open Activity Detail for selected/live/recent tool work, or the full reasoning timeline for thinking blocks when the composer is empty | | `Ctrl-Shift-E` / `Cmd-Shift-E` | Toggle the file-tree sidebar | | `Esc` | Close topmost modal · cancel slash menu · dismiss toast | @@ -81,6 +81,20 @@ When `[memory] enabled = true`, typing `# foo` and pressing `Enter` appends `foo | `Enter` / `Tab` | Run / complete the highlighted command | | `Esc` | Dismiss palette | +## Session Picker (`Ctrl-R` or `/sessions`) + +| Chord | Action | +|----------------------|-----------------------------------------------------| +| `↑` / `↓` / `j` / `k`| Move selection in the session list | +| `1`-`9` | Open the visible session history at that list slot | +| `PgUp` / `PgDn` | Page the history pane | +| `Enter` | Resume the selected session | +| `/` | Search sessions | +| `s` | Cycle sort order | +| `a` | Toggle current-workspace scope vs all workspaces | +| `d` | Delete selected session after confirmation | +| `Esc` / `q` | Close the picker | + ## Approval modal (when a tool requests approval) | Chord | Action | diff --git a/docs/MODES.md b/docs/MODES.md index 4b996166..1e22f1d4 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -78,6 +78,9 @@ See `MCP.md`. Run `deepseek --help` for the canonical list. Common flags: - `-p, --prompt `: one-shot prompt mode (prints and exits) +- `deepseek exec --output-format stream-json `: emit one JSON object per line for harnesses and backend wrappers +- `deepseek exec --resume ` / `--session-id `: continue a saved session non-interactively +- `deepseek exec --continue `: continue the most recent saved session for this workspace non-interactively - `--model `: when using the `deepseek` facade, forward a DeepSeek model override to the TUI - `--workspace `: workspace root for file tools - `--yolo`: start in YOLO mode diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 6b7ab033..16365dce 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -177,6 +177,21 @@ Large RLM outputs should come back as `var_handle`s. Use `handle_read` for bounded text slices, line ranges, counts, or JSONPath projections instead of replaying the full value into the parent transcript. +Inside `rlm_eval`, the loaded source is available as `_context`; `content` is +also bound as a convenience alias because agents naturally reach for it during +Python analysis. The shorter `context` and `ctx` names are intentionally not +bound so user variables can use them without colliding with the bootstrap. + +Child-call timeouts are session policy: use `rlm_configure` with +`sub_query_timeout_secs` before running a large fan-out. The helpers +`sub_query`, `sub_query_batch`, `sub_query_map`, and `sub_rlm` accept a +`timeout_secs` keyword for compatibility with common agent guesses, but the +effective timeout remains configured at the RLM session level. + +`finalize(value, confidence=...)` preserves JSON-serializable values. Strings +become text handles; dicts, lists, numbers, booleans, and null become JSON +handles that `handle_read` can project with JSONPath. + ### Session relay `/relay [focus]` asks the current agent to write `.deepseek/handoff.md` as a diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 87f2a4f9..c1c1e679 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.8.32", - "deepseekBinaryVersion": "0.8.32", + "version": "0.8.33", + "deepseekBinaryVersion": "0.8.33", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",