@pencil-agent/nano-pencil 1.13.6 → 1.13.7

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.13.6",
3
- "commitHash": "d5ae966",
2
+ "version": "1.13.7",
3
+ "commitHash": "9b46faa",
4
4
  "branch": "main",
5
- "builtAt": "2026-04-23T04:35:24.822Z"
5
+ "builtAt": "2026-04-25T14:10:25.443Z"
6
6
  }
@@ -207,8 +207,9 @@ export class ModelRegistry {
207
207
  // Keep built-in models even if custom models failed to load
208
208
  }
209
209
  const builtInModels = this.useOnlyCustomModels
210
- ? this.loadBuiltInModels(overrides, modelOverrides, new Set(["openrouter"]), {
210
+ ? this.loadBuiltInModels(overrides, modelOverrides, new Set(["openrouter", "zai"]), {
211
211
  openrouter: new Set(NANOPENCIL_OPENROUTER_BUILTIN_MODEL_IDS),
212
+ // zai not specified = load all zai models
212
213
  })
213
214
  : this.loadBuiltInModels(overrides, modelOverrides);
214
215
  let combined = this.mergeCustomModels(builtInModels, customModels);
@@ -1183,8 +1183,22 @@ export class AgentSession {
1183
1183
  this.agent.setModel(model);
1184
1184
  this.sessionManager.appendModelChange(model.provider, model.id);
1185
1185
  this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
1186
- // Re-clamp thinking level for new model's capabilities
1187
- this.setThinkingLevel(this.thinkingLevel);
1186
+ // Auto-select thinking level based on model capabilities
1187
+ const currentLevel = this.thinkingLevel;
1188
+ let newLevel;
1189
+ if (!model.reasoning) {
1190
+ // Model doesn't support thinking, force off
1191
+ newLevel = "off";
1192
+ }
1193
+ else if (currentLevel === "off") {
1194
+ // Model supports thinking but current level is off, default to medium
1195
+ newLevel = "medium";
1196
+ }
1197
+ else {
1198
+ // Keep current level but clamp to new model's capabilities
1199
+ newLevel = currentLevel;
1200
+ }
1201
+ this.setThinkingLevel(newLevel);
1188
1202
  await this._emitModelSelect(model, previousModel, "set");
1189
1203
  }
1190
1204
  /**
@@ -1290,8 +1304,19 @@ export class AgentSession {
1290
1304
  this.agent.setModel(nextModel);
1291
1305
  this.sessionManager.appendModelChange(nextModel.provider, nextModel.id);
1292
1306
  this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);
1293
- // Re-clamp thinking level for new model's capabilities
1294
- this.setThinkingLevel(this.thinkingLevel);
1307
+ // Auto-select thinking level based on model capabilities
1308
+ const currentLevel = this.thinkingLevel;
1309
+ let newLevel;
1310
+ if (!nextModel.reasoning) {
1311
+ newLevel = "off";
1312
+ }
1313
+ else if (currentLevel === "off") {
1314
+ newLevel = "medium";
1315
+ }
1316
+ else {
1317
+ newLevel = currentLevel;
1318
+ }
1319
+ this.setThinkingLevel(newLevel);
1295
1320
  await this._emitModelSelect(nextModel, currentModel, "cycle");
1296
1321
  return {
1297
1322
  model: nextModel,
@@ -44,7 +44,7 @@ sal/weights.ts: SalWeights interface, SAL_DEFAULT_WEIGHTS, loadSalWeights() read
44
44
  sal/eval/index.ts: createEvalSink() factory + barrel re-exports; adapter selection via options.adapter or endpoint scheme inference (http(s)→insforge, file://|/|./|../→jsonl, missing→noop); ONLY entry point SAL imports from
45
45
  sal/eval/types.ts: EvalSink interface, EvalEventEnvelope/EvalEventType (run_start/run_end/turn_anchor), EvalAdapterId ("insforge"|"jsonl"|"noop"), CreateEvalSinkOptions, createEvalEvent factory; zero-dependency type surface
46
46
  sal/eval/noop-sink.ts: noopSink — silent EvalSink used when eval disabled or no adapter configured
47
- sal/eval/insforge-sink.ts: InsForgeEvalSink — PostgREST adapter, routes run_start→eval_runs INSERT (merge-duplicates), turn_anchor→eval_turns + eval_sal_anchors×2, run_end→eval_runs PATCH; allowSelfSigned TLS option, batching with default 2000ms interval
47
+ sal/eval/insforge-sink.ts: InsForgeEvalSink — PostgREST adapter, routes run_start→eval_runs INSERT (merge-duplicates), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces with PGRST204 legacy-schema fallback, memory_recalls→eval_memory_recalls batch INSERT, run_end→eval_runs PATCH; allowSelfSigned TLS option, batching with default 2000ms interval
48
48
  sal/eval/jsonl-sink.ts: JsonlEvalSink — append-only filesystem adapter, one JSON object per line, accepts file:// URLs or plain paths, auto-creates parent dir, batched writes
49
49
  sal/README.md: SAL extension usage, sidecar output layout, weights override, pluggability contract
50
50
  team/index.ts: AgentTeam extension entry, /team:/team:spawn/:send/:status/:stop/:terminate/:approve/:mode commands, TEAM_MESSAGE_TYPE renderer
@@ -47,7 +47,7 @@ sal/terrain.ts: TerrainSnapshot/TerrainNode/TerrainEdge model, async buildTerrai
47
47
  sal/anchors.ts: StructuralAnchor/AnchorResolution model, locateTask(), locateAction(), evidence-driven scoring with tunable SalWeights, CJK bigram tokenization
48
48
  sal/weights.ts: SalWeights interface, SAL_DEFAULT_WEIGHTS, loadSalWeights() reads sal-config.json from workspace or .memory-experiments/sal/
49
49
  sal/eval/index.ts: createEvalSink() factory + barrel re-exports; adapter selection via options.adapter or endpoint scheme inference (http(s)→insforge, file://|/|./|../→jsonl, missing→noop); ONLY entry point SAL imports from
50
- sal/eval/types.ts: EvalSink interface, EvalEventEnvelope/EvalEventType (run_start/run_end/turn_anchor/memory_recalls), EvalAdapterId ("insforge"|"jsonl"|"noop"), CreateEvalSinkOptions, createEvalEvent factory; zero-dependency type surface
50
+ sal/eval/types.ts: EvalSink interface, EvalEventEnvelope/EvalEventType (run_start/run_end/turn_anchor/memory_recalls/tool_trace), EvalAdapterId ("insforge"|"jsonl"|"noop"), CreateEvalSinkOptions, createEvalEvent factory; zero-dependency type surface
51
51
  sal/eval/noop-sink.ts: noopSink — silent EvalSink used when eval disabled or no adapter configured
52
52
  sal/eval/insforge-sink.ts: InsForgeEvalSink — PostgREST adapter, routes run_start→eval_runs INSERT (merge-duplicates), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces bounded per-turn summaries (including no-tool turns and truncation counters), memory_recalls→eval_memory_recalls batch INSERT, run_end→eval_runs PATCH; allowSelfSigned TLS option, batching with default 2000ms interval
53
53
  sal/eval/jsonl-sink.ts: JsonlEvalSink — append-only filesystem adapter, one JSON object per line, accepts file:// URLs or plain paths, auto-creates parent dir, batched writes
@@ -2,7 +2,7 @@
2
2
  * [WHO]: Provides InsForgeEvalSink (PostgREST-backed adapter)
3
3
  * [FROM]: Depends on node:https, node:http, node:url; ./types.js for EvalSink/EvalEventEnvelope/CreateEvalSinkOptions
4
4
  * [TO]: Constructed by eval/index.ts factory when adapter resolves to "insforge"
5
- * [HERE]: extensions/defaults/sal/eval/insforge-sink.ts - InsForge-specific routing: run_start→eval_runs INSERT (merge-duplicates, includes pencil_version), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces, memory_recalls→eval_memory_recalls, run_end→eval_runs PATCH
5
+ * [HERE]: extensions/defaults/sal/eval/insforge-sink.ts - InsForge-specific routing: run_start→eval_runs INSERT (merge-duplicates, includes pencil_version), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces with legacy-schema fallback, memory_recalls→eval_memory_recalls, run_end→eval_runs PATCH
6
6
  *
7
7
  * Pluggable: nothing in this file may be imported from outside the eval/ directory.
8
8
  * To add a new backend, write a sibling file with the same EvalSink interface.
@@ -2,7 +2,7 @@
2
2
  * [WHO]: Provides InsForgeEvalSink (PostgREST-backed adapter)
3
3
  * [FROM]: Depends on node:https, node:http, node:url; ./types.js for EvalSink/EvalEventEnvelope/CreateEvalSinkOptions
4
4
  * [TO]: Constructed by eval/index.ts factory when adapter resolves to "insforge"
5
- * [HERE]: extensions/defaults/sal/eval/insforge-sink.ts - InsForge-specific routing: run_start→eval_runs INSERT (merge-duplicates, includes pencil_version), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces, memory_recalls→eval_memory_recalls, run_end→eval_runs PATCH
5
+ * [HERE]: extensions/defaults/sal/eval/insforge-sink.ts - InsForge-specific routing: run_start→eval_runs INSERT (merge-duplicates, includes pencil_version), turn_anchor→eval_turns + eval_sal_anchors×2, tool_trace→eval_tool_traces with legacy-schema fallback, memory_recalls→eval_memory_recalls, run_end→eval_runs PATCH
6
6
  *
7
7
  * Pluggable: nothing in this file may be imported from outside the eval/ directory.
8
8
  * To add a new backend, write a sibling file with the same EvalSink interface.
@@ -222,25 +222,33 @@ export class InsForgeEvalSink {
222
222
  async handleToolTrace(ev) {
223
223
  const p = ev.payload;
224
224
  const taskSignals = p.task_signals;
225
- await this.postJson(`${this.base}/api/database/records/eval_tool_traces`, [{
226
- run_id: ev.run_id,
227
- turn_id: String(p.turn_id ?? 0),
228
- event_id: ev.event_id,
229
- tool_calls: p.tool_calls ? JSON.stringify(p.tool_calls) : null,
230
- tool_sequence: p.tool_sequence ? JSON.stringify(p.tool_sequence) : null,
231
- intent: strOrNull(taskSignals?.intent),
232
- prompt_length: String(taskSignals?.prompt_length ?? 0),
233
- has_error_trace: String(taskSignals?.has_error_trace === true),
234
- has_file_reference: String(taskSignals?.has_file_reference === true),
235
- has_tool_usage: String(p.has_tool_usage === true),
236
- total_tool_calls: String(p.total_tool_calls ?? 0),
237
- total_errors: String(p.total_errors ?? 0),
238
- completed_tool_calls: String(p.completed_tool_calls ?? 0),
239
- truncated_tool_calls: String(p.truncated_tool_calls ?? 0),
240
- truncated_tool_summary: String(p.truncated_tool_summary ?? 0),
241
- duration_ms: String(p.duration_ms ?? 0),
242
- recorded_at: ev.ts,
243
- }], { prefer: "resolution=ignore-duplicates" });
225
+ const row = {
226
+ run_id: ev.run_id,
227
+ turn_id: String(p.turn_id ?? 0),
228
+ event_id: ev.event_id,
229
+ tool_calls: p.tool_calls ? JSON.stringify(p.tool_calls) : null,
230
+ tool_sequence: p.tool_sequence ? JSON.stringify(p.tool_sequence) : null,
231
+ intent: strOrNull(taskSignals?.intent),
232
+ prompt_length: String(taskSignals?.prompt_length ?? 0),
233
+ has_error_trace: String(taskSignals?.has_error_trace === true),
234
+ has_file_reference: String(taskSignals?.has_file_reference === true),
235
+ has_tool_usage: String(p.has_tool_usage === true),
236
+ total_tool_calls: String(p.total_tool_calls ?? 0),
237
+ total_errors: String(p.total_errors ?? 0),
238
+ completed_tool_calls: String(p.completed_tool_calls ?? 0),
239
+ truncated_tool_calls: String(p.truncated_tool_calls ?? 0),
240
+ truncated_tool_summary: String(p.truncated_tool_summary ?? 0),
241
+ duration_ms: String(p.duration_ms ?? 0),
242
+ recorded_at: ev.ts,
243
+ };
244
+ const url = `${this.base}/api/database/records/eval_tool_traces`;
245
+ const result = await this.postJson(url, [row], {
246
+ prefer: "resolution=ignore-duplicates",
247
+ quietErrorCodes: ["PGRST204"],
248
+ });
249
+ if (!result.ok && result.errorCode === "PGRST204") {
250
+ await this.postJson(url, [toLegacyToolTraceRow(row)], { prefer: "resolution=ignore-duplicates" });
251
+ }
244
252
  }
245
253
  // ------------------------------------------------------------------
246
254
  // HTTP helpers
@@ -249,12 +257,12 @@ export class InsForgeEvalSink {
249
257
  const extraHeaders = {};
250
258
  if (extra?.prefer)
251
259
  extraHeaders["Prefer"] = extra.prefer;
252
- return this.httpJson("POST", url, body, extraHeaders);
260
+ return this.httpJson("POST", url, body, extraHeaders, extra?.quietErrorCodes);
253
261
  }
254
262
  patchJson(url, body) {
255
263
  return this.httpJson("PATCH", url, body, {});
256
264
  }
257
- httpJson(method, url, body, extraHeaders) {
265
+ httpJson(method, url, body, extraHeaders, quietErrorCodes = []) {
258
266
  return new Promise((resolve) => {
259
267
  const payload = JSON.stringify(body);
260
268
  let parsed;
@@ -263,7 +271,7 @@ export class InsForgeEvalSink {
263
271
  }
264
272
  catch {
265
273
  console.error(`[sal][eval] invalid URL: ${url}`);
266
- resolve(false);
274
+ resolve({ ok: false });
267
275
  return;
268
276
  }
269
277
  const isHttps = parsed.protocol === "https:";
@@ -287,20 +295,21 @@ export class InsForgeEvalSink {
287
295
  res.on("data", (chunk) => { rawBody += chunk; });
288
296
  res.on("end", () => {
289
297
  const ok = res.statusCode !== undefined && res.statusCode < 300;
290
- if (!ok) {
298
+ const errorCode = parsePostgrestErrorCode(rawBody);
299
+ if (!ok && !quietErrorCodes.includes(errorCode ?? "")) {
291
300
  console.error(`[sal][eval] HTTP ${res.statusCode} ${method} ${parsed.pathname} — ${rawBody.slice(0, 300)}`);
292
301
  }
293
- resolve(ok);
302
+ resolve({ ok, statusCode: res.statusCode, body: rawBody, errorCode });
294
303
  });
295
304
  });
296
305
  req.on("error", (err) => {
297
306
  console.error(`[sal][eval] network error → ${parsed.hostname}: ${err.message}`);
298
- resolve(false);
307
+ resolve({ ok: false });
299
308
  });
300
309
  req.on("timeout", () => {
301
310
  console.error(`[sal][eval] timeout ${method} ${parsed.pathname}`);
302
311
  req.destroy();
303
- resolve(false);
312
+ resolve({ ok: false });
304
313
  });
305
314
  req.write(payload);
306
315
  req.end();
@@ -321,3 +330,16 @@ function numOrNull(v) {
321
330
  const n = Number(v);
322
331
  return isNaN(n) ? null : n;
323
332
  }
333
+ function parsePostgrestErrorCode(rawBody) {
334
+ try {
335
+ const parsed = JSON.parse(rawBody);
336
+ return typeof parsed?.code === "string" ? parsed.code : undefined;
337
+ }
338
+ catch {
339
+ return undefined;
340
+ }
341
+ }
342
+ function toLegacyToolTraceRow(row) {
343
+ const { has_tool_usage: _hasToolUsage, completed_tool_calls: _completedToolCalls, truncated_tool_calls: _truncatedToolCalls, truncated_tool_summary: _truncatedToolSummary, ...legacyRow } = row;
344
+ return legacyRow;
345
+ }
@@ -1,12 +1,13 @@
1
1
  /**
2
- * [WHO]: FooterComponent
2
+ * [WHO]: FooterComponent, renderContextProgressBar()
3
3
  * [FROM]: Depends on @pencil-agent/tui, ../theme/theme.js
4
- * [TO]: Consumed by modes/interactive/components/index.ts
5
- * [HERE]: modes/interactive/components/footer.ts - status bar footer
4
+ * [TO]: Consumed by modes/interactive/components/index.ts, modes/interactive/interactive-mode.ts
5
+ * [HERE]: modes/interactive/components/footer.ts - status bar footer and shared context progress rendering
6
6
  */
7
7
  import { type Component } from "@pencil-agent/tui";
8
8
  import type { AgentSession } from "../../../core/runtime/agent-session.js";
9
9
  import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js";
10
+ export declare function renderContextProgressBar(contextPercent: number, barWidth?: number): string;
10
11
  /**
11
12
  * Footer component that shows pwd, token stats, and context usage.
12
13
  * Computes token/context stats from session, gets git branch and extension statuses from provider.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * [WHO]: FooterComponent
2
+ * [WHO]: FooterComponent, renderContextProgressBar()
3
3
  * [FROM]: Depends on @pencil-agent/tui, ../theme/theme.js
4
- * [TO]: Consumed by modes/interactive/components/index.ts
5
- * [HERE]: modes/interactive/components/footer.ts - status bar footer
4
+ * [TO]: Consumed by modes/interactive/components/index.ts, modes/interactive/interactive-mode.ts
5
+ * [HERE]: modes/interactive/components/footer.ts - status bar footer and shared context progress rendering
6
6
  */
7
7
  import { truncateToWidth, visibleWidth } from "@pencil-agent/tui";
8
8
  import { theme } from "../theme/theme.js";
@@ -31,6 +31,18 @@ function formatTokens(count) {
31
31
  return `${(count / 1000000).toFixed(1)}M`;
32
32
  return `${Math.round(count / 1000000)}M`;
33
33
  }
34
+ export function renderContextProgressBar(contextPercent, barWidth = 12) {
35
+ const safeBarWidth = Math.max(0, Math.floor(barWidth));
36
+ const finitePercent = Number.isFinite(contextPercent) ? contextPercent : 0;
37
+ const clampedPercent = Math.min(100, Math.max(0, finitePercent));
38
+ const filled = Math.min(safeBarWidth, Math.max(0, Math.round((clampedPercent / 100) * safeBarWidth)));
39
+ const empty = Math.max(0, safeBarWidth - filled);
40
+ const fillColor = finitePercent > 90 ? "error" : finitePercent > 70 ? "warning" : "success";
41
+ return theme.fg("dim", "[") +
42
+ theme.fg(fillColor, "█".repeat(filled)) +
43
+ theme.fg("dim", "░".repeat(empty)) +
44
+ theme.fg("dim", "]");
45
+ }
34
46
  /**
35
47
  * Footer component that shows pwd, token stats, and context usage.
36
48
  * Computes token/context stats from session, gets git branch and extension statuses from provider.
@@ -140,11 +152,7 @@ export class FooterComponent {
140
152
  // Build progress bar for context usage (only on wide terminals)
141
153
  let contextBar = "";
142
154
  if (width > 80 && contextPercentValue > 0 && contextPercent !== "?") {
143
- const barWidth = 12;
144
- const filled = Math.round((contextPercentValue / 100) * barWidth);
145
- const empty = barWidth - filled;
146
- const fillColor = contextPercentValue > 90 ? "error" : contextPercentValue > 70 ? "warning" : "success";
147
- contextBar = theme.fg("dim", "[") + theme.fg(fillColor, "█".repeat(filled)) + theme.fg("dim", "░".repeat(empty)) + theme.fg("dim", "] ");
155
+ contextBar = `${renderContextProgressBar(contextPercentValue)} `;
148
156
  }
149
157
  const contextPercentDisplay = contextPercent === "?" || contextTokens === null
150
158
  ? `${contextBar}?/${formatTokens(contextWindow)}${autoIndicator}`
@@ -1,12 +1,25 @@
1
1
  /**
2
2
  * [WHO]: ProviderSelectorComponent
3
3
  * [FROM]: Depends on @pencil-agent/tui, ../theme/theme.js, ./dynamic-border.js
4
- * [TO]: Consumed by modes/interactive/components/index.ts
4
+ * [TO]: Consumed by modes/interactive/interactive-mode.ts
5
5
  * [HERE]: modes/interactive/components/provider-selector.ts -
6
+ * Adds search to provider selection when /model has many providers
6
7
  */
7
- import { Container, SelectList } from "@pencil-agent/tui";
8
- export declare class ProviderSelectorComponent extends Container {
9
- private selectList;
8
+ import { type Focusable, Container, Input } from "@pencil-agent/tui";
9
+ export declare class ProviderSelectorComponent extends Container implements Focusable {
10
+ private searchInput;
11
+ private listContainer;
12
+ private allProviders;
13
+ private filteredProviders;
14
+ private selectedIndex;
15
+ private onSelectCallback;
16
+ private onCancelCallback;
17
+ private _focused;
18
+ get focused(): boolean;
19
+ set focused(value: boolean);
10
20
  constructor(providers: string[], currentProvider: string | undefined, onSelect: (provider: string) => void, onCancel: () => void);
11
- getSelectList(): SelectList;
21
+ private filterProviders;
22
+ private updateList;
23
+ handleInput(keyData: string): void;
24
+ getSearchInput(): Input;
12
25
  }
@@ -1,43 +1,150 @@
1
1
  /**
2
2
  * [WHO]: ProviderSelectorComponent
3
3
  * [FROM]: Depends on @pencil-agent/tui, ../theme/theme.js, ./dynamic-border.js
4
- * [TO]: Consumed by modes/interactive/components/index.ts
4
+ * [TO]: Consumed by modes/interactive/interactive-mode.ts
5
5
  * [HERE]: modes/interactive/components/provider-selector.ts -
6
+ * Adds search to provider selection when /model has many providers
6
7
  */
7
- import { Container, SelectList } from "@pencil-agent/tui";
8
+ import { Container, fuzzyFilter, getEditorKeybindings, Input, Spacer, Text, } from "@pencil-agent/tui";
8
9
  import { getCustomProtocolProviderDefinition, isCustomProtocolProvider, } from "../../../core/custom-providers.js";
9
- import { getSelectListTheme } from "../theme/theme.js";
10
+ import { theme } from "../theme/theme.js";
10
11
  import { DynamicBorder } from "./dynamic-border.js";
11
12
  export class ProviderSelectorComponent extends Container {
12
- selectList;
13
+ searchInput;
14
+ listContainer;
15
+ allProviders;
16
+ filteredProviders;
17
+ selectedIndex = 0;
18
+ onSelectCallback;
19
+ onCancelCallback;
20
+ _focused = false;
21
+ get focused() {
22
+ return this._focused;
23
+ }
24
+ set focused(value) {
25
+ this._focused = value;
26
+ this.searchInput.focused = value;
27
+ }
13
28
  constructor(providers, currentProvider, onSelect, onCancel) {
14
29
  super();
15
- this.addChild(new DynamicBorder());
16
- const items = providers.map((provider) => {
30
+ this.onSelectCallback = onSelect;
31
+ this.onCancelCallback = onCancel;
32
+ // Build provider items with custom protocol labels
33
+ this.allProviders = providers.map((provider) => {
17
34
  const customProvider = isCustomProtocolProvider(provider)
18
35
  ? getCustomProtocolProviderDefinition(provider)
19
36
  : undefined;
20
37
  return {
21
38
  value: provider,
22
39
  label: customProvider?.label ?? provider,
23
- description: provider === currentProvider
24
- ? customProvider
25
- ? "(current, press Enter to edit)"
26
- : "(current)"
27
- : customProvider?.description,
40
+ description: customProvider?.description,
41
+ isCurrent: provider === currentProvider,
28
42
  };
29
43
  });
30
- this.selectList = new SelectList(items, Math.min(Math.max(items.length, 4), 12), getSelectListTheme());
31
- this.selectList.onSelect = (item) => onSelect(item.value);
32
- this.selectList.onCancel = onCancel;
33
- const currentIndex = providers.indexOf(currentProvider ?? "");
34
- if (currentIndex >= 0) {
35
- this.selectList.setSelectedIndex(currentIndex);
36
- }
37
- this.addChild(this.selectList);
44
+ this.filteredProviders = [...this.allProviders];
45
+ // Set initial selection to current provider
46
+ if (currentProvider) {
47
+ const currentIdx = this.allProviders.findIndex((p) => p.value === currentProvider);
48
+ if (currentIdx >= 0) {
49
+ this.selectedIndex = currentIdx;
50
+ }
51
+ }
52
+ this.addChild(new DynamicBorder());
53
+ this.addChild(new Spacer(1));
54
+ this.addChild(new Text(theme.fg("muted", "Search provider:"), 0, 0));
55
+ this.addChild(new Spacer(1));
56
+ this.searchInput = new Input();
57
+ this.searchInput.onSubmit = () => {
58
+ if (this.filteredProviders[this.selectedIndex]) {
59
+ this.onSelectCallback(this.filteredProviders[this.selectedIndex].value);
60
+ }
61
+ };
62
+ this.addChild(this.searchInput);
63
+ this.addChild(new Spacer(1));
64
+ this.listContainer = new Container();
65
+ this.addChild(this.listContainer);
66
+ this.addChild(new Spacer(1));
38
67
  this.addChild(new DynamicBorder());
68
+ this.updateList();
69
+ }
70
+ filterProviders(query) {
71
+ const filtered = query
72
+ ? fuzzyFilter(this.allProviders, query, (p) => `${p.label} ${p.value}`)
73
+ : [...this.allProviders];
74
+ this.filteredProviders = filtered;
75
+ // Reset selectedIndex to first item when filter changes
76
+ this.selectedIndex = 0;
77
+ this.updateList();
78
+ }
79
+ updateList() {
80
+ // Remove all old children
81
+ while (this.listContainer.children.length > 0) {
82
+ this.listContainer.children.pop();
83
+ }
84
+ const maxVisible = 10;
85
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredProviders.length - maxVisible));
86
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredProviders.length);
87
+ for (let index = startIndex; index < endIndex; index++) {
88
+ const item = this.filteredProviders[index];
89
+ if (!item)
90
+ continue;
91
+ const isSelected = index === this.selectedIndex;
92
+ const prefix = isSelected
93
+ ? theme.fg("accent", "->")
94
+ : " ";
95
+ const labelText = isSelected
96
+ ? theme.fg("accent", item.label)
97
+ : item.label;
98
+ const currentTag = item.isCurrent
99
+ ? theme.fg("success", " [current]")
100
+ : "";
101
+ const descText = item.description
102
+ ? theme.fg("muted", ` ${item.description}`)
103
+ : "";
104
+ this.listContainer.addChild(new Text(`${prefix}${labelText}${currentTag}${descText}`, 0, 0));
105
+ }
106
+ if (startIndex > 0 || endIndex < this.filteredProviders.length) {
107
+ this.listContainer.addChild(new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredProviders.length})`), 0, 0));
108
+ }
109
+ if (this.filteredProviders.length === 0) {
110
+ this.listContainer.addChild(new Text(theme.fg("muted", " No matching providers"), 0, 0));
111
+ }
112
+ }
113
+ handleInput(keyData) {
114
+ const kb = getEditorKeybindings();
115
+ if (kb.matches(keyData, "selectUp")) {
116
+ if (this.filteredProviders.length === 0)
117
+ return;
118
+ this.selectedIndex =
119
+ this.selectedIndex === 0
120
+ ? this.filteredProviders.length - 1
121
+ : this.selectedIndex - 1;
122
+ this.updateList();
123
+ }
124
+ else if (kb.matches(keyData, "selectDown")) {
125
+ if (this.filteredProviders.length === 0)
126
+ return;
127
+ this.selectedIndex =
128
+ this.selectedIndex === this.filteredProviders.length - 1
129
+ ? 0
130
+ : this.selectedIndex + 1;
131
+ this.updateList();
132
+ }
133
+ else if (kb.matches(keyData, "selectConfirm")) {
134
+ const selected = this.filteredProviders[this.selectedIndex];
135
+ if (selected) {
136
+ this.onSelectCallback(selected.value);
137
+ }
138
+ }
139
+ else if (kb.matches(keyData, "selectCancel")) {
140
+ this.onCancelCallback();
141
+ }
142
+ else {
143
+ this.searchInput.handleInput(keyData);
144
+ this.filterProviders(this.searchInput.getValue());
145
+ }
39
146
  }
40
- getSelectList() {
41
- return this.selectList;
147
+ getSearchInput() {
148
+ return this.searchInput;
42
149
  }
43
150
  }
@@ -41,7 +41,7 @@ import { DynamicBorder } from "./components/dynamic-border.js";
41
41
  import { ExtensionEditorComponent } from "./components/extension-editor.js";
42
42
  import { ExtensionInputComponent } from "./components/extension-input.js";
43
43
  import { ExtensionSelectorComponent } from "./components/extension-selector.js";
44
- import { FooterComponent } from "./components/footer.js";
44
+ import { FooterComponent, renderContextProgressBar } from "./components/footer.js";
45
45
  import { appKey, appKeyHint, editorKey, keyHint, rawKeyHint, } from "./components/keybinding-hints.js";
46
46
  import { LoginDialogComponent } from "./components/login-dialog.js";
47
47
  import { ModelSelectorComponent } from "./components/model-selector.js";
@@ -3740,7 +3740,7 @@ export class InteractiveMode {
3740
3740
  done();
3741
3741
  this.ui.requestRender();
3742
3742
  });
3743
- return { component: selector, focus: selector.getSelectList() };
3743
+ return { component: selector, focus: selector };
3744
3744
  });
3745
3745
  return;
3746
3746
  }
@@ -4004,7 +4004,7 @@ export class InteractiveMode {
4004
4004
  done();
4005
4005
  this.ui.requestRender();
4006
4006
  });
4007
- return { component: selector, focus: selector.getSelectList() };
4007
+ return { component: selector, focus: selector };
4008
4008
  });
4009
4009
  }
4010
4010
  async showModelsSelector() {
@@ -4716,12 +4716,7 @@ export class InteractiveMode {
4716
4716
  const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
4717
4717
  const contextPercent = contextUsage?.percent ?? 0;
4718
4718
  const contextTokens = contextUsage?.tokens ?? 0;
4719
- // Progress bar (12 chars wide)
4720
- const barWidth = 12;
4721
- const filled = Math.round((contextPercent / 100) * barWidth);
4722
- const empty = barWidth - filled;
4723
- const fillColor = contextPercent > 90 ? "error" : contextPercent > 70 ? "warning" : "success";
4724
- const bar = theme.fg("dim", "[") + theme.fg(fillColor, "█".repeat(filled)) + theme.fg("dim", "░".repeat(empty)) + theme.fg("dim", "]");
4719
+ const bar = renderContextProgressBar(contextPercent);
4725
4720
  const contextLine = ` Context: ${bar} ${contextPercent.toFixed(1)}% (${fmt(contextTokens)}/${fmt(contextWindow)})`;
4726
4721
  lines.push(theme.fg("border", `│`) + padLine(contextLine, width) + theme.fg("border", `│`));
4727
4722
  // Bottom border
File without changes