@oh-my-pi/pi-coding-agent 15.9.3 → 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 +74 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +24 -5
- 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 +16 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +1 -1
- 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/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -1
- package/dist/types/modes/types.d.ts +8 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- 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/hyperlink.d.ts +12 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +25 -5
- 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 +100 -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/eval/py/tool-bridge.ts +43 -5
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- 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 +8 -2
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +44 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -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 +71 -13
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +67 -12
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +52 -17
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +11 -2
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/system/project-prompt.md +1 -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/sdk.ts +17 -9
- package/src/session/agent-session.ts +43 -14
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/system-prompt.ts +4 -0
- package/src/task/render.ts +38 -11
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- 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 +34 -14
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +65 -19
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- 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/hyperlink.ts +42 -7
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +23 -55
- 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/search.ts
CHANGED
|
@@ -16,7 +16,15 @@ import type { InternalResource, ResolveContext } from "../internal-urls/types";
|
|
|
16
16
|
import type { Theme } from "../modes/theme/theme";
|
|
17
17
|
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
|
18
18
|
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine } from "../session/streaming-output";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
Ellipsis,
|
|
21
|
+
fileHyperlink,
|
|
22
|
+
renderStatusLine,
|
|
23
|
+
renderTreeList,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
tryResolveInternalUrlSync,
|
|
26
|
+
uriHyperlink,
|
|
27
|
+
} from "../tui";
|
|
20
28
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
21
29
|
import type { ToolSession } from ".";
|
|
22
30
|
import {
|
|
@@ -1162,6 +1170,26 @@ interface SearchRenderArgs {
|
|
|
1162
1170
|
|
|
1163
1171
|
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
|
1164
1172
|
|
|
1173
|
+
const SEARCH_CODE_FRAME_LINE_RE = /^\s*\*?(\d+)│/;
|
|
1174
|
+
|
|
1175
|
+
function searchScopeMeta(details: SearchToolDetails | undefined): string | undefined {
|
|
1176
|
+
if (!details?.scopePath) return undefined;
|
|
1177
|
+
const label = details.searchPath ? fileHyperlink(details.searchPath, details.scopePath) : details.scopePath;
|
|
1178
|
+
return `in ${label}`;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function linkUrlLikeSearchHeader(raw: string, styled: string): { line: string; absPath?: string } {
|
|
1182
|
+
const resolvedPath = tryResolveInternalUrlSync(raw);
|
|
1183
|
+
if (resolvedPath) return { line: fileHyperlink(resolvedPath, styled), absPath: resolvedPath };
|
|
1184
|
+
return { line: uriHyperlink(raw, styled) };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function parseSearchDisplayLineNumber(line: string): number | undefined {
|
|
1188
|
+
const match = SEARCH_CODE_FRAME_LINE_RE.exec(line);
|
|
1189
|
+
if (!match) return undefined;
|
|
1190
|
+
return Number.parseInt(match[1]!, 10);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1165
1193
|
export const searchToolRenderer = {
|
|
1166
1194
|
inline: true,
|
|
1167
1195
|
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
@@ -1237,8 +1265,11 @@ export const searchToolRenderer = {
|
|
|
1237
1265
|
: undefined;
|
|
1238
1266
|
|
|
1239
1267
|
if (matchCount === 0) {
|
|
1268
|
+
const meta = ["0 matches"];
|
|
1269
|
+
const scopeMeta = searchScopeMeta(details);
|
|
1270
|
+
if (scopeMeta) meta.push(scopeMeta);
|
|
1240
1271
|
const header = renderStatusLine(
|
|
1241
|
-
{ icon: "warning", title: "Search", description: args?.pattern, meta
|
|
1272
|
+
{ icon: "warning", title: "Search", description: args?.pattern, meta },
|
|
1242
1273
|
uiTheme,
|
|
1243
1274
|
);
|
|
1244
1275
|
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
|
@@ -1248,7 +1279,8 @@ export const searchToolRenderer = {
|
|
|
1248
1279
|
|
|
1249
1280
|
const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
|
|
1250
1281
|
const meta = [...summaryParts];
|
|
1251
|
-
|
|
1282
|
+
const scopeMeta = searchScopeMeta(details);
|
|
1283
|
+
if (scopeMeta) meta.push(scopeMeta);
|
|
1252
1284
|
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
|
|
1253
1285
|
const description = args?.pattern ?? undefined;
|
|
1254
1286
|
const header = renderStatusLine(
|
|
@@ -1287,10 +1319,11 @@ export const searchToolRenderer = {
|
|
|
1287
1319
|
maxCollapsedLines: collapsedMatchLineBudget,
|
|
1288
1320
|
itemType: "match",
|
|
1289
1321
|
renderItem: group => {
|
|
1290
|
-
// Track directory context within a group
|
|
1291
|
-
//
|
|
1292
|
-
// from formatGroupedFiles (single-# when directory is `.`).
|
|
1322
|
+
// Track directory/file context within a group so headers and code-frame
|
|
1323
|
+
// lines link to the backing file, with line-specific links for matches.
|
|
1293
1324
|
let contextDir = searchBase ?? "";
|
|
1325
|
+
const hasFileHeader = group.some(line => line.startsWith("# "));
|
|
1326
|
+
let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
|
|
1294
1327
|
return group.map(line => {
|
|
1295
1328
|
if (line.startsWith("## ")) {
|
|
1296
1329
|
// Strip optional ` (suffix)` and `#hash` before resolving.
|
|
@@ -1300,6 +1333,7 @@ export const searchToolRenderer = {
|
|
|
1300
1333
|
.replace(/\s+\([^)]*\)\s*$/, "")
|
|
1301
1334
|
.replace(/#[0-9a-f]+$/, "");
|
|
1302
1335
|
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
|
|
1336
|
+
currentFilePath = absPath;
|
|
1303
1337
|
const styled = uiTheme.fg("dim", line);
|
|
1304
1338
|
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1305
1339
|
}
|
|
@@ -1310,22 +1344,37 @@ export const searchToolRenderer = {
|
|
|
1310
1344
|
.replace(/\s+\([^)]*\)\s*$/, "");
|
|
1311
1345
|
if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
|
|
1312
1346
|
contextDir = "";
|
|
1313
|
-
|
|
1347
|
+
const styled = uiTheme.fg("accent", line);
|
|
1348
|
+
const linked = linkUrlLikeSearchHeader(raw, styled);
|
|
1349
|
+
currentFilePath = linked.absPath;
|
|
1350
|
+
return linked.line;
|
|
1314
1351
|
}
|
|
1315
1352
|
const isDirectory = raw.endsWith("/");
|
|
1316
1353
|
const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
|
|
1317
1354
|
if (isDirectory) {
|
|
1318
|
-
|
|
1319
|
-
|
|
1355
|
+
const absPath = searchBase
|
|
1356
|
+
? name === "."
|
|
1357
|
+
? searchBase
|
|
1358
|
+
: path.join(searchBase, name)
|
|
1359
|
+
: undefined;
|
|
1360
|
+
if (absPath) {
|
|
1361
|
+
contextDir = absPath;
|
|
1320
1362
|
}
|
|
1321
|
-
|
|
1363
|
+
currentFilePath = undefined;
|
|
1364
|
+
const styled = uiTheme.fg("accent", line);
|
|
1365
|
+
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1322
1366
|
}
|
|
1323
1367
|
// Root-level file emitted by formatGroupedFiles when the directory is `.`.
|
|
1324
1368
|
const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
|
|
1369
|
+
currentFilePath = absPath;
|
|
1325
1370
|
const styled = uiTheme.fg("accent", line);
|
|
1326
1371
|
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1327
1372
|
}
|
|
1328
|
-
|
|
1373
|
+
const styled = uiTheme.fg("toolOutput", line);
|
|
1374
|
+
const lineNumber = parseSearchDisplayLineNumber(line);
|
|
1375
|
+
return currentFilePath && lineNumber !== undefined
|
|
1376
|
+
? fileHyperlink(currentFilePath, styled, { line: lineNumber })
|
|
1377
|
+
: styled;
|
|
1329
1378
|
});
|
|
1330
1379
|
},
|
|
1331
1380
|
},
|
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/hyperlink.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OSC 8 terminal hyperlink support for
|
|
2
|
+
* OSC 8 terminal hyperlink support for paths and URLs.
|
|
3
3
|
*
|
|
4
4
|
* Wraps display text in `ESC ] 8 ; id=HASH ; URI ESC \ TEXT ESC ] 8 ; ; ESC \`
|
|
5
5
|
* sequences when the active terminal supports hyperlinks and the user setting
|
|
@@ -63,6 +63,46 @@ export function isHyperlinkEnabled(): boolean {
|
|
|
63
63
|
return TERMINAL.hyperlinks;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function safeHyperlinkUri(uri: string): string | undefined {
|
|
67
|
+
if (!uri || /[\x00-\x1f\x7f]/.test(uri)) return undefined;
|
|
68
|
+
return uri;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wrapHyperlink(uri: string, displayText: string): string {
|
|
72
|
+
if (!isHyperlinkEnabled()) return displayText;
|
|
73
|
+
// Do not double-wrap if the text already embeds an OSC 8 sequence.
|
|
74
|
+
if (displayText.includes("\x1b]8;")) return displayText;
|
|
75
|
+
const safeUri = safeHyperlinkUri(uri);
|
|
76
|
+
if (!safeUri) return displayText;
|
|
77
|
+
const id = buildLinkId(safeUri);
|
|
78
|
+
return `${OSC}8;id=${id};${safeUri}${ST}${displayText}${OSC}8;;${ST}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wrap `displayText` in an OSC 8 hyperlink pointing at `uri`.
|
|
83
|
+
*
|
|
84
|
+
* Returns `displayText` unchanged when hyperlinks are disabled, `uri` contains
|
|
85
|
+
* terminal control bytes, or `displayText` already contains an OSC 8 sequence.
|
|
86
|
+
*/
|
|
87
|
+
export function uriHyperlink(uri: string, displayText: string): string {
|
|
88
|
+
return wrapHyperlink(uri, displayText);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL.
|
|
93
|
+
* `www.example.com` inputs are linked as `https://www.example.com`.
|
|
94
|
+
*/
|
|
95
|
+
export function urlHyperlink(url: string, displayText: string): string {
|
|
96
|
+
const normalized = url.match(/^www\./i) ? `https://${url}` : url;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(normalized);
|
|
99
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return displayText;
|
|
100
|
+
return wrapHyperlink(parsed.href, displayText);
|
|
101
|
+
} catch {
|
|
102
|
+
return displayText;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
66
106
|
/**
|
|
67
107
|
* Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
|
|
68
108
|
*
|
|
@@ -78,12 +118,7 @@ export function isHyperlinkEnabled(): boolean {
|
|
|
78
118
|
* @param opts - Optional line/col position appended as `?line=N&col=M` query params
|
|
79
119
|
*/
|
|
80
120
|
export function fileHyperlink(absPath: string, displayText: string, opts?: { line?: number; col?: number }): string {
|
|
81
|
-
|
|
82
|
-
// Do not double-wrap if the text already embeds an OSC 8 sequence.
|
|
83
|
-
if (displayText.includes("\x1b]8;")) return displayText;
|
|
84
|
-
const uri = buildFileUri(absPath, opts);
|
|
85
|
-
const id = buildLinkId(uri);
|
|
86
|
-
return `${OSC}8;id=${id};${uri}${ST}${displayText}${OSC}8;;${ST}`;
|
|
121
|
+
return wrapHyperlink(buildFileUri(absPath, opts), displayText);
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
/**
|
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/index.ts
CHANGED
|
@@ -278,8 +278,8 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
|
|
|
278
278
|
return renderSearchCall(args, options, theme);
|
|
279
279
|
},
|
|
280
280
|
|
|
281
|
-
renderResult(result, options: RenderResultOptions, theme: Theme) {
|
|
282
|
-
return renderSearchResult(result, options, theme);
|
|
281
|
+
renderResult(result, options: RenderResultOptions, theme: Theme, args) {
|
|
282
|
+
return renderSearchResult(result, options, theme, args);
|
|
283
283
|
},
|
|
284
284
|
};
|
|
285
285
|
|
package/src/web/search/render.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
8
|
-
import {
|
|
8
|
+
import { Markdown, Text } from "@oh-my-pi/pi-tui";
|
|
9
9
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
10
|
-
import type
|
|
10
|
+
import { getMarkdownTheme, type Theme } from "../../modes/theme/theme";
|
|
11
11
|
import {
|
|
12
12
|
formatAge,
|
|
13
13
|
formatCount,
|
|
@@ -21,13 +21,11 @@ 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
|
|
|
28
28
|
const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
29
|
-
const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES;
|
|
30
|
-
const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE;
|
|
31
29
|
const MAX_SNIPPET_LINES = 2;
|
|
32
30
|
const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE;
|
|
33
31
|
const MAX_COLLAPSED_ITEMS = PREVIEW_LIMITS.COLLAPSED_ITEMS;
|
|
@@ -75,7 +73,6 @@ export function renderSearchResult(
|
|
|
75
73
|
theme: Theme,
|
|
76
74
|
args?: {
|
|
77
75
|
query?: string;
|
|
78
|
-
allowLongAnswer?: boolean;
|
|
79
76
|
maxAnswerLines?: number;
|
|
80
77
|
},
|
|
81
78
|
): Component {
|
|
@@ -104,13 +101,6 @@ export function renderSearchResult(
|
|
|
104
101
|
// Get answer text
|
|
105
102
|
const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
|
|
106
103
|
const contentText = answerText || rawText;
|
|
107
|
-
const answerLines = contentText
|
|
108
|
-
? contentText
|
|
109
|
-
.split("\n")
|
|
110
|
-
.filter(l => l.trim())
|
|
111
|
-
.map(l => l.trim())
|
|
112
|
-
: [];
|
|
113
|
-
const totalAnswerLines = answerLines.length;
|
|
114
104
|
|
|
115
105
|
const providerLabel = provider !== "none" ? getSearchProviderLabel(provider) : "None";
|
|
116
106
|
const queryPreview = args?.query
|
|
@@ -159,21 +149,30 @@ export function renderSearchResult(
|
|
|
159
149
|
metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`);
|
|
160
150
|
}
|
|
161
151
|
|
|
152
|
+
const answerMarkdown = contentText ? new Markdown(contentText, 0, 0, getMarkdownTheme()) : undefined;
|
|
162
153
|
const outputBlock = new CachedOutputBlock();
|
|
163
154
|
|
|
164
|
-
return {
|
|
155
|
+
return markFramedBlockComponent({
|
|
165
156
|
render(width: number): string[] {
|
|
166
157
|
// Read mutable state at render time
|
|
167
158
|
const { expanded } = options;
|
|
168
159
|
|
|
169
|
-
//
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
160
|
+
// Answer lines: full markdown when expanded, capped markdown preview when collapsed.
|
|
161
|
+
const answerWidth = Math.max(20, width - 3);
|
|
162
|
+
const renderedAnswer = answerMarkdown ? answerMarkdown.render(answerWidth) : [];
|
|
163
|
+
let answerLines: string[];
|
|
164
|
+
if (renderedAnswer.length === 0) {
|
|
165
|
+
answerLines = [theme.fg("muted", "No answer text returned")];
|
|
166
|
+
} else if (expanded) {
|
|
167
|
+
answerLines = renderedAnswer;
|
|
168
|
+
} else {
|
|
169
|
+
const collapsedCap = args?.maxAnswerLines ?? MAX_COLLAPSED_ANSWER_LINES;
|
|
170
|
+
answerLines = renderedAnswer.slice(0, collapsedCap);
|
|
171
|
+
const remaining = renderedAnswer.length - answerLines.length;
|
|
172
|
+
if (remaining > 0) {
|
|
173
|
+
answerLines.push(theme.fg("muted", formatMoreItems(remaining, "line")));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
177
176
|
|
|
178
177
|
const sourceTree = renderTreeList(
|
|
179
178
|
{
|
|
@@ -217,37 +216,6 @@ export function renderSearchResult(
|
|
|
217
216
|
theme,
|
|
218
217
|
);
|
|
219
218
|
|
|
220
|
-
// Build answer section
|
|
221
|
-
const answerState = sourceCount > 0 ? "success" : "warning";
|
|
222
|
-
const borderColor: "warning" | "dim" = answerState === "warning" ? "warning" : "dim";
|
|
223
|
-
const border = (t: string) => theme.fg(borderColor, t);
|
|
224
|
-
const contentPrefix = border(`${theme.boxSharp.vertical} `);
|
|
225
|
-
const contentSuffix = border(theme.boxSharp.vertical);
|
|
226
|
-
const contentWidth = Math.max(0, width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
|
|
227
|
-
const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
|
|
228
|
-
const answerTree = renderTreeList(
|
|
229
|
-
{
|
|
230
|
-
items: answerTreeLines,
|
|
231
|
-
expanded: true,
|
|
232
|
-
maxCollapsed: answerTreeLines.length,
|
|
233
|
-
itemType: "line",
|
|
234
|
-
renderItem: (line, context) => {
|
|
235
|
-
const coloredLine =
|
|
236
|
-
line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line);
|
|
237
|
-
if (!args?.allowLongAnswer) {
|
|
238
|
-
return coloredLine;
|
|
239
|
-
}
|
|
240
|
-
const prefixWidth = visibleWidth(context.continuePrefix);
|
|
241
|
-
const wrapWidth = Math.max(10, contentWidth - prefixWidth);
|
|
242
|
-
return wrapTextWithAnsi(coloredLine, wrapWidth);
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
theme,
|
|
246
|
-
);
|
|
247
|
-
if (remainingAnswer > 0) {
|
|
248
|
-
answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
219
|
return outputBlock.render(
|
|
252
220
|
{
|
|
253
221
|
header,
|
|
@@ -262,7 +230,7 @@ export function renderSearchResult(
|
|
|
262
230
|
: []),
|
|
263
231
|
{
|
|
264
232
|
label: theme.fg("toolTitle", "Answer"),
|
|
265
|
-
lines:
|
|
233
|
+
lines: answerLines,
|
|
266
234
|
},
|
|
267
235
|
{
|
|
268
236
|
label: theme.fg("toolTitle", "Sources"),
|
|
@@ -278,7 +246,7 @@ export function renderSearchResult(
|
|
|
278
246
|
invalidate() {
|
|
279
247
|
outputBlock.invalidate();
|
|
280
248
|
},
|
|
281
|
-
};
|
|
249
|
+
});
|
|
282
250
|
}
|
|
283
251
|
|
|
284
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>;
|