mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge remote-tracking branch 'origin/main' into fix/hide-the-title-bar-in-fullscreen
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 304" width="304" height="304">
|
||||
<path d="M231.5 283.39C212.11 292.92 189 301.61 166.75 297.81C153.5 295.55 142.22 289.6 130.68 283.17C115.17 274.53 99.21 266.04 83.28 258.21C74.31 253.8 65.61 248.73 56.69 244.16C39.8 235.52 27.3 228.1 17.19 211.3C6.03 192.76 6.99 171.12 6.99 150.17C6.99 134.63 5.88 118.64 9.18 103.38C16.37 70.11 37.46 40.35 66.32 22.17C85.66 9.98 115.65 1.77 138.41 7.15C150.97 10.12 161.64 17.11 173.02 22.81C189.54 31.08 205.77 40.01 222.1 48.74C245.52 61.26 270.45 69.84 283.84 94.65C297.12 119.25 293.01 150.06 293.01 177.17C293.01 197.72 293.01 218.28 293.01 238.83C293.01 248.73 294.8 260.66 291.76 270.19C287.47 283.7 273.26 294.72 258.6 293.5C252.45 292.98 247.76 290.52 242.24 288.23C238.74 286.77 234.68 285.48 231.5 283.39ZM191.9 63.5C187.95 59.69 181.25 57.23 176.29 54.85C167.96 50.85 159.83 46.17 151.7 41.8C145.39 38.41 139.18 34.97 132.17 33.26C122.25 30.84 112.15 33.2 102.61 35.81C72.6 44.02 50.57 67.35 39.21 95.73C32.39 112.77 33.84 131.45 33.84 149.5C33.84 166.9 32 185.63 42.15 200.69C49.73 211.94 62.59 217.37 74.29 223.2C82.51 227.3 90.62 231.76 98.72 236.14C101.09 237.43 105.96 241.15 108.54 240.83C108.5 236.87 107.2 232.85 107.07 228.83C106.72 217.74 106.99 206.6 106.99 195.5C106.99 184.24 106.63 173.01 108.11 161.84C111.9 133.07 128.92 104.76 150.97 86.14C159.42 79 168.85 72.6 179.02 68.19C182.64 66.62 188.93 65.5 191.9 63.5ZM134.04 226.5C133.71 230.93 134.73 235.44 135.82 239.68C141.78 262.78 164.37 275.32 187.6 270.81C196.28 269.13 204.62 266.22 212.7 262.84C217.41 260.88 221.61 256.31 226.59 255.15C235.92 252.97 245.64 261.06 253.98 264.19C258.08 265.72 263.09 268.43 265.74 262.9C267.25 259.77 266.06 249.39 266.06 245.5C266.06 231.17 266.06 216.83 266.06 202.5C266.06 184.5 266.16 166.5 266.16 148.5C266.16 137.6 267.14 125.92 263.79 115.4C257.07 94.29 236.49 81.97 214.51 85.73C206.58 87.08 198.51 89.11 191.17 92.27C190.26 96.24 190.94 101.07 190.94 105.17C190.94 117.19 191.88 129.73 189.82 141.62C185.49 166.6 174.02 189.29 156.68 207.84C151.56 213.31 145.67 218.38 139.59 222.76C137.8 224.05 135.67 225.04 134.04 226.5ZM163.5 111C147.55 126.96 134.79 149.54 134 172.5C133.84 177.17 133.84 181.83 133.84 186.5C133.84 188.15 133.3 190.79 134.5 192C150.7 175.79 163.86 153.06 163.99 129.5C164.01 126.31 165.05 112.87 163.5 111Z" fill="#0f0d0c" fill-rule="evenodd" stroke="#0f0d0c" stroke-width="0.25" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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(
|
||||
|
||||
@@ -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::<serde_json::Value>(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<String> {
|
||||
| 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()));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<TabView> {
|
||||
|
||||
#[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()),
|
||||
|
||||
@@ -938,6 +938,11 @@ struct ClientInner {
|
||||
blobs: Mutex<HashMap<u64, Vec<u8>>>,
|
||||
connected: AtomicBool,
|
||||
last_inbound: Mutex<Instant>,
|
||||
/// 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<Arc<dyn LinkShutdown>>,
|
||||
reader_done: Mutex<bool>,
|
||||
@@ -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<Duration> {
|
||||
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<ReplyOk> {
|
||||
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,
|
||||
|
||||
@@ -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<std::time::Duration> {
|
||||
None
|
||||
}
|
||||
|
||||
fn separator(&self) -> char;
|
||||
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
|
||||
@@ -253,6 +253,22 @@ impl Host for RemoteHost {
|
||||
})
|
||||
}
|
||||
|
||||
fn link_rtt(&self) -> Option<std::time::Duration> {
|
||||
// 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 {
|
||||
|
||||
+144
-1
@@ -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<T: serde::de::DeserializeOwned>(
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
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::<GitHubError>(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::<u64>().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::<Vec<_>>().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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-10
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => {
|
||||
"このペインが何をリッスンしているか確認できませんでした。"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => "这里有以其他用户身份运行的进程,看不到它们的端口。",
|
||||
|
||||
+103
-4
@@ -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<std::time::Duration>,
|
||||
/// 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<crate::ui::host_ops::HostId>,
|
||||
/// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user