Merge pull request #227 from Kuddev/fix/notification-hooks-20260920
fix(agents): connect WSL hooks and repair notification actions
This commit is contained in:
42 files changed
+1751
-170
No files matched your search
+76
@@ -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.
|
||||
@@ -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 /
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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} 秒"
|
||||
}
|
||||
@@ -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} 秒"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 早已答完,屏幕上就是空闲输入框。这块屏幕当初根本没人去匹:
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¬ification, 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(¬ification, 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: ¬ify_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(¬ification)?;
|
||||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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}\"&'"), "<tool>"&'");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user