Merge remote-tracking branch 'upstream/main' into feat/profiles-and-alias
This commit is contained in:
Generated
+4
-4
@@ -2331,7 +2331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "15.9.2"
|
||||
version = "15.9.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -2399,7 +2399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "15.9.2"
|
||||
version = "15.9.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -2411,7 +2411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "15.9.2"
|
||||
version = "15.9.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2457,7 +2457,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "15.9.2"
|
||||
version = "15.9.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-builtins",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "15.9.2"
|
||||
version = "15.9.3"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"dependencies": {
|
||||
"diff": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
"packages/swarm-extension": {
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"bin": {
|
||||
"omp-swarm": "src/cli.ts",
|
||||
},
|
||||
@@ -167,7 +167,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"beautiful-mermaid": "catalog:",
|
||||
@@ -248,15 +248,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.9.2",
|
||||
"@oh-my-pi/omp-stats": "15.9.2",
|
||||
"@oh-my-pi/pi-agent-core": "15.9.2",
|
||||
"@oh-my-pi/pi-ai": "15.9.2",
|
||||
"@oh-my-pi/pi-coding-agent": "15.9.2",
|
||||
"@oh-my-pi/pi-mnemopi": "15.9.2",
|
||||
"@oh-my-pi/pi-natives": "15.9.2",
|
||||
"@oh-my-pi/pi-tui": "15.9.2",
|
||||
"@oh-my-pi/pi-utils": "15.9.2",
|
||||
"@oh-my-pi/hashline": "15.9.3",
|
||||
"@oh-my-pi/omp-stats": "15.9.3",
|
||||
"@oh-my-pi/pi-agent-core": "15.9.3",
|
||||
"@oh-my-pi/pi-ai": "15.9.3",
|
||||
"@oh-my-pi/pi-coding-agent": "15.9.3",
|
||||
"@oh-my-pi/pi-mnemopi": "15.9.3",
|
||||
"@oh-my-pi/pi-natives": "15.9.3",
|
||||
"@oh-my-pi/pi-tui": "15.9.3",
|
||||
"@oh-my-pi/pi-utils": "15.9.3",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
@@ -1259,7 +1259,7 @@
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
@@ -1413,8 +1413,6 @@
|
||||
|
||||
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
@@ -1429,8 +1427,6 @@
|
||||
|
||||
"fastembed/onnxruntime-node/tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
@@ -68,5 +68,5 @@ use napi_derive::napi;
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV15_9_2")]
|
||||
#[napi(js_name = "__piNativesV15_9_3")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
+9
-9
@@ -20,15 +20,15 @@
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@napi-rs/cli": "3.7.0",
|
||||
"@oh-my-pi/hashline": "15.9.2",
|
||||
"@oh-my-pi/omp-stats": "15.9.2",
|
||||
"@oh-my-pi/pi-agent-core": "15.9.2",
|
||||
"@oh-my-pi/pi-ai": "15.9.2",
|
||||
"@oh-my-pi/pi-coding-agent": "15.9.2",
|
||||
"@oh-my-pi/pi-mnemopi": "15.9.2",
|
||||
"@oh-my-pi/pi-natives": "15.9.2",
|
||||
"@oh-my-pi/pi-tui": "15.9.2",
|
||||
"@oh-my-pi/pi-utils": "15.9.2",
|
||||
"@oh-my-pi/hashline": "15.9.3",
|
||||
"@oh-my-pi/omp-stats": "15.9.3",
|
||||
"@oh-my-pi/pi-agent-core": "15.9.3",
|
||||
"@oh-my-pi/pi-ai": "15.9.3",
|
||||
"@oh-my-pi/pi-coding-agent": "15.9.3",
|
||||
"@oh-my-pi/pi-mnemopi": "15.9.3",
|
||||
"@oh-my-pi/pi-natives": "15.9.3",
|
||||
"@oh-my-pi/pi-tui": "15.9.3",
|
||||
"@oh-my-pi/pi-utils": "15.9.3",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-async-hooks": "^2.7.1",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -94,11 +94,13 @@ function createAnthropicMockStream({
|
||||
connectDelayMs = 0,
|
||||
events,
|
||||
hangAfterEvents = false,
|
||||
onIteratorStart,
|
||||
}: {
|
||||
signal: AbortSignal | undefined;
|
||||
connectDelayMs?: number;
|
||||
events?: MockAnthropicEvent[];
|
||||
hangAfterEvents?: boolean;
|
||||
onIteratorStart?: () => void;
|
||||
}): MockAnthropicRequest {
|
||||
const response = new Response(null, {
|
||||
status: 200,
|
||||
@@ -107,6 +109,7 @@ function createAnthropicMockStream({
|
||||
|
||||
const stream: MockAnthropicStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
onIteratorStart?.();
|
||||
if (!events) {
|
||||
await waitForAbortAndThrowAbortError(signal);
|
||||
return;
|
||||
@@ -134,14 +137,45 @@ function createAnthropicMockStream({
|
||||
};
|
||||
}
|
||||
|
||||
type PromiseOutcome<T> = { kind: "fulfilled"; value: T } | { kind: "rejected"; error: unknown };
|
||||
|
||||
async function drainMicrotasksUntil(predicate: () => boolean, errorMessage: string): Promise<void> {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
async function resolveAfterMicrotasks<T>(promise: Promise<T>, errorMessage: string): Promise<T> {
|
||||
let outcome: PromiseOutcome<T> | undefined;
|
||||
promise.then(
|
||||
value => {
|
||||
outcome = { kind: "fulfilled", value };
|
||||
},
|
||||
error => {
|
||||
outcome = { kind: "rejected", error };
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 1000 && !outcome; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
if (!outcome) throw new Error(errorMessage);
|
||||
if (outcome.kind === "rejected") throw outcome.error;
|
||||
return outcome.value;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("anthropic first-event timeout retries", () => {
|
||||
it("retries when the provider never sends the first stream event", async () => {
|
||||
vi.useFakeTimers();
|
||||
let attempt = 0;
|
||||
let firstAttemptIteratorStarted = false;
|
||||
const requestTimeouts: Array<number | undefined> = [];
|
||||
const requestMaxRetries: Array<number | undefined> = [];
|
||||
const create = ((
|
||||
@@ -154,17 +188,36 @@ describe("anthropic first-event timeout retries", () => {
|
||||
return createAnthropicMockStream({
|
||||
signal: requestOptions?.signal,
|
||||
events: attempt === 1 ? undefined : createSuccessfulAnthropicEvents("retry recovered"),
|
||||
onIteratorStart:
|
||||
attempt === 1
|
||||
? () => {
|
||||
firstAttemptIteratorStarted = true;
|
||||
}
|
||||
: undefined,
|
||||
}) as never;
|
||||
}) as unknown as AnthropicMessagesClientLike["messages"]["create"];
|
||||
const client = { messages: { create } } as AnthropicMessagesClientLike;
|
||||
const providerRetryWait = vi.fn(async () => {});
|
||||
|
||||
const result = await streamAnthropic(model, context, {
|
||||
const resultPromise = streamAnthropic(model, context, {
|
||||
client,
|
||||
streamFirstEventTimeoutMs: 1,
|
||||
providerRetryWait,
|
||||
}).result();
|
||||
|
||||
await drainMicrotasksUntil(
|
||||
() => firstAttemptIteratorStarted,
|
||||
"Anthropic mock stream did not enter the hung first attempt",
|
||||
);
|
||||
await drainMicrotasksUntil(() => vi.getTimerCount() > 0, "Anthropic first-event watchdog timer was not armed");
|
||||
expect(attempt).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
const result = await resolveAfterMicrotasks(
|
||||
resultPromise,
|
||||
"Anthropic retry did not settle after the deterministic first-event timeout",
|
||||
);
|
||||
|
||||
expect(attempt).toBe(2);
|
||||
expect(providerRetryWait).toHaveBeenCalledWith(2000, undefined);
|
||||
expect(requestTimeouts).toEqual([1, 1]);
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling and extension-flag-safe parsing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed chat transcript updates after submitting input so frozen scrollback is only thawed when native scrollback replay succeeds, preventing misplaced or duplicated rows when the viewport is not at the tail
|
||||
- Fixed `read` of `.zip` archives to list the central directory without inflating every member, so large or corrupt zip payloads no longer freeze directory reads; member contents are inflated only when a specific entry is read.
|
||||
- Fixed profile bootstrap parsing so stripped `--profile`/`--alias` values no longer make optional or extension flags consume following prompt text, preserved standalone `--` as end-of-options after extension string flags, and made profile aliases respect `ZDOTDIR` while rejecting shell reserved words.
|
||||
- Made native user-level config discovery follow the active profile. Skills, rules, slash commands, prompts, instructions, hooks, tools, settings, extensions, MCP servers, and the top-level `SYSTEM.md`/`RULES.md`/`AGENTS.md` now resolve the user scope through `getAgentDir()`, so a named profile sees only its own `~/.omp/profiles/<name>/agent` config instead of the default profile's `~/.omp/agent` leaking into every profile. This matches the `/mcp` config writer and `getMCPConfigPath("user")`.
|
||||
- Fixed symlinked extension directories being skipped by native auto-discovery. The glob walker runs with `follow_links=false`, so a symlinked directory under `extensions/` was yielded as a symlink but never descended into — its `index.{ts,js}`/`package.json` stayed invisible while real directories loaded normally. `discoverExtensionModulePaths` now detects top-level symlinked directories and resolves their entry points, so an extension shared across profiles via a symlink loads like a real directory (symlinked extension *files* were already handled).
|
||||
|
||||
## [15.9.3] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `@`-mention auto-read injecting an unrelated, same-named file when a mention did not point at a real path — e.g. an npm scope like `@scope/`, a partial path, or a bare token. `generateFileMentionMessages` resolution previously fell back to prefix and repo-wide fuzzy matching (globbing the whole project on every such mention) and auto-read the single "best" guess. Resolution is now exact-only: a mention is auto-read only when it resolves to an existing file or directory; otherwise it is left as prose. The TUI `@`-selector already inserts the real, complete path before send, so post-send guessing was both unnecessary and the source of the wrong-file reads. Directories still resolve and are listed. Removes the per-mention `**/*` project scan.
|
||||
|
||||
## [15.9.2] - 2026-06-05
|
||||
|
||||
### Added
|
||||
@@ -9365,4 +9372,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -46,5 +46,6 @@ const doubled = value * 2;
|
||||
- Callback identity matters.
|
||||
- Type guard preserves narrowing.
|
||||
- Public API, test seam, or DI boundary needs indirection.
|
||||
- Names a non-obvious formula or magic-constant computation that the inlined expression would not explain on its own.
|
||||
|
||||
If none apply, inline it.
|
||||
|
||||
@@ -924,11 +924,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#pendingSubmissionDispose = undefined;
|
||||
}
|
||||
this.editor.setText("");
|
||||
// Reconciliation checkpoint: the rebuild below replays the whole transcript
|
||||
// into native scrollback, so retire frozen block snapshots and let every
|
||||
// block render its current state.
|
||||
this.chatContainer.thaw();
|
||||
this.ui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
|
||||
// Reconciliation checkpoint: only retire frozen block snapshots after TUI
|
||||
// proves the native viewport is at the tail and replays scrollback safely.
|
||||
// Unknown host viewports stay frozen; thawing them would expose live rows
|
||||
// over stale native history and can yank or duplicate when ED3 is unsafe.
|
||||
if (this.ui.refreshNativeScrollbackIfDirty()) {
|
||||
this.chatContainer.thaw();
|
||||
}
|
||||
this.ensureLoadingAnimation();
|
||||
this.ui.requestRender();
|
||||
return submission;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { inflateSync, strFromU8 } from "fflate";
|
||||
|
||||
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
|
||||
async function loadFflate(): Promise<typeof import("fflate")> {
|
||||
if (!fflateModulePromise) fflateModulePromise = import("fflate");
|
||||
return fflateModulePromise;
|
||||
}
|
||||
import { ToolError } from "./tool-errors";
|
||||
|
||||
export type ArchiveFormat = "zip" | "tar" | "tar.gz";
|
||||
|
||||
@@ -35,7 +31,11 @@ interface TarStorage {
|
||||
|
||||
interface ZipStorage {
|
||||
type: "zip";
|
||||
bytes: Uint8Array;
|
||||
archivePath: string;
|
||||
compressedSize: number;
|
||||
compression: number;
|
||||
flags: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
type EntryStorage = TarStorage | ZipStorage;
|
||||
@@ -123,6 +123,321 @@ function getArchiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
|
||||
const ZIP64_EOCD_SIGNATURE = 0x06064b50;
|
||||
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
|
||||
const ZIP_EOCD_SIGNATURE = 0x06054b50;
|
||||
const ZIP_EOCD_MIN_LENGTH = 22;
|
||||
const ZIP_EOCD_MAX_COMMENT_LENGTH = 0xffff;
|
||||
const ZIP64_EOCD_LOCATOR_LENGTH = 20;
|
||||
const ZIP_STORED_COMPRESSION = 0;
|
||||
const ZIP_DEFLATE_COMPRESSION = 8;
|
||||
const ZIP_UTF8_FLAG = 0x0800;
|
||||
const ZIP_ENCRYPTED_FLAG = 0x0001;
|
||||
const ZIP_UINT16_MAX = 0xffff;
|
||||
const ZIP_UINT32_MAX = 0xffffffff;
|
||||
const ZIP_UINT32_RANGE = 0x100000000;
|
||||
|
||||
interface ZipCentralDirectoryInfo {
|
||||
entries: number;
|
||||
offset: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Zip64EntryValues {
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
diskStart: number;
|
||||
}
|
||||
|
||||
interface Zip64EntryPlaceholders {
|
||||
compressedSize: boolean;
|
||||
uncompressedSize: boolean;
|
||||
localHeaderOffset: boolean;
|
||||
diskStart: boolean;
|
||||
}
|
||||
|
||||
function readUInt16LE(bytes: Uint8Array, offset: number): number {
|
||||
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
||||
}
|
||||
|
||||
function readUInt32LE(bytes: Uint8Array, offset: number): number {
|
||||
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
||||
}
|
||||
|
||||
function readUInt64LEAsNumber(bytes: Uint8Array, offset: number): number {
|
||||
const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * ZIP_UINT32_RANGE;
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ToolError("ZIP archive uses offsets or sizes too large to read safely");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readZipRange(filePath: string, start: number, end: number): Promise<Uint8Array> {
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
|
||||
throw new ToolError("Invalid ZIP archive range");
|
||||
}
|
||||
|
||||
const bytes = await Bun.file(filePath).slice(start, end).bytes();
|
||||
if (bytes.byteLength !== end - start) {
|
||||
throw new ToolError("Invalid ZIP archive: truncated data");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(tail: Uint8Array): number {
|
||||
for (let offset = tail.byteLength - ZIP_EOCD_MIN_LENGTH; offset >= 0; offset--) {
|
||||
if (readUInt32LE(tail, offset) !== ZIP_EOCD_SIGNATURE) continue;
|
||||
const commentLength = readUInt16LE(tail, offset + 20);
|
||||
if (offset + ZIP_EOCD_MIN_LENGTH + commentLength === tail.byteLength) return offset;
|
||||
}
|
||||
|
||||
throw new ToolError("Invalid ZIP archive: missing end of central directory");
|
||||
}
|
||||
|
||||
async function readZip64CentralDirectoryInfo(
|
||||
filePath: string,
|
||||
tail: Uint8Array,
|
||||
tailStart: number,
|
||||
eocdOffset: number,
|
||||
): Promise<ZipCentralDirectoryInfo | undefined> {
|
||||
const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_LENGTH;
|
||||
if (locatorOffset < 0) return undefined;
|
||||
|
||||
const locator =
|
||||
locatorOffset >= tailStart
|
||||
? tail.subarray(locatorOffset - tailStart, locatorOffset - tailStart + ZIP64_EOCD_LOCATOR_LENGTH)
|
||||
: await readZipRange(filePath, locatorOffset, eocdOffset);
|
||||
if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return undefined;
|
||||
|
||||
const zip64EocdDisk = readUInt32LE(locator, 4);
|
||||
const zip64EocdOffset = readUInt64LEAsNumber(locator, 8);
|
||||
const totalDisks = readUInt32LE(locator, 16);
|
||||
if (zip64EocdDisk !== 0 || totalDisks > 1) {
|
||||
throw new ToolError("Multi-disk ZIP archives are not supported");
|
||||
}
|
||||
|
||||
const record = await readZipRange(filePath, zip64EocdOffset, zip64EocdOffset + 56);
|
||||
if (readUInt32LE(record, 0) !== ZIP64_EOCD_SIGNATURE) {
|
||||
throw new ToolError("Invalid ZIP archive: missing ZIP64 end of central directory");
|
||||
}
|
||||
if (readUInt32LE(record, 16) !== 0 || readUInt32LE(record, 20) !== 0) {
|
||||
throw new ToolError("Multi-disk ZIP archives are not supported");
|
||||
}
|
||||
|
||||
return {
|
||||
entries: readUInt64LEAsNumber(record, 32),
|
||||
size: readUInt64LEAsNumber(record, 40),
|
||||
offset: readUInt64LEAsNumber(record, 48),
|
||||
};
|
||||
}
|
||||
|
||||
async function readZipCentralDirectoryInfo(filePath: string, fileSize: number): Promise<ZipCentralDirectoryInfo> {
|
||||
if (fileSize < ZIP_EOCD_MIN_LENGTH) {
|
||||
throw new ToolError("Invalid ZIP archive: missing end of central directory");
|
||||
}
|
||||
|
||||
const tailLength = Math.min(fileSize, ZIP_EOCD_MIN_LENGTH + ZIP_EOCD_MAX_COMMENT_LENGTH);
|
||||
const tailStart = fileSize - tailLength;
|
||||
const tail = await readZipRange(filePath, tailStart, fileSize);
|
||||
const eocdIndex = findEndOfCentralDirectory(tail);
|
||||
const eocdOffset = tailStart + eocdIndex;
|
||||
|
||||
if (readUInt16LE(tail, eocdIndex + 4) !== 0 || readUInt16LE(tail, eocdIndex + 6) !== 0) {
|
||||
throw new ToolError("Multi-disk ZIP archives are not supported");
|
||||
}
|
||||
|
||||
let entries = readUInt16LE(tail, eocdIndex + 10);
|
||||
let size = readUInt32LE(tail, eocdIndex + 12);
|
||||
let offset = readUInt32LE(tail, eocdIndex + 16);
|
||||
const needsZip64 = entries === ZIP_UINT16_MAX || size === ZIP_UINT32_MAX || offset === ZIP_UINT32_MAX;
|
||||
const zip64Info = await readZip64CentralDirectoryInfo(filePath, tail, tailStart, eocdOffset);
|
||||
if (zip64Info) {
|
||||
({ entries, size, offset } = zip64Info);
|
||||
} else if (needsZip64) {
|
||||
throw new ToolError("Invalid ZIP archive: missing ZIP64 central directory metadata");
|
||||
}
|
||||
|
||||
if (offset + size > fileSize) {
|
||||
throw new ToolError("Invalid ZIP archive: central directory exceeds file size");
|
||||
}
|
||||
|
||||
return { entries, offset, size };
|
||||
}
|
||||
|
||||
function readZip64EntryValues(
|
||||
extra: Uint8Array,
|
||||
placeholders: Zip64EntryPlaceholders,
|
||||
current: Zip64EntryValues,
|
||||
): Zip64EntryValues {
|
||||
if (
|
||||
!placeholders.compressedSize &&
|
||||
!placeholders.uncompressedSize &&
|
||||
!placeholders.localHeaderOffset &&
|
||||
!placeholders.diskStart
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
while (offset + 4 <= extra.byteLength) {
|
||||
const headerId = readUInt16LE(extra, offset);
|
||||
const dataSize = readUInt16LE(extra, offset + 2);
|
||||
const dataStart = offset + 4;
|
||||
const dataEnd = dataStart + dataSize;
|
||||
if (dataEnd > extra.byteLength) {
|
||||
throw new ToolError("Invalid ZIP archive: malformed extra field");
|
||||
}
|
||||
|
||||
if (headerId === 0x0001) {
|
||||
let cursor = dataStart;
|
||||
let uncompressedSize = current.uncompressedSize;
|
||||
let compressedSize = current.compressedSize;
|
||||
let localHeaderOffset = current.localHeaderOffset;
|
||||
let diskStart = current.diskStart;
|
||||
|
||||
if (placeholders.uncompressedSize) {
|
||||
if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
|
||||
uncompressedSize = readUInt64LEAsNumber(extra, cursor);
|
||||
cursor += 8;
|
||||
}
|
||||
if (placeholders.compressedSize) {
|
||||
if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
|
||||
compressedSize = readUInt64LEAsNumber(extra, cursor);
|
||||
cursor += 8;
|
||||
}
|
||||
if (placeholders.localHeaderOffset) {
|
||||
if (cursor + 8 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
|
||||
localHeaderOffset = readUInt64LEAsNumber(extra, cursor);
|
||||
cursor += 8;
|
||||
}
|
||||
if (placeholders.diskStart) {
|
||||
if (cursor + 4 > dataEnd) throw new ToolError("Invalid ZIP archive: malformed ZIP64 extra field");
|
||||
diskStart = readUInt32LE(extra, cursor);
|
||||
}
|
||||
|
||||
return { compressedSize, uncompressedSize, localHeaderOffset, diskStart };
|
||||
}
|
||||
|
||||
offset = dataEnd;
|
||||
}
|
||||
|
||||
throw new ToolError("Invalid ZIP archive: missing ZIP64 extra field");
|
||||
}
|
||||
|
||||
function parseZipCentralDirectory(
|
||||
filePath: string,
|
||||
centralDirectory: Uint8Array,
|
||||
expectedEntries: number,
|
||||
): ArchiveIndexEntry[] {
|
||||
const entries: ArchiveIndexEntry[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (let index = 0; index < expectedEntries; index++) {
|
||||
if (offset + 46 > centralDirectory.byteLength) {
|
||||
throw new ToolError("Invalid ZIP archive: truncated central directory");
|
||||
}
|
||||
if (readUInt32LE(centralDirectory, offset) !== ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
|
||||
throw new ToolError("Invalid ZIP archive: malformed central directory");
|
||||
}
|
||||
|
||||
const flags = readUInt16LE(centralDirectory, offset + 8);
|
||||
const compression = readUInt16LE(centralDirectory, offset + 10);
|
||||
const compressedSizeRaw = readUInt32LE(centralDirectory, offset + 20);
|
||||
const uncompressedSizeRaw = readUInt32LE(centralDirectory, offset + 24);
|
||||
const fileNameLength = readUInt16LE(centralDirectory, offset + 28);
|
||||
const extraLength = readUInt16LE(centralDirectory, offset + 30);
|
||||
const commentLength = readUInt16LE(centralDirectory, offset + 32);
|
||||
const diskStartRaw = readUInt16LE(centralDirectory, offset + 34);
|
||||
const localHeaderOffsetRaw = readUInt32LE(centralDirectory, offset + 42);
|
||||
const nameStart = offset + 46;
|
||||
const extraStart = nameStart + fileNameLength;
|
||||
const entryEnd = extraStart + extraLength + commentLength;
|
||||
if (entryEnd > centralDirectory.byteLength) {
|
||||
throw new ToolError("Invalid ZIP archive: truncated central directory entry");
|
||||
}
|
||||
|
||||
const rawPath = strFromU8(centralDirectory.subarray(nameStart, extraStart), (flags & ZIP_UTF8_FLAG) === 0);
|
||||
const normalizedPath = normalizeArchiveEntryPath(rawPath);
|
||||
if (normalizedPath) {
|
||||
const values = readZip64EntryValues(
|
||||
centralDirectory.subarray(extraStart, extraStart + extraLength),
|
||||
{
|
||||
compressedSize: compressedSizeRaw === ZIP_UINT32_MAX,
|
||||
uncompressedSize: uncompressedSizeRaw === ZIP_UINT32_MAX,
|
||||
localHeaderOffset: localHeaderOffsetRaw === ZIP_UINT32_MAX,
|
||||
diskStart: diskStartRaw === ZIP_UINT16_MAX,
|
||||
},
|
||||
{
|
||||
compressedSize: compressedSizeRaw,
|
||||
uncompressedSize: uncompressedSizeRaw,
|
||||
localHeaderOffset: localHeaderOffsetRaw,
|
||||
diskStart: diskStartRaw,
|
||||
},
|
||||
);
|
||||
if (values.diskStart !== 0) {
|
||||
throw new ToolError("Multi-disk ZIP archives are not supported");
|
||||
}
|
||||
|
||||
const isDirectory = isArchiveDirectoryName(rawPath);
|
||||
entries.push({
|
||||
path: normalizedPath,
|
||||
isDirectory,
|
||||
size: isDirectory ? 0 : values.uncompressedSize,
|
||||
storage: isDirectory
|
||||
? undefined
|
||||
: {
|
||||
type: "zip",
|
||||
archivePath: filePath,
|
||||
compressedSize: values.compressedSize,
|
||||
compression,
|
||||
flags,
|
||||
localHeaderOffset: values.localHeaderOffset,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
offset = entryEnd;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): Promise<Uint8Array> {
|
||||
if ((storage.flags & ZIP_ENCRYPTED_FLAG) !== 0) {
|
||||
throw new ToolError("Encrypted ZIP entries are not supported");
|
||||
}
|
||||
|
||||
const localHeader = await readZipRange(
|
||||
storage.archivePath,
|
||||
storage.localHeaderOffset,
|
||||
storage.localHeaderOffset + 30,
|
||||
);
|
||||
if (readUInt32LE(localHeader, 0) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
||||
throw new ToolError("Invalid ZIP archive: malformed local file header");
|
||||
}
|
||||
|
||||
const fileNameLength = readUInt16LE(localHeader, 26);
|
||||
const extraLength = readUInt16LE(localHeader, 28);
|
||||
const dataStart = storage.localHeaderOffset + 30 + fileNameLength + extraLength;
|
||||
const compressedBytes = await readZipRange(storage.archivePath, dataStart, dataStart + storage.compressedSize);
|
||||
|
||||
if (storage.compression === ZIP_STORED_COMPRESSION) {
|
||||
return compressedBytes;
|
||||
}
|
||||
if (storage.compression !== ZIP_DEFLATE_COMPRESSION) {
|
||||
throw new ToolError(`Unsupported ZIP compression method: ${storage.compression}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return inflateSync(compressedBytes, { out: new Uint8Array(uncompressedSize) });
|
||||
} catch (error) {
|
||||
throw new ToolError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
|
||||
let archive: Bun.Archive;
|
||||
try {
|
||||
@@ -155,29 +470,19 @@ async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readZipEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
|
||||
const { unzipSync } = await loadFflate();
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
} catch (error) {
|
||||
throw new ToolError(error instanceof Error ? error.message : String(error));
|
||||
async function readZipEntries(filePath: string): Promise<ArchiveIndexEntry[]> {
|
||||
const fileSize = Bun.file(filePath).size;
|
||||
if (!Number.isSafeInteger(fileSize)) {
|
||||
throw new ToolError("ZIP archive is too large to read safely");
|
||||
}
|
||||
|
||||
const entries: ArchiveIndexEntry[] = [];
|
||||
for (const [rawPath, fileBytes] of Object.entries(files)) {
|
||||
const normalizedPath = normalizeArchiveEntryPath(rawPath);
|
||||
if (!normalizedPath) continue;
|
||||
const isDirectory = isArchiveDirectoryName(rawPath);
|
||||
entries.push({
|
||||
path: normalizedPath,
|
||||
isDirectory,
|
||||
size: isDirectory ? 0 : fileBytes.byteLength,
|
||||
storage: isDirectory ? undefined : { type: "zip", bytes: fileBytes },
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
const directoryInfo = await readZipCentralDirectoryInfo(filePath, fileSize);
|
||||
const centralDirectory = await readZipRange(
|
||||
filePath,
|
||||
directoryInfo.offset,
|
||||
directoryInfo.offset + directoryInfo.size,
|
||||
);
|
||||
return parseZipCentralDirectory(filePath, centralDirectory, directoryInfo.entries);
|
||||
}
|
||||
|
||||
export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] {
|
||||
@@ -297,7 +602,10 @@ export class ArchiveReader {
|
||||
throw new ToolError(`Archive file '${normalizedPath}' has no readable storage`);
|
||||
}
|
||||
|
||||
const bytes = entry.storage.type === "tar" ? await entry.storage.file.bytes() : entry.storage.bytes;
|
||||
const bytes =
|
||||
entry.storage.type === "tar"
|
||||
? await entry.storage.file.bytes()
|
||||
: await readZipFileBytes(entry.storage, entry.size);
|
||||
|
||||
return {
|
||||
path: entry.path,
|
||||
@@ -315,7 +623,7 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
|
||||
throw new ToolError(`Unsupported archive format: ${filePath}`);
|
||||
}
|
||||
|
||||
const bytes = await Bun.file(filePath).bytes();
|
||||
const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
|
||||
const entries =
|
||||
format === "zip" ? await readZipEntries(filePath) : await readTarEntries(await Bun.file(filePath).bytes());
|
||||
return new ArchiveReader(format, entries);
|
||||
}
|
||||
|
||||
@@ -217,6 +217,27 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
|
||||
return merged as [LineRange, ...LineRange[]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the line-range component from a read-tool selector that may also
|
||||
* carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
|
||||
* with a range (`raw:50-100`, `50-100:raw`). Returns the parsed ranges when the
|
||||
* selector names any, otherwise `undefined` (pure `raw`/`conflicts`/none).
|
||||
*
|
||||
* Used by content search, which honors line ranges as a match filter but has no
|
||||
* use for verbatim/conflict display modes — so those selectors are accepted and
|
||||
* treated as an unfiltered, whole-resource search rather than rejected.
|
||||
*/
|
||||
export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
|
||||
if (!sel) return undefined;
|
||||
for (const chunk of sel.split(":")) {
|
||||
const lower = chunk.toLowerCase();
|
||||
if (lower === "raw" || lower === "conflicts") continue;
|
||||
const ranges = parseLineRanges(chunk);
|
||||
if (ranges) return ranges;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
|
||||
export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
|
||||
for (const range of ranges) {
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
type ResolvedSearchTarget,
|
||||
resolveReadPath,
|
||||
resolveToolSearchScope,
|
||||
selectorLineRanges,
|
||||
splitInternalUrlSel,
|
||||
splitPathAndSel,
|
||||
} from "./path-utils";
|
||||
import {
|
||||
@@ -109,6 +111,21 @@ interface SearchPathSpec {
|
||||
function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
|
||||
const specs: SearchPathSpec[] = [];
|
||||
for (const entry of rawEntries) {
|
||||
// Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
|
||||
// which peels selector-shaped tails only for selector-capable schemes and
|
||||
// leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
|
||||
// verbatim/index display modes (`raw`, `conflicts`) carry no meaning for
|
||||
// content search, so we accept them — searching the whole resource — and
|
||||
// still honor any embedded line range as a match filter.
|
||||
const internalSplit = splitInternalUrlSel(entry);
|
||||
if (internalSplit.sel !== undefined) {
|
||||
specs.push({
|
||||
original: entry,
|
||||
clean: internalSplit.path,
|
||||
ranges: selectorLineRanges(internalSplit.sel),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const split = splitPathAndSel(entry);
|
||||
let clean = entry;
|
||||
let ranges: [LineRange, ...LineRange[]] | undefined;
|
||||
|
||||
@@ -10,8 +10,6 @@ import path from "node:path";
|
||||
import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import { glob } from "@oh-my-pi/pi-natives";
|
||||
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
||||
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
||||
import { normalizeToLF } from "../edit/normalize";
|
||||
import type { FileMentionMessage } from "../session/messages";
|
||||
@@ -30,27 +28,6 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
|
||||
const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
|
||||
const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
|
||||
const DEFAULT_DIR_LIMIT = 500;
|
||||
const MIN_FUZZY_QUERY_LENGTH = 5;
|
||||
const MAX_RESOLUTION_CANDIDATES = 20_000;
|
||||
const PATH_SEPARATOR_REGEX = /[/._\-\s]+/g;
|
||||
|
||||
type MentionDiscoveryProfile = {
|
||||
hidden: boolean;
|
||||
gitignore: boolean;
|
||||
includeNodeModules: boolean;
|
||||
maxResults: number;
|
||||
cache: boolean;
|
||||
};
|
||||
|
||||
function getMentionCandidateDiscoveryProfile(): MentionDiscoveryProfile {
|
||||
return {
|
||||
hidden: true,
|
||||
gitignore: true,
|
||||
cache: true,
|
||||
includeNodeModules: true,
|
||||
maxResults: MAX_RESOLUTION_CANDIDATES,
|
||||
};
|
||||
}
|
||||
|
||||
// Avoid OOM when users @mention very large files. Above these limits we skip
|
||||
// auto-reading and only include the path in the message.
|
||||
@@ -70,16 +47,6 @@ function sanitizeMentionPath(rawPath: string): string | null {
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
type MentionCandidate = {
|
||||
path: string;
|
||||
pathLower: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
function normalizeMentionQuery(query: string): string {
|
||||
return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, "");
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await Bun.file(filePath).stat();
|
||||
@@ -89,75 +56,13 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function listMentionCandidates(cwd: string): Promise<MentionCandidate[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
const discoveryProfile = getMentionCandidateDiscoveryProfile();
|
||||
const result = await glob({
|
||||
pattern: "**/*",
|
||||
path: cwd,
|
||||
...discoveryProfile,
|
||||
});
|
||||
entries = result.matches.map(match => match.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const candidates: MentionCandidate[] = [];
|
||||
for (const entry of entries) {
|
||||
const pathLower = entry.toLowerCase();
|
||||
const normalizedPath = normalizeMentionQuery(entry);
|
||||
if (normalizedPath.length === 0) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({ path: entry, pathLower, normalizedPath });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveMentionPath(
|
||||
filePath: string,
|
||||
cwd: string,
|
||||
getMentionCandidates: () => Promise<MentionCandidate[]>,
|
||||
): Promise<string | null> {
|
||||
async function resolveMentionPath(filePath: string, cwd: string): Promise<string | null> {
|
||||
// Exact resolution only. The TUI @-selector inserts the real, complete path, so a
|
||||
// mention that does not resolve to an existing file or directory is prose, not a file
|
||||
// reference. Fuzzy/prefix guessing here previously dragged in unrelated same-named
|
||||
// files; that disambiguation belongs to the selector's display, not post-send.
|
||||
const absolutePath = resolveReadPath(filePath, cwd);
|
||||
if (await pathExists(absolutePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const queryLower = filePath.toLowerCase();
|
||||
const candidates = await getMentionCandidates();
|
||||
const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower));
|
||||
if (prefixMatches.length === 1) {
|
||||
return prefixMatches[0]?.path ?? null;
|
||||
}
|
||||
if (prefixMatches.length > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeMentionQuery(filePath);
|
||||
if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scored = candidates
|
||||
.map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) }))
|
||||
.filter(entry => entry.match.matches)
|
||||
.sort((a, b) => {
|
||||
if (a.match.score !== b.match.score) {
|
||||
return a.match.score - b.match.score;
|
||||
}
|
||||
return a.candidate.path.localeCompare(b.candidate.path);
|
||||
});
|
||||
|
||||
if (scored.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = scored[0];
|
||||
|
||||
return best?.candidate.path ?? null;
|
||||
return (await pathExists(absolutePath)) ? filePath : null;
|
||||
}
|
||||
|
||||
function buildTextOutput(textContent: string): { output: string; lineCount: number } {
|
||||
@@ -285,14 +190,9 @@ export async function generateFileMentionMessages(
|
||||
const autoResizeImages = options?.autoResizeImages ?? true;
|
||||
|
||||
const files: FileMentionMessage["files"] = [];
|
||||
let mentionCandidatesPromise: Promise<MentionCandidate[]> | null = null;
|
||||
const getMentionCandidates = (): Promise<MentionCandidate[]> => {
|
||||
mentionCandidatesPromise ??= listMentionCandidates(cwd);
|
||||
return mentionCandidatesPromise;
|
||||
};
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates);
|
||||
const resolvedPath = await resolveMentionPath(filePath, cwd);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -19,63 +19,64 @@ async function createTempDir(): Promise<string> {
|
||||
}
|
||||
|
||||
describe("generateFileMentionMessages path resolution", () => {
|
||||
test("prefers exact path over fuzzy candidates", async () => {
|
||||
test("auto-reads an exact file path", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await Bun.write(path.join(cwd, "httpserap"), "exact file");
|
||||
await fs.mkdir(path.join(cwd, "http_server_api_tests"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "http_server_api_tests", "spec.txt"), "spec");
|
||||
await fs.mkdir(path.join(cwd, "src"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "src", "config.ts"), "export const x = 1;");
|
||||
|
||||
const messages = await generateFileMentionMessages(["httpserap"], cwd);
|
||||
const messages = await generateFileMentionMessages(["src/config.ts"], cwd);
|
||||
expect(messages).toHaveLength(1);
|
||||
const message = messages[0];
|
||||
if (message?.role !== "fileMention") {
|
||||
throw new Error("expected file mention message");
|
||||
}
|
||||
expect(message.files).toHaveLength(1);
|
||||
expect(message.files[0]?.path).toBe("httpserap");
|
||||
expect(message.files[0]?.content).toContain("exact file");
|
||||
expect(message.files[0]?.path).toBe("src/config.ts");
|
||||
expect(message.files[0]?.content).toContain("export const x = 1;");
|
||||
});
|
||||
|
||||
test("resolves unique prefix match", async () => {
|
||||
test("lists an exact directory path", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await fs.mkdir(path.join(cwd, "src"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "src", "index.ts"), "ok");
|
||||
|
||||
const messages = await generateFileMentionMessages(["src"], cwd);
|
||||
expect(messages).toHaveLength(1);
|
||||
const message = messages[0];
|
||||
if (message?.role !== "fileMention") {
|
||||
throw new Error("expected file mention message");
|
||||
}
|
||||
expect(message.files[0]?.path).toBe("src");
|
||||
expect(message.files[0]?.content).toContain("index.ts");
|
||||
});
|
||||
|
||||
test("does not fuzzy- or prefix-resolve mentions that are not real paths", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await fs.mkdir(path.join(cwd, "docs"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "docs", "readme.md"), "hello");
|
||||
await fs.mkdir(path.join(cwd, "assets"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "assets", "widget-input.svg"), "<svg/>");
|
||||
|
||||
const messages = await generateFileMentionMessages(["docs/rea"], cwd);
|
||||
// Partial path (old prefix match), scope-style token and bare substring (old fuzzy
|
||||
// match) must all resolve to nothing: the @-selector turns these into real paths
|
||||
// before send, so an unresolved mention is prose, not a file reference.
|
||||
expect(await generateFileMentionMessages(["docs/rea"], cwd)).toHaveLength(0);
|
||||
expect(await generateFileMentionMessages(["widget/"], cwd)).toHaveLength(0);
|
||||
expect(await generateFileMentionMessages(["widget"], cwd)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("reads only the mentions that resolve to real paths", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await Bun.write(path.join(cwd, "real.txt"), "present");
|
||||
|
||||
const messages = await generateFileMentionMessages(["real.txt", "does-not-exist.txt"], cwd);
|
||||
expect(messages).toHaveLength(1);
|
||||
const message = messages[0];
|
||||
if (message?.role !== "fileMention") {
|
||||
throw new Error("expected file mention message");
|
||||
}
|
||||
expect(message.files[0]?.path).toBe("docs/readme.md");
|
||||
expect(message.files[0]?.content).toContain("hello");
|
||||
});
|
||||
|
||||
test("resolves fuzzy match for segmented names", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await fs.mkdir(path.join(cwd, "http_server_api_tests"), { recursive: true });
|
||||
await Bun.write(path.join(cwd, "http_server_api_tests", "case.ts"), "ok");
|
||||
|
||||
const messages = await generateFileMentionMessages(["httpserap"], cwd);
|
||||
expect(messages).toHaveLength(1);
|
||||
const message = messages[0];
|
||||
if (message?.role !== "fileMention") {
|
||||
throw new Error("expected file mention message");
|
||||
}
|
||||
expect(message.files[0]?.path).toBe("http_server_api_tests");
|
||||
expect(message.files[0]?.content).toContain("case.ts");
|
||||
});
|
||||
|
||||
test("returns no message for ambiguous or short fuzzy queries", async () => {
|
||||
const cwd = await createTempDir();
|
||||
await Bun.write(path.join(cwd, "spec-alpha.txt"), "a");
|
||||
await Bun.write(path.join(cwd, "spec-beta.txt"), "b");
|
||||
await Bun.write(path.join(cwd, "alphabet.txt"), "c");
|
||||
|
||||
const ambiguous = await generateFileMentionMessages(["spec"], cwd);
|
||||
expect(ambiguous).toHaveLength(0);
|
||||
|
||||
const shortQuery = await generateFileMentionMessages(["ab"], cwd);
|
||||
expect(shortQuery).toHaveLength(0);
|
||||
expect(message.files).toHaveLength(1);
|
||||
expect(message.files[0]?.path).toBe("real.txt");
|
||||
expect(message.files[0]?.content).toContain("present");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,44 @@ function createZipArchive(entries: ArchiveFixtureEntry[]): Buffer {
|
||||
return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
|
||||
}
|
||||
|
||||
function createZipArchiveWithRawDeflateEntry(entry: {
|
||||
path: string;
|
||||
compressed: Buffer;
|
||||
originalSize: number;
|
||||
}): Buffer {
|
||||
const pathBuffer = Buffer.from(entry.path.replace(/\\/g, "/"), "utf-8");
|
||||
const localHeader = Buffer.alloc(30, 0);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4);
|
||||
localHeader.writeUInt16LE(0x0800, 6);
|
||||
localHeader.writeUInt16LE(8, 8);
|
||||
localHeader.writeUInt32LE(0, 14);
|
||||
localHeader.writeUInt32LE(entry.compressed.length, 18);
|
||||
localHeader.writeUInt32LE(entry.originalSize, 22);
|
||||
localHeader.writeUInt16LE(pathBuffer.length, 26);
|
||||
|
||||
const centralHeader = Buffer.alloc(46, 0);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0);
|
||||
centralHeader.writeUInt16LE(20, 4);
|
||||
centralHeader.writeUInt16LE(20, 6);
|
||||
centralHeader.writeUInt16LE(0x0800, 8);
|
||||
centralHeader.writeUInt16LE(8, 10);
|
||||
centralHeader.writeUInt32LE(0, 16);
|
||||
centralHeader.writeUInt32LE(entry.compressed.length, 20);
|
||||
centralHeader.writeUInt32LE(entry.originalSize, 24);
|
||||
centralHeader.writeUInt16LE(pathBuffer.length, 28);
|
||||
|
||||
const centralDirectory = Buffer.concat([centralHeader, pathBuffer]);
|
||||
const endOfCentralDirectory = Buffer.alloc(22, 0);
|
||||
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
|
||||
endOfCentralDirectory.writeUInt16LE(1, 8);
|
||||
endOfCentralDirectory.writeUInt16LE(1, 10);
|
||||
endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12);
|
||||
endOfCentralDirectory.writeUInt32LE(localHeader.length + pathBuffer.length + entry.compressed.length, 16);
|
||||
|
||||
return Buffer.concat([localHeader, pathBuffer, entry.compressed, centralDirectory, endOfCentralDirectory]);
|
||||
}
|
||||
|
||||
let artifactCounter = 0;
|
||||
function createTestToolSession(
|
||||
cwd: string,
|
||||
@@ -618,6 +656,24 @@ describe("Coding Agent Tools", () => {
|
||||
expect(result.details?.isDirectory).toBe(true);
|
||||
});
|
||||
|
||||
it("should list zip archives without inflating member payloads", async () => {
|
||||
const archivePath = path.join(testDir, "header-only.zip");
|
||||
fs.writeFileSync(
|
||||
archivePath,
|
||||
createZipArchiveWithRawDeflateEntry({
|
||||
path: "corrupt.bin",
|
||||
compressed: Buffer.from([0xff, 0xff, 0xff, 0xff]),
|
||||
originalSize: 1024,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await readTool.execute("test-call-zip-header-only", { path: archivePath });
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(output).toContain("corrupt.bin");
|
||||
expect(result.details?.isDirectory).toBe(true);
|
||||
});
|
||||
|
||||
for (const archiveCase of [
|
||||
{
|
||||
label: ".tar",
|
||||
@@ -1160,7 +1216,6 @@ function b() {
|
||||
deliveries.push(text);
|
||||
},
|
||||
});
|
||||
AsyncJobManager.setInstance(asyncJobManager);
|
||||
const autoBackgroundBashTool = wrapToolWithMetaNotice(
|
||||
new BashTool(
|
||||
createTestToolSession(
|
||||
@@ -1171,6 +1226,7 @@ function b() {
|
||||
}),
|
||||
{
|
||||
getSessionId: () => "test-session",
|
||||
asyncJobManager,
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1193,7 +1249,6 @@ function b() {
|
||||
deliveries.push({ jobId, text });
|
||||
},
|
||||
});
|
||||
AsyncJobManager.setInstance(asyncJobManager);
|
||||
const autoBackgroundBashTool = wrapToolWithMetaNotice(
|
||||
new BashTool(
|
||||
createTestToolSession(
|
||||
@@ -1204,6 +1259,7 @@ function b() {
|
||||
}),
|
||||
{
|
||||
getSessionId: () => "test-session",
|
||||
asyncJobManager,
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1239,7 +1295,6 @@ function b() {
|
||||
deliveries.push({ jobId, text });
|
||||
},
|
||||
});
|
||||
AsyncJobManager.setInstance(asyncJobManager);
|
||||
const autoBackgroundBashTool = wrapToolWithMetaNotice(
|
||||
new BashTool(
|
||||
createTestToolSession(
|
||||
@@ -1250,6 +1305,7 @@ function b() {
|
||||
}),
|
||||
{
|
||||
getSessionId: () => "test-session",
|
||||
asyncJobManager,
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1348,8 +1404,9 @@ function b() {
|
||||
const manager = new AsyncJobManager({
|
||||
onJobComplete: async () => {},
|
||||
});
|
||||
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {});
|
||||
AsyncJobManager.setInstance(manager);
|
||||
const session = createTestToolSession(testDir, Settings.isolated({ "bash.autoBackground.enabled": true }), {
|
||||
asyncJobManager: manager,
|
||||
});
|
||||
const jobTool = JobTool.createIf(session)!;
|
||||
|
||||
const jobId = manager.register("bash", "test job", async () => "success");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Local SQLite memory engine for Oh My Pi agents",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
Vendored
+1
-1
@@ -136,7 +136,7 @@ export declare class Shell {
|
||||
* `packages/natives/native/index.js` (which derives the name from
|
||||
* `package.json#version`).
|
||||
*/
|
||||
export declare function __piNativesV15_9_2(): void
|
||||
export declare function __piNativesV15_9_3(): void
|
||||
|
||||
/**
|
||||
* Apply conservative pre-execution rewrites to a bash command.
|
||||
|
||||
@@ -23,7 +23,7 @@ export const PtySession = nativeBindings.PtySession;
|
||||
export const Shell = nativeBindings.Shell;
|
||||
|
||||
// functions
|
||||
export const __piNativesV15_9_2 = nativeBindings.__piNativesV15_9_2;
|
||||
export const __piNativesV15_9_3 = nativeBindings.__piNativesV15_9_3;
|
||||
export const applyBashFixups = nativeBindings.applyBashFixups;
|
||||
export const astEdit = nativeBindings.astEdit;
|
||||
export const astGrep = nativeBindings.astGrep;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Native Rust bindings for grep, clipboard, image processing, syntax highlighting, PTY, and shell operations via N-API",
|
||||
"type": "module",
|
||||
"homepage": "https://omp.sh",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Local observability dashboard for pi AI usage statistics",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/swarm-extension",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Swarm orchestration extension for omp",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Derek Rynd",
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
|
||||
- Added `PI_TUI_ED3_SAFE=1` environment override to treat a terminal as non-ED3-risk for eager native scrollback rebuilds on unknown POSIX hosts
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
|
||||
- Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed ED3-risk unknown-viewport renders repainting offscreen structural edits over stale native scrollback, which could duplicate or shift rows when async blocks collapsed or middle rows were deleted.
|
||||
- Fixed TUI shutdown leaving paint-time terminal state and Kitty image data behind by restoring synchronized-output/autowrap modes and purging all transmitted Kitty image ids on stop.
|
||||
- Fixed stdin buffering splitting surrogate-pair text into UTF-16 halves and reduced timing sensitivity for incomplete escape sequences.
|
||||
|
||||
## [15.9.3] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed ED3-risk foreground streaming erasing the head of any block that alone overflows the viewport (a tall tool result drawn in one frame, or a multi-line assistant reply growing past the viewport as it streams). The live-region pin committed native scrollback only up to the sealed-prefix boundary (`liveRegionStart`), so rows of the live block that had physically scrolled above the viewport top were neither pushed into scrollback nor kept in the repainted viewport — they vanished. The commit boundary is now the viewport top: every row above the viewport enters scrollback (only the tail still visible in the viewport stays transient and deferred to the checkpoint).
|
||||
- Fixed the same ED3-risk live-region pin duplicating already-committed scrollback rows when a foreground stream's live region collapsed mid-turn (a tool preview shrinking to its compact result, an assistant block re-wrapping shorter, a late tool completion). Because growth commits every row above the viewport top to native scrollback, a subsequent shrink moved the bottom-anchored viewport back across those committed rows and the repaint re-drew them into the viewport — so they appeared twice on scroll-up, and with no prompt-submit checkpoint to reconcile (autonomous multi-turn runs, or the session ending into the welcome screen) the duplicate was baked permanently into terminal history. The pinned repaint now separates commit geometry from repaint geometry: a collapse clamps the repaint to the committed sealed boundary (`min(#scrollbackHighWater, liveRegionStart)`) instead of re-exposing those rows, leaving native scrollback un-duplicated without emitting ED3 under a possibly-scrolled reader; stale mutable live-region saved lines still reconcile at the next checkpoint.
|
||||
- Fixed hiding overlays during ED3-risk foreground streaming on unknown-viewport terminals leaving the overlay's transient rows in native scrollback. Overlay visibility reductions now bypass the streaming deferral path and rebuild once, so hidden dialog/notification sentinels are scrubbed immediately.
|
||||
- Fixed ED3-risk / unknown-viewport terminals (including WSL fronted by Windows Terminal) keeping the foreground-stream eager-rebuild mode active after the stream had already settled. A later scrolled content shrink or resize-with-append could then bypass the anti-yank deferral and repaint from stale geometry, jumping the viewport or replaying the wrong rows. The eager opt-in now drops immediately when no teardown render is pending, and the one-frame post-checkpoint suffix-suppression path no longer overrides geometry reflow handling.
|
||||
|
||||
## [15.9.2] - 2026-06-05
|
||||
|
||||
@@ -1034,4 +1058,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
||||
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -163,6 +163,16 @@ export class ImageBudget {
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** All image ids believed to be loaded in the terminal store; clears tracking. */
|
||||
takeAllTransmittedIds(): readonly number[] {
|
||||
if (this.#transmitted.size === 0) return EMPTY_IDS;
|
||||
const ids = [...this.#transmitted];
|
||||
this.#transmitted.clear();
|
||||
this.#purgeIds = [];
|
||||
this.#pendingTransmits = [];
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Whether `imageId`'s data still needs to be transmitted to the terminal. */
|
||||
shouldTransmit(imageId: number): boolean {
|
||||
return !this.#transmitted.has(imageId);
|
||||
|
||||
@@ -233,9 +233,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
||||
return { sequences, remainder: remaining };
|
||||
}
|
||||
} else {
|
||||
// Not an escape sequence - take a single character
|
||||
sequences.push(remaining[0]!);
|
||||
pos++;
|
||||
// Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
|
||||
const char = Array.from(remaining)[0] ?? "";
|
||||
sequences.push(char);
|
||||
pos += char.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,8 +245,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
||||
|
||||
export type StdinBufferOptions = {
|
||||
/**
|
||||
* Maximum time to wait for sequence completion (default: 10ms)
|
||||
* After this time, the buffer is flushed even if incomplete
|
||||
* Maximum time to wait for sequence completion (default: 75ms).
|
||||
* After this time, a genuinely incomplete escape is flushed.
|
||||
*/
|
||||
timeout?: number;
|
||||
};
|
||||
@@ -269,7 +270,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
||||
|
||||
constructor(options: StdinBufferOptions = {}) {
|
||||
super();
|
||||
this.#timeoutMs = options.timeout ?? 10;
|
||||
this.#timeoutMs = options.timeout ?? 75;
|
||||
}
|
||||
|
||||
process(data: string | Buffer): void {
|
||||
|
||||
@@ -118,33 +118,44 @@ export function isWindowsTerminalPreviewSixelSupported(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether eager live-frame native scrollback rebuilds are unsafe when the
|
||||
* terminal viewport position is unobservable.
|
||||
* Whether live-frame native scrollback rebuilds are unsafe when the terminal
|
||||
* viewport position is unobservable.
|
||||
*
|
||||
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
|
||||
* terminals below, ED3 can disturb a reader parked in native scrollback during
|
||||
* streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
|
||||
* active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
|
||||
* iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
|
||||
* invalidates a reader's scrollback position during live streaming.
|
||||
* A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
|
||||
* terminals either clamp a scrolled reader back to the active tail or erase host
|
||||
* scrollback when ED3 lands. The important property is not the brand name — it
|
||||
* is that an unknown viewport position cannot be proven safe. Environment
|
||||
* markers are therefore only used to prove *risk* or a strongly-known profile;
|
||||
* unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
|
||||
*
|
||||
* Windows Terminal erases its host scrollback on ED3 and repositions the
|
||||
* viewport against the shortened buffer, so a scrolled-up reader is yanked.
|
||||
* Native win32 is excluded here because the renderer guards it with dedicated
|
||||
* platform checks (the viewport position is never observable on Windows — see
|
||||
* `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
|
||||
* platform means the outer host is Windows Terminal fronting a WSL distro (WT
|
||||
* propagates the variable into the Linux environment), where the same ED3
|
||||
* yank applies. See #1610.
|
||||
*
|
||||
* Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
|
||||
* Native win32 is excluded here because the renderer has dedicated ConPTY
|
||||
* deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
|
||||
* outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
|
||||
*/
|
||||
export function detectTerminalEagerEraseScrollbackRisk(
|
||||
env: NodeJS.ProcessEnv = Bun.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (platform === "win32") return false;
|
||||
|
||||
const term = env.TERM?.toLowerCase() ?? "";
|
||||
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
|
||||
const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
|
||||
|
||||
if (env.PI_TUI_ED3_SAFE === "1") return false;
|
||||
if (env.WT_SESSION) return true;
|
||||
if (
|
||||
env.SSH_CONNECTION ||
|
||||
env.SSH_CLIENT ||
|
||||
env.SSH_TTY ||
|
||||
env.TMUX ||
|
||||
env.STY ||
|
||||
env.ZELLIJ ||
|
||||
term.startsWith("tmux") ||
|
||||
term.startsWith("screen")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
env.WEZTERM_PANE ||
|
||||
env.KITTY_WINDOW_ID ||
|
||||
@@ -155,17 +166,62 @@ export function detectTerminalEagerEraseScrollbackRisk(
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
switch (env.TERM_PROGRAM?.toLowerCase()) {
|
||||
switch (termProgram) {
|
||||
case "alacritty":
|
||||
case "apple_terminal":
|
||||
case "ghostty":
|
||||
case "gnome-terminal":
|
||||
case "iterm.app":
|
||||
case "kgx":
|
||||
case "kitty":
|
||||
case "ptyxis":
|
||||
case "wezterm":
|
||||
case "xfce4-terminal":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
|
||||
// Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
|
||||
// for passive ED3 until a positive terminal-specific integration proves safe.
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
|
||||
export function shouldEnableSynchronizedOutputByDefault(
|
||||
env: NodeJS.ProcessEnv = Bun.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
terminalId: TerminalId = TERMINAL_ID,
|
||||
): boolean {
|
||||
if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
|
||||
if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
|
||||
if (platform === "win32") return false;
|
||||
|
||||
const term = env.TERM?.toLowerCase() ?? "";
|
||||
const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
|
||||
if (
|
||||
env.SSH_CONNECTION ||
|
||||
env.SSH_CLIENT ||
|
||||
env.SSH_TTY ||
|
||||
env.TMUX ||
|
||||
env.STY ||
|
||||
env.ZELLIJ ||
|
||||
term.startsWith("tmux") ||
|
||||
term.startsWith("screen")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (env.VTE_VERSION) return false;
|
||||
switch (termProgram) {
|
||||
case "gnome-terminal":
|
||||
case "kgx":
|
||||
case "ptyxis":
|
||||
case "xfce4-terminal":
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,9 @@ export function emergencyTerminalRestore(): void {
|
||||
// Blind restore only if we know a terminal was started but lost track of it
|
||||
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
||||
process.stdout.write(
|
||||
"\x1b[?2004l" + // Disable bracketed paste
|
||||
"\x1b[?2026l" + // End synchronized output
|
||||
"\x1b[?7h" + // Restore autowrap
|
||||
"\x1b[?2004l" + // Disable bracketed paste
|
||||
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
||||
"\x1b[?2048l" + // Disable in-band resize notifications
|
||||
"\x1b[<u" + // Pop kitty keyboard protocol
|
||||
@@ -897,6 +899,10 @@ export class ProcessTerminal implements Terminal {
|
||||
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
||||
}
|
||||
|
||||
// Leave paint-time terminal modes even if the process exits between the
|
||||
// begin/end halves of a frame. Safe no-ops on terminals that ignored them.
|
||||
this.#safeWrite("\x1b[?2026l\x1b[?7h");
|
||||
|
||||
// Disable bracketed paste mode
|
||||
this.#safeWrite("\x1b[?2004l");
|
||||
|
||||
|
||||
+112
-77
@@ -14,6 +14,7 @@ import {
|
||||
ImageProtocol,
|
||||
setCellDimensions,
|
||||
setTerminalImageProtocol,
|
||||
shouldEnableSynchronizedOutputByDefault,
|
||||
TERMINAL,
|
||||
} from "./terminal-capabilities";
|
||||
import {
|
||||
@@ -168,9 +169,8 @@ export interface RenderRequestOptions {
|
||||
allowUnknownViewportMutation?: boolean;
|
||||
}
|
||||
|
||||
/** Options for deferred native scrollback rebuild checkpoints. */
|
||||
/** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
|
||||
export interface NativeScrollbackRefreshOptions {
|
||||
/** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
|
||||
allowUnknownViewport?: boolean;
|
||||
}
|
||||
/** Type guard to check if a component implements Focusable */
|
||||
@@ -333,8 +333,8 @@ export class Container implements Component {
|
||||
* wrapped at the old size — clear viewport and scrollback so it rewraps at the
|
||||
* new geometry. Also flushes deferred content-only rewrites.
|
||||
* - `liveRegionPinned`: ED3-risk/unknown foreground stream with a reported live
|
||||
* suffix — optionally append newly sealed rows, then repaint the live tail
|
||||
* without letting transient rows enter native history.
|
||||
* suffix — optionally append newly sealed rows, then repaint the live/mutable
|
||||
* tail without letting transient rows enter native history.
|
||||
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
|
||||
* is set, emit those tail rows as scrollback growth first so streaming
|
||||
* output reaches terminal history before the corrected viewport is drawn.
|
||||
@@ -352,7 +352,7 @@ type RenderIntent =
|
||||
| { kind: "sessionReplace" }
|
||||
| { kind: "historyRebuild" }
|
||||
| { kind: "overlayRebuild" }
|
||||
| { kind: "liveRegionPinned"; appendFrom: number; appendTo: number }
|
||||
| { kind: "liveRegionPinned"; appendFrom: number; appendTo: number; renderViewportTop: number }
|
||||
| { kind: "viewportRepaint"; appendFrom?: number }
|
||||
| { kind: "deferredShrink"; paddedLength: number }
|
||||
| { kind: "deferredMutation" }
|
||||
@@ -387,7 +387,7 @@ export class TUI extends Container {
|
||||
#sixelProbeUnsubscribe?: () => void;
|
||||
#showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
|
||||
#clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
|
||||
#synchronizedOutputEnabled = !$flag("PI_NO_SYNC_OUTPUT");
|
||||
#synchronizedOutputEnabled = shouldEnableSynchronizedOutputByDefault();
|
||||
#paintBeginSequence = this.#synchronizedOutputEnabled ? PAINT_BEGIN : PAINT_BEGIN_NO_SYNC;
|
||||
#paintEndSequence = this.#synchronizedOutputEnabled ? PAINT_END : PAINT_END_NO_SYNC;
|
||||
#cursorBeginSequence = this.#synchronizedOutputEnabled ? CURSOR_BEGIN : CURSOR_BEGIN_NO_SYNC;
|
||||
@@ -525,8 +525,8 @@ export class TUI extends Container {
|
||||
|
||||
/**
|
||||
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
||||
* paints. Starts from `PI_NO_SYNC_OUTPUT` and is force-disabled at runtime if
|
||||
* the terminal reports mode 2026 unsupported via DECRQM.
|
||||
* paints. Starts from conservative terminal/env detection and is force-disabled
|
||||
* at runtime if the terminal reports mode 2026 unsupported via DECRQM.
|
||||
*/
|
||||
get synchronizedOutput(): boolean {
|
||||
return this.#synchronizedOutputEnabled;
|
||||
@@ -547,12 +547,13 @@ export class TUI extends Container {
|
||||
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
|
||||
* direct user-input rebuilds are unaffected.
|
||||
*
|
||||
* Disabling does not take effect until the next frame has been classified:
|
||||
* the event batch that ends a foreground stream both removes its UI rows
|
||||
* (loader/status teardown — a shrink) and clears this flag before the
|
||||
* throttled render timer fires. If the flag dropped immediately, that
|
||||
* teardown frame would hit the ED3-risk idle deferral and freeze on screen
|
||||
* (stale spinner) until the next keystroke.
|
||||
* Disabling stays active through one already-requested frame: the event batch
|
||||
* that ends a foreground stream both removes its UI rows (loader/status
|
||||
* teardown — a shrink) and clears this flag before the throttled render timer
|
||||
* fires. If the flag dropped immediately, that teardown frame would hit the
|
||||
* ED3-risk idle deferral and freeze on screen (stale spinner) until the next
|
||||
* keystroke. When no render is pending, disable immediately so a later
|
||||
* unrelated content mutation does not inherit foreground-stream privileges.
|
||||
*/
|
||||
setEagerNativeScrollbackRebuild(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
@@ -561,7 +562,16 @@ export class TUI extends Container {
|
||||
return;
|
||||
}
|
||||
if (!this.#eagerNativeScrollbackRebuild) return;
|
||||
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
||||
if (this.#renderRequested || this.#renderTimer !== undefined) {
|
||||
this.#eagerNativeScrollbackRebuildDisablePending = true;
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "win32" && TERMINAL.eagerEraseScrollbackRisk) {
|
||||
this.#streamingHighWater = 0;
|
||||
this.#markNativeScrollbackDirty();
|
||||
}
|
||||
this.#eagerNativeScrollbackRebuild = false;
|
||||
this.#eagerNativeScrollbackRebuildDisablePending = false;
|
||||
}
|
||||
|
||||
setFocus(component: Component | null): void {
|
||||
@@ -864,6 +874,11 @@ export class TUI extends Container {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
||||
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
||||
this.terminal.write(encodeKittyDeleteImage(id));
|
||||
}
|
||||
}
|
||||
this.#clearSixelProbeState();
|
||||
this.#stopped = true;
|
||||
if (this.#renderTimer) {
|
||||
@@ -898,7 +913,7 @@ export class TUI extends Container {
|
||||
* Callers should only invoke this at checkpoints where the user is expected to be
|
||||
* at the terminal bottom, such as after submitting a new prompt.
|
||||
*/
|
||||
refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
|
||||
refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean {
|
||||
if (!this.#nativeScrollbackDirty || this.#stopped) return false;
|
||||
// Multiplexer panes preserve their own history and never receive a
|
||||
// destructive clear, so a checkpoint "replay" cannot reconcile anything —
|
||||
@@ -908,18 +923,11 @@ export class TUI extends Container {
|
||||
this.#clearNativeScrollbackDirty();
|
||||
return false;
|
||||
}
|
||||
let nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
||||
const allowUnknownViewport = options?.allowUnknownViewport === true;
|
||||
if (nativeViewportAtBottom === false && allowUnknownViewport) {
|
||||
const retriedViewportAtBottom = this.#readNativeViewportAtBottom();
|
||||
if (this.#canReplayNativeScrollbackAtCheckpoint(retriedViewportAtBottom, allowUnknownViewport)) {
|
||||
nativeViewportAtBottom = retriedViewportAtBottom;
|
||||
}
|
||||
}
|
||||
if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, allowUnknownViewport)) {
|
||||
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
||||
if (!this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom)) {
|
||||
return false;
|
||||
}
|
||||
this.#prepareForcedRender(true, allowUnknownViewport);
|
||||
this.#prepareForcedRender(true, false);
|
||||
this.#renderRequested = false;
|
||||
this.#lastRenderAt = this.#renderScheduler.now();
|
||||
this.#doRender();
|
||||
@@ -947,7 +955,7 @@ export class TUI extends Container {
|
||||
this.#renderScheduler.scheduleImmediate(() => this.#scheduleRender());
|
||||
}
|
||||
|
||||
#prepareForcedRender(clearScrollback: boolean, allowUnknownViewportMutation: boolean): void {
|
||||
#prepareForcedRender(clearScrollback: boolean, _allowUnknownViewportMutation: boolean): void {
|
||||
const geometryChanged =
|
||||
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
||||
(this.#previousHeight > 0 && this.#previousHeight !== this.terminal.rows);
|
||||
@@ -957,7 +965,7 @@ export class TUI extends Container {
|
||||
const replayGeometry =
|
||||
geometryChanged &&
|
||||
!isMultiplexerSession() &&
|
||||
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation);
|
||||
this.#canReplayNativeScrollbackAtCheckpoint(this.#readNativeViewportAtBottom());
|
||||
this.#clearScrollbackOnNextRender ||= clearScrollback || replayGeometry;
|
||||
this.#forceViewportRepaintOnNextRender = true;
|
||||
if (this.#renderTimer) {
|
||||
@@ -1454,10 +1462,10 @@ export class TUI extends Container {
|
||||
// diff emitter would otherwise `\r\n`-scroll every transient frame (spinner
|
||||
// ticks, partial output) into native history. Non-ED3-risk terminals keep
|
||||
// their eager live rebuild, which already commits cleanly. Explicit
|
||||
// reconciles — the prompt-submit checkpoint (`clearScrollbackOnNextRender`)
|
||||
// and user-input/IME opt-ins (`explicitViewportMutation`) — are never
|
||||
// deferred: ED3 is safe there because the keystroke pins the host to the
|
||||
// bottom.
|
||||
// reconciles — the prompt-submit checkpoint (`clearScrollbackOnNextRender`),
|
||||
// user-input/IME opt-ins (`explicitViewportMutation`), and overlay visibility
|
||||
// reductions that must scrub transient overlay cells from native history —
|
||||
// are never deferred: the triggering interaction pins the host to the bottom.
|
||||
const streamingWasActive = this.#eagerNativeScrollbackRebuild;
|
||||
if (streamingWasActive && !this.#previousStreamingActive) {
|
||||
this.#streamingHighWater = 0;
|
||||
@@ -1466,7 +1474,16 @@ export class TUI extends Container {
|
||||
if (streamingWasActive && eagerEraseScrollbackRisk) {
|
||||
const streamingActive =
|
||||
this.#eagerNativeScrollbackRebuild && !this.#eagerNativeScrollbackRebuildDisablePending;
|
||||
const explicitReconcile = explicitViewportMutation || this.#clearScrollbackOnNextRender;
|
||||
const explicitReconcile =
|
||||
explicitViewportMutation || this.#clearScrollbackOnNextRender || overlayVisibilityReduced;
|
||||
// The defer below exists only to avoid `\r\n`-scrolling transient frames
|
||||
// past a reader parked in native scrollback. When the terminal can report
|
||||
// that the viewport is at the tail, there is no scrolled reader to yank,
|
||||
// so the planned intent must stand and commit normally — otherwise a row
|
||||
// that scrolls above the viewport top is dropped (neither pushed to
|
||||
// history nor kept in the capped viewport). Production POSIX ED3-risk
|
||||
// terminals cannot report this and stay `undefined`, so they still defer.
|
||||
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
||||
if (!streamingActive) {
|
||||
// Streaming just ended. Keep native scrollback dirty so the next
|
||||
// checkpoint reconciles the settled transcript; never erase here.
|
||||
@@ -1474,6 +1491,7 @@ export class TUI extends Container {
|
||||
this.#markNativeScrollbackDirty();
|
||||
} else if (
|
||||
!explicitReconcile &&
|
||||
nativeViewportAtBottom !== true &&
|
||||
(intent.kind === "sessionReplace" ||
|
||||
intent.kind === "historyRebuild" ||
|
||||
intent.kind === "overlayRebuild" ||
|
||||
@@ -1551,6 +1569,7 @@ export class TUI extends Container {
|
||||
cursorPos,
|
||||
intent.appendFrom,
|
||||
intent.appendTo,
|
||||
intent.renderViewportTop,
|
||||
prevViewportTop,
|
||||
prevHardwareCursorRow,
|
||||
);
|
||||
@@ -1692,13 +1711,10 @@ export class TUI extends Container {
|
||||
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
||||
}
|
||||
// A width change rewraps the whole transcript, so committed scrollback is
|
||||
// mis-wrapped at the old width. Yank is acceptable on an explicit resize, so
|
||||
// rebuild even when the viewport position is unknown (POSIX); the
|
||||
// known-scrolled case already deferred above.
|
||||
if (
|
||||
widthChanged ||
|
||||
this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, allowUnknownViewportMutation)
|
||||
) {
|
||||
// mis-wrapped at the old width. Rebuild only with a positive at-tail proof;
|
||||
// unknown viewports stay dirty because the host scroll position is not
|
||||
// observable and ED3 can yank readers.
|
||||
if (this.#canRebuildNativeScrollbackLive(nativeViewportAtBottom, false)) {
|
||||
return { kind: "historyRebuild" };
|
||||
}
|
||||
// POSIX terminals — and Windows Terminal/ConPTY — that cannot report the
|
||||
@@ -1715,29 +1731,25 @@ export class TUI extends Container {
|
||||
// stale rows until the next input even though the frame has a fresh bottom
|
||||
// viewport to show (issues #1682, foreground-stream fidelity on collapse).
|
||||
// Native history stays dirty and reconciles at the next checkpoint. With no
|
||||
// active eager turn the reader may be scrolled, so defer rather than
|
||||
// repainting over their history.
|
||||
// active eager turn the reader may be scrolled; even a padded shrink repaint
|
||||
// can move ED3-risk unknown host scrollback (WSL/Ghostty-style), so defer
|
||||
// completely rather than repainting over their history.
|
||||
if (nativeViewportAtBottom === undefined && eagerEraseScrollbackRisk) {
|
||||
this.#markNativeScrollbackDirty();
|
||||
if (this.#eagerNativeScrollbackRebuild) {
|
||||
return { kind: "viewportRepaint" };
|
||||
}
|
||||
if (newLines.length <= paddedViewportTop) {
|
||||
return { kind: "deferredMutation" };
|
||||
}
|
||||
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
||||
return this.#eagerNativeScrollbackRebuild ? { kind: "viewportRepaint" } : { kind: "deferredMutation" };
|
||||
}
|
||||
|
||||
// Non-ED3-risk POSIX with an unobservable viewport. If the shrink still
|
||||
// leaves enough rows to cover the previous viewport top, `deferredShrink`
|
||||
// can repaint that stable slice without committing duplicate rows to
|
||||
// native scrollback. When the shrink jumps above that padded viewport
|
||||
// top, `deferredShrink` would draw only blank padding and hide the live
|
||||
// prompt, so rebuild history instead (ED3 is safe on these terminals).
|
||||
// Non-ED3-risk POSIX with an unobservable viewport. `deferredShrink` is
|
||||
// safe only when changed rows are at or below the previous viewport top.
|
||||
// Middle/offscreen deletes renumber rows above the viewport and padding
|
||||
// the old length would repaint shifted rows or blank tail cells.
|
||||
if (newLines.length <= paddedViewportTop) {
|
||||
return { kind: "historyRebuild" };
|
||||
}
|
||||
this.#markNativeScrollbackDirty();
|
||||
if (diff.firstChanged < prevViewportTop) {
|
||||
return { kind: "deferredMutation" };
|
||||
}
|
||||
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
||||
}
|
||||
|
||||
@@ -1797,6 +1809,8 @@ export class TUI extends Container {
|
||||
this.#suppressNextSuffixScroll = false;
|
||||
if (
|
||||
suppressSuffixScroll &&
|
||||
!widthChanged &&
|
||||
!heightChanged &&
|
||||
diff.appendedLines &&
|
||||
diff.firstChanged < this.#previousLines.length &&
|
||||
!isMultiplexerSession()
|
||||
@@ -1891,7 +1905,10 @@ export class TUI extends Container {
|
||||
return { kind: "viewportRepaint" };
|
||||
}
|
||||
}
|
||||
if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
|
||||
// Geometry changes invalidate the terminal's cursor and viewport anchors;
|
||||
// even if the same coalesced frame also edits offscreen content for a scrolled
|
||||
// reader, the resize-specific branch below must repaint/clamp at the new size.
|
||||
if (!pureAppend && structuralMutation && !heightChanged && !widthChanged && !isMultiplexerSession()) {
|
||||
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
|
||||
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
|
||||
this.#markNativeScrollbackDirty();
|
||||
@@ -2022,6 +2039,14 @@ export class TUI extends Container {
|
||||
return { kind: "historyRebuild" };
|
||||
}
|
||||
this.#markNativeScrollbackDirty();
|
||||
if (
|
||||
nativeViewportAtBottom === undefined &&
|
||||
eagerEraseScrollbackRisk &&
|
||||
!cleanTailAppend &&
|
||||
!this.#eagerNativeScrollbackRebuild
|
||||
) {
|
||||
return { kind: "deferredMutation" };
|
||||
}
|
||||
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
|
||||
}
|
||||
|
||||
@@ -2128,15 +2153,8 @@ export class TUI extends Container {
|
||||
#nativeViewportIsKnownScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
|
||||
return nativeViewportAtBottom === false;
|
||||
}
|
||||
|
||||
#canReplayNativeScrollbackAtCheckpoint(
|
||||
nativeViewportAtBottom: boolean | undefined,
|
||||
allowUnknownViewport: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
nativeViewportAtBottom === true ||
|
||||
(nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
|
||||
);
|
||||
#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom: boolean | undefined): boolean {
|
||||
return nativeViewportAtBottom === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2146,10 +2164,9 @@ export class TUI extends Container {
|
||||
* the native viewport) is safe to emit *during ordinary rendering*. POSIX
|
||||
* terminals cannot report whether the user has scrolled up
|
||||
* (`isNativeViewportAtBottom()` is `undefined`), so an unknown position is
|
||||
* treated as unsafe: defer to a non-destructive viewport repaint, mark
|
||||
* scrollback dirty, and reconcile history at the next explicit checkpoint
|
||||
* ({@link refreshNativeScrollbackIfDirty} on prompt submit) where the
|
||||
* editor keystroke has already pinned the terminal to the bottom. Without
|
||||
* treated as unsafe: defer to a non-destructive viewport repaint and keep
|
||||
* scrollback dirty until a later render has a positive at-tail proof. A prompt
|
||||
* submit is no longer treated as proof for unobservable host scrollback.
|
||||
* this, every offscreen transcript edit while streaming wiped scrollback and
|
||||
* yanked a scrolled-up reader out of their current context.
|
||||
* `allowUnknownViewportMutation` (autocomplete/IME) opts directly
|
||||
@@ -2194,11 +2211,27 @@ export class TUI extends Container {
|
||||
if (this.#readNativeViewportAtBottom() !== undefined) return undefined;
|
||||
|
||||
this.#markNativeScrollbackDirty();
|
||||
const viewportTop = Math.max(0, newLines.length - height);
|
||||
const sealedEnd = Math.max(0, Math.min(liveRegionStart, newLines.length));
|
||||
const appendTo = Math.min(sealedEnd, viewportTop);
|
||||
const naturalViewportTop = Math.max(0, newLines.length - height);
|
||||
// Rows before the live-region boundary are sealed. If a live-region
|
||||
// collapse moves the bottom-anchored viewport back across rows already
|
||||
// written to native scrollback, repainting those sealed rows duplicates
|
||||
// them in history. Clamp only to the committed sealed boundary: mutable
|
||||
// rows inside the live region must remain visible even when an earlier
|
||||
// taller live frame pushed their old contents into native scrollback. The
|
||||
// dirty checkpoint later reconciles those stale mutable saved lines.
|
||||
const committedSealedEnd = Math.min(this.#scrollbackHighWater, liveRegionStart);
|
||||
const renderViewportTop = Math.max(naturalViewportTop, committedSealedEnd);
|
||||
// Every row above the natural viewport top has physically scrolled out of
|
||||
// the live viewport, so the terminal has already pushed it into native
|
||||
// scrollback — there is nowhere else for an off-screen row to live. It must
|
||||
// therefore be committed as real content, *including the head of the live
|
||||
// block itself* when that block alone overflows the viewport (a tall tool
|
||||
// result, a long streamed reply). Only the live tail that remains *within*
|
||||
// the natural viewport stays transient (repainted in place, deferred to the
|
||||
// checkpoint rebuild).
|
||||
const appendTo = naturalViewportTop;
|
||||
const appendFrom = Math.min(this.#scrollbackHighWater, appendTo);
|
||||
return { kind: "liveRegionPinned", appendFrom, appendTo };
|
||||
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
||||
}
|
||||
|
||||
#padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
|
||||
@@ -2392,12 +2425,14 @@ export class TUI extends Container {
|
||||
cursorPos: { row: number; col: number } | null,
|
||||
appendFrom: number,
|
||||
appendTo: number,
|
||||
renderViewportTop: number,
|
||||
prevViewportTop: number,
|
||||
prevHardwareCursorRow: number,
|
||||
): void {
|
||||
this.#fullRedrawCount += 1;
|
||||
const viewportTop = Math.max(0, lines.length - height);
|
||||
const boundedAppendTo = Math.max(0, Math.min(appendTo, viewportTop, lines.length));
|
||||
const naturalViewportTop = Math.max(0, lines.length - height);
|
||||
const viewportTop = Math.max(0, Math.min(renderViewportTop, lines.length));
|
||||
const boundedAppendTo = Math.max(0, Math.min(appendTo, naturalViewportTop, lines.length));
|
||||
const boundedAppendFrom = Math.max(0, Math.min(appendFrom, boundedAppendTo));
|
||||
|
||||
// Position at the top visible row with a relative move. Terminals clamp the
|
||||
@@ -2434,7 +2469,7 @@ export class TUI extends Container {
|
||||
buffer += this.#paintEndSequence;
|
||||
this.terminal.write(buffer);
|
||||
|
||||
this.#maxLinesRendered = lines.length;
|
||||
this.#maxLinesRendered = Math.max(lines.length, viewportTop + height);
|
||||
if (boundedAppendTo > this.#scrollbackHighWater) {
|
||||
this.#scrollbackHighWater = boundedAppendTo;
|
||||
}
|
||||
@@ -2677,7 +2712,7 @@ export class TUI extends Container {
|
||||
intent.kind === "diff"
|
||||
? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
|
||||
: intent.kind === "liveRegionPinned"
|
||||
? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo})`
|
||||
? `${intent.kind}(append=${intent.appendFrom}..${intent.appendTo}, viewportTop=${intent.renderViewportTop})`
|
||||
: intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
|
||||
? `${intent.kind}(appendFrom=${intent.appendFrom})`
|
||||
: intent.kind;
|
||||
|
||||
@@ -463,6 +463,18 @@ describe("ImageBudget transmit tracking", () => {
|
||||
expect([...budget.takeTransmits()]).toEqual([]);
|
||||
});
|
||||
|
||||
it("purges all transmitted ids for terminal-session cleanup", () => {
|
||||
const budget = new ImageBudget(3, () => {});
|
||||
budget.enqueueTransmit(1, "TX1");
|
||||
budget.enqueueTransmit(2, "TX2");
|
||||
expect(budget.shouldTransmit(1)).toBe(false);
|
||||
|
||||
expect([...budget.takeAllTransmittedIds()]).toEqual([1, 2]);
|
||||
expect([...budget.takeTransmits()]).toEqual([]);
|
||||
expect(budget.shouldTransmit(1)).toBe(true);
|
||||
expect([...budget.takeAllTransmittedIds()]).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-transmits an image after a purge frees its data", () => {
|
||||
const budget = new ImageBudget(2, () => {});
|
||||
budget.enqueueTransmit(1, "TX1");
|
||||
|
||||
@@ -130,12 +130,13 @@ describe("issue #1610: WSL Windows Terminal ED3-risk detection", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ WT_SESSION: WSL_WT_ENV.WT_SESSION }, "linux")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps native win32 and non-WT Linux hosts off the ED3-risk path", () => {
|
||||
it("keeps native win32 off the ED3-risk path and treats unknown POSIX as risky", () => {
|
||||
// Native Windows is guarded by dedicated process.platform checks in the
|
||||
// renderer; classifying it as ED3-risk would re-freeze streaming (#1635 family).
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ WT_SESSION: WSL_WT_ENV.WT_SESSION }, "win32")).toBe(false);
|
||||
// WSL inside a non-WT host (e.g. VS Code terminal) has no WT scrollback to protect.
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ WSL_DISTRO_NAME: "Ubuntu" }, "linux")).toBe(false);
|
||||
// A WSL/non-WT shell still lacks a scroll-position oracle from the renderer,
|
||||
// so default it to ED3-risk instead of assuming passive clears are safe.
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ WSL_DISTRO_NAME: "Ubuntu" }, "linux")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,11 +183,11 @@ describe("issue #1610: scrolled WSL Windows Terminal viewport", () => {
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getBufferPosition().viewportY).toBe(scrolled.viewportY);
|
||||
|
||||
// The deferred rewrite reconciles at an explicit checkpoint
|
||||
// (prompt submit passes allowUnknownViewport: true).
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
// Unknown viewport checkpoints no longer replay destructively; a
|
||||
// submit key is not proof that WT's host viewport is at the tail.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
@@ -154,14 +154,14 @@ describe("issue #1682: detectTerminalEagerEraseScrollbackRisk", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ ITERM_SESSION_ID: "w0t0p0" }, "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves unrecognized POSIX terminals on the eager path", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({}, "linux")).toBe(false);
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ TERM_PROGRAM: "vscode" }, "darwin")).toBe(false);
|
||||
it("treats unrecognized POSIX terminals as ED3-risk by default", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({}, "linux")).toBe(true);
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ TERM_PROGRAM: "vscode" }, "darwin")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue #1682: TUI eager scrollback rebuild", () => {
|
||||
it("defers on ED3-risk terminal traits and rebuilds at the checkpoint", async () => {
|
||||
it("defers on ED3-risk terminal traits and keeps checkpoint replay non-destructive while viewport is unknown", async () => {
|
||||
await withEnvPatch(CLEAR_MULTIPLEXER_ENV, async () => {
|
||||
await withTerminalRisk(true, async () => {
|
||||
const term = new VirtualTerminal(100, 24);
|
||||
@@ -181,9 +181,9 @@ describe("issue #1682: TUI eager scrollback rebuild", () => {
|
||||
await settle(term);
|
||||
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -219,9 +219,9 @@ describe("issue #1682: TUI eager scrollback rebuild", () => {
|
||||
"shrunk-18",
|
||||
"shrunk-19",
|
||||
]);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -295,7 +295,7 @@ describe("issue #1682: TUI eager scrollback rebuild", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still honors explicit user-input opt-ins on ED3-risk terminal traits", async () => {
|
||||
it("keeps explicit user-input opt-ins non-destructive on ED3-risk terminal traits", async () => {
|
||||
await withEnvPatch(CLEAR_MULTIPLEXER_ENV, async () => {
|
||||
await withTerminalRisk(true, async () => {
|
||||
const term = new VirtualTerminal(100, 24);
|
||||
@@ -314,7 +314,7 @@ describe("issue #1682: TUI eager scrollback rebuild", () => {
|
||||
tui.requestRender(false, { allowUnknownViewportMutation: true });
|
||||
await settle(term);
|
||||
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
} finally {
|
||||
tui.stop();
|
||||
|
||||
@@ -197,13 +197,13 @@ describe("issue #1746: scrolled reader in a non-WT ConPTY host (Tabby)", () => {
|
||||
expect(term.getBufferPosition().viewportY).toBe(scrolled.viewportY);
|
||||
expect(term.getViewport()).toEqual(visibleBefore);
|
||||
|
||||
// The deferred rewrite reconciles at an explicit checkpoint
|
||||
// (prompt submit passes allowUnknownViewport: true). A destructive
|
||||
// replay is acceptable there: the Enter keystroke has already
|
||||
// snapped the host viewport to the bottom.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
// Unknown viewport checkpoints no longer replay destructively: the
|
||||
// prompt keystroke is not proof that the host scrollback viewport is
|
||||
// at the tail on ConPTY/Tabby. Dirty history stays deferred until the
|
||||
// renderer gets a positive at-tail probe.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
@@ -147,12 +147,33 @@ describe("issue #1765: synchronized-output opt-out", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps synchronized output enabled by default", async () => {
|
||||
await withEnvPatch({ PI_NO_SYNC_OUTPUT: undefined }, async () => {
|
||||
it("honors the PI_TUI_SYNC_OUTPUT=0 disable alias", async () => {
|
||||
await withEnvPatch(
|
||||
{ PI_NO_SYNC_OUTPUT: undefined, PI_FORCE_SYNC_OUTPUT: undefined, PI_TUI_SYNC_OUTPUT: "0" },
|
||||
async () => {
|
||||
const term = new VirtualTerminal(32, 4, 100);
|
||||
const writes = captureWrites(term);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLines(["disabled sync"]));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await term.waitForRender();
|
||||
|
||||
expectNoSyncOutput(writes);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps synchronized output available behind an explicit force flag", async () => {
|
||||
await withEnvPatch({ PI_NO_SYNC_OUTPUT: undefined, PI_FORCE_SYNC_OUTPUT: "1" }, async () => {
|
||||
const term = new VirtualTerminal(32, 4, 100);
|
||||
const writes = captureWrites(term);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableLines(["default sync"]));
|
||||
tui.addChild(new MutableLines(["forced sync"]));
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
|
||||
@@ -469,10 +469,9 @@ describe("TUI terminal-state regressions", () => {
|
||||
|
||||
it("rewraps committed native scrollback when the terminal widens on POSIX (unknown viewport)", async () => {
|
||||
// POSIX reports no viewport position. A width change rewraps the whole
|
||||
// transcript, so committed scrollback must be rebuilt at the new width even
|
||||
// though we cannot prove the viewport is at the tail — yank is acceptable on
|
||||
// an explicit resize. Regression: widening drops the wrapped line count, which
|
||||
// the shrink-defer branch intercepted, leaving history wrapped at the old width.
|
||||
// transcript, but unknown host scrollback is not safe to erase: the renderer
|
||||
// keeps history dirty rather than emitting ED3 without a positive at-tail
|
||||
// proof.
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
|
||||
try {
|
||||
@@ -495,12 +494,9 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
|
||||
const wide = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
// Offscreen history rewrapped: each logical line is now a single full row.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
expect(wide).toContain(`L${i}:${"x".repeat(33)}`);
|
||||
}
|
||||
// The stale narrow fragments are gone — no duplicate old-width rows survive.
|
||||
expect(wide.some(line => line.length === 20 && line.startsWith("L"))).toBeFalse();
|
||||
// Unknown viewport: no destructive replay is emitted, so old-width
|
||||
// scrollback remains until a later render has a positive at-tail proof.
|
||||
expect(wide).toContain(`L0:${"x".repeat(17)}`);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -2078,20 +2074,15 @@ describe("TUI terminal-state regressions", () => {
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(visible(term).map(line => line.trim())).toEqual([
|
||||
"line-7",
|
||||
"line-8",
|
||||
"line-9",
|
||||
"line-10",
|
||||
"line-11",
|
||||
"prompt-row",
|
||||
]);
|
||||
const rebuilt = term.getScrollBuffer();
|
||||
const stillDeferred = term.getScrollBuffer();
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const pattern = new RegExp(`\\bline-${i}\\b`);
|
||||
expect(countMatches(rebuilt, pattern), `line-${i} appears once post-checkpoint`).toBe(1);
|
||||
expect(
|
||||
countMatches(stillDeferred, pattern),
|
||||
`line-${i} remains non-duplicated while deferred`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
@@ -2180,20 +2171,9 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(term.getScrollBuffer().join("\n")).not.toContain("short-");
|
||||
|
||||
term.scrollLines(999);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(visible(term).map(line => line.trim())).toEqual([
|
||||
"short-10",
|
||||
"short-11",
|
||||
"short-12",
|
||||
"short-13",
|
||||
"short-14",
|
||||
"short-15",
|
||||
"short-16",
|
||||
"short-17",
|
||||
"short-18",
|
||||
"prompt-row",
|
||||
]);
|
||||
expect(term.getScrollBuffer().join("\n")).not.toContain("short-");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -2371,12 +2351,13 @@ describe("TUI terminal-state regressions", () => {
|
||||
expect(offscreenPos.viewportY).toBeLessThan(offscreenPos.baseY);
|
||||
expect(visible(term).map(line => line.trim())).toEqual(anchored);
|
||||
|
||||
// At an explicit checkpoint (prompt submit equivalent) the deferred edit
|
||||
// reconciles into clean scrollback.
|
||||
// Unknown viewport checkpoints stay non-destructive; the dirty rewrite
|
||||
// waits for a positive at-tail proof instead of assuming prompt submit
|
||||
// makes host scrollback safe.
|
||||
term.scrollLines(999);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
expect(term.getScrollBuffer().join("\n")).toContain("seed-EDIT");
|
||||
expect(term.getScrollBuffer().join("\n")).not.toContain("seed-EDIT");
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -2606,8 +2587,8 @@ describe("TUI terminal-state regressions", () => {
|
||||
// The wrap row paints in the same frame — viewportRepaint is non-destructive
|
||||
// but writes the visible window, so the editor's new visual row is on screen.
|
||||
expect(visible(term).map(line => line.trim())).toContain("wrap-row");
|
||||
// And the deferred-cleanup checkpoint is queued for the next prompt submit.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
// Unknown viewport checkpoint remains non-destructive.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -2662,7 +2643,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
const view = visible(term).map(line => line.trim());
|
||||
expect(view).toContain("STATUS-NEW");
|
||||
expect(view).toContain("EXTRA");
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { formatSeed, runStressScenario, type Scenario, type StressScenarioResult } from "./render-stress-harness";
|
||||
|
||||
// Subprocess entry for the randomized render-stress pool. The parent test spawns
|
||||
// one `bun` process per scenario, writes the scenario JSON to stdin, and reads a
|
||||
// single JSON {@link StressScenarioResult} line back on stdout. Running each
|
||||
// scenario in its own process gives full isolation — fresh Ghostty WASM VT,
|
||||
// Subprocess entry for the randomized render-stress pool. The parent test writes
|
||||
// the scenario JSON to a temp file and spawns one `bun` process per scenario with
|
||||
// `<inputPath> <outputPath>` argv; this process reads the scenario, runs it, and
|
||||
// writes a single JSON {@link StressScenarioResult} to the output file. Running
|
||||
// each scenario in its own process gives full isolation — fresh Ghostty WASM VT,
|
||||
// fresh `process.platform`/env patches, no shared global state to coordinate —
|
||||
// and lets the parent enforce a hard timeout by killing the process, which a
|
||||
// Web Worker could not deliver reliably.
|
||||
// Web Worker could not deliver reliably. File transport (not stdio pipes) is used
|
||||
// because `bun test`'s spawned-child pipes do not deliver data on this runtime.
|
||||
|
||||
function serializeError(error: unknown): { error: string; stack?: string } {
|
||||
if (error instanceof Error) {
|
||||
@@ -16,7 +18,12 @@ function serializeError(error: unknown): { error: string; stack?: string } {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const scenario = JSON.parse(await Bun.stdin.text()) as Scenario;
|
||||
const inputPath = process.argv[2];
|
||||
const outputPath = process.argv[3];
|
||||
if (inputPath === undefined || outputPath === undefined) {
|
||||
throw new Error("render-stress-subprocess requires <inputPath> <outputPath> argv");
|
||||
}
|
||||
const scenario = JSON.parse(await Bun.file(inputPath).text()) as Scenario;
|
||||
let result: StressScenarioResult;
|
||||
try {
|
||||
// patchEnv defaults on: this process owns its env + platform for its one
|
||||
@@ -31,7 +38,7 @@ async function main(): Promise<void> {
|
||||
...serializeError(error),
|
||||
};
|
||||
}
|
||||
await Bun.write(Bun.stdout, JSON.stringify(result));
|
||||
await Bun.write(outputPath, JSON.stringify(result));
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { Subprocess } from "bun";
|
||||
import {
|
||||
buildScenarios,
|
||||
@@ -23,7 +26,7 @@ const SUBPROCESS_ENTRY = `${import.meta.dir}/render-stress-subprocess.ts`;
|
||||
// compiling Ghostty WASM) and is far too slow/heavy for CI. Run it locally only.
|
||||
const SKIP_IN_CI = Boolean(Bun.env.CI);
|
||||
|
||||
type StressSubprocess = Subprocess<Blob, "pipe", "pipe">;
|
||||
type StressSubprocess = Subprocess<"ignore", "ignore", "inherit">;
|
||||
|
||||
// Every spawned stress subprocess, tracked process-wide. Each scenario child
|
||||
// busy-loops in Ghostty WASM without yielding to its JS event loop, so a SIGTERM
|
||||
@@ -132,21 +135,34 @@ async function runScenariosInSubprocesses(scenarios: readonly Scenario[]): Promi
|
||||
if (firstError !== undefined) throw firstError;
|
||||
}
|
||||
|
||||
let ipcCounter = 0;
|
||||
|
||||
async function runScenarioInSubprocess(scenario: Scenario): Promise<void> {
|
||||
const proc = Bun.spawn([process.execPath, SUBPROCESS_ENTRY], {
|
||||
stdin: new Blob([JSON.stringify(scenario)]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
// `bun test`'s spawned-child stdio pipes do not deliver data on this runtime:
|
||||
// a child's piped stdout/stderr reads back empty and a `Blob` stdin arrives
|
||||
// empty, so the scenario JSON and its result travel through temp files rather
|
||||
// than pipes. Each scenario still runs in its own process for full isolation;
|
||||
// only the transport changed. stderr stays inherited so a hard crash (no
|
||||
// result file written) still surfaces its diagnostics.
|
||||
const base = path.join(os.tmpdir(), `omp-tui-stress-ipc-${process.pid}-${ipcCounter++}`);
|
||||
const inputPath = `${base}.in.json`;
|
||||
const outputPath = `${base}.out.json`;
|
||||
await Bun.write(inputPath, JSON.stringify(scenario));
|
||||
const proc = Bun.spawn([process.execPath, SUBPROCESS_ENTRY, inputPath, outputPath], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
liveSubprocesses.add(proc);
|
||||
const stdoutPromise = new Response(proc.stdout).text();
|
||||
const stderrPromise = new Response(proc.stderr).text();
|
||||
const completed = (async (): Promise<StressScenarioResult> => {
|
||||
const [stdout, stderr, exitCode] = await Promise.all([stdoutPromise, stderrPromise, proc.exited]);
|
||||
return parseScenarioResult(stdout, stderr, scenario, exitCode);
|
||||
const exitCode = await proc.exited;
|
||||
const output = await Bun.file(outputPath)
|
||||
.text()
|
||||
.catch(() => "");
|
||||
return parseScenarioResult(output, "", scenario, exitCode);
|
||||
})();
|
||||
void completed.catch(() => {});
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timer: Timer | undefined;
|
||||
const timedOut = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
@@ -163,6 +179,8 @@ async function runScenarioInSubprocess(scenario: Scenario): Promise<void> {
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
liveSubprocesses.delete(proc);
|
||||
fs.rmSync(inputPath, { force: true });
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ describe("StdinBuffer", () => {
|
||||
processInput("hello \u4e16\u754c");
|
||||
expect(emittedSequences).toEqual(["h", "e", "l", "l", "o", " ", "\u4e16", "\u754c"]);
|
||||
});
|
||||
|
||||
it("emits surrogate-pair code points as one text sequence", () => {
|
||||
processInput("🙂");
|
||||
expect(emittedSequences).toEqual(["🙂"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Partial Escape Sequences", () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ function rows(prefix: string, count: number): string[] {
|
||||
}
|
||||
|
||||
describe("streaming scrollback defer", () => {
|
||||
it("keeps sealed prefix scrollable while deferring live-region rows on ED3-risk terminals", async () => {
|
||||
it("commits every row above the viewport (incl. the live-block head) without ED3 on ED3-risk terminals", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await withTerminalRisk(true, async () => {
|
||||
const term = new VirtualTerminal(20, 4);
|
||||
@@ -94,10 +94,14 @@ describe("streaming scrollback defer", () => {
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
// The live block (think-*) overflows the 4-row viewport. Every row that
|
||||
// scrolled above the viewport top — including the live block's own head
|
||||
// (think-0/think-1) — must enter native scrollback; only the visible tail
|
||||
// stays transient. No ED3 erase fires during streaming.
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual([
|
||||
...rows("prior-", 12),
|
||||
...rows("think-", 6).slice(-4),
|
||||
...rows("think-", 6),
|
||||
]);
|
||||
|
||||
live.setLines(rows("think-", 8));
|
||||
@@ -106,8 +110,40 @@ describe("streaming scrollback defer", () => {
|
||||
|
||||
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(buffer.filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
|
||||
expect(buffer.slice(-4)).toEqual(rows("think-", 8).slice(-4));
|
||||
expect(buffer).toEqual([...rows("prior-", 12), ...rows("think-", 8)]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the head of a tall live block that alone overflows the viewport (no sealed prefix)", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await withTerminalRisk(true, async () => {
|
||||
const term = new VirtualTerminal(20, 4);
|
||||
overrideProbe(term, undefined);
|
||||
const tui = new TUI(term);
|
||||
// The only block is the live one (liveRegionStart === 0), so the entire
|
||||
// scrollback commit originates inside the live region. A clamp to the
|
||||
// sealed boundary would commit nothing and erase the block's head.
|
||||
const live = new LiveLineList([]);
|
||||
|
||||
try {
|
||||
tui.addChild(live);
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const writes = capture(term);
|
||||
tui.setEagerNativeScrollbackRebuild(true);
|
||||
|
||||
live.setLines(rows("tool-", 10));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
// tool-0..tool-5 scrolled above the 4-row viewport and must be in
|
||||
// scrollback; tool-6..tool-9 fill the viewport. None are erased.
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getScrollBuffer().map(line => line.trimEnd())).toEqual(rows("tool-", 10));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -155,18 +191,14 @@ describe("streaming scrollback defer", () => {
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getScrollBuffer().length).toBe(scrollbackBefore);
|
||||
|
||||
// The prompt-submit checkpoint reconciles the deferred transcript with
|
||||
// a single ED3 + re-emit — even while eager is still active, because an
|
||||
// explicit reconcile is never deferred.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true);
|
||||
// Unknown viewport checkpoints no longer replay destructively. The
|
||||
// renderer keeps native history dirty rather than treating a prompt
|
||||
// submit as proof that a real host viewport is at tail.
|
||||
expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(false);
|
||||
await settle(term);
|
||||
|
||||
expect(eraseScrollbackCount(writes)).toBe(1);
|
||||
|
||||
const scrollbackAfter = term.getScrollBuffer();
|
||||
expect(scrollbackAfter.length).toBeGreaterThan(scrollbackBefore);
|
||||
expect(scrollbackAfter.join("\n")).toContain("stream-");
|
||||
expect(scrollbackAfter.join("\n")).toContain("more-");
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getScrollBuffer().length).toBe(scrollbackBefore);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -213,4 +245,46 @@ describe("streaming scrollback defer", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate committed sealed rows when the live region collapses mid-stream", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await withTerminalRisk(true, async () => {
|
||||
const term = new VirtualTerminal(20, 4);
|
||||
overrideProbe(term, undefined);
|
||||
const tui = new TUI(term);
|
||||
// Sealed prefix above a live block: growth commits the sealed rows to
|
||||
// native scrollback; a later collapse must not repaint them back into the
|
||||
// viewport (which would duplicate them in history with no ED3 to erase).
|
||||
const sealed = new LineList(rows("prior-", 12));
|
||||
const live = new LiveLineList([]);
|
||||
|
||||
try {
|
||||
tui.addChild(sealed);
|
||||
tui.addChild(live);
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const writes = capture(term);
|
||||
tui.setEagerNativeScrollbackRebuild(true);
|
||||
|
||||
// Live block overflows the viewport — sealed prefix commits once.
|
||||
live.setLines(rows("think-", 30));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
|
||||
|
||||
// Live block collapses to its compact result. The bottom-anchored
|
||||
// viewport would re-expose committed sealed rows; the pin must clamp the
|
||||
// repaint to the committed boundary instead of duplicating them.
|
||||
live.setLines(["done"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
expect(eraseScrollbackCount(writes)).toBe(0);
|
||||
expect(term.getScrollBuffer().filter(line => line.startsWith("prior-"))).toEqual(rows("prior-", 12));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
detectTerminalEagerEraseScrollbackRisk,
|
||||
shouldEnableSynchronizedOutputByDefault,
|
||||
} from "@oh-my-pi/pi-tui/terminal-capabilities";
|
||||
|
||||
describe("terminal capability defaults", () => {
|
||||
it("treats SSH-stripped Linux truecolor sessions as ED3-risk", () => {
|
||||
expect(
|
||||
detectTerminalEagerEraseScrollbackRisk(
|
||||
{ TERM: "xterm-256color", COLORTERM: "truecolor", SSH_TTY: "/dev/pts/3" },
|
||||
"linux",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Ptyxis and unknown POSIX terminals as ED3-risk by default", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ TERM_PROGRAM: "ptyxis" }, "linux")).toBe(true);
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ TERM: "xterm-256color" }, "linux")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps native win32 on the dedicated ConPTY deferral path", () => {
|
||||
expect(detectTerminalEagerEraseScrollbackRisk({ WT_SESSION: "abc" }, "win32")).toBe(false);
|
||||
});
|
||||
|
||||
it("disables synchronized output by default for remote, VTE, and unknown profiles", () => {
|
||||
expect(shouldEnableSynchronizedOutputByDefault({ SSH_CONNECTION: "1 2 3 4" }, "linux", "base")).toBe(false);
|
||||
expect(shouldEnableSynchronizedOutputByDefault({ VTE_VERSION: "6800" }, "linux", "base")).toBe(false);
|
||||
expect(shouldEnableSynchronizedOutputByDefault({ TERM: "xterm-256color" }, "linux", "base")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit synchronized-output force-on for diagnostics", () => {
|
||||
expect(
|
||||
shouldEnableSynchronizedOutputByDefault(
|
||||
{ PI_FORCE_SYNC_OUTPUT: "1", SSH_CONNECTION: "1 2 3 4" },
|
||||
"linux",
|
||||
"base",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -468,7 +468,7 @@ export class VirtualTerminal implements Terminal {
|
||||
text += " ";
|
||||
} else {
|
||||
text +=
|
||||
cell.grapheme_len > 0 ? this.#term.getGraphemeString(row, col) : String.fromCodePoint(cell.codepoint);
|
||||
cell.grapheme_len > 0 ? this.#term.getGraphemeString(row, col) : this.#safeCodepointText(cell.codepoint);
|
||||
}
|
||||
}
|
||||
return text.replace(/\s+$/u, "");
|
||||
@@ -490,7 +490,7 @@ export class VirtualTerminal implements Terminal {
|
||||
text +=
|
||||
cell.grapheme_len > 0
|
||||
? this.#term.getScrollbackGraphemeString(offset, col)
|
||||
: String.fromCodePoint(cell.codepoint);
|
||||
: this.#safeCodepointText(cell.codepoint);
|
||||
}
|
||||
}
|
||||
text = text.replace(/\s+$/u, "");
|
||||
@@ -498,6 +498,18 @@ export class VirtualTerminal implements Terminal {
|
||||
return text;
|
||||
}
|
||||
|
||||
#safeCodepointText(codepoint: number): string {
|
||||
if (
|
||||
!Number.isInteger(codepoint) ||
|
||||
codepoint <= 0 ||
|
||||
codepoint > 0x10ffff ||
|
||||
(codepoint >= 0xd800 && codepoint <= 0xdfff)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return String.fromCodePoint(codepoint);
|
||||
}
|
||||
|
||||
#isDefaultBg(cell: GhosttyCell): boolean {
|
||||
return cell.bg_r === DEFAULT_BG_R && cell.bg_g === DEFAULT_BG_G && cell.bg_b === DEFAULT_BG_B;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "15.9.2",
|
||||
"version": "15.9.3",
|
||||
"description": "Shared utilities for pi packages",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -128,12 +128,18 @@ def classify_next_step(primary: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def system_append(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
return render(_load("system_append.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
def system_append(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace, bot_login: str) -> str:
|
||||
return render(
|
||||
_load("system_append.md"),
|
||||
{"repo": repo, "issue": issue, "workspace": workspace, "bot_login": bot_login},
|
||||
)
|
||||
|
||||
|
||||
def system_append_pr_review(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
return render(_load("system_append_pr_review.md"), {"repo": repo, "issue": issue, "workspace": workspace})
|
||||
def system_append_pr_review(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace, bot_login: str) -> str:
|
||||
return render(
|
||||
_load("system_append_pr_review.md"),
|
||||
{"repo": repo, "issue": issue, "workspace": workspace, "bot_login": bot_login},
|
||||
)
|
||||
|
||||
|
||||
def kickoff(*, repo: RepoInfo, issue: IssueInfo, workspace: Workspace) -> str:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You are **robomp**, an autonomous triage-and-fix bot operating on `{{repo.full_name}}`.
|
||||
You are **@{{bot_login}}**, an autonomous triage-and-fix bot operating on `{{repo.full_name}}`.
|
||||
|
||||
<critical>
|
||||
- **Triage first.** Fresh, unclassified issue → first action is `classify_issue(primary=..., rationale=...)`. NEVER comment, push, open a PR, or run a repro until labels land.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You are **robomp**, reviewing an incoming pull request on `{{repo.full_name}}`.
|
||||
You are **@{{bot_login}}**, reviewing an incoming pull request on `{{repo.full_name}}`.
|
||||
|
||||
<critical>
|
||||
- **Read-only PR review.** Never edit files, commit, push, open a PR, approve, request changes, merge, or close.
|
||||
|
||||
@@ -512,9 +512,19 @@ def _run_rpc_blocking(
|
||||
)
|
||||
inputs.db.set_event_model(inputs.delivery_id, chosen_model)
|
||||
append_system_prompt = (
|
||||
persona.system_append_pr_review(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
|
||||
persona.system_append_pr_review(
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
bot_login=inputs.settings.bot_login,
|
||||
)
|
||||
if task_kind == "review_pr"
|
||||
else persona.system_append(repo=inputs.repo, issue=inputs.issue, workspace=inputs.workspace)
|
||||
else persona.system_append(
|
||||
repo=inputs.repo,
|
||||
issue=inputs.issue,
|
||||
workspace=inputs.workspace,
|
||||
bot_login=inputs.settings.bot_login,
|
||||
)
|
||||
)
|
||||
|
||||
with RpcClient(
|
||||
|
||||
@@ -181,3 +181,27 @@ def test_review_completion_reminder_mentions_submit_only() -> None:
|
||||
)
|
||||
assert "submit_pr_review" in out
|
||||
assert "gh_open_pr" not in out
|
||||
|
||||
|
||||
def test_system_append_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
bot_login="Svitter",
|
||||
)
|
||||
# The hardcoded persona name MUST be replaced by the configured login so
|
||||
# the agent self-mentions the account that actually receives webhooks.
|
||||
assert "You are **@Svitter**" in out
|
||||
assert "**robomp**" not in out
|
||||
|
||||
|
||||
def test_system_append_pr_review_renders_configured_bot_login() -> None:
|
||||
out = persona.system_append_pr_review(
|
||||
repo=_Repo(),
|
||||
issue=_Issue(),
|
||||
workspace=_Workspace(),
|
||||
bot_login="Svitter",
|
||||
)
|
||||
assert "You are **@Svitter**" in out
|
||||
assert "**robomp**" not in out
|
||||
|
||||
@@ -149,7 +149,7 @@ def _patch_worker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("robomp.worker.host_tools.build", lambda _b: ())
|
||||
monkeypatch.setattr(
|
||||
"robomp.worker.persona.system_append",
|
||||
lambda *, repo, issue, workspace: "SYS",
|
||||
lambda *, repo, issue, workspace, bot_login: "SYS",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"robomp.worker.persona.seed_phases",
|
||||
|
||||
Reference in New Issue
Block a user