mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
feat(agent): detect coding agents on Windows via shell-integration command capture
ConPTY has no foreground process group, so the Unix process-table poll (pgid -> argv) has no Windows equivalent and foreground_agent was a stub returning None. Follow Warp's approach instead: the shell integration captures the submitted command line at preexec and carries it percent-encoded on the OSC 133;C mark; the daemon detects the agent from that string. - shell_integration: all four bodies (zsh/bash/fish/PowerShell) append the submitted line to 133;C, truncated to 512 chars and escaped (% ESC BEL CR NL). PowerShell guards the surrogate-splitting truncation and wraps the emission in try/catch (EscapeDataString throws on lone surrogates under .NET Framework / PS 5.1). - pane: ShellState.command stores the capture (cleared on D only, so a stray foreign A/B mid-command can't wipe the chip); on Windows apply_signals feeds it through the new detect_from_command_with. - cli_agent: detect_from_command_with tokenizes a typed command line (quotes, & call operator, case-insensitive) and reuses the argv detection; base_stem handles backslash paths and .exe/.cmd/.bat/.ps1. - ForegroundProbes::agent is now Option<Option<CLIAgent>>: None means 'no process-table view' (native SSH, Windows) and is never applied, fixing the 0.5s poll wiping event-branded agents on native-SSH panes.
This commit is contained in:
+119
-11
@@ -6,12 +6,15 @@
|
||||
//! deliberately *not* tty7's own agent: it only observes and enriches whatever
|
||||
//! agent the user launched.
|
||||
//!
|
||||
//! Detection is command-based: the daemon already
|
||||
//! Detection is command-based: on macOS/Linux the daemon already
|
||||
//! reads the foreground process's `argv` for SSH-context sniffing, so we reuse
|
||||
//! that to match the invoked command against a known agent. Matching is a pure
|
||||
//! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core`
|
||||
//! (framework-light, unit-tested) and called daemon-side, with the resulting
|
||||
//! `Option<CLIAgent>` streamed to the client for the UI.
|
||||
//! `Option<CLIAgent>` streamed to the client for the UI. On Windows ConPTY
|
||||
//! exposes no foreground process group, so the input is the *typed command
|
||||
//! line* the shell integration captures at preexec and carries on the `133;C`
|
||||
//! mark — [`CLIAgent::detect_from_command_with`] matches it the same way.
|
||||
//!
|
||||
//! The enum is serialized across the daemon↔client protocol, so its variants
|
||||
//! are the wire contract; add new agents at the end.
|
||||
@@ -25,7 +28,6 @@
|
||||
//! the client.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -310,7 +312,7 @@ impl CLIAgent {
|
||||
if arg.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
for segment in arg.split('/') {
|
||||
for segment in arg.split(['/', '\\']) {
|
||||
if let Some(agent) =
|
||||
CLIAgent::match_token(&base_stem(segment).to_ascii_lowercase())
|
||||
{
|
||||
@@ -322,6 +324,37 @@ impl CLIAgent {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// [`detect_from_argv_with`](Self::detect_from_argv_with) over a *typed
|
||||
/// command line* rather than a live process `argv` — the Windows detection
|
||||
/// input. ConPTY has no foreground process group to resolve to an argv, so
|
||||
/// there the daemon learns what runs from the shell integration instead:
|
||||
/// PowerShell's `PSConsoleHostReadLine` wrapper reports the submitted line
|
||||
/// on the `133;C` mark (the same capture Warp's Windows integration uses),
|
||||
/// and this matches it like an argv.
|
||||
///
|
||||
/// The tokenization is deliberately naive — whitespace split, surrounding
|
||||
/// quotes trimmed, a leading PowerShell call operator (`&`) dropped, and
|
||||
/// everything lowercased (Windows commands are case-insensitive). A quoted
|
||||
/// launcher path containing spaces splits wrong and misses — notably
|
||||
/// PSReadLine tab-completion's `& 'C:\Program Files\…\claude.exe'` — the
|
||||
/// accepted trade-off for not writing a shell parser; the dominant shapes
|
||||
/// (a bare shim on PATH, `npx …`) tokenize fine, and `agent_commands`
|
||||
/// rules cover personal wrappers.
|
||||
pub fn detect_from_command_with(
|
||||
command: &str,
|
||||
custom: &HashMap<String, String>,
|
||||
) -> Option<CLIAgent> {
|
||||
let mut argv: Vec<String> = command
|
||||
.split_whitespace()
|
||||
.map(|t| t.trim_matches(['"', '\'']).to_ascii_lowercase())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if argv.first().is_some_and(|t| t == "&") {
|
||||
argv.remove(0);
|
||||
}
|
||||
Self::detect_from_argv_with(&argv, custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY`
|
||||
@@ -345,14 +378,24 @@ fn is_env_assignment(token: &str) -> bool {
|
||||
/// The final path component with a leading dir and a trailing script extension
|
||||
/// stripped, lowercased-ready but case preserved (callers lowercase when they
|
||||
/// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`.
|
||||
/// Splits on both separators by hand (not [`Path`]) so a Windows path in a
|
||||
/// captured command line (`C:\…\claude.cmd`) resolves the same on every
|
||||
/// platform — including in tests run on Unix.
|
||||
fn base_stem(token: &str) -> &str {
|
||||
let name = Path::new(token)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(token);
|
||||
// Strip one known script extension; leave unknown suffixes intact so
|
||||
// `claude-code` stays whole.
|
||||
for ext in [".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh"] {
|
||||
// Trailing separators are dropped first (`claude/` → `claude`, matching
|
||||
// the old `Path::file_name` behavior), then everything up to the last
|
||||
// separator.
|
||||
let trimmed = token.trim_end_matches(['/', '\\']);
|
||||
let name = match trimmed.rfind(['/', '\\']) {
|
||||
Some(i) => &trimmed[i + 1..],
|
||||
None => trimmed,
|
||||
};
|
||||
// Strip one known script/launcher extension; leave unknown suffixes intact
|
||||
// so `claude-code` stays whole. The Windows set covers npm's shim trio
|
||||
// (`claude.cmd` / `claude.ps1` / `claude.exe`).
|
||||
for ext in [
|
||||
".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh", ".exe", ".cmd", ".bat", ".ps1",
|
||||
] {
|
||||
if let Some(stem) = name.strip_suffix(ext) {
|
||||
return stem;
|
||||
}
|
||||
@@ -617,6 +660,11 @@ mod tests {
|
||||
CLIAgent::detect_from_argv(&argv(&["cursor-agent"])),
|
||||
Some(CLIAgent::Cursor)
|
||||
);
|
||||
// A trailing separator is tolerated, matching Path::file_name.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_argv(&argv(&["claude/"])),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -737,6 +785,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_from_typed_command_lines() {
|
||||
let none = HashMap::new();
|
||||
// Plain invocations, flags in tow.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("claude --resume abc", &none),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
// Windows launcher shapes: npm shims, absolute backslash paths, and
|
||||
// case-insensitive names.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("claude.exe", &none),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with(
|
||||
r"C:\Users\x\AppData\Roaming\npm\claude.cmd --model opus",
|
||||
&none
|
||||
),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("CLAUDE", &none),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
// PowerShell call operator + a quoted (space-free) path.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with(r#"& "C:\tools\codex.exe""#, &none),
|
||||
Some(CLIAgent::Codex)
|
||||
);
|
||||
// Interpreter-wrapped, Windows separators in the script path.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with(
|
||||
r"node C:\x\node_modules\@anthropic-ai\claude-code\cli.js",
|
||||
&none
|
||||
),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("npx.cmd @google/gemini-cli", &none),
|
||||
Some(CLIAgent::Gemini)
|
||||
);
|
||||
// Non-interpreter launchers never match on their arguments.
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("notepad claude.txt", &none),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("cat codex.md", &none),
|
||||
None
|
||||
);
|
||||
assert_eq!(CLIAgent::detect_from_command_with("", &none), None);
|
||||
// Custom rules apply to the typed launcher too.
|
||||
let custom: HashMap<String, String> = [("cc".to_string(), "claude".to_string())].into();
|
||||
assert_eq!(
|
||||
CLIAgent::detect_from_command_with("cc -c", &custom),
|
||||
Some(CLIAgent::Claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sentinel_events() {
|
||||
let ev = parse_agent_event(
|
||||
|
||||
+135
-12
@@ -404,7 +404,13 @@ enum PaneBackend {
|
||||
/// keeps the reader's signature readable.
|
||||
struct ForegroundProbes {
|
||||
remote: Box<dyn Fn() -> Option<RemoteContext> + Send>,
|
||||
agent: Box<dyn Fn() -> Option<crate::core::cli_agent::CLIAgent> + Send>,
|
||||
/// Outer `None` means this backend has no process-table view of the PTY
|
||||
/// foreground at all (native SSH; Windows, where ConPTY has no foreground
|
||||
/// process group) — "no opinion", never applied, so it can't wipe an agent
|
||||
/// identified another way (sentinel events, the Windows `133;C;<cmd>`
|
||||
/// mark). `Some(answer)` is a real poll result; its inner `None` ("polled,
|
||||
/// no agent") clears the chip.
|
||||
agent: Box<dyn Fn() -> Option<Option<crate::core::cli_agent::CLIAgent>> + Send>,
|
||||
}
|
||||
|
||||
/// The local-PTY backend: the same handles `DaemonPane` has always owned.
|
||||
@@ -715,6 +721,9 @@ impl DaemonPane {
|
||||
// gate closures answer "nothing local": OSC 133 marks from the remote
|
||||
// shell are trusted verbatim (correct — the remote shell *is* the session),
|
||||
// and no process-table SSH detection runs (this pane already *is* SSH).
|
||||
// The agent probe's `None` is "no opinion" (never applied), so an agent
|
||||
// identified from its sentinel events keeps its chip — the poll used to
|
||||
// wipe it within half a second.
|
||||
let reader = Self::spawn_reader(
|
||||
state,
|
||||
shutting_down,
|
||||
@@ -895,7 +904,11 @@ impl DaemonPane {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent = poll_now.then(&foreground_agent_fn);
|
||||
// Flattened: a fired poll whose probe has no
|
||||
// process-table view (native SSH, Windows) folds to
|
||||
// "no opinion" and is never applied — see
|
||||
// [`ForegroundProbes::agent`].
|
||||
let agent = poll_now.then(&foreground_agent_fn).flatten();
|
||||
|
||||
let tr1 = trace.then(std::time::Instant::now);
|
||||
let mut st = state.lock().unwrap();
|
||||
@@ -1604,6 +1617,18 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
}
|
||||
}
|
||||
if let Some(shell) = signals.shell {
|
||||
// Windows: agent identity rides the C mark's command capture — ConPTY
|
||||
// has no foreground process group for the Unix 0.5 s poll to read an
|
||||
// argv from. `C;<cmd>` detects, the prompt marks (`A`/`B`/`D`) cleared
|
||||
// `command` so they apply `None` and clear the chip. Unix keeps the
|
||||
// poll (it sees through scripts and wrappers) and never consults the
|
||||
// mark. Applied before the sentinel events below so an event naming
|
||||
// the agent can still re-brand within the same chunk.
|
||||
#[cfg(windows)]
|
||||
apply_agent(
|
||||
st,
|
||||
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()),
|
||||
);
|
||||
st.shell = shell.clone();
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::Prompt {
|
||||
@@ -1616,6 +1641,23 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
apply_agent_signals(st, signals.agent_events, signals.notification);
|
||||
}
|
||||
|
||||
/// The coding agent named by the shell's last `133;C;<command>` capture — the
|
||||
/// Windows detection input ([`apply_signals`] applies it there on every shell
|
||||
/// mark). `None` both at the prompt (`D` cleared `command`) and for an
|
||||
/// unrecognized command, so applying the answer verbatim also clears
|
||||
/// the chip when the command ends. Compiled on every platform so the unit
|
||||
/// tests cover it from Unix dev machines; only the Windows build calls it.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn agent_from_shell_mark(
|
||||
shell: &ShellState,
|
||||
custom: &std::collections::HashMap<String, String>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
shell
|
||||
.command
|
||||
.as_deref()
|
||||
.and_then(|cmd| crate::core::cli_agent::CLIAgent::detect_from_command_with(cmd, custom))
|
||||
}
|
||||
|
||||
/// Fold the chunk's agent signals into the pane's session state and push any
|
||||
/// resulting change. Called with the state lock held.
|
||||
///
|
||||
@@ -1758,22 +1800,32 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti
|
||||
/// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the
|
||||
/// PTY foreground, from its `argv`. Same process-table read as
|
||||
/// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll.
|
||||
/// Always `Some(answer)` — this platform *has* the process-table view, so even
|
||||
/// "no agent" is a real answer that must apply (it clears the chip when the
|
||||
/// agent exits). See [`ForegroundProbes::agent`] for the outer option's contract.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn foreground_agent(
|
||||
master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
|
||||
let argv = crate::daemon::remote::foreground_argv(pid)?;
|
||||
crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
&argv,
|
||||
crate::core::config::agent_commands_cached(),
|
||||
)
|
||||
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
|
||||
let detect = || {
|
||||
let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
|
||||
let argv = crate::daemon::remote::foreground_argv(pid)?;
|
||||
crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
&argv,
|
||||
crate::core::config::agent_commands_cached(),
|
||||
)
|
||||
};
|
||||
Some(detect())
|
||||
}
|
||||
|
||||
/// Windows: ConPTY has no foreground process group, so there is no process
|
||||
/// table to poll — "no opinion" (`None`), never applied. Agent identity comes
|
||||
/// from the shell integration's `133;C;<command>` capture instead, applied in
|
||||
/// [`apply_signals`] via [`agent_from_shell_mark`].
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn foreground_agent(
|
||||
_master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1791,6 +1843,11 @@ struct ShellState {
|
||||
active: bool,
|
||||
at_prompt: bool,
|
||||
last_exit_code: Option<i32>,
|
||||
/// The command line the shell reported on its last `133;C;<cmd>` mark
|
||||
/// (percent-decoded), cleared when the command finishes (`D`). All of
|
||||
/// tty7's shell integrations carry the payload; it is the Windows
|
||||
/// coding-agent detection input (see [`agent_from_shell_mark`]).
|
||||
command: Option<String>,
|
||||
}
|
||||
|
||||
/// Changes a `feed` call produced, if any.
|
||||
@@ -1869,10 +1926,26 @@ fn handle_osc133(shell: &mut ShellState, rest: &[u8]) -> bool {
|
||||
// grid) instead of the editor — the "un-deletable char / doubled prompt"
|
||||
// glitch. Setting it as early as `D`/`A` closes that window.
|
||||
match rest.first() {
|
||||
// A/B deliberately leave `command` alone: every tty7 integration emits
|
||||
// D *before* A at a real prompt (so it's already cleared there), while
|
||||
// a stray A/B from a foreign integration mid-command (a nested or
|
||||
// remote shell drawing its own prompt — Windows has no pgid gate to
|
||||
// reject it with, cf. issue #26) must not wipe the agent chip.
|
||||
Some(b'A') | Some(b'B') => shell.at_prompt = true,
|
||||
Some(b'C') => shell.at_prompt = false,
|
||||
Some(b'C') => {
|
||||
shell.at_prompt = false;
|
||||
// tty7 extension: our shell integrations append the submitted
|
||||
// command line, percent-encoded — the Windows agent-detection
|
||||
// input (see [`agent_from_shell_mark`]). Bare `C` (a foreign
|
||||
// shell's own integration) leaves it `None`.
|
||||
shell.command = rest
|
||||
.strip_prefix(b"C;")
|
||||
.map(|c| String::from_utf8_lossy(&percent_decode(c)).into_owned())
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
}
|
||||
Some(b'D') => {
|
||||
shell.at_prompt = true;
|
||||
shell.command = None;
|
||||
shell.last_exit_code = rest
|
||||
.strip_prefix(b"D;")
|
||||
.and_then(|c| std::str::from_utf8(c).ok())
|
||||
@@ -2041,7 +2114,9 @@ mod tests {
|
||||
// hanging CI.
|
||||
let mut detected = None;
|
||||
for _ in 0..200 {
|
||||
if let Some(agent) = foreground_agent(&master) {
|
||||
// Flatten: the outer Some is just "this platform has a process
|
||||
// table"; the poll keeps going until detection actually answers.
|
||||
if let Some(agent) = foreground_agent(&master).flatten() {
|
||||
detected = Some(agent);
|
||||
break;
|
||||
}
|
||||
@@ -2406,6 +2481,53 @@ mod tests {
|
||||
assert_eq!(d.shell.as_ref().unwrap().last_exit_code, Some(130));
|
||||
}
|
||||
|
||||
/// The C mark's command capture (tty7 extension, PowerShell integration) —
|
||||
/// the Windows agent-detection input: `C;<cmd>` records the submitted line
|
||||
/// percent-decoded, every prompt mark clears it, and
|
||||
/// [`agent_from_shell_mark`] turns it into the chip's agent.
|
||||
#[test]
|
||||
fn sniff_osc133_command_capture_drives_agent_detection() {
|
||||
let custom = std::collections::HashMap::new();
|
||||
let mut s = OscSniffer::new();
|
||||
|
||||
// A submitted `claude --help` (space percent-encoded, as the
|
||||
// PowerShell body emits it).
|
||||
let c = s.feed(b"\x1b]133;C;claude%20--help\x07");
|
||||
let shell = c.shell.as_ref().unwrap();
|
||||
assert!(!shell.at_prompt);
|
||||
assert_eq!(shell.command.as_deref(), Some("claude --help"));
|
||||
assert_eq!(
|
||||
agent_from_shell_mark(shell, &custom),
|
||||
Some(crate::core::cli_agent::CLIAgent::Claude)
|
||||
);
|
||||
|
||||
// The command finishing (D) clears the capture → the agent clears.
|
||||
let d = s.feed(b"\x1b]133;D;0\x07");
|
||||
let shell = d.shell.as_ref().unwrap();
|
||||
assert_eq!(shell.command, None);
|
||||
assert_eq!(agent_from_shell_mark(shell, &custom), None);
|
||||
|
||||
// A non-agent command sets the capture but detects nothing.
|
||||
let c = s.feed(b"\x1b]133;C;git%20status\x07");
|
||||
let shell = c.shell.as_ref().unwrap();
|
||||
assert_eq!(shell.command.as_deref(), Some("git status"));
|
||||
assert_eq!(agent_from_shell_mark(shell, &custom), None);
|
||||
|
||||
// A bare `C` (a foreign shell integration) leaves no capture.
|
||||
let c = s.feed(b"\x1b]133;C\x07");
|
||||
assert_eq!(c.shell.as_ref().unwrap().command, None);
|
||||
|
||||
// A stray A/B mid-command (a nested/remote shell drawing its own
|
||||
// prompt) must NOT wipe the capture — only D (command finished) does.
|
||||
// Windows has no pgid gate to reject foreign marks with, so this is
|
||||
// what keeps the agent chip alive while the agent runs.
|
||||
let _ = s.feed(b"\x1b]133;C;codex\x07");
|
||||
let a = s.feed(b"\x1b]133;A\x1b]133;B\x07");
|
||||
assert_eq!(a.shell.as_ref().unwrap().command.as_deref(), Some("codex"));
|
||||
let d = s.feed(b"\x1b]133;D;0\x07");
|
||||
assert_eq!(d.shell.as_ref().unwrap().command, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_osc133_edit_mode_does_not_emit_prompt_state() {
|
||||
let mut s = OscSniffer::new();
|
||||
@@ -3034,6 +3156,7 @@ mod tests {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit_code: Some(0),
|
||||
command: None,
|
||||
}),
|
||||
..SniffSignals::default()
|
||||
},
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
//! interoperates with the wider ecosystem rather than a bespoke scheme:
|
||||
//! - `OSC 133 ; A ST` prompt start
|
||||
//! - `OSC 133 ; B ST` prompt end / command input begins
|
||||
//! - `OSC 133 ; C ST` command output begins (command executing)
|
||||
//! - `OSC 133 ; C [; <cmd>] ST` command output begins; all four integrations
|
||||
//! append the submitted command line percent-encoded (tty7 extension — the
|
||||
//! Windows coding-agent detection input, see `core::cli_agent`)
|
||||
//! - `OSC 133 ; D ; <exit> ST` command finished, with its exit code
|
||||
//! - `OSC 133 ; V ; 0/1 ST` tty7 extension: shell edit mode
|
||||
//!
|
||||
//! plus `OSC 7` to report the cwd precisely (many login shells don't emit it
|
||||
//! unless they think they're in Terminal.app).
|
||||
//!
|
||||
@@ -106,10 +109,21 @@ if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then
|
||||
|
||||
# preexec runs after the user hits Enter, before the command runs: mark the
|
||||
# start of command output (C). We track an "active" flag so the very first
|
||||
# prompt (no command yet) doesn't emit a bogus D.
|
||||
# prompt (no command yet) doesn't emit a bogus D. The C mark carries the
|
||||
# submitted line ($1), truncated (detection only reads the front) and with
|
||||
# the bytes that would break OSC framing or the daemon's percent-decode
|
||||
# escaped (% ESC BEL CR NL) — the coding-agent detection input on Windows,
|
||||
# where ConPTY has no process table to poll (see core::cli_agent).
|
||||
__tty7_preexec() {
|
||||
__tty7_cmd_active=1
|
||||
__tty7_osc "133;C"
|
||||
local cmd=$1
|
||||
cmd=${cmd[1,512]}
|
||||
cmd=${cmd//\%/%25}
|
||||
cmd=${cmd//$'\e'/%1B}
|
||||
cmd=${cmd//$'\a'/%07}
|
||||
cmd=${cmd//$'\r'/%0D}
|
||||
cmd=${cmd//$'\n'/%0A}
|
||||
__tty7_osc "133;C;$cmd"
|
||||
}
|
||||
|
||||
autoload -Uz add-zsh-hook
|
||||
@@ -177,9 +191,16 @@ if status is-interactive; and test -z "$TTY7_SHELL_INTEGRATION"
|
||||
printf '\e]7;file://%s%s\a' (hostname) (string replace --all '%' '%25' -- $PWD)
|
||||
end
|
||||
|
||||
# The C mark carries the submitted line, truncated and with the bytes that
|
||||
# would break OSC framing or the daemon's percent-decode escaped (% ESC BEL
|
||||
# CR NL) — the Windows agent-detection input (see core::cli_agent). fish
|
||||
# command substitution splits output on newlines, so a multi-line command
|
||||
# arrives as a list; the final `string join` re-joins it with the escaped
|
||||
# newline. `%` must be escaped first (the other escapes introduce `%`).
|
||||
function __tty7_preexec --on-event fish_preexec
|
||||
set -g __tty7_cmd_active 1
|
||||
__tty7_osc "133;C"
|
||||
set -l cmd (string sub -l 512 -- $argv[1] | string replace -a '%' '%25' | string replace -a \e '%1B' | string replace -a \a '%07' | string replace -a \r '%0D' | string join '%0A')
|
||||
__tty7_osc "133;C;$cmd"
|
||||
end
|
||||
|
||||
# Runs on the fish_prompt *event*, which fires before fish calls the
|
||||
@@ -270,9 +291,18 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then
|
||||
return $ret
|
||||
}
|
||||
|
||||
# The C mark carries the submitted line ($1, from bash-preexec), truncated
|
||||
# and escaped the same way as the zsh path — the Windows agent-detection
|
||||
# input (git-bash; see core::cli_agent).
|
||||
__tty7_preexec() {
|
||||
__tty7_cmd_active=1
|
||||
__tty7_osc "133;C"
|
||||
local cmd=${1:0:512}
|
||||
cmd=${cmd//\%/%25}
|
||||
cmd=${cmd//$'\e'/%1B}
|
||||
cmd=${cmd//$'\a'/%07}
|
||||
cmd=${cmd//$'\r'/%0D}
|
||||
cmd=${cmd//$'\n'/%0A}
|
||||
__tty7_osc "133;C;$cmd"
|
||||
}
|
||||
|
||||
if [[ -z "${bash_preexec_imported:-}" ]]; then
|
||||
@@ -464,7 +494,13 @@ fi
|
||||
/// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`).
|
||||
/// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest
|
||||
/// thing PowerShell has to a preexec. After it returns the submitted line,
|
||||
/// before the command runs, we emit `133;C` (command output begins).
|
||||
/// before the command runs, we emit `133;C;<command>` (command output
|
||||
/// begins), carrying the submitted line percent-encoded as a tty7
|
||||
/// extension. That capture is the Windows coding-agent detection input:
|
||||
/// ConPTY has no foreground process group for the daemon's process-table
|
||||
/// poll to read an `argv` from, so — like Warp — the daemon learns what
|
||||
/// runs from the line the shell itself reported (see
|
||||
/// `core::cli_agent::CLIAgent::detect_from_command_with`).
|
||||
///
|
||||
/// `$?` must be captured as the very first statement of `prompt` (an
|
||||
/// assignment sets `$?` to true, clobbering it), and is restored before the
|
||||
@@ -543,7 +579,25 @@ if (-not $env:TTY7_SHELL_INTEGRATION) {
|
||||
$line = & $global:__Tty7OrigReadLine
|
||||
if (-not [string]::IsNullOrWhiteSpace($line)) {
|
||||
$global:__Tty7CmdActive = $true
|
||||
Write-Host -NoNewline "$($global:__Tty7Esc)]133;C$($global:__Tty7Bel)"
|
||||
# Carry the submitted line on the C mark (tty7 extension): the daemon
|
||||
# detects coding agents from it on Windows, where ConPTY exposes no
|
||||
# foreground process group to read an argv from. Truncated (detection
|
||||
# only reads the front) and percent-encoded so the payload can't carry
|
||||
# a raw `;`, ESC or BEL into the OSC framing. The cut can split a
|
||||
# surrogate pair, and a lone high surrogate makes EscapeDataString
|
||||
# throw on .NET Framework (PS 5.1) — inside this wrapper that would
|
||||
# swallow the submitted line, so drop it and keep the whole mark
|
||||
# best-effort: a plain `C` still flips the prompt state.
|
||||
$cmd = if ($line.Length -gt 512) { $line.Substring(0, 512) } else { $line }
|
||||
if ([char]::IsHighSurrogate($cmd[$cmd.Length - 1])) {
|
||||
$cmd = $cmd.Substring(0, $cmd.Length - 1)
|
||||
}
|
||||
try {
|
||||
$cmd = [Uri]::EscapeDataString($cmd)
|
||||
Write-Host -NoNewline "$($global:__Tty7Esc)]133;C;$cmd$($global:__Tty7Bel)"
|
||||
} catch {
|
||||
Write-Host -NoNewline "$($global:__Tty7Esc)]133;C$($global:__Tty7Bel)"
|
||||
}
|
||||
}
|
||||
$line
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user