Merge pull request #223 from Kuddev/integrate/galact-fixes-20260920
Integrate five terminal fixes with compatibility and regression corrections
This commit is contained in:
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
@@ -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 (_) {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
¬ifications[1],
|
||||
crate::notify::Notification::AiTurnIssue {
|
||||
outcome: crate::ai_hook::AiTurnOutcome::Failed,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(!notifications[1].is_attention());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn failed_cold_resume_keeps_target_for_retry(cx: &mut TestAppContext) {
|
||||
let (view, window, receiver) = open(cx);
|
||||
|
||||
@@ -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(),
|
||||
¬ification,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-23
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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, ¬ification, 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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user