feat: harvest 6 community PRs for v0.8.47

Harvested and vetted — no malware, no external deps, no injection:
- #1859 (@harvey2011888): loop guard now reports Failed on halt
- #1870 (@victorcheng2333): honour DEEPSEEK_YOLO env on startup
- #1935 (@IIzzaya): replace [x] with [✓] completion markers
- #1837 (@PurplePulse): fix macOS title centering (pin to top)
- #1967 (@cyq1017): show base_url in /config view
- #1906 (@knqiufan): copy transcript without visual-wrap newlines

Also fix cycle_manager archive_dir_for to use resolve_state_dir
so recall_archive tests pass with the migrated sessions path.

Co-authored-by: victorcheng2333 <victorcheng2333@users.noreply.github.com>
Co-authored-by: IIzzaya <IIzzaya@users.noreply.github.com>
Co-authored-by: PurplePulse <PurplePulse@users.noreply.github.com>
Co-authored-by: cyq1017 <cyq1017@users.noreply.github.com>
Co-authored-by: knqiufan <knqiufan@users.noreply.github.com>
This commit is contained in:
Hunter Bown
2026-05-26 14:34:21 -05:00
parent 8ed4301d35
commit 236ad4137d
23 changed files with 691 additions and 107 deletions
+174 -6
View File
@@ -5,7 +5,9 @@ use std::time::Duration;
use super::CommandResult;
use crate::client::DeepSeekClient;
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name_for_provider};
use crate::config::{
COMMON_DEEPSEEK_MODELS, Config, clear_api_key, expand_path, normalize_model_name_for_provider,
};
use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::llm_client::LlmClient;
use crate::localization::resolve_locale;
@@ -122,6 +124,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
}
}
"approval_mode" | "approval" => Some(app.approval_mode.label().to_string()),
"base_url" => {
let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
{
Ok(config) => config,
Err(err) => {
return CommandResult::error(format!("Failed to load config: {err}"));
}
};
Some(config.deepseek_base_url())
}
"locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
"theme" | "ui_theme" => {
Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
@@ -284,7 +296,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu
use anyhow::Context;
use std::fs;
let path = config_toml_path()?;
let path = config_toml_path(None)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
@@ -320,11 +332,15 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu
Ok(path)
}
pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
pub fn persist_root_string_key(
config_path: Option<&Path>,
key: &str,
value: &str,
) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;
let path = config_toml_path()?;
let path = config_toml_path(config_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
@@ -351,8 +367,11 @@ pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf
/// Resolve the path to `~/.deepseek/config.toml` (or
/// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
/// never write to a different file than the one we read.
pub(super) fn config_toml_path() -> anyhow::Result<PathBuf> {
pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
use anyhow::Context;
if let Some(path) = config_path {
return Ok(expand_path(path.to_string_lossy().as_ref()));
}
if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let trimmed = env.trim();
if !trimmed.is_empty() {
@@ -417,7 +436,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
app.mcp_config_path = PathBuf::from(expand_tilde(value));
app.mcp_restart_required = true;
let message = if persist {
match persist_root_string_key("mcp_config_path", value) {
match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value)
{
Ok(path) => format!(
"mcp_config_path = {} (saved to {}; restart required for MCP tool pool)",
app.mcp_config_path.display(),
@@ -433,6 +453,26 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
};
return CommandResult::message(message);
}
"base_url" => {
let value = value.trim();
if value.is_empty() {
return CommandResult::error("base_url cannot be empty");
}
if persist {
match persist_root_string_key(app.config_path.as_deref(), "base_url", value) {
Ok(path) => {
return CommandResult::message(format!(
"base_url = {value} (saved to {})",
path.display()
));
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
return CommandResult::error(format!(
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving."
));
}
_ => {}
}
@@ -1750,6 +1790,134 @@ mod tests {
assert!(saved.contains("cost_currency = \"cny\""));
}
#[test]
fn config_command_base_url_save_persists_value() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(
&mut app,
Some("base_url https://example.internal.local/v1 --save"),
);
let msg = result.message.unwrap();
let saved_path = config_toml_path(None).unwrap();
let saved = fs::read_to_string(&saved_path).unwrap();
assert_eq!(
msg,
format!(
"base_url = https://example.internal.local/v1 (saved to {})",
saved_path.display()
)
);
assert!(saved.contains("base_url = \"https://example.internal.local/v1\""));
}
#[test]
fn config_command_base_url_without_save_requires_save() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = config_command(&mut app, Some("base_url https://example.internal.local/v1"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(
msg.contains("base_url must be saved with --save"),
"got {msg}"
);
}
#[test]
fn config_command_base_url_reads_current_value_from_config() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-show-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(
&config_path,
"base_url = \"https://api.from-config.local/v1\"\n",
)
.unwrap();
let mut app = create_test_app();
let result = config_command(&mut app, Some("base_url"));
let msg = result.message.unwrap();
assert_eq!(msg, "base_url = https://api.from-config.local/v1");
}
#[test]
fn config_command_base_url_reads_current_value_from_app_config_path() {
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-app-config-path-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
fs::write(
&config_path,
"base_url = \"https://api.from-app-path.local/v1\"\n",
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("base_url"));
let msg = result.message.unwrap();
assert_eq!(msg, "base_url = https://api.from-app-path.local/v1");
}
#[test]
fn config_command_base_url_save_persists_to_app_config_path() {
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-save-app-path-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(
&mut app,
Some("base_url https://example.session.local/v1 --save"),
);
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert_eq!(
msg,
format!(
"base_url = https://example.session.local/v1 (saved to {})",
config_path.display()
)
);
assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
}
#[test]
fn theme_command_accepts_grayscale_arg() {
let nanos = SystemTime::now()
+6 -2
View File
@@ -702,8 +702,12 @@ pub fn persist_status_items(
}
/// Persist a root-level string key in `config.toml`.
pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<std::path::PathBuf> {
config::persist_root_string_key(key, value)
pub fn persist_root_string_key(
config_path: Option<&std::path::Path>,
key: &str,
value: &str,
) -> anyhow::Result<std::path::PathBuf> {
config::persist_root_string_key(config_path, key, value)
}
pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String {
+3 -3
View File
@@ -70,7 +70,7 @@ enum NetworkEdit {
}
fn list_policy() -> anyhow::Result<String> {
let path = super::config::config_toml_path()?;
let path = super::config::config_toml_path(None)?;
let doc = load_config_doc(&path)?;
let network = doc.get("network").and_then(Value::as_table);
let default = network
@@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result<String> {
}
fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result<String> {
let path = super::config::config_toml_path()?;
let path = super::config::config_toml_path(None)?;
let mut doc = load_config_doc(&path)?;
let network = network_table_mut(&mut doc)?;
@@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result<String> {
_ => bail!("Usage: /network default <allow|deny|prompt>"),
};
let path = super::config::config_toml_path()?;
let path = super::config::config_toml_path(None)?;
let mut doc = load_config_doc(&path)?;
let network = network_table_mut(&mut doc)?;
network.insert("default".to_string(), Value::String(normalized.to_string()));
+1 -1
View File
@@ -441,7 +441,7 @@ fn sync_skills(app: &mut App) -> CommandResult {
}
SkillSyncOutcome::Denied { name, host } => {
failed += 1;
let _ = writeln!(out, " [x] {name} — network denied ({host})");
let _ = writeln!(out, " [] {name} — network denied ({host})");
}
SkillSyncOutcome::NeedsApproval { name, host } => {
failed += 1;
+5 -1
View File
@@ -687,7 +687,11 @@ fn apply_reasoning_effort(
app.last_effective_reasoning_effort = None;
app.update_model_compaction_budget();
if persist {
commands::persist_root_string_key("reasoning_effort", effort.as_setting())?;
commands::persist_root_string_key(
app.config_path.as_deref(),
"reasoning_effort",
effort.as_setting(),
)?;
}
config.reasoning_effort = Some(effort.as_setting().to_string());
Ok(())
+3 -1
View File
@@ -1944,7 +1944,9 @@ impl Engine {
if let Some(message) = loop_guard_halt {
crate::logging::warn(message.clone());
let _ = self.tx_event.send(Event::status(message)).await;
let _ = self.tx_event.send(Event::status(message.clone())).await;
// 设置 turn_error 以确保最终返回 TurnOutcomeStatus::Failed 而非 Completed
turn_error = Some(message);
break;
}
+12 -9
View File
@@ -284,7 +284,7 @@ impl StructuredState {
let marker = match item.status {
crate::tools::todo::TodoStatus::Pending => "[ ]",
crate::tools::todo::TodoStatus::InProgress => "[~]",
crate::tools::todo::TodoStatus::Completed => "[x]",
crate::tools::todo::TodoStatus::Completed => "[]",
};
out.push_str(&format!("- {marker} {}\n", item.content));
}
@@ -299,7 +299,7 @@ impl StructuredState {
let marker = match item.status {
crate::tools::plan::StepStatus::Pending => "[ ]",
crate::tools::plan::StepStatus::InProgress => "[~]",
crate::tools::plan::StepStatus::Completed => "[x]",
crate::tools::plan::StepStatus::Completed => "[]",
};
out.push_str(&format!("- {marker} {}\n", item.step));
}
@@ -463,14 +463,17 @@ pub struct CycleArchiveHeader {
pub message_count: usize,
}
/// Resolve the on-disk archive directory: `~/.deepseek/sessions/<id>/cycles`.
/// Resolve the on-disk archive directory: `~/.codewhale/sessions/<id>/cycles`
/// (or legacy `~/.deepseek/sessions/<id>/cycles`).
fn archive_dir_for(session_id: &str) -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?;
Ok(home
.join(".deepseek")
.join("sessions")
.join(session_id)
.join("cycles"))
let sessions = codewhale_config::resolve_state_dir("sessions")
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".deepseek")
.join("sessions")
});
Ok(sessions.join(session_id).join("cycles"))
}
/// Archive a cycle's messages to JSONL on disk and return the path written.
+13 -5
View File
@@ -839,8 +839,12 @@ async fn main() -> Result<()> {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
// The `deepseek` launcher forwards `--yolo` to this binary via
// the DEEPSEEK_YOLO env var (which the config loader folds into
// `config.yolo`), not as a CLI flag. Honour either source.
let yolo = cli.yolo || config.yolo.unwrap_or(false);
let needs_engine = args.auto
|| cli.yolo
|| yolo
|| resume_session_id.is_some()
|| args.output_format == ExecOutputFormat::StreamJson;
if needs_engine {
@@ -848,7 +852,7 @@ async fn main() -> Result<()> {
|| config.max_subagents(),
|value| value.clamp(1, MAX_SUBAGENTS),
);
let auto_mode = args.auto || cli.yolo;
let auto_mode = args.auto || yolo;
run_exec_agent(
&config,
&model,
@@ -4869,6 +4873,10 @@ async fn run_interactive(
let _ = manager.cleanup_old_sessions();
}
// The `deepseek` launcher forwards `--yolo` to this binary via the
// DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
let yolo = cli.yolo || config.yolo.unwrap_or(false);
tui::run_tui(
config,
tui::TuiOptions {
@@ -4876,7 +4884,7 @@ async fn run_interactive(
workspace,
config_path: cli.config.clone(),
config_profile: cli.profile.clone(),
allow_shell: cli.yolo || config.allow_shell(),
allow_shell: yolo || config.allow_shell(),
use_alt_screen,
use_mouse_capture,
use_bracketed_paste,
@@ -4885,9 +4893,9 @@ async fn run_interactive(
notes_path: config.notes_path(),
mcp_config_path: config.mcp_config_path(),
use_memory: config.memory_enabled(),
start_in_agent_mode: cli.yolo,
start_in_agent_mode: yolo,
skip_onboarding: cli.skip_onboarding,
yolo: cli.yolo, // YOLO mode auto-approves all tool executions
yolo, // YOLO mode auto-approves all tool executions
resume_session_id,
initial_input,
max_subagents,
+4
View File
@@ -770,6 +770,10 @@ impl Settings {
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
(
"base_url",
"HTTP base URL for DeepSeek-compatible endpoints.",
),
(
"locale",
"UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419",
+92 -5
View File
@@ -15,6 +15,7 @@ use crate::tools::review::ReviewOutput;
use crate::tui::app::TranscriptSpacing;
use crate::tui::diff_render;
use crate::tui::markdown_render;
use crate::tui::ui_text::CopyLineSeparator;
// === Constants ===
@@ -158,6 +159,12 @@ pub struct TranscriptRenderOptions {
pub spacing: TranscriptSpacing,
}
pub(crate) struct RenderedTranscriptLine {
pub line: Line<'static>,
pub copy_prefix_width: usize,
pub copy_separator_after: CopyLineSeparator,
}
impl Default for TranscriptRenderOptions {
fn default() -> Self {
Self {
@@ -296,6 +303,39 @@ impl HistoryCell {
}
}
pub(crate) fn lines_with_copy_metadata(
&self,
width: u16,
options: TranscriptRenderOptions,
) -> Vec<RenderedTranscriptLine> {
match self {
HistoryCell::User { content } => render_message_with_copy_metadata(
USER_GLYPH,
user_label_style(),
user_body_style(),
content,
width,
),
HistoryCell::Assistant { content, streaming } => render_message_with_copy_metadata(
ASSISTANT_GLYPH,
assistant_label_style_for(*streaming, options.low_motion),
message_body_style(),
content,
width,
),
HistoryCell::System { content } if !is_cycle_boundary(content) => {
render_message_with_copy_metadata(
"Note",
system_label_style(),
system_body_style(),
content,
width,
)
}
_ => hard_break_copy_lines(self.lines_with_options(width, options)),
}
}
/// Render the cell in transcript mode: full content, no caps, no
/// "Alt+V for details" affordances.
///
@@ -2193,6 +2233,19 @@ fn render_message(
content: &str,
width: u16,
) -> Vec<Line<'static>> {
render_message_with_copy_metadata(prefix, label_style, body_style, content, width)
.into_iter()
.map(|rendered| rendered.line)
.collect()
}
fn render_message_with_copy_metadata(
prefix: &str,
label_style: Style,
body_style: Style,
content: &str,
width: u16,
) -> Vec<RenderedTranscriptLine> {
let prefix_width = UnicodeWidthStr::width(prefix);
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1));
@@ -2200,7 +2253,7 @@ fn render_message(
let rendered =
markdown_render::render_markdown_tagged(content, content_width as u16, body_style);
for (idx, rendered_line) in rendered.into_iter().enumerate() {
if idx == 0 {
let line = if idx == 0 {
let mut spans = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(
@@ -2210,7 +2263,7 @@ fn render_message(
spans.push(Span::raw(" "));
}
spans.extend(rendered_line.line.spans);
lines.push(Line::from(spans));
Line::from(spans)
} else {
let indent = if prefix.is_empty() {
String::new()
@@ -2225,15 +2278,49 @@ fn render_message(
let rail_style = Style::default().fg(palette::TEXT_DIM);
let mut spans = vec![Span::styled(indent, rail_style)];
spans.extend(rendered_line.line.spans);
lines.push(Line::from(spans));
}
Line::from(spans)
};
lines.push(RenderedTranscriptLine {
line,
copy_prefix_width: rendered_line.copy_prefix_width
+ history_copy_prefix_width(prefix, prefix_width, rendered_line.is_code, idx),
copy_separator_after: rendered_line.copy_separator_after,
});
}
if lines.is_empty() {
lines.push(Line::from(""));
lines.push(RenderedTranscriptLine {
line: Line::from(""),
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
lines
}
fn history_copy_prefix_width(
prefix: &str,
prefix_width: usize,
is_code: bool,
line_index: usize,
) -> usize {
if line_index > 0 && is_code && !prefix.is_empty() {
prefix_width + 1
} else {
0
}
}
fn hard_break_copy_lines(lines: Vec<Line<'static>>) -> Vec<RenderedTranscriptLine> {
lines
.into_iter()
.map(|line| RenderedTranscriptLine {
line,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
})
.collect()
}
/// Render a plain-text user message: split on newlines, word-wrap each line,
/// preserve leading whitespace. No markdown interpretation (headings, lists,
/// code blocks, etc. are rendered as literal text).
+111 -44
View File
@@ -33,6 +33,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::palette;
use crate::tui::osc8;
use crate::tui::ui_text::CopyLineSeparator;
// Thread-local counter incremented every time `parse` runs. Used by tests to
// prove that width-only changes hit the cached-AST path and skip parsing.
@@ -101,6 +102,8 @@ pub struct ParsedMarkdown {
pub struct RenderedMarkdownLine {
pub line: Line<'static>,
pub is_code: bool,
pub copy_prefix_width: usize,
pub copy_separator_after: CopyLineSeparator,
}
/// Parse markdown source into a width-independent block AST.
@@ -227,6 +230,8 @@ pub fn render_parsed_tagged(
.map(|line| RenderedMarkdownLine {
line,
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}),
);
continue;
@@ -246,6 +251,8 @@ pub fn render_parsed_tagged(
Style::default().fg(palette::TEXT_DIM),
)),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::HorizontalRule => {
@@ -255,18 +262,19 @@ pub fn render_parsed_tagged(
Style::default().fg(palette::TEXT_DIM),
)),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::ListItem { bullet, text } => {
let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY);
out.extend(
render_list_line(bullet, text, width, bullet_style, base_style)
.into_iter()
.map(|line| RenderedMarkdownLine {
line,
is_code: false,
}),
);
out.extend(render_list_line_tagged(
bullet,
text,
width,
bullet_style,
base_style,
));
}
Block::Code { line } => {
let code_style = Style::default()
@@ -280,19 +288,16 @@ pub fn render_parsed_tagged(
let link_style = Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::UNDERLINED);
out.extend(
render_line_with_links(text, width, base_style, link_style)
.into_iter()
.map(|line| RenderedMarkdownLine {
line,
is_code: false,
}),
);
out.extend(render_line_with_links_tagged(
text, width, base_style, link_style,
));
}
Block::Blank => {
out.push(RenderedMarkdownLine {
line: Line::from(""),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
Block::TableRow(_) | Block::TableSeparator => unreachable!(),
@@ -304,6 +309,8 @@ pub fn render_parsed_tagged(
out.push(RenderedMarkdownLine {
line: Line::from(""),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
@@ -484,6 +491,7 @@ fn render_wrapped_line_tagged(
};
let mut out = Vec::new();
let last_index = wrapped.len().saturating_sub(1);
for (idx, chunk) in wrapped.into_iter().enumerate() {
let line = if idx == 0 {
Line::from(vec![Span::raw(prefix), Span::styled(chunk, style)])
@@ -493,47 +501,87 @@ fn render_wrapped_line_tagged(
Span::styled(chunk, style),
])
};
out.push(RenderedMarkdownLine { line, is_code });
let copy_separator_after = if idx == last_index {
CopyLineSeparator::Newline
} else if is_code {
CopyLineSeparator::None
} else {
CopyLineSeparator::Space
};
out.push(RenderedMarkdownLine {
line,
is_code,
copy_prefix_width: if indent_code { prefix_width } else { 0 },
copy_separator_after,
});
}
out
}
fn render_list_line(
fn render_list_line_tagged(
bullet: &str,
text: &str,
width: usize,
bullet_style: Style,
text_style: Style,
) -> Vec<Line<'static>> {
) -> Vec<RenderedMarkdownLine> {
let bullet_prefix = format!("{bullet} ");
let bullet_width = bullet_prefix.width();
let available = width.saturating_sub(bullet_width).max(1);
let wrapped = render_line_with_links(text, available, text_style, link_style());
let wrapped = render_line_with_links_tagged(text, available, text_style, link_style());
let mut out = Vec::new();
for (idx, line) in wrapped.into_iter().enumerate() {
for (idx, rendered) in wrapped.into_iter().enumerate() {
if idx == 0 {
let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)];
spans.extend(line.spans);
out.push(Line::from(spans));
spans.extend(rendered.line.spans);
out.push(RenderedMarkdownLine {
line: Line::from(spans),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: rendered.copy_separator_after,
});
} else {
let mut spans = vec![Span::raw(" ".repeat(bullet_width))];
spans.extend(line.spans);
out.push(Line::from(spans));
spans.extend(rendered.line.spans);
out.push(RenderedMarkdownLine {
line: Line::from(spans),
is_code: false,
copy_prefix_width: bullet_width,
copy_separator_after: rendered.copy_separator_after,
});
}
}
out
}
#[cfg(test)]
fn render_line_with_links(
line: &str,
width: usize,
base_style: Style,
link_style: Style,
) -> Vec<Line<'static>> {
render_line_with_links_tagged(line, width, base_style, link_style)
.into_iter()
.map(|rendered| rendered.line)
.collect()
}
fn render_line_with_links_tagged(
line: &str,
width: usize,
base_style: Style,
link_style: Style,
) -> Vec<RenderedMarkdownLine> {
if line.trim().is_empty() {
return vec![Line::from("")];
return vec![RenderedMarkdownLine {
line: Line::from(""),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}];
}
// Flatten inline tokens into (word, style) pairs preserving inter-token spaces.
@@ -558,8 +606,8 @@ fn render_line_with_links(
}
}
let mut lines = Vec::new();
let mut current_spans: Vec<Span> = Vec::new();
let mut lines: Vec<RenderedMarkdownLine> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut current_width = 0usize;
for word in words {
@@ -581,12 +629,7 @@ fn render_line_with_links(
if ww > width && width > 0 {
// Flush the in-progress line first.
if !current_spans.is_empty() {
if let Some(last) = current_spans.last()
&& last.content.as_ref() == " "
{
current_spans.pop();
}
lines.push(Line::from(std::mem::take(&mut current_spans)));
push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space);
current_width = 0;
}
// Char-break the word into width-sized chunks. Each full chunk
@@ -597,7 +640,12 @@ fn render_line_with_links(
for ch in word.text.chars() {
let cw = ch.width().unwrap_or(1);
if chunk_w + cw > width && chunk_w > 0 {
lines.push(Line::from(vec![word.span_for(std::mem::take(&mut chunk))]));
lines.push(RenderedMarkdownLine {
line: Line::from(vec![word.span_for(std::mem::take(&mut chunk))]),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::None,
});
chunk_w = 0;
}
chunk.push(ch);
@@ -612,13 +660,7 @@ fn render_line_with_links(
// Wrap before this word if it doesn't fit.
if current_width > 0 && current_width + ww > width {
// Trim trailing space span before breaking.
if let Some(last) = current_spans.last()
&& last.content.as_ref() == " "
{
current_spans.pop();
}
lines.push(Line::from(current_spans));
current_spans = Vec::new();
push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space);
current_width = 0;
}
current_spans.push(word.into_span());
@@ -626,14 +668,39 @@ fn render_line_with_links(
}
if !current_spans.is_empty() {
lines.push(Line::from(current_spans));
push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Newline);
} else if let Some(last) = lines.last_mut() {
last.copy_separator_after = CopyLineSeparator::Newline;
}
if lines.is_empty() {
lines.push(Line::from(""));
lines.push(RenderedMarkdownLine {
line: Line::from(""),
is_code: false,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
});
}
lines
}
fn push_inline_line(
lines: &mut Vec<RenderedMarkdownLine>,
spans: &mut Vec<Span<'static>>,
copy_separator_after: CopyLineSeparator,
) {
if let Some(last) = spans.last()
&& last.content.as_ref() == " "
{
spans.pop();
}
lines.push(RenderedMarkdownLine {
line: Line::from(std::mem::take(spans)),
is_code: false,
copy_prefix_width: 0,
copy_separator_after,
});
}
#[derive(Clone)]
struct InlineToken {
text: String,
+35 -10
View File
@@ -744,9 +744,14 @@ pub(crate) fn selection_to_text(app: &App) -> Option<String> {
let end_index = end.line_index.min(lines.len().saturating_sub(1));
let start_index = start.line_index.min(end_index);
let mut selected_lines = Vec::new();
let line_meta = app.viewport.transcript_cache.line_meta();
let mut selected = String::new();
let mut separator_before = None;
#[allow(clippy::needless_range_loop)]
for line_index in start_index..=end_index {
if let Some(separator) = separator_before {
selected.push_str(separator);
}
// Rail-prefix decorations are stored as cache metadata rather than
// detected from glyphs, so new decoration types are covered without
// changes to the copy path (#1163).
@@ -755,30 +760,50 @@ pub(crate) fn selection_to_text(app: &App) -> Option<String> {
// slice off the rail prefix so subsequent column offsets operate
// on content-only text.
let full_text = line_to_plain(&lines[line_index]);
let line_text = if rail_width > 0 {
let line_after_rail = if rail_width > 0 {
slice_text(&full_text, rail_width, text_display_width(&full_text))
} else {
full_text
};
let line_after_rail_width = text_display_width(&line_after_rail);
let copy_prefix_width = line_meta
.get(line_index)
.map(|meta| meta.copy_prefix_width())
.unwrap_or(0)
.min(line_after_rail_width);
let line_text = if copy_prefix_width > 0 {
slice_text(&line_after_rail, copy_prefix_width, line_after_rail_width)
} else {
line_after_rail
};
let line_width = text_display_width(&line_text);
let visual_prefix_width = rail_width.saturating_add(copy_prefix_width);
// Selection coordinates are recorded in rendered-column space, which
// includes the visual rail prefix. Add rail_width back so the column
// window maps correctly into the rail-stripped text.
// includes visual prefixes. Add them back so the column window maps
// correctly into copy-only text.
let (raw_col_start, raw_col_end) = if start_index == end_index {
(start.column, end.column)
} else if line_index == start_index {
(start.column, line_width.saturating_add(rail_width))
(start.column, line_width.saturating_add(visual_prefix_width))
} else if line_index == end_index {
(0, end.column)
} else {
(0, line_width.saturating_add(rail_width))
(0, line_width.saturating_add(visual_prefix_width))
};
let col_start = raw_col_start.saturating_sub(rail_width).min(line_width);
let col_end = raw_col_end.saturating_sub(rail_width).min(line_width);
let col_start = raw_col_start
.saturating_sub(visual_prefix_width)
.min(line_width);
let col_end = raw_col_end
.saturating_sub(visual_prefix_width)
.min(line_width);
let slice = slice_text(&line_text, col_start, col_end);
selected_lines.push(slice);
selected.push_str(&slice);
separator_before = line_meta
.get(line_index)
.map(|meta| meta.copy_separator_after().as_str())
.or(Some("\n"));
}
Some(selected_lines.join("\n"))
Some(selected)
}
+4 -3
View File
@@ -22,11 +22,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK));
f.render_widget(block, area);
const TOP_MARGIN: u16 = 2;
let content_width = 76.min(area.width.saturating_sub(4));
let content_height = 20.min(area.height.saturating_sub(4));
let content_height = 20.min(area.height.saturating_sub(TOP_MARGIN + 2));
let content_area = Rect {
x: (area.width - content_width) / 2,
y: (area.height - content_height) / 2,
x: (area.width.saturating_sub(content_width)) / 2,
y: TOP_MARGIN,
width: content_width,
height: content_height,
};
+28
View File
@@ -17,6 +17,8 @@
use std::time::{Duration, Instant};
use crate::tui::ui_text::CopyLineSeparator;
const TRACKPAD_EVENT_WINDOW: Duration = Duration::from_millis(35);
const WHEEL_LINES_PER_TICK: i32 = 3;
const TRACKPAD_BASE_LINES_PER_TICK: i32 = 1;
@@ -36,6 +38,8 @@ pub enum TranscriptLineMeta {
CellLine {
cell_index: usize,
line_in_cell: usize,
copy_prefix_width: usize,
copy_separator_after: CopyLineSeparator,
},
Spacer,
}
@@ -48,10 +52,32 @@ impl TranscriptLineMeta {
TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
..
} => Some((cell_index, line_in_cell)),
TranscriptLineMeta::Spacer => None,
}
}
#[must_use]
pub fn copy_separator_after(&self) -> CopyLineSeparator {
match *self {
TranscriptLineMeta::CellLine {
copy_separator_after,
..
} => copy_separator_after,
TranscriptLineMeta::Spacer => CopyLineSeparator::Newline,
}
}
#[must_use]
pub fn copy_prefix_width(&self) -> usize {
match *self {
TranscriptLineMeta::CellLine {
copy_prefix_width, ..
} => copy_prefix_width,
TranscriptLineMeta::Spacer => 0,
}
}
}
// === Transcript Scroll State ===
@@ -271,6 +297,8 @@ mod tests {
TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
copy_prefix_width: 0,
copy_separator_after: CopyLineSeparator::Newline,
}
}
+10 -10
View File
@@ -442,7 +442,7 @@ fn push_work_checklist_lines(
let (prefix, color) = match item.status {
TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED),
TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING),
TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS),
TodoStatus::Completed => ("[]", palette::STATUS_SUCCESS),
};
let text = format!("{prefix} #{} {}", item.id, item.content);
lines.push(Line::from(Span::styled(
@@ -533,7 +533,7 @@ fn push_work_strategy_lines(
let (prefix, color) = match step.status {
StepStatus::Pending => ("[ ]", theme.plan_pending_color),
StepStatus::InProgress => ("[~]", theme.plan_in_progress_color),
StepStatus::Completed => ("[x]", theme.plan_completed_color),
StepStatus::Completed => ("[]", theme.plan_completed_color),
};
let mut text = format!("{prefix} {}", step.text);
if !step.elapsed.is_empty() {
@@ -1361,7 +1361,7 @@ fn first_nonempty_line(text: &str) -> &str {
fn tool_status_marker(status: ToolStatus) -> (&'static str, ratatui::style::Color) {
match status {
ToolStatus::Running => ("[~]", palette::STATUS_WARNING),
ToolStatus::Success => ("[x]", palette::STATUS_SUCCESS),
ToolStatus::Success => ("[]", palette::STATUS_SUCCESS),
ToolStatus::Failed => ("[!]", palette::STATUS_ERROR),
}
}
@@ -1656,7 +1656,7 @@ pub fn subagent_panel_lines(
fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) {
match status {
"running" => ("[~]", palette::STATUS_WARNING),
"done" => ("[x]", palette::STATUS_SUCCESS),
"done" => ("[]", palette::STATUS_SUCCESS),
"failed" => ("[!]", palette::STATUS_ERROR),
"canceled" | "interrupted" => ("[-]", palette::TEXT_MUTED),
_ => ("[ ]", palette::TEXT_MUTED),
@@ -2152,7 +2152,7 @@ mod tests {
"recent section missing: {text:?}"
);
assert!(
text.iter().any(|line| line.contains("[x] read_file")),
text.iter().any(|line| line.contains("[] read_file")),
"recent read_file row missing: {text:?}"
);
}
@@ -2181,7 +2181,7 @@ mod tests {
let text = lines_to_text(&task_panel_lines(&app, 64, 8));
assert!(
!text.iter().any(|line| line.contains("[x] read_file")),
!text.iter().any(|line| line.contains("[] read_file")),
"expired completed active row should leave the sidebar: {text:?}"
);
}
@@ -2219,7 +2219,7 @@ mod tests {
let text = lines_to_text(&task_panel_lines(&app, 64, 8));
assert!(
text.iter().any(|line| line.contains("[x] read_file")),
text.iter().any(|line| line.contains("[] read_file")),
"fresh completed active row should linger briefly: {text:?}"
);
}
@@ -2372,7 +2372,7 @@ mod tests {
.expect("failed grep row should stay visible");
let read_group_index = text
.iter()
.position(|line| line.contains("[x] read_file x3"))
.position(|line| line.contains("[] read_file x3"))
.expect("repeated read_file rows should collapse");
assert!(
@@ -2381,7 +2381,7 @@ mod tests {
);
assert_eq!(
text.iter()
.filter(|line| line.contains("[x] read_file"))
.filter(|line| line.contains("[] read_file"))
.count(),
1,
"read_file should render once after grouping: {text:?}"
@@ -2481,7 +2481,7 @@ mod tests {
assert!(
text.iter()
.any(|line| line.contains("[x] cargo check 1.2s")),
.any(|line| line.contains("[] cargo check 1.2s")),
"status marker and duration should stay in the row label: {text:?}"
);
assert!(
+30 -3
View File
@@ -26,6 +26,7 @@ use ratatui::{
use crate::tui::app::TranscriptSpacing;
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
use crate::tui::scrolling::TranscriptLineMeta;
use crate::tui::ui_text::CopyLineSeparator;
/// Per-cell cached render output. Reused across `ensure` calls when the
/// upstream cell's revision counter hasn't changed.
@@ -45,6 +46,12 @@ struct CachedCell {
/// Rendered lines for this cell (without trailing inter-cell spacers),
/// shared via `Arc` so cache enumeration is O(N) not O(N*lines).
lines: Arc<Vec<Line<'static>>>,
/// Copy separators aligned with `lines`. These preserve source hard
/// newlines while allowing copy to remove visual soft-wrap breaks.
copy_separators: Arc<Vec<CopyLineSeparator>>,
/// Display-column widths of visual prefixes that should be omitted from
/// clipboard text, aligned with `lines`.
copy_prefix_widths: Arc<Vec<usize>>,
/// Whether this cell's rendered output was empty (e.g. Thinking hidden).
/// Cached so we can skip empty cells without re-rendering.
is_empty: bool,
@@ -183,11 +190,21 @@ impl TranscriptViewCache {
} else {
width
};
let rendered = cell.lines_with_options(render_width, options);
let is_empty = rendered.is_empty();
let rendered = cell.lines_with_copy_metadata(render_width, options);
let mut lines = Vec::with_capacity(rendered.len());
let mut copy_separators = Vec::with_capacity(rendered.len());
let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
for rendered_line in rendered {
lines.push(rendered_line.line);
copy_prefix_widths.push(rendered_line.copy_prefix_width);
copy_separators.push(rendered_line.copy_separator_after);
}
let is_empty = lines.is_empty();
new_per_cell.push(CachedCell {
revision: current_rev,
lines: Arc::new(rendered),
lines: Arc::new(lines),
copy_separators: Arc::new(copy_separators),
copy_prefix_widths: Arc::new(copy_prefix_widths),
is_empty,
is_stream_continuation: cell.is_stream_continuation(),
is_conversational: cell.is_conversational(),
@@ -280,6 +297,16 @@ impl TranscriptViewCache {
self.line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
copy_prefix_width: cached
.copy_prefix_widths
.get(line_in_cell)
.copied()
.unwrap_or(0),
copy_separator_after: cached
.copy_separators
.get(line_in_cell)
.copied()
.unwrap_or(CopyLineSeparator::Newline),
});
}
+99
View File
@@ -294,6 +294,21 @@ fn word_cursor_modifier_accepts_control_and_alt() {
assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));
}
fn select_full_transcript(app: &mut App) {
app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
line_index: 0,
column: 0,
});
app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
line_index: app
.viewport
.transcript_cache
.total_lines()
.saturating_sub(1),
column: 80,
});
}
#[test]
fn selection_point_from_position_ignores_top_padding() {
let area = Rect {
@@ -375,6 +390,90 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() {
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam"));
}
#[test]
fn selection_to_text_removes_visual_wrap_breaks_from_paragraphs() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "alpha beta gamma delta epsilon".to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
14,
app.transcript_render_options(),
);
select_full_transcript(&mut app);
let selected = selection_to_text(&app).expect("selection text");
assert!(
!selected.contains('\n'),
"soft-wrapped paragraph copied with visual newlines: {selected:?}"
);
assert!(selected.contains("alpha beta gamma delta epsilon"));
}
#[test]
fn selection_to_text_preserves_wrapped_long_words() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "abcdefghijklmnop".to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
10,
app.transcript_render_options(),
);
select_full_transcript(&mut app);
let selected = selection_to_text(&app).expect("selection text");
assert_eq!(selected, "abcdefghijklmnop");
}
#[test]
fn selection_to_text_strips_code_block_visual_wrap_prefixes() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "```\nlet example = abcdefghijklmnop;\n```".to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
14,
app.transcript_render_options(),
);
select_full_transcript(&mut app);
let selected = selection_to_text(&app).expect("selection text");
assert_eq!(selected, "let example = abcdefghijklmnop;");
}
#[test]
fn selection_to_text_strips_list_continuation_prefixes() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Assistant {
content: "- alpha beta gamma delta epsilon".to_string(),
streaming: false,
}];
app.resync_history_revisions();
app.viewport.transcript_cache.ensure(
&app.history,
&app.history_revisions,
14,
app.transcript_render_options(),
);
select_full_transcript(&mut app);
let selected = selection_to_text(&app).expect("selection text");
assert_eq!(selected, "- alpha beta gamma delta epsilon");
}
#[test]
fn selection_to_text_copies_rendered_transcript_block() {
let mut app = create_test_app();
+18
View File
@@ -6,6 +6,24 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::tui::history::HistoryCell;
use crate::tui::osc8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CopyLineSeparator {
None,
Space,
Newline,
}
impl CopyLineSeparator {
#[must_use]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::None => "",
Self::Space => " ",
Self::Newline => "\n",
}
}
}
pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
+38
View File
@@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect};
use std::cell::{Cell, RefCell};
use std::fmt;
use crate::config::Config;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::settings::Settings;
@@ -614,6 +615,15 @@ impl ConfigView {
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Model,
key: "base_url".to_string(),
value: Config::load(app.config_path.clone(), app.config_profile.as_deref())
.map(|config| config.deepseek_base_url())
.unwrap_or_else(|_| "(unavailable)".to_string()),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Permissions,
key: "approval_mode".to_string(),
@@ -2013,6 +2023,7 @@ mod tests {
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{buffer::Buffer, layout::Rect};
use std::fs;
use std::path::PathBuf;
fn create_test_app() -> App {
@@ -2175,6 +2186,7 @@ mod tests {
.collect::<Vec<_>>();
assert!(keys.contains(&"model"));
assert!(keys.contains(&"reasoning_effort"));
assert!(keys.contains(&"base_url"));
assert!(keys.contains(&"approval_mode"));
assert!(keys.contains(&"theme"));
assert!(keys.contains(&"locale"));
@@ -2193,6 +2205,32 @@ mod tests {
assert!(view.rows.iter().all(|row| row.editable));
}
#[test]
fn config_view_base_url_reflects_app_config_path() {
let temp_root = std::env::temp_dir().join(format!(
"deepseek-tui-base-url-view-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("config.toml");
fs::write(
&config_path,
"base_url = \"https://ui-config-view.local/v1\"\n",
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let view = ConfigView::new_for_app(&app);
let row = view
.rows
.iter()
.find(|row| row.key == "base_url")
.expect("base_url row missing");
assert_eq!(row.value, "https://ui-config-view.local/v1");
}
#[test]
fn config_view_exposes_all_available_saved_settings() {
let app = create_test_app();
+1 -1
View File
@@ -204,7 +204,7 @@ impl ModalView for StatusPickerView {
for (idx, item) in self.rows.iter().enumerate() {
let checked = *self.selected.get(idx).unwrap_or(&false);
let is_cursor = idx == self.cursor;
let mark = if checked { "[x]" } else { "[ ]" };
let mark = if checked { "[]" } else { "[ ]" };
let row_style = if is_cursor {
Style::default()
+2 -1
View File
@@ -1948,7 +1948,8 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
)),
];
let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3);
// Keep the welcome block near the top of the chat pane (header is separate).
let top_padding = 2usize;
let mut lines = Vec::new();
for _ in 0..top_padding {
lines.push(Line::from(""));
+1 -1
View File
@@ -540,7 +540,7 @@ If you are upgrading from older releases:
`false`. When `true`, the notification body includes the elapsed
duration and the turn's cost in the configured display currency.
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport.
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar because the terminal, not the TUI, owns the selection.
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
+1 -1
View File
@@ -102,7 +102,7 @@ Run `codewhale --help` for the canonical list. Common flags:
- `-r, --resume <ID|PREFIX|latest>`: resume a saved session
- `-c, --continue`: resume the most recent session in this workspace
- `--max-subagents <N>`: clamp to `1..=20`
- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection.
- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
- `--profile <NAME>`: select config profile
- `--config <PATH>`: config file path
- `-v, --verbose`: verbose logging