@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/tools/eval-render.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +25 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/settings-schema.ts +11 -1
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +28 -27
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/__tests__/shared-executors.test.ts +2 -2
- package/src/eval/agent-bridge.ts +4 -5
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/lsp/client.ts +80 -2
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/main.ts +1 -1
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +22 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/tool-execution.ts +27 -13
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +26 -10
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/interactive-mode.ts +4 -4
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/session/agent-session.ts +6 -2
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/task/render.ts +38 -11
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-render.ts +24 -9
- package/src/tools/eval.ts +14 -19
- package/src/tools/fetch.ts +5 -5
- package/src/tools/read.ts +7 -7
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/ssh.ts +21 -8
- package/src/tools/write.ts +17 -8
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/render.ts +3 -3
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
package/src/tools/eval.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { jsBackend, pythonBackend } from "../eval";
|
|
6
6
|
import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend";
|
|
7
|
-
import {
|
|
7
|
+
import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout";
|
|
8
8
|
import { IdleTimeout } from "../eval/idle-timeout";
|
|
9
9
|
import { defaultEvalSessionId } from "../eval/session-id";
|
|
10
10
|
import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
|
|
@@ -313,16 +313,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
313
313
|
for (let i = 0; i < cells.length; i++) {
|
|
314
314
|
const cell = cells[i];
|
|
315
315
|
const backend = cell.resolved.backend;
|
|
316
|
-
// The per-cell `timeout` is a
|
|
317
|
-
// work
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// agent/llm is bounded by a plain wall-clock timeout. The watchdog
|
|
324
|
-
// drives `combinedSignal`; we pass no wall-clock deadline downstream
|
|
325
|
-
// so the backends never arm a competing fixed timer.
|
|
316
|
+
// The per-cell `timeout` is a budget on the cell runtime's *own*
|
|
317
|
+
// work. Host-side `agent()`/`parallel()`/`llm()` bridge calls suspend
|
|
318
|
+
// that budget entirely and restart a fresh timeout window when control
|
|
319
|
+
// returns to Python/JS. Compute, stdout, `log()`/`phase()`, and
|
|
320
|
+
// ordinary tool calls all count against the budget. The watchdog drives
|
|
321
|
+
// `combinedSignal`; we pass no wall-clock deadline downstream so the
|
|
322
|
+
// backends never arm a competing fixed timer.
|
|
326
323
|
const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
|
|
327
324
|
const idle = new IdleTimeout(idleTimeoutMs);
|
|
328
325
|
const combinedSignal = signal
|
|
@@ -355,14 +352,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
355
352
|
outputSink!.push(chunk);
|
|
356
353
|
},
|
|
357
354
|
onStatus: event => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (event.op === EVAL_HEARTBEAT_OP) {
|
|
365
|
-
idle.bump();
|
|
355
|
+
if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
|
|
356
|
+
idle.pause();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (event.op === EVAL_TIMEOUT_RESUME_OP) {
|
|
360
|
+
idle.resume();
|
|
366
361
|
return;
|
|
367
362
|
}
|
|
368
363
|
cellResult.statusEvents ??= [];
|
package/src/tools/fetch.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ToolSession } from "../sdk";
|
|
|
14
14
|
import type { AgentStorage } from "../session/agent-storage";
|
|
15
15
|
import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
|
|
16
16
|
import { renderStatusLine, urlHyperlink } from "../tui";
|
|
17
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
17
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
18
18
|
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
|
19
19
|
import { ensureTool } from "../utils/tools-manager";
|
|
20
20
|
import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
|
|
@@ -1488,11 +1488,11 @@ export function renderReadUrlResult(
|
|
|
1488
1488
|
const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
|
|
1489
1489
|
const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
|
|
1490
1490
|
const outputBlock = new CachedOutputBlock();
|
|
1491
|
-
return {
|
|
1491
|
+
return markFramedBlockComponent({
|
|
1492
1492
|
render: (width: number) =>
|
|
1493
1493
|
outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
|
|
1494
1494
|
invalidate: () => outputBlock.invalidate(),
|
|
1495
|
-
};
|
|
1495
|
+
});
|
|
1496
1496
|
}
|
|
1497
1497
|
|
|
1498
1498
|
const description = formatReadUrlDescription(details.finalUrl);
|
|
@@ -1542,7 +1542,7 @@ export function renderReadUrlResult(
|
|
|
1542
1542
|
let lastExpanded: boolean | undefined;
|
|
1543
1543
|
let contentPreviewLines: string[] | undefined;
|
|
1544
1544
|
|
|
1545
|
-
return {
|
|
1545
|
+
return markFramedBlockComponent({
|
|
1546
1546
|
render: (width: number) => {
|
|
1547
1547
|
const { expanded } = options;
|
|
1548
1548
|
|
|
@@ -1582,5 +1582,5 @@ export function renderReadUrlResult(
|
|
|
1582
1582
|
contentPreviewLines = undefined;
|
|
1583
1583
|
lastExpanded = undefined;
|
|
1584
1584
|
},
|
|
1585
|
-
};
|
|
1585
|
+
});
|
|
1586
1586
|
}
|
package/src/tools/read.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
truncateLine,
|
|
30
30
|
} from "../session/streaming-output";
|
|
31
31
|
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
|
|
32
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
32
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
33
33
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
34
34
|
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
|
|
35
35
|
import { convertFileWithMarkit } from "../utils/markit";
|
|
@@ -2413,11 +2413,11 @@ export const readToolRenderer = {
|
|
|
2413
2413
|
const header = renderStatusLine({ icon: "error", title }, uiTheme);
|
|
2414
2414
|
const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
|
|
2415
2415
|
const outputBlock = new CachedOutputBlock();
|
|
2416
|
-
return {
|
|
2416
|
+
return markFramedBlockComponent({
|
|
2417
2417
|
render: (width: number) =>
|
|
2418
2418
|
outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
|
|
2419
2419
|
invalidate: () => outputBlock.invalidate(),
|
|
2420
|
-
};
|
|
2420
|
+
});
|
|
2421
2421
|
}
|
|
2422
2422
|
const details = result.details;
|
|
2423
2423
|
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
@@ -2465,7 +2465,7 @@ export const readToolRenderer = {
|
|
|
2465
2465
|
const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
|
|
2466
2466
|
const lines = [...detailLines, ...warningLines];
|
|
2467
2467
|
const outputBlock = new CachedOutputBlock();
|
|
2468
|
-
return {
|
|
2468
|
+
return markFramedBlockComponent({
|
|
2469
2469
|
render: (width: number) =>
|
|
2470
2470
|
outputBlock.render(
|
|
2471
2471
|
{
|
|
@@ -2482,7 +2482,7 @@ export const readToolRenderer = {
|
|
|
2482
2482
|
uiTheme,
|
|
2483
2483
|
),
|
|
2484
2484
|
invalidate: () => outputBlock.invalidate(),
|
|
2485
|
-
};
|
|
2485
|
+
});
|
|
2486
2486
|
}
|
|
2487
2487
|
|
|
2488
2488
|
const suffix = details?.suffixResolution;
|
|
@@ -2514,7 +2514,7 @@ export const readToolRenderer = {
|
|
|
2514
2514
|
let cachedWidth: number | undefined;
|
|
2515
2515
|
let cachedExpanded: boolean | undefined;
|
|
2516
2516
|
let cachedLines: string[] | undefined;
|
|
2517
|
-
return {
|
|
2517
|
+
return markFramedBlockComponent({
|
|
2518
2518
|
render: (width: number) => {
|
|
2519
2519
|
const expanded = options.expanded;
|
|
2520
2520
|
if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
|
|
@@ -2551,7 +2551,7 @@ export const readToolRenderer = {
|
|
|
2551
2551
|
cachedExpanded = undefined;
|
|
2552
2552
|
cachedLines = undefined;
|
|
2553
2553
|
},
|
|
2554
|
-
};
|
|
2554
|
+
});
|
|
2555
2555
|
},
|
|
2556
2556
|
mergeCallAndResult: true,
|
|
2557
2557
|
};
|
|
@@ -171,6 +171,52 @@ export function formatMoreItems(remaining: number, itemType: string): string {
|
|
|
171
171
|
return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Maximum rows a tool's streaming/pending *call* preview may render before it is
|
|
176
|
+
* capped. This is intentionally conservative: the preview still sits inside a
|
|
177
|
+
* transcript that already consumed some viewport rows, and tool blocks carry
|
|
178
|
+
* extra chrome (status/header/border/"more lines"), so a "reasonable" raw code
|
|
179
|
+
* or command preview like 10-12 lines can still overflow and strand its top
|
|
180
|
+
* while the block is volatile. Keeping the live call window short avoids that
|
|
181
|
+
* across terminals without turning the transcript into an interactive scroller.
|
|
182
|
+
*/
|
|
183
|
+
export const CALL_PREVIEW_MAX_LINES = 6;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Cap a pre-rendered pending/call preview to a bounded window. When truncated,
|
|
187
|
+
* show both the head and the live tail so the user can still see what the tool
|
|
188
|
+
* is currently writing while the volatile block stays short enough not to strand
|
|
189
|
+
* its top above the viewport. `Ctrl+O` widens the bounded window, but does not
|
|
190
|
+
* fully uncap live tool previews for the same reason.
|
|
191
|
+
*
|
|
192
|
+
* `prefix` (raw, e.g. a dim tree gutter) is prepended to the summary line so
|
|
193
|
+
* nested previews stay aligned.
|
|
194
|
+
*/
|
|
195
|
+
export function capPreviewLines(
|
|
196
|
+
lines: string[],
|
|
197
|
+
theme: Theme,
|
|
198
|
+
options: { max?: number; expanded?: boolean; prefix?: string } = {},
|
|
199
|
+
): string[] {
|
|
200
|
+
const max = options.max ?? (options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : CALL_PREVIEW_MAX_LINES);
|
|
201
|
+
if (lines.length <= max) return lines;
|
|
202
|
+
if (max <= 1) {
|
|
203
|
+
const hint = formatExpandHint(theme, options.expanded, true);
|
|
204
|
+
const moreLine = `${formatMoreItems(lines.length, "line")}${hint ? ` ${hint}` : ""}`;
|
|
205
|
+
return [`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`];
|
|
206
|
+
}
|
|
207
|
+
const bodyBudget = max - 1; // reserve one summary row
|
|
208
|
+
const headCount = Math.max(1, Math.ceil(bodyBudget / 2));
|
|
209
|
+
const tailCount = Math.max(1, bodyBudget - headCount);
|
|
210
|
+
const hidden = Math.max(0, lines.length - headCount - tailCount);
|
|
211
|
+
const hint = formatExpandHint(theme, options.expanded, true);
|
|
212
|
+
const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
|
|
213
|
+
return [
|
|
214
|
+
...lines.slice(0, headCount),
|
|
215
|
+
`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`,
|
|
216
|
+
...lines.slice(lines.length - tailCount),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
export function formatMeta(meta: string[], theme: Theme): string {
|
|
175
221
|
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
|
|
176
222
|
}
|
package/src/tools/ssh.ts
CHANGED
|
@@ -13,11 +13,11 @@ import type { SSHHostInfo } from "../ssh/connection-manager";
|
|
|
13
13
|
import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
|
|
14
14
|
import { executeSSH } from "../ssh/ssh-executor";
|
|
15
15
|
import { renderStatusLine } from "../tui";
|
|
16
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
16
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
17
17
|
import type { ToolSession } from ".";
|
|
18
18
|
import { truncateForPrompt } from "./approval";
|
|
19
19
|
import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
|
|
20
|
-
import { replaceTabs } from "./render-utils";
|
|
20
|
+
import { capPreviewLines, replaceTabs } from "./render-utils";
|
|
21
21
|
import { ToolError } from "./tool-errors";
|
|
22
22
|
import { toolResult } from "./tool-result";
|
|
23
23
|
import { clampTimeout } from "./tool-timeouts";
|
|
@@ -244,16 +244,22 @@ export const sshToolRenderer = {
|
|
|
244
244
|
const header = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}]` }, uiTheme);
|
|
245
245
|
const cmdLines = formatSshCommandLines(command, uiTheme);
|
|
246
246
|
const outputBlock = new CachedOutputBlock();
|
|
247
|
-
return {
|
|
247
|
+
return markFramedBlockComponent({
|
|
248
248
|
render: (width: number): string[] =>
|
|
249
249
|
outputBlock.render(
|
|
250
|
-
{
|
|
250
|
+
{
|
|
251
|
+
header,
|
|
252
|
+
state: "pending",
|
|
253
|
+
sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
|
|
254
|
+
width,
|
|
255
|
+
animate: true,
|
|
256
|
+
},
|
|
251
257
|
uiTheme,
|
|
252
258
|
),
|
|
253
259
|
invalidate: () => {
|
|
254
260
|
outputBlock.invalidate();
|
|
255
261
|
},
|
|
256
|
-
};
|
|
262
|
+
});
|
|
257
263
|
},
|
|
258
264
|
|
|
259
265
|
renderResult(
|
|
@@ -273,7 +279,7 @@ export const sshToolRenderer = {
|
|
|
273
279
|
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
274
280
|
const outputBlock = new CachedOutputBlock();
|
|
275
281
|
|
|
276
|
-
return {
|
|
282
|
+
return markFramedBlockComponent({
|
|
277
283
|
render: (width: number): string[] => {
|
|
278
284
|
// REACTIVE: read mutable options at render time
|
|
279
285
|
const { expanded, renderContext } = options;
|
|
@@ -319,7 +325,14 @@ export const sshToolRenderer = {
|
|
|
319
325
|
{
|
|
320
326
|
header,
|
|
321
327
|
state: "success",
|
|
322
|
-
sections: [
|
|
328
|
+
sections: [
|
|
329
|
+
{
|
|
330
|
+
lines: options.isPartial
|
|
331
|
+
? capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded })
|
|
332
|
+
: cmdLines,
|
|
333
|
+
},
|
|
334
|
+
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
|
|
335
|
+
],
|
|
323
336
|
width,
|
|
324
337
|
},
|
|
325
338
|
uiTheme,
|
|
@@ -328,7 +341,7 @@ export const sshToolRenderer = {
|
|
|
328
341
|
invalidate: () => {
|
|
329
342
|
outputBlock.invalidate();
|
|
330
343
|
},
|
|
331
|
-
};
|
|
344
|
+
});
|
|
332
345
|
},
|
|
333
346
|
mergeCallAndResult: true,
|
|
334
347
|
};
|
package/src/tools/write.ts
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
import { ToolError } from "./tool-errors";
|
|
59
59
|
import { toolResult } from "./tool-result";
|
|
60
60
|
|
|
61
|
-
const LOOSE_HASHLINE_HEADER_RE = /^\s
|
|
61
|
+
const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
|
|
62
62
|
|
|
63
63
|
let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
|
|
64
64
|
async function loadFflate(): Promise<typeof import("fflate")> {
|
|
@@ -109,7 +109,7 @@ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: str
|
|
|
109
109
|
/**
|
|
110
110
|
* Strip hashline display prefixes from write content.
|
|
111
111
|
*
|
|
112
|
-
* Only active when hashline edit mode is enabled — the model sees
|
|
112
|
+
* Only active when hashline edit mode is enabled — the model sees `[PATH#HASH]`
|
|
113
113
|
* headers plus `LINE:` prefixes in read output and sometimes copies them into write content.
|
|
114
114
|
*/
|
|
115
115
|
function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
|
|
@@ -122,7 +122,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
|
|
|
122
122
|
/**
|
|
123
123
|
* Record a snapshot of the freshly-written `content` for `absolutePath`
|
|
124
124
|
* so subsequent hashline edits address the new file with a current tag,
|
|
125
|
-
* and return the matching
|
|
125
|
+
* and return the matching `[displayPath#TAG]` header. Returns `undefined`
|
|
126
126
|
* when the session is not in hashline mode so callers can no-op cheaply.
|
|
127
127
|
*
|
|
128
128
|
* Mirrors the post-commit snapshot recording the hashline patcher performs
|
|
@@ -770,7 +770,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
770
770
|
context?: AgentToolContext,
|
|
771
771
|
): Promise<AgentToolResult<WriteToolDetails>> {
|
|
772
772
|
return untilAborted(signal, async () => {
|
|
773
|
-
// Strip hashline display prefixes (
|
|
773
|
+
// Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
|
|
774
774
|
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
|
|
775
775
|
const internalRouter = InternalUrlRouter.instance();
|
|
776
776
|
if (internalRouter.canHandle(path)) {
|
|
@@ -935,11 +935,20 @@ function normalizeDisplayText(text: string): string {
|
|
|
935
935
|
return text.replace(/\r/g, "");
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
-
function formatStreamingContent(
|
|
938
|
+
function formatStreamingContent(
|
|
939
|
+
content: string,
|
|
940
|
+
expanded: boolean,
|
|
941
|
+
language: string | undefined,
|
|
942
|
+
uiTheme: Theme,
|
|
943
|
+
): string {
|
|
939
944
|
if (!content) return "";
|
|
940
945
|
const lines = normalizeDisplayText(content).split("\n");
|
|
941
946
|
const totalLines = lines.length;
|
|
942
|
-
|
|
947
|
+
// Collapsed: follow the streaming edge with a bounded tail window so the box
|
|
948
|
+
// stays short enough not to strand its scrolled-off head above the viewport
|
|
949
|
+
// while the block is volatile. `Ctrl+O` (expanded) lifts the cap for a
|
|
950
|
+
// deliberate full view — matching the eval streaming preview.
|
|
951
|
+
const startIndex = expanded ? 0 : Math.max(0, totalLines - WRITE_STREAMING_PREVIEW_LINES);
|
|
943
952
|
const visibleLines = lines.slice(startIndex);
|
|
944
953
|
const hidden = startIndex;
|
|
945
954
|
const highlighted = highlightCode(visibleLines.join("\n"), language);
|
|
@@ -1005,8 +1014,8 @@ export const writeToolRenderer = {
|
|
|
1005
1014
|
return new Text(text, 0, 0);
|
|
1006
1015
|
}
|
|
1007
1016
|
|
|
1008
|
-
// Show streaming preview of content
|
|
1009
|
-
text += formatStreamingContent(args.content, lang, uiTheme);
|
|
1017
|
+
// Show streaming preview of content — bounded tail while collapsed, full on Ctrl+O.
|
|
1018
|
+
text += formatStreamingContent(args.content, Boolean(options?.expanded), lang, uiTheme);
|
|
1010
1019
|
|
|
1011
1020
|
return new Text(text, 0, 0);
|
|
1012
1021
|
},
|
package/src/tui/code-cell.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface CodeCellOptions {
|
|
|
25
25
|
output?: string;
|
|
26
26
|
outputMaxLines?: number;
|
|
27
27
|
codeMaxLines?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Show the LAST `codeMaxLines` rows (the live streaming edge) instead of the
|
|
30
|
+
* first, with a "… N earlier lines" marker on top. Lets a pending preview
|
|
31
|
+
* follow code as it is written while staying bounded. Ignored when `expanded`.
|
|
32
|
+
*/
|
|
33
|
+
codeTail?: boolean;
|
|
28
34
|
expanded?: boolean;
|
|
29
35
|
/** Animate the cell border with a sweeping segment while pending/running. */
|
|
30
36
|
animate?: boolean;
|
|
@@ -102,13 +108,22 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
|
|
|
102
108
|
const normalizedCode = replaceTabs(code ?? "");
|
|
103
109
|
const rawCodeLines = sanitizeTerminalLines(normalizedCode);
|
|
104
110
|
const maxCodeLines = expanded ? rawCodeLines.length : Math.min(rawCodeLines.length, codeMaxLines);
|
|
105
|
-
const visibleCode = rawCodeLines.slice(0, maxCodeLines).join("\n");
|
|
106
|
-
const codeLines = highlightCode(visibleCode, language);
|
|
107
111
|
const hiddenCodeLines = rawCodeLines.length - maxCodeLines;
|
|
112
|
+
const tail = options.codeTail === true && !expanded && hiddenCodeLines > 0;
|
|
113
|
+
const startIndex = tail ? rawCodeLines.length - maxCodeLines : 0;
|
|
114
|
+
const visibleCode = rawCodeLines.slice(startIndex, startIndex + maxCodeLines).join("\n");
|
|
115
|
+
const codeLines = highlightCode(visibleCode, language);
|
|
108
116
|
if (hiddenCodeLines > 0) {
|
|
109
117
|
const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
if (tail) {
|
|
119
|
+
// Earlier rows scrolled above the live tail window — mark them on top so
|
|
120
|
+
// the newest streamed line stays pinned to the bottom of the box.
|
|
121
|
+
const earlier = `… ${hiddenCodeLines} earlier line${hiddenCodeLines === 1 ? "" : "s"}${hint ? ` ${hint}` : ""}`;
|
|
122
|
+
codeLines.unshift(theme.fg("dim", earlier));
|
|
123
|
+
} else {
|
|
124
|
+
const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
|
|
125
|
+
codeLines.push(theme.fg("dim", moreLine));
|
|
126
|
+
}
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
const outputLines: string[] = [];
|
package/src/tui/output-block.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bordered output container with optional header and sections.
|
|
3
3
|
*/
|
|
4
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
4
5
|
import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import type { Theme } from "../modes/theme/theme";
|
|
6
7
|
import { getSixelLineMask } from "../utils/sixel";
|
|
@@ -19,6 +20,19 @@ export interface OutputBlockOptions {
|
|
|
19
20
|
animate?: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent");
|
|
24
|
+
|
|
25
|
+
export type FramedBlockComponent = Component & { [FRAMED_BLOCK_COMPONENT]?: true };
|
|
26
|
+
|
|
27
|
+
export function markFramedBlockComponent<T extends Component>(component: T): T & FramedBlockComponent {
|
|
28
|
+
(component as T & FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] = true;
|
|
29
|
+
return component as T & FramedBlockComponent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isFramedBlockComponent(component: Component): boolean {
|
|
33
|
+
return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
const BORDER_SHIMMER_TICK_MS = 16;
|
|
23
37
|
/** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
|
|
24
38
|
* ms. Position is derived from the wall clock against this fixed cycle so a
|
package/src/web/search/render.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
truncateToWidth,
|
|
22
22
|
} from "../../tools/render-utils";
|
|
23
23
|
import { renderStatusLine, renderTreeList } from "../../tui";
|
|
24
|
-
import { CachedOutputBlock } from "../../tui/output-block";
|
|
24
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../../tui/output-block";
|
|
25
25
|
import { getSearchProviderLabel } from "./provider";
|
|
26
26
|
import type { SearchResponse } from "./types";
|
|
27
27
|
|
|
@@ -152,7 +152,7 @@ export function renderSearchResult(
|
|
|
152
152
|
const answerMarkdown = contentText ? new Markdown(contentText, 0, 0, getMarkdownTheme()) : undefined;
|
|
153
153
|
const outputBlock = new CachedOutputBlock();
|
|
154
154
|
|
|
155
|
-
return {
|
|
155
|
+
return markFramedBlockComponent({
|
|
156
156
|
render(width: number): string[] {
|
|
157
157
|
// Read mutable state at render time
|
|
158
158
|
const { expanded } = options;
|
|
@@ -246,7 +246,7 @@ export function renderSearchResult(
|
|
|
246
246
|
invalidate() {
|
|
247
247
|
outputBlock.invalidate();
|
|
248
248
|
},
|
|
249
|
-
};
|
|
249
|
+
});
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
/** Render web search call (query preview) */
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keepalive for in-flight host-side eval bridge calls.
|
|
3
|
-
*
|
|
4
|
-
* The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
|
|
5
|
-
* as a wall-clock budget on the cell's *own* work, but pauses that budget while
|
|
6
|
-
* a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
|
|
7
|
-
* completion) call is in flight. Those calls are the only thing that re-arms the
|
|
8
|
-
* watchdog — and they can run for long stretches with **no** status of their own
|
|
9
|
-
* (a subagent's time-to-first-token on a reasoning model, a long quiet nested
|
|
10
|
-
* tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
|
|
11
|
-
* watchdog would mistake that delegated work for the cell stalling and abort it
|
|
12
|
-
* mid-flight, killing the subagent.
|
|
13
|
-
*
|
|
14
|
-
* {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
|
|
15
|
-
* {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
|
|
16
|
-
* then on a fixed cadence until it settles. The event rides the same
|
|
17
|
-
* `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
|
|
18
|
-
* the watchdog without any new plumbing. The heartbeat is the *sole* signal that
|
|
19
|
-
* extends the budget: consumers MUST treat it as a pure keepalive — bump the
|
|
20
|
-
* watchdog and drop it (never persist or render it) — see the executor display
|
|
21
|
-
* sinks and the eval tool's `onStatus` handler. Every other status event
|
|
22
|
-
* (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
|
|
23
|
-
*/
|
|
24
|
-
import type { JsStatusEvent } from "./js/shared/types";
|
|
25
|
-
/**
|
|
26
|
-
* Synthetic status op emitted purely to keep the eval idle watchdog alive while
|
|
27
|
-
* a host-side bridge call is in flight. Carries no payload.
|
|
28
|
-
*/
|
|
29
|
-
export declare const EVAL_HEARTBEAT_OP = "heartbeat";
|
|
30
|
-
/**
|
|
31
|
-
* Test seam: override the heartbeat cadence so integration tests can exercise
|
|
32
|
-
* the keepalive within a sub-second idle budget. Pass no value to restore the
|
|
33
|
-
* production default.
|
|
34
|
-
*/
|
|
35
|
-
export declare function setBridgeHeartbeatIntervalMs(ms?: number): void;
|
|
36
|
-
/**
|
|
37
|
-
* Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
|
|
38
|
-
* {@link emitStatus} — one immediately, then on a fixed cadence — until it
|
|
39
|
-
* settles. The immediate beat pauses the watchdog the instant the call begins,
|
|
40
|
-
* so a bridge call that starts close to the budget edge (after the cell already
|
|
41
|
-
* spent most of it computing) is not aborted before the first interval tick. A
|
|
42
|
-
* no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
|
|
43
|
-
* nobody).
|
|
44
|
-
*/
|
|
45
|
-
export declare function withBridgeHeartbeat<T>(emitStatus: ((event: JsStatusEvent) => void) | undefined, operation: () => Promise<T>): Promise<T>;
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { EVAL_HEARTBEAT_OP, setBridgeHeartbeatIntervalMs, withBridgeHeartbeat } from "../heartbeat";
|
|
3
|
-
import type { JsStatusEvent } from "../js/shared/types";
|
|
4
|
-
|
|
5
|
-
describe("withBridgeHeartbeat", () => {
|
|
6
|
-
afterEach(() => {
|
|
7
|
-
setBridgeHeartbeatIntervalMs();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("pumps heartbeat events on cadence while the operation is pending, then stops", async () => {
|
|
11
|
-
setBridgeHeartbeatIntervalMs(20);
|
|
12
|
-
const events: JsStatusEvent[] = [];
|
|
13
|
-
|
|
14
|
-
const value = await withBridgeHeartbeat(
|
|
15
|
-
event => events.push(event),
|
|
16
|
-
async () => {
|
|
17
|
-
await Bun.sleep(130);
|
|
18
|
-
return "done";
|
|
19
|
-
},
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
expect(value).toBe("done");
|
|
23
|
-
// ~6 ticks fit in 130ms at a 20ms cadence; assert it ticked repeatedly
|
|
24
|
-
// without pinning the exact count (scheduler jitter).
|
|
25
|
-
expect(events.length).toBeGreaterThanOrEqual(3);
|
|
26
|
-
expect(events.every(event => event.op === EVAL_HEARTBEAT_OP)).toBe(true);
|
|
27
|
-
|
|
28
|
-
// The interval is cleared once the operation settles: no further ticks.
|
|
29
|
-
const settledCount = events.length;
|
|
30
|
-
await Bun.sleep(80);
|
|
31
|
-
expect(events.length).toBe(settledCount);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("emits a heartbeat immediately so a bridge call extends the budget at once", async () => {
|
|
35
|
-
// Interval far longer than the operation: the only beat that can fire is
|
|
36
|
-
// the immediate one at call start. It must still reach the sink.
|
|
37
|
-
setBridgeHeartbeatIntervalMs(10_000);
|
|
38
|
-
const events: JsStatusEvent[] = [];
|
|
39
|
-
|
|
40
|
-
await withBridgeHeartbeat(
|
|
41
|
-
event => events.push(event),
|
|
42
|
-
async () => {
|
|
43
|
-
await Bun.sleep(30);
|
|
44
|
-
return "done";
|
|
45
|
-
},
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
expect(events.length).toBe(1);
|
|
49
|
-
expect(events[0]?.op).toBe(EVAL_HEARTBEAT_OP);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("runs the operation without emitting when no status sink is wired", async () => {
|
|
53
|
-
setBridgeHeartbeatIntervalMs(5);
|
|
54
|
-
let ran = 0;
|
|
55
|
-
|
|
56
|
-
const value = await withBridgeHeartbeat(undefined, async () => {
|
|
57
|
-
ran++;
|
|
58
|
-
await Bun.sleep(40);
|
|
59
|
-
return 42;
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
expect(value).toBe(42);
|
|
63
|
-
expect(ran).toBe(1);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("clears the heartbeat even when the operation throws", async () => {
|
|
67
|
-
setBridgeHeartbeatIntervalMs(15);
|
|
68
|
-
const events: JsStatusEvent[] = [];
|
|
69
|
-
|
|
70
|
-
await expect(
|
|
71
|
-
withBridgeHeartbeat(
|
|
72
|
-
event => events.push(event),
|
|
73
|
-
async () => {
|
|
74
|
-
await Bun.sleep(60);
|
|
75
|
-
throw new Error("boom");
|
|
76
|
-
},
|
|
77
|
-
),
|
|
78
|
-
).rejects.toThrow("boom");
|
|
79
|
-
|
|
80
|
-
const afterThrow = events.length;
|
|
81
|
-
await Bun.sleep(60);
|
|
82
|
-
expect(events.length).toBe(afterThrow);
|
|
83
|
-
});
|
|
84
|
-
});
|
package/src/eval/heartbeat.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keepalive for in-flight host-side eval bridge calls.
|
|
3
|
-
*
|
|
4
|
-
* The eval watchdog ({@link ../tools/eval IdleTimeout}) caps a cell's `timeout`
|
|
5
|
-
* as a wall-clock budget on the cell's *own* work, but pauses that budget while
|
|
6
|
-
* a host-side `agent()`/`parallel()` (via `runSubprocess`) or `llm()` (a single
|
|
7
|
-
* completion) call is in flight. Those calls are the only thing that re-arms the
|
|
8
|
-
* watchdog — and they can run for long stretches with **no** status of their own
|
|
9
|
-
* (a subagent's time-to-first-token on a reasoning model, a long quiet nested
|
|
10
|
-
* tool, or the entire body of a oneshot `llm()` call). Without a keepalive the
|
|
11
|
-
* watchdog would mistake that delegated work for the cell stalling and abort it
|
|
12
|
-
* mid-flight, killing the subagent.
|
|
13
|
-
*
|
|
14
|
-
* {@link withBridgeHeartbeat} bridges that gap by emitting a synthetic
|
|
15
|
-
* {@link EVAL_HEARTBEAT_OP} status event immediately when the call begins and
|
|
16
|
-
* then on a fixed cadence until it settles. The event rides the same
|
|
17
|
-
* `emitStatus → onStatus` channel both runtimes already forward, so it re-arms
|
|
18
|
-
* the watchdog without any new plumbing. The heartbeat is the *sole* signal that
|
|
19
|
-
* extends the budget: consumers MUST treat it as a pure keepalive — bump the
|
|
20
|
-
* watchdog and drop it (never persist or render it) — see the executor display
|
|
21
|
-
* sinks and the eval tool's `onStatus` handler. Every other status event
|
|
22
|
-
* (compute helpers, `log()`/`phase()`, tool results) counts against the budget.
|
|
23
|
-
*/
|
|
24
|
-
import type { JsStatusEvent } from "./js/shared/types";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Synthetic status op emitted purely to keep the eval idle watchdog alive while
|
|
28
|
-
* a host-side bridge call is in flight. Carries no payload.
|
|
29
|
-
*/
|
|
30
|
-
export const EVAL_HEARTBEAT_OP = "heartbeat";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Heartbeat cadence. Comfortably below the default 30s idle budget (and the
|
|
34
|
-
* larger budgets long fanouts run under), so a working bridge call always bumps
|
|
35
|
-
* the watchdog before it expires, while a genuine stall is still bounded once
|
|
36
|
-
* the call settles and the heartbeat stops.
|
|
37
|
-
*/
|
|
38
|
-
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
39
|
-
|
|
40
|
-
let heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Test seam: override the heartbeat cadence so integration tests can exercise
|
|
44
|
-
* the keepalive within a sub-second idle budget. Pass no value to restore the
|
|
45
|
-
* production default.
|
|
46
|
-
*/
|
|
47
|
-
export function setBridgeHeartbeatIntervalMs(ms?: number): void {
|
|
48
|
-
heartbeatIntervalMs = ms === undefined ? HEARTBEAT_INTERVAL_MS : Math.max(1, Math.floor(ms));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Run {@link operation}, pumping {@link EVAL_HEARTBEAT_OP} status events through
|
|
53
|
-
* {@link emitStatus} — one immediately, then on a fixed cadence — until it
|
|
54
|
-
* settles. The immediate beat pauses the watchdog the instant the call begins,
|
|
55
|
-
* so a bridge call that starts close to the budget edge (after the cell already
|
|
56
|
-
* spent most of it computing) is not aborted before the first interval tick. A
|
|
57
|
-
* no-op wrapper when no `emitStatus` sink is wired (the heartbeat would reach
|
|
58
|
-
* nobody).
|
|
59
|
-
*/
|
|
60
|
-
export async function withBridgeHeartbeat<T>(
|
|
61
|
-
emitStatus: ((event: JsStatusEvent) => void) | undefined,
|
|
62
|
-
operation: () => Promise<T>,
|
|
63
|
-
): Promise<T> {
|
|
64
|
-
if (!emitStatus) return operation();
|
|
65
|
-
emitStatus({ op: EVAL_HEARTBEAT_OP });
|
|
66
|
-
const timer = setInterval(() => emitStatus({ op: EVAL_HEARTBEAT_OP }), heartbeatIntervalMs);
|
|
67
|
-
// Never keep the event loop alive for the heartbeat alone.
|
|
68
|
-
timer.unref?.();
|
|
69
|
-
try {
|
|
70
|
-
return await operation();
|
|
71
|
-
} finally {
|
|
72
|
-
clearInterval(timer);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
File without changes
|