@oh-my-pi/pi-tui 18.11.1 → 18.11.2

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 (40) hide show
  1. package/CHANGELOG.md +27 -110
  2. package/dist/types/apps/git/git-tui.d.ts +16 -2
  3. package/dist/types/charts/chart-svg.d.ts +7 -13
  4. package/dist/types/chat/table-chart.d.ts +11 -15
  5. package/dist/types/chrome/transcript-container.d.ts +2 -0
  6. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  7. package/dist/types/prompt/composer.d.ts +20 -0
  8. package/dist/types/prompt/custom-editor.d.ts +2 -0
  9. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  10. package/dist/types/prompt/prose-gate.d.ts +3 -0
  11. package/dist/types/prompt/queued-messages.d.ts +1 -1
  12. package/dist/types/prompt/typing-word.d.ts +25 -0
  13. package/dist/types/status-line/host.d.ts +6 -4
  14. package/dist/types/terminal.d.ts +1 -1
  15. package/dist/types/tui.d.ts +9 -0
  16. package/package.json +9 -9
  17. package/src/apps/git/git-tui.ts +27 -10
  18. package/src/apps/git/sidebar.ts +5 -1
  19. package/src/autocomplete.ts +7 -0
  20. package/src/charts/chart-svg.ts +19 -22
  21. package/src/chat/assistant-message.ts +3 -3
  22. package/src/chat/table-chart.ts +24 -57
  23. package/src/chrome/transcript-container.ts +10 -0
  24. package/src/components/settings-list.ts +2 -4
  25. package/src/overlays/agent-hub-projection.ts +63 -12
  26. package/src/overlays/btw-history-panel.ts +30 -23
  27. package/src/overlays/extensions/extension-list.ts +4 -6
  28. package/src/overlays/settings-selector.ts +3 -0
  29. package/src/prompt/composer.ts +158 -11
  30. package/src/prompt/custom-editor.ts +19 -1
  31. package/src/prompt/macos-spelling.ts +28 -5
  32. package/src/prompt/prose-gate.ts +3 -0
  33. package/src/prompt/queued-messages.ts +9 -2
  34. package/src/prompt/typing-word.ts +59 -0
  35. package/src/status-line/component.ts +18 -10
  36. package/src/status-line/footer.ts +18 -35
  37. package/src/status-line/host.ts +6 -1
  38. package/src/terminal.ts +97 -24
  39. package/src/tools/eval.ts +40 -7
  40. package/src/tui.ts +28 -7
@@ -2,9 +2,11 @@
2
2
  * Draws a {@link ChartSpec} as a standalone SVG document colored only with
3
3
  * figure tokens — `var(--fg)`, `var(--muted)`, `var(--border)`,
4
4
  * `var(--accent)`, `var(--surface)`, `var(--c1)`…`var(--c6)` — on a
5
- * transparent background — the token vocabulary of assistant ```svg figures —
6
- * so the host resolves them against the live theme (`prepareSvg`) for both
7
- * the rasterized terminal image and the native TSP blob.
5
+ * transparent background — the token vocabulary of assistant ```svg figures:
6
+ * the host resolves them against the live theme (`prepareSvg`) for the
7
+ * rasterized terminal image, and a TSP terminal draws the chart as an ```svg
8
+ * figure in the palette it shows. The root carries {@link chartAlt} as its
9
+ * `aria-label`.
8
10
  *
9
11
  * Styled after Apple's charts and Tern's UI: a UI sans (Geist where Tern
10
12
  * provides it), muted labels, hairline gridlines that recede behind the
@@ -236,15 +238,8 @@ interface Tier {
236
238
  readonly high: number;
237
239
  }
238
240
 
239
- /** A drawn chart: the SVG document and its size in user units. */
240
- export interface ChartSvg {
241
- readonly svg: string;
242
- readonly width: number;
243
- readonly height: number;
244
- }
245
-
246
- /** The SVG document for `spec`. */
247
- export function renderChartSvg(spec: ChartSpec): ChartSvg {
241
+ /** The SVG document for `spec`, labeled for screen readers with {@link chartAlt}. */
242
+ export function renderChartSvg(spec: ChartSpec): string {
248
243
  const layer = drawChart(spec);
249
244
  const header = drawHeader(spec, layer);
250
245
  const head = matrixHead(spec);
@@ -268,8 +263,8 @@ export function renderChartSvg(spec: ChartSpec): ChartSvg {
268
263
  height += 20;
269
264
  }
270
265
  height = Math.ceil(height + PAD);
271
- const svg = [
272
- `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" font-family="${FONT}" font-size="${LABEL_SIZE}">`,
266
+ return [
267
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" role="img" aria-label="${escapeXml(chartAlt(spec))}" font-family="${FONT}" font-size="${LABEL_SIZE}">`,
273
268
  layer.body.includes(`url(#${INK_MASK})`) ? INK_DEFS : "",
274
269
  `<g transform="translate(0 ${PAD})">${header.body}</g>`,
275
270
  head.body && `<g transform="translate(0 ${num(PAD + header.height)})">${head.body}</g>`,
@@ -278,11 +273,10 @@ export function renderChartSvg(spec: ChartSpec): ChartSvg {
278
273
  caption,
279
274
  "</svg>",
280
275
  ].join("");
281
- return { svg, width: WIDTH, height };
282
276
  }
283
277
 
284
- /** One-line description of the chart, for screen readers and terminals that show the alt text. */
285
- export function chartAlt(spec: ChartSpec): string {
278
+ /** One-line description of the chart, for screen readers. */
279
+ function chartAlt(spec: ChartSpec): string {
286
280
  const joint =
287
281
  spec.kind === "stacked" ? " + " : spec.kind === "slope" ? " → " : spec.kind === "butterfly" ? " vs " : ", ";
288
282
  const names = spec.series.map(entry => entry.name).join(joint);
@@ -4432,6 +4426,13 @@ function token(name: string): string {
4432
4426
  return `var(--${name})`;
4433
4427
  }
4434
4428
 
4429
+ /** `raw` safe as XML text or a double-quoted attribute value. */
4430
+ function escapeXml(raw: string): string {
4431
+ return raw.replace(/[&<>"]/g, char =>
4432
+ char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : "&quot;",
4433
+ );
4434
+ }
4435
+
4435
4436
  function num(value: number): string {
4436
4437
  return Number(value.toFixed(1)).toString();
4437
4438
  }
@@ -4469,10 +4470,6 @@ function text(x: number, y: number, content: string | readonly TextRun[], style:
4469
4470
  style.weight ? `font-weight="${style.weight}"` : "",
4470
4471
  style.italic ? `font-style="italic"` : "",
4471
4472
  ].filter(Boolean);
4472
- const escape = (raw: string) =>
4473
- raw.replace(/[&<>"]/g, char =>
4474
- char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : "&quot;",
4475
- );
4476
4473
  const body = runs
4477
4474
  .filter(run => run.content)
4478
4475
  .map(run => {
@@ -4480,7 +4477,7 @@ function text(x: number, y: number, content: string | readonly TextRun[], style:
4480
4477
  run.fill ? ` fill="${token(run.fill)}"` : "",
4481
4478
  run.weight ? ` font-weight="${run.weight}"` : "",
4482
4479
  ].join("");
4483
- return own ? `<tspan${own}>${escape(run.content)}</tspan>` : escape(run.content);
4480
+ return own ? `<tspan${own}>${escapeXml(run.content)}</tspan>` : escapeXml(run.content);
4484
4481
  })
4485
4482
  .join("");
4486
4483
  return `<text ${attrs.join(" ")}>${body}</text>`;
@@ -39,7 +39,7 @@ import { formatTurnUsage, type TurnUsageSummary } from "../overlays/usage-row";
39
39
  import { FigureMarkdown } from "./figure-markdown";
40
40
  import { svgFigureRendering } from "./svg-figure";
41
41
  import { hasSvgFence } from "./svg-source";
42
- import { describeTableChart, hasChartTable, lookupTableChart, splitTableCharts } from "./table-chart";
42
+ import { chartFence, hasChartTable, lookupTableChart, splitTableCharts } from "./table-chart";
43
43
 
44
44
  /**
45
45
  * Max wrapped rows of a turn-ending provider error rendered inline in the
@@ -919,7 +919,7 @@ export class AssistantMessageComponent extends Container {
919
919
  children.push(markdown(`t${index}`, `t${index}`, linked(source, streaming), streaming));
920
920
  continue;
921
921
  }
922
- // A chart follows its table as an SVG image node; the prose splits around it.
922
+ // A chart follows its table as an ```svg figure the terminal colors; the prose splits around it.
923
923
  const segments = splitTableCharts(source, streaming);
924
924
  segments.forEach((segment, part) => {
925
925
  const slot = part === 0 ? `t${index}` : `t${index}.${part}`;
@@ -930,7 +930,7 @@ export class AssistantMessageComponent extends Container {
930
930
  }
931
931
  const chart = lookupTableChart(segment.table);
932
932
  if (chart instanceof Promise) this.#awaitChart(chart);
933
- else if (chart) children.push(describeTableChart(chart, slot));
933
+ else if (chart) children.push(markdown(slot, slot, chartFence(chart), false));
934
934
  });
935
935
  } else if (content.type === "thinking") {
936
936
  const display = resolveThinkingDisplay(content, this.#proseOnlyThinking);
@@ -5,24 +5,19 @@
5
5
  * and draws that chart — locally under `always`, through the host's
6
6
  * model-backed {@link TableChartPlanner} under `smart` for the tables
7
7
  * {@link shouldJudge} picks — and caches it per table source. {@link TableChartFigure} shows it
8
- * as a rasterized terminal image; {@link describeTableChart} hands a TSP
9
- * terminal the SVG itself. Both resolve the figure tokens against the live
10
- * theme, so a chart follows theme switches like the prose around it.
8
+ * as a terminal image rasterized in omp's theme; {@link chartFence} hands a
9
+ * TSP terminal the SVG as an ```svg fence, which it draws in the palette it
10
+ * shows. Either way a chart follows theme switches like the prose around it.
11
11
  */
12
12
  import { logger } from "@oh-my-pi/pi-utils";
13
13
  import type { Token, Tokens } from "@oh-my-pi/pi-utils/marked";
14
14
  import { type ChartPlan, buildChart, planChart, shouldJudge, worthCharting } from "../charts/chart-plan";
15
- import { chartAlt, renderChartSvg } from "../charts/chart-svg";
15
+ import { renderChartSvg } from "../charts/chart-svg";
16
16
  import { analyzeTable, type TableAnalysis } from "../charts/table-data";
17
17
  import type { ImageBudget } from "../components/image";
18
18
  import { lexDocument } from "../components/markdown";
19
- import { keyed } from "../native/describe";
20
- import { nativeImageNode } from "../native/blobs";
21
- import type { NativeNode } from "../native/node";
22
- import { getThemeEpoch } from "../theme";
23
19
  import type { Component } from "../tui";
24
- import { SvgFigure, svgFigurePalette } from "./svg-figure";
25
- import { prepareSvg } from "./svg-source";
20
+ import { SvgFigure } from "./svg-figure";
26
21
 
27
22
  /**
28
23
  * `smart`: a model refines the local guess where meaning decides (kind,
@@ -47,18 +42,11 @@ export interface TableChartRequest {
47
42
  */
48
43
  export type TableChartPlanner = (request: TableChartRequest) => Promise<ChartPlan | null>;
49
44
 
50
- /** A drawn chart, ready to resolve against the theme. */
51
- export interface TableChart {
52
- /** SVG colored with figure tokens (`var(--accent)`, …). */
53
- readonly svg: string;
54
- readonly alt: string;
55
- /** Size in SVG user units. */
56
- readonly width: number;
57
- readonly height: number;
58
- }
59
-
60
- /** The chart under a table: drawn, none, or a smart pick on its way (the promise settles once the cache holds it). */
61
- export type ChartLookup = TableChart | null | Promise<void>;
45
+ /**
46
+ * The chart under a table: its SVG, colored with figure tokens (`var(--accent)`, …)
47
+ * still to resolve; none; or a smart pick on its way (the promise settles once the cache holds it).
48
+ */
49
+ export type ChartLookup = string | null | Promise<void>;
62
50
 
63
51
  /** One run of a Markdown block: prose (ending at a charted table), or the chart drawn after that table. */
64
52
  export type ChartSegment =
@@ -67,8 +55,6 @@ export type ChartSegment =
67
55
 
68
56
  /** Tables cached per source; a long session keeps its recent charts without growing without bound. */
69
57
  const CACHE_LIMIT = 256;
70
- /** SVG user units per terminal column (16 per row on 1:2 cells), sizing native images. */
71
- const UNITS_PER_COLUMN = 8;
72
58
  /** A GFM delimiter row (`|---|:--:|`), the cheap test before lexing a block. */
73
59
  const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*\|/m;
74
60
 
@@ -78,20 +64,14 @@ interface ChartEntry {
78
64
  readonly guess: ChartPlan | undefined;
79
65
  /** The host's planner picks this table's chart. */
80
66
  readonly smart: boolean;
81
- /** `undefined` until drawn (or judged chartless). */
82
- chart?: TableChart | null;
67
+ /** The chart's SVG; `undefined` until drawn (or judged chartless). */
68
+ chart?: string | null;
83
69
  pending?: Promise<void>;
84
70
  }
85
71
 
86
72
  let chartMode: TableChartMode = "off";
87
73
  let chartPlanner: TableChartPlanner | undefined;
88
74
  const charts = new Map<string, ChartEntry>();
89
- /** The theme-resolved image node owning a chart's native bytes. */
90
- const kNativeImage = Symbol("tableChart.nativeImage");
91
-
92
- interface NativeTagged extends TableChart {
93
- [kNativeImage]?: { epoch: number; image: NativeNode };
94
- }
95
75
 
96
76
  /** Set how tables become charts (the host's `tui.autoGraph`) and its `smart` planner; the host rebuilds the transcript after. */
97
77
  export function setTableCharts(mode: TableChartMode, planner?: TableChartPlanner): void {
@@ -188,34 +168,21 @@ function chartEntry(table: Tokens.Table): ChartEntry {
188
168
  * Draw `plan`. A guess must also pass {@link worthCharting}; a model's pick
189
169
  * already judged the chart worth drawing.
190
170
  */
191
- function drawChart(analysis: TableAnalysis, plan: ChartPlan, guessed = false): TableChart | null {
171
+ function drawChart(analysis: TableAnalysis, plan: ChartPlan, guessed = false): string | null {
192
172
  const spec = buildChart(analysis, plan);
193
173
  if (!spec || (guessed && !worthCharting(spec))) return null;
194
- return { ...renderChartSvg(spec), alt: chartAlt(spec) };
174
+ return renderChartSvg(spec);
195
175
  }
196
176
 
197
177
  /**
198
- * A native `image` node drawing `chart` from its SVG — no raster — resolved
199
- * against the active theme; rebuilt when the theme changes.
178
+ * Chart `svg` as a Markdown ```svg fence, for a TSP terminal's `md` node. Its
179
+ * figure tokens stay unresolved: the terminal draws the fence as a figure in
180
+ * the palette it shows the prose in, so the chart cannot fall out of step with
181
+ * the page when omp's own idea of the appearance does, and a theme switch
182
+ * recolors it without a resend.
200
183
  */
201
- export function describeTableChart(chart: TableChart, key: string): NativeNode {
202
- const epoch = getThemeEpoch();
203
- const tagged: NativeTagged = chart;
204
- let entry = tagged[kNativeImage];
205
- if (entry?.epoch !== epoch) {
206
- const svg = prepareSvg(chart.svg, svgFigurePalette());
207
- entry = {
208
- epoch,
209
- image: nativeImageNode(new TextEncoder().encode(svg), "image/svg+xml", {
210
- alt: chart.alt,
211
- w: chart.width,
212
- h: chart.height,
213
- max: { w: `${Math.round(chart.width / UNITS_PER_COLUMN)}ch` },
214
- }),
215
- };
216
- tagged[kNativeImage] = entry;
217
- }
218
- return keyed(entry.image, key);
184
+ export function chartFence(svg: string): string {
185
+ return `\`\`\`svg\n${svg}\n\`\`\``;
219
186
  }
220
187
 
221
188
  export interface TableChartFigureOptions {
@@ -235,8 +202,8 @@ export class TableChartFigure implements Component {
235
202
  readonly #options: TableChartFigureOptions;
236
203
  readonly #figure: SvgFigure;
237
204
  #table: Tokens.Table | undefined;
238
- /** `undefined` while not looked up or waiting for a pick. */
239
- #chart: TableChart | null | undefined;
205
+ /** The chart's SVG; `undefined` while not looked up or waiting for a pick. */
206
+ #chart: string | null | undefined;
240
207
  #waiting = false;
241
208
  #disposed = false;
242
209
 
@@ -289,6 +256,6 @@ export class TableChartFigure implements Component {
289
256
  return;
290
257
  }
291
258
  this.#chart = found;
292
- if (found) this.#figure.update(found.svg, true);
259
+ if (found) this.#figure.update(found, true);
293
260
  }
294
261
  }
@@ -412,6 +412,16 @@ export class TranscriptContainer extends Container {
412
412
  return this.#entries.map(entry => entry.emitted);
413
413
  }
414
414
 
415
+ /** Whether any block is still streaming or otherwise unfinalized. */
416
+ hasActiveBlock(): boolean {
417
+ this.#syncEntries();
418
+ this.#settleFinalized();
419
+ for (let index = this.#frontier; index < this.#entries.length; index++) {
420
+ if (this.#entries[index]!.state === "active") return true;
421
+ }
422
+ return false;
423
+ }
424
+
415
425
  /** Whether visible active capacity and live-block memory permit another admission. */
416
426
  canAdmit(rows: number): boolean {
417
427
  const active = this.#entries.filter(entry => entry.state === "active").length;
@@ -4,6 +4,7 @@ import { fuzzyFilter } from "../fuzzy";
4
4
  import { getKeybindings } from "../keybindings";
5
5
  import { extractPrintableText } from "../keys";
6
6
  import type { MouseRoutable, SgrMouseEvent } from "../mouse";
7
+ import { slugify } from "@oh-my-pi/pi-utils";
7
8
  import type { TspPrefsControl, TspPrefsRow, TspPrefsSection, TspProps, TspSpan } from "@oh-my-pi/pi-wire";
8
9
  import { col, node, span } from "../native/describe";
9
10
  import { sameItems, sameProps } from "../native/memo";
@@ -78,10 +79,7 @@ export interface PrefsSectionRef {
78
79
 
79
80
  /** The section id for a section title (`"Status Line"` → `"status-line"`). */
80
81
  export function prefsSectionId(title: string): string {
81
- return title
82
- .toLowerCase()
83
- .replace(/[^a-z0-9]+/g, "-")
84
- .replace(/^-|-$/g, "");
82
+ return slugify(title);
85
83
  }
86
84
 
87
85
  /** A setting as a native settings row, given its typed control; `undefined` for headings. */
@@ -4,6 +4,10 @@ import type { ObservableSession } from "./session-observer-registry";
4
4
 
5
5
  export type AgentMetrics = AgentMetricsSummary;
6
6
 
7
+ export interface AgentCostSnapshot {
8
+ cost: number | undefined;
9
+ }
10
+
7
11
  export interface AggregateMetrics extends AgentMetrics {
8
12
  reportedAgents: number;
9
13
  /** Rows whose duration is an observer-measured active runtime. */
@@ -203,15 +207,28 @@ export function hubFallbackStatsSession<TRecord extends AgentRecordLike>(
203
207
  return session && typeof session.getSessionStats === "function" ? session : undefined;
204
208
  }
205
209
 
206
- function withDirectCost(metrics: AgentMetrics | undefined, cost: number | undefined): AgentMetrics | undefined {
210
+ /**
211
+ * Shared row policy for the Agent Hub and the status-line cost total.
212
+ * Non-cost metrics come from live progress, then persisted history, then the retained
213
+ * snapshot (parked rows only), then the live session. Cost always overlays the cumulative
214
+ * direct-session bill: live, then `history.directCost` (written at park), then retained.
215
+ * Persisted `history.metrics.cost` is a leaf-scoped snapshot, so it never wins.
216
+ */
217
+ function projectRowMetrics<TMetrics extends AgentCostSnapshot>(
218
+ ref: AgentRecordLike,
219
+ observed: ObservableSession | undefined,
220
+ live: TMetrics | undefined,
221
+ retained: TMetrics | undefined,
222
+ hasLiveSession: boolean,
223
+ ): TMetrics | AgentMetrics | undefined {
224
+ const cost = live?.cost ?? ref.history?.directCost ?? retained?.cost;
225
+ const metrics = observed?.progress
226
+ ? progressMetrics(observed)
227
+ : (ref.history?.metrics ?? (!hasLiveSession ? retained : undefined) ?? live);
207
228
  return metrics && cost !== undefined ? { ...metrics, cost } : metrics;
208
229
  }
209
230
 
210
- /**
211
- * Preserve progress/history for non-cost metrics, overlaying cumulative direct-session cost.
212
- * Persisted `history.metrics.cost` is a leaf-scoped snapshot, so it never overrides live
213
- * progress; only the cumulative direct-cost sources do.
214
- */
231
+ /** Full Agent Hub row metrics; see {@link projectRowMetrics} for source precedence. */
215
232
  export function hubRowMetrics<TRecord extends AgentRecordLike>(
216
233
  ref: TRecord,
217
234
  observed: ObservableSession | undefined,
@@ -220,12 +237,46 @@ export function hubRowMetrics<TRecord extends AgentRecordLike>(
220
237
  const session = hubFallbackStatsSession(ref, observed);
221
238
  const liveEntry = session ? sessionMetrics.get(session) : undefined;
222
239
  const retainedEntry = sessionMetrics.get(ref);
223
- const historyMetrics = ref.history?.metrics;
224
- const cost = liveEntry?.metrics?.cost ?? ref.history?.directCost ?? retainedEntry?.metrics?.cost;
225
- if (observed?.progress) return withDirectCost(progressMetrics(observed), cost);
226
- if (historyMetrics) return withDirectCost(historyMetrics, cost);
227
- if (!session && retainedEntry?.metrics) return withDirectCost(retainedEntry.metrics, cost);
228
- return withDirectCost(liveEntry?.metrics, cost);
240
+ return projectRowMetrics(ref, observed, liveEntry?.metrics, retainedEntry?.metrics, session !== undefined);
241
+ }
242
+
243
+ /** Cost-only projection for frequent status updates; full roster metrics keep their own cache. */
244
+ export function aggregateCost<TRecord extends AgentRecordLike>(args: {
245
+ rows: readonly TRecord[];
246
+ observedById: ReadonlyMap<string, ObservableSession>;
247
+ sessionCosts: WeakMap<object, AgentCostSnapshot>;
248
+ }): number {
249
+ let total = 0;
250
+ const countedSessions = new Set<NonNullable<AgentRecordLike["session"]>>();
251
+ for (const ref of args.rows) {
252
+ const observed = args.observedById.get(ref.id);
253
+ const session = hubFallbackStatsSession(ref, observed);
254
+ let cached = session ? args.sessionCosts.get(session) : undefined;
255
+ if (session) {
256
+ const directCost = readSessionDirectCost(session);
257
+ if (directCost !== undefined) {
258
+ cached = { cost: directCost };
259
+ args.sessionCosts.set(session, cached);
260
+ } else if (!cached || !fallbackReadCurrent(cached, session)) {
261
+ const stamp = fallbackReadStamp(session);
262
+ cached = { cost: readSessionMetrics(session, undefined)?.cost };
263
+ if (stamp) fallbackReadStamps.set(cached, stamp);
264
+ args.sessionCosts.set(session, cached);
265
+ }
266
+ if (cached?.cost !== undefined) args.sessionCosts.set(ref, cached);
267
+ }
268
+ const cost = projectRowMetrics(
269
+ ref,
270
+ observed,
271
+ cached?.cost !== undefined ? cached : undefined,
272
+ args.sessionCosts.get(ref),
273
+ session !== undefined,
274
+ )?.cost;
275
+ if (cost === undefined || (session && countedSessions.has(session))) continue;
276
+ if (session) countedSessions.add(session);
277
+ total += finiteMetric(cost);
278
+ }
279
+ return total;
229
280
  }
230
281
 
231
282
  export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
@@ -369,8 +369,9 @@ export class BtwHistoryPanel implements Component, Focusable {
369
369
  }
370
370
  handleInput(data: string): void {
371
371
  if (this.#composer) {
372
- // The input owns every key, including panel shortcuts and pasted text.
373
- this.#composer.input.handleInput(data);
372
+ // The input owns every key it handles, including panel shortcuts and
373
+ // pasted text; the line keys it ignores still scroll the answer above.
374
+ if (!this.#composer.input.handleInput(data)) this.#scrollDetail(data);
374
375
  this.#options.requestRender();
375
376
  return;
376
377
  }
@@ -417,31 +418,37 @@ export class BtwHistoryPanel implements Component, Focusable {
417
418
  else if (matchesKey(data, "home")) this.#select(0);
418
419
  else if (matchesKey(data, "end")) this.#select(this.#records.length - 1);
419
420
  else return;
420
- } else {
421
- const by: TspScrollBy | undefined = matchesSelectUp(data)
422
- ? "line-up"
423
- : matchesSelectDown(data)
424
- ? "line-down"
425
- : matchesSelectPageUp(data)
426
- ? "page-up"
427
- : matchesSelectPageDown(data)
428
- ? "page-down"
429
- : matchesKey(data, "home")
430
- ? "start"
431
- : matchesKey(data, "end")
432
- ? "end"
433
- : undefined;
434
- if (by === "line-up") this.#detail.scroll(-1);
435
- else if (by === "line-down") this.#detail.scroll(1);
436
- else if (by === "page-up") this.#detail.page(-1);
437
- else if (by === "page-down") this.#detail.page(1);
438
- else if (!this.#detail.handleScrollKey(data)) return;
439
- if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
440
- this.#followLatest = matchesKey(data, "end");
421
+ } else if (!this.#scrollDetail(data)) {
422
+ return;
441
423
  }
442
424
  this.#options.requestRender();
443
425
  }
444
426
 
427
+ /** Apply an answer-pane scroll key; a scroll stops following the newest row unless it is End. */
428
+ #scrollDetail(data: string): boolean {
429
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
430
+ ? "line-up"
431
+ : matchesSelectDown(data)
432
+ ? "line-down"
433
+ : matchesSelectPageUp(data)
434
+ ? "page-up"
435
+ : matchesSelectPageDown(data)
436
+ ? "page-down"
437
+ : matchesKey(data, "home")
438
+ ? "start"
439
+ : matchesKey(data, "end")
440
+ ? "end"
441
+ : undefined;
442
+ if (by === "line-up") this.#detail.scroll(-1);
443
+ else if (by === "line-down") this.#detail.scroll(1);
444
+ else if (by === "page-up") this.#detail.page(-1);
445
+ else if (by === "page-down") this.#detail.page(1);
446
+ else if (!this.#detail.handleScrollKey(data)) return false;
447
+ if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
448
+ this.#followLatest = matchesKey(data, "end");
449
+ return true;
450
+ }
451
+
445
452
  /** A large glass sheet (the list beside the reader), palette-sized while there is nothing to list. */
446
453
  get nativeOverlay(): NonNullable<Component["nativeOverlay"]> {
447
454
  return this.#records.length > 0 ? SHEET : EMPTY_SHEET;
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import type { TspPickerGroup, TspPickerItem, TspProps, TspSpan, TspTone } from "@oh-my-pi/pi-wire";
9
9
  import type { Component } from "../../tui";
10
- import { formatKeyHint } from "../../app-keybindings";
11
10
  import { node, span, stableKey } from "../../native/describe";
12
11
  import type { NativeNode } from "../../native/node";
13
12
  import { pickerFuzzyHits, pickerQuery } from "../../native/picker";
@@ -499,11 +498,10 @@ export class ExtensionList implements Component {
499
498
  ]
500
499
  : undefined,
501
500
  facts: { kind: this.#getKindLabel(ext.kind) },
502
- disabled: effectivelyDisabled
503
- ? masterDisabled
504
- ? "Provider disabled"
505
- : `Disabled · ${formatKeyHint("space")} to enable`
506
- : undefined,
501
+ // Only a disabled provider makes the row inert. An extension switched
502
+ // off by its own toggle must stay activatable, or the click/dblclick
503
+ // that turned it off could never turn it back on.
504
+ disabled: masterDisabled ? "Provider disabled" : undefined,
507
505
  };
508
506
  }
509
507
 
@@ -1101,6 +1101,9 @@ export class SettingsSelectorComponent implements Component {
1101
1101
  const open = list.openSubmenu;
1102
1102
  if (open?.id === id && open.component instanceof MultiSelectSubmenu) {
1103
1103
  open.component.setValues(value);
1104
+ // The native page applies a chip toggle without closing its
1105
+ // editor, so the row and its tab badge only update here.
1106
+ this.#refreshItems();
1104
1107
  return;
1105
1108
  }
1106
1109
  const next = [...value];