@underactive/pi-topping 0.12.0 → 0.13.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.
@@ -1,18 +1,16 @@
1
1
  import type { EntryRenderer, MessageRenderer, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
3
- import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
3
+ import { thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
4
+ import { BOX_STYLES, type BoxGlyphs } from "@underactive/pi-topping-primitives/glyphs";
5
+ import { isThinkingLevel, type ThinkingLevel } from "./format.ts";
4
6
  import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
5
7
  import { stripControlChars } from "./util.ts";
6
8
 
7
9
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
8
10
  export const PROMPT_BOX_ENTRY_TYPE = "pi-topping-prompt-entry";
9
11
 
10
- const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
11
- double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
12
- single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
13
- rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
14
- heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
15
- };
12
+ // Typed against this repo's BorderStyle, so a style added to settings.ts without a glyph set fails the typecheck.
13
+ const BORDER_GLYPHS: Readonly<Record<BorderStyle, BoxGlyphs>> = BOX_STYLES;
16
14
 
17
15
  /**
18
16
  * False visibility options hide each corresponding part; present provider and
@@ -79,12 +77,12 @@ export function buildCompletionMarkerLine(
79
77
  if (borderStyle === "none") return truncateToWidth(content, safeWidth);
80
78
 
81
79
  const g = BORDER_GLYPHS[borderStyle];
82
- const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
83
- const prefix = `${g.bl}${g.h.repeat(2)} `;
80
+ const border = thinkingLevelColorizer(theme, borderColor, thinkingLevel);
81
+ const prefix = `${g.bottomLeft}${g.horizontal.repeat(2)} `;
84
82
 
85
83
  const suffix = markerStyle === "bookend"
86
- ? ` ${g.h.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.br}`
87
- : ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
84
+ ? ` ${g.horizontal.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.bottomRight}`
85
+ : ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.horizontal.repeat(length)).join(" ")}`;
88
86
  const line = `${border(prefix)}${content}${border(suffix)}`;
89
87
  return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line;
90
88
  }
@@ -103,7 +101,7 @@ export function buildPromptBoxLines(
103
101
  const g = BORDER_GLYPHS[borderStyle];
104
102
  const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
105
103
  const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
106
- const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
104
+ const border = thinkingLevelColorizer(theme, borderColor, thinkingLevel);
107
105
  const iconColor = (text: string) => theme.fg("text", text);
108
106
  const muted = (text: string) => theme.fg("dim", text);
109
107
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -131,12 +129,12 @@ export function buildPromptBoxLines(
131
129
  const labelText = combinedLabel
132
130
  ? truncateToWidth(combinedLabel, Math.max(0, innerWidth - 3)).replace(/\x1b\[0m/g, "")
133
131
  : "";
134
- const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
132
+ const titleLength = icon ? visibleWidth(`${g.horizontal}${g.horizontal} ${icon} `) : 0;
135
133
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
136
134
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
137
- const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
138
- const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
139
- const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
135
+ const iconSeg = icon ? `${border(`${g.horizontal}${g.horizontal} `)}${iconColor(icon)}${border(" ")}` : "";
136
+ const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.horizontal}`)}` : "";
137
+ const topLine = border(g.topLeft) + iconSeg + border(g.horizontal.repeat(topFill)) + timeSeg + border(g.topRight);
140
138
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
141
139
 
142
140
  if (content) {
@@ -144,14 +142,14 @@ export function buildPromptBoxLines(
144
142
  for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
145
143
  const displayLine = rawLine || " ";
146
144
  const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
147
- const line = `${border(g.v)}${bodyText(` ${padded} `)}${border(g.v)}`;
145
+ const line = `${border(g.vertical)}${bodyText(` ${padded} `)}${border(g.vertical)}`;
148
146
  lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
149
147
  }
150
148
  }
151
149
 
152
150
  const labelLength = labelText ? visibleWidth(labelText) + 3 : 0; // label + right-side " ═"
153
- const labelSeg = labelText ? `${border(" ")}${muted(labelText)}${border(` ${g.h}`)}` : "";
154
- const bottomLine = `${border(g.bl)}${border(g.h.repeat(Math.max(0, innerWidth - labelLength)))}${labelSeg}${border(g.br)}`;
151
+ const labelSeg = labelText ? `${border(" ")}${muted(labelText)}${border(` ${g.horizontal}`)}` : "";
152
+ const bottomLine = `${border(g.bottomLeft)}${border(g.horizontal.repeat(Math.max(0, innerWidth - labelLength)))}${labelSeg}${border(g.bottomRight)}`;
155
153
  lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
156
154
  return lines;
157
155
  }
package/src/session.ts CHANGED
@@ -15,16 +15,26 @@ import type {
15
15
  Theme,
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import { VERSION } from "@earendil-works/pi-coding-agent";
18
+ import { CHANNELS, PI_VIRTUAL_MODEL_API, WORKING_STATUS_SLIDE_MS, subscribe } from "@underactive/pi-topping-primitives/contracts";
18
19
  import { Text, type Component } from "@earendil-works/pi-tui";
19
- import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
20
- import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./routesense-feed.ts";
20
+ import {
21
+ ActivityMeter,
22
+ FADE_SHADE_COUNT,
23
+ formatElapsedUnits,
24
+ RateHoldFade,
25
+ rateToLevel,
26
+ slideOutTail,
27
+ StreamingWordCounter,
28
+ } from "@underactive/pi-topping-primitives/activity";
21
29
  import {
22
30
  dimAttribute,
23
- getThinkingLevelColorizer,
31
+ fadeThemeColorString,
32
+ renderMeter,
33
+ thinkingLevelColorizer,
34
+ } from "@underactive/pi-topping-primitives/activity-tui";
35
+ import {
24
36
  isThinkingLevel,
25
37
  ELAPSED_INTERVAL_MS,
26
- fadeThemeColorString,
27
- formatElapsed,
28
38
  formatTokenRate,
29
39
  formatTokens,
30
40
  isFullyDefaultAppearance,
@@ -32,22 +42,17 @@ import {
32
42
  RESPONSE_MODEL_FADE_MS,
33
43
  RESPONSE_MODEL_HOLD_MS,
34
44
  RESPONSE_MODEL_SLIDE_FRAME_MS,
35
- RESPONSE_MODEL_SLIDE_MS,
36
45
  SHIMMER_INTERVAL_MS,
37
- slideOutTail,
38
46
  SPINNER_FRAME_MS,
39
47
  SPINNER_FRAMES,
40
- TOKEN_RATE_FADE_SHADE_COUNT,
41
- StreamingWordCounter,
42
48
  type ThinkingLevel,
43
49
  } from "./format.ts";
44
50
  import { buildLoaderMessage } from "./loader-message.ts";
45
- import { showMenu } from "./menu.ts";
51
+ import { showMenu } from "@underactive/pi-topping-primitives/menu";
46
52
  import { applyOutputStyle, OUTPUT_STYLES } from "./output-style.ts";
47
53
  import { getResponseModelColorizer } from "./nvidia-green.ts";
48
54
  import { registerSetupCommand } from "./setup-command.ts";
49
55
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
50
- import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.ts";
51
56
  import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
52
57
  import { PreviewRenderer } from "./preview.ts";
53
58
  import {
@@ -81,8 +86,6 @@ type UIPromptKind = UIPromptStartEvent["kind"];
81
86
  export const MIN_HOST_VERSION = "1.2.0";
82
87
  const DONE_ENTRY_TYPE = "pi-topping-done";
83
88
  const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
84
- /** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
85
- const VIRTUAL_MODEL_API = "pi-virtual";
86
89
  const WAITING_LABELS: Record<UIPromptKind, string> = {
87
90
  select: "Waiting for selection",
88
91
  confirm: "Waiting for confirmation",
@@ -129,9 +132,7 @@ interface SessionState {
129
132
  liveTokens: number;
130
133
  shimmerOrigin: number;
131
134
  activityMeter: ActivityMeter;
132
- rateTracker: TokRateTracker;
133
- lastTokenRateSampledAt: number;
134
- lastTokenRateTotal: number;
135
+ tokenRate: RateHoldFade;
135
136
  responseModel: string;
136
137
  served: { model: string; backend: string; routedId: string } | null;
137
138
  /** The backend companion to the last raw response-model report. */
@@ -147,8 +148,6 @@ interface SessionState {
147
148
  /** When the response model appeared in the loader and began sliding out; 0 if it never slid. */
148
149
  responseModelShownAt: number;
149
150
  responseModelSlideTimer: ReturnType<typeof setInterval> | null;
150
- tokenRateText: string;
151
- tokenRateFadeStartsAt: number;
152
151
  timer: ReturnType<typeof setInterval> | null;
153
152
  busy: boolean;
154
153
  waiting: { kind: UIPromptKind; title?: string } | null;
@@ -160,15 +159,6 @@ interface SessionState {
160
159
  /** Sentinel distinguishing "never called setWorkingMessage()" from an explicit `undefined` message. */
161
160
  const NOT_SENT = Symbol("not-sent");
162
161
 
163
- /** Reset all token-rate tracking fields on `state` to their fresh-turn values. */
164
- function resetTokenRateState(state: SessionState): void {
165
- state.rateTracker.reset();
166
- state.lastTokenRateSampledAt = 0;
167
- state.lastTokenRateTotal = 0;
168
- state.tokenRateText = "";
169
- state.tokenRateFadeStartsAt = 0;
170
- }
171
-
172
162
  function makeFreshState(): SessionState {
173
163
  return {
174
164
  startTime: 0,
@@ -177,9 +167,12 @@ function makeFreshState(): SessionState {
177
167
  liveTokens: 0,
178
168
  shimmerOrigin: 0,
179
169
  activityMeter: new ActivityMeter(),
180
- rateTracker: new TokRateTracker(),
181
- lastTokenRateSampledAt: 0,
182
- lastTokenRateTotal: 0,
170
+ tokenRate: new RateHoldFade({
171
+ holdMs: TOKEN_RATE_HOLD_MS,
172
+ fadeMs: TOKEN_RATE_FADE_MS,
173
+ shadeCount: FADE_SHADE_COUNT,
174
+ sampleIntervalMs: METER_INTERVAL_MS,
175
+ }),
183
176
  responseModel: "",
184
177
  served: null,
185
178
  lastResponseModelBackend: NOT_SENT,
@@ -191,8 +184,6 @@ function makeFreshState(): SessionState {
191
184
  responseModelFadeGeneration: 0,
192
185
  responseModelShownAt: 0,
193
186
  responseModelSlideTimer: null,
194
- tokenRateText: "",
195
- tokenRateFadeStartsAt: 0,
196
187
  timer: null,
197
188
  busy: false,
198
189
  waiting: null,
@@ -334,11 +325,9 @@ export class SessionManager {
334
325
  typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0 ? rawTokens : undefined;
335
326
  this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
336
327
  if (exactTokens !== undefined) {
337
- // Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
328
+ // Only restart the sampling here, not the held rate or its fade:
338
329
  // those must keep holding/fading across message boundaries to avoid flicker.
339
- this.#state.rateTracker.reset();
340
- this.#state.lastTokenRateSampledAt = 0;
341
- this.#state.lastTokenRateTotal = this.#state.confirmTokens;
330
+ this.#state.tokenRate.resetSampling(this.#state.confirmTokens);
342
331
  }
343
332
  this.#state.liveTokens = 0;
344
333
  this.#counter.reset();
@@ -407,7 +396,7 @@ export class SessionManager {
407
396
  ctx.ui.setWorkingMessage();
408
397
  if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
409
398
  this.#state.activityMeter.reset();
410
- resetTokenRateState(this.#state);
399
+ this.#state.tokenRate.reset();
411
400
  this.#state.lastMessage = NOT_SENT;
412
401
  if (this.#settings.features.doneMarker && hadPrompt) {
413
402
  const aborted = e.aborted === true;
@@ -451,10 +440,10 @@ export class SessionManager {
451
440
  const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
452
441
  const rawModel = sanitizedText(entry.data.model);
453
442
  const modelColored = features.doneMarkerModel && rawModel
454
- ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
443
+ ? thinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
455
444
  : "";
456
445
  const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
457
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model, aborted ? "warning" : "dim");
446
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsedUnits(elapsedMs), details, model, aborted ? "warning" : "dim");
458
447
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
459
448
  const borderColor = aborted ? "warning" : this.#settings.decorations.doneMarkerBorderColor;
460
449
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
@@ -491,14 +480,13 @@ export class SessionManager {
491
480
  this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
492
481
  this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
493
482
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
494
- this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
495
- this.#loaderInStatusline = announcesEmbedded(data);
483
+ subscribe(this.#pi.events, CHANNELS.workingStatusEmbedded, ({ embedded }) => {
484
+ this.#loaderInStatusline = embedded;
496
485
  });
497
- this.#pi.events.on(ROUTED_MODEL_CHANNEL, (data) => {
486
+ subscribe(this.#pi.events, CHANNELS.routedModel, (announcement) => {
498
487
  // Direct routes serve compaction and other extensions' side requests; a route while idle
499
488
  // is such a background request too, and must not leave a stale model for the next turn.
500
- const announcement = routedModelAnnouncement(data);
501
- if (!announcement || announcement.reason === "direct" || !this.#state.busy) return;
489
+ if (announcement.reason === "direct" || !this.#state.busy) return;
502
490
  this.updateResponseModel({ raw: announcement.modelId, weak: true, provider: announcement.provider, routedId: announcement.modelId });
503
491
  });
504
492
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
@@ -597,7 +585,7 @@ export class SessionManager {
597
585
 
598
586
  const color = this.#settings.decorations.spinnerColor;
599
587
  ctx.ui.setWorkingIndicator({
600
- frames: SPINNER_FRAMES.map((frame) => getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
588
+ frames: SPINNER_FRAMES.map((frame) => thinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
601
589
  });
602
590
  }
603
591
 
@@ -618,7 +606,7 @@ export class SessionManager {
618
606
  if (message.responseModel !== undefined) {
619
607
  return { raw: message.responseModel, weak: false, routedId: sanitizedText(message.model) || undefined };
620
608
  }
621
- if (this.#currentCtx?.model?.api !== VIRTUAL_MODEL_API) return undefined;
609
+ if (this.#currentCtx?.model?.api !== PI_VIRTUAL_MODEL_API) return undefined;
622
610
  return { raw: message.model, weak: true, provider: typeof message.provider === "string" ? message.provider : undefined };
623
611
  }
624
612
 
@@ -632,7 +620,7 @@ export class SessionManager {
632
620
  private restatesSelection(report: ResponseModelReport, responseModel: string): boolean {
633
621
  const selected = this.#currentCtx?.model;
634
622
  if (!selected) return false;
635
- if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
623
+ if (selected.api === PI_VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
636
624
  return modelsResemble(selected.id, responseModel) || modelsSpelledAlike(selected, responseModel);
637
625
  }
638
626
 
@@ -684,7 +672,7 @@ export class SessionManager {
684
672
  state.responseModelShownAt = Date.now();
685
673
  state.responseModelSlideTimer = setInterval(() => {
686
674
  this.tick();
687
- if (Date.now() - state.responseModelShownAt >= RESPONSE_MODEL_SLIDE_MS) this.stopResponseModelSlide();
675
+ if (Date.now() - state.responseModelShownAt >= WORKING_STATUS_SLIDE_MS) this.stopResponseModelSlide();
688
676
  }, RESPONSE_MODEL_SLIDE_FRAME_MS);
689
677
  }
690
678
 
@@ -737,12 +725,12 @@ export class SessionManager {
737
725
  render(shade++);
738
726
  this.#state.responseModelFadeTimer = setInterval(() => {
739
727
  if (this.#state.responseModelFadeGeneration !== generation) return;
740
- if (shade >= TOKEN_RATE_FADE_SHADE_COUNT) {
728
+ if (shade >= FADE_SHADE_COUNT) {
741
729
  this.cancelResponseModelFade(ctx);
742
730
  return;
743
731
  }
744
732
  render(shade++);
745
- }, RESPONSE_MODEL_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT);
733
+ }, RESPONSE_MODEL_FADE_MS / FADE_SHADE_COUNT);
746
734
  }, RESPONSE_MODEL_HOLD_MS);
747
735
  }
748
736
 
@@ -779,7 +767,7 @@ export class SessionManager {
779
767
  state.lastResponseModelRaw = NOT_SENT;
780
768
  state.lastResponseModelBackend = NOT_SENT;
781
769
  state.activityMeter.reset();
782
- resetTokenRateState(state);
770
+ state.tokenRate.reset();
783
771
  state.midTurnInputs = 0;
784
772
  state.lastMessage = NOT_SENT;
785
773
  this.#counter.reset();
@@ -802,27 +790,15 @@ export class SessionManager {
802
790
  const total = state.confirmTokens + state.liveTokens;
803
791
  const features = this.#settings.features;
804
792
  const decorations = this.#settings.decorations;
805
- let hasNewTokenCount = false;
806
- if ((decorations.tokenActivityMonitor || features.tokenRate) && now - state.lastTokenRateSampledAt >= METER_INTERVAL_MS) {
807
- // Do not reset the fade for an EMA-only decay while output is quiet.
808
- hasNewTokenCount = total > state.lastTokenRateTotal;
809
- state.lastTokenRateTotal = total;
810
- const tokenRate = state.rateTracker.sample(total, now);
811
- if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate, decorations.meterUpperBoundTps));
812
- state.lastTokenRateSampledAt = now;
793
+ // RateHoldFade samples at most once per METER_INTERVAL_MS and holds a rate only when the
794
+ // total grew, so an EMA-only decay while output is quiet does not reset the fade.
795
+ if ((decorations.tokenActivityMonitor || features.tokenRate) && state.tokenRate.sample(total, now) && decorations.tokenActivityMonitor) {
796
+ state.activityMeter.push(rateToLevel(state.tokenRate.tracker.tokenRate, decorations.meterUpperBoundTps));
813
797
  }
814
798
  const responseModelShown = features.responseModel && state.responseModel
815
799
  ? slideOutTail(state.responseModel, now - state.responseModelShownAt)
816
800
  : "";
817
- if (features.tokenRate) {
818
- const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
819
- if (latestTokenRate && hasNewTokenCount) {
820
- state.tokenRateText = latestTokenRate;
821
- state.tokenRateFadeStartsAt = now + TOKEN_RATE_HOLD_MS;
822
- } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
823
- state.tokenRateText = "";
824
- }
825
- }
801
+ const tokenRate = state.tokenRate.display(now);
826
802
  const showSpinner = this.spinnerInMessage();
827
803
  const msg = isFullyDefaultAppearance(features, decorations) && !showSpinner && !responseModelShown
828
804
  ? undefined
@@ -837,15 +813,11 @@ export class SessionManager {
837
813
  spinnerAtMs: showSpinner ? now : undefined,
838
814
  word: state.workingText.text,
839
815
  shimmerAtMs: now - state.shimmerOrigin,
840
- renderMeter: (colorizer, dimmed) => state.activityMeter.render((level, char) =>
841
- ActivityMeter.colorizeCell(level, char, ctx.ui.theme, colorizer, dimmed),
842
- ),
816
+ renderMeter: (colorizer, dimmed) => renderMeter(state.activityMeter, ctx.ui.theme, colorizer, dimmed),
843
817
  elapsedMs: now - state.startTime,
844
818
  outputTokens: total,
845
- tokenRateText: state.tokenRateText,
846
- tokenRateShade: now < state.tokenRateFadeStartsAt
847
- ? undefined
848
- : Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
819
+ tokenRateText: features.tokenRate && tokenRate.rate !== null ? formatTokenRate(tokenRate.rate) : "",
820
+ tokenRateShade: tokenRate.phase === "fading" ? tokenRate.fadeShade : undefined,
849
821
  responseModel: responseModelShown,
850
822
  },
851
823
  );
@@ -866,19 +838,17 @@ export class SessionManager {
866
838
  const before = this.indicatorFingerprint();
867
839
  const styleBefore = this.#settings.outputStyle;
868
840
  const preview = new PreviewRenderer(ctx, this.#allPacks);
869
- const result = await showMenu<Record<string, boolean | string>>(ctx, {
841
+ const result = await showMenu(ctx, {
870
842
  title: "Pi Topping: Settings",
871
- maxHeight: "75%",
843
+ layout: { maxHeight: "75%" },
872
844
  sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
873
845
  hints: ["↑↓ move", "PgUp/PgDn page", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
874
- preview: preview.render.bind(preview),
846
+ preview,
875
847
  });
876
848
  if (!result.applied) return;
877
849
 
878
850
  const updatedSettings = applyMenuResult(this.#settings, result.values);
879
- try {
880
- saveSettings(updatedSettings);
881
- } catch {
851
+ if (!saveSettings(updatedSettings).ok) {
882
852
  ctx.ui.notify("Failed to save Pi Topping settings", "error");
883
853
  return;
884
854
  }