diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 00000000..9fab6a16
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,89 @@
+name: Bug report
+description: Something in tty7 doesn't work as expected
+labels: ["bug"]
+body:
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Before you file
+ options:
+ - label: I searched the existing issues and this isn't a duplicate.
+ required: true
+ - label: I'm on the latest tty7 release, or I've said below why I can't be.
+ required: true
+
+ - type: textarea
+ id: summary
+ attributes:
+ label: What happened?
+ description: The behaviour you saw, in a sentence or two.
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: Steps to reproduce
+ description: >
+ Numbered steps starting from a freshly opened tty7 window. If a
+ specific command or escape sequence triggers it, paste the exact one —
+ "some TUI app" is rarely enough to reproduce a terminal bug.
+ placeholder: |
+ 1. Open a new tab
+ 2. Run `printf '\e[?2004h'`
+ 3. Type a character — the pane freezes
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: What did you expect instead?
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: tty7 version
+ description: Run `tty7 --version`, or check the About window.
+ placeholder: "26.9.2"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: platform
+ attributes:
+ label: Platform
+ options:
+ - macOS (Apple Silicon)
+ - macOS (Intel)
+ - Windows
+ - Linux
+ validations:
+ required: true
+
+ - type: input
+ id: context
+ attributes:
+ label: Shell and TUI app involved
+ description: >
+ The shell you were running, and the TUI app plus its version if one is
+ on screen when it happens.
+ placeholder: fish 3.7.1, neovim 0.10.2
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Log output
+ description: >
+ The tail of `~/.config/tty7/tty7.log`
+ (`%APPDATA%\tty7\tty7.log` on Windows), if it has anything from around
+ the time it broke. This is rendered as code, so no backticks needed.
+ render: shell
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Anything else?
+ description: Screenshots, a recording, or anything else worth knowing.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index bcbeb42f..62e78b3f 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,8 +1,5 @@
-blank_issues_enabled: true
+blank_issues_enabled: false
contact_links:
- - name: Questions & ideas
- url: https://github.com/l0ng-ai/tty7/discussions
- about: Not sure it's a bug? Want to discuss an idea first? Start a discussion.
- name: Security vulnerabilities
url: https://github.com/l0ng-ai/tty7/security/advisories/new
about: Please report security issues privately, not as public issues.
diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml
new file mode 100644
index 00000000..8424395b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/idea.yml
@@ -0,0 +1,35 @@
+name: Idea
+description: Suggest an improvement or a new capability
+labels: ["enhancement"]
+body:
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Before you file
+ options:
+ - label: I searched the existing issues and this isn't already proposed.
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: What's the problem?
+ description: >
+ What you were trying to do, and where tty7 got in the way. Leave the
+ solution for the next box — the problem is the part we can't guess.
+ validations:
+ required: true
+
+ - type: textarea
+ id: behaviour
+ attributes:
+ label: How should it behave?
+ description: The shape you have in mind, if you have one.
+
+ - type: textarea
+ id: prior_art
+ attributes:
+ label: Prior art and workarounds
+ description: >
+ How other terminals handle it, and what you're doing today to work
+ around it.
diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml
deleted file mode 100644
index d32ab16a..00000000
--- a/.github/ISSUE_TEMPLATE/issue.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Issue
-description: Report a bug or suggest an improvement
-body:
- - type: dropdown
- id: kind
- attributes:
- label: Type
- options:
- - Bug — something doesn't work as expected
- - Idea — suggest an improvement or new capability
- validations:
- required: true
- - type: textarea
- id: detail
- attributes:
- label: What's going on?
- description: >
- For a bug: what you did, what you saw, and what you expected instead.
- For an idea: the problem it solves and how it should behave.
- validations:
- required: true
- - type: input
- id: version
- attributes:
- label: tty7 version (bugs)
- placeholder: v0.2.0
- - type: dropdown
- id: platform
- attributes:
- label: Platform (bugs)
- options:
- - macOS (Apple Silicon)
- - macOS (Intel)
- - Windows
- - Linux
- - type: textarea
- id: extra
- attributes:
- label: Anything else?
- description: >
- Screenshots or recordings, the exact command / escape sequence that
- triggers it, the shell you were using, or the TUI app (vim, htop, …)
- and its version if one is involved.
diff --git a/assets/icons/agents/qodercli.svg b/assets/icons/agents/qodercli.svg
new file mode 100644
index 00000000..44eb1276
--- /dev/null
+++ b/assets/icons/agents/qodercli.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs
index c44e3219..d92ab7ef 100644
--- a/crates/tty7-cli/src/output.rs
+++ b/crates/tty7-cli/src/output.rs
@@ -526,12 +526,15 @@ mod tests {
);
// What the pane's own terminal says it is doing beats naming the agent
// running it — every tab of a workspace would otherwise read alike.
+ // The mark the agent writes in front of that title comes off here too:
+ // `tab_label` reads `TabView::label`, so the table says what the tab
+ // strip says without either being told about the other.
assert_eq!(
tab_label(&view(&|v| {
v.osc_title = Some("✳ fixing the switcher".into());
v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude);
})),
- "✳ fixing the switcher"
+ "fixing the switcher"
);
assert_eq!(
tab_label(&view(
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs
index 63298afb..e8865107 100644
--- a/crates/tty7-core/src/core/agent_hooks.rs
+++ b/crates/tty7-core/src/core/agent_hooks.rs
@@ -43,6 +43,15 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
}
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
+ // Qoder also emits SessionStart after compacting the active turn.
+ // Preserve its status until a real turn or session boundary arrives.
+ if agent == "qodercli"
+ && event == "session-start"
+ && let Ok(payload) = serde_json::from_str::(stdin_json)
+ && payload.get("source").and_then(|value| value.as_str()) == Some("compact")
+ {
+ return None;
+ }
if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" {
let blocks = stdin_json.contains("elicitation_dialog")
|| (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt"))
@@ -257,10 +266,11 @@ pub enum HookAgent {
Qwen,
Goose,
Kimi,
+ QoderCLI,
}
impl HookAgent {
- pub const ALL: [HookAgent; 13] = [
+ pub const ALL: [HookAgent; 14] = [
HookAgent::Claude,
HookAgent::Codex,
HookAgent::TraeCode,
@@ -274,6 +284,7 @@ impl HookAgent {
HookAgent::Qwen,
HookAgent::Goose,
HookAgent::Kimi,
+ HookAgent::QoderCLI,
];
/// The hooks behind a detected agent process, if it has any.
@@ -296,6 +307,7 @@ impl HookAgent {
CLIAgent::Qwen => Some(HookAgent::Qwen),
CLIAgent::Goose => Some(HookAgent::Goose),
CLIAgent::Kimi => Some(HookAgent::Kimi),
+ CLIAgent::QoderCLI => Some(HookAgent::QoderCLI),
CLIAgent::Aider
| CLIAgent::Amp
| CLIAgent::Cursor
@@ -317,6 +329,7 @@ impl HookAgent {
HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS),
HookAgent::Droid => Some(DROID_HOOK_EVENTS),
HookAgent::Qwen => Some(QWEN_HOOK_EVENTS),
+ HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS),
HookAgent::Copilot
| HookAgent::OpenCode
| HookAgent::Pi
@@ -352,6 +365,7 @@ impl HookAgent {
HookAgent::Qwen => "qwen",
HookAgent::Goose => "goose",
HookAgent::Kimi => "kimi",
+ HookAgent::QoderCLI => "qodercli",
}
}
@@ -370,6 +384,7 @@ impl HookAgent {
HookAgent::Qwen => "Qwen Code",
HookAgent::Goose => "Goose",
HookAgent::Kimi => "Kimi Code",
+ HookAgent::QoderCLI => "Qoder CLI",
}
}
@@ -402,6 +417,7 @@ impl HookAgent {
target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"])
}
HookAgent::Kimi => target.kimi_config_path(),
+ HookAgent::QoderCLI => target.qoder_settings_path(),
}
}
@@ -494,6 +510,15 @@ impl<'a> HookTarget<'a> {
self.under_home(&[".kimi-code", "config.toml"])
}
+ fn qoder_settings_path(&self) -> PathBuf {
+ if self.is_local()
+ && let Some(dir) = std::env::var_os("QODER_CONFIG_DIR").filter(|d| !d.is_empty())
+ {
+ return PathBuf::from(dir).join("settings.json");
+ }
+ self.under_home(&[".qoder", "settings.json"])
+ }
+
fn traecli_hooks_path(&self) -> PathBuf {
if self.is_local() {
if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) {
@@ -794,6 +819,18 @@ const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[
("SessionEnd", "session-end", None),
];
+const QODER_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("PermissionRequest", "permission-request"),
+ // An authorized MCP tool can still pause for user input mid-call.
+ ("Elicitation", "question-asked"),
+ ("PostToolUse", "tool-complete"),
+ ("Stop", "stop"),
+ ("StopFailure", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
fn hook_map_state(
target: &HookTarget,
path: &Path,
@@ -1138,6 +1175,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option {
| HookAgent::Gemini
| HookAgent::Droid
| HookAgent::Qwen
+ | HookAgent::QoderCLI
| HookAgent::Kimi => None,
}
}
@@ -1570,6 +1608,7 @@ mod tests {
.chain(TRAE_CODE_HOOK_EVENTS)
.chain(GEMINI_HOOK_EVENTS)
.chain(DROID_HOOK_EVENTS)
+ .chain(QODER_HOOK_EVENTS)
.chain(QWEN_HOOK_EVENTS)
.chain(GOOSE_HOOK_EVENTS)
.chain(KIMI_HOOK_EVENTS)
@@ -1607,6 +1646,7 @@ mod tests {
"/home/me/.agents/plugins/tty7/hooks/hooks.json",
),
(HookAgent::Kimi, "/home/me/.kimi-code/config.toml"),
+ (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"),
] {
assert_eq!(
agent.target_path(&t),
@@ -1627,6 +1667,7 @@ mod tests {
HookAgent::Qwen,
HookAgent::Goose,
HookAgent::Kimi,
+ HookAgent::QoderCLI,
] {
assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled);
install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug()));
@@ -1649,6 +1690,113 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ #[test]
+ fn qoder_compaction_preserves_the_active_turn() {
+ use crate::core::cli_agent::{AgentSessionState, AgentStatus};
+
+ let mut state = AgentSessionState::default();
+ state.apply_event(&round_trip(
+ "qodercli",
+ "prompt-submit",
+ r#"{"session_id":"q-1","cwd":"/repo","prompt":"Continue the task"}"#,
+ ));
+ let before = state.clone();
+ let compact = r#"{"session_id":"q-1","cwd":"/repo","source":"compact"}"#;
+ if let Some(event) = effective_event("qodercli", "session-start", compact) {
+ state.apply_event(&round_trip("qodercli", event, compact));
+ }
+ assert_eq!(state, before, "compaction must preserve the active turn");
+
+ state.apply_event(&round_trip("qodercli", "tool-complete", "{}"));
+ assert_eq!(state.status, AgentStatus::Working);
+ state.apply_event(&round_trip("qodercli", "stop", "{}"));
+ assert_eq!(state.status, AgentStatus::Done);
+
+ for input in [
+ r#"{"source":"startup","message":"compact"}"#,
+ r#"{"source":"resume"}"#,
+ r#"{"source":"clear"}"#,
+ "{}",
+ "not JSON",
+ ] {
+ let event = effective_event("qodercli", "session-start", input)
+ .expect("ordinary session starts still reach the state machine");
+ let mut session = before.clone();
+ session.apply_event(&round_trip("qodercli", event, input));
+ assert_eq!(session.status, AgentStatus::Idle, "{input}");
+ }
+ assert_eq!(
+ effective_event("claude", "session-start", compact),
+ Some("session-start"),
+ "the filter is specific to Qoder"
+ );
+ assert_eq!(
+ effective_event("qodercli", "prompt-submit", compact),
+ Some("prompt-submit")
+ );
+ }
+
+ #[test]
+ fn qoder_mcp_elicitation_waits_for_user_input() {
+ use crate::core::cli_agent::{AgentSessionState, AgentStatus};
+
+ let apply_hook = |state: &mut AgentSessionState, hook: &str, input: &str| {
+ let event = HookAgent::QoderCLI
+ .hook_map_events()
+ .unwrap()
+ .iter()
+ .find_map(|(name, event)| (*name == hook).then_some(*event))
+ .and_then(|event| effective_event("qodercli", event, input));
+ if let Some(event) = event {
+ state.apply_event(&round_trip("qodercli", event, input));
+ }
+ };
+ let mut state = AgentSessionState::default();
+ apply_hook(
+ &mut state,
+ "UserPromptSubmit",
+ r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#,
+ );
+ assert_eq!(state.status, AgentStatus::Working);
+ // Qoder says outright when it is blocked — `PermissionRequest` and
+ // `Elicitation` — so it must not also carry `Notification`, which
+ // fires for non-blocking alerts and would strand the pane on
+ // "waiting". Asserting the map has no seat for it is the check; a
+ // `Notification` payload put through `apply_hook` would be dropped
+ // for want of one and prove nothing.
+ assert!(
+ !HookAgent::QoderCLI
+ .hook_map_events()
+ .unwrap()
+ .iter()
+ .any(|(hook, _)| *hook == "Notification"),
+ "an unblocking alert must not read as a question"
+ );
+
+ // The MCP tool is already authorized, so no PermissionRequest precedes
+ // its request for more information from the user.
+ apply_hook(
+ &mut state,
+ "Elicitation",
+ r#"{
+ "session_id":"q-1",
+ "hook_event_name":"Elicitation",
+ "mcp_server_name":"tickets",
+ "message":"Choose a project",
+ "mode":"form"
+ }"#,
+ );
+ assert_eq!(state.status, AgentStatus::Waiting);
+ assert_eq!(state.message.as_deref(), Some("Choose a project"));
+ assert_eq!(state.session_id.as_deref(), Some("q-1"));
+
+ apply_hook(&mut state, "PostToolUse", r#"{"session_id":"q-1"}"#);
+ assert_eq!(state.status, AgentStatus::Working);
+ assert_eq!(state.message, None);
+ apply_hook(&mut state, "Stop", r#"{"session_id":"q-1"}"#);
+ assert_eq!(state.status, AgentStatus::Done);
+ }
+
/// Qwen is the one agent that reports a blocked turn outright, so it must
/// not also carry the `Notification` hook the others need — that event fires
/// for non-blocking alerts too and would strand the pane on "waiting".
@@ -1906,6 +2054,7 @@ mod tests {
"/home/me/.omp/agent/extensions/tty7/index.ts",
),
(HookAgent::Kimi, "/home/me/.kimi-code/config.toml"),
+ (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"),
] {
assert_eq!(
agent.target_path(&target),
@@ -2131,6 +2280,104 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ #[test]
+ fn qoder_config_dir_controls_local_hook_lifecycle() {
+ const CASE_ENV: &str = "TTY7_TEST_QODER_CONFIG_CASE";
+ const ROOT_ENV: &str = "TTY7_TEST_QODER_CONFIG_ROOT";
+ let Ok(case) = std::env::var(CASE_ENV) else {
+ // Each case gets its own environment, without changing the one
+ // shared by the other tests or touching the user's settings.
+ for case in ["override", "empty", "unset"] {
+ let sandbox = tempfile::tempdir().unwrap();
+ let mut child = std::process::Command::new(std::env::current_exe().unwrap());
+ child
+ .args([
+ "--exact",
+ "core::agent_hooks::tests::qoder_config_dir_controls_local_hook_lifecycle",
+ "--nocapture",
+ ])
+ .env(CASE_ENV, case)
+ .env(ROOT_ENV, sandbox.path());
+ match case {
+ "override" => {
+ child.env("QODER_CONFIG_DIR", sandbox.path().join("custom config"))
+ }
+ "empty" => child.env("QODER_CONFIG_DIR", ""),
+ _ => child.env_remove("QODER_CONFIG_DIR"),
+ };
+ let output = crate::core::proc::output_within(
+ crate::core::proc::hide_console(&mut child),
+ std::time::Duration::from_secs(30),
+ )
+ .expect("run the isolated Qoder hook test");
+ assert!(
+ output.status.success(),
+ "{case}:\n{}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr),
+ );
+ }
+ return;
+ };
+
+ let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap());
+ let host = local_host();
+ let target = HookTarget {
+ host: &*host,
+ home: root.join("home"),
+ exe: std::env::current_exe().unwrap(),
+ };
+ let default_settings = target.home.join(".qoder").join("settings.json");
+ let custom_settings = root.join("custom config").join("settings.json");
+ let (settings, untouched) = if case == "override" {
+ (&custom_settings, &default_settings)
+ } else {
+ (&default_settings, &custom_settings)
+ };
+ let user_config = serde_json::json!({
+ "model": "qoder-test",
+ "hooks": {
+ "Stop": [{ "hooks": [{ "type": "command", "command": "echo user-hook" }] }]
+ }
+ });
+ for path in [settings, untouched] {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, user_config.to_string()).unwrap();
+ }
+
+ let agent = HookAgent::QoderCLI;
+ assert_eq!(agent.target_path(&target), *settings);
+ let remote_host = FakeRemote::shared();
+ let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me"));
+ assert_eq!(
+ agent.target_path(&remote),
+ PathBuf::from("/home/me/.qoder/settings.json"),
+ "a local override must not redirect remote hooks"
+ );
+
+ assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled);
+ assert_eq!(
+ install_hooks(&target, agent).unwrap(),
+ HookOutcome::Installed
+ );
+ assert_eq!(hooks_state(&target, agent), HooksState::Installed);
+ assert!(
+ std::fs::read_to_string(settings)
+ .unwrap()
+ .contains("agent-hook qodercli")
+ );
+ assert_eq!(
+ uninstall_hooks(&target, agent).unwrap(),
+ HookOutcome::Removed
+ );
+ assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled);
+ for path in [settings, untouched] {
+ let actual: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
+ assert_eq!(actual, user_config, "{}", path.display());
+ }
+ }
+
#[test]
fn install_is_idempotent_and_preserves_user_hooks() {
let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id()));
diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs
index f504631f..1af51403 100644
--- a/crates/tty7-core/src/core/cli_agent.rs
+++ b/crates/tty7-core/src/core/cli_agent.rs
@@ -26,10 +26,11 @@ pub enum CLIAgent {
// Keep new variants at the end: daemon messages serialize this enum and
// moving an existing discriminant would break mixed-version clients.
TraeCode,
+ QoderCLI,
}
impl CLIAgent {
- pub const ALL: [CLIAgent; 20] = [
+ pub const ALL: [CLIAgent; 21] = [
CLIAgent::Claude,
CLIAgent::Codex,
CLIAgent::TraeCode,
@@ -50,6 +51,7 @@ impl CLIAgent {
CLIAgent::Qwen,
CLIAgent::OhMyPi,
CLIAgent::Kimi,
+ CLIAgent::QoderCLI,
];
fn aliases(self) -> &'static [&'static str] {
@@ -83,6 +85,15 @@ impl CLIAgent {
// kimi-cli install a `kimi` — same vendor, same brand, so one
// detection covers them. Only the standalone one has hooks.
CLIAgent::Kimi => &["kimi", "kimi-code"],
+ // The npm package installs two binaries and `qoder` is the one the
+ // documentation tells people to run: it dispatches to the CLI for a
+ // bare invocation, a flag, or a prompt, and only hands off to the
+ // IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists.
+ // Detecting only `qodercli` would miss every session started the
+ // documented way, since the dispatcher is what the pty sees. An IDE
+ // launch is the cost: it wears the CLI's avatar for as long as the
+ // launcher takes to exit.
+ CLIAgent::QoderCLI => &["qoder", "qodercli"],
}
}
@@ -108,6 +119,7 @@ impl CLIAgent {
CLIAgent::Qwen => "qwen",
CLIAgent::OhMyPi => "omp",
CLIAgent::Kimi => "kimi",
+ CLIAgent::QoderCLI => "qodercli",
}
}
@@ -138,6 +150,7 @@ impl CLIAgent {
CLIAgent::Qwen => "Qwen Code",
CLIAgent::OhMyPi => "Oh My Pi",
CLIAgent::Kimi => "Kimi Code",
+ CLIAgent::QoderCLI => "Qoder CLI",
}
}
@@ -169,6 +182,7 @@ impl CLIAgent {
CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")),
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")),
+ CLIAgent::QoderCLI => Some(format!("qodercli{flags} --resume {session_id}")),
CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")),
CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")),
CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")),
@@ -185,6 +199,9 @@ impl CLIAgent {
// "If false, chat history is not saved and --continue/--resume
// will not work" — the yargs negation of `--chat-recording`.
CLIAgent::Qwen => &["--no-chat-recording"],
+ // Print mode still emits a session id in hooks when persistence
+ // is disabled, but there is no saved conversation to reopen.
+ CLIAgent::QoderCLI => &["--no-session-persistence"],
_ => &[],
};
argv.iter().any(|t| ephemeral.contains(&t.as_str()))
@@ -202,6 +219,9 @@ impl CLIAgent {
"claude{flags} --resume {session_id} --fork-session"
)),
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")),
+ CLIAgent::QoderCLI => Some(format!(
+ "qodercli{flags} --resume {session_id} --fork-session"
+ )),
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")),
CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")),
// Droid forks with a standalone flag rather than resume-plus-a-switch.
@@ -229,7 +249,8 @@ impl CLIAgent {
| CLIAgent::Droid
| CLIAgent::Amp
| CLIAgent::Qwen
- | CLIAgent::Goose => Some("Fork Session"),
+ | CLIAgent::Goose
+ | CLIAgent::QoderCLI => Some("Fork Session"),
_ => None,
}
}
@@ -393,6 +414,21 @@ impl CLIAgent {
"--worktree-ref",
"--ref",
],
+ // `--resume`/`-r` restores a past session and `--continue`/`-c` the
+ // most recent one; `--session-id` is a third spelling of the same
+ // thing. All three clash with the `--resume {id}` this command
+ // appends, and `--fork-session` is the flag the fork variant
+ // appends itself. `--worktree` would create or switch trees again;
+ // Qoder's `-w` means `--cwd` and must survive.
+ CLIAgent::QoderCLI => &[
+ "--resume",
+ "-r",
+ "--continue",
+ "-c",
+ "--session-id",
+ "--fork-session",
+ "--worktree",
+ ],
_ => &[],
};
let mut i = 0;
@@ -457,6 +493,7 @@ impl CLIAgent {
// The blue of the flame in Kimi's brand mark; the glyph itself is
// black, which Codex and Grok already have covered.
CLIAgent::Kimi => 0x027AFF,
+ CLIAgent::QoderCLI => 0xFFFFFF,
}
}
@@ -475,6 +512,7 @@ impl CLIAgent {
pub fn icon_rgb(self) -> u32 {
match self {
CLIAgent::TraeCode => 0x32F08C,
+ CLIAgent::QoderCLI => 0x000000,
_ => 0xFFFFFF,
}
}
@@ -496,6 +534,7 @@ impl CLIAgent {
CLIAgent::OhMyPi => "icons/agents/omp.svg",
CLIAgent::Qwen => "icons/agents/qwen.svg",
CLIAgent::Kimi => "icons/agents/kimi.svg",
+ CLIAgent::QoderCLI => "icons/agents/qodercli.svg",
CLIAgent::Aider
| CLIAgent::Auggie
| CLIAgent::Hermes
@@ -848,6 +887,32 @@ mod tests {
);
}
+ /// The npm package installs `qoder` and `qodercli`, and the documentation
+ /// tells people to run the first one. Both are `#!/usr/bin/env node`
+ /// scripts, so what the pty carries is node plus the path to the shim —
+ /// the dispatcher's own child, which is where the name `qodercli` appears
+ /// on that path, is not the process group leader and is never read.
+ #[test]
+ fn qoder_is_detected_through_either_of_its_binaries() {
+ for launcher in [
+ "qoder",
+ "qodercli",
+ "/opt/homebrew/bin/qoder",
+ "/opt/homebrew/bin/qodercli",
+ ] {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["node", launcher])),
+ Some(CLIAgent::QoderCLI),
+ "on {launcher}"
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&[launcher])),
+ Some(CLIAgent::QoderCLI),
+ "on {launcher}"
+ );
+ }
+ }
+
#[test]
fn detects_npx_package_form() {
assert_eq!(
@@ -1508,6 +1573,88 @@ mod tests {
.as_deref(),
Some("grok --yolo --resume g-3")
);
+ assert_eq!(
+ CLIAgent::QoderCLI
+ .resume_command("q-1", Some(&argv(&["qodercli", "--model", "qoder-1"])))
+ .as_deref(),
+ Some("qodercli --model qoder-1 --resume q-1")
+ );
+ assert_eq!(
+ CLIAgent::QoderCLI
+ .resume_command(
+ "q-2",
+ Some(&argv(&["qodercli", "--resume", "q-1", "--fork-session"]))
+ )
+ .as_deref(),
+ Some("qodercli --resume q-2"),
+ "a stale --resume id and --fork-session come off before the new one goes on"
+ );
+ assert_eq!(
+ CLIAgent::QoderCLI
+ .resume_command(
+ "q-3",
+ Some(&argv(&["qodercli", "--session-id", "old", "--yolo"]))
+ )
+ .as_deref(),
+ Some("qodercli --yolo --resume q-3"),
+ "`--session-id` names a new session and is rejected next to `--resume`"
+ );
+ }
+
+ #[test]
+ fn qoder_resume_and_fork_do_not_recreate_worktrees() {
+ for worktree in [
+ vec!["--worktree"],
+ vec!["--worktree", "old-tree"],
+ vec!["--worktree=old-tree"],
+ ] {
+ for cwd_flag in ["-w", "--cwd"] {
+ let mut launch = argv(&["qodercli", "--model", "qoder-1"]);
+ launch.extend(argv(&worktree));
+ launch.extend(argv(&[cwd_flag, "/repo/current-tree"]));
+ assert_eq!(
+ CLIAgent::QoderCLI.resume_command("q-1", Some(&launch)),
+ Some(format!(
+ "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1"
+ )),
+ "launch argv: {launch:?}"
+ );
+ assert_eq!(
+ CLIAgent::QoderCLI.fork_command("q-1", Some(&launch)),
+ Some(format!(
+ "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1 --fork-session"
+ )),
+ "launch argv: {launch:?}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn qoder_session_commands_require_persistence() {
+ let ephemeral = argv(&["qodercli", "--print", "--no-session-persistence"]);
+ assert_eq!(
+ CLIAgent::QoderCLI.resume_command("q-1", Some(&ephemeral)),
+ None
+ );
+ assert_eq!(
+ CLIAgent::QoderCLI.fork_command("q-1", Some(&ephemeral)),
+ None
+ );
+
+ let persistent = argv(&["qodercli", "--model", "qoder-1"]);
+ assert_eq!(
+ CLIAgent::QoderCLI
+ .resume_command("q-1", Some(&persistent))
+ .as_deref(),
+ Some("qodercli --model qoder-1 --resume q-1")
+ );
+ assert_eq!(
+ CLIAgent::QoderCLI
+ .fork_command("q-1", Some(&persistent))
+ .as_deref(),
+ Some("qodercli --model qoder-1 --resume q-1 --fork-session")
+ );
}
#[test]
diff --git a/crates/tty7-core/src/core/tab_view.rs b/crates/tty7-core/src/core/tab_view.rs
index 149c585f..bbed34aa 100644
--- a/crates/tty7-core/src/core/tab_view.rs
+++ b/crates/tty7-core/src/core/tab_view.rs
@@ -91,6 +91,73 @@ pub fn strip_host_prefix(raw: &str) -> &str {
}
}
+/// The marks a coding agent writes in front of the title it sets while it
+/// works, and which of them to take back off.
+///
+/// Agents animate in the terminal title and do not agree on an alphabet:
+/// Claude Code cycles the quadrant circles and rests on an asterisk, others
+/// step through the braille frames, some write nothing. Rendered as they
+/// arrive, a column of tabs carries a mark in front of some rows and not
+/// others, in three vocabularies, while the row already says what the agent is
+/// doing — in one, with its status dot.
+///
+/// **A known alphabet, not a shape.** The obvious rule — a leading character
+/// that is non-ASCII and above some code point, followed by a space — matches
+/// by shape, and a tab called `🔥 build`, or `📁 ~/repo` from somebody's shell
+/// integration, fits it exactly and loses its first character with no way to
+/// ask for it back and no clue as to what took it. Matching a list of marks we
+/// have actually seen costs the same and cannot do that. When an agent invents
+/// a mark that is not here yet the failure is today's behaviour — the mark
+/// stays — which is the safe direction to fail in, and adding it is a line in
+/// the table below.
+const STATUS_MARKS: &[char] = &[
+ // Claude Code: the quadrant circles while it works, the asterisk at rest.
+ '\u{25D0}', '\u{25D1}', '\u{25D2}', '\u{25D3}', '\u{2733}',
+];
+
+/// Whether `c` is one of the braille cells the common spinners are built from.
+/// The whole block, because the frame sets differ between agents and every
+/// cell in it is a spinner frame somewhere — none is a character a human puts
+/// at the front of a tab's name.
+fn is_braille_frame(c: char) -> bool {
+ ('\u{2800}'..='\u{28FF}').contains(&c)
+}
+
+/// `title` with any leading status marks taken off.
+///
+/// A mark only counts with whitespace behind it, which is how every agent
+/// writes one and is one more thing a title would have to do by accident.
+/// Variation selectors and zero-width joiners ride along with the mark.
+pub fn strip_status_mark(title: &str) -> &str {
+ let mut rest = title;
+ loop {
+ let mut chars = rest.chars();
+ let Some(first) = chars.next() else {
+ return rest;
+ };
+ if !STATUS_MARKS.contains(&first) && !is_braille_frame(first) {
+ return rest;
+ }
+ let after = chars
+ .as_str()
+ .trim_start_matches(|c: char| matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{200D}'));
+ let trimmed = after.trim_start();
+ // Nothing between the mark and the rest of the title: a title that
+ // happens to start with the character, not a mark in front of one.
+ if trimmed.len() == after.len() {
+ return rest;
+ }
+ // A mark with nothing behind it is the whole title. Taking it would
+ // leave an empty string, and an empty title is not a tab called
+ // nothing — it is a tab that falls back to its number, which is less
+ // than the mark was saying.
+ if trimmed.is_empty() {
+ return rest;
+ }
+ rest = trimmed;
+ }
+}
+
impl TabView {
pub fn label(&self) -> TabLabel<'_> {
if let Some(name) = self
@@ -105,6 +172,7 @@ impl TabView {
.osc_title
.as_deref()
.map(str::trim)
+ .map(strip_status_mark)
.filter(|t| !t.is_empty())
{
return TabLabel::Osc(title);
@@ -159,6 +227,72 @@ pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec {
#[cfg(test)]
mod tests {
+
+ /// The marks come off, whichever alphabet the agent picked.
+ #[test]
+ fn a_status_mark_comes_off_the_front_of_a_title() {
+ for raw in [
+ "\u{2733} fixing the switcher", // Claude Code at rest
+ "\u{25D0} fixing the switcher", // and while it works
+ "\u{25D3} fixing the switcher",
+ "\u{280B} fixing the switcher", // a braille frame
+ "\u{28FF} fixing the switcher", // the far end of the block
+ "\u{2733}\u{FE0F} fixing the switcher", // with an emoji selector
+ "\u{2733} \u{280B} fixing the switcher", // two of them, both go
+ ] {
+ assert_eq!(strip_status_mark(raw), "fixing the switcher", "on {raw:?}");
+ }
+ }
+
+ /// The reason this matches an alphabet rather than a shape. Every one of
+ /// these fits "leading non-ASCII character above U+2000, then a space",
+ /// and every one of them is somebody's title rather than an agent's mark —
+ /// a shape rule eats the first character of each, silently.
+ #[test]
+ fn a_title_that_merely_looks_like_one_is_left_alone() {
+ for raw in [
+ "\u{1F525} build", // fire, a name somebody chose
+ "\u{1F4C1} ~/repo", // folder, from a shell integration
+ "\u{2192} deploy", // an arrow
+ "\u{2714} done", // a tick
+ "\u{2022} notes", // a bullet
+ "\u{4E2D}\u{6587} title", // a title in a script with no case
+ "\u{2733}fixing", // no space: part of the word
+ "fixing the switcher", // nothing to take
+ "",
+ ] {
+ assert_eq!(strip_status_mark(raw), raw, "on {raw:?}");
+ }
+ }
+
+ /// A name the user typed is theirs, mark or no mark. The strip is for the
+ /// title an agent writes, and `label` reaches the name first — but that
+ /// ordering is the only thing keeping a tab someone deliberately called
+ /// `\u{2733} release` from being renamed behind their back, so it is worth
+ /// saying out loud.
+ #[test]
+ fn a_name_the_user_gave_is_never_stripped() {
+ let view = TabView {
+ id: TabId::new(),
+ name: Some("\u{2733} release".to_string()),
+ title: "zsh".to_string(),
+ osc_title: Some("\u{2733} fixing the switcher".to_string()),
+ cwd: None,
+ agent: None,
+ status: None,
+ live: true,
+ panes: 1,
+ };
+ assert_eq!(view.label(), TabLabel::Named("\u{2733} release"));
+ }
+
+ /// A title that is only a mark keeps it, rather than becoming empty and
+ /// falling through to the tab's number.
+ #[test]
+ fn a_mark_on_its_own_is_still_a_title() {
+ assert_eq!(strip_status_mark("\u{2733}"), "\u{2733}");
+ assert_eq!(strip_status_mark("\u{2733} "), "\u{2733} ");
+ }
use super::*;
use crate::core::machine::{AgentFacts, Tab};
@@ -196,7 +330,7 @@ mod tests {
cwd: Some("/work".into()),
..view()
};
- assert_eq!(titled.label(), TabLabel::Osc("✳ fixing the switcher"));
+ assert_eq!(titled.label(), TabLabel::Osc("fixing the switcher"));
let blank_title = TabView {
osc_title: Some(" ".into()),
diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs
index 72bb557f..d8b01dd4 100644
--- a/crates/tty7-core/src/daemon/control.rs
+++ b/crates/tty7-core/src/daemon/control.rs
@@ -938,6 +938,11 @@ struct ClientInner {
blobs: Mutex>>,
connected: AtomicBool,
last_inbound: Mutex,
+ /// How long the last `Ping` took to come back, in microseconds; zero until
+ /// one has. Only `Ping` is timed: every other request does work on the far
+ /// side, so its round trip measures that work and not the link, and a
+ /// `ReadFile` of a large file would report the network as seconds slow.
+ last_rtt_us: AtomicU64,
hello: ControlHelloOk,
shutdown: Option>,
reader_done: Mutex,
@@ -1016,6 +1021,7 @@ impl ControlClient {
blobs: Mutex::new(HashMap::new()),
connected: AtomicBool::new(true),
last_inbound: Mutex::new(Instant::now()),
+ last_rtt_us: AtomicU64::new(0),
hello: ok,
shutdown,
reader_done: Mutex::new(false),
@@ -1059,6 +1065,19 @@ impl ControlClient {
.unwrap_or_default()
}
+ /// The last measured round trip to the peer, or `None` on a link nothing
+ /// has pinged yet.
+ ///
+ /// Fed by every [`ControlRequest::Ping`] that comes back — the keepalive's
+ /// as well as any a caller sends itself — so a link that is being kept
+ /// alive already carries a number without anyone asking for one.
+ pub fn last_rtt(&self) -> Option {
+ match self.inner.last_rtt_us.load(Ordering::Relaxed) {
+ 0 => None,
+ us => Some(Duration::from_micros(us)),
+ }
+ }
+
pub fn call(&self, req: ControlRequest) -> io::Result {
self.call_full(req, &[]).map(|r| r.reply)
}
@@ -1086,6 +1105,9 @@ impl ControlClient {
}
let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed);
+ // Read off before `req` is moved into the message below.
+ let timed = matches!(req, ControlRequest::Ping);
+ let sent_at = Instant::now();
log::debug!(target: "tty7::control", "#{req_id} {req:?}");
let (tx, rx) = sync_channel(1);
self.inner.pending()?.insert(req_id, tx);
@@ -1108,9 +1130,21 @@ impl ControlClient {
match rx.recv_timeout(deadline) {
Ok(reply) => {
let blob = self.inner.take_blob(req_id);
- reply
+ let out = reply
.into_result()
- .map(|reply| ControlResponse { reply, blob })
+ .map(|reply| ControlResponse { reply, blob });
+ // Only a ping that actually came back. A timeout leaves the
+ // last good number in place rather than recording the deadline
+ // as the link's latency, and a refusal measures the peer's
+ // opinion of the request rather than the distance to it.
+ if timed && out.is_ok() {
+ let us = sent_at.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
+ // Zero means "never measured", so a sub-microsecond round
+ // trip on a loopback link rounds up rather than reading as
+ // no measurement at all.
+ self.inner.last_rtt_us.store(us.max(1), Ordering::Relaxed);
+ }
+ out
}
Err(RecvTimeoutError::Timeout) => {
self.inner.forget(req_id);
@@ -1343,6 +1377,7 @@ mod tests {
blobs: Mutex::new(HashMap::new()),
connected: AtomicBool::new(true),
last_inbound: Mutex::new(Instant::now()),
+ last_rtt_us: AtomicU64::new(0),
hello: ControlHelloOk {
control_version: CONTROL_VERSION,
protocol_version: 0,
diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs
index 6bd0a7de..28291a03 100644
--- a/crates/tty7-core/src/host/mod.rs
+++ b/crates/tty7-core/src/host/mod.rs
@@ -173,6 +173,15 @@ pub trait WatchHandle: Send + Sync {
pub trait Host: Send + Sync + 'static {
fn id(&self) -> HostId;
+ /// How far away this host is: the round trip to it, measured now.
+ ///
+ /// `None` from a host with no link to measure — the local one, whose
+ /// "peer" is this process's own daemon over a Unix socket — and from a
+ /// remote one whose link is down or has not answered a ping yet.
+ fn link_rtt(&self) -> Option {
+ None
+ }
+
fn separator(&self) -> char;
fn join(&self, dir: &Path, name: &str) -> PathBuf {
diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs
index 7183abe8..c90896f4 100644
--- a/crates/tty7-core/src/host/remote.rs
+++ b/crates/tty7-core/src/host/remote.rs
@@ -253,6 +253,22 @@ impl Host for RemoteHost {
})
}
+ fn link_rtt(&self) -> Option {
+ // A ping of our own rather than whatever the keepalive last left
+ // behind: that one only fires on an idle link, and a link being polled
+ // for this is by definition not idle, so its number would age out of
+ // date exactly while someone is watching it.
+ if self.client.is_connected()
+ && let Err(e) = self.client.ping()
+ {
+ // The last good measurement below still stands. A link that has
+ // just gone down reports the distance it had while it was up,
+ // which is better than a blank until something notices it is gone.
+ log::debug!("could not ping {:?}: {e}", self.id);
+ }
+ self.client.last_rtt()
+ }
+
/// The peer owns these panes' PTYs, so it is the one that can walk their
/// process trees. A peer that does not announce the feature is not asked:
/// it would answer `Err` and the caller cannot tell that apart from a pane
@@ -1051,6 +1067,75 @@ mod tests {
.unwrap()
}
+ #[test]
+ fn link_rtt_pings_and_reports_what_came_back() {
+ let (host, seen) = host_with_peer('/', |req| match req {
+ ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])),
+ other => panic!("unexpected request {other:?}"),
+ });
+
+ assert!(
+ host.link_rtt().is_some(),
+ "a ping that came back is a measurement"
+ );
+ assert_eq!(seen.recv().unwrap(), ControlRequest::Ping);
+ assert_eq!(
+ seen.try_recv().ok(),
+ None,
+ "one row is worth one round trip and no more"
+ );
+ }
+
+ /// The latency row must mean the distance to the peer, not how long the
+ /// peer spent on whatever was asked of it. Timing every call would put a
+ /// `ReadFile` of a large file, or a `Git` that shells out, on that row and
+ /// read as a network gone seconds slow.
+ #[test]
+ fn only_a_ping_is_timed() {
+ let (host, _seen) = host_with_peer('/', |req| match req {
+ ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])),
+ ControlRequest::Exists { .. } => Some((ControlReply::Ok(ReplyOk::Bool(true)), vec![])),
+ other => panic!("unexpected request {other:?}"),
+ });
+
+ assert_eq!(
+ host.client().last_rtt(),
+ None,
+ "a link nothing has pinged has no measurement to report"
+ );
+ assert!(host.exists(Path::new("/etc/hosts")));
+ assert_eq!(
+ host.client().last_rtt(),
+ None,
+ "an ordinary call measures the peer's work, so it leaves the link's latency alone"
+ );
+ host.client().ping().unwrap();
+ assert!(host.client().last_rtt().is_some());
+ }
+
+ /// A link that has just gone down keeps the distance it had while it was
+ /// up. Blanking the row on the first failed ping would take the number
+ /// away at exactly the moment someone is looking at it to work out why the
+ /// pane has stopped responding.
+ #[test]
+ fn a_link_that_goes_down_keeps_its_last_measurement() {
+ let served = std::sync::atomic::AtomicBool::new(true);
+ let (host, _seen) = host_with_peer('/', move |req| match req {
+ ControlRequest::Ping => match served.swap(false, Ordering::Relaxed) {
+ true => Some((ControlReply::Ok(ReplyOk::Pong), vec![])),
+ // Hanging up rather than answering, which is what a peer whose
+ // machine went away looks like from here.
+ false => None,
+ },
+ other => panic!("unexpected request {other:?}"),
+ });
+
+ let measured = host.link_rtt().expect("the first ping came back");
+ assert_eq!(host.link_rtt(), Some(measured));
+ assert!(!host.is_connected(), "the second ping took the link down");
+ assert_eq!(host.link_rtt(), Some(measured));
+ }
+
#[test]
fn shells_come_from_the_peer() {
let (host, seen) = host_with_peer('/', |req| match req {
diff --git a/src/core/update.rs b/src/core/update.rs
index 1d4ed849..31a865d1 100644
--- a/src/core/update.rs
+++ b/src/core/update.rs
@@ -1766,6 +1766,11 @@ struct GitHubAsset {
browser_download_url: String,
}
+#[derive(serde::Deserialize)]
+struct GitHubError {
+ message: String,
+}
+
/// `nightly.json`, written by the nightly workflow. Only `version` is read
/// today; the rest is there so a build can be traced back to its commit
/// without cross-referencing the release notes.
@@ -1853,7 +1858,30 @@ async fn fetch_json(
let mut response = client.send(request).await.context("sending the request")?;
if !response.status().is_success() {
- anyhow::bail!("GitHub returned HTTP {}", response.status().as_u16());
+ let status = response.status().as_u16();
+ let rate_remaining = response
+ .headers()
+ .get("x-ratelimit-remaining")
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let rate_reset = response
+ .headers()
+ .get("x-ratelimit-reset")
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let mut body = Vec::new();
+ let _ = response
+ .body_mut()
+ .take(8 * 1024)
+ .read_to_end(&mut body)
+ .await;
+ anyhow::bail!(github_http_error(
+ status,
+ rate_remaining.as_deref(),
+ rate_reset.as_deref(),
+ &body,
+ now_secs(),
+ ));
}
let mut body = Vec::new();
@@ -1866,6 +1894,54 @@ async fn fetch_json(
serde_json::from_slice(&body).context("parsing JSON")
}
+fn github_http_error(
+ status: u16,
+ rate_remaining: Option<&str>,
+ rate_reset: Option<&str>,
+ body: &[u8],
+ now: u64,
+) -> String {
+ let message = serde_json::from_slice::(body)
+ .ok()
+ .map(|error| sanitize_github_message(&error.message));
+ // 403 is what the REST API has always answered a spent quota with; 429 is
+ // what it increasingly answers instead, and both carry the same headers.
+ let rate_limited = matches!(status, 403 | 429)
+ && (rate_remaining == Some("0")
+ || message.as_deref().is_some_and(|message| {
+ message.to_ascii_lowercase().contains("rate limit exceeded")
+ }));
+
+ if rate_limited {
+ let retry = rate_reset
+ .and_then(|reset| reset.parse::().ok())
+ .filter(|reset| *reset > now)
+ .map(|reset| {
+ let minutes = (reset - now).div_ceil(60);
+ let suffix = if minutes == 1 { "" } else { "s" };
+ format!("try again in about {minutes} minute{suffix}")
+ })
+ .unwrap_or_else(|| "try again shortly".to_string());
+ return format!("GitHub API rate limit exceeded; {retry} (HTTP {status})");
+ }
+
+ match message.filter(|message| !message.is_empty()) {
+ Some(message) => format!("GitHub returned HTTP {status}: {message}"),
+ None => format!("GitHub returned HTTP {status}"),
+ }
+}
+
+fn sanitize_github_message(message: &str) -> String {
+ let single_line = message.split_whitespace().collect::>().join(" ");
+ let mut chars = single_line.chars();
+ let shortened: String = chars.by_ref().take(200).collect();
+ if chars.next().is_some() {
+ format!("{shortened}…")
+ } else {
+ shortened
+ }
+}
+
/// Returns the release together with the version it advertises, which is not
/// always something the release object states outright — see `resolve_version`.
async fn fetch_latest_release(
@@ -2895,6 +2971,73 @@ fn is_update_available(latest: &str, current: &str) -> bool {
mod tests {
use super::*;
+ #[test]
+ fn github_rate_limit_error_says_when_to_retry() {
+ let error = github_http_error(
+ 403,
+ Some("0"),
+ Some("4600"),
+ br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#,
+ 1000,
+ );
+
+ assert_eq!(
+ error,
+ "GitHub API rate limit exceeded; try again in about 60 minutes (HTTP 403)"
+ );
+ }
+
+ /// GitHub answers a spent quota with 403 or 429 depending on the endpoint
+ /// and the era. Only the 403 spelling used to reach the retry advice, so a
+ /// 429 told the reader the quota was gone without saying when it returns.
+ #[test]
+ fn a_429_is_a_rate_limit_too() {
+ let error = github_http_error(
+ 429,
+ Some("0"),
+ Some("1600"),
+ br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#,
+ 1000,
+ );
+
+ assert_eq!(
+ error,
+ "GitHub API rate limit exceeded; try again in about 10 minutes (HTTP 429)"
+ );
+ }
+
+ #[test]
+ fn expired_github_rate_limit_says_to_retry_shortly() {
+ let error = github_http_error(
+ 403,
+ Some("0"),
+ Some("999"),
+ br#"{"message":"API rate limit exceeded."}"#,
+ 1000,
+ );
+
+ assert_eq!(
+ error,
+ "GitHub API rate limit exceeded; try again shortly (HTTP 403)"
+ );
+ }
+
+ #[test]
+ fn github_json_error_keeps_a_short_actionable_message() {
+ let error = github_http_error(
+ 404,
+ None,
+ None,
+ br#"{"message":"Not Found\nPlease check the repository"}"#,
+ 1000,
+ );
+
+ assert_eq!(
+ error,
+ "GitHub returned HTTP 404: Not Found Please check the repository"
+ );
+ }
+
fn github_asset(name: &str) -> GitHubAsset {
GitHubAsset {
name: name.to_string(),
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index e5c2f206..56555d5d 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -6820,6 +6820,11 @@ impl Render for TerminalView {
div()
.id("terminal-surface")
+ // The surface, not the grid inside it, is what carries the role:
+ // a11y focus is only ever reported for a `div` that tracks a focus
+ // handle *and* has a node of its own, so a terminal with no role
+ // here is a window whose focused element is the window.
+ .role(gpui::Role::MultilineTextInput)
.track_focus(&self.focus_handle)
.key_context(self.key_context())
.size_full()
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index 801694f7..7c449076 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -62,6 +62,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"),
"icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"),
"icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"),
+ "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"),
_ => return None,
};
Some(bytes)
diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs
index 0d379823..f609284a 100644
--- a/src/ui/file_tree.rs
+++ b/src/ui/file_tree.rs
@@ -6,12 +6,12 @@ use std::sync::Arc;
use crate::core::config::RightPanelTab;
use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex};
use crate::terminal::git_data::index_of;
-use crate::ui::app::Tty7App;
+use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::file_copy;
use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub};
use crate::ui::host_registry::HostRegistry;
use crate::ui::i18n::{L10nKey, t, t_fmt};
-use crate::ui::right_panel::{ROW_GLYPH, git_badge};
+use crate::ui::right_panel::{ROW_GLYPH, ROW_INSET, git_badge};
use crate::ui::scm::status::{status_color, status_glyph};
use gpui::prelude::*;
use gpui::{
@@ -24,6 +24,18 @@ use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
};
+// The tree is laid out the way every other list in this panel is: the column
+// sits a `ROW_INSET` short of `CONTENT_INSET` and each row pads itself back
+// out, so a depth-0 name lands on the panel's 12px rail while the row's hover
+// and selection fill bleeds past it to 8. Depth is added on top of that inset,
+// so `INDENT` is the step between levels and nothing else.
+//
+// It used to run its own pair of numbers instead — a `px_1()` column and a 6px
+// row — which put the tree's names 2px left of the search field directly above
+// them and let a selected row's fill reach twice as close to the panel edge as
+// an Info or Source Control row's. Two adjacent left edges that disagree by
+// 2px is the one misalignment a reader can actually catch, because the search
+// glyph sits right there to compare against.
const INDENT: f32 = 14.0;
const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
@@ -1579,7 +1591,7 @@ impl Tty7App {
.min_h_0()
.overflow_y_scroll()
.track_scroll(&self.right_panel.tree_scroll)
- .px_1()
+ .px(px(CONTENT_INSET - ROW_INSET))
.pb_1()
.track_focus(&self.file_tree.focus_handle)
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
@@ -1718,9 +1730,9 @@ impl Tty7App {
return vec![
h_flex()
// Aligned with the label column of a real row at this
- // depth: 6 for the row's own inset, INDENT for the depth,
- // then the width of the icon and its gap.
- .pl(px(6.0 + row.depth as f32 * INDENT + 20.0))
+ // depth: ROW_INSET for the row's own inset, INDENT for the
+ // depth, then the width of the icon and its gap.
+ .pl(px(ROW_INSET + row.depth as f32 * INDENT + 20.0))
.py_1()
.items_center()
.text_xs()
@@ -1806,8 +1818,8 @@ impl Tty7App {
.id(SharedString::from(format!("tree-{}", path.display())))
.items_center()
.gap_1()
- .pl(px(6.0 + row.depth as f32 * INDENT))
- .pr_1()
+ .pl(px(ROW_INSET + row.depth as f32 * INDENT))
+ .pr(px(ROW_INSET))
.py_1()
.rounded(cx.theme().radius)
.cursor_pointer()
@@ -1903,8 +1915,8 @@ impl Tty7App {
h_flex()
.items_center()
.gap_1()
- .pl(px(6.0 + (row.depth + 1) as f32 * INDENT))
- .pr_1()
+ .pl(px(ROW_INSET + (row.depth + 1) as f32 * INDENT))
+ .pr(px(ROW_INSET))
.py_0p5()
.child(Input::new(&input).xsmall())
.into_any_element(),
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 516967b2..47f63081 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -760,6 +760,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
+ L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github",
L10nKey::SettingsSearchAppHttpProxyKeywords => {
"proxy http https socks socks5 clash v2ray network download update"
@@ -855,6 +856,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsSearchKimiCodeKeywords => {
"agent integration hooks install kimi code kimi-code moonshot"
}
+ L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli",
L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi",
L10nKey::SettingsSearchPortForwardingKeywords => {
"ssh tunnel local remote dynamic socks forward rule"
@@ -1070,6 +1072,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PanelPortsRestricted => {
"Something here runs as another user, whose ports aren't visible."
}
+ L10nKey::PanelLatency => "latency",
L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}",
L10nKey::PanelCwd => "cwd",
L10nKey::PanelShell => "shell",
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index c9e4c8a2..59eefef9 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -769,6 +769,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
+ L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
L10nKey::SettingsSearchAboutKeywords => {
"バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check"
}
@@ -906,6 +907,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchKimiCodeKeywords => {
"エージェント 統合 フック インストール kimi code moonshot agent integration hooks install"
}
+ L10nKey::SettingsSearchQoderCLIKeywords => {
+ "エージェント 統合 フック インストール qoder qodercli agent integration hooks install"
+ }
L10nKey::SettingsSearchPiKeywords => {
"エージェント 統合 拡張 インストール pi agent integration extension install"
}
@@ -1129,6 +1133,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelSessionSubtitle => "セッション",
L10nKey::PanelProcessesSubtitle => "プロセス",
L10nKey::PanelPortsSubtitle => "ポート",
+ L10nKey::PanelLatency => "遅延",
L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。",
L10nKey::PanelPortsProbeFailed => {
"このペインが何をリッスンしているか確認できませんでした。"
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index a4a585af..017aba94 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -586,6 +586,7 @@ l10n_keys! {
SettingsAgentQwenCode,
SettingsAgentGoose,
SettingsAgentKimiCode,
+ SettingsAgentQoderCLI,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -640,6 +641,7 @@ l10n_keys! {
SettingsSearchPortForwardingKeywords,
SettingsSearchProgramKeywords,
SettingsSearchQwenCodeKeywords,
+ SettingsSearchQoderCLIKeywords,
SettingsSearchRememberWindowSizeKeywords,
SettingsSearchReportMouseToAppsKeywords,
SettingsSearchRestoreLastLayoutKeywords,
@@ -788,6 +790,7 @@ l10n_keys! {
PanelPortsUnsupported,
PanelPortsProbeFailed,
PanelPortsRestricted,
+ PanelLatency,
PortAutoForwarded,
PanelCwd,
PanelShell,
@@ -1566,6 +1569,7 @@ mod tests {
L10nKey::SettingsAgentOpencode,
L10nKey::SettingsAgentPi,
L10nKey::SettingsAgentQwenCode,
+ L10nKey::SettingsAgentQoderCLI,
// Windows names its backdrop materials, and Japanese Windows keeps
// those names in Latin script — so does this list. Chinese does
// translate them (云母 / 亚克力), which is what Microsoft's own
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index 77b0b9d2..efce02fb 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -676,6 +676,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentQwenCode => "Qwen Code",
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
+ L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
L10nKey::SettingsSearchAboutKeywords => {
"关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update"
}
@@ -811,6 +812,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchKimiCodeKeywords => {
"Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install"
}
+ L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli",
L10nKey::SettingsSearchPiKeywords => {
"Pi agent 集成 扩展 安装 pi agent integration extension install"
}
@@ -1020,6 +1022,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelSessionSubtitle => "会话",
L10nKey::PanelProcessesSubtitle => "进程",
L10nKey::PanelPortsSubtitle => "端口",
+ L10nKey::PanelLatency => "延迟",
L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。",
L10nKey::PanelPortsProbeFailed => "没能查出这个窗格在监听什么。",
L10nKey::PanelPortsRestricted => "这里有以其他用户身份运行的进程,看不到它们的端口。",
diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs
index 43e21b74..364621f4 100644
--- a/src/ui/right_panel.rs
+++ b/src/ui/right_panel.rs
@@ -167,6 +167,16 @@ pub(crate) struct RightPanelState {
/// and "nobody could tell us" are different sentences and the panel has to
/// say which one it means.
pub(crate) procs_unsupported: bool,
+ /// The last round trip measured to the machine `procs_pane` lives on.
+ /// `None` before the first ping comes back.
+ pub(crate) link_rtt: Option,
+ /// Which host `link_rtt` was measured against, and — since only a remote
+ /// pane has one — whether the latency row is drawn at all. Held per host
+ /// rather than per pane so that moving between two panes of the same
+ /// machine keeps the number on screen: it belongs to the link the two
+ /// panes share, and blanking it per pane would empty the row for as long
+ /// as the next poll takes to cross the network.
+ pub(crate) link_host: Option,
/// How `procs_pane`'s loopback ports can be reached from this machine.
/// Read by the Ports list to decide what a click on a port does, and by
/// the watch to decide whether it has to keep looking with the panel shut.
@@ -251,6 +261,32 @@ enum InfoValue {
},
}
+/// The table convention for a cell with nothing in it. Needs no translating,
+/// and is shorter to read than any of the sentences it stands in for.
+const EMPTY: &str = "—";
+
+/// A round trip, at the precision the number is worth reading to.
+///
+/// Whole milliseconds up to a second: tenths of a millisecond on a link that
+/// varies by whole ones is noise dressed as measurement. Past a second the
+/// millisecond stops mattering and the second is the unit anyone would say it
+/// in.
+fn format_rtt(rtt: std::time::Duration) -> String {
+ let ms = rtt.as_secs_f64() * 1000.;
+ if ms < 1. {
+ // Loopback and a peer on the same LAN both land here. Rounding to
+ // "0 ms" would read as a failed measurement rather than a fast one.
+ return "<1 ms".to_string();
+ }
+ // Rounded before the comparison, so 999.6 ms is not shown as "1000 ms" —
+ // a millisecond reading that has run past the unit's own range.
+ let rounded = ms.round() as u64;
+ if rounded < 1000 {
+ return format!("{rounded} ms");
+ }
+ format!("{:.1} s", rtt.as_secs_f64())
+}
+
/// One label/value line of the Session section.
struct InfoRow {
label: &'static str,
@@ -781,6 +817,23 @@ impl Tty7App {
if let Some(ssh) = view.ssh_spec() {
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
}
+ // Only where there is a network between here and the shell. On
+ // a pane of this machine's own the row would be reporting the
+ // round trip to a Unix socket, which is a number with nothing
+ // to compare it against.
+ if self.right_panel.link_host.is_some() {
+ rows.push(InfoRow::text(
+ t(L10nKey::PanelLatency),
+ // A link whose first ping has not come back yet,
+ // rather than one measured at zero. The dash is the
+ // table's empty cell, the same one a clean working
+ // tree gets.
+ self.right_panel
+ .link_rtt
+ .map(format_rtt)
+ .unwrap_or_else(|| EMPTY.to_string()),
+ ));
+ }
git = view.git_status(cx);
}
// Read off the same pane the rows above describe, rather than off
@@ -916,7 +969,7 @@ impl Tty7App {
this.child(
div()
.text_color(cx.theme().muted_foreground)
- .child("—".to_string()),
+ .child(EMPTY.to_string()),
)
})
.when(added > 0, |this| {
@@ -1589,6 +1642,14 @@ impl Tty7App {
let Some(pane_id) = pane_id else { return };
self.right_panel.procs_forwards = forwards.clone();
self.right_panel.procs_host = host.clone();
+ // Per host, not per pane — see `link_host`. A pane of this machine's
+ // own has no host at all, which is what clears the section rather than
+ // leaving the last remote pane's numbers under a local one.
+ let link_host = host.as_ref().map(|h| h.id());
+ if self.right_panel.link_host != link_host {
+ self.right_panel.link_host = link_host;
+ self.right_panel.link_rtt = None;
+ }
if self.right_panel.procs_pane != Some(pane_id) {
self.right_panel.procs_pane = Some(pane_id);
self.right_panel.procs = None;
@@ -1620,7 +1681,15 @@ impl Tty7App {
) {
cx.spawn(async move |this, cx| {
let route = forwards.clone();
- let (procs, managed) = cx
+ // Only while someone is looking. This poll also runs with the panel
+ // shut, watching for ports to forward, and a round trip per round
+ // for a row nobody can see is the far end's time spent on nothing.
+ let want_link = this
+ .read_with(cx, |app, _| {
+ app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info
+ })
+ .unwrap_or(false);
+ let (procs, managed, link) = cx
.background_executor()
.spawn(async move {
// A remote workspace's pane runs on the peer, so the peer
@@ -1637,7 +1706,11 @@ impl Tty7App {
None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)),
};
let managed = route.map(|r| r.list()).unwrap_or_default();
- (procs, managed)
+ let link = match (want_link, &host) {
+ (true, Some(host)) => host.link_rtt(),
+ _ => None,
+ };
+ (procs, managed, link)
})
.await;
let keep_polling = this
@@ -1655,6 +1728,13 @@ impl Tty7App {
if forwards.is_some() {
app.loopback_panel.managed = managed;
}
+ // Only when this round actually asked. A round that did not
+ // leaves the last answer in place, so reopening the panel
+ // shows the number it was closed on rather than a dash
+ // until the next poll lands.
+ if want_link {
+ app.right_panel.link_rtt = link;
+ }
cx.notify();
let wanted = app.procs_wanted();
if !wanted {
@@ -1813,7 +1893,7 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin
#[cfg(test)]
mod tests {
- use super::{InfoRow, InfoValue, forwards_port};
+ use super::{InfoRow, InfoValue, format_rtt, forwards_port};
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward {
@@ -1919,6 +1999,25 @@ mod tests {
);
}
+ #[test]
+ fn a_round_trip_is_read_at_the_precision_it_is_worth() {
+ use std::time::Duration;
+ // A peer on the same machine or the same LAN. "0 ms" would read as a
+ // measurement that failed rather than one that was fast.
+ assert_eq!(format_rtt(Duration::from_micros(120)), "<1 ms");
+ assert_eq!(format_rtt(Duration::from_micros(999)), "<1 ms");
+ assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms");
+ assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms");
+ assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms");
+ // Rounding up out of the millisecond's own range hands the number to
+ // the unit above rather than printing a four-digit millisecond.
+ assert_eq!(format_rtt(Duration::from_micros(999_600)), "1.0 s");
+ // Past a second the millisecond has stopped carrying information, and
+ // the second is the unit anyone would say the number in.
+ assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s");
+ assert_eq!(format_rtt(Duration::from_secs(4)), "4.0 s");
+ }
+
#[test]
fn copyable_takes_the_text_the_row_shows_and_nothing_else() {
// `copyable()` reads the value it was given; rows built with an
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 45fdfe3b..5c2cb38c 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -656,6 +656,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsAgentKimiCode,
keywords: SettingsSearchKimiCodeKeywords,
},
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentQoderCLI,
+ keywords: SettingsSearchQoderCLIKeywords,
+ },
SearchEntry {
section: WindowTabs,
title: SettingsStartupWindow,
diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs
index a00b3943..1891a9df 100644
--- a/src/ui/switcher.rs
+++ b/src/ui/switcher.rs
@@ -3725,8 +3725,8 @@ mod tests {
view.name = None;
assert_eq!(
tab_view_label(&view, 0, None),
- "✳ 修复 workspace switcher",
- "then the title the local strip would be showing, verbatim"
+ "修复 workspace switcher",
+ "then the title the local strip would be showing — mark and all, which is to say without the mark"
);
view.osc_title = Some("user@host:~/repo/025/tty7".to_string());