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());