@oh-my-pi/pi-coding-agent 15.7.5 → 15.7.6
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 +30 -1
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
- package/dist/types/eval/heartbeat.d.ts +45 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +24 -5
- package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +2 -0
- package/dist/types/lsp/utils.d.ts +4 -0
- package/dist/types/modes/acp/acp-agent.d.ts +1 -1
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/custom-editor.d.ts +0 -1
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/tools/ask.d.ts +8 -6
- package/dist/types/tools/eval-backends.d.ts +12 -0
- package/dist/types/tools/eval-render.d.ts +52 -0
- package/dist/types/tools/eval.d.ts +2 -35
- package/dist/types/tools/index.d.ts +4 -11
- package/dist/types/tui/output-block.d.ts +4 -3
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/thinking-note.ts +13 -0
- package/package.json +9 -9
- package/src/config/model-registry.ts +33 -4
- package/src/config/settings-schema.ts +10 -0
- package/src/discovery/claude.ts +41 -22
- package/src/edit/index.ts +23 -3
- package/src/eval/__tests__/agent-bridge.test.ts +90 -0
- package/src/eval/__tests__/heartbeat.test.ts +84 -0
- package/src/eval/__tests__/llm-bridge.test.ts +30 -0
- package/src/eval/agent-bridge.ts +44 -38
- package/src/eval/heartbeat.ts +74 -0
- package/src/eval/js/executor.ts +13 -9
- package/src/eval/llm-bridge.ts +20 -14
- package/src/eval/py/executor.ts +14 -18
- package/src/exec/bash-executor.ts +31 -5
- package/src/extensibility/extensions/loader.ts +16 -18
- package/src/extensibility/extensions/runner.ts +22 -17
- package/src/extensibility/extensions/types.ts +39 -5
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/lsp/diagnostics-ledger.ts +51 -0
- package/src/lsp/index.ts +9 -22
- package/src/lsp/utils.ts +21 -0
- package/src/modes/acp/acp-agent.ts +8 -4
- package/src/modes/components/assistant-message.ts +28 -1
- package/src/modes/components/custom-editor.ts +9 -7
- package/src/modes/components/hook-selector.ts +159 -32
- package/src/modes/components/tool-execution.ts +20 -4
- package/src/modes/controllers/event-controller.ts +5 -2
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +0 -15
- package/src/modes/interactive-mode.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +17 -6
- package/src/modes/theme/theme-schema.json +30 -0
- package/src/modes/theme/theme.ts +39 -2
- package/src/modes/types.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/tools/ask.md +2 -1
- package/src/prompts/tools/eval.md +1 -1
- package/src/session/agent-session.ts +65 -11
- package/src/tools/ask.ts +74 -32
- package/src/tools/eval-backends.ts +38 -0
- package/src/tools/eval-render.ts +750 -0
- package/src/tools/eval.ts +27 -754
- package/src/tools/index.ts +7 -37
- package/src/tools/renderers.ts +1 -1
- package/src/tools/write.ts +9 -1
- package/src/tui/output-block.ts +5 -4
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { FileDiagnosticsResult } from "./index";
|
|
2
|
+
import { summarizeDiagnosticMessages } from "./utils";
|
|
3
|
+
|
|
4
|
+
const DIAGNOSTIC_LOCATION_PREFIX_RE = /^.*?:\d+:\d+\s+/;
|
|
5
|
+
|
|
6
|
+
export function diagnosticIdentity(message: string): string {
|
|
7
|
+
return message.replace(DIAGNOSTIC_LOCATION_PREFIX_RE, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class DiagnosticsLedger {
|
|
11
|
+
readonly #seen = new Map<string, Set<string>>();
|
|
12
|
+
|
|
13
|
+
reduce(absPath: string, result: FileDiagnosticsResult): FileDiagnosticsResult {
|
|
14
|
+
const previous = this.#seen.get(absPath);
|
|
15
|
+
const currentIdentities = new Set<string>();
|
|
16
|
+
const fresh: string[] = [];
|
|
17
|
+
|
|
18
|
+
for (const message of result.messages) {
|
|
19
|
+
const identity = diagnosticIdentity(message);
|
|
20
|
+
currentIdentities.add(identity);
|
|
21
|
+
if (!previous?.has(identity)) {
|
|
22
|
+
fresh.push(message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (currentIdentities.size === 0) {
|
|
27
|
+
this.#seen.delete(absPath);
|
|
28
|
+
} else {
|
|
29
|
+
this.#seen.set(absPath, currentIdentities);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (fresh.length === result.messages.length) {
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...result,
|
|
38
|
+
messages: fresh,
|
|
39
|
+
...summarizeDiagnosticMessages(fresh),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DiagnosticsLedgerOwner {
|
|
45
|
+
diagnosticsLedger?: DiagnosticsLedger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getDiagnosticsLedger(owner: DiagnosticsLedgerOwner): DiagnosticsLedger {
|
|
49
|
+
owner.diagnosticsLedger ??= new DiagnosticsLedger();
|
|
50
|
+
return owner.diagnosticsLedger;
|
|
51
|
+
}
|
package/src/lsp/index.ts
CHANGED
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
resolveDiagnosticTargets,
|
|
80
80
|
resolveSymbolColumn,
|
|
81
81
|
sortDiagnostics,
|
|
82
|
+
summarizeDiagnosticMessages,
|
|
82
83
|
symbolKindToIcon,
|
|
83
84
|
uriToFile,
|
|
84
85
|
} from "./utils";
|
|
@@ -816,12 +817,15 @@ export interface WritethroughOptions {
|
|
|
816
817
|
onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
|
|
817
818
|
/** Signal to cancel a pending deferred diagnostics fetch. */
|
|
818
819
|
deferredSignal?: AbortSignal;
|
|
820
|
+
/** Transform diagnostics before surfacing them after a successful fetch. */
|
|
821
|
+
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
|
819
822
|
}
|
|
820
823
|
|
|
821
824
|
/** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
|
|
822
825
|
type ResolvedWritethroughOptions = {
|
|
823
826
|
enableFormat: boolean;
|
|
824
827
|
enableDiagnostics: boolean;
|
|
828
|
+
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
|
825
829
|
};
|
|
826
830
|
|
|
827
831
|
/** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
|
|
@@ -881,6 +885,7 @@ function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughO
|
|
|
881
885
|
if (existing) {
|
|
882
886
|
existing.options.enableFormat ||= options.enableFormat;
|
|
883
887
|
existing.options.enableDiagnostics ||= options.enableDiagnostics;
|
|
888
|
+
existing.options.transformDiagnostics ??= options.transformDiagnostics;
|
|
884
889
|
return existing;
|
|
885
890
|
}
|
|
886
891
|
const batch: LspWritethroughBatchState = {
|
|
@@ -904,27 +909,6 @@ export async function flushLspWritethroughBatch(
|
|
|
904
909
|
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
|
|
905
910
|
}
|
|
906
911
|
|
|
907
|
-
function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
|
|
908
|
-
const counts = { error: 0, warning: 0, info: 0, hint: 0 };
|
|
909
|
-
for (const message of messages) {
|
|
910
|
-
const match = message.match(/\[(error|warning|info|hint)\]/i);
|
|
911
|
-
if (!match) continue;
|
|
912
|
-
const key = match[1].toLowerCase() as keyof typeof counts;
|
|
913
|
-
counts[key] += 1;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
const parts: string[] = [];
|
|
917
|
-
if (counts.error > 0) parts.push(`${counts.error} error(s)`);
|
|
918
|
-
if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
|
|
919
|
-
if (counts.info > 0) parts.push(`${counts.info} info(s)`);
|
|
920
|
-
if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
|
|
921
|
-
|
|
922
|
-
return {
|
|
923
|
-
summary: parts.length > 0 ? parts.join(", ") : "no issues",
|
|
924
|
-
errored: counts.error > 0,
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
|
|
928
912
|
function mergeDiagnostics(
|
|
929
913
|
results: Array<FileDiagnosticsResult | undefined>,
|
|
930
914
|
options: ResolvedWritethroughOptions,
|
|
@@ -1083,12 +1067,14 @@ async function runLspWritethrough(
|
|
|
1083
1067
|
|
|
1084
1068
|
// 6. Get diagnostics from all servers (wait for fresh results)
|
|
1085
1069
|
if (enableDiagnostics) {
|
|
1086
|
-
|
|
1070
|
+
const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
|
|
1087
1071
|
signal: operationSignal,
|
|
1088
1072
|
minVersions,
|
|
1089
1073
|
expectedDocumentVersions,
|
|
1090
1074
|
allowUnversionedLspDiagnostics: false,
|
|
1091
1075
|
});
|
|
1076
|
+
diagnostics =
|
|
1077
|
+
fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
|
|
1092
1078
|
}
|
|
1093
1079
|
});
|
|
1094
1080
|
} catch {
|
|
@@ -1155,6 +1141,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
|
|
|
1155
1141
|
const resolvedOptions: ResolvedWritethroughOptions = {
|
|
1156
1142
|
enableFormat: options?.enableFormat ?? false,
|
|
1157
1143
|
enableDiagnostics: options?.enableDiagnostics ?? false,
|
|
1144
|
+
transformDiagnostics: options?.transformDiagnostics,
|
|
1158
1145
|
};
|
|
1159
1146
|
if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
|
|
1160
1147
|
return writethroughNoop;
|
package/src/lsp/utils.ts
CHANGED
|
@@ -221,6 +221,27 @@ export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string {
|
|
|
221
221
|
return parts.length > 0 ? parts.join(", ") : "no issues";
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
export function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
|
|
225
|
+
const counts = { error: 0, warning: 0, info: 0, hint: 0 };
|
|
226
|
+
for (const message of messages) {
|
|
227
|
+
const match = message.match(/\[(error|warning|info|hint)\]/i);
|
|
228
|
+
if (!match) continue;
|
|
229
|
+
const key = match[1].toLowerCase() as keyof typeof counts;
|
|
230
|
+
counts[key] += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parts: string[] = [];
|
|
234
|
+
if (counts.error > 0) parts.push(`${counts.error} error(s)`);
|
|
235
|
+
if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
|
|
236
|
+
if (counts.info > 0) parts.push(`${counts.info} info(s)`);
|
|
237
|
+
if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
summary: parts.length > 0 ? parts.join(", ") : "no issues",
|
|
241
|
+
errored: counts.error > 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
224
245
|
// =============================================================================
|
|
225
246
|
// Location Formatting
|
|
226
247
|
// =============================================================================
|
|
@@ -47,7 +47,11 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
|
47
47
|
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
|
|
48
48
|
import { Settings } from "../../config/settings";
|
|
49
49
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
50
|
-
import
|
|
50
|
+
import {
|
|
51
|
+
type ExtensionUIContext,
|
|
52
|
+
type ExtensionUIDialogOptions,
|
|
53
|
+
getExtensionUISelectOptionLabel,
|
|
54
|
+
} from "../../extensibility/extensions";
|
|
51
55
|
import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
|
|
52
56
|
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
|
|
53
57
|
import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
|
|
@@ -302,7 +306,7 @@ export function createAcpExtensionUiContext(
|
|
|
302
306
|
getSessionId(),
|
|
303
307
|
"select",
|
|
304
308
|
title,
|
|
305
|
-
{ type: "string", enum: options },
|
|
309
|
+
{ type: "string", enum: options.map(getExtensionUISelectOptionLabel) },
|
|
306
310
|
dialogOptions,
|
|
307
311
|
);
|
|
308
312
|
return typeof value === "string" ? value : undefined;
|
|
@@ -1981,7 +1985,7 @@ export class AcpAgent implements Agent {
|
|
|
1981
1985
|
}
|
|
1982
1986
|
if (servers.length === 0) {
|
|
1983
1987
|
record.mcpManager = undefined;
|
|
1984
|
-
await record.session.refreshMCPTools([]);
|
|
1988
|
+
await record.session.refreshMCPTools([], { activateAll: true });
|
|
1985
1989
|
return;
|
|
1986
1990
|
}
|
|
1987
1991
|
|
|
@@ -2008,7 +2012,7 @@ export class AcpAgent implements Agent {
|
|
|
2008
2012
|
}
|
|
2009
2013
|
|
|
2010
2014
|
record.mcpManager = manager;
|
|
2011
|
-
await record.session.refreshMCPTools(result.tools);
|
|
2015
|
+
await record.session.refreshMCPTools(result.tools, { activateAll: true });
|
|
2012
2016
|
}
|
|
2013
2017
|
|
|
2014
2018
|
#toMcpConfig(server: McpServer): MCPServerConfig {
|
|
@@ -2,6 +2,7 @@ import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
|
|
|
2
2
|
import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
3
3
|
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { settings } from "../../config/settings";
|
|
5
|
+
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
|
|
5
6
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
6
7
|
import { isSilentAbort } from "../../session/messages";
|
|
7
8
|
import { resolveImageOptions } from "../../tools/render-utils";
|
|
@@ -21,6 +22,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
21
22
|
message?: AssistantMessage,
|
|
22
23
|
private hideThinkingBlock = false,
|
|
23
24
|
private readonly onImageUpdate?: () => void,
|
|
25
|
+
private readonly thinkingRenderers: readonly AssistantThinkingRenderer[] = [],
|
|
24
26
|
) {
|
|
25
27
|
super();
|
|
26
28
|
|
|
@@ -131,6 +133,27 @@ export class AssistantMessageComponent extends Container {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
#appendThinkingExtensions(contentIndex: number, thinkingIndex: number, text: string): void {
|
|
137
|
+
for (const renderer of this.thinkingRenderers) {
|
|
138
|
+
try {
|
|
139
|
+
const component = renderer(
|
|
140
|
+
{
|
|
141
|
+
contentIndex,
|
|
142
|
+
thinkingIndex,
|
|
143
|
+
text,
|
|
144
|
+
requestRender: () => this.onImageUpdate?.(),
|
|
145
|
+
},
|
|
146
|
+
theme,
|
|
147
|
+
);
|
|
148
|
+
if (component) {
|
|
149
|
+
this.#contentContainer.addChild(component);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore extension renderer failures and keep the original thinking block visible.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
updateContent(message: AssistantMessage): void {
|
|
135
158
|
this.#lastMessage = message;
|
|
136
159
|
|
|
@@ -146,6 +169,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
// Render content in order
|
|
172
|
+
let thinkingIndex = 0;
|
|
149
173
|
for (let i = 0; i < message.content.length; i++) {
|
|
150
174
|
const content = message.content[i];
|
|
151
175
|
if (content.type === "text" && content.text.trim()) {
|
|
@@ -166,13 +190,16 @@ export class AssistantMessageComponent extends Container {
|
|
|
166
190
|
this.#contentContainer.addChild(new Spacer(1));
|
|
167
191
|
}
|
|
168
192
|
} else {
|
|
193
|
+
const thinkingText = content.thinking.trim();
|
|
169
194
|
// Thinking traces in thinkingText color, italic
|
|
170
195
|
this.#contentContainer.addChild(
|
|
171
|
-
new Markdown(
|
|
196
|
+
new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
|
|
172
197
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
173
198
|
italic: true,
|
|
174
199
|
}),
|
|
175
200
|
);
|
|
201
|
+
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
202
|
+
thinkingIndex += 1;
|
|
176
203
|
if (hasVisibleContentAfter) {
|
|
177
204
|
this.#contentContainer.addChild(new Spacer(1));
|
|
178
205
|
}
|
|
@@ -49,7 +49,6 @@ export class CustomEditor extends Editor {
|
|
|
49
49
|
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections. */
|
|
50
50
|
decorateText = (text: string): string => highlightMagicKeywords(text);
|
|
51
51
|
onEscape?: () => void;
|
|
52
|
-
shouldBypassAutocompleteOnEscape?: () => boolean;
|
|
53
52
|
onClear?: () => void;
|
|
54
53
|
onExit?: () => void;
|
|
55
54
|
onCycleThinkingLevel?: () => void;
|
|
@@ -186,12 +185,15 @@ export class CustomEditor extends Editor {
|
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
// Intercept configured interrupt shortcut.
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
// When the autocomplete popup is visible, ESC's first job is to dismiss
|
|
189
|
+
// the popup — let super.handleInput() route it to #cancelAutocomplete().
|
|
190
|
+
// The user can press ESC again afterward to fire the global interrupt
|
|
191
|
+
// handler. This matches the standard TUI/IDE pattern and prevents a
|
|
192
|
+
// single ESC from both closing an @ completion and aborting an active
|
|
193
|
+
// agent run (#1655).
|
|
194
|
+
if (this.#matchesAction(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
|
|
195
|
+
this.onEscape();
|
|
196
|
+
return;
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
// Intercept configured clear shortcut
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
extractPrintableText,
|
|
8
8
|
fuzzyFilter,
|
|
9
9
|
Markdown,
|
|
10
|
+
type MarkdownTheme,
|
|
10
11
|
matchesKey,
|
|
11
12
|
padding,
|
|
12
13
|
renderInlineMarkdown,
|
|
@@ -14,8 +15,8 @@ import {
|
|
|
14
15
|
Spacer,
|
|
15
16
|
Text,
|
|
16
17
|
type TUI,
|
|
17
|
-
truncateToWidth,
|
|
18
18
|
visibleWidth,
|
|
19
|
+
wrapTextWithAnsi,
|
|
19
20
|
} from "@oh-my-pi/pi-tui";
|
|
20
21
|
import { getMarkdownTheme, type ThemeColor, theme } from "../../modes/theme/theme";
|
|
21
22
|
import {
|
|
@@ -69,6 +70,34 @@ export interface HookSelectorOptions {
|
|
|
69
70
|
slider?: HookSelectorSlider;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
export interface HookSelectorOption {
|
|
74
|
+
label: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type HookSelectorOptionInput = string | HookSelectorOption;
|
|
79
|
+
|
|
80
|
+
function normalizeHookSelectorOption(option: HookSelectorOptionInput): HookSelectorOption {
|
|
81
|
+
if (typeof option === "string") return { label: option };
|
|
82
|
+
if (option.description?.trim()) {
|
|
83
|
+
return { label: option.label, description: option.description.trim() };
|
|
84
|
+
}
|
|
85
|
+
return { label: option.label };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splitLeadingSpacesForWrap(line: string, width: number): { indent: string; body: string } {
|
|
89
|
+
let indentLength = 0;
|
|
90
|
+
while (indentLength < line.length && line.charCodeAt(indentLength) === 32) {
|
|
91
|
+
indentLength += 1;
|
|
92
|
+
}
|
|
93
|
+
const maxIndentLength = Math.max(0, width - 1);
|
|
94
|
+
const clampedIndentLength = Math.min(indentLength, maxIndentLength);
|
|
95
|
+
return {
|
|
96
|
+
indent: line.slice(0, clampedIndentLength),
|
|
97
|
+
body: line.slice(indentLength),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
class OutlinedList extends Container {
|
|
73
102
|
#lines: string[] = [];
|
|
74
103
|
|
|
@@ -81,19 +110,26 @@ class OutlinedList extends Container {
|
|
|
81
110
|
const borderColor = (text: string) => theme.fg("border", text);
|
|
82
111
|
const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
|
|
83
112
|
const innerWidth = Math.max(1, width - 2);
|
|
84
|
-
const content =
|
|
113
|
+
const content: string[] = [];
|
|
114
|
+
for (const line of this.#lines) {
|
|
85
115
|
const normalized = replaceTabs(line);
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
116
|
+
const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
|
|
117
|
+
const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
|
|
118
|
+
for (const wrappedBody of wrapped.length > 0 ? wrapped : [""]) {
|
|
119
|
+
const wrappedLine = `${indent}${wrappedBody}`;
|
|
120
|
+
const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
|
|
121
|
+
content.push(
|
|
122
|
+
`${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
90
126
|
return [horizontal, ...content, horizontal];
|
|
91
127
|
}
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
export class HookSelectorComponent extends Container {
|
|
95
|
-
#options:
|
|
96
|
-
#filteredOptions:
|
|
131
|
+
#options: HookSelectorOption[];
|
|
132
|
+
#filteredOptions: HookSelectorOption[];
|
|
97
133
|
#searchQuery = "";
|
|
98
134
|
#selectedIndex: number;
|
|
99
135
|
#maxVisible: number;
|
|
@@ -110,17 +146,18 @@ export class HookSelectorComponent extends Container {
|
|
|
110
146
|
#slider: HookSelectorSlider | undefined;
|
|
111
147
|
#sliderIndex: number = 0;
|
|
112
148
|
#sliderComponent: Text | undefined;
|
|
149
|
+
#lastRenderWidth: number | undefined;
|
|
113
150
|
constructor(
|
|
114
151
|
title: string,
|
|
115
|
-
options:
|
|
152
|
+
options: HookSelectorOptionInput[],
|
|
116
153
|
onSelect: (option: string) => void,
|
|
117
154
|
onCancel: () => void,
|
|
118
155
|
opts?: HookSelectorOptions,
|
|
119
156
|
) {
|
|
120
157
|
super();
|
|
121
158
|
|
|
122
|
-
this.#options = options;
|
|
123
|
-
this.#filteredOptions = options;
|
|
159
|
+
this.#options = options.map(normalizeHookSelectorOption);
|
|
160
|
+
this.#filteredOptions = this.#options;
|
|
124
161
|
this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1);
|
|
125
162
|
this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
|
|
126
163
|
this.#onSelectCallback = onSelect;
|
|
@@ -156,7 +193,7 @@ export class HookSelectorComponent extends Container {
|
|
|
156
193
|
opts?.onTimeout?.();
|
|
157
194
|
const selected = this.#filteredOptions[this.#selectedIndex];
|
|
158
195
|
if (selected) {
|
|
159
|
-
this.#onSelectCallback(selected);
|
|
196
|
+
this.#onSelectCallback(selected.label);
|
|
160
197
|
} else {
|
|
161
198
|
this.#onCancelCallback();
|
|
162
199
|
}
|
|
@@ -180,32 +217,111 @@ export class HookSelectorComponent extends Container {
|
|
|
180
217
|
this.#updateList();
|
|
181
218
|
}
|
|
182
219
|
|
|
183
|
-
#
|
|
220
|
+
#renderOptionLines(option: HookSelectorOption, isSelected: boolean, mdTheme: MarkdownTheme): string[] {
|
|
221
|
+
const label = isSelected
|
|
222
|
+
? renderInlineMarkdown(option.label, mdTheme, t => theme.fg("accent", t))
|
|
223
|
+
: renderInlineMarkdown(option.label, mdTheme, t => theme.fg("text", t));
|
|
224
|
+
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
|
225
|
+
const lines = [prefix + label];
|
|
226
|
+
if (option.description) {
|
|
227
|
+
const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg("muted", t));
|
|
228
|
+
lines.push(` ${description}`);
|
|
229
|
+
}
|
|
230
|
+
return lines;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#renderedLineRowCount(line: string, renderWidth: number): number {
|
|
234
|
+
const normalized = replaceTabs(line);
|
|
235
|
+
if (this.#outlinedList) {
|
|
236
|
+
const innerWidth = Math.max(1, renderWidth - 2);
|
|
237
|
+
const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
|
|
238
|
+
const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
|
|
239
|
+
return Math.max(1, wrapped.length);
|
|
240
|
+
}
|
|
241
|
+
const wrapped = wrapTextWithAnsi(normalized, Math.max(1, renderWidth - 2));
|
|
242
|
+
return Math.max(1, wrapped.length);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#optionRowCount(
|
|
246
|
+
option: HookSelectorOption,
|
|
247
|
+
renderWidth: number | undefined,
|
|
248
|
+
isSelected: boolean,
|
|
249
|
+
mdTheme: MarkdownTheme,
|
|
250
|
+
): number {
|
|
251
|
+
if (renderWidth === undefined) return option.description ? 2 : 1;
|
|
252
|
+
let rows = 0;
|
|
253
|
+
for (const line of this.#renderOptionLines(option, isSelected, mdTheme)) {
|
|
254
|
+
rows += this.#renderedLineRowCount(line, renderWidth);
|
|
255
|
+
}
|
|
256
|
+
return rows;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#totalOptionRows(options: HookSelectorOption[], renderWidth?: number, mdTheme?: MarkdownTheme): number {
|
|
260
|
+
const themeForRows = mdTheme ?? getMarkdownTheme();
|
|
261
|
+
let rows = 0;
|
|
262
|
+
for (const option of options) {
|
|
263
|
+
rows += this.#optionRowCount(option, renderWidth, false, themeForRows);
|
|
264
|
+
}
|
|
265
|
+
return rows;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#getVisibleOptionRange(
|
|
269
|
+
total: number,
|
|
270
|
+
renderWidth?: number,
|
|
271
|
+
mdTheme: MarkdownTheme = getMarkdownTheme(),
|
|
272
|
+
): { startIndex: number; endIndex: number } {
|
|
273
|
+
if (total === 0) return { startIndex: 0, endIndex: 0 };
|
|
274
|
+
|
|
275
|
+
const rowBudget = Math.max(1, this.#maxVisible);
|
|
276
|
+
const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
|
|
277
|
+
let startIndex = selectedIndex;
|
|
278
|
+
let endIndex = selectedIndex + 1;
|
|
279
|
+
let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!, renderWidth, true, mdTheme);
|
|
280
|
+
let beforeRows = 0;
|
|
281
|
+
const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
|
|
282
|
+
|
|
283
|
+
while (startIndex > 0) {
|
|
284
|
+
const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
|
|
285
|
+
if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
|
|
286
|
+
startIndex--;
|
|
287
|
+
beforeRows += cost;
|
|
288
|
+
rows += cost;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
while (endIndex < total) {
|
|
292
|
+
const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!, renderWidth, false, mdTheme);
|
|
293
|
+
if (rows + cost > rowBudget) break;
|
|
294
|
+
endIndex++;
|
|
295
|
+
rows += cost;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
while (startIndex > 0) {
|
|
299
|
+
const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
|
|
300
|
+
if (rows + cost > rowBudget) break;
|
|
301
|
+
startIndex--;
|
|
302
|
+
rows += cost;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { startIndex, endIndex };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#updateList(renderWidth = this.#lastRenderWidth): void {
|
|
184
309
|
const lines: string[] = [];
|
|
185
310
|
const total = this.#filteredOptions.length;
|
|
186
|
-
const startIndex = Math.max(
|
|
187
|
-
0,
|
|
188
|
-
Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
|
|
189
|
-
);
|
|
190
|
-
const endIndex = Math.min(startIndex + this.#maxVisible, total);
|
|
191
|
-
|
|
192
311
|
const mdTheme = getMarkdownTheme();
|
|
312
|
+
const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme);
|
|
313
|
+
|
|
193
314
|
for (let i = startIndex; i < endIndex; i++) {
|
|
194
315
|
const option = this.#filteredOptions[i];
|
|
195
316
|
if (option === undefined) continue;
|
|
196
|
-
|
|
197
|
-
const label = isSelected
|
|
198
|
-
? renderInlineMarkdown(option, mdTheme, t => theme.fg("accent", t))
|
|
199
|
-
: renderInlineMarkdown(option, mdTheme, t => theme.fg("text", t));
|
|
200
|
-
const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
|
201
|
-
lines.push(prefix + label);
|
|
317
|
+
lines.push(...this.#renderOptionLines(option, i === this.#selectedIndex, mdTheme));
|
|
202
318
|
}
|
|
203
319
|
|
|
204
320
|
if (total === 0) {
|
|
205
321
|
lines.push(theme.fg("dim", " No matching options"));
|
|
206
322
|
}
|
|
207
323
|
|
|
208
|
-
if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
|
|
324
|
+
if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus(renderWidth, mdTheme)) {
|
|
209
325
|
lines.push(this.#renderStatusLine(total));
|
|
210
326
|
}
|
|
211
327
|
if (this.#outlinedList) {
|
|
@@ -253,12 +369,12 @@ export class HookSelectorComponent extends Container {
|
|
|
253
369
|
slider.onChange?.(next);
|
|
254
370
|
}
|
|
255
371
|
|
|
256
|
-
#isSearchEnabled(): boolean {
|
|
257
|
-
return this.#options
|
|
372
|
+
#isSearchEnabled(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
|
|
373
|
+
return this.#totalOptionRows(this.#options, renderWidth, mdTheme) > this.#maxVisible;
|
|
258
374
|
}
|
|
259
375
|
|
|
260
|
-
#shouldRenderSearchStatus(): boolean {
|
|
261
|
-
return this.#isSearchEnabled() || this.#searchQuery.length > 0;
|
|
376
|
+
#shouldRenderSearchStatus(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
|
|
377
|
+
return this.#isSearchEnabled(renderWidth, mdTheme) || this.#searchQuery.length > 0;
|
|
262
378
|
}
|
|
263
379
|
|
|
264
380
|
#renderStatusLine(total: number): string {
|
|
@@ -273,7 +389,9 @@ export class HookSelectorComponent extends Container {
|
|
|
273
389
|
|
|
274
390
|
#setSearchQuery(query: string): void {
|
|
275
391
|
this.#searchQuery = query;
|
|
276
|
-
this.#filteredOptions = query.trim()
|
|
392
|
+
this.#filteredOptions = query.trim()
|
|
393
|
+
? fuzzyFilter(this.#options, query, option => `${option.label} ${option.description ?? ""}`)
|
|
394
|
+
: this.#options;
|
|
277
395
|
this.#selectedIndex = 0;
|
|
278
396
|
this.#updateList();
|
|
279
397
|
}
|
|
@@ -322,7 +440,7 @@ export class HookSelectorComponent extends Container {
|
|
|
322
440
|
}
|
|
323
441
|
} else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
324
442
|
const selected = this.#filteredOptions[this.#selectedIndex];
|
|
325
|
-
if (selected) this.#onSelectCallback(selected);
|
|
443
|
+
if (selected) this.#onSelectCallback(selected.label);
|
|
326
444
|
} else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
|
|
327
445
|
if (this.#slider) this.#moveSlider(-1);
|
|
328
446
|
else this.#onLeftCallback?.();
|
|
@@ -334,6 +452,15 @@ export class HookSelectorComponent extends Container {
|
|
|
334
452
|
}
|
|
335
453
|
}
|
|
336
454
|
|
|
455
|
+
override render(width: number): string[] {
|
|
456
|
+
const renderWidth = Math.max(1, width);
|
|
457
|
+
if (this.#lastRenderWidth !== renderWidth) {
|
|
458
|
+
this.#lastRenderWidth = renderWidth;
|
|
459
|
+
this.#updateList(renderWidth);
|
|
460
|
+
}
|
|
461
|
+
return super.render(renderWidth);
|
|
462
|
+
}
|
|
463
|
+
|
|
337
464
|
dispose(): void {
|
|
338
465
|
this.#countdown?.dispose();
|
|
339
466
|
}
|
|
@@ -141,6 +141,14 @@ export interface ToolExecutionHandle {
|
|
|
141
141
|
setExpanded(expanded: boolean): void;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** Drive pending-tool redraws at ~60fps so the animated border sweep is smooth.
|
|
145
|
+
* The TUI already throttles at its 16ms `MIN_RENDER_INTERVAL_MS`, so this is the
|
|
146
|
+
* natural upper bound and static frames diff to a no-op redraw at ~zero cost. */
|
|
147
|
+
const SPINNER_RENDER_INTERVAL_MS = 16;
|
|
148
|
+
/** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
|
|
149
|
+
* 60fps render cadence (mirrors `Loader`). */
|
|
150
|
+
const SPINNER_GLYPH_ADVANCE_MS = 80;
|
|
151
|
+
|
|
144
152
|
/**
|
|
145
153
|
* Component that renders a tool call with its result (updateable)
|
|
146
154
|
*/
|
|
@@ -177,6 +185,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
177
185
|
// Spinner animation for partial task results
|
|
178
186
|
#spinnerFrame?: number;
|
|
179
187
|
#spinnerInterval?: NodeJS.Timeout;
|
|
188
|
+
#lastSpinnerAdvanceAt = 0;
|
|
180
189
|
// Todo write completion strikethrough reveal animation
|
|
181
190
|
#todoStrikeInterval?: NodeJS.Timeout;
|
|
182
191
|
// Track if args are still being streamed (for edit/write spinner)
|
|
@@ -404,13 +413,20 @@ export class ToolExecutionComponent extends Container {
|
|
|
404
413
|
this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
|
|
405
414
|
const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
|
|
406
415
|
if (needsSpinner && !this.#spinnerInterval) {
|
|
416
|
+
this.#lastSpinnerAdvanceAt = performance.now();
|
|
407
417
|
this.#spinnerInterval = setInterval(() => {
|
|
418
|
+
const now = performance.now();
|
|
408
419
|
const frameCount = theme.spinnerFrames.length;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
420
|
+
// Redraw at ~60fps for a smooth border sweep, but only step the spinner
|
|
421
|
+
// glyph at its classic ~12.5fps cadence. The TUI throttles renders at
|
|
422
|
+
// 16ms and the differ drops no-op redraws, so the extra ticks are free.
|
|
423
|
+
if (frameCount > 0 && now - this.#lastSpinnerAdvanceAt >= SPINNER_GLYPH_ADVANCE_MS) {
|
|
424
|
+
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
|
|
425
|
+
this.#renderState.spinnerFrame = this.#spinnerFrame;
|
|
426
|
+
this.#lastSpinnerAdvanceAt = now;
|
|
427
|
+
}
|
|
412
428
|
this.#ui.requestRender();
|
|
413
|
-
},
|
|
429
|
+
}, SPINNER_RENDER_INTERVAL_MS);
|
|
414
430
|
} else if (!needsSpinner && this.#spinnerInterval) {
|
|
415
431
|
clearInterval(this.#spinnerInterval);
|
|
416
432
|
this.#spinnerInterval = undefined;
|
|
@@ -277,8 +277,11 @@ export class EventController {
|
|
|
277
277
|
this.#lastThinkingCount = 0;
|
|
278
278
|
this.#assistantMessageStreaming = true;
|
|
279
279
|
this.#resetReadGroup();
|
|
280
|
-
this.ctx.streamingComponent = new AssistantMessageComponent(
|
|
281
|
-
|
|
280
|
+
this.ctx.streamingComponent = new AssistantMessageComponent(
|
|
281
|
+
undefined,
|
|
282
|
+
this.ctx.hideThinkingBlock,
|
|
283
|
+
() => this.ctx.ui.requestRender(),
|
|
284
|
+
this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
|
|
282
285
|
);
|
|
283
286
|
this.ctx.streamingMessage = event.message;
|
|
284
287
|
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|