@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/lsp/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
|
3
4
|
import { applyWorkspaceEdit } from "./edits";
|
|
@@ -147,6 +148,7 @@ const CLIENT_CAPABILITIES = {
|
|
|
147
148
|
failureHandling: "textOnlyTransactional",
|
|
148
149
|
},
|
|
149
150
|
configuration: true,
|
|
151
|
+
workspaceFolders: true,
|
|
150
152
|
symbol: {
|
|
151
153
|
dynamicRegistration: false,
|
|
152
154
|
symbolKind: {
|
|
@@ -318,6 +320,22 @@ async function startMessageReader(client: LspClient): Promise<void> {
|
|
|
318
320
|
}
|
|
319
321
|
}
|
|
320
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Build the workspace folder list advertised to the server. Identical shape
|
|
325
|
+
* for `initialize` params and `workspace/workspaceFolders` server requests.
|
|
326
|
+
*/
|
|
327
|
+
function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: string }> {
|
|
328
|
+
return [{ uri: fileToUri(client.cwd), name: path.basename(client.cwd) || "workspace" }];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Handle workspace/workspaceFolders requests from the server.
|
|
333
|
+
*/
|
|
334
|
+
async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
|
|
335
|
+
if (typeof message.id !== "number") return;
|
|
336
|
+
await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
|
|
337
|
+
}
|
|
338
|
+
|
|
321
339
|
/**
|
|
322
340
|
* Handle workspace/configuration requests from the server.
|
|
323
341
|
*/
|
|
@@ -364,6 +382,10 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
|
|
|
364
382
|
await handleConfigurationRequest(client, message);
|
|
365
383
|
return;
|
|
366
384
|
}
|
|
385
|
+
if (message.method === "workspace/workspaceFolders") {
|
|
386
|
+
await handleWorkspaceFoldersRequest(client, message);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
367
389
|
if (message.method === "workspace/applyEdit") {
|
|
368
390
|
await handleApplyEditRequest(client, message);
|
|
369
391
|
return;
|
|
@@ -412,7 +434,60 @@ async function sendResponse(
|
|
|
412
434
|
/** Timeout for warmup initialize requests (5 seconds) */
|
|
413
435
|
export const WARMUP_TIMEOUT_MS = 5000;
|
|
414
436
|
|
|
415
|
-
/** Max time to
|
|
437
|
+
/** Max time to poll rust-analyzer after progress ends but before Cargo workspaces are ready. */
|
|
438
|
+
const RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS = 5_000;
|
|
439
|
+
const RUST_ANALYZER_WORKSPACE_READY_POLL_MS = 100;
|
|
440
|
+
const RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS = 2_000;
|
|
441
|
+
const rustAnalyzerReadyClients = new WeakSet<LspClient>();
|
|
442
|
+
|
|
443
|
+
function commandBasename(command: string): string {
|
|
444
|
+
const slash = command.lastIndexOf("/");
|
|
445
|
+
const backslash = command.lastIndexOf("\\");
|
|
446
|
+
const separator = Math.max(slash, backslash);
|
|
447
|
+
return separator === -1 ? command : command.slice(separator + 1);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isRustAnalyzerClient(client: LspClient): boolean {
|
|
451
|
+
return (
|
|
452
|
+
commandBasename(client.config.command) === "rust-analyzer" ||
|
|
453
|
+
(client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isRustAnalyzerStatusTimeout(err: unknown): boolean {
|
|
458
|
+
return err instanceof Error && err.message.startsWith("LSP request rust-analyzer/analyzerStatus timed out after ");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function waitForRustAnalyzerWorkspace(client: LspClient, signal?: AbortSignal): Promise<void> {
|
|
462
|
+
if (rustAnalyzerReadyClients.has(client)) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const started = Date.now();
|
|
466
|
+
const deadline = started + RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS;
|
|
467
|
+
while (true) {
|
|
468
|
+
throwIfAborted(signal);
|
|
469
|
+
let status: unknown;
|
|
470
|
+
try {
|
|
471
|
+
status = await sendRequest(client, "rust-analyzer/analyzerStatus", {}, signal, 1_000);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (!isRustAnalyzerStatusTimeout(err) || Date.now() >= deadline) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const ready = typeof status === "string" && !status.startsWith("No workspaces");
|
|
480
|
+
if (ready && Date.now() - started >= RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS) {
|
|
481
|
+
rustAnalyzerReadyClients.add(client);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (Date.now() >= deadline) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
416
491
|
const PROJECT_LOAD_TIMEOUT_MS = 15_000;
|
|
417
492
|
|
|
418
493
|
/** Max time to wait for graceful LSP shutdown and process exit. */
|
|
@@ -530,7 +605,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
|
|
|
530
605
|
rootPath: cwd,
|
|
531
606
|
capabilities: CLIENT_CAPABILITIES,
|
|
532
607
|
initializationOptions: config.initOptions ?? {},
|
|
533
|
-
workspaceFolders:
|
|
608
|
+
workspaceFolders: currentWorkspaceFolders(client),
|
|
534
609
|
},
|
|
535
610
|
undefined, // signal
|
|
536
611
|
initTimeoutMs,
|
|
@@ -635,6 +710,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
|
|
|
635
710
|
? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
|
|
636
711
|
: []),
|
|
637
712
|
]);
|
|
713
|
+
if (isRustAnalyzerClient(client)) {
|
|
714
|
+
await waitForRustAnalyzerWorkspace(client, signal);
|
|
715
|
+
}
|
|
638
716
|
}
|
|
639
717
|
|
|
640
718
|
/**
|
package/src/lsp/index.ts
CHANGED
|
@@ -304,6 +304,32 @@ const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
|
|
|
304
304
|
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
|
|
305
305
|
const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
|
|
306
306
|
const WORKSPACE_SYMBOL_LIMIT = 200;
|
|
307
|
+
const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
|
|
308
|
+
"definition",
|
|
309
|
+
"type_definition",
|
|
310
|
+
"implementation",
|
|
311
|
+
"references",
|
|
312
|
+
"rename",
|
|
313
|
+
"hover",
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
|
|
317
|
+
|
|
318
|
+
function hasRustWorkspaceAncestor(filePath: string): boolean {
|
|
319
|
+
let dir = path.dirname(filePath);
|
|
320
|
+
while (true) {
|
|
321
|
+
for (const marker of RUST_WORKSPACE_MARKERS) {
|
|
322
|
+
if (fs.existsSync(path.join(dir, marker))) {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const parent = path.dirname(dir);
|
|
327
|
+
if (parent === dir) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
dir = parent;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
307
333
|
|
|
308
334
|
function limitDiagnosticMessages(messages: string[]): string[] {
|
|
309
335
|
if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
|
|
@@ -1940,10 +1966,21 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1940
1966
|
try {
|
|
1941
1967
|
const client = await getOrCreateClient(serverConfig, this.session.cwd);
|
|
1942
1968
|
const targetFile = resolvedFile;
|
|
1969
|
+
const isRustAnalyzerServer =
|
|
1970
|
+
serverName === "rust-analyzer" ||
|
|
1971
|
+
path.basename(serverConfig.command) === "rust-analyzer" ||
|
|
1972
|
+
(serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
|
|
1973
|
+
const needsProjectIndex =
|
|
1974
|
+
targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
|
|
1975
|
+
const rustWorkspaceWait =
|
|
1976
|
+
needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
|
|
1943
1977
|
|
|
1944
1978
|
if (targetFile) {
|
|
1945
1979
|
await ensureFileOpen(client, targetFile, signal);
|
|
1946
1980
|
}
|
|
1981
|
+
if (rustWorkspaceWait) {
|
|
1982
|
+
await waitForProjectLoaded(client, signal);
|
|
1983
|
+
}
|
|
1947
1984
|
|
|
1948
1985
|
// For project-aware servers, references/rename/definition without a `symbol`
|
|
1949
1986
|
// silently falls back to the first non-whitespace column on the line, which
|
|
@@ -1968,10 +2005,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1968
2005
|
|
|
1969
2006
|
let output: string;
|
|
1970
2007
|
|
|
1971
|
-
|
|
1972
|
-
// to ensure the server has indexed all project files.
|
|
1973
|
-
const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
|
|
1974
|
-
if (crossFileActions.has(action)) {
|
|
2008
|
+
if (needsProjectIndex && !isRustAnalyzerServer) {
|
|
1975
2009
|
await waitForProjectLoaded(client, signal);
|
|
1976
2010
|
}
|
|
1977
2011
|
|
package/src/lsp/render.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
truncateToWidth,
|
|
22
22
|
} from "../tools/render-utils";
|
|
23
23
|
import { renderStatusLine } from "../tui";
|
|
24
|
-
import { CachedOutputBlock } from "../tui/output-block";
|
|
24
|
+
import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
|
|
25
25
|
import type { LspParams, LspToolDetails } from "./types";
|
|
26
26
|
|
|
27
27
|
// =============================================================================
|
|
@@ -138,7 +138,7 @@ export function renderResult(
|
|
|
138
138
|
|
|
139
139
|
const outputBlock = new CachedOutputBlock();
|
|
140
140
|
|
|
141
|
-
return {
|
|
141
|
+
return markFramedBlockComponent({
|
|
142
142
|
render(width: number): string[] {
|
|
143
143
|
// Read mutable state at render time
|
|
144
144
|
const { expanded, isPartial, spinnerFrame } = options;
|
|
@@ -194,7 +194,7 @@ export function renderResult(
|
|
|
194
194
|
invalidate() {
|
|
195
195
|
outputBlock.invalidate();
|
|
196
196
|
},
|
|
197
|
-
};
|
|
197
|
+
});
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// =============================================================================
|
package/src/main.ts
CHANGED
|
@@ -268,7 +268,7 @@ async function runInteractiveMode(
|
|
|
268
268
|
force: forceSetupWizard,
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
-
await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
|
|
271
|
+
await mode.init({ suppressWelcomeIntro: resuming || setupScenes.length > 0 });
|
|
272
272
|
|
|
273
273
|
if (setupScenes.length > 0) {
|
|
274
274
|
await runSetupWizard(mode, setupScenes);
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
matchesKey,
|
|
28
28
|
padding,
|
|
29
29
|
replaceTabs,
|
|
30
|
+
ScrollView,
|
|
30
31
|
Spacer,
|
|
31
32
|
Text,
|
|
32
33
|
truncateToWidth,
|
|
@@ -205,9 +206,12 @@ class AgentListPane implements Component {
|
|
|
205
206
|
return lines;
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
const overflow = this.agents.length > this.maxVisible;
|
|
210
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
208
211
|
const start = this.scrollOffset;
|
|
209
212
|
const end = Math.min(start + this.maxVisible, this.agents.length);
|
|
210
213
|
|
|
214
|
+
const rows: string[] = [];
|
|
211
215
|
for (let i = start; i < end; i++) {
|
|
212
216
|
const agent = this.agents[i];
|
|
213
217
|
const selected = i === this.selectedIndex;
|
|
@@ -224,12 +228,17 @@ class AgentListPane implements Component {
|
|
|
224
228
|
line = theme.fg("dim", line);
|
|
225
229
|
}
|
|
226
230
|
|
|
227
|
-
|
|
231
|
+
rows.push(truncateToWidth(line, rowWidth));
|
|
228
232
|
}
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
const sv = new ScrollView(rows, {
|
|
235
|
+
height: rows.length,
|
|
236
|
+
scrollbar: "auto",
|
|
237
|
+
totalRows: this.agents.length,
|
|
238
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
239
|
+
});
|
|
240
|
+
sv.setScrollOffset(this.scrollOffset);
|
|
241
|
+
lines.push(...sv.render(width));
|
|
233
242
|
|
|
234
243
|
return lines;
|
|
235
244
|
}
|
|
@@ -18,6 +18,15 @@ export class AssistantMessageComponent extends Container {
|
|
|
18
18
|
#convertedKittyImages = new Map<string, ImageContent>();
|
|
19
19
|
#kittyConversionsInFlight = new Set<string>();
|
|
20
20
|
#transcriptBlockFinalized: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* When true, the turn-ending `Error: …` line for `stopReason === "error"` is
|
|
23
|
+
* suppressed because the same error is currently shown in the pinned banner
|
|
24
|
+
* above the editor (see `EventController` + `ErrorBannerComponent`). Avoids
|
|
25
|
+
* rendering the identical error twice (inline + banner) at the error moment.
|
|
26
|
+
* Restored to `false` when the banner is cleared at the next turn so the
|
|
27
|
+
* transcript keeps the error in history.
|
|
28
|
+
*/
|
|
29
|
+
#errorPinned = false;
|
|
21
30
|
|
|
22
31
|
constructor(
|
|
23
32
|
message?: AssistantMessage,
|
|
@@ -49,6 +58,18 @@ export class AssistantMessageComponent extends Container {
|
|
|
49
58
|
this.hideThinkingBlock = hide;
|
|
50
59
|
}
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Toggle suppression of the inline `Error: …` line while the same error is
|
|
63
|
+
* pinned in the banner above the editor. Re-renders so the change is visible.
|
|
64
|
+
*/
|
|
65
|
+
setErrorPinned(pinned: boolean): void {
|
|
66
|
+
if (this.#errorPinned === pinned) return;
|
|
67
|
+
this.#errorPinned = pinned;
|
|
68
|
+
if (this.#lastMessage) {
|
|
69
|
+
this.updateContent(this.#lastMessage);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
isTranscriptBlockFinalized(): boolean {
|
|
53
74
|
return this.#transcriptBlockFinalized;
|
|
54
75
|
}
|
|
@@ -246,7 +267,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
246
267
|
this.#contentContainer.addChild(new Spacer(1));
|
|
247
268
|
}
|
|
248
269
|
this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
|
|
249
|
-
} else if (message.stopReason === "error") {
|
|
270
|
+
} else if (message.stopReason === "error" && !this.#errorPinned) {
|
|
250
271
|
const errorMsg = message.errorMessage || "Unknown error";
|
|
251
272
|
this.#contentContainer.addChild(new Spacer(1));
|
|
252
273
|
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import { replaceTabs } from "../../tools/render-utils";
|
|
3
|
+
import { highlightCode, theme } from "../theme/theme";
|
|
4
|
+
import type { CopyTarget } from "../utils/copy-targets";
|
|
5
|
+
import {
|
|
6
|
+
matchesSelectCancel,
|
|
7
|
+
matchesSelectDown,
|
|
8
|
+
matchesSelectPageDown,
|
|
9
|
+
matchesSelectPageUp,
|
|
10
|
+
matchesSelectUp,
|
|
11
|
+
} from "../utils/keybinding-matchers";
|
|
12
|
+
import { keyHint, rawKeyHint } from "./keybinding-hints";
|
|
13
|
+
|
|
14
|
+
/** Minimum rows reserved for the tree even on short terminals. */
|
|
15
|
+
const MIN_TREE_ROWS = 3;
|
|
16
|
+
/** Fixed chrome rows: top border, two dividers, footer, bottom border. */
|
|
17
|
+
const CHROME_ROWS = 5;
|
|
18
|
+
|
|
19
|
+
export interface CopySelectorCallbacks {
|
|
20
|
+
/** A copy target was chosen — copy its `content`. */
|
|
21
|
+
onPick: (target: CopyTarget) => void;
|
|
22
|
+
/** The picker was dismissed. */
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FlatNode {
|
|
27
|
+
target: CopyTarget;
|
|
28
|
+
depth: number;
|
|
29
|
+
/** Last among its siblings (drives └─ vs ├─). */
|
|
30
|
+
isLast: boolean;
|
|
31
|
+
/** Per-ancestor flag: does ancestor at that level have a following sibling? */
|
|
32
|
+
ancestorHasNext: boolean[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
|
|
36
|
+
function fit(text: string, width: number): string {
|
|
37
|
+
if (width <= 0) return "";
|
|
38
|
+
const w = visibleWidth(text);
|
|
39
|
+
if (w === width) return text;
|
|
40
|
+
if (w < width) return text + padding(width - w);
|
|
41
|
+
const cut = truncateToWidth(text, width);
|
|
42
|
+
const cw = visibleWidth(cut);
|
|
43
|
+
return cw < width ? cut + padding(width - cw) : cut;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function paint(s: string): string {
|
|
47
|
+
return theme.fg("border", s);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function topBorder(width: number, title: string): string {
|
|
51
|
+
const box = theme.boxSharp;
|
|
52
|
+
const inner = Math.max(0, width - 2);
|
|
53
|
+
if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
|
|
54
|
+
const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
|
|
55
|
+
const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
|
|
56
|
+
return (
|
|
57
|
+
paint(box.topLeft + box.horizontal) +
|
|
58
|
+
theme.bold(theme.fg("accent", shown)) +
|
|
59
|
+
paint(box.horizontal.repeat(fillWidth) + box.topRight)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function divider(width: number): string {
|
|
64
|
+
const box = theme.boxSharp;
|
|
65
|
+
return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bottomBorder(width: number): string {
|
|
69
|
+
const box = theme.boxSharp;
|
|
70
|
+
return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wrap pre-styled content in vertical borders with single-column insets. */
|
|
74
|
+
function row(content: string, width: number): string {
|
|
75
|
+
const box = theme.boxSharp;
|
|
76
|
+
return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
|
|
80
|
+
function connectorCells(symbol: string): string {
|
|
81
|
+
const chars = Array.from(symbol);
|
|
82
|
+
return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */
|
|
86
|
+
function gutterCells(hasNext: boolean): string {
|
|
87
|
+
return `${hasNext ? theme.tree.vertical : " "} `;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one
|
|
92
|
+
* outlined box: a title, the tree of copy targets (recent assistant messages
|
|
93
|
+
* with their code blocks nested beneath), a live preview of the highlighted
|
|
94
|
+
* node, and a keybinding footer. Every node copies its `content` on Enter.
|
|
95
|
+
*/
|
|
96
|
+
export class CopySelectorComponent implements Component {
|
|
97
|
+
#roots: CopyTarget[];
|
|
98
|
+
#cursorId: string;
|
|
99
|
+
#treeRows = MIN_TREE_ROWS;
|
|
100
|
+
// Reused across renders to wrap preview content to the pane width.
|
|
101
|
+
#previewText = new Text("", 0, 0);
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
roots: CopyTarget[],
|
|
105
|
+
private readonly callbacks: CopySelectorCallbacks,
|
|
106
|
+
) {
|
|
107
|
+
this.#roots = roots;
|
|
108
|
+
this.#cursorId = roots[0]?.id ?? "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
invalidate(): void {}
|
|
112
|
+
|
|
113
|
+
#flatten(): FlatNode[] {
|
|
114
|
+
const out: FlatNode[] = [];
|
|
115
|
+
const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => {
|
|
116
|
+
nodes.forEach((target, i) => {
|
|
117
|
+
const isLast = i === nodes.length - 1;
|
|
118
|
+
out.push({ target, depth, isLast, ancestorHasNext });
|
|
119
|
+
if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
walk(this.#roots, 0, []);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
handleInput(keyData: string): void {
|
|
127
|
+
if (matchesSelectCancel(keyData)) {
|
|
128
|
+
this.callbacks.onCancel();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const flat = this.#flatten();
|
|
133
|
+
if (flat.length === 0) return;
|
|
134
|
+
const idx = Math.max(
|
|
135
|
+
0,
|
|
136
|
+
flat.findIndex(n => n.target.id === this.#cursorId),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (matchesSelectUp(keyData)) {
|
|
140
|
+
this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id;
|
|
141
|
+
} else if (matchesSelectDown(keyData)) {
|
|
142
|
+
this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id;
|
|
143
|
+
} else if (matchesSelectPageUp(keyData)) {
|
|
144
|
+
this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id;
|
|
145
|
+
} else if (matchesSelectPageDown(keyData)) {
|
|
146
|
+
this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id;
|
|
147
|
+
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
148
|
+
const target = flat[idx]!.target;
|
|
149
|
+
if (target.content !== undefined) this.callbacks.onPick(target);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] {
|
|
154
|
+
const inner = Math.max(0, width - 4);
|
|
155
|
+
const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows)));
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
for (let r = 0; r < rows; r++) {
|
|
158
|
+
const i = start + r;
|
|
159
|
+
const node = flat[i];
|
|
160
|
+
if (!node) {
|
|
161
|
+
out.push(row("", width));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const target = node.target;
|
|
165
|
+
const isSelected = i === cursorIdx;
|
|
166
|
+
|
|
167
|
+
let prefix = "";
|
|
168
|
+
for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!);
|
|
169
|
+
if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch);
|
|
170
|
+
|
|
171
|
+
const cursor = isSelected ? "❯ " : " ";
|
|
172
|
+
const hint = target.hint ?? "";
|
|
173
|
+
const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
|
|
174
|
+
const used = visibleWidth(cursor) + visibleWidth(prefix);
|
|
175
|
+
const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth));
|
|
176
|
+
const left = isSelected
|
|
177
|
+
? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain))
|
|
178
|
+
: cursor + theme.fg("dim", prefix) + labelPlain;
|
|
179
|
+
const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint));
|
|
180
|
+
out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width));
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] {
|
|
186
|
+
const out: string[] = [];
|
|
187
|
+
const hint = target?.hint;
|
|
188
|
+
out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width));
|
|
189
|
+
|
|
190
|
+
const contentRows = rows - 1;
|
|
191
|
+
if (!target || contentRows <= 0) {
|
|
192
|
+
while (out.length < rows) out.push(row("", width));
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Code/command previews are syntax-highlighted; everything else is shown
|
|
197
|
+
// as plain text. Both are wrapped (not hard-truncated) to the pane width.
|
|
198
|
+
const isCode = target.language !== undefined;
|
|
199
|
+
const source = isCode
|
|
200
|
+
? highlightCode(replaceTabs(target.preview), target.language).join("\n")
|
|
201
|
+
: replaceTabs(target.preview);
|
|
202
|
+
this.#previewText.setText(source);
|
|
203
|
+
const wrapped = this.#previewText.render(Math.max(1, width - 4));
|
|
204
|
+
|
|
205
|
+
const hasMore = wrapped.length > contentRows;
|
|
206
|
+
const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows);
|
|
207
|
+
for (let k = 0; k < contentRows; k++) {
|
|
208
|
+
if (k < visibleCount) {
|
|
209
|
+
out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width));
|
|
210
|
+
} else if (k === visibleCount && hasMore) {
|
|
211
|
+
out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width));
|
|
212
|
+
} else {
|
|
213
|
+
out.push(row("", width));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
render(width: number): string[] {
|
|
220
|
+
const height = process.stdout.rows || 40;
|
|
221
|
+
const flat = this.#flatten();
|
|
222
|
+
const cursorIdx = Math.max(
|
|
223
|
+
0,
|
|
224
|
+
flat.findIndex(n => n.target.id === this.#cursorId),
|
|
225
|
+
);
|
|
226
|
+
const selected = flat[cursorIdx]?.target;
|
|
227
|
+
|
|
228
|
+
const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS);
|
|
229
|
+
const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2)));
|
|
230
|
+
this.#treeRows = treeRows;
|
|
231
|
+
const previewRows = Math.max(1, available - treeRows);
|
|
232
|
+
|
|
233
|
+
const footer = [
|
|
234
|
+
rawKeyHint("↑↓", "move"),
|
|
235
|
+
keyHint("tui.select.confirm", "copy"),
|
|
236
|
+
keyHint("tui.select.cancel", "quit"),
|
|
237
|
+
].join(theme.fg("dim", " · "));
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
topBorder(width, "Copy to clipboard"),
|
|
241
|
+
...this.#renderTree(width, flat, cursorIdx, treeRows),
|
|
242
|
+
divider(width),
|
|
243
|
+
...this.#renderPreview(width, selected, previewRows),
|
|
244
|
+
divider(width),
|
|
245
|
+
row(footer, width),
|
|
246
|
+
bottomBorder(width),
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
extractPrintableText,
|
|
11
11
|
matchesKey,
|
|
12
12
|
padding,
|
|
13
|
+
ScrollView,
|
|
13
14
|
truncateToWidth,
|
|
14
15
|
visibleWidth,
|
|
15
16
|
} from "@oh-my-pi/pi-tui";
|
|
@@ -134,25 +135,33 @@ export class ExtensionList implements Component {
|
|
|
134
135
|
const startIdx = this.#scrollOffset;
|
|
135
136
|
const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length);
|
|
136
137
|
|
|
138
|
+
// Reserve the rightmost column for the scrollbar when overflowing
|
|
139
|
+
const overflow = this.#listItems.length > this.#maxVisible;
|
|
140
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
141
|
+
|
|
137
142
|
// Render visible items
|
|
143
|
+
const rows: string[] = [];
|
|
138
144
|
for (let i = startIdx; i < endIdx; i++) {
|
|
139
145
|
const listItem = this.#listItems[i];
|
|
140
146
|
const isSelected = this.#focused && i === this.#selectedIndex;
|
|
141
147
|
|
|
142
148
|
if (listItem.type === "master") {
|
|
143
|
-
|
|
149
|
+
rows.push(this.#renderMasterSwitch(listItem, isSelected, rowWidth));
|
|
144
150
|
} else if (listItem.type === "kind-header") {
|
|
145
|
-
|
|
151
|
+
rows.push(this.#renderKindHeader(listItem, isSelected, rowWidth));
|
|
146
152
|
} else {
|
|
147
|
-
|
|
153
|
+
rows.push(this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled));
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
const sv = new ScrollView(rows, {
|
|
158
|
+
height: rows.length,
|
|
159
|
+
scrollbar: "auto",
|
|
160
|
+
totalRows: this.#listItems.length,
|
|
161
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
162
|
+
});
|
|
163
|
+
sv.setScrollOffset(this.#scrollOffset);
|
|
164
|
+
lines.push(...sv.render(width));
|
|
156
165
|
|
|
157
166
|
return lines;
|
|
158
167
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
7
|
padding,
|
|
8
|
+
ScrollView,
|
|
8
9
|
Spacer,
|
|
9
10
|
Text,
|
|
10
11
|
truncateToWidth,
|
|
@@ -115,15 +116,19 @@ class HistoryResultsList implements Component {
|
|
|
115
116
|
);
|
|
116
117
|
const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length);
|
|
117
118
|
|
|
119
|
+
const overflow = this.#results.length > this.#maxVisible;
|
|
120
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
121
|
+
const rows: string[] = [];
|
|
122
|
+
|
|
118
123
|
for (let i = startIndex; i < endIndex; i++) {
|
|
119
124
|
const entry = this.#results[i];
|
|
120
125
|
const isSelected = i === this.#selectedIndex;
|
|
121
126
|
|
|
122
127
|
const timeStr = relativeTime(entry.created_at);
|
|
123
128
|
const timeWidth = visibleWidth(timeStr);
|
|
124
|
-
const showTime =
|
|
129
|
+
const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
|
|
125
130
|
|
|
126
|
-
const promptBudget = Math.max(4,
|
|
131
|
+
const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
|
|
127
132
|
const normalized = entry.prompt.replace(/\s+/g, " ").trim();
|
|
128
133
|
const plain = truncateToWidth(normalized, promptBudget);
|
|
129
134
|
const highlighted = highlightTokens(plain, this.#tokens);
|
|
@@ -133,21 +138,24 @@ class HistoryResultsList implements Component {
|
|
|
133
138
|
|
|
134
139
|
if (showTime) {
|
|
135
140
|
// Pad the prompt region so the timestamp sits flush right with a one-cell gap.
|
|
136
|
-
line = `${truncateToWidth(line,
|
|
141
|
+
line = `${truncateToWidth(line, rowWidth - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
|
|
144
|
+
rows.push(
|
|
140
145
|
isSelected
|
|
141
|
-
? theme.bg("selectedBg", truncateToWidth(line,
|
|
142
|
-
: truncateToWidth(line,
|
|
146
|
+
? theme.bg("selectedBg", truncateToWidth(line, rowWidth, Ellipsis.Omit, true))
|
|
147
|
+
: truncateToWidth(line, rowWidth),
|
|
143
148
|
);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
const sv = new ScrollView(rows, {
|
|
152
|
+
height: rows.length,
|
|
153
|
+
scrollbar: "auto",
|
|
154
|
+
totalRows: this.#results.length,
|
|
155
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
156
|
+
});
|
|
157
|
+
sv.setScrollOffset(startIndex);
|
|
158
|
+
lines.push(...sv.render(width));
|
|
151
159
|
return lines;
|
|
152
160
|
}
|
|
153
161
|
}
|