Merge branch 'main' into fix/git-drawer-repaint-20260920

This commit is contained in:
Kuddev authored and GitHub committed 2026-09-20 20:10:10 +08:00
commit f0a64d24ad
42 files changed
+1751 -170

No files matched your search

@@ -0,0 +1,76 @@
# Notification actions share live confirmation identity
## Status
Proposed for review with implementation.
## Context
Completion notifications covered more cases than questions and failures. The
shell exit code was discarded before notification selection. Confirmation buttons
recognized only a trailing Y/n prompt and wrote text plus bare CR in one batch.
Native Windows notifications had no answer actions and did not retain their native
object beyond the delivery call.
## Evidence
`terminal/view/runtime.rs` already receives the shell exit code. Codex 0.154.0's
request_user_input handler submits the current question when a numbered option is
pressed; an extra Enter can submit the next question. Its approval UI advertises
configured one-key shortcuts. The terminal's shared keyboard encoder already
supports legacy VT, kitty and ConPTY control-key protocols.
## Decision
Retain up to 64 native notifications on one MTA worker with a bounded request
queue. Native action callbacks enqueue pane/request/choice events onto the UI
thread. Both in-app and native buttons resolve the same live confirmation before
writing input. Source pane, session, complete visible prompt, terminal mode and
request generation prevent old buttons from answering a different request.
A short bounded retry allows hook delivery to precede the interactive form's
render. Completion, new requests, manual input and view destruction cancel stale
work. Numbered Codex questions send a single advertised choice; approval buttons
use their displayed shortcut. Binary text and Enter are separate writes; a
lifecycle change or intervening input cancels the pending Enter. Multi-question
forms can publish the next question once a new frame is visible.
Short nonzero command exits produce a failure notification. Successful commands
retain the duration threshold, and hooked Agents retain their completion owner.
Each actionable request has its own throttle identity so answering one question
does not suppress a different question from the same pane.
## Rejected alternatives
- Unconditionally send `Y\r`: wrong for numbered choices and negotiated input.
- Add Enter after a Codex digit: can act on the next question.
- Trust only the original notification's text: stale OS notifications can survive
a session, prompt or terminal-mode change.
- Keep one worker per toast: duplicates thread and COM lifetime management.
- Assume showing a toast proves its click callback survives: separate lifetimes.
## Consequences
Unknown/freeform forms open the source pane without guessing an answer. Native
Windows toasts display at most five actions. The bounded retained set limits
activation support for very old Action Center entries; terminal state validation
is still required for every delivered callback. UI queue acceptance is not proof
that an arbitrary third-party CLI accepted an answer.
## Validation
State tests cover stale requests, displayed shortcuts, unsupported forms and
single-question submission. GPUI terminal tests inspect actual PTY writes under
legacy and negotiated keyboard modes, cancellation and repeated clicks. Native
XML tests reject terminal controls/markup injection. Native Windows notification
click, foreground policy, visual layout and packaged-runtime acceptance remain
platform integration checks and are not established by compilation alone.
## Supersedes
None.
## Revisit when
Providers expose a documented structured answer transport or the platform offers
an owned cross-platform notification activation service.
@@ -0,0 +1,72 @@
# WSL command hooks use the guest home and authenticated pane transport
## Status
Proposed for review with implementation.
## Context
A Windows terminal can launch Codex inside WSL. Windows hook installation does
not configure the guest home, discover its CLI version, or deliver native Linux
hook events through a Windows named pipe. Completion-only installation and screen
observations therefore remained active even when Windows reported native hooks.
## Evidence
`ai_hook/win.rs` installs host configuration. `terminal/session.rs` launches WSL
as a local PTY. The shared lifecycle already gives full native hooks precedence
and has no authority to infer that a separate host's installation covers WSL.
Codex can render compaction and queued input above its ordinary prompt row.
## Decision
Reuse `ai_hook/remote.rs` file planning and its POSIX adapter for WSL command hooks.
A bounded worker targets the original distribution and user without replacing the
interactive shell. Each PTY receives a fresh random token through WSLENV; its
stream parser checks that token before forwarding the existing OSC hook envelope.
Linux process identity remains separate from host PIDs.
Guest CLI discovery first respects PATH, then probes bounded conventional user
installation locations. Discovery can select a fallback NVM version when shell
initialization is unavailable; it does not claim to execute arbitrary shell
configuration. Explicit `setup-ai --wsl` permits repair/removal through the same
planner. Provider opt-out, unrelated hooks and notify chains remain preserved.
Claude question tools and terminal errors enter the shared lifecycle. Failed,
incomplete and cancelled outcomes do not display successful completion. Screen
fallback recognizes Codex queue/compaction chrome only where lifecycle coverage
is absent; it cannot override a native running turn.
## Rejected alternatives
- Reuse Windows hook paths inside WSL: wrong executable and configuration domain.
- Infer completion from output silence: tools and compaction can be silent.
- Overwrite guest settings: loses custom hooks, trust and user opt-out.
- Start a poller per pane: unbounded process and thread cost.
- Install plugin adapters without their startup environment: incomplete delivery.
## Consequences
One worker owns guest setup with an eight-second budget, bounded queue and retry
cache. Installation is asynchronous; an already-running CLI does not gain hooks
retroactively. Codex may require review in `/hooks`, and a fresh PTY is necessary
for its channel token. A CLI launched before initial setup finishes may require
restart. Unsupported/missing Python or customized owned assets cause a logged
failure while preserving configuration and screen fallback.
## Validation
Planner tests cover guest paths, custom notify preservation and repeated setup.
Python tests exercise PATH/NVM discovery, actual PTY delivery and transactional
writes. Stream tests check token rejection and every frame split. Lifecycle tests
cover queued input, compaction, question/tool-error/end-error distinctions.
Windows WSL delivery and hook trust still require integration acceptance.
## Supersedes
None; extends the existing SSH installation and native Codex contracts.
## Revisit when
WSL supplies a provider-neutral guest integration service, native hooks gain a
portable host transport, or plugin adapters can use the same command transport.
+1 -1
View File
@@ -193,7 +193,7 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [
[target.'cfg(windows)'.dependencies]
windows-core = "=0.61.2"
windows = { version = "=0.61.3", features = ["Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] }
windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] }
dirs = "6.0.0"
dwrote = "0.11.5"
# Registry reads for installed-shell detection (WSL distros under Lxss, Git /
+3 -1
View File
@@ -861,5 +861,7 @@
},
"ai_hooks": {
"announced": "AI turn notifications connected (Claude / Codex / Kimi / Pi / opencode). To revoke: pebrel setup-ai --remove"
}
},
"notification.command_finished": "Command finished in {seconds}s",
"notification.command_failed": "Command failed (exit {code}) after {seconds}s"
}
+3 -1
View File
@@ -861,5 +861,7 @@
},
"ai_hooks": {
"announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤销:pebrel setup-ai --remove"
}
},
"notification.command_finished": "命令结束,用时 {seconds} 秒",
"notification.command_failed": "命令失败(退出码 {code}),用时 {seconds} 秒"
}
+3 -1
View File
@@ -176,5 +176,7 @@
},
"ai_hooks": {
"announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤銷:pebrel setup-ai --remove"
}
},
"notification.command_finished": "命令結束,耗時 {seconds} 秒",
"notification.command_failed": "命令失敗(結束代碼 {code}),耗時 {seconds} 秒"
}
+2 -2
View File
@@ -147,8 +147,8 @@ def send(source, native, mode, payload, token):
if len(envelope) > MAX_ENVELOPE:
# Lifecycle must still arrive when a tool or answer is huge. Keep
# protocol identities and drop content, without inventing a result.
keep = ("hook_event_name", "type", "kind", "session_id", "thread-id", "turn_id", "turn-id", "source", "bridge_sequence", "event_id", "notification_type", "permission_mode", "stop_reason", "background_tasks", "agent_id", "agent_type")
question_input = payload.get("tool_input") if payload.get("tool_name") == "request_user_input" else None
keep = ("hook_event_name", "type", "kind", "session_id", "thread-id", "turn_id", "turn-id", "source", "bridge_sequence", "event_id", "notification_type", "permission_mode", "stop_reason", "error", "background_tasks", "agent_id", "agent_type")
question_input = payload.get("tool_input") if payload.get("tool_name") in ("request_user_input", "AskUserQuestion") else None
tool_name = payload.get("tool_name")
payload = {key: payload[key] for key in keep if key in payload}
if question_input is not None:
+32 -2
View File
@@ -9,6 +9,7 @@ import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
@@ -39,15 +40,44 @@ def capture(program, *args):
return ""
try:
with tempfile.TemporaryFile() as output:
env = dict(os.environ)
# Node-managed launchers use /usr/bin/env node. The interpreter
# beside the discovered launcher must be reachable too.
env["PATH"] = str(Path(program).parent) + os.pathsep + env.get("PATH", "")
subprocess.run([program, *args], stdin=subprocess.DEVNULL,
stdout=output, stderr=subprocess.DEVNULL,
timeout=2, check=True)
timeout=2, check=True, env=env)
output.seek(0)
return output.read(65536).decode("utf-8", "replace")
except (OSError, subprocess.SubprocessError):
return ""
def find_program(name):
found = shutil.which(name)
if found:
return found
home = Path.home()
candidates = [home / ".local/bin" / name, home / ".npm-global/bin" / name]
nvm = Path(os.environ.get("NVM_DIR", home / ".nvm"))
candidates.append(nvm / "current/bin" / name)
# Noninteractive login shells commonly skip nvm initialization. Probe
# executable files, not shell output, with a bounded version-directory scan.
versions = nvm / "versions/node"
try:
entries = []
for i, entry in enumerate(versions.iterdir()):
if i >= 128:
break
version = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", entry.name)
if version:
entries.append((tuple(map(int, version.groups())), entry))
candidates.extend(entry / "bin" / name for _, entry in sorted(entries, reverse=True))
except OSError:
pass
return next((str(path) for path in candidates if path.is_file() and os.access(path, os.X_OK)), None)
def locations():
home = Path.home()
root = Path(os.environ.get("XDG_DATA_HOME", home / ".local/share")) / "pebrel/ai"
@@ -76,7 +106,7 @@ def snapshot():
"path": str(path), "sha256": digest(content),
"content": content.decode("utf-8") if content is not None else None,
}
codex = shutil.which("codex")
codex = find_program("codex")
return {
"version": 1, "root": str(root), "python": sys.executable,
"shell": os.environ.get("SHELL", "/bin/sh"), "files": files,
+5 -2
View File
@@ -30,8 +30,11 @@ any = [
id = "working_status"
state = "working"
priority = 400
region = "bottom_non_empty_lines(4)"
line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$']
region = "bottom_non_empty_lines(16)"
any = [
{ line_regex = ['^[•◦]\s+(?:Working|Compacting context) \([^)]*esc to interrupt\)(?: · .*)?$'] },
{ line_regex = ['^[•◦]\s+Messages to be submitted after next tool call \(press esc to interrupt and send immediately\)$'] },
]
[[rules]]
id = "prompt_idle"
+18
View File
@@ -1060,6 +1060,24 @@ mod tests {
assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id);
}
#[test]
fn codex_queue_and_compaction_are_live_work_only_in_the_chrome_tail() {
for chrome in [
"• Working (12s · esc to interrupt)\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Continue the task",
"• Compacting context (1m 41s · esc to interrupt)\n └ Making room to continue.",
] {
let frame = format!("{chrome}\n› Ask Codex to do anything\ngpt-6 max · /project");
assert_eq!(detect("codex", &frame).unwrap().status, AgentStatus::Working);
let stale = format!(
"{chrome}\n{}› Ask Codex to do anything\ngpt-6 max · /project",
"completed output\n".repeat(20)
);
assert_eq!(detect("codex", &stale).unwrap().status, AgentStatus::Idle);
}
let quoted = "The log said Compacting context; Messages to be submitted after next tool call\n› Ask Codex to do anything\ngpt-6 max · /project";
assert_eq!(detect("codex", quoted).unwrap().status, AgentStatus::Idle);
}
#[test]
fn real_codex_idle_chrome_reads_idle() {
// codex 早已答完,屏幕上就是空闲输入框。这块屏幕当初根本没人去匹:
+4 -1
View File
@@ -63,13 +63,16 @@ const HELPER_ARGS: [&str; 1] = ["claude"];
/// Claude hook events we subscribe to. Session boundaries carry the id needed
/// for resume/fork; PostToolUse lets a stale permission state return to working
/// before the whole turn completes.
const CLAUDE_EVENTS: [&str; 7] = [
const CLAUDE_EVENTS: [&str; 10] = [
"SessionStart",
"UserPromptSubmit",
"Notification",
"PermissionRequest",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"StopFailure",
"SessionEnd",
];
+7 -5
View File
@@ -280,11 +280,13 @@ impl AgentActivity {
},
AiHookKind::TurnDone => {
self.screen_armed = false;
if event.source == "pi"
&& !matches!(
event.turn_outcome,
super::AiTurnOutcome::Succeeded | super::AiTurnOutcome::Unspecified
)
if matches!(
event.turn_outcome,
super::AiTurnOutcome::Failed
| super::AiTurnOutcome::Incomplete
| super::AiTurnOutcome::Cancelled
) || (event.source == "pi"
&& event.turn_outcome == super::AiTurnOutcome::Unknown)
{
AgentStatus::Idle
} else {
+63 -1
View File
@@ -77,7 +77,7 @@ fn native_codex_owns_start_permission_recovery_interrupt_and_end() {
("UserPromptSubmit", AgentStatus::Working),
("PermissionRequest", AgentStatus::Blocked),
("PostToolUse", AgentStatus::Working),
("Interrupt", AgentStatus::Done),
("Interrupt", AgentStatus::Idle),
] {
let hook = codex(event, "native", Some("turn1"));
assert!(hook.capabilities().lifecycle && hook.capabilities().attention_events);
@@ -245,3 +245,65 @@ fn kimi_failure_and_interrupt_do_not_announce_success() {
}
}
}
#[test]
fn queued_input_and_compaction_keep_native_turn_running() {
let screens = [
"• Working (12s · esc to interrupt)\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Continue the task\n› Ask Codex to do anything\ngpt-6 max · /project",
"• Compacting context (1m 41s · esc to interrupt)\n └ Making room to continue.\n› Ask Codex to do anything\ngpt-6 max · /project",
"› Ask Codex to do anything\ngpt-6 max · /project",
];
let mut activity = AgentActivity::default();
activity.apply_hook(&codex("UserPromptSubmit", "main", Some("turn")));
activity.input_sent();
for screen in screens {
for _ in 0..8 {
assert!(!activity.observe_screen(crate::ai_agents::detect("codex", screen)));
assert_eq!(activity.status(), AgentStatus::Working);
}
}
let compact = native(
"full",
None,
json!({
"hook_event_name":"SessionStart", "session_id":"main", "source":"compact"
}),
);
activity.apply_hook(&compact);
assert_eq!(activity.status(), AgentStatus::Working);
activity.apply_hook(&codex("Stop", "main", Some("turn")));
assert_eq!(activity.status(), AgentStatus::Done);
}
#[test]
fn claude_question_tool_failure_and_terminal_failure_have_distinct_effects() {
let mut activity = AgentActivity::default();
let parse = |payload: Value| {
parse_remote_envelope(format!("nebula-hook/1 source=claude\n{payload}").as_bytes(), Some(1))
.unwrap()
};
let question = parse(json!({"hook_event_name":"PreToolUse", "tool_name":"AskUserQuestion",
"tool_input":{"questions":[{"question":"Choose a scope","options":[{"label":"Current"}]}]}}));
assert_eq!(question.kind, AiHookKind::NeedsAttention);
activity.apply_hook(&question);
assert_eq!(activity.status(), AgentStatus::Blocked);
let tool_failure = parse(
json!({"hook_event_name":"PostToolUseFailure", "tool_name":"Bash", "error":"exit 1"}),
);
activity.apply_hook(&tool_failure);
assert_eq!(
activity.status(),
AgentStatus::Working,
"the agent can recover from a tool failure"
);
let failed = parse(json!({"hook_event_name":"StopFailure", "error":"rate_limit"}));
activity.apply_hook(&failed);
assert_eq!(activity.status(), AgentStatus::Idle);
assert_eq!(failed.turn_outcome, AiTurnOutcome::Failed);
let notification =
crate::notify::Notification::from_ai_hook(&failed, failed.message.clone(), false).unwrap();
assert!(notification.is_failure());
let success = parse(json!({"hook_event_name":"Stop"}));
activity.apply_hook(&success);
assert_eq!(activity.status(), AgentStatus::Done);
}
+14 -4
View File
@@ -98,8 +98,16 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option<AiHookEvent> {
"claude" => match payload.get("hook_event_name").and_then(Value::as_str) {
Some("SessionStart") => (AiHookKind::SessionStart, None),
Some("UserPromptSubmit") => (AiHookKind::PromptSubmit, None),
Some("PostToolUse") => (AiHookKind::ToolComplete, None),
Some("PreToolUse")
if payload.get("tool_name").and_then(Value::as_str) == Some("AskUserQuestion") =>
{
(AiHookKind::NeedsAttention, attention_message(&payload))
},
Some("PreToolUse" | "PostToolUse" | "PostToolUseFailure") => {
(AiHookKind::ToolComplete, None)
},
Some("Stop") => (AiHookKind::TurnDone, None),
Some("StopFailure") => (AiHookKind::TurnDone, context_string(&payload, &["error"])),
Some("SessionEnd") => (AiHookKind::SessionEnd, None),
// `Notification` 覆盖「权限询问」和「idle 提醒」两类,将来也可能
// 用来传别的东西。类型不可操作时丢掉,读不到类型时照常上报。
@@ -180,7 +188,9 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option<AiHookEvent> {
if source == "codex" && kind == AiHookKind::TurnDone && event_id.is_none() {
event_id = turn_id.as_ref().map(|id| format!("codex:turn:{id}:done"));
}
let turn_outcome = if source == "kimi" && kind == AiHookKind::TurnDone {
let turn_outcome = if matches!(source.as_str(), "claude" | "kimi")
&& kind == AiHookKind::TurnDone
{
match payload.get("hook_event_name").and_then(Value::as_str) {
Some("Stop") => AiTurnOutcome::Succeeded,
Some("Interrupt") => AiTurnOutcome::Cancelled,
@@ -299,11 +309,11 @@ fn unix_time_ms() -> u64 {
.map_or(0, |duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
}
/// 远端会话只能提交事件语义,Pane 身份始终由本地 SSH 通道覆盖,
/// 远端会话只能提交事件语义,Pane 身份始终由本地 PTY 通道覆盖,
/// 防止远端载荷把通知路由到同一窗口中的其他标签页。
pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option<u64>) -> Option<AiHookEvent> {
let mut event = parse_envelope(bytes)?;
// Only this entrypoint is reached after SSH token verification. Ignore
// Only this entrypoint is reached after SSH/WSL token verification. Ignore
// claimed process metadata on the local named-pipe path.
let header = std::str::from_utf8(bytes.split(|byte| *byte == b'\n').next()?).ok()?;
event.remote_process = header.split_whitespace().find_map(|field| {
+20 -1
View File
@@ -1,4 +1,4 @@
//! SSH installation policy. This module plans owned file edits; it never opens
//! POSIX hook installation policy. This module plans owned file edits; it never opens
//! a connection, changes a terminal, or interprets screen output.
use std::collections::BTreeMap;
@@ -12,6 +12,18 @@ use super::{CLAUDE_EVENTS, bridges, installation};
pub(crate) const FILE_ADAPTER: &str = include_str!("../../res/hooks/remote_files.py");
const BRIDGE: &str = include_str!("../../res/hooks/remote_bridge.py");
const SHELL: &str = include_str!("../../res/hooks/remote_shell.py");
pub(crate) const TOKEN_ENV: &str = "PEBREL_REMOTE_HOOK_TOKEN";
pub(crate) fn new_token() -> std::io::Result<String> {
use std::fmt::Write as _;
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|error| std::io::Error::other(error.to_string()))?;
let mut token = String::with_capacity(32);
for byte in bytes {
let _ = write!(token, "{byte:02x}");
}
Ok(token)
}
#[derive(Debug, Deserialize)]
pub(crate) struct Snapshot {
@@ -99,6 +111,13 @@ pub(crate) fn response(raw: &str) -> Result<Value, String> {
}
impl Snapshot {
/// WSL keeps its normal login shell. Only command hooks have a complete
/// transport there; plugin adapters currently need the SSH bootstrap env.
pub(crate) fn for_wsl(mut self) -> Self {
self.providers.retain(|name, _| matches!(name.as_str(), "claude" | "codex"));
self
}
fn file(&self, name: &str) -> Result<&File, String> {
self.files.get(name).ok_or_else(|| format!("missing integration file: {name}"))
}
+46
View File
@@ -172,3 +172,49 @@ fn export_isolated_ssh_acceptance_plan() {
)
.unwrap();
}
#[test]
fn wsl_installs_guest_command_hooks_and_preserves_custom_notify() {
let mut snapshot = snapshot().for_wsl();
put(&mut snapshot, "codex_config", "notify = ['user-notifier']\nmodel = 'kept'\n");
let edits = snapshot.plan(Action::Install).unwrap().unwrap();
assert!(!edits.iter().any(|edit| matches!(edit.name.as_str(), "opencode" | "pi")));
apply(&mut snapshot, edits);
let hooks: Value = serde_json::from_str(snapshot.raw("codex").unwrap().unwrap()).unwrap();
for name in [
"SessionStart",
"UserPromptSubmit",
"PermissionRequest",
"PreToolUse",
"PostToolUse",
"Stop",
"Interrupt",
"SessionEnd",
] {
let group = hooks["hooks"][name].to_string();
assert!(group.contains("--hooks=full"), "{name}: {group}");
assert!(group.contains("/home/"));
assert!(!group.contains(".exe"));
}
assert!(snapshot.raw("codex_config").unwrap().unwrap().contains("user-notifier"));
assert!(snapshot.plan(Action::Automatic).unwrap().unwrap().is_empty());
}
#[test]
fn wsl_replaces_the_legacy_windows_helper_without_chaining_a_dead_executable() {
let mut snapshot = snapshot().for_wsl();
put(
&mut snapshot,
"codex_config",
"notify = ['D:/Program Files/Nebula Terminal/runtime/nebula-hook.exe', 'codex']\n",
);
let edits = snapshot.plan(Action::Install).unwrap().unwrap();
apply(&mut snapshot, edits);
let config =
snapshot.raw("codex_config").unwrap().unwrap().parse::<toml_edit::DocumentMut>().unwrap();
let argv = notify(&config).unwrap().unwrap();
assert_eq!(argv.len(), 2);
assert!(argv[0].starts_with("/home/"));
assert_eq!(argv[1], "codex");
assert!(!argv[0].contains(".exe"));
}
+7 -1
View File
@@ -918,8 +918,14 @@ pub struct SetupAiOptions {
#[clap(long)]
pub remove: bool,
/// Install or remove hooks on an SSH host (alias or user@host).
#[clap(long, value_name = "DESTINATION")]
#[clap(long, value_name = "DESTINATION", conflicts_with = "wsl")]
pub ssh: Option<String>,
/// Install or remove hooks in a WSL distribution's own user configuration.
#[clap(long, value_name = "DISTRIBUTION")]
pub wsl: Option<String>,
/// WSL user; defaults to that distribution's configured user.
#[clap(long, value_name = "USER", requires = "wsl")]
pub wsl_user: Option<String>,
}
/// Options for the `ssh` subcommand: every token after `ssh` is captured raw
+1
View File
@@ -64,6 +64,7 @@ pub(crate) fn try_write_stderr(args: std::fmt::Arguments<'_>) {
pub(crate) enum GpuiShellEvent {
TrayFocus(Option<u64>),
NotificationFocus(Option<u64>),
NotificationChoice { pane_id: u64, request_id: u64, choice: usize },
TrayQuit,
MuxAttach,
RuntimeControl(std::sync::Arc<crate::runtime_api::RuntimeDispatch>),
@@ -1,3 +1,5 @@
mod choices;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -5,9 +7,10 @@ use nebula_terminal::term::TermMode;
use regex::Regex;
#[derive(Clone)]
pub(crate) struct BinaryConfirmation {
pub(crate) struct Confirmation {
pub id: u64,
pub question: String,
pub choices: Vec<String>,
}
#[derive(Clone, PartialEq, Eq)]
@@ -19,10 +22,11 @@ struct Fingerprint {
}
struct Pending {
public: BinaryConfirmation,
public: Confirmation,
fingerprint: Fingerprint,
allow: String,
deny: String,
replies: Vec<String>,
}
#[derive(Default)]
@@ -30,6 +34,8 @@ pub(super) struct ConfirmationState {
pending: Option<Pending>,
consumed: Option<Fingerprint>,
provider_request: Option<String>,
waiting: bool,
generation: u64,
}
fn describe(
@@ -39,7 +45,7 @@ fn describe(
mode: TermMode,
waiting: bool,
) -> Option<(Fingerprint, String, String, String)> {
if !waiting || program.is_empty() || mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC) {
if !waiting || program.is_empty() {
return None;
}
let context: Vec<String> = screen
@@ -79,7 +85,15 @@ fn describe(
}
impl ConfirmationState {
pub fn generation(&self) -> u64 {
self.generation
}
pub fn observe_waiting(&mut self, waiting: bool) {
if self.waiting != waiting {
self.generation = self.generation.wrapping_add(1);
}
self.waiting = waiting;
if !waiting {
self.pending = None;
self.consumed = None;
@@ -89,6 +103,7 @@ impl ConfirmationState {
pub fn observe_input(&mut self, bytes: &[u8]) {
if !bytes.is_empty() {
self.generation = self.generation.wrapping_add(1);
self.invalidate();
}
}
@@ -100,6 +115,7 @@ impl ConfirmationState {
self.pending = None;
self.consumed = None;
self.provider_request = Some(request.to_owned());
self.generation = self.generation.wrapping_add(1);
}
}
@@ -116,10 +132,19 @@ impl ConfirmationState {
session_id: Option<&str>,
mode: TermMode,
waiting: bool,
) -> Option<BinaryConfirmation> {
let Some((fingerprint, question, allow, deny)) =
describe(screen, program, session_id, mode, waiting)
else {
) -> Option<Confirmation> {
let described = describe(screen, program, session_id, mode, waiting)
.map(|(fingerprint, question, allow, deny)| {
(fingerprint, question, allow, deny, Vec::new(), Vec::new())
})
.or_else(|| {
choices::describe(screen, program, session_id, mode, waiting).map(
|(fingerprint, question, labels, replies)| {
(fingerprint, question, String::new(), String::new(), labels, replies)
},
)
});
let Some((fingerprint, question, allow, deny, choices, replies)) = described else {
self.invalidate();
return None;
};
@@ -132,8 +157,8 @@ impl ConfirmationState {
return Some(pending.public.clone());
}
static NEXT: AtomicU64 = AtomicU64::new(1);
let public = BinaryConfirmation { id: NEXT.fetch_add(1, Ordering::Relaxed), question };
self.pending = Some(Pending { public: public.clone(), fingerprint, allow, deny });
let public = Confirmation { id: NEXT.fetch_add(1, Ordering::Relaxed), question, choices };
self.pending = Some(Pending { public: public.clone(), fingerprint, allow, deny, replies });
Some(public)
}
@@ -147,18 +172,50 @@ impl ConfirmationState {
mode: TermMode,
waiting: bool,
) -> Option<Vec<u8>> {
if self.pending.as_ref()?.public.id != request_id {
self.answer_choice(
request_id,
usize::from(!allow),
screen,
program,
session_id,
mode,
waiting,
)
}
pub fn answer_choice(
&mut self,
request_id: u64,
choice: usize,
screen: &str,
program: &str,
session_id: Option<&str>,
mode: TermMode,
waiting: bool,
) -> Option<Vec<u8>> {
let pending = self.pending.as_ref()?;
if pending.public.id != request_id {
return None;
}
let pending = self.pending.take()?;
self.consumed = Some(pending.fingerprint.clone());
let (current, _, _, _) = describe(screen, program, session_id, mode, waiting)?;
if current != pending.fingerprint {
return None;
}
let mut reply = if allow { pending.allow } else { pending.deny }.into_bytes();
reply.push(b'\r');
Some(reply)
let reply = if pending.replies.is_empty() {
let (current, _, _, _) = describe(screen, program, session_id, mode, waiting)?;
if current != pending.fingerprint {
return None;
}
match choice {
0 => format!("{}\r", pending.allow),
1 => format!("{}\r", pending.deny),
_ => return None,
}
} else {
let (current, _, _, _) = choices::describe(screen, program, session_id, mode, waiting)?;
if current != pending.fingerprint {
return None;
}
pending.replies.get(choice)?.clone()
};
self.consumed = self.pending.take().map(|pending| pending.fingerprint);
Some(reply.into_bytes())
}
}
@@ -0,0 +1,130 @@
//! Codex's displayed one-key choices. A question digit submits the current
//! answer itself; appending Enter could accidentally submit the next question.
use super::*;
type Choices = (Fingerprint, String, Vec<String>, Vec<String>);
pub(super) fn describe(
screen: &str,
program: &str,
session_id: Option<&str>,
mode: TermMode,
waiting: bool,
) -> Option<Choices> {
if !waiting
|| crate::ai_agents::AgentKind::parse(program) != Some(crate::ai_agents::AgentKind::Codex)
{
return None;
}
let mut context: Vec<String> = screen
.lines()
.rev()
.filter(|line| !line.trim().is_empty())
.take(24)
.map(|line| line.trim().to_owned())
.collect();
context.reverse();
let footer = context.last()?.to_ascii_lowercase();
let question = footer.contains("tab to add notes") && footer.contains("enter to submit");
let approval = footer == "press enter to confirm or esc to cancel";
if !question && !approval {
return None;
}
static ROW: OnceLock<Regex> = OnceLock::new();
let row = ROW.get_or_init(|| Regex::new(r"^(?:[›❯>]\s*)?([1-9])\.\s+(.+)$").unwrap());
static KEY: OnceLock<Regex> = OnceLock::new();
let key = KEY.get_or_init(|| Regex::new(r"\(([a-z])\)$").unwrap());
let mut labels = Vec::new();
let mut replies = Vec::new();
let mut first_row = None;
for (ix, line) in context[..context.len() - 1].iter().enumerate() {
let Some(captures) = row.captures(line) else { continue };
let number = captures[1].parse::<usize>().ok()?;
if number != labels.len() + 1 || labels.len() >= 9 {
return None;
}
first_row.get_or_insert(ix);
let label = captures[2].trim();
if label.chars().count() > 240 {
return None;
}
if question {
// Codex's Other/notes path needs free text and cannot be answered
// by selecting an advertised option. Keep that path in the terminal.
if label.starts_with("Other") || label.starts_with("None of the above") {
continue;
}
replies.push(number.to_string());
labels.push(label.to_owned());
} else {
let shortcut = key.captures(label)?;
replies.push(shortcut[1].to_owned());
labels.push(label[..shortcut.get(0)?.start()].trim().to_owned());
}
}
if labels.is_empty() {
return None;
}
let first = first_row?;
let question =
context[..first].iter().rev().find(|line| !line.starts_with("Question "))?.clone();
Some((
Fingerprint {
context,
program: program.into(),
session_id: session_id.map(str::to_owned),
mode,
},
question,
labels,
replies,
))
}
#[cfg(test)]
mod tests {
use super::*;
const QUESTION: &str = "Question 1/2 (2 unanswered)\nChoose a scope.\n› 1. Current Selected directory\n 2. All Whole workspace\ntab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt";
#[test]
fn a_number_submits_only_the_current_question_and_old_buttons_expire() {
let mut state = ConfirmationState::default();
let first = state.capture(QUESTION, "codex", Some("s"), TermMode::empty(), true).unwrap();
assert_eq!(first.choices.len(), 2);
assert_eq!(
state.answer_choice(first.id, 1, QUESTION, "codex", Some("s"), TermMode::empty(), true),
Some(b"2".to_vec())
);
assert!(
state
.answer_choice(first.id, 1, QUESTION, "codex", Some("s"), TermMode::empty(), true)
.is_none()
);
let second = QUESTION.replace("Question 1/2", "Question 2/2");
let current = state.capture(&second, "codex", Some("s"), TermMode::empty(), true).unwrap();
assert_ne!(first.id, current.id);
assert!(
state
.answer_choice(first.id, 0, &second, "codex", Some("s"), TermMode::empty(), true)
.is_none()
);
}
#[test]
fn approval_uses_the_advertised_shortcut_and_rejects_unknown_controls() {
let prompt = "Grant permissions?\n› 1. For this turn (y)\n2. Deny (d)\nPress enter to confirm or esc to cancel";
let (_, _, labels, replies) =
describe(prompt, "codex", None, TermMode::empty(), true).unwrap();
assert_eq!(labels, ["For this turn", "Deny"]);
assert_eq!(replies, ["y", "d"]);
for invalid in [
prompt.replace("(d)", "(ctrl+d)"),
prompt.replace("2. Deny", "4. Deny"),
prompt.replace("Press enter", "Documentation: press enter"),
] {
assert!(describe(&invalid, "codex", None, TermMode::empty(), true).is_none());
}
assert!(describe(prompt, "claude", None, TermMode::empty(), true).is_none());
assert!(describe(prompt, "codex", None, TermMode::empty(), false).is_none());
}
}
+32 -6
View File
@@ -93,6 +93,23 @@ fn use_win32_input_mode(mode: &TermMode) -> bool {
crate::input::terminal_input::use_win32_input_mode(*mode)
}
/// Notification choices are synthetic keystrokes, not an IME composition.
/// Encode printable keys when the client explicitly requests every key as CSI-u.
pub(super) fn encode_choice_text(text: &str, mode: &TermMode) -> Vec<u8> {
let mut bytes = Vec::new();
for ch in text.chars() {
let key = Keystroke {
modifiers: Default::default(),
key: ch.to_string(),
key_char: Some(ch.to_string()),
};
bytes.extend(
kitty_sequence(&key, mode, true).unwrap_or_else(|| ch.to_string().into_bytes()),
);
}
bytes
}
/// 子进程是否请求过 kitty 键盘协议(三位标志任一)。kitty 是线上合同,
/// 压过 DECSET 9001——口径同旧壳 `input/terminal_input.rs`。
fn kitty_keyboard_active(mode: &TermMode) -> bool {
@@ -165,7 +182,7 @@ pub(super) fn trace_enter(ks: &Keystroke, mode: &TermMode, bytes: &[u8]) {
);
}
fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
fn kitty_sequence(ks: &Keystroke, mode: &TermMode, synthetic: bool) -> Option<Vec<u8>> {
use crate::input::terminal_input::{KeyInput, build_sequence};
use winit::event::ElementState;
use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey};
@@ -182,7 +199,9 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
(Key::Named(NamedKey::Enter), Key::Named(NamedKey::Enter))
} else {
if !mode.intersects(TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_ALL_KEYS_AS_ESC)
|| !(ks.modifiers.control || ks.modifiers.alt)
|| !(ks.modifiers.control
|| ks.modifiers.alt
|| synthetic && mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC))
{
return None;
}
@@ -197,7 +216,9 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
_ => return None,
}
};
let character = if ks.modifiers.shift {
let character = if synthetic {
ks.key_char.as_deref().and_then(|text| text.chars().next()).unwrap_or(base)
} else if ks.modifiers.shift {
ks.key_char
.as_deref()
.filter(|text| text.len() == 1 && text.as_bytes()[0].is_ascii_graphic())
@@ -208,7 +229,7 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
};
(Key::Character(character.to_string().into()), Key::Character(base.to_string().into()))
};
let input = KeyInput {
let mut input = KeyInput {
logical_key,
state: ElementState::Pressed,
location: KeyLocation::Standard,
@@ -230,7 +251,12 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
modifiers.set(ModifiersState::ALT, ks.modifiers.alt);
modifiers.set(ModifiersState::CONTROL, ks.modifiers.control);
modifiers.set(ModifiersState::SUPER, ks.modifiers.platform);
Some(build_sequence(&input, modifiers, *mode))
let mut bytes = build_sequence(&input, modifiers, *mode);
if synthetic && mode.contains(TermMode::REPORT_EVENT_TYPES) {
input.state = ElementState::Released;
bytes.extend(build_sequence(&input, modifiers, *mode));
}
Some(bytes)
}
/// These Windows chords must reach DefWindowProc so the existing close guard
@@ -279,7 +305,7 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
if let Some(bytes) = kitty_escape(ks, mode) {
return Some(bytes);
}
if let Some(bytes) = kitty_sequence(ks, mode) {
if let Some(bytes) = kitty_sequence(ks, mode, false) {
return Some(bytes);
}
@@ -158,6 +158,7 @@ pub(super) fn local_options(
// 身份契约必须最后写:它以环境表里 `WSLENV` 的现值为基准合并,才能同时
// 保住上面那段的 cwd 上报条目。见 [`crate::agent_env`]。
crate::agent_env::apply(&mut options.env, pane_id);
crate::platform::wsl_hooks::prepare(&mut options);
options
}
@@ -184,7 +185,11 @@ pub fn spawn(
// 用于 resize 锚定问题的字节级取证。
let record = std::env::var_os("NEBULA_PTY_RECORD").is_some();
let native_prompt = proxy.events.native_prompt.clone();
let event_loop = EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, record)?;
let mut event_loop =
EventLoop::new(Arc::clone(&term), proxy, pty, options.drain_on_exit, record)?;
if let Some(token) = options.env.get(crate::ai_hook::remote::TOKEN_ENV) {
event_loop.set_remote_hook_token(token.clone());
}
let notifier = Notifier(event_loop.channel());
let _io_thread = event_loop.spawn();
+1 -1
View File
@@ -654,7 +654,7 @@ impl TerminalView {
}
},
TermEvent::AiHookEnvelope(envelope) => {
// SSH pane 里的 agent 靠私有 OSC 把 hook 信封带回本地(本地
// SSH/WSL pane 里的 agent 靠私有 OSC 把 hook 信封带回本地(原生本地
// agent 走 workspace 的 ai_events 通道)。信封在 event_loop 里
// 已核过通道令牌,这里解析出来喂进同一个应用路径。
//
@@ -41,6 +41,22 @@ impl TerminalView {
return false;
}
self.agent_activity.apply_hook(event);
if matches!(
event.kind,
AiHookKind::SessionStart
| AiHookKind::NeedsAttention
| AiHookKind::PromptSubmit
| AiHookKind::ToolComplete
| AiHookKind::TurnDone
) {
self.last_command_failed = event.kind == AiHookKind::TurnDone
&& event.active_background_tasks() == 0
&& matches!(
event.turn_outcome,
crate::ai_hook::AiTurnOutcome::Failed
| crate::ai_hook::AiTurnOutcome::Incomplete
);
}
if let Some(target) = target {
let previous = self.recovery.target.clone();
self.recovery.confirm(target);
@@ -83,7 +99,11 @@ impl TerminalView {
let status = self.agent_activity.status();
self.confirmation.observe_waiting(status == AgentStatus::Blocked);
if event.kind == AiHookKind::NeedsAttention {
self.confirmation.set_provider_request(event.event_id.as_deref());
let request = event
.event_id
.clone()
.unwrap_or_else(|| format!("{}:{}", event.source, event.received_sequence));
self.confirmation.set_provider_request(Some(&request));
if let Some(mut attention) = event.attention.clone() {
attention.pane_id = Some(self.pane_id);
cx.emit(TerminalViewEvent::AiAttention(attention));
@@ -5,7 +5,7 @@ use super::{TerminalView, TerminalViewEvent};
impl TerminalView {
pub(crate) fn capture_confirmation(
&mut self,
) -> Option<super::super::confirmation::BinaryConfirmation> {
) -> Option<super::super::confirmation::Confirmation> {
let (screen, mode) = self.runtime_screen_state()?;
let program = self.running_program.as_deref().unwrap_or_default();
let waiting = self.confirmation_waiting();
@@ -18,7 +18,7 @@ impl TerminalView {
)
}
fn confirmation_waiting(&self) -> bool {
pub(crate) fn confirmation_waiting(&self) -> bool {
self.marked_text.is_none()
&& self
.running_program
@@ -38,11 +38,27 @@ impl TerminalView {
allow: bool,
cx: &mut Context<Self>,
) -> bool {
self.answer_choice(request_id, usize::from(!allow), cx)
}
pub(crate) fn confirmation_generation(&self) -> u64 {
self.confirmation.generation()
}
pub(crate) fn answer_choice(
&mut self,
request_id: u64,
choice: usize,
cx: &mut Context<Self>,
) -> bool {
if self.session.is_none() || self.pending_runtime_submit.is_some() {
return false;
}
let Some((screen, mode)) = self.runtime_screen_state() else { return false };
let waiting = self.confirmation_waiting();
let reply = self.confirmation.answer(
let reply = self.confirmation.answer_choice(
request_id,
allow,
choice,
&screen,
self.running_program.as_deref().unwrap_or_default(),
self.ai_session.as_ref().map(|identity| identity.session_id.as_str()),
@@ -50,9 +66,178 @@ impl TerminalView {
waiting,
);
let Some(reply) = reply else { return false };
self.write_input(reply, cx);
self.agent_activity.submitted();
let submit = reply.last() == Some(&b'\r');
let text =
std::str::from_utf8(if submit { &reply[..reply.len() - 1] } else { &reply }).unwrap();
let bytes = super::super::keymap::encode_choice_text(text, &mode);
self.write_input(bytes, cx);
let epoch = self.prompt_input_epoch;
let generation = self.confirmation_generation();
let executor = cx.background_executor().clone();
cx.spawn(async move |this, cx| {
// Separate line submission from text so TUIs cannot treat the pair
// as one pasted chunk. Manual input or a new lifecycle cancels it.
executor.timer(std::time::Duration::from_millis(75)).await;
if submit {
let _ = this.update(cx, |view, cx| {
if view.prompt_input_epoch != epoch
|| view.confirmation_generation() != generation
|| !view.confirmation_waiting()
{
return;
}
let bytes = crate::input::terminal_input::build_runtime_sequence_for_program(
crate::runtime_api::RuntimeKey::Enter,
Default::default(),
1,
view.term_mode(),
view.running_program.as_deref(),
);
view.write_input(bytes, cx);
});
return;
}
// A numbered answer may advance a multi-question form without a
// new hook. Advertise the next live question once its frame arrives.
for _ in 0..8 {
let keep_waiting = this
.update(cx, |view, cx| {
if view.prompt_input_epoch != epoch || !view.confirmation_waiting() {
return false;
}
if let Some(next) = view.capture_confirmation() {
cx.emit(TerminalViewEvent::Notification(
crate::notify::Notification::AiTurn {
program: view.running_program.clone().unwrap_or_default(),
message: Some(next.question),
attention: true,
},
));
false
} else {
true
}
})
.unwrap_or(false);
if !keep_waiting {
return;
}
executor.timer(std::time::Duration::from_millis(75)).await;
}
})
.detach();
cx.emit(TerminalViewEvent::TitleChanged);
true
}
}
#[cfg(all(test, feature = "gpui-test-support"))]
mod tests {
use super::*;
use crate::gpui_shell::terminal::view::startup_tests::{feed, open};
use gpui::TestAppContext;
use nebula_terminal::event_loop::Msg;
use std::time::Duration;
fn waiting(
view: &mut TerminalView,
program: &str,
prompt: &str,
session: &str,
cx: &mut Context<TerminalView>,
) {
view.session.as_ref().unwrap().term.lock().set_options(nebula_terminal::term::Config {
kitty_keyboard: true,
..Default::default()
});
let header = if program == "codex" {
"nebula-hook/1 source=codex codex_hooks=full"
} else {
"nebula-hook/1 source=claude"
};
let wire = format!(
"{header}\n{}",
serde_json::json!({"hook_event_name":"PermissionRequest", "session_id":session})
);
let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(42)).unwrap();
view.handle_ai_hook(&event, cx);
feed(view, prompt.replace('\n', "\r\n").as_bytes());
}
fn input(receiver: &std::sync::mpsc::Receiver<Msg>) -> Vec<Vec<u8>> {
receiver
.try_iter()
.filter_map(|msg| match msg {
Msg::Input(bytes) => Some(bytes.to_vec()),
_ => None,
})
.collect()
}
#[gpui::test]
fn binary_answers_separate_legacy_enter_and_cancel_after_manual_input(cx: &mut TestAppContext) {
let (view, window, receiver) = open(cx);
view.update(window, |view, cx| {
waiting(view, "claude", "Allow operation? [Y/n]", "confirmation-test-legacy", cx);
let request = view.capture_confirmation().unwrap();
assert!(view.answer_choice(request.id, 0, cx));
});
assert_eq!(input(&receiver), [b"Y".to_vec()]);
window.run_until_parked();
window.background_executor.advance_clock(Duration::from_millis(80));
window.run_until_parked();
assert_eq!(input(&receiver), [b"\r".to_vec()]);
view.update(window, |view, cx| {
view.confirmation.observe_waiting(false);
view.confirmation.observe_waiting(true);
let request = view.capture_confirmation().unwrap();
assert!(view.answer_choice(request.id, 1, cx));
view.write_input(b"x".to_vec(), cx);
});
assert_eq!(input(&receiver), [b"n".to_vec(), b"x".to_vec()]);
window.run_until_parked();
window.background_executor.advance_clock(Duration::from_millis(80));
window.run_until_parked();
assert!(
input(&receiver).is_empty(),
"a delayed Enter cannot follow intervening keyboard input"
);
}
#[gpui::test]
fn binary_confirmation_releases_text_and_enter_when_key_events_are_requested(
cx: &mut TestAppContext,
) {
let (view, window, receiver) = open(cx);
view.update(window, |view, cx| {
waiting(view, "claude", "Allow operation? [Y/n]", "confirmation-test-key-events", cx);
feed(view, b"\x1b[>10u");
let request = view.capture_confirmation().unwrap();
assert!(view.answer_choice(request.id, 0, cx));
});
assert_eq!(input(&receiver), [b"\x1b[89u\x1b[89;1:3u".to_vec()]);
window.run_until_parked();
window.background_executor.advance_clock(Duration::from_millis(80));
window.run_until_parked();
assert_eq!(input(&receiver), [b"\x1b[13u\x1b[13;1:3u".to_vec()]);
}
#[gpui::test]
fn numbered_codex_answers_send_one_key_without_enter(cx: &mut TestAppContext) {
let (view, window, receiver) = open(cx);
view.update(window, |view, cx| {
waiting(view, "codex", "Question 1/1 (1 unanswered)\nChoose a scope.\n› 1. Current\n 2. All\ntab to add notes | enter to submit answer | esc to interrupt", "confirmation-test-numbered", cx);
feed(view, b"\x1b[>10u");
assert!(view.term_mode().contains(nebula_terminal::term::TermMode::REPORT_ALL_KEYS_AS_ESC));
let request = view.capture_confirmation().unwrap();
assert_eq!(request.choices, ["Current", "All"]);
assert!(view.answer_choice(request.id, 1, cx));
assert!(!view.answer_choice(request.id, 0, cx));
});
assert_eq!(input(&receiver), [b"\x1b[50u\x1b[50;1:3u".to_vec()]);
window.run_until_parked();
window.background_executor.advance_clock(Duration::from_millis(800));
window.run_until_parked();
assert!(input(&receiver).is_empty());
}
}
@@ -6,19 +6,16 @@ impl super::TerminalView {
self.answer_reader.is_some()
}
pub(super) fn notify_command_done(&self, cx: &mut Context<Self>) {
if self.agent_activity.hook_seen() {
return;
}
pub(super) fn notify_command_done(&self, exit_code: Option<i32>, cx: &mut Context<Self>) {
if let Some(started) = self.command_started
&& started.elapsed() >= crate::notify::COMMAND_NOTIFY_MIN
&& let Some(notification) = crate::notify::Notification::command_finished(
self.running_program.clone(),
started.elapsed(),
exit_code,
self.agent_activity.hook_seen(),
)
{
cx.emit(super::TerminalViewEvent::Notification(
crate::notify::Notification::CommandDone {
duration: started.elapsed(),
program: self.running_program.clone(),
},
));
cx.emit(super::TerminalViewEvent::Notification(notification));
}
}
}
@@ -159,7 +159,7 @@ impl TerminalView {
exit_code: Option<i32>,
cx: &mut Context<Self>,
) {
self.notify_command_done(cx);
self.notify_command_done(exit_code, cx);
self.last_command_failed = exit_code.is_some_and(|code| code != 0);
if self.clear_foreground_agent_state(cx) {
cx.emit(TerminalViewEvent::TitleChanged);
+121 -10
View File
@@ -19,7 +19,10 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use gpui::{AnyElement, App, IntoElement as _, ParentElement as _, Styled as _, Window, div, px};
use gpui::{
AnyElement, App, InteractiveElement as _, IntoElement as _, ParentElement as _, Styled as _,
Window, div, px,
};
use gpui_component::notification::Notification;
use gpui_component::{Root, WindowExt as _};
@@ -249,7 +252,7 @@ pub(crate) fn confirmation_for_pane(
cx: &mut App,
text: String,
pane_id: u64,
confirmation: crate::gpui_shell::terminal::confirmation::BinaryConfirmation,
confirmation: crate::gpui_shell::terminal::confirmation::Confirmation,
) {
let language = super::config::ui_language(cx);
let allow_label = language.text(crate::i18n::Message::CommonYes).to_owned();
@@ -261,6 +264,30 @@ pub(crate) fn confirmation_for_pane(
cx.defer(move |cx| super::workspace::windowing::focus_notification(Some(pane_id), cx));
})
.content(move |_, _, cx| {
if !confirmation.choices.is_empty() {
return v_flex()
.gap_2()
.child(div().text_sm().child(confirmation.question.clone()))
.children(confirmation.choices.iter().enumerate().map(|(choice, label)| {
Button::new(("confirmation-choice", choice))
.debug_selector(|| format!("confirmation-choice-{choice}"))
.w_full()
.min_h(px(32.0))
.tooltip(label.clone())
.label(label.clone())
.small()
.ghost()
.on_click(cx.listener(move |note, _, window, cx| {
cx.stop_propagation();
let feedback = window.window_handle();
note.dismiss(window, cx);
cx.defer(move |cx| {
reply_to_choice(pane_id, request_id, choice, Some(feedback), cx)
});
}))
}))
.into_any_element();
}
v_flex()
.gap_2()
.child(div().text_sm().child(confirmation.question.clone()))
@@ -314,12 +341,21 @@ fn reply_to_confirmation(
allow: bool,
feedback: gpui::AnyWindowHandle,
cx: &mut App,
) {
reply_to_choice(pane_id, request_id, usize::from(!allow), Some(feedback), cx);
}
pub(crate) fn reply_to_choice(
pane_id: u64,
request_id: u64,
choice: usize,
feedback: Option<gpui::AnyWindowHandle>,
cx: &mut App,
) {
super::workspace::windowing::focus_notification(Some(pane_id), cx);
let applied = super::workspace::windowing::notification_view(pane_id, cx).is_some_and(|view| {
view.update(cx, |view, cx| view.answer_confirmation(request_id, allow, cx))
});
if !applied {
let applied = super::workspace::windowing::notification_view(pane_id, cx)
.is_some_and(|view| view.update(cx, |view, cx| view.answer_choice(request_id, choice, cx)));
if !applied && let Some(feedback) = feedback {
let _ = feedback.update(cx, |_, window, cx| {
let language = crate::display::LanguagePreference::from(
nebula_settings::RuntimeSettings::load().language,
@@ -446,7 +482,7 @@ mod tests {
};
use crate::gpui_shell::config::Settings;
use crate::gpui_shell::terminal::confirmation::BinaryConfirmation;
use crate::gpui_shell::terminal::confirmation::Confirmation;
struct Empty;
@@ -487,6 +523,81 @@ mod tests {
cx.run_until_parked();
}
#[gpui::test]
fn numbered_choice_buttons_have_real_hit_targets_and_report_a_closed_pane(
cx: &mut TestAppContext,
) {
struct NotificationSurface;
impl Render for NotificationSurface {
fn render(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
div().relative().size_full().children(render_layer(window, cx))
}
}
initialize(cx, true);
cx.update(|cx| {
cx.set_reduce_motion(true);
crate::gpui_shell::workspace::windowing::initialize(
cx,
crate::runtime_api::RuntimeHub::new(),
);
});
let mut surface = None;
let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|_| NotificationSurface);
surface = Some(view.clone());
Root::new(view, window, cx)
});
let surface = surface.unwrap();
cx.update(|window, cx| {
confirmation_for_pane(
window,
cx,
"Choose a scope".into(),
99991,
Confirmation {
id: 99991,
question: "Choose a scope".into(),
choices: vec!["Current directory".into(), "Whole workspace".into()],
},
)
});
cx.update(|_, cx| surface.update(cx, |_, cx| cx.notify()));
cx.run_until_parked();
cx.background_executor.advance_clock(Duration::from_millis(200));
cx.run_until_parked();
cx.update(|window, cx| {
surface.update(cx, |_, cx| cx.notify());
let _ = window.draw(cx);
});
let original = ids(cx);
assert_eq!(original.len(), 1);
let bounds =
cx.debug_bounds("confirmation-choice-1").expect("numbered option is rendered");
assert!(bounds.size.width >= px(100.0) && bounds.size.height >= px(32.0));
cx.simulate_mouse_down(
bounds.center(),
gpui::MouseButton::Left,
gpui::Modifiers::default(),
);
cx.simulate_mouse_up(
bounds.center(),
gpui::MouseButton::Left,
gpui::Modifiers::default(),
);
settle_dismissal(cx);
let current = ids(cx);
assert!(!current.contains(&original[0]), "the clicked stale card is dismissed");
assert_eq!(
current.len(),
1,
"the closed source produces visible expired-request feedback"
);
}
#[gpui::test]
fn disabling_ai_toasts_removes_only_ai_cards_and_can_be_reenabled(cx: &mut TestAppContext) {
initialize(cx, true);
@@ -505,7 +616,7 @@ mod tests {
cx,
"AI confirmation".into(),
7101,
BinaryConfirmation { id: 7201, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 7201, question: "Continue?".into() },
);
banner(window, cx, ToastKind::Warning, "Configuration needs attention");
});
@@ -561,7 +672,7 @@ mod tests {
cx,
"Permission".into(),
7303,
BinaryConfirmation { id: 7304, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 7304, question: "Continue?".into() },
);
});
cx.run_until_parked();
@@ -608,7 +719,7 @@ mod tests {
cx,
"Hidden AI confirmation".into(),
7102,
BinaryConfirmation { id: 7202, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 7202, question: "Continue?".into() },
);
banner_for_pane(
window,
@@ -7,7 +7,7 @@ use nebula_settings::NotificationDuration;
use std::{cell::Cell, rc::Rc};
use crate::gpui_shell::config::Settings;
use crate::gpui_shell::terminal::confirmation::BinaryConfirmation;
use crate::gpui_shell::terminal::confirmation::Confirmation;
struct Empty;
@@ -185,7 +185,7 @@ fn persistent_mode_has_no_expiry_and_retains_manual_dismissal(cx: &mut TestAppCo
cx,
"Persistent confirmation".into(),
8201,
BinaryConfirmation { id: 8301, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8301, question: "Continue?".into() },
);
} else {
toast(window, cx, ToastKind::Success, "Persistent ordinary toast");
@@ -215,7 +215,7 @@ fn refreshed_confirmation_gets_a_new_deadline_even_if_the_old_entity_is_retained
cx,
"Refreshed confirmation".into(),
8202,
BinaryConfirmation { id: 8302, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8302, question: "Continue?".into() },
);
};
let old = window.update(|window, cx| {
@@ -244,7 +244,7 @@ fn a_refreshed_persistent_card_cannot_be_closed_by_its_predecessors_timer(cx: &m
cx,
"Mode switch fixture".into(),
8203,
BinaryConfirmation { id: 8303, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8303, question: "Continue?".into() },
);
};
window.update(push);
@@ -280,7 +280,7 @@ fn default_mode_preserves_short_toasts_banners_and_persistent_update_notices(
cx,
"Default confirmation fixture".into(),
8204,
BinaryConfirmation { id: 8304, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8304, question: "Continue?".into() },
);
} else {
banner(window, cx, ToastKind::Warning, "Default non-AI configuration fixture");
@@ -323,7 +323,7 @@ fn duration_still_applies_to_non_ai_cards_when_ai_visibility_is_off(cx: &mut Tes
cx,
"Hidden AI fixture".into(),
8205,
BinaryConfirmation { id: 8305, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8305, question: "Continue?".into() },
);
});
assert_eq!(ids(&mut window).len(), 3);
@@ -408,7 +408,7 @@ fn overflow_removal_cannot_dismiss_a_refreshed_confirmation(cx: &mut TestAppCont
cx,
"Overflow confirmation".into(),
8206,
BinaryConfirmation { id: 8306, question: "Continue?".into() },
Confirmation { choices: Vec::new(), id: 8306, question: "Continue?".into() },
);
};
push(window, cx);
@@ -35,6 +35,54 @@ impl NebulaWorkspace {
notification: Notification,
window: &mut Window,
cx: &mut Context<Self>,
) {
if notification.is_attention()
&& let Some(source) = self.tabs.iter().find_map(|tab| match tab {
WorkspaceTab::Terminal { panes, .. } => {
panes.iter().find(|pane| pane.id == pane_id).map(|pane| pane.view.clone())
},
_ => None,
})
&& source.update(cx, |view, _| view.capture_confirmation()).is_none()
{
let generation = source.read(cx).confirmation_generation();
let source = source.downgrade();
let executor = cx.background_executor().clone();
cx.spawn_in(window, async move |this, cx| {
for attempt in 0..8 {
executor.timer(std::time::Duration::from_millis(75)).await;
let ready = source
.update(cx, |view, _| {
if view.confirmation_generation() != generation
|| !view.confirmation_waiting()
{
return None;
}
Some(view.capture_confirmation().is_some())
})
.ok()
.flatten();
let Some(ready) = ready else { return };
if ready || attempt == 7 {
let _ = this.update_in(cx, |workspace, window, cx| {
workspace.deliver_ready_notification(pane_id, notification, window, cx);
});
return;
}
}
})
.detach();
return;
}
self.deliver_ready_notification(pane_id, notification, window, cx);
}
fn deliver_ready_notification(
&mut self,
pane_id: u64,
notification: Notification,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab_index) = self.tab_of_pane(pane_id) else { return };
let Some(WorkspaceTab::Terminal { panes, focused, .. }) = self.tabs.get(tab_index) else {
@@ -70,13 +118,13 @@ impl NebulaWorkspace {
cx.notify();
return;
}
let attention = notification.is_attention();
let confirmation = if attention {
source_view.and_then(|view| view.update(cx, |view, _| view.capture_confirmation()))
} else {
None
};
if delivery.in_app {
let attention = notification.is_attention();
let confirmation = if attention {
source_view.and_then(|view| view.update(cx, |view, _| view.capture_confirmation()))
} else {
None
};
// Log the original message before the banner creates a bounded preview.
let (title, body) = notification.raw_toast_text();
let kind = if attention || notification.is_failure() {
@@ -85,7 +133,7 @@ impl NebulaWorkspace {
crate::display::ToastKind::Info
};
let text = format!("{title} \u{b7} {body}");
if let Some(confirmation) = confirmation {
if let Some(confirmation) = confirmation.clone() {
crate::gpui_shell::toast::confirmation_for_pane(
window,
cx,
@@ -105,7 +153,19 @@ impl NebulaWorkspace {
}
}
if delivery.system {
crate::notify::deliver_gpui(&notification, pane_id);
let choices = confirmation.map(|confirmation| {
let language = crate::gpui_shell::config::ui_language(cx);
let labels = if confirmation.choices.is_empty() {
vec![
language.text(crate::i18n::Message::CommonYes).to_owned(),
language.text(crate::i18n::Message::CommonNo).to_owned(),
]
} else {
confirmation.choices
};
(confirmation.id, labels)
});
crate::notify::deliver_gpui_with_choices(&notification, pane_id, choices);
}
cx.notify();
}
@@ -79,6 +79,19 @@ impl NebulaWorkspace {
let pane = *pane;
cx.defer(move |cx| super::windowing::focus_notification(pane, cx));
},
GpuiShellEvent::NotificationChoice { pane_id, request_id, choice } => {
let (pane_id, request_id, choice) = (*pane_id, *request_id, *choice);
cx.defer(move |cx| {
super::windowing::dispatch_shell_events(
vec![GpuiShellEvent::NotificationChoice {
pane_id,
request_id,
choice,
}],
cx,
)
});
},
GpuiShellEvent::TrayQuit => {
self.quit_from_tray(cx);
return;
@@ -861,6 +861,12 @@ pub(crate) fn dispatch_shell_events(events: Vec<GpuiShellEvent>, cx: &mut App) {
for event in events {
match event {
GpuiShellEvent::NotificationFocus(pane_id) => focus_notification(pane_id, cx),
GpuiShellEvent::NotificationChoice { pane_id, request_id, choice } => {
let feedback = entry_with_pane(pane_id, cx).map(|entry| entry.handle);
crate::gpui_shell::toast::reply_to_choice(
pane_id, request_id, choice, feedback, cx,
);
},
GpuiShellEvent::TrayFocus(pane_id) => {
let target =
pane_id.and_then(|pane_id| entry_with_pane(pane_id, cx)).or_else(|| {
@@ -1241,6 +1247,7 @@ pub(crate) fn focus_notification(pane_id: Option<u64>, cx: &mut App) {
}
super::quick_terminal::show_native_window(entry.native_hwnd);
}
cx.activate(true);
let workspace = entry.workspace.clone();
let _ = entry.handle.update(cx, move |_, window, cx| {
let _ = workspace.update(cx, |workspace, cx| {
@@ -1254,12 +1261,22 @@ pub(crate) fn focus_notification(pane_id: Option<u64>, cx: &mut App) {
#[cfg(windows)]
if let Some(hwnd) = native_hwnd(window) {
use windows_sys::Win32::UI::WindowsAndMessaging::{
IsIconic, SW_RESTORE, ShowWindow,
BringWindowToTop, IsIconic, IsWindowVisible, SW_RESTORE, SW_SHOW,
SetForegroundWindow, ShowWindow,
};
// Consult native visibility: tray/OS state can differ from the
// workspace flag. A notification click is an explicit request
// to reveal and foreground this window, including a hidden HWND.
unsafe {
let hwnd = hwnd as *mut std::ffi::c_void;
if IsIconic(hwnd) != 0 {
ShowWindow(hwnd, SW_RESTORE);
} else if IsWindowVisible(hwnd) == 0 {
ShowWindow(hwnd, SW_SHOW);
}
BringWindowToTop(hwnd);
if SetForegroundWindow(hwnd) == 0 {
log::debug!("notification foreground request deferred to GPUI activation");
}
}
}
+7
View File
@@ -255,6 +255,13 @@ fn main() -> Result<(), Box<dyn Error>> {
Some(Subcommands::NotifyTest) => std::process::exit(crate::notify::notify_test()),
#[cfg(windows)]
Some(Subcommands::SetupAi(options)) => {
if let Some(distro) = &options.wsl {
std::process::exit(crate::platform::wsl_hooks::setup_cli(
distro,
options.wsl_user.as_deref(),
options.remove,
));
}
if let Some(destination) = &options.ssh {
std::process::exit(crate::ssh_session::setup_ai_cli(destination, options.remove));
}
+156 -29
View File
@@ -48,8 +48,8 @@ pub fn init_proxy(proxy: EventLoopProxy<Event>) {
let _ = PROXY.set(proxy);
}
use crate::platform::notifications::ToastActivation;
pub use crate::platform::notifications::notify_test;
use crate::platform::notifications::{ToastActivation, toast_clickable};
#[cfg(feature = "gpui-shell")]
static GPUI_ACTIVATION: OnceLock<std::sync::mpsc::Sender<crate::gpui_shell::GpuiShellEvent>> =
@@ -94,6 +94,8 @@ pub enum Notification {
Bell { program: Option<String> },
/// A tracked command finished (OSC 133;C started it, 133;D ended it).
CommandDone { duration: Duration, program: Option<String> },
/// Shell-reported nonzero exit status, including short failed commands.
CommandFailed { duration: Duration, program: Option<String>, exit_code: i32 },
/// Free-text notification from a program (OSC 9, iTerm style). Claude
/// Code emits these (with the turn's actual message) when its notif
/// channel is `iterm2`/`iterm2_with_bell`. Carries the tracked program
@@ -137,6 +139,19 @@ pub(crate) fn clamp_toast_body(body: &str) -> String {
}
impl Notification {
pub(crate) fn command_finished(
program: Option<String>,
duration: Duration,
exit_code: Option<i32>,
hook_seen: bool,
) -> Option<Self> {
if let Some(exit_code) = exit_code.filter(|code| *code != 0) {
return Some(Self::CommandFailed { program, duration, exit_code });
}
(!hook_seen && duration >= COMMAND_NOTIFY_MIN)
.then_some(Self::CommandDone { program, duration })
}
pub(crate) fn from_ai_hook(
event: &crate::ai_hook::AiHookEvent,
message: Option<String>,
@@ -172,11 +187,12 @@ impl Notification {
pub(crate) fn is_failure(&self) -> bool {
matches!(
self,
Self::AiTurnIssue {
outcome: crate::ai_hook::AiTurnOutcome::Failed
| crate::ai_hook::AiTurnOutcome::Incomplete,
..
}
Self::CommandFailed { .. }
| Self::AiTurnIssue {
outcome: crate::ai_hook::AiTurnOutcome::Failed
| crate::ai_hook::AiTurnOutcome::Incomplete,
..
}
)
}
@@ -187,6 +203,7 @@ impl Notification {
Self::AiTurn { .. } | Self::AiTurnIssue { .. } => return true,
Self::Bell { program }
| Self::CommandDone { program, .. }
| Self::CommandFailed { program, .. }
| Self::Text { program, .. } => program.as_deref(),
};
program.is_some_and(|program| crate::ai_agents::AgentKind::parse(program).is_some())
@@ -208,18 +225,23 @@ impl Notification {
),
None => (crate::brand::NAME.to_owned(), "终端响铃".to_owned()),
},
Self::CommandDone { duration, program } => {
let secs = duration.as_secs();
let human = if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
};
match program {
Some(p) => (p.clone(), format!("命令完成,用时 {human}")),
None => (crate::brand::NAME.to_owned(), format!("命令完成,用时 {human}")),
}
},
Self::CommandDone { duration, program } => (
program.clone().unwrap_or_else(|| crate::brand::NAME.to_owned()),
notification_language().format(
crate::i18n::Message::NotificationCommandFinished,
&[("seconds", &duration.as_secs().to_string())],
),
),
Self::CommandFailed { duration, program, exit_code } => (
program.clone().unwrap_or_else(|| crate::brand::NAME.to_owned()),
notification_language().format(
crate::i18n::Message::NotificationCommandFailed,
&[
("seconds", &duration.as_secs().to_string()),
("code", &exit_code.to_string()),
],
),
),
Self::Text { body, program } => match program {
Some(p) => (p.clone(), body.clone()),
None => (crate::brand::NAME.to_owned(), body.clone()),
@@ -345,36 +367,86 @@ impl PaneFailureThrottle {
#[cfg(any(feature = "gpui-shell", test))]
#[derive(Default)]
struct PaneNotificationThrottle {
recent: HashMap<(u64, bool), Instant>,
recent: HashMap<(u64, bool, Option<u64>), Instant>,
}
#[cfg(any(feature = "gpui-shell", test))]
impl PaneNotificationThrottle {
fn accepts(&mut self, pane_id: u64, attention: bool, now: Instant) -> bool {
self.accepts_request(pane_id, attention, None, now)
}
fn accepts_request(
&mut self,
pane_id: u64,
attention: bool,
request: Option<u64>,
now: Instant,
) -> bool {
self.recent.retain(|_, last| now.saturating_duration_since(*last) < TOAST_THROTTLE);
if self.recent.contains_key(&(pane_id, attention)) {
if self.recent.contains_key(&(pane_id, attention, request)) {
return false;
}
self.recent.insert((pane_id, attention), now);
self.recent.insert((pane_id, attention, request), now);
true
}
}
#[cfg(feature = "gpui-shell")]
pub(crate) fn deliver_gpui(notification: &Notification, pane_id: u64) {
deliver_gpui_with_choices(notification, pane_id, None);
}
#[cfg(feature = "gpui-shell")]
pub(crate) fn deliver_gpui_with_choices(
notification: &Notification,
pane_id: u64,
confirmation: Option<(u64, Vec<String>)>,
) {
static THROTTLE: OnceLock<Mutex<PaneNotificationThrottle>> = OnceLock::new();
let accepted = THROTTLE
.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.accepts(pane_id, notification.is_attention(), Instant::now());
.accepts_request(
pane_id,
notification.is_attention(),
confirmation.as_ref().map(|c| c.0),
Instant::now(),
);
if !accepted {
log::debug!("notify: toast suppressed for pane={pane_id}: {notification:?}");
return;
}
let (title, body) = notification.toast_text();
log::debug!("notify: system toast source for pane={pane_id}: {notification:?}");
spawn_toast(title, body, application_activation(Some(pane_id)));
let actions = confirmation
.and_then(|(request_id, labels)| {
let sender = GPUI_ACTIVATION.get()?.clone();
Some(
labels
.into_iter()
.enumerate()
.map(|(choice, label)| {
let sender = sender.clone();
crate::platform::notifications::ToastAction {
label: label.chars().take(80).collect(),
activate: Arc::new(move || {
let _ = sender.send(
crate::gpui_shell::GpuiShellEvent::NotificationChoice {
pane_id,
request_id,
choice,
},
);
}),
}
})
.collect(),
)
})
.unwrap_or_default();
spawn_actionable_toast(title, body, application_activation(Some(pane_id)), actions);
}
/// Deliver `notification` for a window that is currently unfocused.
@@ -436,12 +508,16 @@ pub(crate) fn toast(title: &str, body: &str) {
}
fn spawn_toast(title: String, body: String, activation: Option<ToastActivation>) {
if let Err(error) = std::thread::Builder::new()
.name("pebrel-toast".into())
.spawn(move || toast_clickable(&title, &body, activation))
{
log::warn!("notify: failed to spawn toast thread: {error}");
}
spawn_actionable_toast(title, body, activation, Vec::new());
}
fn spawn_actionable_toast(
title: String,
body: String,
activation: Option<ToastActivation>,
actions: Vec<crate::platform::notifications::ToastAction>,
) {
crate::platform::notifications::dispatch(title, body, activation, actions);
}
#[cfg(test)]
@@ -456,6 +532,57 @@ mod delivery_tests {
crate::ai_hook::parse_remote_envelope(envelope.as_bytes(), Some(12)).unwrap()
}
#[test]
fn short_failures_notify_while_success_and_hook_completion_remain_quiet() {
for hook in [false, true] {
let failed = Notification::command_finished(
Some("cargo".into()),
Duration::from_millis(50),
Some(1),
hook,
)
.unwrap();
assert!(failed.is_failure());
assert!(!failed.is_attention());
assert!(
Notification::command_finished(
Some("cargo".into()),
Duration::from_millis(50),
Some(0),
hook
)
.is_none()
);
}
assert!(
Notification::command_finished(
Some("codex".into()),
Duration::from_secs(60),
Some(0),
true
)
.is_none()
);
assert!(
Notification::command_finished(
Some("cargo".into()),
Duration::from_secs(60),
Some(0),
false
)
.is_some()
);
}
#[test]
fn successive_questions_have_independent_native_notification_identity() {
let mut throttle = PaneNotificationThrottle::default();
let now = Instant::now();
assert!(throttle.accepts_request(42, true, Some(100), now));
assert!(!throttle.accepts_request(42, true, Some(100), now));
assert!(throttle.accepts_request(42, true, Some(101), now));
}
#[test]
fn unfinished_background_tasks_suppress_every_terminal_turn_result() {
for reason in ["stop", "error", "length", "aborted"] {
+1
View File
@@ -36,6 +36,7 @@ pub mod startup;
pub(crate) mod update_installation;
#[cfg(feature = "gpui-shell")]
pub(crate) mod window_chrome;
pub(crate) mod wsl_hooks;
pub use capabilities::CAPABILITIES;
+60 -37
View File
@@ -3,6 +3,38 @@
pub(crate) type ToastActivation = std::sync::Arc<dyn Fn() + Send + Sync>;
#[derive(Clone)]
pub(crate) struct ToastAction {
pub label: String,
pub activate: ToastActivation,
}
#[cfg(windows)]
mod windows;
pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option<ToastActivation>) {
toast_actionable(title, body, activation, Vec::new());
}
/// Dispatch owns the platform's worker and COM lifetime; notification policy
/// remains in the application capability.
pub(crate) fn dispatch(
title: String,
body: String,
activation: Option<ToastActivation>,
actions: Vec<ToastAction>,
) {
#[cfg(windows)]
toast_actionable(&title, &body, activation, actions);
#[cfg(not(windows))]
if let Err(error) = std::thread::Builder::new()
.name("pebrel-toast".into())
.spawn(move || toast_actionable(&title, &body, activation, actions))
{
log::warn!("notify: failed to spawn toast thread: {error}");
}
}
#[cfg(target_os = "macos")]
static MACOS_READY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
@@ -40,44 +72,23 @@ pub fn show(title: &str, body: &str) {
});
}
/// [`toast`], optionally wired for click-to-focus: activating the banner (or
/// its Action Center entry) surfaces `window` and, when a pane is named, its
/// tab. Uses the in-process WinRT Activated handler — no COM server, no
/// protocol registration. The one trade-off: clicks after Pebrel exited do
/// nothing, which is exactly right (there is nothing left to focus).
#[cfg(windows)]
pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option<ToastActivation>) {
use tauri_winrt_notification::{IconCrop, Toast};
// Attribute the toast to the Pebrel AUMID so it reads "Pebrel" instead of
// "Windows PowerShell". One registry write, cached per process.
win::ensure_aumid();
let mut toast = Toast::new(win::AUMID)
.title(title)
.text1(body)
.duration(tauri_winrt_notification::Duration::Short);
// Belt and braces: besides the AUMID IconUri (which some Windows builds
// cache stale), embed the logo per-toast as appLogoOverride so the banner
// always carries the Pebrel mark next to the message.
if let Some(icon) = win::icon_path() {
toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME);
}
if let Some(activation) = activation {
toast = toast.on_activated(move |_action| {
activation();
Ok(())
});
}
match toast.show() {
Ok(()) => log::debug!("notify: toast shown"),
Err(err) => log::warn!("notify: toast failed: {err}"),
}
pub(crate) fn toast_actionable(
title: &str,
body: &str,
activation: Option<ToastActivation>,
actions: Vec<ToastAction>,
) {
windows::enqueue(title, body, activation, actions);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option<ToastActivation>) {
pub(crate) fn toast_actionable(
title: &str,
body: &str,
activation: Option<ToastActivation>,
actions: Vec<ToastAction>,
) {
#[cfg(target_os = "macos")]
{
if objc2_foundation::NSBundle::mainBundle().bundleIdentifier().is_none() {
@@ -95,14 +106,21 @@ pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option<ToastA
.resolved();
notification.action("default", language.text(crate::i18n::Message::CommonOpen));
}
for (index, action) in actions.iter().enumerate() {
notification.action(&format!("choice-{index}"), &action.label);
}
match notification.show() {
Ok(handle) => {
if let Some(activation) = activation {
if activation.is_some() || !actions.is_empty() {
let result = handle.wait_for_response(move |response: &notify_rust::NotificationResponse| {
if response.is_default_action()
|| matches!(response, notify_rust::NotificationResponse::Action(action) if action == "default")
{
activation();
if let Some(activation) = &activation { activation(); }
} else if let notify_rust::NotificationResponse::Action(id) = response {
if let Some(index) = id.strip_prefix("choice-").and_then(|id| id.parse::<usize>().ok())
&& let Some(action) = actions.get(index)
{ (action.activate)(); }
}
});
if let Err(error) = result {
@@ -115,7 +133,12 @@ pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option<ToastA
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
pub(crate) fn toast_clickable(title: &str, body: &str, _activation: Option<ToastActivation>) {
pub(crate) fn toast_actionable(
title: &str,
body: &str,
_activation: Option<ToastActivation>,
_actions: Vec<ToastAction>,
) {
crate::platform::notifications::show(title, body);
}
@@ -0,0 +1,143 @@
//! Own WinRT notification objects until their bounded retention expires.
//! A successful Show RPC alone does not keep Activated callbacks alive.
use std::collections::VecDeque;
use std::sync::{OnceLock, mpsc};
use ::windows::Data::Xml::Dom::XmlDocument;
use ::windows::Foundation::TypedEventHandler;
use ::windows::UI::Notifications::{
ToastActivatedEventArgs, ToastNotification, ToastNotificationManager,
};
use ::windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx, CoUninitialize};
use ::windows::core::{IInspectable, Interface};
use super::{ToastAction, ToastActivation, win};
struct Request {
title: String,
body: String,
open: Option<ToastActivation>,
actions: Vec<ToastAction>,
}
pub(super) fn enqueue(
title: &str,
body: &str,
open: Option<ToastActivation>,
actions: Vec<ToastAction>,
) {
static WORKER: OnceLock<Option<mpsc::SyncSender<Request>>> = OnceLock::new();
let worker = WORKER.get_or_init(|| {
let (sender, receiver) = mpsc::sync_channel(32);
std::thread::Builder::new()
.name("pebrel-native-notifications".into())
.spawn(move || run(receiver))
.map(|_| sender)
.map_err(|error| log::warn!("notify: could not start native worker: {error}"))
.ok()
});
let Some(worker) = worker else { return };
if let Err(error) =
worker.try_send(Request { title: title.to_owned(), body: body.to_owned(), open, actions })
{
log::warn!("notify: native queue unavailable: {error}");
}
}
fn run(receiver: mpsc::Receiver<Request>) {
// All WinRT objects live on this MTA thread; callbacks only enqueue app events.
if let Err(error) = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.ok() {
log::warn!("notify: COM initialization failed: {error}");
return;
}
let mut live = VecDeque::new();
for request in receiver {
match show(request) {
Ok(notification) => {
if live.len() == 64 {
live.pop_front();
}
live.push_back(notification);
},
Err(error) => log::warn!("notify: native delivery failed: {error}"),
}
}
drop(live);
unsafe { CoUninitialize() };
}
fn show(request: Request) -> ::windows::core::Result<ToastNotification> {
win::ensure_aumid();
let document = XmlDocument::new()?;
document.LoadXml(&xml(&request).into())?;
let notification = ToastNotification::CreateToastNotification(&document)?;
notification.Activated(&TypedEventHandler::<ToastNotification, IInspectable>::new(
move |_, args| {
let argument = args
.as_ref()
.and_then(|args| args.cast::<ToastActivatedEventArgs>().ok())
.and_then(|args| args.Arguments().ok())
.map(|value| value.to_string())
.unwrap_or_default();
if argument.is_empty() {
if let Some(open) = &request.open {
open();
}
} else if let Some(index) =
argument.strip_prefix("choice-").and_then(|id| id.parse::<usize>().ok())
&& let Some(action) = request.actions.get(index)
{
(action.activate)();
}
Ok(())
},
))?;
ToastNotificationManager::CreateToastNotifierWithId(&win::AUMID.into())?.Show(&notification)?;
Ok(notification)
}
fn xml(request: &Request) -> String {
let mut xml = format!(
"<toast><visual><binding template=\"ToastGeneric\"><text>{}</text><text>{}</text>",
escape(&request.title),
escape(&request.body),
);
if let Some(path) = win::icon_path() {
xml.push_str(&format!(
"<image placement=\"appLogoOverride\" src=\"{}\"/>",
escape(&path.to_string_lossy())
));
}
xml.push_str("</binding></visual>");
if !request.actions.is_empty() {
xml.push_str("<actions>");
for (index, action) in request.actions.iter().take(5).enumerate() {
xml.push_str(&format!("<action content=\"{}\" arguments=\"choice-{index}\" activationType=\"foreground\"/>", escape(&action.label)));
}
xml.push_str("</actions>");
}
xml.push_str("</toast>");
xml
}
fn escape(text: &str) -> String {
text.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t'))
.collect::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_controls_and_markup_cannot_break_the_toast_document() {
assert_eq!(escape("<tool>\u{1b}\u{0}\"&'"), "&lt;tool&gt;&quot;&amp;&apos;");
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Guest hook setup is a Windows transport capability. Provider policy stays in ai_hook.
#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub(crate) use windows::{prepare, setup_cli};
#[cfg(not(windows))]
pub(crate) fn prepare(_options: &mut nebula_terminal::tty::Options) {}
@@ -0,0 +1,236 @@
//! WSL transport for the shared POSIX hook installer. No provider policy here.
//!
//! A guest has its own home and executable search path. Windows hook files and
//! Windows PIDs cannot stand in for either. Delivery uses the pane's existing
//! authenticated OSC protocol, with a fresh token for every PTY.
use std::collections::HashMap;
use std::io::{Read as _, Seek as _, Write as _};
use std::process::{Command, Stdio};
use std::sync::{OnceLock, mpsc};
use std::time::{Duration, Instant};
use nebula_terminal::tty;
use serde_json::json;
use crate::ai_hook::remote::{self, Action, Snapshot};
const BUDGET: Duration = Duration::from_secs(8);
const MAX_OUTPUT: u64 = 20 * 1024 * 1024;
const RETRY_AFTER: Duration = Duration::from_secs(300);
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct Target {
distro: Option<String>,
user: Option<String>,
}
impl Target {
fn from_shell(shell: &tty::Shell) -> Option<Self> {
(crate::display::extract_program(shell.program()).as_deref() == Some("wsl")).then(|| Self {
distro: crate::shell_detect::wsl_launch_distro(shell.program(), shell.args())
.map(str::to_owned),
user: crate::shell_detect::wsl_launch_user(shell.program(), shell.args())
.map(str::to_owned),
})
}
fn command(&self) -> Command {
let mut command = Command::new("wsl.exe");
if let Some(distro) = &self.distro {
command.args(["--distribution", distro]);
}
if let Some(user) = &self.user {
command.args(["--user", user]);
}
// This is a separate setup process. The user's interactive shell and
// its launch arguments are left to WSL's normal /etc/passwd selection.
command.args(["--exec", "sh", "-lc", "exec python3 -"]);
crate::platform::process::hidden_command(&mut command);
command
}
fn exchange(&self, request: &[u8], deadline: Instant) -> Result<String, String> {
let run = || -> std::io::Result<String> {
let mut input = tempfile::tempfile()?;
input.write_all(request)?;
input.rewind()?;
let mut output = tempfile::tempfile()?;
let mut child = self
.command()
.stdin(input)
.stdout(output.try_clone()?)
.stderr(Stdio::null())
.spawn()?;
let status = loop {
let poll = child.try_wait().and_then(|status| {
if status.is_none()
&& (Instant::now() >= deadline || output.metadata()?.len() > MAX_OUTPUT)
{
return Err(std::io::Error::other("WSL hook setup exceeded its budget"));
}
Ok(status)
});
match poll {
Ok(Some(status)) => break status,
Ok(None) => {
std::thread::sleep(Duration::from_millis(10));
},
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(error);
},
}
};
if !status.success() || output.metadata()?.len() > MAX_OUTPUT {
return Err(std::io::Error::other("WSL hook setup failed"));
}
output.rewind()?;
let mut text = String::new();
output.take(MAX_OUTPUT).read_to_string(&mut text)?;
Ok(text)
};
run().map_err(|error| error.to_string())
}
fn install(&self, action: Action) -> Result<(), String> {
let deadline = Instant::now() + BUDGET;
let raw = self.exchange(&remote::request(&json!({"action":"snapshot"})), deadline)?;
let snapshot: Snapshot = serde_json::from_value(remote::response(&raw)?)
.map_err(|_| "invalid WSL integration snapshot")?;
let Some(files) = snapshot.for_wsl().plan(action)? else { return Ok(()) };
if files.is_empty() {
return Ok(());
}
let raw =
self.exchange(&remote::request(&json!({"action":"apply", "files":files})), deadline)?;
if remote::response(&raw)?.get("applied").and_then(serde_json::Value::as_bool) != Some(true)
{
return Err("WSL integration did not confirm installation".into());
}
log::info!("ai_hook: WSL hook files updated; Codex may require review in /hooks");
Ok(())
}
}
/// Called during local PTY preparation. Guest I/O belongs to one setup worker;
/// the caller checks the local preference and prepares the pane environment.
pub(crate) fn prepare(options: &mut tty::Options) {
let Some(target) = options.shell.as_ref().and_then(Target::from_shell) else { return };
if nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) {
return;
}
let token = match remote::new_token() {
Ok(token) => token,
Err(error) => {
log::warn!("ai_hook: WSL channel unavailable: {error}");
return;
},
};
options.env.insert(remote::TOKEN_ENV.into(), token);
let wslenv = options.env.entry("WSLENV".into()).or_default();
if !wslenv.split(':').any(|entry| entry.split('/').next() == Some(remote::TOKEN_ENV)) {
if !wslenv.is_empty() {
wslenv.push(':');
}
wslenv.push_str(remote::TOKEN_ENV);
}
match worker().and_then(|tx| tx.try_send(target).ok()) {
Some(()) => {},
None => log::warn!("ai_hook: WSL setup queue unavailable; retry with setup-ai --wsl"),
}
}
fn worker() -> Option<&'static mpsc::SyncSender<Target>> {
static WORKER: OnceLock<Option<mpsc::SyncSender<Target>>> = OnceLock::new();
WORKER
.get_or_init(|| {
let (tx, rx) = mpsc::sync_channel::<Target>(16);
std::thread::Builder::new()
.name("pebrel-wsl-hooks".into())
.spawn(move || {
let mut attempted: HashMap<Target, Instant> = HashMap::new();
while let Ok(target) = rx.recv() {
if nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false) {
continue;
}
attempted.retain(|_, at| at.elapsed() < RETRY_AFTER);
if attempted.contains_key(&target) {
continue;
}
// Cache has a fixed bound even if custom launchers invent users.
if attempted.len() >= 32 {
attempted.clear();
}
attempted.insert(target.clone(), Instant::now());
if let Err(error) = target.install(Action::Automatic) {
log::warn!("ai_hook: WSL integration unavailable: {error}");
}
}
})
.ok()
.map(|_| tx)
})
.as_ref()
}
pub(crate) fn setup_cli(distro: &str, user: Option<&str>, remove: bool) -> i32 {
let target = Target { distro: Some(distro.into()), user: user.map(str::to_owned) };
match target.install(if remove { Action::Remove } else { Action::Install }) {
Ok(()) => {
println!(
"{}",
if remove {
"WSL hook integration removed."
} else {
"WSL hook integration installed. Open a new Pebrel WSL terminal. Codex may require review in /hooks."
}
);
0
},
Err(error) => {
eprintln!("WSL hook integration: {error}");
1
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_targets_the_same_distribution_and_user_without_changing_the_shell() {
let shell = tty::Shell::new(
"wsl.exe".into(),
vec![
"-d".into(),
"Debian custom".into(),
"-u".into(),
"alice".into(),
"--cd".into(),
"/work/my project".into(),
],
);
let target = Target::from_shell(&shell).unwrap();
let command = target.command();
let args: Vec<_> =
command.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect();
assert_eq!(
args,
[
"--distribution",
"Debian custom",
"--user",
"alice",
"--exec",
"sh",
"-lc",
"exec python3 -"
]
);
assert_eq!(shell.args()[4..], ["--cd", "/work/my project"]);
assert!(Target::from_shell(&tty::Shell::new("pwsh.exe".into(), vec![])).is_none());
}
}
+1 -9
View File
@@ -1745,15 +1745,7 @@ fn clear_secret(secret: &mut Option<Vec<u8>>) {
}
fn remote_hook_token() -> io::Result<String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes)
.map_err(|err| io::Error::other(format!("生成 SSH Hook 令牌失败: {err}")))?;
let mut token = String::with_capacity(32);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(token, "{byte:02x}");
}
Ok(token)
crate::ai_hook::remote::new_token()
}
fn render_error<H: SshEventHost>(
+48
View File
@@ -298,6 +298,7 @@ pub struct EventLoop<T: tty::EventedPty, U: EventListener> {
event_proxy: U,
drain_on_exit: bool,
ref_test: bool,
remote_hook_token: Option<String>,
}
impl<T, U> EventLoop<T, U>
@@ -324,6 +325,7 @@ where
event_proxy,
drain_on_exit,
ref_test,
remote_hook_token: None,
})
}
@@ -331,6 +333,11 @@ where
EventLoopSender { sender: self.tx.clone(), poller: self.poll.clone() }
}
/// Bind in-band hook delivery to this PTY before its reader starts.
pub fn set_remote_hook_token(&mut self, token: String) {
self.remote_hook_token = Some(token);
}
/// Drain the channel.
///
/// Returns `Err(())` when a shutdown message was received; otherwise the
@@ -511,6 +518,9 @@ where
pub fn spawn(mut self) -> JoinHandle<(Self, State)> {
thread::spawn_named("PTY reader", move || {
let mut state = State::default();
if let Some(token) = self.remote_hook_token.take() {
state.stream.set_remote_hook_token(token);
}
let mut buf = [0u8; READ_BUFFER_SIZE];
let poll_opts = PollMode::Level;
@@ -918,6 +928,44 @@ mod tests {
use crate::term::Config;
use crate::term::test::TermSize;
#[test]
fn authenticated_hook_frames_survive_every_chunk_boundary_and_reject_other_panes() {
#[derive(Clone, Default)]
struct Listener(std::sync::Arc<std::sync::Mutex<Vec<Vec<u8>>>>);
impl EventListener for Listener {
fn send_event(&self, event: Event) {
if let Event::AiHookEnvelope(envelope) = event {
self.0.lock().unwrap().push(envelope);
}
}
}
// Base64 for a small envelope; authentication is checked before delivery.
let frame = b"\x1b]777;nebula-hook;0123456789abcdef0123456789abcdef;bmVidWxhLWhvb2svMSBzb3VyY2U9Y29kZXgKe30=\x07";
for token in [
None,
Some("ffffffffffffffffffffffffffffffff"),
Some("0123456789abcdef0123456789abcdef"),
] {
for split in 0..=frame.len() {
let listener = Listener::default();
let mut terminal =
Term::new(Config::default(), &TermSize::new(80, 24), listener.clone());
let mut stream = StreamProcessor::default();
if let Some(token) = token {
stream.set_remote_hook_token(token.into());
}
stream.feed(&mut terminal, &listener, &frame[..split]);
stream.feed(&mut terminal, &listener, &frame[split..]);
let events = listener.0.lock().unwrap();
if token == Some("0123456789abcdef0123456789abcdef") {
assert_eq!(events.as_slice(), [b"nebula-hook/1 source=codex\n{}".to_vec()]);
} else {
assert!(events.is_empty());
}
}
}
}
#[test]
fn shell_identity_cwd_and_title_keep_wire_order_across_chunk_boundaries() {
#[derive(Clone, Default)]
+21
View File
@@ -40,6 +40,27 @@ class RemoteHooksTests(unittest.TestCase):
self.environment.start()
self.addCleanup(self.environment.stop)
def test_discovery_finds_nvm_cli_and_its_interpreter_without_login_path(self):
files = module("remote_files")
bindir = self.root / ".nvm/versions/node/v24.18.0/bin"
bindir.mkdir(parents=True)
node = bindir / "node"
node.write_text("#!/bin/sh\nprintf 'hooks stable true\\n'\n")
node.chmod(0o700)
cli = bindir / "codex"
cli.write_text("#!/usr/bin/env node\n")
cli.chmod(0o700)
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}):
self.assertEqual(files.find_program("codex"), str(cli))
self.assertEqual(files.capture(str(cli), "features", "list"), "hooks stable true\n")
configured = self.root / "bin"
configured.mkdir()
preferred = configured / "codex"
preferred.write_text("#!/bin/sh\n")
preferred.chmod(0o700)
with patch.dict(os.environ, {"PATH": str(configured)}):
self.assertEqual(files.find_program("codex"), str(preferred))
def test_compare_and_swap_does_not_overwrite_concurrent_user_edit(self):
files = module("remote_files")
snapshot = files.snapshot()