@danypops/pi-jittor 0.3.1 → 0.5.0

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.
@@ -9,8 +9,10 @@ import {
9
9
  TELEMETRY_STALE_AFTER_MS,
10
10
  } from "@danypops/jittor";
11
11
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { BorderedSelectPanel, type TextMeasure } from "malevich-tui-components";
13
14
  import { sessionSecretField } from "../session-identity.ts";
15
+ import { showConfirmDialog, showRouteOverrideMenu } from "../tui-prompts.ts";
14
16
  import type { ProviderBudget } from "./footer.ts";
15
17
 
16
18
  export interface JittorPanelClient {
@@ -32,7 +34,14 @@ export function providerBudgetMetricQuery(status: RouterStatus): MetricQuery | n
32
34
  }
33
35
  }
34
36
 
35
- type PanelAction = "pause" | "resume" | "refresh" | "override" | "clear-override" | "close";
37
+ export type PanelAction = "pause" | "resume" | "refresh" | "override" | "clear-override" | "close";
38
+
39
+ export interface StatusPanelTheme {
40
+ fg(color: string, text: string): string;
41
+ bold(text: string): string;
42
+ }
43
+
44
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
36
45
 
37
46
  function latest(
38
47
  rows: StoredMetricObservation[],
@@ -326,10 +335,13 @@ export function buildStatusView(status: RouterStatus, metrics: StoredMetricObser
326
335
  return lines;
327
336
  }
328
337
 
329
- async function snapshot(
330
- client: JittorPanelClient,
331
- sessionId: string,
332
- ): Promise<{ status: RouterStatus; metrics: StoredMetricObservation[] }> {
338
+ export interface StatusPanelSnapshot {
339
+ status: RouterStatus;
340
+ metrics: StoredMetricObservation[];
341
+ }
342
+
343
+ /** Shared by the standalone status panel below and the unified /jittor shell, so both fetch identically. */
344
+ export async function fetchStatusSnapshot(client: JittorPanelClient, sessionId: string): Promise<StatusPanelSnapshot> {
333
345
  const status = (await client.call("router.status", { session_id: sessionId })) as RouterStatus;
334
346
  const query = providerBudgetMetricQuery(status);
335
347
  const metrics = query ? ((await client.call("metrics.query", query)) as StoredMetricObservation[]) : [];
@@ -341,74 +353,113 @@ async function chooseOverride(ctx: ExtensionCommandContext, routes: Route[]): Pr
341
353
  ctx.ui.notify("Pi reports no authenticated routes for the current provider.", "warning");
342
354
  return undefined;
343
355
  }
344
- const labels = routes.map(routeText);
345
- const selected = await ctx.ui.select("Override route", labels);
346
- const index = selected ? labels.indexOf(selected) : -1;
347
- return index >= 0 ? routes[index] : undefined;
356
+ return showRouteOverrideMenu(ctx, routes, routeText);
357
+ }
358
+
359
+ /**
360
+ * The Status panel's real chrome (a BorderedSelectPanel, replacing the hand-rolled border lines
361
+ * this used to draw directly) plus its own r/p/o/c/Esc key handling, wired to `onAction` rather
362
+ * than a `done` callback directly -- reusable by the standalone panel below and the unified
363
+ * /jittor shell. Defaults to a full-chrome standalone panel (`framed: true`); pass `framed: false`
364
+ * when nesting this as one tab's content inside another framed container.
365
+ */
366
+ export function createStatusPanel(
367
+ current: StatusPanelSnapshot,
368
+ theme: StatusPanelTheme,
369
+ onAction: (action: PanelAction) => void,
370
+ framed = true,
371
+ ): BorderedSelectPanel {
372
+ const controls = current.status.paused
373
+ ? "r refresh · p release emergency halt · o override · c clear override · Esc close"
374
+ : "r refresh · p emergency halt · o override · c clear override · Esc close";
375
+ const content = {
376
+ invalidate: () => {},
377
+ render: (width: number): string[] =>
378
+ buildStatusView(current.status, current.metrics).map((line) => truncateToWidth(` ${line}`, width, "…")),
379
+ handleInput(data: string): void {
380
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) onAction("close");
381
+ else if (data === "r") onAction("refresh");
382
+ else if (data === "p") onAction(current.status.paused ? "resume" : "pause");
383
+ else if (data === "o") onAction("override");
384
+ else if (data === "c") onAction("clear-override");
385
+ },
386
+ };
387
+ return new BorderedSelectPanel({
388
+ title: "Jittor",
389
+ list: content,
390
+ helpText: controls,
391
+ theme: {
392
+ border: (text) => theme.fg("borderMuted", text),
393
+ title: theme.bold,
394
+ help: (text) => theme.fg("dim", text),
395
+ },
396
+ measure: hostTextMeasure,
397
+ framed,
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Performs the real side effect for one resolved status action -- confirmations and daemon calls.
403
+ * A no-op for "close" and "refresh" is handled by the caller (refresh is a plain re-poll with no
404
+ * confirmation). Shared by the standalone status panel below and the unified /jittor shell.
405
+ */
406
+ export async function runStatusAction(
407
+ ctx: ExtensionCommandContext,
408
+ client: JittorPanelClient,
409
+ action: PanelAction,
410
+ current: StatusPanelSnapshot,
411
+ sessionId: string,
412
+ ): Promise<void> {
413
+ if (action === "close") return;
414
+ if (action === "refresh") {
415
+ await client.call("telemetry.poll", {});
416
+ return;
417
+ }
418
+ if (action === "pause" || action === "resume") {
419
+ if (
420
+ await showConfirmDialog(
421
+ ctx,
422
+ action === "pause" ? "Emergency-halt provider requests?" : "Release emergency halt?",
423
+ "This changes provider-request enforcement. Use /jittor off to disable blocking entirely.",
424
+ )
425
+ ) {
426
+ await client.call(action === "pause" ? "router.pause" : "router.resume", { session_id: sessionId, ...sessionSecretField(sessionId) });
427
+ }
428
+ return;
429
+ }
430
+ if (action === "clear-override") {
431
+ if (await showConfirmDialog(ctx, "Clear route override?", "Policy-controlled routing will resume."))
432
+ await client.call("router.clear_override", { session_id: sessionId, ...sessionSecretField(sessionId) });
433
+ return;
434
+ }
435
+ const route = await chooseOverride(ctx, current.status.availableRoutes);
436
+ if (route && (await showConfirmDialog(ctx, "Apply route override?", `${routeText(route)} for one hour`))) {
437
+ await client.call("router.override", {
438
+ route,
439
+ expiresAt: Date.now() + 60 * 60 * 1_000,
440
+ session_id: sessionId,
441
+ ...sessionSecretField(sessionId),
442
+ });
443
+ }
348
444
  }
349
445
 
350
446
  export async function showJittorPanel(ctx: ExtensionCommandContext, client: JittorPanelClient): Promise<void> {
351
447
  const session_id = ctx.sessionManager.getSessionId();
352
448
  for (;;) {
353
- const current = await snapshot(client, session_id);
449
+ const current = await fetchStatusSnapshot(client, session_id);
354
450
  if (ctx.mode !== "tui") {
355
451
  ctx.ui.notify(buildStatusView(current.status, current.metrics).join("\n"), "info");
356
452
  return;
357
453
  }
358
- const action = await ctx.ui.custom<PanelAction>((_tui, theme, _keybindings, done) => ({
359
- invalidate() {},
360
- render(width: number): string[] {
361
- const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
362
- const controls = current.status.paused
363
- ? "r refresh · p release emergency halt · o override · c clear override · Esc close"
364
- : "r refresh · p emergency halt · o override · c clear override · Esc close";
365
- return [
366
- border,
367
- truncateToWidth(theme.bold("Jittor"), width, ""),
368
- border,
369
- ...buildStatusView(current.status, current.metrics).map((line) => truncateToWidth(` ${line}`, width, "…")),
370
- border,
371
- truncateToWidth(theme.fg("dim", controls), width, "…"),
372
- border,
373
- ];
374
- },
375
- handleInput(data: string): void {
376
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
377
- else if (data === "r") done("refresh");
378
- else if (data === "p") done(current.status.paused ? "resume" : "pause");
379
- else if (data === "o") done("override");
380
- else if (data === "c") done("clear-override");
381
- },
382
- }));
454
+ const action = await ctx.ui.custom<PanelAction>((_tui, theme, _keybindings, done) => {
455
+ const panel = createStatusPanel(current, theme, done);
456
+ return {
457
+ invalidate: () => panel.invalidate(),
458
+ render: (width: number) => panel.render(width),
459
+ handleInput: (data: string) => panel.handleInput(data),
460
+ };
461
+ });
383
462
  if (!action || action === "close") return;
384
- if (action === "refresh") {
385
- await client.call("telemetry.poll", {});
386
- continue;
387
- }
388
- if (action === "pause" || action === "resume") {
389
- if (
390
- await ctx.ui.confirm(
391
- action === "pause" ? "Emergency-halt provider requests?" : "Release emergency halt?",
392
- "This changes provider-request enforcement. Use /jittor off to disable blocking entirely.",
393
- )
394
- ) {
395
- await client.call(action === "pause" ? "router.pause" : "router.resume", { session_id, ...sessionSecretField(session_id) });
396
- }
397
- continue;
398
- }
399
- if (action === "clear-override") {
400
- if (await ctx.ui.confirm("Clear route override?", "Policy-controlled routing will resume."))
401
- await client.call("router.clear_override", { session_id, ...sessionSecretField(session_id) });
402
- continue;
403
- }
404
- const route = await chooseOverride(ctx, current.status.availableRoutes);
405
- if (route && (await ctx.ui.confirm("Apply route override?", `${routeText(route)} for one hour`))) {
406
- await client.call("router.override", {
407
- route,
408
- expiresAt: Date.now() + 60 * 60 * 1_000,
409
- session_id,
410
- ...sessionSecretField(session_id),
411
- });
412
- }
463
+ await runStatusAction(ctx, client, action, current, session_id);
413
464
  }
414
465
  }
@@ -27,7 +27,7 @@ interface BenchmarkTheme {
27
27
  bold(text: string): string;
28
28
  }
29
29
 
30
- type BenchmarkPanelAction = "refresh" | "close";
30
+ export type BenchmarkPanelAction = "refresh" | "close";
31
31
 
32
32
  const COMPONENT_LABELS: Record<UtilityComponentName, string> = { quality: "Q", cost: "$", latency: "L", context: "C", reliability: "R" };
33
33
 
@@ -58,8 +58,19 @@ function benchmarkRows(shown: RankedModel[], currentIdentity: string): Record<st
58
58
  });
59
59
  }
60
60
 
61
- export function renderBenchmarkView(result: ModelRankingResult, currentIdentity: string, width: number, theme: BenchmarkTheme): string[] {
62
- const safeWidth = Math.max(1, width);
61
+ /**
62
+ * The Benchmarks panel's real chrome plus its own r/Esc key handling, wired to `onAction` rather
63
+ * than a `done` callback directly -- reusable by renderBenchmarkView/showBenchmarkPanel below and
64
+ * the unified /jittor shell. Defaults to a full-chrome standalone panel (`framed: true`); pass
65
+ * `framed: false` when nesting this as one tab's content inside another framed container.
66
+ */
67
+ export function createBenchmarkPanel(
68
+ result: ModelRankingResult,
69
+ currentIdentity: string,
70
+ theme: BenchmarkTheme,
71
+ onAction: (action: BenchmarkPanelAction) => void,
72
+ framed = true,
73
+ ): BorderedSelectPanel {
63
74
  const shown = result.ranked.slice(0, BENCHMARK_TUI_MAX_CANDIDATES);
64
75
  const currentIndex = result.ranked.findIndex((item) => item.identity.startsWith(`${currentIdentity}:`));
65
76
  const recommended = result.ranked[0];
@@ -96,6 +107,10 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
96
107
  : []),
97
108
  ...(result.scopeWarning ? [truncateToWidth(result.scopeWarning, availableWidth, "…")] : []),
98
109
  ],
110
+ handleInput(data: string): void {
111
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) onAction("close");
112
+ else if (data === "r") onAction("refresh");
113
+ },
99
114
  };
100
115
  return new BorderedSelectPanel({
101
116
  title: "Jittor Benchmark Recommendations",
@@ -107,7 +122,40 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
107
122
  help: (text) => theme.fg("dim", text),
108
123
  },
109
124
  measure: hostTextMeasure,
110
- }).render(safeWidth);
125
+ framed,
126
+ });
127
+ }
128
+
129
+ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity: string, width: number, theme: BenchmarkTheme): string[] {
130
+ return createBenchmarkPanel(result, currentIdentity, theme, () => undefined).render(Math.max(1, width));
131
+ }
132
+
133
+ /** Shared by the standalone benchmark panel below and the unified /jittor shell, so both fetch identically. */
134
+ export async function fetchBenchmarkRanking(
135
+ ctx: ExtensionCommandContext,
136
+ client: BenchmarkPanelClient,
137
+ candidates: ModelCandidate[],
138
+ domain: ModelTaskDomain,
139
+ type: ModelTaskType,
140
+ ): Promise<ModelRankingResult> {
141
+ const session_id = ctx.sessionManager.getSessionId();
142
+ return (await client.call("models.rank", {
143
+ candidates,
144
+ session_id,
145
+ ...sessionSecretField(session_id),
146
+ scopeAuthority: "available-models",
147
+ domain,
148
+ type,
149
+ budgetPressure: 0,
150
+ weights: {
151
+ quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
152
+ cost: MODEL_RANKING_DEFAULT_COST_WEIGHT,
153
+ latency: MODEL_RANKING_DEFAULT_LATENCY_WEIGHT,
154
+ context: MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
155
+ reliability: MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
156
+ },
157
+ sourceIds: ["openrouter-models", "lmarena-hf", "artificial-analysis-direct", "openrouter-design-arena"],
158
+ })) as ModelRankingResult;
111
159
  }
112
160
 
113
161
  export async function showBenchmarkPanel(
@@ -119,24 +167,7 @@ export async function showBenchmarkPanel(
119
167
  type: ModelTaskType,
120
168
  ): Promise<void> {
121
169
  for (;;) {
122
- const session_id = ctx.sessionManager.getSessionId();
123
- const result = (await client.call("models.rank", {
124
- candidates,
125
- session_id,
126
- ...sessionSecretField(session_id),
127
- scopeAuthority: "available-models",
128
- domain,
129
- type,
130
- budgetPressure: 0,
131
- weights: {
132
- quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
133
- cost: MODEL_RANKING_DEFAULT_COST_WEIGHT,
134
- latency: MODEL_RANKING_DEFAULT_LATENCY_WEIGHT,
135
- context: MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
136
- reliability: MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
137
- },
138
- sourceIds: ["openrouter-models", "lmarena-hf", "artificial-analysis-direct", "openrouter-design-arena"],
139
- })) as ModelRankingResult;
170
+ const result = await fetchBenchmarkRanking(ctx, client, candidates, domain, type);
140
171
  if (ctx.mode !== "tui") {
141
172
  ctx.ui.notify(
142
173
  renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"),
@@ -144,16 +175,14 @@ export async function showBenchmarkPanel(
144
175
  );
145
176
  return;
146
177
  }
147
- const action = await ctx.ui.custom<BenchmarkPanelAction>((_tui, theme, _keybindings, done) => ({
148
- invalidate() {},
149
- render(width: number): string[] {
150
- return renderBenchmarkView(result, currentIdentity, width, theme);
151
- },
152
- handleInput(data: string): void {
153
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
154
- else if (data === "r") done("refresh");
155
- },
156
- }));
178
+ const action = await ctx.ui.custom<BenchmarkPanelAction>((_tui, theme, _keybindings, done) => {
179
+ const panel = createBenchmarkPanel(result, currentIdentity, theme, done);
180
+ return {
181
+ invalidate: () => panel.invalidate(),
182
+ render: (width: number) => panel.render(width),
183
+ handleInput: (data: string) => panel.handleInput(data),
184
+ };
185
+ });
157
186
  if (!action || action === "close") return;
158
187
  await client.call("benchmark.refresh", { force: true });
159
188
  }
@@ -50,6 +50,7 @@ const OPERATION_RETRY_MODE = {
50
50
  "router.clear_override": "once",
51
51
  "router.current_route": "once",
52
52
  "router.available_routes": "once",
53
+ "cache.economics": "retry",
53
54
  } as const satisfies Record<OperationName, "retry" | "once">;
54
55
 
55
56
  export function operationRetryMode(operation: OperationName): "retry" | "once" {
@@ -3,6 +3,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { BorderedSelectPanel, Menu, type MenuTheme, type TextMeasure } from "malevich-tui-components";
5
5
  import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
6
+ import { showConfirmDialog } from "./tui-prompts.ts";
6
7
 
7
8
  export interface SettingsSnapshot {
8
9
  enforcementEnabled: boolean;
@@ -16,8 +17,8 @@ interface SettingsTheme {
16
17
  bold(text: string): string;
17
18
  }
18
19
 
19
- type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
20
- type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
20
+ export type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
21
+ export type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
21
22
 
22
23
  export interface SettingsEffects {
23
24
  setEnforcement(enabled: boolean): void | Promise<void>;
@@ -25,7 +26,21 @@ export interface SettingsEffects {
25
26
  setRecovery(enabled: boolean): void | Promise<void>;
26
27
  }
27
28
 
28
- const SETTINGS_KEYS: SettingsKey[] = ["enforcement", "footer", "recovery", ...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const)];
29
+ // Enforcement -> Budget -> Providers -> UI: safety posture (the global kill-switch everything
30
+ // else is downstream of) leads, followed by spend limits, then provider-specific quirks (today,
31
+ // only Codex recovery -- grouped under its own header instead of sitting flat next to global
32
+ // switches, which read as "too Codex-oriented" with nothing to signal its narrower scope), then
33
+ // display preferences last.
34
+ const SETTINGS_KEYS: SettingsKey[] = ["enforcement", ...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const), "recovery", "footer"];
35
+
36
+ type SettingsCategory = "Enforcement" | "Budget" | "Providers" | "UI";
37
+
38
+ function categoryOf(key: SettingsKey): SettingsCategory {
39
+ if (key === "enforcement") return "Enforcement";
40
+ if (key === "recovery") return "Providers";
41
+ if (key === "footer") return "UI";
42
+ return "Budget";
43
+ }
29
44
 
30
45
  function state(enabled: boolean, theme: SettingsTheme): string {
31
46
  return enabled ? theme.fg("success", "ON") : theme.fg("muted", "OFF");
@@ -69,11 +84,54 @@ function menuTheme(theme: SettingsTheme): MenuTheme {
69
84
  };
70
85
  }
71
86
 
72
- function createSettingsPanel(
87
+ /**
88
+ * Wraps `menu` (already built over `SETTINGS_KEYS` in order, with no `title` of its own) so its
89
+ * rendered output gets a non-selectable category header line inserted before each contiguous run
90
+ * of same-category rows. `Menu` has no native header/divider item type, and keeps its selected
91
+ * index private with no getter, so headers are inserted into the *rendered lines*, never built as
92
+ * extra selectable items -- `menu`'s own input handling, selection state, and every existing
93
+ * keybinding stay completely untouched; this only changes what gets displayed.
94
+ *
95
+ * With no `title` set, `Menu.render` always returns exactly `[rule, ...itemLines, rule]` (see
96
+ * malevich-tui-components' Menu/renderFramedPanel). If that ever stops holding -- a future
97
+ * malevich release changing Menu's own frame shape -- this falls back to Menu's unmodified
98
+ * output rather than slicing the wrong lines into a garbled view.
99
+ */
100
+ function groupedSettingsMenu(
101
+ menu: Menu,
102
+ theme: SettingsTheme,
103
+ ): { invalidate(): void; handleInput(data: string): void; render(width: number): string[] } {
104
+ return {
105
+ invalidate: () => menu.invalidate(),
106
+ handleInput: (data: string) => menu.handleInput(data),
107
+ render(width: number): string[] {
108
+ const rendered = menu.render(width);
109
+ if (rendered.length !== SETTINGS_KEYS.length + 2) return rendered;
110
+ const itemLines = rendered.slice(1, 1 + SETTINGS_KEYS.length);
111
+ const out: string[] = [rendered[0]!];
112
+ let lastCategory: SettingsCategory | undefined;
113
+ for (const [index, key] of SETTINGS_KEYS.entries()) {
114
+ const category = categoryOf(key);
115
+ if (category !== lastCategory) {
116
+ if (lastCategory !== undefined) out.push("");
117
+ out.push(theme.bold(theme.fg("dim", category)));
118
+ lastCategory = category;
119
+ }
120
+ out.push(itemLines[index]!);
121
+ }
122
+ out.push(rendered.at(-1)!);
123
+ return out;
124
+ },
125
+ };
126
+ }
127
+
128
+ /** Defaults to a full-chrome standalone panel (`framed: true`); pass `framed: false` when nesting this as one tab's content inside another framed container (e.g. the unified /jittor shell's own outer border). */
129
+ export function createSettingsPanel(
73
130
  snapshot: SettingsSnapshot,
74
131
  theme: SettingsTheme,
75
132
  onAction: (action: SettingsAction) => void,
76
133
  selected = 0,
134
+ framed = true,
77
135
  ): BorderedSelectPanel {
78
136
  const menu = new Menu({
79
137
  items: SETTINGS_KEYS.map((key) => ({ label: rowText(key, snapshot, theme), action: () => onAction({ kind: "activate", key }) })),
@@ -91,7 +149,7 @@ function createSettingsPanel(
91
149
  for (let index = 0; index < selected; index += 1) menu.handleInput("\x1b[B");
92
150
  return new BorderedSelectPanel({
93
151
  title: "Jittor Settings",
94
- list: menu,
152
+ list: groupedSettingsMenu(menu, theme),
95
153
  helpText: "Token budgets are user values; provider quotas remain separate. · ↑/↓ select · Enter edit · Esc close",
96
154
  theme: {
97
155
  border: (text) => theme.fg("borderMuted", text),
@@ -99,6 +157,7 @@ function createSettingsPanel(
99
157
  help: (text) => theme.fg("dim", text),
100
158
  },
101
159
  measure: hostTextMeasure,
160
+ framed,
102
161
  });
103
162
  }
104
163
 
@@ -130,6 +189,53 @@ async function editBudget(ctx: ExtensionCommandContext, budgets: UsageBudgetCont
130
189
  ctx.ui.notify(`${label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
131
190
  }
132
191
 
192
+ /**
193
+ * Performs the real side effect for one resolved settings action -- confirmations, effects calls,
194
+ * budget prompts. A no-op for "close". Shared by the standalone settings panel below and the
195
+ * unified /jittor shell, so the two interactive surfaces can never drift apart.
196
+ */
197
+ export async function runSettingsAction(
198
+ ctx: ExtensionCommandContext,
199
+ action: SettingsAction,
200
+ enforcement: EnforcementControl,
201
+ recovery: CodexRecoveryControl,
202
+ budgets: UsageBudgetControl,
203
+ effects: SettingsEffects,
204
+ ): Promise<void> {
205
+ if (action.kind === "close") return;
206
+ if (action.key === "enforcement") {
207
+ if (enforcement.isEnabled()) {
208
+ if (
209
+ await showConfirmDialog(
210
+ ctx,
211
+ "Disable routing enforcement?",
212
+ "Jittor will remain monitor-only and will no longer block unsafe provider requests.",
213
+ )
214
+ )
215
+ await effects.setEnforcement(false);
216
+ } else await effects.setEnforcement(true);
217
+ return;
218
+ }
219
+ if (action.key === "footer") {
220
+ await effects.setFooter(!enforcement.isFooterEnabled());
221
+ return;
222
+ }
223
+ if (action.key === "recovery") {
224
+ if (!recovery.isCodexRecoveryEnabled()) {
225
+ if (
226
+ await showConfirmDialog(
227
+ ctx,
228
+ "Enable Codex recovery?",
229
+ "Jittor may start bounded hidden retries only after transient Codex failures fully settle.",
230
+ )
231
+ )
232
+ await effects.setRecovery(true);
233
+ } else await effects.setRecovery(false);
234
+ return;
235
+ }
236
+ await editBudget(ctx, budgets, action.key.slice("budget-".length) as UsagePeriod);
237
+ }
238
+
133
239
  export async function showSettingsPanel(
134
240
  ctx: ExtensionCommandContext,
135
241
  enforcement: EnforcementControl,
@@ -160,34 +266,6 @@ export async function showSettingsPanel(
160
266
  };
161
267
  });
162
268
  if (!action || action.kind === "close") return;
163
- if (action.key === "enforcement") {
164
- if (enforcement.isEnabled()) {
165
- if (
166
- await ctx.ui.confirm(
167
- "Disable routing enforcement?",
168
- "Jittor will remain monitor-only and will no longer block unsafe provider requests.",
169
- )
170
- )
171
- await effects.setEnforcement(false);
172
- } else await effects.setEnforcement(true);
173
- continue;
174
- }
175
- if (action.key === "footer") {
176
- await effects.setFooter(!enforcement.isFooterEnabled());
177
- continue;
178
- }
179
- if (action.key === "recovery") {
180
- if (!recovery.isCodexRecoveryEnabled()) {
181
- if (
182
- await ctx.ui.confirm(
183
- "Enable Codex recovery?",
184
- "Jittor may start bounded hidden retries only after transient Codex failures fully settle.",
185
- )
186
- )
187
- await effects.setRecovery(true);
188
- } else await effects.setRecovery(false);
189
- continue;
190
- }
191
- await editBudget(ctx, budgets, action.key.slice("budget-".length) as UsagePeriod);
269
+ await runSettingsAction(ctx, action, enforcement, recovery, budgets, effects);
192
270
  }
193
271
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Confirm/select prompts rendered with malevich-tui-components' own Dialog/TabMenu, inside
3
+ * jittor's own theme and frame -- replacing Pi's host-native `ctx.ui.confirm`/`ctx.ui.select`
4
+ * (a visually separate native prompt) for every jittor-owned confirmation and selection.
5
+ *
6
+ * TUI-only: every real caller (settings-tui.ts's runSettingsAction, observability/status.ts's
7
+ * runStatusAction/chooseOverride) is itself only ever invoked from within an already-`ctx.mode
8
+ * === "tui"` code path -- the non-TUI notify fallback returns before ever reaching one of these
9
+ * calls -- so there is no non-TUI branch to fall back to here.
10
+ *
11
+ * Budget entry (settings-tui.ts's editBudget) deliberately keeps Pi's native `ctx.ui.input`:
12
+ * malevich has no plain visible text-input component today -- only `MaskedInput` (renders mask
13
+ * glyphs only, built for secrets; wrong semantics for a token count a user wants to see as they
14
+ * type) and `Form` (owns focus/validation/layout but requires a host-supplied `FormFieldInput`,
15
+ * and `MaskedInput` is the only one malevich ships). Building a new visible-text-input component
16
+ * is out of scope here; this is a deliberate, documented decision, not an oversight.
17
+ */
18
+ import type { Route } from "@danypops/jittor";
19
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
20
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import { Dialog, TabMenu, type TextMeasure } from "malevich-tui-components";
22
+
23
+ interface PromptTheme {
24
+ fg(color: string, text: string): string;
25
+ bold(text: string): string;
26
+ }
27
+
28
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
29
+
30
+ /**
31
+ * A bordered Dialog with exactly a Yes/No choice, resolving `true` for confirm, `false` for
32
+ * cancel -- including Escape, Ctrl+C, or the host dismissing the panel outright (`custom()`
33
+ * resolving `undefined`). A drop-in replacement for `ctx.ui.confirm(title, body)`.
34
+ */
35
+ export async function showConfirmDialog(
36
+ ctx: ExtensionCommandContext,
37
+ title: string,
38
+ body: string,
39
+ options: { confirmLabel?: string; cancelLabel?: string } = {},
40
+ ): Promise<boolean> {
41
+ const confirmLabel = options.confirmLabel ?? "Yes";
42
+ const cancelLabel = options.cancelLabel ?? "No";
43
+ const confirmed = await ctx.ui.custom<boolean>((_tui, theme: PromptTheme, _keybindings, done) => {
44
+ const dialog = new Dialog({
45
+ title,
46
+ body,
47
+ actions: [
48
+ { label: confirmLabel, key: "y", action: () => done(true) },
49
+ { label: cancelLabel, key: "n", action: () => done(false) },
50
+ ],
51
+ theme: {
52
+ border: (text) => theme.fg("borderMuted", text),
53
+ title: theme.bold,
54
+ body: (text) => text,
55
+ dim: (text) => theme.fg("dim", text),
56
+ },
57
+ measure: hostTextMeasure,
58
+ });
59
+ return {
60
+ invalidate: () => dialog.invalidate(),
61
+ render: (width: number) => dialog.render(width),
62
+ handleInput(data: string): void {
63
+ if (matchesKey(data, "ctrl+c")) {
64
+ done(false);
65
+ return;
66
+ }
67
+ dialog.handleInput(data);
68
+ },
69
+ };
70
+ });
71
+ return confirmed ?? false;
72
+ }
73
+
74
+ /**
75
+ * A TabMenu over one flat level -- every route is a leaf node, no drill-down -- resolving the
76
+ * selected route, or `undefined` on cancel (Escape at the root, Ctrl+C, or the host dismissing
77
+ * the panel outright). A drop-in replacement for `ctx.ui.select("Override route", labels)`
78
+ * followed by mapping the chosen label back to its route.
79
+ */
80
+ export async function showRouteOverrideMenu(
81
+ ctx: ExtensionCommandContext,
82
+ routes: Route[],
83
+ routeLabel: (route: Route) => string,
84
+ ): Promise<Route | undefined> {
85
+ return ctx.ui.custom<Route | undefined>((_tui, theme: PromptTheme, _keybindings, done) => {
86
+ const tabMenu = new TabMenu<Route>({
87
+ nodes: routes.map((route) => ({ label: routeLabel(route), value: route })),
88
+ theme: {
89
+ tab: (text) => theme.fg("dim", text),
90
+ activeTab: (text) => theme.bold(theme.fg("accent", text)),
91
+ breadcrumb: (text) => theme.fg("dim", text),
92
+ description: (text) => theme.fg("dim", text),
93
+ help: (text) => theme.fg("dim", text),
94
+ },
95
+ onSelect: (route) => done(route),
96
+ onCancel: () => done(undefined),
97
+ });
98
+ return {
99
+ invalidate: () => tabMenu.invalidate(),
100
+ render: (width: number) => tabMenu.render(width),
101
+ handleInput(data: string): void {
102
+ if (matchesKey(data, "ctrl+c")) {
103
+ done(undefined);
104
+ return;
105
+ }
106
+ tabMenu.handleInput(data);
107
+ },
108
+ };
109
+ });
110
+ }