@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +9 -0
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  7. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  8. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  9. package/dist/types/lsp/index.d.ts +2 -0
  10. package/dist/types/lsp/utils.d.ts +4 -0
  11. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  13. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  14. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -2
  17. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +2 -2
  20. package/dist/types/session/agent-session.d.ts +8 -1
  21. package/dist/types/tools/ask.d.ts +8 -6
  22. package/dist/types/tools/eval-backends.d.ts +12 -0
  23. package/dist/types/tools/eval-render.d.ts +52 -0
  24. package/dist/types/tools/eval.d.ts +2 -35
  25. package/dist/types/tools/index.d.ts +4 -11
  26. package/dist/types/tui/output-block.d.ts +4 -3
  27. package/examples/extensions/README.md +1 -0
  28. package/examples/extensions/thinking-note.ts +13 -0
  29. package/package.json +9 -9
  30. package/src/config/model-registry.ts +33 -4
  31. package/src/config/settings-schema.ts +10 -0
  32. package/src/discovery/claude.ts +41 -22
  33. package/src/edit/index.ts +23 -3
  34. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  35. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  36. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  37. package/src/eval/agent-bridge.ts +44 -38
  38. package/src/eval/heartbeat.ts +74 -0
  39. package/src/eval/js/executor.ts +13 -9
  40. package/src/eval/llm-bridge.ts +20 -14
  41. package/src/eval/py/executor.ts +14 -18
  42. package/src/exec/bash-executor.ts +31 -5
  43. package/src/extensibility/extensions/loader.ts +16 -18
  44. package/src/extensibility/extensions/runner.ts +22 -17
  45. package/src/extensibility/extensions/types.ts +39 -5
  46. package/src/internal-urls/docs-index.generated.ts +3 -3
  47. package/src/lsp/diagnostics-ledger.ts +51 -0
  48. package/src/lsp/index.ts +9 -22
  49. package/src/lsp/utils.ts +21 -0
  50. package/src/modes/acp/acp-agent.ts +8 -4
  51. package/src/modes/components/assistant-message.ts +28 -1
  52. package/src/modes/components/custom-editor.ts +9 -7
  53. package/src/modes/components/hook-selector.ts +159 -32
  54. package/src/modes/components/tool-execution.ts +20 -4
  55. package/src/modes/controllers/event-controller.ts +5 -2
  56. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  57. package/src/modes/controllers/input-controller.ts +0 -15
  58. package/src/modes/interactive-mode.ts +2 -1
  59. package/src/modes/rpc/rpc-mode.ts +17 -6
  60. package/src/modes/theme/theme-schema.json +30 -0
  61. package/src/modes/theme/theme.ts +39 -2
  62. package/src/modes/types.ts +2 -1
  63. package/src/modes/utils/ui-helpers.ts +5 -2
  64. package/src/prompts/tools/ask.md +2 -1
  65. package/src/prompts/tools/eval.md +1 -1
  66. package/src/session/agent-session.ts +65 -11
  67. package/src/tools/ask.ts +74 -32
  68. package/src/tools/eval-backends.ts +38 -0
  69. package/src/tools/eval-render.ts +750 -0
  70. package/src/tools/eval.ts +27 -754
  71. package/src/tools/index.ts +7 -37
  72. package/src/tools/renderers.ts +1 -1
  73. package/src/tools/write.ts +9 -1
  74. 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
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers, {
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 type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
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(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
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
- // Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
190
- if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
191
- if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
192
- this.onEscape();
193
- return;
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 = this.#lines.map(line => {
113
+ const content: string[] = [];
114
+ for (const line of this.#lines) {
85
115
  const normalized = replaceTabs(line);
86
- const fitted = truncateToWidth(normalized, innerWidth);
87
- const pad = Math.max(0, innerWidth - visibleWidth(fitted));
88
- return `${borderColor(theme.boxSharp.vertical)}${fitted}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
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: string[];
96
- #filteredOptions: string[];
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: string[],
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
- #updateList(): void {
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
- const isSelected = i === this.#selectedIndex;
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.length > this.#maxVisible;
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() ? fuzzyFilter(this.#options, query, option => option) : this.#options;
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
- if (frameCount === 0) return;
410
- this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
411
- this.#renderState.spinnerFrame = this.#spinnerFrame;
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
- }, 80);
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(undefined, this.ctx.hideThinkingBlock, () =>
281
- this.ctx.ui.requestRender(),
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);