Merge pull request #223 from Kuddev/integrate/galact-fixes-20260920

Integrate five terminal fixes with compatibility and regression corrections
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-20 16:16:49 +08:00
commit 5ce18ba0c4
42 files changed
+2660 -243

No files matched your search

@@ -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.
@@ -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.
+45 -11
View File
@@ -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<boolean> {
// 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 (_) {}
}
+2
View File
@@ -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,
+9
View File
@@ -280,7 +280,16 @@ impl AgentActivity {
},
AiHookKind::TurnDone => {
self.screen_armed = false;
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"),
+30 -1
View File
@@ -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};
+1
View File
@@ -178,6 +178,7 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option<AiHookEvent> {
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 {
+98 -6
View File
@@ -131,7 +131,15 @@ pub(crate) fn nebula_prompt_line_from_raw_grid<T: EventListener>(
typed_tail: &str,
env: &SuggestEnv,
) -> Option<PromptLineSnapshot> {
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<T: EventListener>(
terminal: &Term<T>,
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<T: EventListener>(
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<T: EventListener>(terminal: &Term<T>, cursor: Point) -> Option<String> {
raw_grid_line_with_boundary(terminal, cursor, None).map(|(text, _)| text)
}
fn raw_grid_line_with_boundary<T: EventListener>(
terminal: &Term<T>,
cursor: Point,
input_start: Option<Point>,
) -> Option<(String, Option<usize>)> {
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<T: EventListener>(terminal: &Term<T>, 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<T: EventListener>(terminal: &Term<T>, 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<T: EventListener>(terminal: &Term<T>, 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::<Cell>::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;
+5 -6
View File
@@ -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 });
+1 -16
View File
@@ -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").
+60 -9
View File
@@ -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();
@@ -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<Event>,
wake_pending: Arc<AtomicBool>,
pub(super) native_prompt: NativePromptState,
}
#[derive(Clone, Default)]
pub(super) struct NativePromptState(Arc<Mutex<NativePrompt>>);
/// 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;
+27 -8
View File
@@ -63,6 +63,7 @@ impl Dimensions for GridSize {
pub struct TerminalSession {
pub term: Arc<FairMutex<Term<EventProxy>>>,
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<nebula_terminal::event_loop::Msg>) {
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<nebula_terminal::event_loop::Msg>,
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,
))
}
@@ -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<String> {
shared().history.hint(scope, prefix).map(str::to_owned)
}
/// 重算一个 pane 的 ghost/弹窗建议。`line_override` 是 grid 读出的屏幕真值
/// (Windows 唯一行来源,见旧壳 `nebula_input_from_raw_grid` 的契约)。
pub fn update(
@@ -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<std::time::Instant>,
/// 上次跑进程树探测的时刻,用于节流。
last_process_probe: Option<std::time::Instant>,
prompt_process_probe: Option<gpui::Task<()>>,
prompt_input_epoch: u64,
native_prompt_seen: bool,
native_prompt_epoch: Option<u64>,
last_prompt_process_probe: Option<std::time::Instant>,
active_run: Option<crate::runtime_api::RuntimePaneRun>,
last_run: Option<crate::runtime_api::RuntimeRunOutcome>,
/// 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<u8>, cx: &mut Context<Self>) {
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<Self>) {
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) {
@@ -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);
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 };
@@ -11,13 +11,24 @@ impl TerminalView {
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在
/// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。
pub(super) fn commit_line(&mut self, cx: &mut Context<Self>) {
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>) {
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 两次
/// 取锁之间时拼成“旧提示 + 新光标”的跳动帧。
@@ -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<u8> {
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<crate::process_tree::ProcessEntry> {
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);
});
}
@@ -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>) {
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<Self>) {
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<std::time::Instant>,
input_epoch: u64,
result: Result<Vec<crate::process_tree::ProcessEntry>, String>,
cx: &mut Context<Self>,
) {
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;
@@ -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(),
@@ -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!(
&notifications[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);
+117 -7
View File
@@ -208,8 +208,9 @@ pub(crate) fn banner_for_pane(
kind: ToastKind,
text: impl Into<String>,
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::<PaneToast>(("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, _| settings.ai_toasts = false);
Root::new(cx.new(|_| Empty), window, cx)
});
@@ -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);
@@ -100,7 +100,7 @@ impl NebulaWorkspace {
kind,
text,
pane_id,
notification.is_ai(),
&notification,
);
}
}
+3 -23
View File
@@ -195,31 +195,11 @@ fn main() -> Result<(), Box<dyn Error>> {
#[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
+34 -1
View File
@@ -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 {
+23
View File
@@ -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")]
{
@@ -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(())
}
@@ -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");
}
}
+12 -12
View File
@@ -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,20 +98,9 @@ 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, &notification, Some(pane_id));
}
}
}
self.dirty = true;
self.display.window.request_redraw();
true
@@ -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;
+29 -8
View File
@@ -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);
+1 -4
View File
@@ -517,13 +517,10 @@ pub struct TermTheme {
/// 只有自带完整终端 palette 的主题使用;现有七个主题保持 `None`,继续
/// 走原来的背景/浅色替换合同。
pub exact: Option<ExactTermColors>,
/// 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];
+36
View File
@@ -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);
+7 -3
View File
@@ -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"),
+6 -78
View File
@@ -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<T> {
/// 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<T> Term<T> {
}
}
/// 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<D: Dimensions>(config: Config, dimensions: &D, event_proxy: T) -> Term<T> {
let num_cols = dimensions.columns();
let num_lines = dimensions.screen_lines();
@@ -472,6 +397,7 @@ impl<T> Term<T> {
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<T> Term<T> {
// 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<T: EventListener> Handler for Term<T> {
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());
+126
View File
@@ -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<T> Term<T> {
/// 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<Point> {
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
}
}
@@ -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);
}
}
+2 -1
View File
@@ -288,7 +288,8 @@ fn spawn_shell(config: &Options, conpty: &Conpty) -> Result<ChildExitWatcher> {
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;
+46 -15
View File
@@ -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(
+29 -15
View File
@@ -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);
});
+167 -4
View File
@@ -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);