@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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
fuzzyMatch,
|
|
7
7
|
Input,
|
|
8
8
|
matchesKey,
|
|
9
|
+
ScrollView,
|
|
9
10
|
Spacer,
|
|
10
11
|
Text,
|
|
11
12
|
TruncatedText,
|
|
@@ -492,6 +493,10 @@ class TreeList implements Component {
|
|
|
492
493
|
const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
|
|
493
494
|
const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
|
|
494
495
|
|
|
496
|
+
const overflow = this.#filteredNodes.length > this.maxVisibleLines;
|
|
497
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
498
|
+
const rows: string[] = [];
|
|
499
|
+
|
|
495
500
|
for (let i = startIndex; i < endIndex; i++) {
|
|
496
501
|
const flatNode = this.#filteredNodes[i];
|
|
497
502
|
const entry = flatNode.node.entry;
|
|
@@ -560,15 +565,22 @@ class TreeList implements Component {
|
|
|
560
565
|
if (isSelected) {
|
|
561
566
|
line = theme.bg("selectedBg", line);
|
|
562
567
|
}
|
|
563
|
-
|
|
568
|
+
rows.push(truncateToWidth(line, rowWidth));
|
|
564
569
|
}
|
|
565
570
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
),
|
|
571
|
-
);
|
|
571
|
+
const sv = new ScrollView(rows, {
|
|
572
|
+
height: rows.length,
|
|
573
|
+
scrollbar: "auto",
|
|
574
|
+
totalRows: this.#filteredNodes.length,
|
|
575
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
576
|
+
});
|
|
577
|
+
sv.setScrollOffset(startIndex);
|
|
578
|
+
lines.push(...sv.render(width));
|
|
579
|
+
|
|
580
|
+
const filterLabel = this.#getFilterLabel();
|
|
581
|
+
if (filterLabel) {
|
|
582
|
+
lines.push(truncateToWidth(theme.fg("muted", ` ${filterLabel.trim()}`), width));
|
|
583
|
+
}
|
|
572
584
|
|
|
573
585
|
return lines;
|
|
574
586
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
extractPrintableText,
|
|
5
5
|
fuzzyFilter,
|
|
6
6
|
matchesKey,
|
|
7
|
+
ScrollView,
|
|
7
8
|
Spacer,
|
|
8
9
|
Text,
|
|
9
10
|
truncateToWidth,
|
|
@@ -48,14 +49,10 @@ class UserMessageList implements Component {
|
|
|
48
49
|
return this.#isSearchEnabled() || this.#searchQuery.length > 0;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
#renderStatusLine(
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
? `${selectedCount}/${total} of ${this.messages.length}`
|
|
56
|
-
: `${selectedCount}/${total}`;
|
|
57
|
-
const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
|
|
58
|
-
return theme.fg("muted", ` (${count})${suffix}`);
|
|
52
|
+
#renderStatusLine(_total: number): string {
|
|
53
|
+
const query = this.#searchQuery.trim();
|
|
54
|
+
const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
|
|
55
|
+
return theme.fg("muted", ` ${suffix}`);
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
#setSearchQuery(query: string): void {
|
|
@@ -103,6 +100,9 @@ class UserMessageList implements Component {
|
|
|
103
100
|
const endIndex = Math.min(startIndex + this.#maxVisible, total);
|
|
104
101
|
|
|
105
102
|
// Render visible messages (2 lines per message + blank line)
|
|
103
|
+
const overflow = total > this.#maxVisible;
|
|
104
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
105
|
+
const messageLines: string[] = [];
|
|
106
106
|
for (let i = startIndex; i < endIndex; i++) {
|
|
107
107
|
const message = this.#filteredMessages[i];
|
|
108
108
|
if (!message) continue;
|
|
@@ -113,26 +113,37 @@ class UserMessageList implements Component {
|
|
|
113
113
|
|
|
114
114
|
// First line: cursor + message
|
|
115
115
|
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
|
116
|
-
const maxMsgWidth =
|
|
116
|
+
const maxMsgWidth = rowWidth - 2; // Account for cursor (2 chars)
|
|
117
117
|
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
|
|
118
118
|
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
messageLines.push(messageLine);
|
|
121
121
|
|
|
122
122
|
// Second line: metadata (position in history)
|
|
123
123
|
const position = this.messages.indexOf(message) + 1;
|
|
124
124
|
const metadata = ` Message ${position} of ${this.messages.length}`;
|
|
125
125
|
const metadataLine = theme.fg("muted", metadata);
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
messageLines.push(metadataLine);
|
|
127
|
+
messageLines.push(""); // Blank line between messages
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (total === 0) {
|
|
131
131
|
lines.push(theme.fg("muted", " No matching messages"));
|
|
132
|
+
} else {
|
|
133
|
+
const visibleCount = endIndex - startIndex;
|
|
134
|
+
const linesPerItem = visibleCount > 0 ? messageLines.length / visibleCount : 1;
|
|
135
|
+
const sv = new ScrollView(messageLines, {
|
|
136
|
+
height: messageLines.length,
|
|
137
|
+
scrollbar: "auto",
|
|
138
|
+
totalRows: Math.round(total * linesPerItem),
|
|
139
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
140
|
+
});
|
|
141
|
+
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
|
|
142
|
+
lines.push(...sv.render(width));
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
// Add
|
|
135
|
-
if (
|
|
145
|
+
// Add search indicator if needed
|
|
146
|
+
if (this.#shouldRenderSearchStatus()) {
|
|
136
147
|
lines.push(this.#renderStatusLine(total));
|
|
137
148
|
}
|
|
138
149
|
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
getEnvApiKey,
|
|
7
7
|
getProviderDetails,
|
|
8
8
|
type ProviderDetails,
|
|
9
|
-
type ToolCall,
|
|
10
9
|
type UsageLimit,
|
|
11
10
|
type UsageReport,
|
|
12
11
|
} from "@oh-my-pi/pi-ai";
|
|
@@ -239,121 +238,6 @@ export class CommandController {
|
|
|
239
238
|
}
|
|
240
239
|
}
|
|
241
240
|
|
|
242
|
-
handleCopyCommand(sub?: string) {
|
|
243
|
-
switch (sub) {
|
|
244
|
-
case "code":
|
|
245
|
-
return this.#copyCode();
|
|
246
|
-
case "all":
|
|
247
|
-
return this.#copyAllCode();
|
|
248
|
-
case "cmd":
|
|
249
|
-
return this.#copyLastCommand();
|
|
250
|
-
case "last":
|
|
251
|
-
case undefined:
|
|
252
|
-
return this.#copyLastMessage();
|
|
253
|
-
default:
|
|
254
|
-
this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
#copyLastMessage() {
|
|
259
|
-
const assistantText = this.ctx.session.getLastAssistantText();
|
|
260
|
-
if (assistantText) {
|
|
261
|
-
this.#doCopy(assistantText, "Copied last agent message to clipboard");
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
|
|
266
|
-
const handoffText = this.ctx.session.getLastVisibleHandoffText();
|
|
267
|
-
if (handoffText) {
|
|
268
|
-
this.#doCopy(handoffText, "Copied handoff context to clipboard");
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
#copyCode() {
|
|
277
|
-
const text = this.ctx.session.getLastAssistantText();
|
|
278
|
-
if (!text) {
|
|
279
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
|
|
283
|
-
const lastMatch = matches.at(-1);
|
|
284
|
-
if (!lastMatch) {
|
|
285
|
-
this.ctx.showWarning("No code block found in the last agent message.");
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#copyAllCode() {
|
|
292
|
-
const text = this.ctx.session.getLastAssistantText();
|
|
293
|
-
if (!text) {
|
|
294
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
|
|
298
|
-
if (matches.length === 0) {
|
|
299
|
-
this.ctx.showWarning("No code blocks found in the last agent message.");
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n");
|
|
303
|
-
this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
#extractEvalCode(args: unknown): string | undefined {
|
|
307
|
-
if (!args || typeof args !== "object") return undefined;
|
|
308
|
-
const cells = (args as { cells?: unknown }).cells;
|
|
309
|
-
if (!Array.isArray(cells)) return undefined;
|
|
310
|
-
|
|
311
|
-
const codeBlocks: string[] = [];
|
|
312
|
-
for (const cell of cells) {
|
|
313
|
-
if (!cell || typeof cell !== "object") continue;
|
|
314
|
-
const code = (cell as { code?: unknown }).code;
|
|
315
|
-
if (typeof code === "string" && code.length > 0) {
|
|
316
|
-
codeBlocks.push(code);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
#copyLastCommand() {
|
|
324
|
-
const messages = this.ctx.session.messages;
|
|
325
|
-
// Walk backwards to find the last bash/eval tool call
|
|
326
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
327
|
-
const msg = messages[i];
|
|
328
|
-
if (msg.role !== "assistant") continue;
|
|
329
|
-
const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
|
|
330
|
-
for (let j = toolCalls.length - 1; j >= 0; j--) {
|
|
331
|
-
const tc = toolCalls[j];
|
|
332
|
-
if (tc.name === "bash" && typeof tc.arguments.command === "string") {
|
|
333
|
-
this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (tc.name === "eval") {
|
|
337
|
-
const code = this.#extractEvalCode(tc.arguments);
|
|
338
|
-
if (code) {
|
|
339
|
-
this.#doCopy(code, "Copied last eval code to clipboard");
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
this.ctx.showWarning("No bash or eval command found in the conversation.");
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
#doCopy(content: string, label: string) {
|
|
349
|
-
try {
|
|
350
|
-
copyToClipboard(content);
|
|
351
|
-
this.ctx.showStatus(label);
|
|
352
|
-
} catch (error) {
|
|
353
|
-
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
241
|
async handleSessionCommand(): Promise<void> {
|
|
358
242
|
const stats = this.ctx.session.getSessionStats();
|
|
359
243
|
const premiumRequests =
|
|
@@ -49,9 +49,15 @@ export class EventController {
|
|
|
49
49
|
#lastIntent: string | undefined = undefined;
|
|
50
50
|
#backgroundToolCallIds = new Set<string>();
|
|
51
51
|
#assistantMessageStreaming = false;
|
|
52
|
+
#agentTurnActive = false;
|
|
52
53
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
53
54
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
54
55
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
56
|
+
// Assistant component whose turn-ending error is currently mirrored in the
|
|
57
|
+
// pinned banner. Its inline `Error: …` line is suppressed while pinned and
|
|
58
|
+
// restored when the banner clears at the next `agent_start` (see
|
|
59
|
+
// #handleMessageEnd / #handleAgentStart).
|
|
60
|
+
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
|
|
55
61
|
#idleCompactionTimer?: NodeJS.Timeout;
|
|
56
62
|
#ircExpiryTimers = new Map<string, NodeJS.Timeout>();
|
|
57
63
|
#handlers: AgentSessionEventHandlers;
|
|
@@ -172,21 +178,21 @@ export class EventController {
|
|
|
172
178
|
|
|
173
179
|
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
|
|
174
180
|
await run(event);
|
|
175
|
-
// While assistant
|
|
176
|
-
//
|
|
177
|
-
// (Markdown fences, wrapping, previews). Let the
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// Background-running tools are excluded so late async
|
|
181
|
-
//
|
|
182
|
-
//
|
|
181
|
+
// While an assistant turn is active, visible status chrome and foreground
|
|
182
|
+
// transcript blocks can re-render after rows have entered native scrollback
|
|
183
|
+
// (idle Working loader, Markdown fences, wrapping, tool previews). Let the
|
|
184
|
+
// TUI use its foreground live-region path instead of idle deferral, which
|
|
185
|
+
// can otherwise leave the loader/status frame frozen until the next input.
|
|
186
|
+
// Background-running tools after the turn ends are excluded so late async
|
|
187
|
+
// updates keep the no-yank deferral; agent_start/agent_end bracket the
|
|
188
|
+
// foreground turn.
|
|
183
189
|
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
|
|
184
190
|
this.#refreshToolRenderMode();
|
|
185
191
|
}
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
#refreshToolRenderMode(): void {
|
|
189
|
-
let foregroundToolActive = this.#assistantMessageStreaming;
|
|
195
|
+
let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
|
|
190
196
|
if (!foregroundToolActive) {
|
|
191
197
|
for (const toolCallId of this.ctx.pendingTools.keys()) {
|
|
192
198
|
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
|
@@ -199,11 +205,16 @@ export class EventController {
|
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
208
|
+
this.#agentTurnActive = true;
|
|
202
209
|
this.#lastIntent = undefined;
|
|
203
210
|
this.#readToolCallArgs.clear();
|
|
204
211
|
this.#readToolCallAssistantComponents.clear();
|
|
205
212
|
this.#assistantMessageStreaming = false;
|
|
206
213
|
this.#lastAssistantComponent = undefined;
|
|
214
|
+
// Restore the previous turn's inline error in the transcript before dropping
|
|
215
|
+
// the banner, so the error stays in history once the banner is gone.
|
|
216
|
+
this.#pinnedErrorComponent?.setErrorPinned(false);
|
|
217
|
+
this.#pinnedErrorComponent = undefined;
|
|
207
218
|
this.ctx.clearPinnedError();
|
|
208
219
|
if (this.ctx.retryEscapeHandler) {
|
|
209
220
|
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
@@ -215,6 +226,7 @@ export class EventController {
|
|
|
215
226
|
this.ctx.statusContainer.clear();
|
|
216
227
|
}
|
|
217
228
|
this.#cancelIdleCompaction();
|
|
229
|
+
this.#refreshToolRenderMode();
|
|
218
230
|
this.ctx.ensureLoadingAnimation();
|
|
219
231
|
this.ctx.ui.requestRender();
|
|
220
232
|
}
|
|
@@ -493,12 +505,15 @@ export class EventController {
|
|
|
493
505
|
this.ctx.streamingMessage = undefined;
|
|
494
506
|
// Pin a turn-ending provider error (e.g. Anthropic content-filter block)
|
|
495
507
|
// above the editor so it survives transcript scroll. Cleared at the next
|
|
496
|
-
// turn's agent_start.
|
|
508
|
+
// turn's agent_start. Suppress the transcript's inline `Error: …` line for
|
|
509
|
+
// the same message while pinned so the error isn't rendered twice.
|
|
497
510
|
if (
|
|
498
511
|
event.message.stopReason === "error" &&
|
|
499
512
|
event.message.errorMessage &&
|
|
500
513
|
!isSilentAbort(event.message.errorMessage)
|
|
501
514
|
) {
|
|
515
|
+
this.#lastAssistantComponent?.setErrorPinned(true);
|
|
516
|
+
this.#pinnedErrorComponent = this.#lastAssistantComponent;
|
|
502
517
|
this.ctx.showPinnedError(event.message.errorMessage);
|
|
503
518
|
}
|
|
504
519
|
this.ctx.statusLine.invalidate();
|
|
@@ -646,6 +661,7 @@ export class EventController {
|
|
|
646
661
|
}
|
|
647
662
|
}
|
|
648
663
|
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
|
|
664
|
+
this.#agentTurnActive = false;
|
|
649
665
|
this.#assistantMessageStreaming = false;
|
|
650
666
|
if (this.ctx.loadingAnimation) {
|
|
651
667
|
this.ctx.loadingAnimation.stop();
|
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
setPreferredSearchProvider,
|
|
38
38
|
} from "../../tools";
|
|
39
39
|
import { shortenPath } from "../../tools/render-utils";
|
|
40
|
+
import { copyToClipboard } from "../../utils/clipboard";
|
|
40
41
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
41
42
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
42
43
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
44
|
+
import { CopySelectorComponent } from "../components/copy-selector";
|
|
43
45
|
import { ExtensionDashboard } from "../components/extensions";
|
|
44
46
|
import { HistorySearchComponent } from "../components/history-search";
|
|
45
47
|
import { ModelSelectorComponent } from "../components/model-selector";
|
|
@@ -52,6 +54,8 @@ import { ToolExecutionComponent } from "../components/tool-execution";
|
|
|
52
54
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
53
55
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
54
56
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
57
|
+
import { computeContextBreakdown } from "../utils/context-usage";
|
|
58
|
+
import { buildCopyTargets } from "../utils/copy-targets";
|
|
55
59
|
|
|
56
60
|
const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
|
|
57
61
|
"anthropic",
|
|
@@ -407,6 +411,7 @@ export class SelectorController {
|
|
|
407
411
|
}
|
|
408
412
|
|
|
409
413
|
showModelSelector(options?: { temporaryOnly?: boolean }): void {
|
|
414
|
+
const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens;
|
|
410
415
|
this.showSelector(done => {
|
|
411
416
|
const selector = new ModelSelectorComponent(
|
|
412
417
|
this.ctx.ui,
|
|
@@ -470,7 +475,7 @@ export class SelectorController {
|
|
|
470
475
|
done();
|
|
471
476
|
this.ctx.ui.requestRender();
|
|
472
477
|
},
|
|
473
|
-
options,
|
|
478
|
+
{ ...options, currentContextTokens },
|
|
474
479
|
);
|
|
475
480
|
return { component: selector, focus: selector };
|
|
476
481
|
});
|
|
@@ -598,6 +603,38 @@ export class SelectorController {
|
|
|
598
603
|
});
|
|
599
604
|
}
|
|
600
605
|
|
|
606
|
+
showCopySelector(): void {
|
|
607
|
+
const targets = buildCopyTargets(this.ctx.session);
|
|
608
|
+
if (targets.length === 0) {
|
|
609
|
+
this.ctx.showStatus("Nothing to copy yet.");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
614
|
+
const done = () => {
|
|
615
|
+
overlayHandle?.hide();
|
|
616
|
+
this.ctx.ui.requestRender();
|
|
617
|
+
};
|
|
618
|
+
const selector = new CopySelectorComponent(targets, {
|
|
619
|
+
onPick: target => {
|
|
620
|
+
done();
|
|
621
|
+
if (target.content === undefined) return;
|
|
622
|
+
void copyToClipboard(target.content);
|
|
623
|
+
this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard");
|
|
624
|
+
},
|
|
625
|
+
onCancel: done,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
overlayHandle = this.ctx.ui.showOverlay(selector, {
|
|
629
|
+
anchor: "bottom-center",
|
|
630
|
+
width: "100%",
|
|
631
|
+
maxHeight: "100%",
|
|
632
|
+
margin: 0,
|
|
633
|
+
});
|
|
634
|
+
this.ctx.ui.setFocus(selector);
|
|
635
|
+
this.ctx.ui.requestRender();
|
|
636
|
+
}
|
|
637
|
+
|
|
601
638
|
showTreeSelector(): void {
|
|
602
639
|
const tree = this.ctx.sessionManager.getTree();
|
|
603
640
|
const realLeafId = this.ctx.sessionManager.getLeafId();
|
|
@@ -2700,10 +2700,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2700
2700
|
return this.#commandController.handleShareCommand();
|
|
2701
2701
|
}
|
|
2702
2702
|
|
|
2703
|
-
handleCopyCommand(sub?: string) {
|
|
2704
|
-
return this.#commandController.handleCopyCommand(sub);
|
|
2705
|
-
}
|
|
2706
|
-
|
|
2707
2703
|
handleTodoCommand(args: string): Promise<void> {
|
|
2708
2704
|
return this.#todoCommandController.handleTodoCommand(args);
|
|
2709
2705
|
}
|
|
@@ -2936,6 +2932,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2936
2932
|
this.#selectorController.showUserMessageSelector();
|
|
2937
2933
|
}
|
|
2938
2934
|
|
|
2935
|
+
showCopySelector(): void {
|
|
2936
|
+
this.#selectorController.showCopySelector();
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
2939
|
showTreeSelector(): void {
|
|
2940
2940
|
this.#selectorController.showTreeSelector();
|
|
2941
2941
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
|
|
13
13
|
import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import chalk from "chalk";
|
|
15
|
+
import { LRUCache } from "lru-cache/raw";
|
|
15
16
|
import * as z from "zod/v4";
|
|
16
17
|
// Embed theme JSON files at build time
|
|
17
18
|
import darkThemeJson from "./dark.json" with { type: "json" };
|
|
@@ -2429,17 +2430,54 @@ function getHighlightColors(t: Theme): NativeHighlightColors {
|
|
|
2429
2430
|
return cachedHighlightColors;
|
|
2430
2431
|
}
|
|
2431
2432
|
|
|
2433
|
+
/**
|
|
2434
|
+
* Memoized native syntax highlight. Returns the joined ANSI string, or `null`
|
|
2435
|
+
* when the native tokenizer throws so callers can apply their own fallback.
|
|
2436
|
+
*
|
|
2437
|
+
* Keyed on `(lang, code)` and reset whenever the active `theme` instance
|
|
2438
|
+
* changes — the ANSI colors are baked into the highlighted output, so a theme
|
|
2439
|
+
* switch (which always reassigns `theme`) must invalidate every entry.
|
|
2440
|
+
*
|
|
2441
|
+
* Why this exists: animated tool blocks (eval/bash) repaint their box on every
|
|
2442
|
+
* ~16ms border-shimmer frame, and markdown re-lexes on every streamed delta.
|
|
2443
|
+
* Without memoization each frame re-tokenizes an unchanged code body through the
|
|
2444
|
+
* Rust FFI — ~26ms for 100 lines, ~40ms for 150 — overrunning the 16ms frame
|
|
2445
|
+
* budget and starving the spinner/render timers (the "TUI freeze").
|
|
2446
|
+
*/
|
|
2447
|
+
const HIGHLIGHT_CACHE_MAX = 256;
|
|
2448
|
+
const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
|
|
2449
|
+
let highlightCacheTheme: Theme | undefined;
|
|
2450
|
+
|
|
2451
|
+
function highlightCached(code: string, validLang: string | undefined): string | null {
|
|
2452
|
+
if (highlightCacheTheme !== theme) {
|
|
2453
|
+
highlightCache.clear();
|
|
2454
|
+
highlightCacheTheme = theme;
|
|
2455
|
+
}
|
|
2456
|
+
const key = `${validLang ?? ""}\x00${code}`;
|
|
2457
|
+
const hit = highlightCache.get(key);
|
|
2458
|
+
if (hit !== undefined) {
|
|
2459
|
+
return hit;
|
|
2460
|
+
}
|
|
2461
|
+
let highlighted: string;
|
|
2462
|
+
try {
|
|
2463
|
+
highlighted = nativeHighlightCode(code, validLang, getHighlightColors(theme));
|
|
2464
|
+
} catch {
|
|
2465
|
+
return null;
|
|
2466
|
+
}
|
|
2467
|
+
highlightCache.set(key, highlighted);
|
|
2468
|
+
return highlighted;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2432
2471
|
/**
|
|
2433
2472
|
* Highlight code with syntax coloring based on file extension or language.
|
|
2434
2473
|
* Returns array of highlighted lines.
|
|
2435
2474
|
*/
|
|
2436
2475
|
export function highlightCode(code: string, lang?: string): string[] {
|
|
2437
2476
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
}
|
|
2477
|
+
const highlighted = highlightCached(code, validLang);
|
|
2478
|
+
// Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
|
|
2479
|
+
// onto the result, which would corrupt the cached string otherwise.
|
|
2480
|
+
return (highlighted ?? code).split("\n");
|
|
2443
2481
|
}
|
|
2444
2482
|
|
|
2445
2483
|
export function getSymbolTheme(): SymbolTheme {
|
|
@@ -2484,11 +2522,9 @@ export function getMarkdownTheme(): MarkdownTheme {
|
|
|
2484
2522
|
resolveMermaidAscii,
|
|
2485
2523
|
highlightCode: (code: string, lang?: string): string[] => {
|
|
2486
2524
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
|
|
2491
|
-
}
|
|
2525
|
+
const highlighted = highlightCached(code, validLang);
|
|
2526
|
+
if (highlighted !== null) return highlighted.split("\n");
|
|
2527
|
+
return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
|
|
2492
2528
|
},
|
|
2493
2529
|
};
|
|
2494
2530
|
cachedMarkdownTheme = markdownTheme;
|
package/src/modes/types.ts
CHANGED
|
@@ -222,7 +222,6 @@ export interface InteractiveModeContext {
|
|
|
222
222
|
// Command handling
|
|
223
223
|
handleExportCommand(text: string): Promise<void>;
|
|
224
224
|
handleShareCommand(): Promise<void>;
|
|
225
|
-
handleCopyCommand(sub?: string): void;
|
|
226
225
|
handleTodoCommand(args: string): Promise<void>;
|
|
227
226
|
handleSessionCommand(): Promise<void>;
|
|
228
227
|
handleJobsCommand(): Promise<void>;
|
|
@@ -263,6 +262,7 @@ export interface InteractiveModeContext {
|
|
|
263
262
|
showModelSelector(options?: { temporaryOnly?: boolean }): void;
|
|
264
263
|
showPluginSelector(mode?: "install" | "uninstall"): void;
|
|
265
264
|
showUserMessageSelector(): void;
|
|
265
|
+
showCopySelector(): void;
|
|
266
266
|
showTreeSelector(): void;
|
|
267
267
|
showSessionSelector(): void;
|
|
268
268
|
handleResumeSession(sessionPath: string): Promise<void>;
|