@danypops/pi-jittor 0.2.1 → 0.3.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.
@@ -16,8 +16,9 @@ import {
16
16
  } from "@danypops/jittor";
17
17
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
18
18
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
19
- import type { UsageBudgetControl } from "./settings.ts";
20
- import type { JittorPanelClient } from "./tui.ts";
19
+ import { HistoryChart, type HistoryChartTheme, ProgressBar, type TextMeasure } from "malevich-tui-components";
20
+ import type { UsageBudgetControl } from "../settings.ts";
21
+ import type { JittorPanelClient } from "./status.ts";
21
22
 
22
23
  type UsageAction = "period-prev" | "period-next" | "view-next" | "refresh" | "close";
23
24
  type UsageColor =
@@ -60,10 +61,9 @@ export interface UsageTheme {
60
61
  * perceptual separation between adjacent categories.
61
62
  *
62
63
  * Terminal foreground color is a single channel with a hard ceiling on how many hues stay mutually
63
- * distinguishable (most qualitative-palette guidance caps around 8–12). Once the hue palette is
64
- * exhausted, seriesStyle adds bold as a second, independent visual channel before any exact
65
- * color+weight combination repeats a standard visualization technique (encode extra categories
66
- * on an additional channel rather than silently reusing an indistinguishable color).
64
+ * distinguishable (most qualitative-palette guidance caps around 8–12). seriesStyle derives both a
65
+ * hue and optional bold weight from stable identity bits, using a second visual channel to reduce
66
+ * collisions without allowing ranking or render order to change a model's appearance.
67
67
  */
68
68
  const SERIES_HUES: UsageColor[] = [
69
69
  "accent",
@@ -77,15 +77,23 @@ const SERIES_HUES: UsageColor[] = [
77
77
  "syntaxOperator",
78
78
  ];
79
79
 
80
- /** Returns a style function for the Nth series: cycles hue, then adds bold once the palette wraps. */
81
- function seriesStyle(index: number, theme: UsageTheme): (text: string) => string {
82
- const hue = SERIES_HUES[index % SERIES_HUES.length]!;
83
- const useBold = Math.floor(index / SERIES_HUES.length) % 2 === 1;
80
+ /**
81
+ * Stable FNV-1a identity hash. Series ordering changes with totals and selected period, so assigning
82
+ * colors by array index makes a model change color whenever another model overtakes it. Deriving
83
+ * both visual channels from its provider/model key keeps that identity fixed across token and cost
84
+ * graphics, period switches, refreshes, and extension restarts.
85
+ */
86
+ function seriesStyle(identity: string, theme: UsageTheme): (text: string) => string {
87
+ let hash = 0x811c9dc5;
88
+ for (let index = 0; index < identity.length; index += 1) {
89
+ hash ^= identity.charCodeAt(index);
90
+ hash = Math.imul(hash, 0x01000193) >>> 0;
91
+ }
92
+ const hue = SERIES_HUES[hash % SERIES_HUES.length]!;
93
+ const useBold = Math.floor(hash / SERIES_HUES.length) % 2 === 1;
84
94
  return (text: string) => (useBold ? theme.bold(theme.fg(hue, text)) : theme.fg(hue, text));
85
95
  }
86
96
 
87
- const PARTIAL_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
88
-
89
97
  function compact(value: number): string {
90
98
  if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(value >= 10_000_000_000 ? 0 : 1)}B`;
91
99
  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
@@ -93,58 +101,6 @@ function compact(value: number): string {
93
101
  return String(Math.round(value));
94
102
  }
95
103
 
96
- interface RenderableSeries {
97
- key: string;
98
- provider: string;
99
- model: string;
100
- total: number;
101
- }
102
- interface RenderableBucket {
103
- start: number;
104
- end: number;
105
- total: number;
106
- series: Record<string, number>;
107
- }
108
- interface RenderableChart {
109
- period: UsagePeriod;
110
- start: number;
111
- end: number;
112
- buckets: RenderableBucket[];
113
- series: RenderableSeries[];
114
- total: number;
115
- truncated: boolean;
116
- }
117
-
118
- function mergeBuckets(buckets: RenderableBucket[], maximum: number): RenderableBucket[] {
119
- if (buckets.length <= maximum) return buckets;
120
- const result: RenderableBucket[] = [];
121
- for (let index = 0; index < maximum; index += 1) {
122
- const from = Math.floor((index * buckets.length) / maximum);
123
- const to = Math.max(from + 1, Math.floor(((index + 1) * buckets.length) / maximum));
124
- const selected = buckets.slice(from, to);
125
- const series: Record<string, number> = {};
126
- for (const bucket of selected) {
127
- for (const [key, value] of Object.entries(bucket.series)) series[key] = (series[key] ?? 0) + value;
128
- }
129
- result.push({
130
- start: selected[0]!.start,
131
- end: selected[selected.length - 1]!.end,
132
- total: selected.reduce((sum, bucket) => sum + bucket.total, 0),
133
- series,
134
- });
135
- }
136
- return result;
137
- }
138
-
139
- function seriesAt(bucket: RenderableBucket, chart: RenderableChart, valueHeight: number): number {
140
- let cumulative = 0;
141
- for (let index = 0; index < chart.series.length; index += 1) {
142
- cumulative += bucket.series[chart.series[index]!.key] ?? 0;
143
- if (valueHeight <= cumulative) return index;
144
- }
145
- return Math.max(0, chart.series.length - 1);
146
- }
147
-
148
104
  /** Compact USD formatter: full cents below $1k, then the same k/M suffix convention as compact(). */
149
105
  function formatUsd(value: number): string {
150
106
  const magnitude = Math.abs(value);
@@ -160,18 +116,6 @@ function formatPeriodPoint(value: number, period: UsagePeriod): string {
160
116
  return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
161
117
  }
162
118
 
163
- function axisLabels(start: number, end: number, period: UsagePeriod, width: number): string {
164
- const labels = [formatPeriodPoint(start, period), formatPeriodPoint(start + (end - start) / 2, period), formatPeriodPoint(end, period)];
165
- const positions = [0, Math.max(0, Math.floor((width - labels[1]!.length) / 2)), Math.max(0, width - labels[2]!.length)];
166
- const characters = Array.from({ length: width }, () => " ");
167
- for (let labelIndex = 0; labelIndex < labels.length; labelIndex += 1) {
168
- for (let index = 0; index < labels[labelIndex]!.length && positions[labelIndex]! + index < width; index += 1) {
169
- characters[positions[labelIndex]! + index] = labels[labelIndex]![index]!;
170
- }
171
- }
172
- return characters.join("");
173
- }
174
-
175
119
  function displayIdentity(value: string): string {
176
120
  return value
177
121
  .replace(/[\r\n\t]/g, " ")
@@ -184,110 +128,76 @@ function plainTheme(): UsageTheme {
184
128
  return { fg: (_color, text) => text, bold: (text) => text };
185
129
  }
186
130
 
131
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
132
+
133
+ function chartTheme(theme: UsageTheme, seriesIdentities: string[]): HistoryChartTheme {
134
+ return {
135
+ title: theme.bold,
136
+ subtitle: (text) => text,
137
+ axis: (text) => theme.fg("borderMuted", text),
138
+ warningLine: (text) => theme.fg("warning", text),
139
+ errorLine: (text) => theme.fg("error", text),
140
+ muted: (text) => theme.fg("muted", text),
141
+ series: (index) => seriesStyle(seriesIdentities[index] ?? `series:${index}`, theme),
142
+ };
143
+ }
144
+
187
145
  interface ChartRenderOptions {
188
146
  title: string;
189
147
  formatValue: (value: number) => string;
190
- /** Appended after the observed/budget amount, e.g. " tokens"; empty when formatValue already carries a unit prefix like "$". */
191
148
  unitSuffix: string;
192
149
  subtitle?: string;
193
150
  budget?: number;
194
151
  noDataText: string;
195
152
  }
196
153
 
197
- /** Shared cumulative bar-chart renderer behind renderUsageGraph and renderCostGraph. */
198
- function renderChart(chart: RenderableChart, width: number, theme: UsageTheme, options: ChartRenderOptions): string[] {
199
- const { formatValue, unitSuffix } = options;
200
- const safeWidth = Math.max(20, width);
201
- const chartColumns = Math.max(1, Math.floor((safeWidth - USAGE_Y_AXIS_WIDTH - 1) / 2));
202
- const increments = mergeBuckets(chart.buckets, chartColumns);
203
- const runningSeries: Record<string, number> = {};
204
- let runningTotal = 0;
205
- const buckets = increments.map((bucket) => {
206
- runningTotal += bucket.total;
207
- for (const [key, value] of Object.entries(bucket.series)) runningSeries[key] = (runningSeries[key] ?? 0) + value;
208
- return { ...bucket, total: runningTotal, series: { ...runningSeries } };
154
+ /** Adapts Jittor's domain graph to Malevich's reusable cumulative HistoryChart and budget ProgressBar. */
155
+ function renderChart(
156
+ chart: { period: UsagePeriod; buckets: UsageGraph["buckets"]; series: UsageGraph["series"]; total: number; truncated: boolean },
157
+ width: number,
158
+ theme: UsageTheme,
159
+ options: ChartRenderOptions,
160
+ ): string[] {
161
+ const component = new HistoryChart({
162
+ title: options.title,
163
+ buckets: chart.buckets,
164
+ series: chart.series.map((series) => ({
165
+ key: series.key,
166
+ label: `${displayIdentity(series.provider)}/${displayIdentity(series.model)}`,
167
+ })),
168
+ formatValue: options.formatValue,
169
+ unitSuffix: options.unitSuffix,
170
+ ...(options.subtitle ? { subtitle: options.subtitle } : {}),
171
+ ...(options.budget !== undefined ? { budget: options.budget } : {}),
172
+ noDataText: options.noDataText,
173
+ truncated: chart.truncated,
174
+ formatAxisLabel: (value) => formatPeriodPoint(value, chart.period),
175
+ theme: chartTheme(
176
+ theme,
177
+ chart.series.map((series) => series.key),
178
+ ),
179
+ measure: hostTextMeasure,
180
+ height: USAGE_CHART_HEIGHT,
181
+ yAxisWidth: USAGE_Y_AXIS_WIDTH,
182
+ maxSeriesShown: USAGE_RENDER_MAX_SERIES,
209
183
  });
210
- const barStep = buckets.length * 2 <= safeWidth - USAGE_Y_AXIS_WIDTH ? 2 : 1;
211
- const plotWidth = buckets.length * barStep;
212
- const budget = typeof options.budget === "number" && Number.isFinite(options.budget) && options.budget > 0 ? options.budget : undefined;
213
- const maximum = Math.max(chart.total, budget ?? 0);
214
- const observed = chart.truncated ? `at least ${formatValue(chart.total)}` : formatValue(chart.total);
215
- const budgetState =
216
- budget === undefined
217
- ? `${observed}${unitSuffix} · budget not configured${chart.truncated ? " · query limit reached" : ""}`
218
- : chart.total > budget
219
- ? `${observed}${unitSuffix} / ${formatValue(budget)} budget · OVER BUDGET by ${chart.truncated ? "at least " : ""}${formatValue(chart.total - budget)}`
220
- : chart.truncated
221
- ? `${observed}${unitSuffix} / ${formatValue(budget)} budget · state unknown · query limit reached`
222
- : `${observed}${unitSuffix} / ${formatValue(budget)} budget · ${formatValue(budget - chart.total)} remaining`;
223
- const lines = [
224
- truncateToWidth(theme.bold(options.title), safeWidth, ""),
225
- truncateToWidth(budgetState, safeWidth, "…"),
226
- ...(options.subtitle ? [truncateToWidth(options.subtitle, safeWidth, "…")] : []),
227
- "",
228
- ];
229
- if (maximum === 0) {
230
- lines.push(theme.fg("dim", options.noDataText));
231
- return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
232
- }
233
-
234
- for (let row = 0; row < USAGE_CHART_HEIGHT; row += 1) {
235
- const fromBottom = USAGE_CHART_HEIGHT - row - 1;
236
- const lower = (maximum * fromBottom) / USAGE_CHART_HEIGHT;
237
- const upper = (maximum * (fromBottom + 1)) / USAGE_CHART_HEIGHT;
238
- const thresholdRow = budget !== undefined && budget > lower && budget <= upper;
239
- const label = thresholdRow
240
- ? formatValue(budget)
241
- : row === 0
242
- ? formatValue(maximum)
243
- : row === Math.floor(USAGE_CHART_HEIGHT / 2)
244
- ? formatValue(maximum / 2)
245
- : "";
246
- if (thresholdRow) {
247
- const color = chart.total > budget ? "error" : "warning";
248
- lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${theme.fg(color, "┄".repeat(plotWidth))}`);
249
- continue;
250
- }
251
- let plot = "";
252
- for (const bucket of buckets) {
253
- const scaled = (bucket.total / maximum) * USAGE_CHART_HEIGHT;
254
- const occupancy = Math.max(0, Math.min(1, scaled - fromBottom));
255
- if (occupancy <= 0) {
256
- plot += " ".repeat(barStep);
257
- continue;
258
- }
259
- const block = PARTIAL_BLOCKS[Math.max(0, Math.ceil(occupancy * PARTIAL_BLOCKS.length) - 1)]!;
260
- const valueHeight = Math.min(bucket.total, (maximum * (fromBottom + Math.min(occupancy, 0.5))) / USAGE_CHART_HEIGHT);
261
- plot += seriesStyle(seriesAt(bucket, chart, valueHeight), theme)(block) + (barStep === 2 ? " " : "");
262
- }
263
- lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${plot}`);
264
- }
265
- lines.push(`${"0".padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", `└${"─".repeat(plotWidth)}`)}`);
266
- lines.push(`${" ".repeat(USAGE_Y_AXIS_WIDTH)}${axisLabels(chart.start, chart.end, chart.period, plotWidth)}`);
267
- lines.push("");
268
- const displayedSeries = chart.series.slice(0, USAGE_RENDER_MAX_SERIES);
269
- for (let index = 0; index < displayedSeries.length; index += 1) {
270
- const series = displayedSeries[index]!;
271
- const bullet = seriesStyle(index, theme)("■");
272
- lines.push(
273
- truncateToWidth(
274
- `${bullet} ${displayIdentity(series.provider)}/${displayIdentity(series.model)} ${formatValue(series.total)}`,
275
- safeWidth,
276
- "…",
277
- ),
278
- );
279
- }
280
- if (chart.series.length > displayedSeries.length)
281
- lines.push(truncateToWidth(theme.fg("muted", `… ${chart.series.length - displayedSeries.length} more series omitted`), safeWidth, "…"));
282
- return lines.map((line) => (visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…")));
184
+ const lines = component.render(width);
185
+ if (options.budget === undefined || !Number.isFinite(options.budget) || options.budget <= 0 || chart.total === 0) return lines;
186
+ const meter = new ProgressBar({
187
+ value: chart.total,
188
+ max: options.budget,
189
+ label: "Budget",
190
+ style: chart.total > options.budget ? (text) => theme.fg("error", text) : (text) => theme.fg("accent", text),
191
+ measure: hostTextMeasure,
192
+ });
193
+ lines.splice(options.subtitle ? 3 : 2, 0, ...meter.render(Math.max(20, width)));
194
+ return lines;
283
195
  }
284
196
 
285
197
  export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageTheme, tokenBudget?: number): string[] {
286
198
  return renderChart(
287
199
  {
288
200
  period: chart.period,
289
- start: chart.start,
290
- end: chart.end,
291
201
  buckets: chart.buckets,
292
202
  series: chart.series,
293
203
  total: chart.totalTokens,
@@ -310,8 +220,6 @@ export function renderCostGraph(chart: CostGraph, width: number, theme: UsageThe
310
220
  return renderChart(
311
221
  {
312
222
  period: chart.period,
313
- start: chart.start,
314
- end: chart.end,
315
223
  buckets: chart.buckets,
316
224
  series: chart.series,
317
225
  total: chart.totalUsd,
@@ -14,8 +14,9 @@ import {
14
14
  type UtilityComponentName,
15
15
  } from "@danypops/jittor";
16
16
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
- import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
18
- import { sessionSecretField } from "./session-identity.ts";
17
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+ import { BorderedSelectPanel, Table, type TextMeasure } from "malevich-tui-components";
19
+ import { sessionSecretField } from "../session-identity.ts";
19
20
 
20
21
  export interface BenchmarkPanelClient {
21
22
  call(operation: string, input: unknown): Promise<any>;
@@ -36,18 +37,25 @@ function componentText(item: RankedModel): string {
36
37
  .join(" · ");
37
38
  }
38
39
 
39
- function candidateLines(item: RankedModel, index: number, currentIdentity: string): string[] {
40
- const current = item.identity.startsWith(`${currentIdentity}:`);
41
- const localSamples = item.components.find((component) => component.name === "reliability")?.evidenceCount ?? 0;
42
- const provenance = item.provenance
43
- .slice(0, BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE)
44
- .map((source) => `${source.sourceId}@${source.revision} ${source.freshness}`)
45
- .join(" · ");
46
- return [
47
- ` ${index + 1}. ${item.identity}${index === 0 ? " recommended" : ""}${current ? " current" : ""}`,
48
- ` utility ${item.utility === null ? "?" : item.utility.toFixed(3)} · confidence ${(item.confidence * 100).toFixed(0)}% · ${componentText(item)}`,
49
- ` local n=${localSamples}${provenance ? ` · ${provenance}` : " · no external provenance"}`,
50
- ];
40
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
41
+
42
+ function benchmarkRows(shown: RankedModel[], currentIdentity: string): Record<string, string>[] {
43
+ return shown.flatMap((item, index) => {
44
+ const current = item.identity.startsWith(`${currentIdentity}:`);
45
+ const localSamples = item.components.find((component) => component.name === "reliability")?.evidenceCount ?? 0;
46
+ const provenance = item.provenance
47
+ .slice(0, BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE)
48
+ .map((source) => `${source.sourceId}@${source.revision} ${source.freshness}`)
49
+ .join(" · ");
50
+ return [
51
+ { rank: `${index + 1}.`, detail: `${item.identity}${index === 0 ? " recommended" : ""}${current ? " current" : ""}` },
52
+ {
53
+ rank: "",
54
+ detail: `utility ${item.utility === null ? "?" : item.utility.toFixed(3)} · confidence ${(item.confidence * 100).toFixed(0)}% · ${componentText(item)}`,
55
+ },
56
+ { rank: "", detail: `local n=${localSamples}${provenance ? ` · ${provenance}` : " · no external provenance"}` },
57
+ ];
58
+ });
51
59
  }
52
60
 
53
61
  export function renderBenchmarkView(result: ModelRankingResult, currentIdentity: string, width: number, theme: BenchmarkTheme): string[] {
@@ -61,21 +69,45 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
61
69
  : recommended && currentIndex === 0
62
70
  ? "Current model is the top recommendation."
63
71
  : "Current model is outside the ranked candidates.";
64
- const lines = [
65
- theme.fg("borderMuted", "─".repeat(safeWidth)),
66
- theme.bold("Jittor Benchmark Recommendations"),
67
- result.scopeAuthority === "exact-session"
68
- ? "Scope: exact session"
69
- : "Scope: available models · ADVISORY (exact session scope unavailable)",
70
- `Domain: ${result.domain} · Type: ${result.type} · evidence ${result.completeness}`,
71
- reason,
72
- ...shown.flatMap((item, index) => candidateLines(item, index, currentIdentity)),
73
- ...(result.ranked.length > shown.length ? [` … ${result.ranked.length - shown.length} more candidates omitted`] : []),
74
- ...(result.scopeWarning ? [result.scopeWarning] : []),
75
- theme.fg("dim", "r refresh · Esc close"),
76
- theme.fg("borderMuted", "─".repeat(safeWidth)),
77
- ];
78
- return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
72
+ const table = new Table({
73
+ columns: [
74
+ { header: "#", key: "rank", width: 4 },
75
+ { header: "Candidate / evidence", key: "detail" },
76
+ ],
77
+ rows: benchmarkRows(shown, currentIdentity),
78
+ headerStyle: theme.bold,
79
+ measure: hostTextMeasure,
80
+ });
81
+ const content = {
82
+ invalidate: () => table.invalidate(),
83
+ render: (availableWidth: number): string[] => [
84
+ truncateToWidth(
85
+ result.scopeAuthority === "exact-session"
86
+ ? "Scope: exact session"
87
+ : "Scope: available models · ADVISORY (exact session scope unavailable)",
88
+ availableWidth,
89
+ "…",
90
+ ),
91
+ truncateToWidth(`Domain: ${result.domain} · Type: ${result.type} · evidence ${result.completeness}`, availableWidth, "…"),
92
+ truncateToWidth(reason, availableWidth, "…"),
93
+ ...table.render(availableWidth),
94
+ ...(result.ranked.length > shown.length
95
+ ? [truncateToWidth(`… ${result.ranked.length - shown.length} more candidates omitted`, availableWidth, "…")]
96
+ : []),
97
+ ...(result.scopeWarning ? [truncateToWidth(result.scopeWarning, availableWidth, "…")] : []),
98
+ ],
99
+ };
100
+ return new BorderedSelectPanel({
101
+ title: "Jittor Benchmark Recommendations",
102
+ list: content,
103
+ helpText: "r refresh · Esc close",
104
+ theme: {
105
+ border: (text) => theme.fg("borderMuted", text),
106
+ title: theme.bold,
107
+ help: (text) => theme.fg("dim", text),
108
+ },
109
+ measure: hostTextMeasure,
110
+ }).render(safeWidth);
79
111
  }
80
112
 
81
113
  export async function showBenchmarkPanel(
@@ -12,8 +12,8 @@ import {
12
12
  MILLISECONDS_PER_SECOND,
13
13
  } from "@danypops/jittor";
14
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import type { CodexRecoveryControl } from "../settings.ts";
16
- import { headerValue } from "./http-headers.ts";
15
+ import { headerValue } from "../../observability/http-headers.ts";
16
+ import type { CodexRecoveryControl } from "../../settings.ts";
17
17
 
18
18
  export interface CodexRecoveryRuntime {
19
19
  now(): number;
@@ -10,11 +10,58 @@ function defaultConnector(): Promise<JittorClient> {
10
10
  let connector: JittorConnector = defaultConnector;
11
11
  let retrying: RetryingClient<JittorClient> = createRetryingClient(() => connector(), { label: "Jittor" });
12
12
 
13
+ /**
14
+ * Exhaustive transport-retry policy. New daemon operations cannot compile until their mutability
15
+ * is classified: only reads may be transparently invoked twice after a connection-shaped error.
16
+ */
17
+ const OPERATION_RETRY_MODE = {
18
+ "metrics.record": "once",
19
+ "metrics.record_batch": "once",
20
+ "metrics.query": "retry",
21
+ "metrics.distinct_scopes": "retry",
22
+ "metrics.usage_series": "retry",
23
+ "metrics.cost_by_task": "retry",
24
+ "metrics.prune": "once",
25
+ "benchmark.refresh": "once",
26
+ "benchmark.status": "retry",
27
+ "benchmark.query": "retry",
28
+ "catalog.refresh": "once",
29
+ "catalog.status": "retry",
30
+ "catalog.query": "retry",
31
+ "usage.import": "once",
32
+ "usage.import_status": "retry",
33
+ "usage.import_cancel": "once",
34
+ "export.status": "retry",
35
+ "export.flush": "once",
36
+ "session.register": "once",
37
+ "session.release": "once",
38
+ "models.rank": "once",
39
+ "context.assess": "retry",
40
+ "context.delta": "retry",
41
+ "context.snapshot": "once",
42
+ "compaction.estimate": "retry",
43
+ "service.checkpoint": "once",
44
+ "telemetry.poll": "retry",
45
+ "router.status": "retry",
46
+ "router.decide": "retry",
47
+ "router.pause": "once",
48
+ "router.resume": "once",
49
+ "router.override": "once",
50
+ "router.clear_override": "once",
51
+ "router.current_route": "once",
52
+ "router.available_routes": "once",
53
+ } as const satisfies Record<OperationName, "retry" | "once">;
54
+
55
+ export function operationRetryMode(operation: OperationName): "retry" | "once" {
56
+ return OPERATION_RETRY_MODE[operation];
57
+ }
58
+
13
59
  export async function callJittor<Name extends OperationName>(
14
60
  operation: Name,
15
61
  input: OperationInputs[Name],
16
62
  ): Promise<OperationOutputs[Name]> {
17
- return retrying.call((client) => client.call(operation, input));
63
+ const invoke = (client: JittorClient) => client.call(operation, input);
64
+ return operationRetryMode(operation) === "retry" ? retrying.call(invoke) : retrying.callOnce(invoke);
18
65
  }
19
66
 
20
67
  export function setJittorClientConnectorForTests(value: JittorConnector): void {
@@ -1,6 +1,7 @@
1
1
  import { USAGE_PERIODS, type UsagePeriod } from "@danypops/jittor";
2
2
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { BorderedSelectPanel, Menu, type MenuTheme, type TextMeasure } from "malevich-tui-components";
4
5
  import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
5
6
 
6
7
  export interface SettingsSnapshot {
@@ -56,16 +57,53 @@ export function settingsSnapshot(
56
57
  };
57
58
  }
58
59
 
60
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
61
+
62
+ function menuTheme(theme: SettingsTheme): MenuTheme {
63
+ return {
64
+ border: () => "",
65
+ selected: (text) => theme.fg("accent", text),
66
+ normal: (text) => text,
67
+ dim: (text) => theme.fg("dim", text),
68
+ title: theme.bold,
69
+ };
70
+ }
71
+
72
+ function createSettingsPanel(
73
+ snapshot: SettingsSnapshot,
74
+ theme: SettingsTheme,
75
+ onAction: (action: SettingsAction) => void,
76
+ selected = 0,
77
+ ): BorderedSelectPanel {
78
+ const menu = new Menu({
79
+ items: SETTINGS_KEYS.map((key) => ({ label: rowText(key, snapshot, theme), action: () => onAction({ kind: "activate", key }) })),
80
+ theme: menuTheme(theme),
81
+ onClose: () => onAction({ kind: "close" }),
82
+ measure: hostTextMeasure,
83
+ matchesKey: (data, key) => {
84
+ if (key === "enter") return matchesKey(data, "enter") || matchesKey(data, "space");
85
+ if (key === "escape") return matchesKey(data, "escape") || matchesKey(data, "ctrl+c");
86
+ if (key === "up") return matchesKey(data, "up");
87
+ if (key === "down") return matchesKey(data, "down");
88
+ return false;
89
+ },
90
+ });
91
+ for (let index = 0; index < selected; index += 1) menu.handleInput("\x1b[B");
92
+ return new BorderedSelectPanel({
93
+ title: "Jittor Settings",
94
+ list: menu,
95
+ helpText: "Token budgets are user values; provider quotas remain separate. · ↑/↓ select · Enter edit · Esc close",
96
+ theme: {
97
+ border: (text) => theme.fg("borderMuted", text),
98
+ title: theme.bold,
99
+ help: (text) => theme.fg("dim", text),
100
+ },
101
+ measure: hostTextMeasure,
102
+ });
103
+ }
104
+
59
105
  export function renderSettingsView(snapshot: SettingsSnapshot, selected: number, width: number, theme: SettingsTheme): string[] {
60
- const safeWidth = Math.max(20, width);
61
- const lines = [theme.bold("Jittor Settings"), theme.fg("dim", "Token budgets are user values; provider quotas remain separate."), ""];
62
- for (let index = 0; index < SETTINGS_KEYS.length; index += 1) {
63
- const selectedRow = index === selected;
64
- const prefix = selectedRow ? theme.fg("accent", "› ") : " ";
65
- lines.push(`${prefix}${rowText(SETTINGS_KEYS[index]!, snapshot, theme)}`);
66
- }
67
- lines.push("", theme.fg("dim", "↑/↓ select · Enter edit · Esc close"));
68
- return lines.map((line) => (visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…")));
106
+ return createSettingsPanel(snapshot, theme, () => undefined, Math.max(0, selected)).render(Math.max(20, width));
69
107
  }
70
108
 
71
109
  function plainTheme(): SettingsTheme {
@@ -79,7 +117,7 @@ async function editBudget(ctx: ExtensionCommandContext, budgets: UsageBudgetCont
79
117
  if (input === undefined) return;
80
118
  const normalized = input.trim().toLowerCase();
81
119
  if (normalized === "off" || normalized === "clear") {
82
- budgets.setUsageTokenBudget(period, undefined);
120
+ await budgets.setUsageTokenBudget(period, undefined);
83
121
  ctx.ui.notify(`${label} token budget cleared.`, "info");
84
122
  return;
85
123
  }
@@ -88,7 +126,7 @@ async function editBudget(ctx: ExtensionCommandContext, budgets: UsageBudgetCont
88
126
  ctx.ui.notify("Enter a positive token count, or `off` to clear this threshold.", "warning");
89
127
  return;
90
128
  }
91
- budgets.setUsageTokenBudget(period, tokens);
129
+ await budgets.setUsageTokenBudget(period, tokens);
92
130
  ctx.ui.notify(`${label} token budget set to ${tokens.toLocaleString()} tokens.`, "info");
93
131
  }
94
132
 
@@ -104,33 +142,20 @@ export async function showSettingsPanel(
104
142
  },
105
143
  ): Promise<void> {
106
144
  if (ctx.mode !== "tui") {
107
- ctx.ui.notify(
108
- renderSettingsView(settingsSnapshot(enforcement, recovery, budgets), -1, 100, plainTheme())
109
- .slice(0, -2)
110
- .join("\n"),
111
- "info",
112
- );
145
+ const snapshot = settingsSnapshot(enforcement, recovery, budgets);
146
+ ctx.ui.notify(["Jittor Settings", ...SETTINGS_KEYS.map((key) => rowText(key, snapshot, plainTheme()))].join("\n"), "info");
113
147
  return;
114
148
  }
115
149
  for (;;) {
116
150
  const snapshot = settingsSnapshot(enforcement, recovery, budgets);
117
151
  const action = await ctx.ui.custom<SettingsAction>((tui, theme, _keybindings, done) => {
118
- let selected = 0;
152
+ const panel = createSettingsPanel(snapshot, theme, done);
119
153
  return {
120
- invalidate() {},
121
- render(width: number): string[] {
122
- return renderSettingsView(snapshot, selected, width, theme);
123
- },
124
- handleInput(data: string): void {
125
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done({ kind: "close" });
126
- else if (matchesKey(data, "up")) {
127
- selected = (selected - 1 + SETTINGS_KEYS.length) % SETTINGS_KEYS.length;
128
- tui.requestRender();
129
- } else if (matchesKey(data, "down")) {
130
- selected = (selected + 1) % SETTINGS_KEYS.length;
131
- tui.requestRender();
132
- } else if (matchesKey(data, "return") || matchesKey(data, "enter") || matchesKey(data, "space"))
133
- done({ kind: "activate", key: SETTINGS_KEYS[selected]! });
154
+ invalidate: () => panel.invalidate(),
155
+ render: (width) => panel.render(width),
156
+ handleInput(data: string) {
157
+ panel.handleInput(data);
158
+ tui.requestRender();
134
159
  },
135
160
  };
136
161
  });