diff --git a/architecture/notes/nebula_app/ai_hook/2026-09-20-pi-settled-version-compatibility.md b/architecture/notes/nebula_app/ai_hook/2026-09-20-pi-settled-version-compatibility.md new file mode 100644 index 0000000..9828280 --- /dev/null +++ b/architecture/notes/nebula_app/ai_hook/2026-09-20-pi-settled-version-compatibility.md @@ -0,0 +1,75 @@ +# Pi settled-turn compatibility + +## Status + +Implemented while integrating PR #207. + +## Context + +Pi 0.80.4 added `agent_settled`, which follows retry completion. `agent_end` +alone may describe a failed attempt that Pi will retry. Earlier Pi releases +only expose the older completion event and package name. + +PR #207 imported VERSION from the renamed SDK at module scope. Pi 0.60.0's +loader only aliases the earlier package name, so the extension failed to load +before its fallback could run. Mocking the import in event tests hid the failure. + +## Evidence + +The published older loader awaits extension factories and supplies SDK aliases +for Node installations or virtual modules for bundled installations. Loading +the original PR bridge through its alias rules produced MODULE_NOT_FOUND. +Guarded dynamic imports of both SDK names register successfully with both +alias and virtual-module modes using the actual Jiti loader. + +## Decision + +The asynchronous extension factory probes the available SDK through the host +loader. Missing/unparseable versions keep the existing agent_end fallback; +supported versions wait for agent_settled. Rejected event registration also +falls back. No unguarded runtime dependency on either package name is added. + +Each attempt updates the result, but only the final settled edge sends it. +Session switches/shutdown discard unfinished results. The bridge sends typed +stop reasons without copying raw provider error text into helper arguments. + +The shared notification model remains authoritative: failure and incomplete +results use issue notifications, cancellation is silent, and unknown results +remain unconfirmed. Legacy Pi events without metadata preserve their activity +edge but still produce a neutral result notification. Failures from the new +bridge cannot create a successful-completion activity badge. Existing pane +replacement, failure cooldown and user-selected duration remain in force. + +## Rejected alternatives + +- A static renamed-package import breaks old loaders before registration. +- Registering an unknown event cannot detect support: older event buses may + accept the name without ever emitting it. +- A time-based debounce cannot prove that provider retries have settled. +- Replacing typed issues with localized strings would lose failure classification + and prevent the shared cooldown/replacement policy from recognizing them. + +## Consequences + +Version probing occurs once at extension registration and adds no production +dependency or timer. Older/unknown hosts retain the limitations of agent_end; +they cannot acquire the newer provider event through a terminal-side heuristic. +Error details remain visible in the provider's terminal output. + +## Validation + +Bridge tests cover retries, cancellation, unknown outcomes, duplicate edges, +session switching and shutdown. A loader test erases TypeScript only and checks +real module resolution with old/new/missing/invalid SDK metadata. Local Jiti +probes cover Node aliases and bundled virtual modules for Pi 0.60.0 and 0.85.1. +Rust regressions cover typed notification and activity behavior, including +background work and legacy payloads. + +## Supersedes + +None. + +## Revisit when + +Pi exposes a version-independent capability API, or the supported minimum +release guarantees agent_settled and no longer requires the older package alias. diff --git a/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md b/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md new file mode 100644 index 0000000..6a837aa --- /dev/null +++ b/architecture/notes/nebula_terminal/term/2026-09-20-native-prompt-input-boundaries.md @@ -0,0 +1,78 @@ +# Native prompt input boundaries + +## Status + +Implemented while integrating PR #215. + +## Context + +Native CMD does not provide a pre-execution callback like the managed +PowerShell integration. PR #215 uses a prompt marker and input epochs to keep +command activity independent of Clink, including queued PTY events and nested +shells. Its text-based submission fallback still required a conventional +`C:\path>` prompt, even though the injected PROMPT preserved arbitrary text. +Custom, multiline and empty prompts therefore failed to start builtin-command +activity. History recall also cannot be recovered from a keystroke mirror. + +## Evidence + +- The shared input snapshot rejected `[C:\work] pause`, `>pause` and `pause` + when those were the actual rendered custom prompt and input. +- Attaching activity to receipt of a queued UI event can misattribute a prompt + from before Enter to the command currently running. +- PTY parsing already handles semantic OSC 133 markers between parser slices, + where the cursor position is exact; that is the appropriate coordinate owner. + +## Decision + +CMD's child-only PROMPT receives OSC 133 A/B around the user's prompt, followed +by its native prompt marker. Repeated preparation is idempotent, including an +inherited prefix-only marker from the initial PR implementation. + +The terminal records the B input boundary in absolute grid coordinates. The +shared input adapter extracts echoed text from that boundary, including wrapped +lines, without depending on a prompt suffix or the keystroke mirror. Prompt +navigation and input-boundary methods share the `term/prompt.rs` responsibility. +Reset, reflow, command events and input that can submit/invalidate the prompt +discard the boundary. Alternate-screen content cannot expose a primary prompt. + +The application's native input epoch remains the activity authority. A B marker +does not finish a command or emit a command result; completion still requires +the current native marker plus successful process evidence. Nested interactive +shells retain their outer runtime run. Paste captures activity before echo and +does not fabricate command history. + +## Rejected alternatives + +- Assuming every `>` or user-entered line is a shell prompt can misclassify REPLs. +- Reconstructing commands from keys fails after history recall and editing. +- Treating a prompt as unconditional command completion loses nested runs and + allows stale queued output to finish a later submission. +- Keeping coordinates across reflow without a mapping can read unrelated text. + +## Consequences + +No dependency, background service or persistent format is added. One optional +coordinate accompanies existing terminal prompt state. The input adapter keeps +its bounded wrapped-line scan. User changes that remove all injected markers +fall back to existing conservative text detection; no arbitrary prompt grammar +is guessed. Resized prompts need fresh boundary evidence for exact extraction. + +## Validation + +Regressions exercise split OSC input, scrolling, reset and reflow, plus rendered +custom/multiline/empty/CJK/wrapped prompts, history recall, empty Enter, paste, +runtime submission and stale prompt epochs. PR #215's existing nested-shell, +process-failure and editing-key tests remain in the native suite. A filled last +cell is already part of echoed input even while the cursor is waiting to wrap. +The full-width regression failed with a 75-column prompt plus `pause` before +this correction; it also covers an empty full-width prompt at the bottom row. + +## Supersedes + +None. + +## Revisit when + +Grid reflow exposes stable anchor remapping, or a native CMD pre-execution hook +can supply authoritative input and exit codes without overriding user behavior. diff --git a/nebula_app/res/hooks/pi.ts b/nebula_app/res/hooks/pi.ts index 9bc1ef9..3ed3edb 100644 --- a/nebula_app/res/hooks/pi.ts +++ b/nebula_app/res/hooks/pi.ts @@ -4,6 +4,24 @@ import { openSync, readSync, closeSync } from "node:fs"; import { randomUUID } from "node:crypto"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +async function supportsSettled(): Promise { + // Pi's loader supplies either package alias (or a bundled virtual module). + // A static runtime import of the renamed package prevents old Pi from loading + // this extension at all, before any agent_end fallback can be registered. + for (const name of ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"]) { + try { + const sdk = await import(name); + const version = sdk.VERSION ?? sdk.default?.VERSION; + const match = typeof version === "string" && /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version); + if (match) { + const [, major, minor, patch] = match.map(Number); + return major > 0 || minor > 80 || (minor === 80 && patch >= 4); + } + } catch (_) {} + } + return false; +} + // Only provider IDs and the first JSONL metadata line are recovery identities. // A timestamped basename or a process ID cannot be handed to pi --session. function sessionFor(ctx: any): { session_id?: string; session_file?: string } { @@ -30,12 +48,15 @@ function sessionFor(ctx: any): { session_id?: string; session_file?: string } { return typeof direct === "string" && direct ? { session_id: direct } : {}; } -export default function (pi: ExtensionAPI) { +export default async function (pi: ExtensionAPI) { let active = false; + let stopReason = "unknown"; + // agent_end ends one attempt; agent_settled (Pi 0.80.4+) includes retries. + let hasSettled = await supportsSettled(); let sequence = 0; const sequenceEpoch = BigInt(Date.now()) * 1000000n; const bridge_instance = randomUUID(); - const send = (kind: "session-start" | "prompt" | "tool-complete" | "done" | "session-end", ctx?: any, result: { stop_reason?: string; message?: string } = {}) => { + const send = (kind: "session-start" | "prompt" | "tool-complete" | "done" | "session-end", ctx?: any, result: { stop_reason?: string } = {}) => { const hook = process.env.PEBREL_HOOK_EXE ?? process.env.NEBULA_HOOK_EXE; if (!hook) return; try { @@ -58,22 +79,35 @@ export default function (pi: ExtensionAPI) { } catch (_) {} }; + const finish = (ctx: any) => { + if (!active) return; + active = false; + send("done", ctx, { stop_reason: stopReason }); + }; pi.on("agent_start", async (_event, ctx) => { active = true; + stopReason = "unknown"; send("prompt", ctx); }); pi.on("agent_end", async (event, ctx) => { if (!active) return; - active = false; - // agent_end also fires after API errors and cancellation. The last - // assistant result describes this turn, including recovery after a retry. const last = [...(event.messages ?? [])].reverse().find(message => message.role === "assistant"); - send("done", ctx, last?.role === "assistant" ? { - stop_reason: last.stopReason, - message: last.stopReason === "error" ? last.errorMessage?.slice(0, 4000) : undefined, - } : {}); + // Never forward provider errors: they may contain requests or credentials. + stopReason = last?.role === "assistant" ? last.stopReason ?? "unknown" : "unknown"; + if (!hasSettled) finish(ctx); }); - try { pi.on("session_start", async (_event, ctx) => send("session-start", ctx)); } catch (_) {} + if (hasSettled) { + try { pi.on("agent_settled", async (_event, ctx) => finish(ctx)); } + catch (_) { hasSettled = false; } + } + try { pi.on("session_start", async (_event, ctx) => { + active = false; + stopReason = "unknown"; + send("session-start", ctx); + }); } catch (_) {} try { pi.on("tool_result", async (_event, ctx) => send("tool-complete", ctx)); } catch (_) {} - try { pi.on("session_shutdown", async (_event, ctx) => send("session-end", ctx)); } catch (_) {} + try { pi.on("session_shutdown", async (_event, ctx) => { + active = false; + send("session-end", ctx); + }); } catch (_) {} } diff --git a/nebula_app/src/ai_hook/event.rs b/nebula_app/src/ai_hook/event.rs index cc959fb..91f1d2d 100644 --- a/nebula_app/src/ai_hook/event.rs +++ b/nebula_app/src/ai_hook/event.rs @@ -23,6 +23,8 @@ pub enum AiHookKind { /// provider result metadata may classify it; never scan assistant prose. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AiTurnOutcome { + /// Older Pi bridges omit result metadata; preserve their activity edge. + Unspecified, Succeeded, Failed, Cancelled, diff --git a/nebula_app/src/ai_hook/lifecycle.rs b/nebula_app/src/ai_hook/lifecycle.rs index dc0afd0..60f730d 100644 --- a/nebula_app/src/ai_hook/lifecycle.rs +++ b/nebula_app/src/ai_hook/lifecycle.rs @@ -280,7 +280,16 @@ impl AgentActivity { }, AiHookKind::TurnDone => { self.screen_armed = false; - AgentStatus::Done + if event.source == "pi" + && !matches!( + event.turn_outcome, + super::AiTurnOutcome::Succeeded | super::AiTurnOutcome::Unspecified + ) + { + AgentStatus::Idle + } else { + AgentStatus::Done + } }, AiHookKind::NeedsAttention => AgentStatus::Blocked, AiHookKind::SessionEnd => unreachable!("handled above"), diff --git a/nebula_app/src/ai_hook/native_tests.rs b/nebula_app/src/ai_hook/native_tests.rs index 5694ace..dd3d177 100644 --- a/nebula_app/src/ai_hook/native_tests.rs +++ b/nebula_app/src/ai_hook/native_tests.rs @@ -24,8 +24,37 @@ fn non_successful_turns_do_not_emit_a_completion_notification() { Some(1), ) .unwrap(); - assert!(crate::notify::Notification::from_ai_hook(&event, None, false).is_some()); + assert!(crate::notify::Notification::from_ai_hook(&event, None, false,).is_some()); } +#[test] +fn pi_result_metadata_distinguishes_legacy_unknown_and_background_work() { + use crate::ai_agents::AgentStatus; + use crate::notify::Notification; + for (metadata, outcome, status, notifies) in [ + ("", AiTurnOutcome::Unspecified, AgentStatus::Done, true), + (",\"stop_reason\":\"stop\"", AiTurnOutcome::Succeeded, AgentStatus::Done, true), + (",\"stop_reason\":\"error\"", AiTurnOutcome::Failed, AgentStatus::Idle, true), + (",\"stop_reason\":\"aborted\"", AiTurnOutcome::Cancelled, AgentStatus::Idle, false), + (",\"stop_reason\":\"length\"", AiTurnOutcome::Incomplete, AgentStatus::Idle, true), + (",\"stop_reason\":\"toolUse\"", AiTurnOutcome::Incomplete, AgentStatus::Idle, true), + (",\"stop_reason\":\"future\"", AiTurnOutcome::Unknown, AgentStatus::Idle, true), + (",\"stop_reason\":null", AiTurnOutcome::Unknown, AgentStatus::Idle, true), + (",\"stop_reason\":42", AiTurnOutcome::Unknown, AgentStatus::Idle, true), + ] { + let wire = format!("nebula-hook/1 source=pi\n{{\"kind\":\"done\"{metadata}}}"); + let mut event = parse_remote_envelope(wire.as_bytes(), Some(1)).unwrap(); + assert_eq!(event.turn_outcome, outcome); + let mut activity = lifecycle::AgentActivity::default(); + assert!(activity.apply_hook(&event)); + assert_eq!(activity.status(), status); + assert_eq!(Notification::from_ai_hook(&event, None, false).is_some(), notifies); + event.background_tasks = Some(AiBackgroundTasks { active: 1, total: 1 }); + assert!(activity.apply_hook(&event)); + assert_eq!(activity.status(), AgentStatus::Working); + assert!(Notification::from_ai_hook(&event, None, false).is_none()); + } +} + use crate::ai_agents::{AgentStatus, AgentStatusSource}; use lifecycle::AgentActivity; use serde_json::{Value, json}; diff --git a/nebula_app/src/ai_hook/protocol.rs b/nebula_app/src/ai_hook/protocol.rs index 6b9e062..857242b 100644 --- a/nebula_app/src/ai_hook/protocol.rs +++ b/nebula_app/src/ai_hook/protocol.rs @@ -178,6 +178,7 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { Some("error") => AiTurnOutcome::Failed, Some("aborted") => AiTurnOutcome::Cancelled, Some("length" | "toolUse") => AiTurnOutcome::Incomplete, + None if !payload.as_object()?.contains_key("stop_reason") => AiTurnOutcome::Unspecified, _ => AiTurnOutcome::Unknown, } } else { diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs index 6d54e5c..52de1d1 100644 --- a/nebula_app/src/display/input_state.rs +++ b/nebula_app/src/display/input_state.rs @@ -131,7 +131,15 @@ pub(crate) fn nebula_prompt_line_from_raw_grid( typed_tail: &str, env: &SuggestEnv, ) -> Option { - let text = raw_grid_logical_line(terminal, cursor)?; + let input_start = terminal.nebula_prompt_input_point(); + let (text, boundary) = raw_grid_line_with_boundary(terminal, cursor, input_start)?; + if let Some(boundary) = boundary { + let (prompt, input) = text.split_at(boundary); + return Some(PromptLineSnapshot { prompt: prompt.to_owned(), input: input.to_owned() }); + } + if input_start.is_some() { + return None; + } prompt_line_snapshot(&text, typed_tail, env, terminal.nebula_prompt_active()) } @@ -141,12 +149,14 @@ pub(crate) fn nebula_shell_ready_from_raw_grid( terminal: &Term, env: &SuggestEnv, ) -> bool { + if let Some(line) = + nebula_prompt_line_from_raw_grid(terminal, terminal.grid().cursor.point, "", env) + { + return line.input.trim().is_empty(); + } let Some(text) = raw_grid_logical_line(terminal, terminal.grid().cursor.point) else { return false; }; - if let Some(line) = prompt_line_snapshot(&text, "", env, terminal.nebula_prompt_active()) { - return line.input.trim().is_empty(); - } let prompt = text.trim_end(); let Some(marker) = prompt.chars().next_back() else { return false }; safe_shell_prompt_marker(prompt, marker, env) @@ -158,12 +168,23 @@ pub(crate) fn nebula_shell_prompt_restored_from_raw_grid( expected_prompt: &str, env: &SuggestEnv, ) -> bool { + if terminal.mode().intersects(nebula_terminal::term::TermMode::ALT_SCREEN) { + return false; + } let cursor = terminal.grid().cursor.point; raw_grid_logical_line(terminal, cursor) .is_some_and(|line| shell_prompt_restored(expected_prompt, &line, env)) } fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> Option { + raw_grid_line_with_boundary(terminal, cursor, None).map(|(text, _)| text) +} + +fn raw_grid_line_with_boundary( + terminal: &Term, + cursor: Point, + input_start: Option, +) -> Option<(String, Option)> { let grid = terminal.grid(); if !raw_grid_line_is_readable(cursor.line, grid.topmost_line(), grid.bottommost_line()) { return None; @@ -172,7 +193,13 @@ fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> if columns == 0 { return None; } - let cursor_col = cursor.column.0.min(columns); + // A filled last cell leaves the cursor on that cell until another printable + // character wraps. Its contents already belong to the echoed input. + let cursor_col = if cursor == grid.cursor.point && grid.cursor.input_needs_wrap { + columns + } else { + cursor.column.0.min(columns) + }; if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { return None; @@ -198,9 +225,13 @@ fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> } let mut text = String::with_capacity(columns); + let mut boundary = None; for row in first_row..=cursor.line.0 { let row_end = if row == cursor.line.0 { cursor_col } else { columns }; for col in 0..row_end { + if input_start == Some(Point::new(Line(row), Column(col))) { + boundary = Some(text.len()); + } let cell: &Cell = &grid[Line(row)][Column(col)]; if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; @@ -209,7 +240,10 @@ fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> } } - Some(text) + if input_start == Some(Point::new(cursor.line, Column(cursor_col))) { + boundary = Some(text.len()); + } + Some((text, boundary)) } fn prompt_line_snapshot( @@ -226,6 +260,13 @@ fn prompt_line_snapshot( input: input.strip_prefix(' ').unwrap_or(input).to_owned(), }); } + // CMD history recall and completion need not update the keystroke mirror. + if matches!(env, SuggestEnv::Local) + && let Some((head, input)) = text.split_once('>') + && cmd_path_prompt(head) + { + return Some(PromptLineSnapshot { prompt: format!("{head}>"), input: input.to_owned() }); + } if typed_tail.is_empty() || !text.ends_with(typed_tail) { return None; } @@ -249,12 +290,28 @@ fn shell_prompt_restored(expected_prompt: &str, current_line: &str, env: &Sugges return true; } + if matches!(env, SuggestEnv::Local) + && expected.strip_suffix('>').is_some_and(cmd_path_prompt) + && current.strip_suffix('>').is_some_and(cmd_path_prompt) + { + return true; + } + !env.is_this_machine() && remote_prompt_anchor(expected, marker) .zip(remote_prompt_anchor(current, marker)) .is_some_and(|(expected, current)| expected == current) } +fn cmd_path_prompt(head: &str) -> bool { + let bytes = head.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\' + && !head.contains(['<', '>', '|', '\r', '\n']) +} + fn safe_shell_prompt_marker(prompt: &str, marker: char, env: &SuggestEnv) -> bool { if matches!(marker, '$' | '#' | '%') { return true; @@ -362,6 +419,41 @@ mod tests { ); } + #[test] + fn semantic_prompt_input_handles_pending_wrap_and_bottom_row_scroll() { + use nebula_terminal::event::VoidListener; + use nebula_terminal::event_loop::StreamProcessor; + use nebula_terminal::grid::Grid; + + for start_row in [1, 2] { + for prompt_len in [0, 75, 79, 80, 81, 160] { + for input in ["", "pause"] { + let size = Grid::::new(2, 80, 0); + let mut terminal = Term::new(Default::default(), &size, VoidListener); + let prompt = "x".repeat(prompt_len); + let bytes = + format!("\x1b[{start_row};1H\x1b]133;A\x07{prompt}\x1b]133;B\x07{input}"); + StreamProcessor::default().feed(&mut terminal, &VoidListener, bytes.as_bytes()); + let snapshot = nebula_prompt_line_from_raw_grid( + &terminal, + terminal.grid().cursor.point, + "", + &SuggestEnv::Local, + ) + .unwrap_or_else(|| { + panic!("row {start_row}, prompt {prompt_len}, input {input:?}") + }); + assert_eq!(snapshot.prompt, prompt); + assert_eq!(snapshot.input, input); + assert_eq!( + nebula_shell_ready_from_raw_grid(&terminal, &SuggestEnv::Local), + input.is_empty() + ); + } + } + } + } + #[test] fn cmd_prompt_reconciles_the_typed_tail() { let env = SuggestEnv::Local; diff --git a/nebula_app/src/display/terminal_color.rs b/nebula_app/src/display/terminal_color.rs index b715e8e..2b0fd43 100644 --- a/nebula_app/src/display/terminal_color.rs +++ b/nebula_app/src/display/terminal_color.rs @@ -142,11 +142,7 @@ pub(crate) fn is_fixed_color(color: Color, overrides: &Colors) -> bool { match color { Color::Spec(_) => true, Color::Named(name) => overrides[name].is_some(), - Color::Indexed(index) => { - overrides[index as usize].is_some() - // 16..=23 是 Nebula 随主题重建的 powerline 动态槽位。 - || index >= 24 - }, + Color::Indexed(index) => overrides[index as usize].is_some() || index >= 16, } } @@ -328,7 +324,10 @@ mod tests { let mut overrides = Colors::default(); assert!(is_fixed_color(Color::Spec(VteRgb { r: 1, g: 2, b: 3 }), &overrides)); assert!(is_fixed_color(Color::Indexed(24), &overrides)); - assert!(!is_fixed_color(Color::Indexed(16), &overrides)); + for index in 16..=255 { + assert!(is_fixed_color(Color::Indexed(index), &overrides)); + } + assert!(!is_fixed_color(Color::Indexed(15), &overrides)); assert!(!is_fixed_color(Color::Named(NamedColor::Red), &overrides)); overrides[NamedColor::Red] = Some(VteRgb { r: 1, g: 2, b: 3 }); diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index b3bbab6..c654690 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -22,12 +22,6 @@ use crate::display::color::{List, Rgb}; use crate::renderer::ui::Rgba; use nebula_terminal::vte::ansi::NamedColor; -/// First 256-color palette slot claimed for the powerline prompt chips -/// (16..=23: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg). Chosen at the -/// very start of the 6×6×6 cube — the darkest corner, rarely load-bearing for -/// TUIs — so hijacking eight slots stays invisible in practice. -pub(crate) const POWERLINE_SLOT0: usize = 16; - /// Built-in chrome themes exposed from the settings panel. The original seven /// Nebula looks remain unchanged; Nord/Paper are an additive dark/light pair /// carrying their own complete palettes. @@ -292,14 +286,6 @@ impl NebulaTheme { *colors = *defaults; let p = self.palette(); colors[NamedColor::Background] = p.term_bg; - // Powerline prompt slots: the injected prompt paints its segment chips - // with indexed colors 16..=23 instead of baked-in truecolor, so a - // theme switch remaps the palette and every chip ALREADY PRINTED in - // scrollback recolors instantly — indexed cells resolve the palette at - // draw time; truecolor is frozen the moment it is printed. - for (i, rgb) in self.powerline_colors().into_iter().enumerate() { - colors[POWERLINE_SLOT0 + i] = rgb; - } if let Some(exact) = self.exact_term_colors() { let foreground = rgb8(exact.foreground); colors[NamedColor::Foreground] = foreground; @@ -376,8 +362,7 @@ impl NebulaTheme { } } - /// Segment colors for the injected powerline prompt, published into the - /// 256-color palette at [`POWERLINE_SLOT0`]`..+8` by [`Self::apply_term_colors`]. + /// 旧提示符段色参考;不发布到终端扩展色表,避免覆盖应用的索引色。 /// Order: icon bg/fg, path bg/fg, branch bg/fg, time bg/fg — one flat color /// per chip (the old per-character truecolor gradient could never follow a /// theme switch retroactively, which users read as "the prompt is stuck"). diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 956ffbc..7f953c5 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -368,7 +368,7 @@ fn runtime_background( } /// 主题叠加在 toml 配色之上(镜像旧壳 `apply_term_colors` 的范围与次序): -/// 背景永远替换;Powerline 槽位 16..=23 替换。原有主题保持旧合同:仅浅色 +/// 背景永远替换;保留应用使用的扩展色槽。原有主题保持旧合同:仅浅色 /// 主题替换前景与 ANSI-16;自带完整 palette 的主题(Nord/Paper)应用其明确色表。 fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) { let resolved = crate::gpui_shell::theme::ResolvedTheme::builtin(theme, None); @@ -405,9 +405,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the return; } palette.background = rgba8(term.background); - for (i, color) in term.powerline.into_iter().enumerate() { - set_indexed(palette, nebula_settings::POWERLINE_SLOT0 + i as u8, rgba8(color)); - } if let Some(exact) = term.exact { let foreground = rgba8(resolved.foreground_override().unwrap_or(exact.foreground)); palette.foreground = foreground; @@ -447,11 +444,6 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the } } -fn set_indexed(palette: &mut Palette, index: u8, color: gpui::Rgba) { - palette.indexed.retain(|(existing, _)| *existing != index); - palette.indexed.push((index, color)); -} - fn rgba8(color: nebula_settings::Rgb8) -> gpui::Rgba { gpui::Rgba { r: color[0] as f32 / 255.0, @@ -817,6 +809,65 @@ mod tests { assert!(!settings.term_config().default_cursor_style.blinking); } + #[test] + fn builtin_themes_preserve_application_extended_colors() { + use nebula_terminal::term::color::Colors; + use nebula_terminal::vte::ansi::{Color, Rgb}; + + for theme in ThemeName::BUILTIN { + let mut palette = Palette::default(); + apply_theme(&mut palette, theme); + let overrides = Colors::default(); + for (index, expected) in [ + (16, [0, 0, 0]), + (17, [0, 0, 95]), + (18, [0, 0, 135]), + (19, [0, 0, 175]), + (20, [0, 0, 215]), + (21, [0, 0, 255]), + (22, [0, 95, 0]), + (23, [0, 95, 95]), + (231, [255, 255, 255]), + (255, [238, 238, 238]), + ] { + assert_eq!( + palette.resolve(Color::Indexed(index), &overrides, false), + rgba8(expected), + "{} index {index}", + theme.prompt_name() + ); + assert_eq!( + palette.query_reply(index as usize, &overrides), + Rgb { r: expected[0], g: expected[1], b: expected[2] } + ); + } + } + } + + #[test] + fn theme_switches_preserve_user_extended_colors_and_osc_overrides() { + use nebula_terminal::term::color::Colors; + use nebula_terminal::vte::ansi::{Color, Rgb}; + + let configured = [12, 34, 56]; + let osc = Rgb { r: 78, g: 90, b: 123 }; + let mut palette = Palette::default(); + palette.indexed.push((16, rgba8(configured))); + for theme in ThemeName::BUILTIN { + apply_theme(&mut palette, theme); + let mut overrides = Colors::default(); + assert_eq!(palette.resolve(Color::Indexed(16), &overrides, false), rgba8(configured)); + overrides[16] = Some(osc); + assert_eq!( + palette.resolve(Color::Indexed(16), &overrides, false), + rgba8([78, 90, 123]) + ); + assert_eq!(palette.query_reply(16, &overrides), osc); + overrides[16] = None; + assert_eq!(palette.query_reply(16, &overrides), Rgb { r: 12, g: 34, b: 56 }); + } + } + #[test] fn existing_dark_theme_keeps_user_terminal_colors() { let mut palette = Palette::default(); diff --git a/nebula_app/src/gpui_shell/terminal/event_mailbox.rs b/nebula_app/src/gpui_shell/terminal/event_mailbox.rs index 7448665..839a417 100644 --- a/nebula_app/src/gpui_shell/terminal/event_mailbox.rs +++ b/nebula_app/src/gpui_shell/terminal/event_mailbox.rs @@ -1,8 +1,8 @@ //! Coalesce repaint wakeups before enqueueing; retain every semantic event. use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use futures::{Stream, channel::mpsc}; @@ -12,6 +12,18 @@ use nebula_terminal::event::Event; pub(super) struct EventSender { sender: mpsc::UnboundedSender, wake_pending: Arc, + pub(super) native_prompt: NativePromptState, +} + +#[derive(Clone, Default)] +pub(super) struct NativePromptState(Arc>); + +/// PTY 接收与用户输入共用的边界;队列消费只唤醒视图,不重新给旧标记盖章。 +#[derive(Clone, Copy, Default)] +pub(super) struct NativePrompt { + pub seen: bool, + pub input_epoch: u64, + pub pending: bool, } pub(super) struct EventReceiver { @@ -23,13 +35,88 @@ pub(super) fn channel() -> (EventSender, EventReceiver) { let (sender, receiver) = mpsc::unbounded(); let wake_pending = Arc::new(AtomicBool::new(false)); ( - EventSender { sender, wake_pending: wake_pending.clone() }, + EventSender { + sender, + wake_pending: wake_pending.clone(), + native_prompt: NativePromptState::default(), + }, EventReceiver { receiver, wake_pending }, ) } +/// Editing a CMD input line does not invalidate a prompt already received from +/// the PTY. Submission and unknown control sequences remain conservative. +pub(super) fn preserves_native_prompt(mut bytes: &[u8]) -> bool { + while let Some((&byte, rest)) = bytes.split_first() { + if byte != 0x1b { + if byte.is_ascii_control() && !matches!(byte, 8 | 9 | 127) { + return false; + } + bytes = rest; + continue; + } + let Some(sequence) = bytes.strip_prefix(b"\x1b[") else { return false }; + let Some(end) = sequence.iter().position(|byte| (0x40..=0x7e).contains(byte)) else { + return false; + }; + let parameters = &sequence[..end]; + match sequence[end] { + b'A'..=b'D' | b'H' | b'F' if parameters.is_empty() => {}, + b'~' if matches!(parameters, b"1" | b"2" | b"3" | b"4" | b"5" | b"6") => {}, + b'_' => { + let Ok(parameters) = std::str::from_utf8(parameters) else { return false }; + let mut fields = parameters.split(';'); + let mut values = [0u16; 6]; + for value in &mut values { + let Some(parsed) = fields.next().and_then(|field| field.parse().ok()) else { + return false; + }; + *value = parsed; + } + if fields.next().is_some() || values[3] > 1 { + return false; + } + // Vk;Sc;Uc;Kd;Cs;Rc: Enter is a boundary even if Uc is zero. + if values[0] == 13 || (values[2] < 32 && !matches!(values[2], 0 | 8 | 9 | 27)) { + return false; + } + }, + _ => return false, + } + bytes = &sequence[end + 1..]; + } + true +} + +impl NativePromptState { + pub(super) fn snapshot(&self) -> NativePrompt { + *self.0.lock().unwrap() + } + + pub(super) fn observe_input(&self, input_epoch: u64, preserves_prompt: bool) { + let mut prompt = self.0.lock().unwrap(); + prompt.input_epoch = input_epoch; + prompt.pending &= preserves_prompt; + } + + pub(super) fn consume_native_prompt(&self) { + self.0.lock().unwrap().pending = false; + } + + pub(super) fn observe_prompt(&self) { + let mut prompt = self.0.lock().unwrap(); + prompt.seen = true; + prompt.pending = true; + } +} + impl EventSender { pub(super) fn send(&self, event: Event) { + if matches!(&event, Event::UserVar { name, value } + if name == "pebrel_cmd_prompt" && value == "1") + { + self.native_prompt.observe_prompt(); + } let wake = matches!(event, Event::Wakeup); if wake && self.wake_pending.swap(true, Ordering::AcqRel) { return; diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 6b8eb4d..45041b3 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -63,6 +63,7 @@ impl Dimensions for GridSize { pub struct TerminalSession { pub term: Arc>>, pub notifier: Notifier, + pub(super) native_prompt: super::event_mailbox::NativePromptState, /// PTY 直系 shell PID;关闭确认沿用旧壳 `busy_child(shell_pid)` 判据。 /// SSH 没有本地 shell 进程,固定为 0。 pub shell_pid: u32, @@ -71,21 +72,33 @@ pub struct TerminalSession { #[cfg(all(test, feature = "gpui-test-support"))] pub(super) fn test_session() -> (TerminalSession, std::sync::mpsc::Receiver) { - let (events, _) = super::event_mailbox::channel(); + let (session, receiver, _, _) = test_session_with_events(); + (session, receiver) +} + +#[cfg(all(test, feature = "gpui-test-support"))] +pub(super) fn test_session_with_events() -> ( + TerminalSession, + std::sync::mpsc::Receiver, + super::event_mailbox::EventReceiver, + EventProxy, +) { + let (events, event_rx) = super::event_mailbox::channel(); let (stages, _) = unbounded(); - let term = Term::new( - Config::default(), - &GridSize { columns: 80, screen_lines: 24 }, - EventProxy { events, stages }, - ); + let proxy = EventProxy { events, stages }; + let term = + Term::new(Config::default(), &GridSize { columns: 80, screen_lines: 24 }, proxy.clone()); let (sender, receiver) = nebula_terminal::event_loop::EventLoopSender::standalone().unwrap(); ( TerminalSession { term: Arc::new(FairMutex::new(term)), notifier: Notifier(sender), + native_prompt: proxy.events.native_prompt.clone(), shell_pid: 0, }, receiver, + event_rx, + proxy, ) } @@ -170,11 +183,12 @@ pub fn spawn( // NEBULA_PTY_RECORD=1 复用 ref_test 的 conout 录制(./nebula.recording), // 用于 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 notifier = Notifier(event_loop.channel()); let _io_thread = event_loop.spawn(); - Ok((TerminalSession { term, notifier, shell_pid }, rx, stage_rx)) + Ok((TerminalSession { term, notifier, native_prompt, shell_pid }, rx, stage_rx)) } /// 启动一个 SSH 直连会话(russh,与旧壳 `create_ssh_pane` 同一业务层): @@ -196,6 +210,7 @@ pub fn spawn_ssh( screen_lines: window_size.num_lines as usize, }; let term = Arc::new(FairMutex::new(Term::new(term_config, &grid, proxy.clone()))); + let native_prompt = proxy.events.native_prompt.clone(); let sender = crate::ssh_session::spawn_session_at( destination, initial_remote_cwd, @@ -204,5 +219,9 @@ pub fn spawn_ssh( proxy, )?; - Ok((TerminalSession { term, notifier: Notifier(sender), shell_pid: 0 }, rx, stage_rx)) + Ok(( + TerminalSession { term, notifier: Notifier(sender), native_prompt, shell_pid: 0 }, + rx, + stage_rx, + )) } diff --git a/nebula_app/src/gpui_shell/terminal/suggest.rs b/nebula_app/src/gpui_shell/terminal/suggest.rs index 2835e37..ff3831b 100644 --- a/nebula_app/src/gpui_shell/terminal/suggest.rs +++ b/nebula_app/src/gpui_shell/terminal/suggest.rs @@ -38,6 +38,14 @@ fn shared() -> MutexGuard<'static, Shared> { .unwrap_or_else(std::sync::PoisonError::into_inner) } +#[cfg(test)] +pub(super) fn history_hint_for_test( + scope: &crate::nebula_history::HistoryScope, + prefix: &str, +) -> Option { + shared().history.hint(scope, prefix).map(str::to_owned) +} + /// 重算一个 pane 的 ghost/弹窗建议。`line_override` 是 grid 读出的屏幕真值 /// (Windows 唯一行来源,见旧壳 `nebula_input_from_raw_grid` 的契约)。 pub fn update( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 221f180..9078599 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -9,6 +9,8 @@ mod confirmation; mod cwd_report; mod image_paste; mod layout; +#[cfg(all(test, windows, feature = "gpui-test-support"))] +mod native_cmd_tests; mod notifications; #[cfg(all(test, feature = "gpui-test-support"))] mod output_tests; @@ -311,6 +313,11 @@ pub struct TerminalView { command_started: Option, /// 上次跑进程树探测的时刻,用于节流。 last_process_probe: Option, + prompt_process_probe: Option>, + prompt_input_epoch: u64, + native_prompt_seen: bool, + native_prompt_epoch: Option, + last_prompt_process_probe: Option, active_run: Option, last_run: Option, /// Shared lifecycle owns hook authority, session scope and fallback evidence. @@ -661,6 +668,12 @@ impl TerminalView { }, TermEvent::Bell => self.on_bell(cx), TermEvent::UserVar { name, value } => { + if name == "pebrel_cmd_prompt" + && value == "1" + && self.suggest.suggest_env.is_this_machine() + { + self.on_native_cmd_prompt(cx); + } self.suggest.completion_shell_report(&name, &value); cx.notify(); }, @@ -712,6 +725,7 @@ impl TerminalView { /// 输入后回到底部并请求重绘。 fn write_input(&mut self, bytes: Vec, cx: &mut Context) { + self.prompt_input_epoch = self.prompt_input_epoch.wrapping_add(1); self.path_drop.invalidate(); self.image_paste.observe_input(&bytes); self.confirmation.observe_input(&bytes); @@ -719,11 +733,18 @@ impl TerminalView { if let Some(session) = &self.session { { let mut term = session.term.lock(); + // Match the parser's Term-lock order before attributing queued prompts. + let preserves_prompt = super::event_mailbox::preserves_native_prompt(&bytes); + session.native_prompt.observe_input(self.prompt_input_epoch, preserves_prompt); + if !preserves_prompt { + term.nebula_end_prompt(); + } term.scroll_display(Scroll::Bottom); term.selection = None; } session.notifier.notify(bytes); } + self.sync_native_prompt(); self.restart_cursor_blink(cx); cx.notify(); } @@ -984,6 +1005,7 @@ impl TerminalView { fn paste_now_impl(&mut self, text: &str, emit: bool, cx: &mut Context) { let normalized = text.replace("\r\n", "\r").replace('\n', "\r"); + self.capture_native_paste_submission(&normalized, cx); // 行镜像吃粘贴的字面文本;多行/控制字符由引擎侧作废(与旧壳 // `nebula_input_text` 的防注入契约一致)。 if !self.term_mode().contains(TermMode::ALT_SCREEN) { diff --git a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs index ac4674a..eb7d085 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -95,7 +95,6 @@ impl TerminalView { })); } } else if event.kind == AiHookKind::TurnDone - && status == AgentStatus::Done && let Some(notification) = crate::notify::Notification::from_ai_hook(event, event.message.clone(), false) { @@ -117,6 +116,7 @@ impl TerminalView { self.flush_pending_runtime_submit(cx); self.flush_pending_shell_command(cx); self.reconcile_shell_activity(cx); + self.on_native_cmd_prompt(cx); self.probe_missing_codex_session(cx); let Some(session) = &self.session else { return }; let (prompt_restored, screen) = { @@ -143,12 +143,16 @@ impl TerminalView { (prompt_restored, screen) }; if prompt_restored + && !self.native_prompt_seen && self.pending_runtime_submit.is_none() && self.pending_shell_command.is_none() && !self.recovery.preparing() { - log::debug!("command lifecycle: shell prompt restored pane={}", self.pane_id); - self.finish_foreground_command(None, cx); + if self.suggest.suggest_env.is_this_machine() { + self.probe_restored_prompt(cx); + } else { + self.finish_foreground_command(None, cx); + } return; } let Some(screen) = screen else { return }; diff --git a/nebula_app/src/gpui_shell/terminal/view/completion.rs b/nebula_app/src/gpui_shell/terminal/view/completion.rs index bc958bb..20e5fed 100644 --- a/nebula_app/src/gpui_shell/terminal/view/completion.rs +++ b/nebula_app/src/gpui_shell/terminal/view/completion.rs @@ -11,13 +11,24 @@ impl TerminalView { /// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在 /// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。 pub(super) fn commit_line(&mut self, cx: &mut Context) { - let agent_already_active = + self.sync_native_prompt(); + let agent_active = self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some(); - if !agent_already_active { - self.suggest.pending_command_prompt = None; + let command_already_active = + self.command_running && !self.command_running_disproved || agent_active; + let native_submission = + self.native_prompt_epoch == Some(self.prompt_input_epoch) && !agent_active; + if command_already_active && !native_submission { + // Input belongs to the foreground command, not a new shell submission. + crate::display::nebula_clear_line(&mut self.suggest); + if agent_active { + self.agent_activity.input_sent(); + } + return; } + self.suggest.pending_command_prompt = None; #[cfg(windows)] - if !agent_already_active && let Some(session) = &self.session { + if let Some(session) = &self.session { let term = session.term.lock(); if !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) { let cursor = term.grid().cursor.point; @@ -41,10 +52,12 @@ impl TerminalView { self.suggest.pending_command_prompt = None; } } + let confirmed_submission = self.suggest.pending_command_prompt.is_some() + && !self.suggest.screen_line.trim().is_empty(); suggest::commit_line(&mut self.suggest); - if agent_already_active { - self.agent_activity.input_sent(); - return; + if confirmed_submission { + self.mark_submitted_command(native_submission); + cx.notify(); } if let Some(agent) = crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) @@ -57,6 +70,70 @@ impl TerminalView { } } + fn mark_submitted_command(&mut self, native_submission: bool) { + if native_submission && self.command_running { + // 前一条的进程检查可能尚未返回;保留外层 Runtime run, + // 但下一次提交必须使旧命令/输入的异步结果失效。 + self.command_started = Some(std::time::Instant::now()); + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; + } + self.mark_command_running(); + } + + pub(super) fn capture_native_paste_submission(&mut self, text: &str, cx: &mut Context) { + self.sync_native_prompt(); + let native_submission = self.native_prompt_epoch == Some(self.prompt_input_epoch); + if !self.native_prompt_seen + || (self.command_running && !self.command_running_disproved && !native_submission) + || self.runtime_agent().is_some() + || self + .term_mode() + .intersects(TermMode::ALT_SCREEN | TermMode::VI | TermMode::BRACKETED_PASTE) + { + return; + } + let Some((submitted, _)) = text.rsplit_once('\r') else { return }; + let prompt = self.session.as_ref().and_then(|session| { + let term = session.term.lock(); + crate::display::nebula_prompt_line_from_raw_grid( + &term, + term.grid().cursor.point, + &self.suggest.line_buf, + &self.suggest.suggest_env, + ) + }); + if let Some(line) = prompt { + if !line.input.trim().is_empty() || !submitted.trim().is_empty() { + // 尚无 PTY 回显,只捕获活动边界,不把猜测的粘贴内容写入历史。 + self.suggest.pending_command_prompt = Some(line.prompt); + self.mark_submitted_command(native_submission); + cx.notify(); + } + } + } + + /// Runtime writes text before its echo barrier; retain the idle prompt now, + /// rather than trying to infer it after marking the submission as running. + pub(super) fn capture_runtime_prompt(&mut self) { + if self.command_running || self.runtime_agent().is_some() { + return; + } + let Some(session) = &self.session else { return }; + let term = session.term.lock(); + if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) { + return; + } + if let Some(line) = crate::display::nebula_prompt_line_from_raw_grid( + &term, + term.grid().cursor.point, + "", + &self.suggest.suggest_env, + ) { + self.suggest.pending_command_prompt = Some(line.prompt); + } + } + /// 用元素在网格快照同一次 `Term` 锁内取得的提示行重算 ghost/弹窗。 /// 这与旧壳 `draw_pane` 的锁序一致,避免退格回显夹在 render/paint 两次 /// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。 diff --git a/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs new file mode 100644 index 0000000..b466829 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/native_cmd_tests.rs @@ -0,0 +1,858 @@ +//! Native CMD input comes from the marked grid, including history recall. +use super::*; +use gpui::TestAppContext; +use nebula_terminal::event::Event; +use nebula_terminal::event_loop::StreamProcessor; +use startup_tests::{feed, open}; + +fn prompt_bytes(prompt: &str, input: &str) -> Vec { + format!( + "\x1b]133;A\x07{prompt}\x1b]133;B\x07\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07{input}" + ) + .into_bytes() +} + +fn root_process() -> Vec { + vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + depth: 0, + executable: "cmd.exe".into(), + }] +} + +#[gpui::test] +fn custom_native_prompts_capture_recalled_input_and_keep_submission_epochs( + cx: &mut gpui::TestAppContext, +) { + for prompt in [ + "[C:\\work] ", + "[C:\\work]\r\n>", + "", + "工作目录 :: ", + &"x".repeat(75), + &"x".repeat(79), + &"x".repeat(80), + &"x".repeat(160), + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + let mut stream = StreamProcessor::default(); + stream.feed( + &mut view.session.as_ref().unwrap().term.lock(), + &proxy, + &prompt_bytes(prompt, "pause"), + ); + view.suggest.line_buf.clear(); + view.commit_line(cx); + assert_eq!(view.suggest.last_committed, "pause", "prompt {prompt:?}"); + assert!(view.command_running); + view.write_input(b"\r".to_vec(), cx); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(root_process()), + cx, + ); + assert!(view.command_running, "queued old prompt must not finish pause"); + + let mut next = b"\r\n".to_vec(); + next.extend(prompt_bytes(prompt, "")); + stream.feed(&mut view.session.as_ref().unwrap().term.lock(), &proxy, &next); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(root_process()), + cx, + ); + assert!(!view.command_running, "fresh prompt ends the builtin wait"); + }); + } +} + +#[gpui::test] +fn custom_native_prompts_handle_empty_submit_paste_and_runtime_without_fake_history( + cx: &mut gpui::TestAppContext, +) { + let nonce = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + for (index, prompt) in ["", "[C:\\work] ", "[C:\\work]\r\n>", &"x".repeat(80), &"x".repeat(160)] + .into_iter() + .enumerate() + { + for action in ["empty", "paste", "runtime"] { + let prefix = format!("echo pebrel_native_history_{nonce}_{index}_{action}"); + let command = format!("{prefix} echoed"); + let scope = crate::nebula_history::HistoryScope::Local; + assert_eq!(suggest::history_hint_for_test(&scope, &prefix), None); + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, _events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + StreamProcessor::default().feed( + &mut view.session.as_ref().unwrap().term.lock(), + &proxy, + &prompt_bytes(prompt, ""), + ); + assert!(crate::display::nebula_shell_ready_from_raw_grid( + &view.session.as_ref().unwrap().term.lock(), + &view.suggest.suggest_env, + )); + match action { + "empty" => view.commit_line(cx), + "paste" => view.paste_now_impl(&format!("{command}\r\n"), false, cx), + _ => { + view.runtime_prompt(command.clone(), true, cx).unwrap(); + }, + } + assert_eq!(view.command_running, action != "empty", "{prompt:?} {action}"); + // Runtime keeps the submitted program identity before its echo + // barrier. Persistent history must still wait for screen input. + let identity = if action == "runtime" { command.as_str() } else { "" }; + assert_eq!(view.suggest.last_committed, identity, "{prompt:?} {action}"); + assert_eq!( + suggest::history_hint_for_test(&scope, &prefix), + None, + "unconfirmed input is not history: {prompt:?} {action}" + ); + if action != "empty" { + assert!(view.suggest.pending_command_prompt.is_some()); + } else { + // Positive control reads the same history owner after real + // terminal echo and Enter, including pending-wrap prompts. + feed(view, command.as_bytes()); + view.commit_line(cx); + assert_eq!(view.suggest.last_committed, command); + assert_eq!( + suggest::history_hint_for_test(&scope, &prefix).as_deref(), + Some(" echoed") + ); + } + }); + } + } +} + +fn native_prompt(view: &mut TerminalView, cx: &mut Context<'_, TerminalView>) { + view.session.as_ref().unwrap().native_prompt.observe_prompt(); + view.process_event(Event::UserVar { name: "pebrel_cmd_prompt".into(), value: "1".into() }, cx); +} + +#[gpui::test] +fn native_queued_prompt_cannot_finish_a_later_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + // Parse a real prompt, but hold its mailbox delivery until after Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let started = view.command_started; + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "queued startup prompt must not end pause" + ); + // A later real prompt still completes this same command without another Enter. + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_queued_prompt_allows_fast_next_submission(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, _events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07pause", + ); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!( + view.suggest.last_committed, "pause", + "a parsed prompt owns the next input even before UI delivery" + ); + assert_ne!(view.command_started, first, "new command invalidates first command probes"); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_queued_prompt_allows_fast_newline_paste(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>echo first"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + let first = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"\r\nfirst\r\nC:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.paste_now_impl("pause\r\n", false, cx); + assert_ne!(view.command_started, first, "newline paste starts its own command boundary"); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + first, + 1, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!( + view.suggest.last_committed, "echo first", + "unconfirmed paste must not enter history" + ); + }); +} + +#[gpui::test] +fn native_cmd_submission_starts_activity_without_osc_or_clink(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.suggest.line_buf = "ping -n 8 127.0.0.1".into(); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[gpui::test] +fn native_cmd_prompt_return_ends_non_agent_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.suggest.pending_command_prompt.is_none()); + }); +} + +#[gpui::test] +fn native_cmd_internal_input_keeps_the_original_command_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"Password: reply"); + view.suggest.line_buf = "reply".into(); + view.commit_line(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert_eq!(view.command_started, started); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_empty_enter_does_not_start_activity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_history_submission_uses_echo_without_typed_mirror(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>ping -n 8 127.0.0.1"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.last_committed, "ping -n 8 127.0.0.1"); + }); +} + +#[gpui::test] +fn native_cmd_changed_directory_prompt_ends_command(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"D:\\other>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_alternate_screen_prompt_is_not_shell_completion(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + feed(view, b"\x1b[?1049hC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_unknown_process_snapshot_does_not_end_builtin_wait(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(5)); + view.session.as_mut().unwrap().shell_pid = u32::MAX; + feed(view, b"Value: "); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_cmd_runtime_enter_uses_the_same_submission_boundary(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>pause"); + view.runtime_send_key(crate::runtime_api::RuntimeKey::Enter, Default::default(), 1, cx) + .unwrap(); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + }); +} + +#[gpui::test] +fn native_cmd_runtime_prompt_captures_echo_before_submitting(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + view.runtime_prompt("pause".into(), true, cx).unwrap(); + feed(view, b"pause"); + view.flush_pending_runtime_submit(cx); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + feed(view, b"\r\nPress any key to continue . . ."); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + feed(view, b"\r\nC:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe( + view.command_started, + view.prompt_input_epoch, + Ok(vec![]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_prompt_probe_requires_current_successful_process_evidence(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + let child = crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }; + view.apply_prompt_process_probe(started, 0, Err("snapshot unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone(), child]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.apply_prompt_process_probe(None, 0, Ok(vec![root.clone()]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "stale command result"); + feed(view, b"still executing"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "prompt changed during probe" + ); + feed(view, b"\r\nC:\\work>"); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_cmd_input_invalidates_pending_prompt_probe(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + view.mark_command_running(); + let started = view.command_started; + feed(view, b"C:\\work>"); + // The new input has reached the PTY, but its echo has not arrived yet. + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_marker_restores_prompt_despite_background_process(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "python.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_marker_disables_visible_prompt_guessing(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + native_prompt(view, cx); + view.mark_command_running(); + view.suggest.pending_command_prompt = Some("C:\\work>".into()); + feed(view, b"C:\\work>"); + view.refresh_agent_screen_state(cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "set /p or removed PROMPT marker is not completion" + ); + }); +} + +#[gpui::test] +fn native_marker_rejects_nested_shell_and_unknown_snapshot(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Err("unavailable".into()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + root.clone(), + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert!(view.command_running, "outer command remains live"); + // The periodic process fallback must not undo a confirmed inner prompt. + view.session.as_mut().unwrap().shell_pid = std::process::id(); + view.suggest.last_committed = "ping -n 5 127.0.0.1".into(); + view.command_started = Some(std::time::Instant::now() - std::time::Duration::from_secs(4)); + view.reconcile_shell_activity(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + view.command_started = started; + // A fresh outer prompt after the nested shell exits can complete the run. + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![root]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + }); +} + +#[gpui::test] +fn native_nested_prompt_keeps_outer_run_and_agent_identity(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + let processes = vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes.clone()), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + feed(view, b"C:\\work>pause"); + view.commit_line(cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.running_program = Some("pi".into()); + let hook = crate::ai_hook::parse_remote_envelope( + b"nebula-hook/1 source=pi pane=42\n{\"kind\":\"prompt\",\"session_id\":\"nested\",\"bridge_sequence\":1}", + Some(42), + ).unwrap(); + view.handle_ai_hook(&hook, cx); + let started = view.command_started; + native_prompt(view, cx); + view.apply_prompt_process_probe(started, 0, Ok(processes), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.running_program.as_deref(), Some("pi")); + assert!(view.agent_activity.hook_seen()); + assert_eq!(view.active_run.unwrap().run_id, run_id); + }); +} + +#[gpui::test] +fn native_nested_prompt_clears_cli_progress_without_ending_outer_run(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + view.active_run = Some(crate::runtime_api::begin_runtime_run()); + let run_id = view.active_run.unwrap().run_id; + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![ + crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }, + crate::process_tree::ProcessEntry { + pid: 2, + parent_pid: 1, + executable: "cmd.exe".into(), + depth: 1, + }, + ]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + assert_eq!(view.active_run.unwrap().run_id, run_id); + assert!(view.last_run.is_none()); + }); +} + +#[gpui::test] +fn native_marker_pending_probe_cannot_finish_new_input(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"pause\r".to_vec(), cx); + view.apply_prompt_process_probe(started, 0, Ok(vec![]), cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + }); +} + +#[gpui::test] +fn native_prompt_survives_typing_before_process_probe_returns(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(b"echo next".to_vec(), cx); + let root = crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }; + view.apply_prompt_process_probe(started, 0, Ok(vec![root.clone()]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Running, + "old input snapshot is discarded" + ); + view.apply_prompt_process_probe(started, 1, Ok(vec![root]), cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "typing without submitting must not lose the only prompt marker" + ); + }); +} + +#[gpui::test] +fn native_queued_prompt_cannot_finish_encoded_submission(cx: &mut TestAppContext) { + for bytes in [&b"pause\r"[..], &b"\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"[..]] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.mark_command_running(); + let started = view.command_started; + native_prompt(view, cx); + view.write_input(bytes.to_vec(), cx); + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running, "{bytes:?}"); + }); + } +} + +#[gpui::test] +fn native_queued_prompt_survives_editing_keys(cx: &mut TestAppContext) { + for bytes in [ + &b"\x08"[..], + &b"\x1b[D"[..], + &b"\x1b[8;14;8;1;0;1_\x1b[8;14;8;0;0;1_"[..], + &b"\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"[..], + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let (session, _input, mut events, proxy) = session::test_session_with_events(); + view.session = Some(session); + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + view.mark_command_running(); + let started = view.command_started; + nebula_terminal::event_loop::StreamProcessor::default().feed( + &mut *view.session.as_ref().unwrap().term.lock(), + &proxy, + b"C:\\work>\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07", + ); + view.write_input(bytes.to_vec(), cx); + while let Ok(event) = events.try_recv() { + view.process_event(event, cx); + } + view.apply_prompt_process_probe( + started, + view.prompt_input_epoch, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle, "{bytes:?}"); + }); + } +} + +#[gpui::test] +fn native_newline_paste_starts_command_without_recording_unconfirmed_history( + cx: &mut TestAppContext, +) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>"); + native_prompt(view, cx); + view.paste_now_impl("pause\r\n", false, cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + assert_eq!(view.suggest.pending_command_prompt.as_deref(), Some("C:\\work>")); + assert!(view.suggest.last_committed.is_empty(), "paste is not yet echoed shell history"); + }); +} + +#[gpui::test] +fn native_paste_without_shell_submission_does_not_start_command(cx: &mut TestAppContext) { + for (screen, pasted, bracketed) in [ + ("C:\\work>", "pause", false), + ("C:\\work>", "\r\n", false), + ("Password: ", "secret\r\n", false), + ("C:\\work>", "pause\r\n", true), + ] { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, screen.as_bytes()); + native_prompt(view, cx); + if bracketed { + feed(view, b"\x1b[?2004h"); + } + view.paste_now_impl(pasted, false, cx); + assert_eq!( + view.sidebar_activity(), + SidebarActivity::Idle, + "{screen:?} {pasted:?} bracketed={bracketed}" + ); + }); + } +} + +#[gpui::test] +fn native_prompt_completion_clears_progress_without_another_enter(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + view.suggest.suggest_env = crate::display::SuggestEnv::Local; + feed(view, b"C:\\work>python synthetic-progress.py"); + view.commit_line(cx); + let started = view.command_started; + view.process_event(Event::Progress { state: 3, value: None }, cx); + native_prompt(view, cx); + view.apply_prompt_process_probe( + started, + 0, + Ok(vec![crate::process_tree::ProcessEntry { + pid: 1, + parent_pid: 0, + executable: "cmd.exe".into(), + depth: 0, + }]), + cx, + ); + assert_eq!(view.sidebar_activity(), SidebarActivity::Idle); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + }); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index bc14cf0..e81ee91 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -145,6 +145,9 @@ impl TerminalView { self.command_running_disproved = false; self.command_started = None; self.last_process_probe = None; + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; + self.consume_native_prompt(); self.pending_runtime_submit = None; self.suggest.pending_command_prompt = None; self.awaiting_input = false; @@ -423,6 +426,9 @@ impl TerminalView { self.ensure_runtime_readable()?; let bytes = self.runtime_key_sequence(key, modifiers, repeat)?; let bytes_sent = bytes.len(); + if key == crate::runtime_api::RuntimeKey::Enter && modifiers == Default::default() { + self.commit_line(cx); + } self.write_input(bytes, cx); Ok(bytes_sent) } @@ -469,6 +475,7 @@ impl TerminalView { baseline_screen: self.runtime_screen_snapshot().unwrap_or_default(), submit_bytes, }); + self.capture_runtime_prompt(); let run = crate::runtime_api::begin_runtime_run(); let run_id = run.run_id; self.active_run = Some(run); @@ -515,6 +522,9 @@ impl TerminalView { )); } let recognized_agent = submit && self.runtime_agent().is_some(); + if submit { + self.capture_runtime_prompt(); + } let mut bytes = crate::input::terminal_input::build_runtime_text_sequence(&text, self.term_mode()); if submit { @@ -779,13 +789,149 @@ impl TerminalView { .detach(); } + pub(super) fn sync_native_prompt(&mut self) { + if !self.suggest.suggest_env.is_this_machine() { + return; + } + if let Some(session) = &self.session { + let prompt = session.native_prompt.snapshot(); + self.native_prompt_seen = prompt.seen; + self.native_prompt_epoch = prompt.pending.then_some(prompt.input_epoch); + } + } + + pub(super) fn consume_native_prompt(&mut self) { + if let Some(session) = &self.session { + session.native_prompt.consume_native_prompt(); + } + self.native_prompt_epoch = None; + } + + pub(super) fn on_native_cmd_prompt(&mut self, cx: &mut Context) { + self.sync_native_prompt(); + if self.pending_runtime_submit.is_some() + || self.pending_shell_command.is_some() + || self.recovery.preparing() + || self.exited.is_some() + { + return; + } + if self.command_running && self.native_prompt_epoch == Some(self.prompt_input_epoch) { + self.probe_restored_prompt(cx); + } + } + + pub(super) fn probe_restored_prompt(&mut self, cx: &mut Context) { + if self.prompt_process_probe.is_some() + || self + .last_prompt_process_probe + .is_some_and(|at| at.elapsed() < std::time::Duration::from_secs(1)) + { + return; + } + let Some(session) = &self.session else { return }; + let pid = session.shell_pid; + let started = self.command_started; + let input_epoch = self.prompt_input_epoch; + self.last_prompt_process_probe = Some(std::time::Instant::now()); + let work = + cx.background_executor().spawn(async move { crate::process_tree::descendants(pid) }); + self.prompt_process_probe = Some(cx.spawn(async move |this, cx| { + let result = work.await; + let _ = this.update(cx, |view, cx| { + view.prompt_process_probe = None; + view.apply_prompt_process_probe(started, input_epoch, result, cx); + }); + })); + } + + pub(super) fn apply_prompt_process_probe( + &mut self, + started: Option, + input_epoch: u64, + result: Result, String>, + cx: &mut Context, + ) { + self.sync_native_prompt(); + if self.command_started != started + || self.prompt_input_epoch != input_epoch + || self.pending_runtime_submit.is_some() + || self.exited.is_some() + { + return; + } + let Ok(processes) = result else { return }; + if self.native_prompt_seen { + if self.native_prompt_epoch != Some(input_epoch) { + return; + } + // Nested shells inherit PROMPT; their marker cannot end the outer run. + let root_is_cmd = processes.iter().any(|p| { + p.depth == 0 + && crate::process_tree::display_name(&p.executable).eq_ignore_ascii_case("cmd") + }); + let nested_shell = processes.iter().any(|p| { + p.depth > 0 + && crate::process_tree::is_interactive_shell_command( + &crate::process_tree::display_name(&p.executable), + ) + }); + self.consume_native_prompt(); + if root_is_cmd { + if nested_shell { + self.command_running_disproved = true; + if !self.agent_activity.hook_seen() + && !self.agent_activity.status().is_decided() + { + self.process_event( + nebula_terminal::event::Event::Progress { state: 0, value: None }, + cx, + ); + } + cx.notify(); + } else { + self.finish_foreground_command(None, cx); + } + } + return; + } + if processes.iter().any(|process| { + process.depth > 0 + && !crate::process_tree::is_interactive_shell_command( + &crate::process_tree::display_name(&process.executable), + ) + && !matches!( + process.executable.to_ascii_lowercase().as_str(), + "conhost.exe" | "openconsole.exe" + ) + }) { + return; + } + let restored = self.session.as_ref().is_some_and(|session| { + let term = session.term.lock(); + self.suggest.pending_command_prompt.as_deref().is_some_and(|expected| { + crate::display::nebula_shell_prompt_restored_from_raw_grid( + &term, + expected, + &self.suggest.suggest_env, + ) + }) + }); + if restored { + self.finish_foreground_command(None, cx); + } + } + /// `command_running` 的统一置位口:进程树探测的节流窗口从这里起算, /// 上一条命令的反证同时作废(新命令开始,「树里没活儿」不再成立)。 /// /// 上一条命令的失败标记与「刚完成」的对勾也在这里作废:新命令一起跑,旧结果 /// 就不再是这个 pane 的现状。 pub(super) fn mark_command_running(&mut self) { + self.consume_native_prompt(); if !self.command_running { + self.prompt_process_probe = None; + self.last_prompt_process_probe = None; self.command_started = Some(std::time::Instant::now()); self.last_process_probe = None; } @@ -864,6 +1010,11 @@ impl TerminalView { // Only a deliberately entered interactive shell may use this hint. // Start-Sleep and other builtins run inside the shell itself; absence // of a child cannot finish them. SSH/WSL were excluded at entry. + // A native CMD prompt is stronger than the process fallback. In particular, + // an idle nested shell remains a child of the outer command. + if self.native_prompt_seen { + return; + } let interactive_shell = crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed); let disproved = interactive_shell && !evidence.busy; diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index 28c1991..19364aa 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -243,6 +243,11 @@ impl TerminalView { command_running_disproved: false, command_started: None, last_process_probe: None, + prompt_process_probe: None, + prompt_input_epoch: 0, + native_prompt_seen: false, + native_prompt_epoch: None, + last_prompt_process_probe: None, active_run: None, last_run: None, agent_activity: Default::default(), diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs index 4b490b6..320fd9d 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs @@ -62,6 +62,26 @@ pub(super) fn feed(view: &mut TerminalView, bytes: &[u8]) { parser.advance(&mut *term, bytes); } +#[gpui::test] +fn ended_command_does_not_leave_osc_progress_running(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + for exit_code in [Some(0), Some(1), None] { + view.process_event(Event::CommandStart, cx); + view.process_event(Event::Progress { state: 3, value: None }, cx); + assert_eq!(view.sidebar_activity(), SidebarActivity::Running); + view.process_event(Event::CommandDone { exit_code }, cx); + let expected = if exit_code.is_some_and(|code| code != 0) { + SidebarActivity::CommandFailed + } else { + SidebarActivity::Idle + }; + assert_eq!(view.sidebar_activity(), expected); + assert_eq!(view.progress, crate::taskbar::TaskProgress::None); + } + }); +} + #[gpui::test] fn ligature_changes_update_all_faces_without_replacing_the_open_session(cx: &mut TestAppContext) { let (view, window, _) = open(cx); @@ -166,6 +186,70 @@ fn review_regression_cold_resume_survives_initial_prompt_and_clears_on_exit( })); } +#[gpui::test] +fn pi_cancelled_turn_becomes_idle_instead_of_completed(cx: &mut TestAppContext) { + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + for payload in [ + r#"{"kind":"prompt","session_id":"pi-cancelled-test"}"#, + r#"{"kind":"done","stop_reason":"aborted","session_id":"pi-cancelled-test"}"#, + ] { + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(view.pane_id)) + .expect("Pi hook"); + assert!(view.handle_ai_hook(&event, cx)); + } + assert_eq!(view.agent_activity.status(), crate::ai_agents::AgentStatus::Idle); + }); +} + +#[gpui::test] +fn pi_outcomes_emit_only_the_matching_notification(cx: &mut TestAppContext) { + use std::cell::RefCell; + use std::rc::Rc; + + let (view, window, _) = open(cx); + let notifications = Rc::new(RefCell::new(Vec::new())); + let observed = notifications.clone(); + let _subscription = view.update(window, |_, cx| { + cx.subscribe(&view, move |_, _, event, _| { + if let TerminalViewEvent::Notification(notification) = event { + observed.borrow_mut().push(notification.clone()); + } + }) + }); + for (reason, status, expected_count) in [ + ("aborted", crate::ai_agents::AgentStatus::Idle, 0), + ("unknown", crate::ai_agents::AgentStatus::Idle, 1), + ("error", crate::ai_agents::AgentStatus::Idle, 2), + ("stop", crate::ai_agents::AgentStatus::Done, 3), + ] { + view.update(window, |view, cx| { + for payload in [ + serde_json::json!({"kind": "prompt", "session_id": "pi-test"}), + serde_json::json!({"kind": "done", "stop_reason": reason, "session_id": "pi-test"}), + ] { + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = + crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(view.pane_id)) + .expect("Pi hook"); + assert!(view.handle_ai_hook(&event, cx)); + } + assert_eq!(view.agent_activity.status(), status); + }); + assert_eq!(notifications.borrow().len(), expected_count, "{reason}"); + } + let notifications = notifications.borrow(); + assert!(matches!( + ¬ifications[1], + crate::notify::Notification::AiTurnIssue { + outcome: crate::ai_hook::AiTurnOutcome::Failed, + .. + } + )); + assert!(!notifications[1].is_attention()); +} + #[gpui::test] fn failed_cold_resume_keeps_target_for_retry(cx: &mut TestAppContext) { let (view, window, receiver) = open(cx); diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index ba6e7a5..fd2ccfc 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -208,8 +208,9 @@ pub(crate) fn banner_for_pane( kind: ToastKind, text: impl Into, pane_id: u64, - ai_toast: bool, + source: &crate::notify::Notification, ) { + let ai_toast = source.is_ai(); let text = text.into(); if text.trim().is_empty() || !PANE_BANNERS.lock().unwrap_or_else(|poison| poison.into_inner()).accepts( @@ -230,7 +231,17 @@ pub(crate) fn banner_for_pane( } else { notification = notification.id1::(("pane-result", pane_id)); } - push_banner(window, cx, notification, ai_toast); + // Pi completion has a shorter source default. The shared duration setting + // still overrides this value, including the explicit Persistent choice. + let ttl = match source { + crate::notify::Notification::AiTurn { program, message: None, attention: false } + if program == "pi" => + { + Duration::from_secs(5) + }, + _ => BANNER_TTL, + }; + push_with_duration(window, cx, notification, Some(ttl), ai_toast); } pub(crate) fn confirmation_for_pane( @@ -450,6 +461,7 @@ mod tests { gpui_component::init(cx); let mut settings = Settings::load(nebula_settings::ThemeName::Nord); settings.ai_toasts = enabled; + settings.notification_duration = nebula_settings::NotificationDuration::Default; cx.set_global(settings); init(cx); }); @@ -461,6 +473,14 @@ mod tests { }) } + fn pi_completion() -> crate::notify::Notification { + crate::notify::Notification::AiTurn { + program: "pi".into(), + message: None, + attention: false, + } + } + fn settle_dismissal(cx: &mut VisualTestContext) { cx.run_until_parked(); cx.background_executor.advance_clock(Duration::from_millis(200)); @@ -472,7 +492,14 @@ mod tests { initialize(cx, true); let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "AI completed", 7101, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "AI completed", + 7101, + &pi_completion(), + ); confirmation_for_pane( window, cx, @@ -496,17 +523,86 @@ mod tests { cx.run_until_parked(); assert_eq!(ids(cx), vec![original[2]], "reenabling must not replay old notices"); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Next AI turn", 7101, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Next AI turn", + 7101, + &pi_completion(), + ); }); assert_eq!(ids(cx).len(), 2); } + #[gpui::test] + fn pi_completion_expires_without_dismissing_attention_or_later_turns( + cx: &mut TestAppContext, + ) { + initialize(cx, true); + let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); + cx.update(|window, cx| { + banner_for_pane( + window, + cx, + ToastKind::Info, + "First Pi completion", + 7301, + &pi_completion(), + ); + let failed = crate::notify::Notification::AiTurnIssue { + program: "pi".into(), + message: None, + outcome: crate::ai_hook::AiTurnOutcome::Failed, + }; + banner_for_pane(window, cx, ToastKind::Info, "Pi failure", 7302, &failed); + confirmation_for_pane( + window, + cx, + "Permission".into(), + 7303, + BinaryConfirmation { id: 7304, question: "Continue?".into() }, + ); + }); + cx.run_until_parked(); + let original = ids(cx); + assert_eq!(original.len(), 3); + cx.background_executor.advance_clock(Duration::from_secs(4)); + cx.run_until_parked(); + assert_eq!(ids(cx), original, "completion remains readable before expiry"); + cx.update(|window, cx| { + banner_for_pane( + window, + cx, + ToastKind::Info, + "Second Pi completion", + 7301, + &pi_completion(), + ); + }); + cx.run_until_parked(); + let later = *ids(cx).last().unwrap(); + cx.background_executor.advance_clock(Duration::from_secs(1)); + settle_dismissal(cx); + assert_eq!(ids(cx), vec![original[1], original[2], later]); + cx.background_executor.advance_clock(Duration::from_secs(4)); + settle_dismissal(cx); + assert_eq!(ids(cx), original[1..]); + } + #[gpui::test] fn disabled_ai_cards_cannot_reenter_the_component_queue(cx: &mut TestAppContext) { initialize(cx, false); let (_, cx) = cx.add_window_view(|window, cx| Root::new(cx.new(|_| Empty), window, cx)); cx.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Hidden AI turn", 7102, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Hidden AI turn", + 7102, + &pi_completion(), + ); confirmation_for_pane( window, cx, @@ -514,7 +610,14 @@ mod tests { 7102, BinaryConfirmation { id: 7202, question: "Continue?".into() }, ); - banner_for_pane(window, cx, ToastKind::Info, "Build finished", 7102, false); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Build finished", + 7102, + &crate::notify::Notification::Bell { program: None }, + ); }); cx.run_until_parked(); assert_eq!(ids(cx).len(), 1, "ordinary terminal notices remain available"); @@ -524,7 +627,14 @@ mod tests { fn deferred_startup_banner_rechecks_the_current_preference(cx: &mut TestAppContext) { initialize(cx, true); let (_, cx) = cx.add_window_view(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Queued AI turn", 7103, true); + banner_for_pane( + window, + cx, + ToastKind::Info, + "Queued AI turn", + 7103, + &pi_completion(), + ); cx.update_global::(|settings, _| settings.ai_toasts = false); Root::new(cx.new(|_| Empty), window, cx) }); diff --git a/nebula_app/src/gpui_shell/toast/duration_tests.rs b/nebula_app/src/gpui_shell/toast/duration_tests.rs index ade84bf..506db29 100644 --- a/nebula_app/src/gpui_shell/toast/duration_tests.rs +++ b/nebula_app/src/gpui_shell/toast/duration_tests.rs @@ -44,21 +44,69 @@ fn settle_dismissal(cx: &mut VisualTestContext) { advance(cx, Duration::from_millis(250)); } +fn pi_completion() -> crate::notify::Notification { + crate::notify::Notification::AiTurn { program: "pi".into(), message: None, attention: false } +} + +#[gpui::test] +fn pi_completion_uses_five_seconds_only_when_the_user_keeps_the_default(cx: &mut TestAppContext) { + for (index, duration) in [ + NotificationDuration::Default, + NotificationDuration::FiveSeconds, + NotificationDuration::TenSeconds, + NotificationDuration::ThirtySeconds, + NotificationDuration::NinetySeconds, + NotificationDuration::Persistent, + ] + .into_iter() + .enumerate() + { + let mut window = open(duration, cx); + window.update(|window, cx| { + banner_for_pane( + window, + cx, + ToastKind::Info, + format!("Pi complete {duration:?}"), + 84000 + index as u64, + &pi_completion(), + ); + }); + let original = ids(&mut window); + assert_eq!(original.len(), 1); + if let Some(timeout) = duration.timeout(Some(Duration::from_secs(5))) { + advance(&mut window, timeout - Duration::from_millis(1)); + assert_eq!(ids(&mut window), original); + advance(&mut window, Duration::from_millis(1)); + settle_dismissal(&mut window); + assert!(ids(&mut window).is_empty(), "{duration:?}"); + } else { + advance(&mut window, Duration::from_secs(3600)); + assert_eq!(ids(&mut window), original, "explicit persistent choice wins"); + } + } +} + #[gpui::test] fn pane_result_replacement_preserves_other_panes_and_uses_the_selected_duration( cx: &mut TestAppContext, ) { let mut window = open(NotificationDuration::FiveSeconds, cx); let old = window.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Warning, "Overloaded", 83001, true); + let failure = crate::notify::Notification::AiTurnIssue { + program: "pi".into(), + message: None, + outcome: crate::ai_hook::AiTurnOutcome::Failed, + }; + banner_for_pane(window, cx, ToastKind::Warning, "Overloaded", 83001, &failure); let old = window.notifications(cx)[0].clone(); - banner_for_pane(window, cx, ToastKind::Info, "Other pane", 83002, true); + banner_for_pane(window, cx, ToastKind::Info, "Other pane", 83002, &pi_completion()); old }); let original = ids(&mut window); advance(&mut window, Duration::from_secs(4)); window.update(|window, cx| { - banner_for_pane(window, cx, ToastKind::Info, "Recovered", 83001, true); + banner_for_pane(window, cx, ToastKind::Info, "Recovered", 83001, &pi_completion()); }); let replaced = ids(&mut window); assert_eq!(replaced.len(), 2); diff --git a/nebula_app/src/gpui_shell/workspace/notifications.rs b/nebula_app/src/gpui_shell/workspace/notifications.rs index e041c5e..7103f81 100644 --- a/nebula_app/src/gpui_shell/workspace/notifications.rs +++ b/nebula_app/src/gpui_shell/workspace/notifications.rs @@ -100,7 +100,7 @@ impl NebulaWorkspace { kind, text, pane_id, - notification.is_ai(), + ¬ification, ); } } diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index f0c4990..cf44c5a 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -195,31 +195,11 @@ fn main() -> Result<(), Box> { #[cfg(windows)] panic::attach_handler(); - // Portable builds are not necessarily on PATH. Export the exact executable - // before any PTY is created so Codex/Claude can call the supported control - // plane directly instead of scanning processes, port files, or source code. - // - // This is the fallback layer: every terminal pane gets the full identity - // contract (`TERM_PROGRAM`, pane id, bin dir, `PATH`) from `agent_env`. - // Setting it on the process too covers children spawned outside a PTY, - // which never see `tty::Options::env`. - #[cfg(windows)] if options.subcommands.is_none() - && let Ok(executable) = env::current_exe() + && let Err(error) = platform::startup::prepare_gui_process() { - // SAFETY: startup is still single-threaded here; all child PTYs are - // created later and inherit this stable value. - unsafe { env::set_var(agent_env::CLI_ENV, executable) }; - } - - #[cfg(windows)] - if options.subcommands.is_none() && env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { - // 必须在进任何消息循环之前脱离启动控制台。GPUI 以前在这条 - // FreeConsole 之前就 `return`,启动器(agent 作业对象)一退出 - // 窗口就被带走。旧壳注释同一合同。 - unsafe { - FreeConsole(); - } + platform::startup::report_error(&error, true); + return Err(error.into()); } // 产品主窗:GPUI 作为 nebula.exe 的 UI 层,从主线程直接进 GPUI diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index f94c33d..528e831 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -156,7 +156,7 @@ impl Notification { outcome: event.turn_outcome, }); }, - AiTurnOutcome::Unknown if event.source == "pi" => { + AiTurnOutcome::Unknown | AiTurnOutcome::Unspecified if event.source == "pi" => { return Some(Self::AiTurnIssue { program: event.source.clone(), message: None, @@ -560,6 +560,39 @@ mod delivery_tests { } } + #[test] + fn pi_outcomes_do_not_turn_failure_or_cancellation_into_success() { + use crate::ai_hook::AiTurnOutcome; + for (reason, expected) in [ + ("error", Some(AiTurnOutcome::Failed)), + ("aborted", None), + ("unknown", Some(AiTurnOutcome::Unknown)), + ("future", Some(AiTurnOutcome::Unknown)), + ("length", Some(AiTurnOutcome::Incomplete)), + ("toolUse", Some(AiTurnOutcome::Incomplete)), + ] { + let event = pi_result(Some(reason), None); + let notification = Notification::from_ai_hook(&event, None, false); + match (notification, expected) { + (None, None) => {}, + (Some(Notification::AiTurnIssue { outcome, .. }), Some(expected)) => { + assert_eq!(outcome, expected); + }, + result => panic!("unexpected {reason} result: {result:?}"), + } + } + for reason in [None, Some("stop")] { + let mut payload = serde_json::json!({"kind": "done"}); + if let Some(reason) = reason { + payload["stop_reason"] = reason.into(); + } + let wire = format!("nebula-hook/1 source=pi\n{payload}"); + let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(1)).unwrap(); + let note = Notification::from_ai_hook(&event, None, false).unwrap(); + assert_eq!(matches!(note, Notification::AiTurn { .. }), reason == Some("stop")); + } + } + #[test] fn every_registered_ai_is_recognized_for_bell_text_and_command_events() { for agent in crate::ai_agents::AgentKind::ALL { diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index f2ce192..0d5f4d9 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -6,6 +6,29 @@ pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { let _ = (error, gui_launch); } +#[cfg(windows)] +mod console; + +/// Prepare process-wide GUI state before worker threads or terminal children exist. +pub(crate) fn prepare_gui_process() -> std::io::Result<()> { + #[cfg(windows)] + { + // Portable builds may not be on PATH. Non-PTY children need the same + // executable fallback that agent_env supplies for each terminal pane. + if let Ok(executable) = std::env::current_exe() { + // SAFETY: main calls this while startup is still single-threaded. + unsafe { std::env::set_var(crate::agent_env::CLI_ENV, executable) }; + } + if std::env::var_os("NEBULA_DETACHED_LAUNCH").is_some() { + // Detach before either GUI event loop starts, so a launcher exiting + // cannot take the window down with its startup console. + unsafe { windows_sys::Win32::System::Console::FreeConsole() }; + } + console::prepare_console_for_gui()?; + } + Ok(()) +} + pub fn prepare_gui() { #[cfg(target_os = "macos")] { diff --git a/nebula_app/src/platform/startup/console.rs b/nebula_app/src/platform/startup/console.rs new file mode 100644 index 0000000..39af5af --- /dev/null +++ b/nebula_app/src/platform/startup/console.rs @@ -0,0 +1,12 @@ +//! Normalize the process attribute before any terminal children inherit it. + +pub(crate) fn prepare_console_for_gui() -> std::io::Result<()> { + // The NULL-handler ignore flag is inherited by ConPTY shells. Normalize it + // once, before any workers or terminals exist; never toggle it around spawn. + // SAFETY: this changes only this process's console attribute, not its parent + // or any existing child. The call also works without an attached console. + if unsafe { windows_sys::Win32::System::Console::SetConsoleCtrlHandler(None, 0) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} diff --git a/nebula_app/src/platform/startup/console_tests.rs b/nebula_app/src/platform/startup/console_tests.rs new file mode 100644 index 0000000..8adfaae --- /dev/null +++ b/nebula_app/src/platform/startup/console_tests.rs @@ -0,0 +1,118 @@ +//! Console attributes are process-global: each scenario runs in a disposable process. +use std::env; +use std::process::{Child, Command}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use windows_sys::Win32::System::Console::{ + AllocConsole, CTRL_C_EVENT, FreeConsole, GenerateConsoleCtrlEvent, SetConsoleCtrlHandler, +}; + +const TEST: &str = "console_tests::gui_startup_restores_child_ctrl_c"; + +struct TestChild(Child); + +impl Drop for TestChild { + fn drop(&mut self) { + // Failure cleanup only: a killed child never counts as an interruption. + if !matches!(self.0.try_wait(), Ok(Some(_))) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } +} + +struct ReadyFile(std::path::PathBuf); + +impl Drop for ReadyFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn wait(child: &mut TestChild) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(12); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "console test child exceeded its deadline"); + thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn gui_startup_restores_child_ctrl_c() { + let Ok(role) = env::var("PEBREL_CONSOLE_TEST_ROLE") else { + for scenario in ["unchanged", "normalized", "detached"] { + let mut child = TestChild( + Command::new(env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env("PEBREL_CONSOLE_TEST_ROLE", "host") + .env("PEBREL_CONSOLE_TEST_SCENARIO", scenario) + .spawn() + .unwrap(), + ); + assert!(wait(&mut child).success(), "GUI console startup failed ({scenario})"); + } + return; + }; + if role == "target" { + // This integration-test executable uses the console subsystem. Never + // attach or allocate here: either operation resets the inherited flag. + let ready = env::var_os("PEBREL_CONSOLE_TEST_READY").unwrap(); + std::fs::write(ready, b"ready").unwrap(); + thread::sleep(Duration::from_secs(5)); + return; + } + + // Never broadcast to cargo's or the developer's console. + unsafe { FreeConsole() }; + assert_ne!(unsafe { AllocConsole() }, 0); + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 1) }, 0); + let scenario = env::var("PEBREL_CONSOLE_TEST_SCENARIO").unwrap(); + let detached = scenario == "detached"; + if detached { + assert_ne!(unsafe { FreeConsole() }, 0); + } + if scenario != "unchanged" { + super::prepare_console_for_gui().unwrap(); + } + if detached { + // The API must also succeed for Explorer/detached launches. + return; + } + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_nanos(); + let ready_file = ReadyFile( + env::temp_dir().join(format!("pebrel-ctrl-ready-{}-{stamp}", std::process::id())), + ); + let ready = &ready_file.0; + let mut target = TestChild( + Command::new(env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env("PEBREL_CONSOLE_TEST_ROLE", "target") + .env("PEBREL_CONSOLE_TEST_READY", &ready) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(3); + while !ready.exists() { + assert!(Instant::now() < deadline, "target did not become ready"); + thread::sleep(Duration::from_millis(20)); + } + // Shield only the disposable test driver AFTER the child inherited the + // production startup state. This cannot change the existing child's state. + assert_ne!(unsafe { SetConsoleCtrlHandler(None, 1) }, 0); + let started = Instant::now(); + assert_ne!(unsafe { GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) }, 0); + let status = wait(&mut target); + if scenario == "unchanged" { + // Negative control: the inherited ignore flag must suppress Ctrl+C. + // Without this check a reset in the child could make both cases pass. + assert!(status.success(), "unfixed child unexpectedly handled Ctrl+C"); + assert!(started.elapsed() >= Duration::from_secs(4)); + } else { + assert_eq!(status.code().map(|code| code as u32), Some(0xc000013a)); + assert!(started.elapsed() < Duration::from_secs(3), "child did not interrupt promptly"); + } +} diff --git a/nebula_app/src/window_context/agent_activity.rs b/nebula_app/src/window_context/agent_activity.rs index c89eedd..6462495 100644 --- a/nebula_app/src/window_context/agent_activity.rs +++ b/nebula_app/src/window_context/agent_activity.rs @@ -73,7 +73,18 @@ impl WindowContext { project_status(state); } let status = state.agent_activity.status(); - if matches!(status, AgentStatus::Done | AgentStatus::Blocked) { + let message = event + .attention + .as_ref() + .map(|context| context.summary_for_pane(pane_id)) + .or_else(|| event.message.clone()); + if matches!(event.kind, AiHookKind::TurnDone | AiHookKind::NeedsAttention) + && let Some(notification) = crate::notify::Notification::from_ai_hook( + event, + message, + status == AgentStatus::Blocked, + ) + { let mut background_tab = false; for (i, tab) in self.tabs.iter_mut().enumerate() { let mut ids = Vec::new(); @@ -87,18 +98,7 @@ impl WindowContext { } } if !self.display.window.has_focus() || background_tab { - let message = event - .attention - .as_ref() - .map(|context| context.summary_for_pane(pane_id)) - .or_else(|| event.message.clone()); - if let Some(notification) = crate::notify::Notification::from_ai_hook( - event, - message, - status == AgentStatus::Blocked, - ) { - crate::notify::deliver(&self.display.window, ¬ification, Some(pane_id)); - } + crate::notify::deliver(&self.display.window, ¬ification, Some(pane_id)); } } self.dirty = true; diff --git a/nebula_app/tests/windows_console_startup.rs b/nebula_app/tests/windows_console_startup.rs new file mode 100644 index 0000000..055c5e5 --- /dev/null +++ b/nebula_app/tests/windows_console_startup.rs @@ -0,0 +1,11 @@ +//! A console-subsystem target inherits the parent's Ctrl+C state automatically. +//! A GUI target calling AttachConsole would reset that state and hide the bug. +#![cfg(windows)] +#![windows_subsystem = "console"] + +#[path = "../src/platform/startup/console.rs"] +mod console; +use console::prepare_console_for_gui; + +#[path = "../src/platform/startup/console_tests.rs"] +mod console_tests; diff --git a/nebula_settings/src/custom_theme.rs b/nebula_settings/src/custom_theme.rs index 3dcd9b1..68850a7 100644 --- a/nebula_settings/src/custom_theme.rs +++ b/nebula_settings/src/custom_theme.rs @@ -60,13 +60,7 @@ impl ThemeDefinition { } }); - let mut indexed = IndexedPalette::from_ansi(exact.ansi); - // These slots are part of Nebula's existing prompt contract. Keeping - // them in the snapshot means a custom theme recolors prompt chips and - // the rest of the terminal from one value object. - for (offset, color) in term.powerline.into_iter().enumerate() { - indexed.colors[16 + offset] = color; - } + let indexed = IndexedPalette::from_ansi(exact.ansi); Self { name: base.prompt_name().to_owned(), @@ -724,6 +718,33 @@ fn relative_luminance(color: Rgb8) -> f64 { mod tests { use super::*; + #[test] + fn builtin_snapshots_preserve_standard_extended_colors() { + // 提示符配色不能覆盖应用使用的 xterm 色立方。 + for name in ThemeName::BUILTIN { + let snapshot = ThemeDefinition::from_builtin(name); + for (index, expected) in [ + (16, [0, 0, 0]), + (17, [0, 0, 95]), + (18, [0, 0, 135]), + (19, [0, 0, 175]), + (20, [0, 0, 215]), + (21, [0, 0, 255]), + (22, [0, 95, 0]), + (23, [0, 95, 95]), + (231, [255, 255, 255]), + (255, [238, 238, 238]), + ] { + assert_eq!( + snapshot.terminal.palette.colors[index], + expected, + "{} index {index}", + name.prompt_name() + ); + } + } + } + #[test] fn builtin_snapshot_keeps_declared_exact_colors() { let nord = ThemeDefinition::from_builtin(ThemeName::Nord); @@ -733,7 +754,7 @@ mod tests { assert_eq!(nord.terminal.cursor, exact.cursor); assert_eq!(nord.terminal.selection_background, exact.selection_background); assert_eq!(nord.terminal.palette.ansi_colors(), exact.ansi); - assert_eq!(nord.terminal.palette.colors[16], ThemeName::Nord.term_theme().powerline[0]); + assert_eq!(nord.terminal.palette.colors[16], [0, 0, 0]); assert_eq!(nord.ui.selection[3], 255); assert_eq!(nord.ui.line[3], 255); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 4925581..2df4bc6 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -517,13 +517,10 @@ pub struct TermTheme { /// 只有自带完整终端 palette 的主题使用;现有七个主题保持 `None`,继续 /// 走原来的背景/浅色替换合同。 pub exact: Option, - /// Powerline 提示符段色,发布到 256 色表 [`POWERLINE_SLOT0`]`..+8`。 + /// 旧提示符段色参考;不再发布到终端扩展色表。 pub powerline: [Rgb8; 8], } -/// Powerline 槽位起点(索引色 16..=23)。 -pub const POWERLINE_SLOT0: u8 = 16; - /// 浅色主题的替换前景(#24292f)。 pub const LIGHT_FOREGROUND: Rgb8 = [36, 41, 47]; diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 4abca5b..0cff468 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -227,6 +227,7 @@ impl StreamProcessor { OscEvent::PromptMark => { terminal.nebula_add_prompt_mark(); }, + OscEvent::PromptInput => terminal.nebula_mark_prompt_input(), OscEvent::InlineImage { data, width, height } => { let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| { (f32::from(ws.cell_width), f32::from(ws.cell_height)) @@ -948,6 +949,41 @@ mod tests { } } + #[test] + fn semantic_input_boundary_survives_chunking_and_scrolling() { + use crate::index::{Column, Line, Point}; + + let bytes = b"\x1b]133;A\x07[first]\r\n>\x1b]133;B\x07pause"; + for split in 0..=bytes.len() { + let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener); + let mut stream = StreamProcessor::default(); + stream.feed(&mut terminal, &VoidListener, &bytes[..split]); + stream.feed(&mut terminal, &VoidListener, &bytes[split..]); + assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(1), Column(1)))); + stream.feed(&mut terminal, &VoidListener, b"\r\n"); + assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(0), Column(1)))); + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;C\x07"); + assert_eq!(terminal.nebula_prompt_input_point(), None); + } + } + + #[test] + fn input_boundaries_require_a_prompt_and_do_not_survive_reflow_or_reset() { + let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener); + let mut stream = StreamProcessor::default(); + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;B\x07"); + assert_eq!(terminal.nebula_prompt_input_point(), None); + for ending in [b"\x1bc".as_slice(), b"\x1b]133;D;0\x07", b"\x1b]133;A\x07"] { + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07"); + assert!(terminal.nebula_prompt_input_point().is_some()); + stream.feed(&mut terminal, &VoidListener, ending); + assert_eq!(terminal.nebula_prompt_input_point(), None); + } + stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07"); + terminal.resize(TermSize::new(10, 2)); + assert_eq!(terminal.nebula_prompt_input_point(), None); + } + #[test] fn shell_semantic_events_track_the_active_prompt() { let size = TermSize::new(80, 24); diff --git a/nebula_terminal/src/osc_cwd.rs b/nebula_terminal/src/osc_cwd.rs index b089f2c..e41cead 100644 --- a/nebula_terminal/src/osc_cwd.rs +++ b/nebula_terminal/src/osc_cwd.rs @@ -43,6 +43,8 @@ pub enum OscEvent { Cwd(String), /// OSC 133;A — the shell is about to draw a prompt (semantic zone start). PromptMark, + /// OSC 133;B — the prompt ended; subsequent cells contain shell input. + PromptInput, /// OSC 133;C — a command started executing. CommandStart, /// OSC 133;D — the command finished. `exit_code` is the first parameter @@ -207,12 +209,14 @@ impl CwdSniffer { } if let Some(rest) = self.payload.strip_prefix(b"133;") { // Semantic prompt zones (FinalTerm). `A` may carry kitty-style - // `;key=value` params — accept those too. B (command start being - // typed) has no consumer yet. + // `;key=value` params — accept those too. let phased = |ch: u8| rest.first() == Some(&ch) && (rest.len() == 1 || rest[1] == b';'); if phased(b'A') { return Some(OscEvent::PromptMark); } + if phased(b'B') { + return Some(OscEvent::PromptInput); + } if phased(b'C') { return Some(OscEvent::CommandStart); } @@ -557,7 +561,7 @@ mod tests { #[test] fn osc133_other_phases_ignored() { // B (command line start) has no consumer; C/D became events. - assert!(events(b"\x1b]133;B\x07").is_empty()); + assert_eq!(events(b"\x1b]133;B\x07"), vec![(8, OscEvent::PromptInput)]); assert_eq!(events(b"\x1b]133;C\x07"), vec![(8, OscEvent::CommandStart)]); assert_eq!( events(b"\x1b]133;D;0\x07"), diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index 7f8d51d..9517799 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -33,6 +33,7 @@ mod damage; mod keyboard; #[cfg(test)] mod keyboard_contract_tests; +mod prompt; mod renderable; pub mod search; @@ -231,6 +232,8 @@ pub struct Term { /// Whether OSC 133 currently identifies this pane as accepting shell input. nebula_prompt_active: bool, + /// OSC 133;B input start in absolute grid coordinates. + nebula_prompt_input: Option<(usize, Column)>, /// One-shot suppression of the next primary-DA answer: the side-loaded /// ConPTY host's bring-up DA1 query was already answered by the response @@ -356,84 +359,6 @@ impl Term { } } - /// The cursor row in the grid's absolute line numbering (see - /// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays - /// (prompt marks, inline images) can anchor to it. - pub fn nebula_cursor_abs_line(&self) -> usize { - self.grid.scrolled_out() - + self.grid.history_size() - + self.grid.cursor.point.line.0.max(0) as usize - } - - /// Record a shell prompt row (OSC 133;A) at the current cursor line. - /// - /// Called by the PTY reader between `parser.advance` slices, so the cursor - /// sits exactly on the fresh prompt row. Marks only make sense on the - /// primary screen — the alternate screen has no scrollback to jump. - pub fn nebula_add_prompt_mark(&mut self) { - if self.mode.contains(TermMode::ALT_SCREEN) { - return; - } - let abs = self.nebula_cursor_abs_line(); - - // A screen redraw (clear, resize) can re-emit a mark for the same or - // an earlier row; drop those so the deque stays strictly increasing. - while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) { - self.nebula_prompt_marks.pop_back(); - } - // Prune marks whose rows have scrolled out of history entirely. - let floor = self.grid.scrolled_out(); - while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) { - self.nebula_prompt_marks.pop_front(); - } - - self.nebula_prompt_marks.push_back(abs); - self.nebula_prompt_active = true; - } - - pub fn nebula_end_prompt(&mut self) { - self.nebula_prompt_active = false; - } - - pub fn nebula_prompt_active(&self) -> bool { - self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN) - } - - /// Scroll the viewport to the previous (`up`) or next shell prompt mark. - /// - /// Returns whether the viewport moved, so callers know to redraw. - pub fn nebula_prompt_jump(&mut self, up: bool) -> bool - where - T: EventListener, - { - if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() { - return false; - } - - let scrolled_out = self.grid.scrolled_out(); - let history = self.grid.history_size(); - // Absolute line currently shown at the top of the viewport. - let top_abs = scrolled_out + history - self.grid.display_offset(); - - let target = if up { - self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs) - } else { - self.nebula_prompt_marks.iter().find(|&&m| m > top_abs) - }; - let Some(&mark) = target else { return false }; - - // Put the mark's row at the viewport top: offset = history - relative - // row. Marks on the visible screen clamp to 0 (bottom), long-gone - // marks clamp to the scrollback top. - let offset = (scrolled_out + history).saturating_sub(mark).min(history); - let delta = offset as i32 - self.grid.display_offset() as i32; - if delta == 0 { - return false; - } - self.scroll_display(Scroll::Delta(delta)); - true - } - pub fn new(config: Config, dimensions: &D, event_proxy: T) -> Term { let num_cols = dimensions.columns(); let num_lines = dimensions.screen_lines(); @@ -472,6 +397,7 @@ impl Term { keyboard_mode_stack: Default::default(), nebula_prompt_marks: Default::default(), nebula_prompt_active: false, + nebula_prompt_input: None, active_charset: Default::default(), vi_mode_cursor: Default::default(), cursor_style: Default::default(), @@ -841,6 +767,7 @@ impl Term { // Reflow rewraps history rows, so absolute prompt-mark lines no longer // match; drop them rather than jump to shifted positions. self.nebula_prompt_marks.clear(); + self.nebula_prompt_input = None; // Invalidate selection and tabs only when necessary. if old_cols != num_cols { @@ -2069,6 +1996,7 @@ impl Handler for Term { self.grid.reset(); self.inactive_grid.reset(); self.nebula_prompt_marks.clear(); + self.nebula_prompt_input = None; self.nebula_prompt_active = false; self.scroll_region = Line(0)..Line(self.screen_lines() as i32); self.tabs = TabStops::new(self.columns()); diff --git a/nebula_terminal/src/term/prompt.rs b/nebula_terminal/src/term/prompt.rs new file mode 100644 index 0000000..ac7cc16 --- /dev/null +++ b/nebula_terminal/src/term/prompt.rs @@ -0,0 +1,126 @@ +//! Semantic prompt marks and input boundaries owned by the terminal grid. +use super::{Term, TermMode}; +use crate::event::EventListener; +use crate::grid::{Dimensions, Scroll}; +use crate::index::{Column, Line, Point}; + +impl Term { + /// The cursor row in the grid's absolute line numbering (see + /// [`Grid::scrolled_out`]): stable across scrollback growth, so overlays + /// (prompt marks, inline images) can anchor to it. + pub fn nebula_cursor_abs_line(&self) -> usize { + self.grid.scrolled_out() + + self.grid.history_size() + + self.grid.cursor.point.line.0.max(0) as usize + } + + /// Record a shell prompt row (OSC 133;A) at the current cursor line. + /// + /// Called by the PTY reader between `parser.advance` slices, so the cursor + /// sits exactly on the fresh prompt row. Marks only make sense on the + /// primary screen — the alternate screen has no scrollback to jump. + pub fn nebula_add_prompt_mark(&mut self) { + if self.mode.contains(TermMode::ALT_SCREEN) { + return; + } + let abs = self.nebula_cursor_abs_line(); + + // A screen redraw (clear, resize) can re-emit a mark for the same or + // an earlier row; drop those so the deque stays strictly increasing. + while self.nebula_prompt_marks.back().is_some_and(|&m| m >= abs) { + self.nebula_prompt_marks.pop_back(); + } + // Prune marks whose rows have scrolled out of history entirely. + let floor = self.grid.scrolled_out(); + while self.nebula_prompt_marks.front().is_some_and(|&m| m < floor) { + self.nebula_prompt_marks.pop_front(); + } + + self.nebula_prompt_marks.push_back(abs); + self.nebula_prompt_active = true; + self.nebula_prompt_input = None; + } + + pub fn nebula_end_prompt(&mut self) { + self.nebula_prompt_active = false; + self.nebula_prompt_input = None; + } + + pub fn nebula_prompt_active(&self) -> bool { + self.nebula_prompt_active && !self.mode.contains(TermMode::ALT_SCREEN) + } + + /// Capture OSC 133;B between parser slices, before input is echoed. + pub fn nebula_mark_prompt_input(&mut self) { + if self.nebula_prompt_active() { + let mut line = self.nebula_cursor_abs_line(); + let mut column = self.grid.cursor.point.column; + if self.grid.cursor.input_needs_wrap { + line += 1; + column = Column(0); + } + self.nebula_prompt_input = Some((line, column)); + } + } + + /// Resolve the boundary after scrollback growth; reflow/reset discard it. + /// A column equal to the grid width denotes an empty pending-wrap boundary. + pub fn nebula_prompt_input_point(&self) -> Option { + if !self.nebula_prompt_active() { + return None; + } + let (line, column) = self.nebula_prompt_input?; + if line < self.grid.scrolled_out() || column.0 >= self.columns() { + return None; + } + // Until input is echoed, a full-width prompt's next insertion point is + // still on the filled row. This also covers the bottom row, where the + // eventual wrap will scroll the grid before displaying the input. + if self.grid.cursor.input_needs_wrap + && line == self.nebula_cursor_abs_line() + 1 + && column == Column(0) + { + return Some(Point::new(self.grid.cursor.point.line, Column(self.columns()))); + } + let relative = + line as i64 - self.grid.scrolled_out() as i64 - self.grid.history_size() as i64; + let point = Point::new(Line(i32::try_from(relative).ok()?), column); + (point.line >= self.grid.topmost_line() && point.line <= self.grid.bottommost_line()) + .then_some(point) + } + + /// Scroll the viewport to the previous (`up`) or next shell prompt mark. + /// + /// Returns whether the viewport moved, so callers know to redraw. + pub fn nebula_prompt_jump(&mut self, up: bool) -> bool + where + T: EventListener, + { + if self.mode.contains(TermMode::ALT_SCREEN) || self.nebula_prompt_marks.is_empty() { + return false; + } + + let scrolled_out = self.grid.scrolled_out(); + let history = self.grid.history_size(); + // Absolute line currently shown at the top of the viewport. + let top_abs = scrolled_out + history - self.grid.display_offset(); + + let target = if up { + self.nebula_prompt_marks.iter().rev().find(|&&m| m < top_abs) + } else { + self.nebula_prompt_marks.iter().find(|&&m| m > top_abs) + }; + let Some(&mark) = target else { return false }; + + // Put the mark's row at the viewport top: offset = history - relative + // row. Marks on the visible screen clamp to 0 (bottom), long-gone + // marks clamp to the scrollback top. + let offset = (scrolled_out + history).saturating_sub(mark).min(history); + let delta = offset as i32 - self.grid.display_offset() as i32; + if delta == 0 { + return false; + } + self.scroll_display(Scroll::Delta(delta)); + true + } +} diff --git a/nebula_terminal/src/tty/windows/cmd_prompt.rs b/nebula_terminal/src/tty/windows/cmd_prompt.rs new file mode 100644 index 0000000..8d40727 --- /dev/null +++ b/nebula_terminal/src/tty/windows/cmd_prompt.rs @@ -0,0 +1,91 @@ +use crate::tty::Options; + +const MARK: &str = "\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07"; +const START: &str = "\x1b]133;A\x07"; +const END: &str = "\x1b]133;B\x07\x1b]1337;SetUserVar=pebrel_cmd_prompt=MQ==\x07"; + +pub(super) fn prepare(config: &Options) -> Options { + let mut prepared = config.clone(); + let Some(shell) = &config.shell else { return prepared }; + let name = shell.program().rsplit(['/', '\\']).next().unwrap_or_default(); + if !name.eq_ignore_ascii_case("cmd") && !name.eq_ignore_ascii_case("cmd.exe") { + return prepared; + } + + let prompt = config + .env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PROMPT")) + .map(|(_, value)| value.clone()) + .or_else(|| (!config.env_is_complete).then(|| std::env::var("PROMPT").ok()).flatten()) + .unwrap_or_else(|| "$P$G".into()); + prepared.env.retain(|key, _| !key.eq_ignore_ascii_case("PROMPT")); + // 只改子进程的环境副本;嵌套启动或重复准备不能不断叠加标记。 + let prompt = if prompt.starts_with(START) && prompt.ends_with(END) { + prompt + } else { + let prompt = prompt.strip_prefix(MARK).unwrap_or(&prompt); + format!("{START}{prompt}{END}") + }; + prepared.env.insert("PROMPT".into(), prompt); + prepared +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tty::Shell; + + fn cmd() -> Options { + Options { + shell: Some(Shell::new("C:\\Windows\\System32\\CMD.EXE".into(), vec!["/d".into()])), + env_is_complete: true, + ..Options::default() + } + } + + #[test] + fn native_prompt_preserves_custom_text_without_mutating_caller() { + let mut config = cmd(); + config.env.insert("Prompt".into(), "[$P]$_$G".into()); + let prepared = prepare(&config); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}[$P]$_$G{END}")); + assert!(!prepared.env.contains_key("Prompt")); + assert_eq!(config.env.get("Prompt").unwrap(), "[$P]$_$G"); + } + + #[test] + fn native_prompt_defaults_and_is_idempotent() { + let prepared = prepare(&cmd()); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}$P$G{END}")); + assert_eq!(prepare(&prepared), prepared); + } + + #[test] + fn native_prompt_does_not_instrument_other_shells() { + for name in ["powershell.exe", "pwsh", "wsl.exe", "mycmd.exe"] { + let mut config = cmd(); + config.shell = Some(Shell::new(name.into(), vec![])); + assert_eq!(prepare(&config), config); + } + let mut config = cmd(); + config.shell = None; + assert_eq!(prepare(&config), config); + } + + #[test] + fn native_prompt_preserves_explicit_empty_prompt() { + let mut config = cmd(); + config.env.insert("PROMPT".into(), String::new()); + assert_eq!(prepare(&config).env.get("PROMPT").unwrap(), &format!("{START}{END}")); + } + + #[test] + fn native_prompt_upgrades_a_prefix_only_marker_without_duplicating_it() { + let mut config = cmd(); + config.env.insert("PROMPT".into(), format!("{MARK}[$P]$S")); + let prepared = prepare(&config); + assert_eq!(prepared.env.get("PROMPT").unwrap(), &format!("{START}[$P]$S{END}")); + assert_eq!(prepare(&prepared), prepared); + } +} diff --git a/nebula_terminal/src/tty/windows/conpty.rs b/nebula_terminal/src/tty/windows/conpty.rs index 90b350e..12357b9 100644 --- a/nebula_terminal/src/tty/windows/conpty.rs +++ b/nebula_terminal/src/tty/windows/conpty.rs @@ -288,7 +288,8 @@ fn spawn_shell(config: &Options, conpty: &Conpty) -> Result { let mut cmdline = win32_string(&cmdline(config)); let cwd = config.working_directory.as_ref().map(win32_string); let mut creation_flags = EXTENDED_STARTUPINFO_PRESENT; - let custom_env_block = convert_custom_env(&config.env, config.env_is_complete); + let child_config = super::cmd_prompt::prepare(config); + let custom_env_block = convert_custom_env(&child_config.env, child_config.env_is_complete); let custom_env_block_pointer = match &custom_env_block { Some(custom_env_block) => { creation_flags |= CREATE_UNICODE_ENVIRONMENT; diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index d39a81b..3fc4784 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -14,6 +14,7 @@ use crate::tty::{ChildEvent, EventedPty, EventedReadWrite, Options, Shell}; mod blocking; mod child; +mod cmd_prompt; mod conpty; mod environment; @@ -496,11 +497,8 @@ function global:prompt { if ($global:NebulaPromptCount -gt 1) { $leadingNewline = "`n" } } - # Segment colors come from the terminal's 256-color palette, slots - # 16..=23 (icon bg/fg, path bg/fg, branch bg/fg, time bg/fg), published - # per-theme by Nebula (theme.rs::apply_term_colors). Indexed colors mean a - # theme switch recolors every prompt already in scrollback — truecolor - # (the old scheme) is frozen the moment it prints. No theme file, no polling. + # 使用主题的 ANSI-16 索引,历史提示符仍随主题换色;不占用应用所需的 + # xterm 扩展色槽。路径和时间用默认前景/背景,避免浅色主题的 ANSI 灰阶低对比。 if ($userPrompt) { # 视觉全部来自用户提示符;Nebula 只补协议:133;A 标出提示符起点,标题 @@ -508,25 +506,25 @@ function global:prompt { $output = "$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$userPrompt" } elseif (-not (Get-NebulaBoolSetting 'powerline' $true)) { $branchText = if ($branch) { " ($branch)" } else { "" } - $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;19m$loc$branchText $e[35m$NebPromptArrow $reset" + $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$e[38;5;6m$loc$branchText $e[35m$NebPromptArrow $reset" } else { $segs = New-Object System.Collections.ArrayList - [void]$segs.Add(@{ bg=16; fg=17; t=" $NebFolderIcon " }) - [void]$segs.Add(@{ bg=18; fg=19; t=" $loc " }) - if ($branch) { [void]$segs.Add(@{ bg=20; fg=21; t=" $NebGitBranchIcon $branch " }) } - [void]$segs.Add(@{ bg=22; fg=23; t=" $NebClockIcon $time " }) + [void]$segs.Add(@{ bg=4; fg='38;5;0'; t=" $NebFolderIcon " }) + [void]$segs.Add(@{ bg=$null; fg='39'; t=" $loc " }) + if ($branch) { [void]$segs.Add(@{ bg=$null; fg='38;5;6'; t=" $NebGitBranchIcon $branch " }) } + [void]$segs.Add(@{ bg=$null; fg='39'; t=" $NebClockIcon $time " }) # 49 = default background on both caps: the cap cell's square corners # always match the real terminal bg (any theme / wallpaper). $out = "$reset$e[38;5;$($segs[0].bg)m$e[49m$NebLeftRound$reset" for ($i = 0; $i -lt $segs.Count; $i++) { $s = $segs[$i] - $out += "$e[48;5;$($s.bg)m$e[38;5;$($s.fg)m$($s.t)" - if ($i -lt $segs.Count - 1) { - $nb = $segs[$i + 1].bg - $out += "$reset$e[38;5;$($s.bg)m$e[48;5;${nb}m$NebArrow$reset" + $bg = if ($null -eq $s.bg) { '49' } else { "48;5;$($s.bg)" } + $out += "$e[${bg}m$e[$($s.fg)m$($s.t)" + if ($null -ne $s.bg) { + $out += "$reset$e[38;5;$($s.bg)m$e[49m$NebArrow$reset" } else { - $out += "$reset$e[38;5;$($s.bg)m$e[49m$NebRightRound$reset" + $out += $reset } } $output = "$leadingNewline$e]133;A$([char]7)$e]2;NEBULA|$cwd|$branch$([char]7)$out`n`n$e[35m$NebPromptArrow $reset" @@ -1357,6 +1355,39 @@ $env:NEBULA_CONFIG_DIR = $env:PEBREL_CONFIG_DIR function global:Set-PSReadLineOption { } "#; + #[test] + fn powershell_prompt_uses_only_theme_ansi_slots() { + run_powershell_integration_case( + PS_PRELUDE, + r#" +function global:git { $global:LASTEXITCODE = 0; 'test-branch' } +foreach ($powerline in @($true, $false)) { + $global:TestPowerline = $powerline + function global:Get-NebulaBoolSetting { param($key, $default); $global:TestPowerline } + $rendered = prompt + $colors = [regex]::Matches($rendered, '\x1b\[(?:38|48);5;(\d+)m') + if ($colors.Count -eq 0) { throw 'No indexed prompt colors' } + foreach ($color in $colors) { + if ([int]$color.Groups[1].Value -gt 15) { + throw "Prompt occupied extended color $($color.Groups[1].Value)" + } + } + if ($powerline) { + $e = [char]27 + if (-not $rendered.Contains("$e[49m$e[39m $((Get-Location).Path) ")) { + throw 'Path must use theme default foreground and background' + } + if (-not $rendered.Contains("$e[49m$e[39m $([char]0xf017)")) { + throw 'Clock must use theme default foreground and background' + } + } + if ($rendered -notlike '*test-branch*') { throw 'Missing branch' } + if ($rendered -notlike "*$([char]27)]133;A*") { throw 'Missing prompt boundary' } +} +"#, + ); + } + #[test] fn powershell_integration_preserves_the_users_prediction_source() { run_powershell_integration_case( diff --git a/scripts/tests/pi-notification-bridge.test.mjs b/scripts/tests/pi-notification-bridge.test.mjs index 0178e47..58ad17f 100644 --- a/scripts/tests/pi-notification-bridge.test.mjs +++ b/scripts/tests/pi-notification-bridge.test.mjs @@ -13,16 +13,18 @@ const code = stripTypeScriptTypes(embedded) .replace('import { spawn } from "node:child_process";', "") .replace('import { openSync, readSync, closeSync } from "node:fs";', "") .replace('import { randomUUID } from "node:crypto";', "") - .replace("export default function", "function register"); + .replace('await import(name)', 'await loadPiModule(name)') + .replace("export default async function", "async function register"); -function bridge() { +async function bridge() { const handlers = new Map(); const sent = []; const ctx = { cwd: "/project", sessionManager: { getSessionId: () => "session-1" } }; - runInNewContext(`${code}\nregister(pi);`, { + await runInNewContext(`${code}\nregister(pi);`, { pi: { on: (name, handler) => handlers.set(name, handler) }, process: { pid: 42, env: { PEBREL_HOOK_EXE: "fake-helper" } }, openSync, readSync, closeSync, randomUUID, Buffer, + loadPiModule: async () => ({ VERSION: "0.85.1" }), spawn: (_exe, args) => { sent.push(JSON.parse(args[1])); return { unref() {} }; @@ -31,70 +33,82 @@ function bridge() { return { sent, emit: (name, event = {}) => handlers.get(name)(event, ctx), + settle: () => handlers.get("agent_settled")({}, ctx), }; } const assistant = (stopReason, errorMessage) => ({ role: "assistant", stopReason, errorMessage }); test("API overload preserves the failure instead of sending a bare completion", async () => { - const b = bridge(); + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [assistant("error", "Our servers are currently overloaded.")] }); + assert.equal(b.sent.length, 1, "an attempt is not a settled turn"); + await b.settle(); assert.equal(b.sent.length, 2); assert.equal(b.sent[1].kind, "done"); assert.equal(b.sent[1].stop_reason, "error"); - assert.equal(b.sent[1].message, "Our servers are currently overloaded."); + assert.equal(b.sent[1].message, undefined); assert.equal(b.sent[1].session_id, "session-1"); }); test("a successful retry overrides an earlier error in the same turn", async () => { - const b = bridge(); + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [assistant("error", "overload"), assistant("stop")] }); + await b.settle(); assert.equal(b.sent[1].stop_reason, "stop"); assert.equal(b.sent[1].message, undefined); }); test("a failed tool is not an assistant request failure", async () => { - const b = bridge(); + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [{ role: "toolResult", isError: true }, assistant("stop")] }); + await b.settle(); assert.equal(b.sent[1].stop_reason, "stop"); }); test("cancellation and length exhaustion retain their distinct outcomes", async () => { for (const reason of ["aborted", "length", "toolUse"]) { - const b = bridge(); + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [assistant(reason)] }); + await b.settle(); assert.equal(b.sent[1].stop_reason, reason); } }); test("missing assistant metadata never fabricates success", async () => { - const b = bridge(); + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [] }); - assert.equal(b.sent[1].stop_reason, undefined); + await b.settle(); + assert.equal(b.sent[1].stop_reason, "unknown"); }); -test("duplicate agent_end emits only once until a new turn starts", async () => { - const b = bridge(); +test("duplicate agent_end and agent_settled emit only once until a new turn starts", async () => { + const b = await bridge(); const event = { messages: [assistant("error", "overload")] }; await b.emit("agent_start"); await b.emit("agent_end", event); await b.emit("agent_end", event); + await b.settle(); + await b.settle(); assert.equal(b.sent.length, 2); await b.emit("agent_start"); await b.emit("agent_end", event); + await b.settle(); assert.equal(b.sent.length, 4); assert.notEqual(b.sent[1].event_id, b.sent[3].event_id); assert.ok(BigInt(b.sent[3].bridge_sequence) > BigInt(b.sent[1].bridge_sequence)); }); -test("oversized provider errors are bounded before spawning the helper", async () => { - const b = bridge(); +test("oversized provider errors are omitted before spawning the helper", async () => { + const b = await bridge(); await b.emit("agent_start"); await b.emit("agent_end", { messages: [assistant("error", "x".repeat(10_000))] }); - assert.equal(b.sent[1].message.length, 4000); + await b.settle(); + assert.equal(b.sent[1].stop_reason, "error"); + assert.equal(b.sent[1].message, undefined); }); diff --git a/scripts/tests/test_pi_bridge.mjs b/scripts/tests/test_pi_bridge.mjs index 487c89f..9409116 100644 --- a/scripts/tests/test_pi_bridge.mjs +++ b/scripts/tests/test_pi_bridge.mjs @@ -3,19 +3,27 @@ import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { stripTypeScriptTypes } from 'node:module'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { test, after } from 'node:test'; const root = new URL('../../', import.meta.url); -const embedded = readFileSync(new URL('nebula_app/res/hooks/pi.ts', root), 'utf8'); +const bridgeSource = new URL('nebula_app/src/ai_hook/bridges.rs', root); +const bridgePath = readFileSync(bridgeSource, 'utf8').match(/PI_EXTENSION_TS: &str = include_str!\("([^"]+)"\)/)?.[1]; +assert.ok(bridgePath, 'test must execute the production PI_EXTENSION_TS asset'); +const embedded = readFileSync(new URL(bridgePath, bridgeSource), 'utf8'); const events = []; globalThis.__pebrelBridgeSpawn = (_hook, args) => { events.push(JSON.parse(args[1])); return { unref() {} }; }; +globalThis.__pebrelPiVersion = '0.85.1'; +globalThis.__pebrelLoadPiModule = async () => ({ VERSION: globalThis.__pebrelPiVersion }); const executable = stripTypeScriptTypes(embedded.replace( 'import { spawn } from "node:child_process";', 'const spawn = globalThis.__pebrelBridgeSpawn;', +).replace( + 'await import(name)', + 'await globalThis.__pebrelLoadPiModule(name)', ) + '\nexport { sessionFor };'); const bridge = await import('data:text/javascript;base64,' + Buffer.from(executable).toString('base64')); mkdirSync(new URL('tmp/', root), { recursive: true }); @@ -25,6 +33,36 @@ const context = (id, file) => ({ sessionManager: { getSessionId: () => id, getSessionFile: () => file }, }); +test('the unchanged runtime imports load with old, new, absent and malformed SDK versions', async () => { + const cases = [ + ['@mariozechner/pi-coding-agent', '0.60.0', false], + ['@earendil-works/pi-coding-agent', '0.80.3', false], + ['@earendil-works/pi-coding-agent', '0.80.4', true], + ['@earendil-works/pi-coding-agent', '0.85.1', true], + ['@earendil-works/pi-coding-agent', 'invalid', false], + [null, null, false], + ]; + for (const [name, version, settled] of cases) { + const sandbox = mkdtempSync(join(directory, 'loader-')); + const asset = join(sandbox, 'bridge.mjs'); + // Only erase TypeScript; keep every production import and the factory intact. + writeFileSync(asset, stripTypeScriptTypes(embedded)); + if (name) { + const sdk = join(sandbox, 'node_modules', name); + mkdirSync(sdk, { recursive: true }); + writeFileSync(join(sdk, 'package.json'), JSON.stringify({ type: 'module', exports: './index.js' })); + writeFileSync(join(sdk, 'index.js'), `export const VERSION = ${JSON.stringify(version)};`); + } + const loaded = await import(pathToFileURL(asset)); + const callbacks = new Map(); + await loaded.default({ on: (name, callback) => callbacks.set(name, callback) }); + assert.equal(callbacks.has('agent_settled'), settled, `${name} ${version}`); + for (const event of ['agent_start', 'agent_end', 'session_start', 'session_shutdown']) { + assert.equal(typeof callbacks.get(event), 'function'); + } + } +}); + test('native header replaces filename guessing; process badges are never recovery IDs', () => { const file = join(directory, 'timestamp_native-id.jsonl'); writeFileSync(file, JSON.stringify({ type: 'session', id: 'native-id' }) + '\n'); @@ -48,16 +86,141 @@ test('stale or corrupt file cannot replace direct native identity', () => { } }); +test('a retryable failed attempt must not report successful completion', async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + await bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = { ...context('retrying'), isIdle: () => false }; + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [ + { role: 'assistant', stopReason: 'error', errorMessage: 'synthetic retryable failure' }, + ] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt']); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + +for (const reason of ['stop', 'error', 'aborted', 'future', 'length', 'toolUse']) { + test(`settled ${reason} reports its stop_reason once, without leaking error text`, async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + await bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = context('settled'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [ + { role: 'assistant', stopReason: reason, errorMessage: 'SECRET-provider-request' }, + ] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt']); + assert.equal(typeof callbacks.get('agent_settled'), 'function'); + await callbacks.get('agent_settled')({}, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt', 'done']); + assert.equal(events.at(-1).stop_reason, reason); + assert.equal('outcome' in events.at(-1), false); + assert.ok(!JSON.stringify(events.slice(firstEvent)).includes('SECRET')); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } + }); +} + +test('retry success replaces the failed attempt; switching sessions drops unfinished results', async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + await bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = context('retry-success'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'error' }] }, ctx); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.equal(events.slice(firstEvent).filter(event => event.kind === 'done').length, 1); + assert.equal(events.at(-1).stop_reason, 'stop'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'error' }] }, ctx); + await callbacks.get('session_start')({}, context('new-session')); + await callbacks.get('agent_settled')({}, context('new-session')); + assert.equal(events.at(-1).kind, 'session-start'); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + +test('pre-settled Pi versions retain agent_end fallback without registering unsupported events', async () => { + const previousVersion = globalThis.__pebrelPiVersion; + globalThis.__pebrelPiVersion = '0.80.3'; + const legacy = bridge; + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + await legacy.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + assert.equal(callbacks.has('agent_settled'), false); + const ctx = context('legacy'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt', 'done']); + assert.equal(events.at(-1).stop_reason, 'stop'); + } finally { + globalThis.__pebrelPiVersion = previousVersion; + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + +test('shutdown discards pending completion and a new turn cannot reuse an old result', async () => { + const previous = process.env.PEBREL_HOOK_EXE; + process.env.PEBREL_HOOK_EXE = 'test-hook'; + const firstEvent = events.length; + try { + const callbacks = new Map(); + await bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + const ctx = context('shutdown'); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'error' }] }, ctx); + await callbacks.get('session_shutdown')({}, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.deepEqual(events.slice(firstEvent).map(event => event.kind), ['prompt', 'session-end']); + await callbacks.get('session_start')({}, ctx); + await callbacks.get('agent_start')({}, ctx); + await callbacks.get('agent_settled')({}, ctx); + assert.equal(events.at(-1).stop_reason, 'unknown'); + } finally { + events.splice(firstEvent); + if (previous === undefined) delete process.env.PEBREL_HOOK_EXE; + else process.env.PEBREL_HOOK_EXE = previous; + } +}); + test('session switch retains one process ordering stream and reports identity before turns', async () => { const previous = process.env.PEBREL_HOOK_EXE; process.env.PEBREL_HOOK_EXE = 'test-hook'; try { const callbacks = new Map(); - bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); + await bridge.default({ on: (kind, callback) => callbacks.set(kind, callback) }); await callbacks.get('session_start')({}, context('first')); await callbacks.get('session_start')({}, context('second')); await callbacks.get('agent_start')({}, context('second')); - await callbacks.get('agent_end')({}, context('second')); + await callbacks.get('agent_end')({ messages: [{ role: 'assistant', stopReason: 'stop' }] }, context('second')); + await callbacks.get('agent_settled')({}, context('second')); assert.deepEqual(events.map(event => event.session_id), ['first', 'second', 'second', 'second']); assert.deepEqual(events.map(event => event.kind), ['session-start', 'session-start', 'prompt', 'done']); assert.equal(new Set(events.map(event => event.bridge_instance)).size, 1);