@underactive/pi-topping 0.6.4 → 0.7.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,6 +1,7 @@
1
- import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type DoneMarkerStyle, type SettingColor } from "./settings.ts";
3
+ import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
4
+ import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
4
5
  import { stripControlChars } from "./util.ts";
5
6
 
6
7
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
@@ -25,11 +26,15 @@ export interface PromptBoxDetails {
25
26
  icon?: string;
26
27
  provider?: string;
27
28
  model?: string;
28
- borderColor?: ThemeColor;
29
+ borderColor?: PromptBorderColor;
29
30
  borderStyle?: BorderStyle;
31
+ thinkingLevel?: ThinkingLevel;
30
32
  }
31
33
 
32
- type PromptTheme = { fg(color: string, text: string): string };
34
+ type PromptTheme = {
35
+ fg(color: string, text: string): string;
36
+ getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
37
+ };
33
38
 
34
39
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
35
40
 
@@ -51,15 +56,16 @@ export function buildCompletionMarkerLine(
51
56
  width: number,
52
57
  theme: PromptTheme,
53
58
  borderStyle: DoneMarkerBorderStyle,
54
- borderColor: SettingColor,
59
+ borderColor: DoneMarkerBorderColor,
55
60
  markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
61
+ thinkingLevel?: ThinkingLevel,
56
62
  ): string {
57
63
  const safeWidth = Math.max(0, Math.floor(width));
58
64
  if (safeWidth === 0) return "";
59
65
  if (borderStyle === "none") return truncateToWidth(content, safeWidth);
60
66
 
61
67
  const g = BORDER_GLYPHS[borderStyle];
62
- const border = (text: string) => theme.fg(borderColor, text);
68
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
63
69
  const prefix = `${g.bl}${g.h.repeat(2)} `;
64
70
 
65
71
  const suffix = markerStyle === "bookend"
@@ -81,8 +87,10 @@ export function buildPromptBoxLines(
81
87
 
82
88
  const borderStyle = isBorderStyle(options.borderStyle) ? options.borderStyle : "double";
83
89
  const g = BORDER_GLYPHS[borderStyle];
84
- const borderColor = isSettingColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
85
- const border = (text: string) => theme.fg(borderColor, text);
90
+ const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
91
+ const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
92
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
93
+ const iconColor = (text: string) => theme.fg("text", text);
86
94
  const label = (text: string) => theme.fg("customMessageLabel", text);
87
95
  const muted = (text: string) => theme.fg("dim", text);
88
96
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -113,7 +121,7 @@ export function buildPromptBoxLines(
113
121
  const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
114
122
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
115
123
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
116
- const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : "";
124
+ const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
117
125
  const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
118
126
  const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
119
127
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
@@ -138,16 +146,19 @@ export function buildPromptBoxLines(
138
146
  export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
139
147
  const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
140
148
  const details = (message.details ?? {}) as PromptBoxDetails;
141
- const linesByWidth = new Map<number, string[]>();
149
+ let cachedWidth: number | undefined;
150
+ let cachedLines: string[] | undefined;
142
151
  return {
143
152
  render(width: number): string[] {
144
- let lines = linesByWidth.get(width);
145
- if (!lines) {
146
- lines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
147
- linesByWidth.set(width, lines);
153
+ if (cachedWidth !== width) {
154
+ cachedWidth = width;
155
+ cachedLines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
148
156
  }
149
- return lines;
157
+ return cachedLines!;
158
+ },
159
+ invalidate() {
160
+ cachedWidth = undefined;
161
+ cachedLines = undefined;
150
162
  },
151
- invalidate() { linesByWidth.clear(); },
152
163
  };
153
164
  };
package/src/session.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  buildWorkingMessage,
19
19
  DEFAULT_WORKING_WORD,
20
20
  dimAttribute,
21
+ getThinkingLevelColorizer,
22
+ isThinkingLevel,
21
23
  ELAPSED_INTERVAL_MS,
22
24
  fadeThemeColorString,
23
25
  formatElapsed,
@@ -34,15 +36,16 @@ import {
34
36
  TOKEN_RATE_FADE_SHADE_COUNT,
35
37
  TOKEN_RATE_PLACEHOLDER,
36
38
  StreamingWordCounter,
39
+ type ThinkingLevel,
37
40
  } from "./format.ts";
38
41
  import { isSiblingSetupEnabled } from "./flags.ts";
39
42
  import { showMenu } from "./menu.ts";
40
43
  import { registerSetupCommand } from "./setup-command.ts";
41
- import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
44
+ import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
42
45
  import { notifyMissingToppingsOnce } from "./toppings.ts";
43
46
  import { PreviewRenderer } from "./preview.ts";
44
47
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
45
- import { stripControlChars } from "./util.ts";
48
+ import { modelsResemble, stripControlChars } from "./util.ts";
46
49
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
47
50
 
48
51
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -73,6 +76,7 @@ interface DoneEntryData {
73
76
  elapsedMs: number;
74
77
  tokens?: number;
75
78
  midTurnInputs?: number;
79
+ thinkingLevel?: ThinkingLevel;
76
80
  }
77
81
 
78
82
  interface SessionState {
@@ -86,6 +90,8 @@ interface SessionState {
86
90
  lastTokenRateSampledAt: number;
87
91
  lastTokenRateTotal: number;
88
92
  responseModel: string;
93
+ /** Last raw responseModel value seen, or the NOT_SENT sentinel before the first call. */
94
+ lastResponseModelRaw: unknown;
89
95
  responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
90
96
  responseModelFadeTimer: ReturnType<typeof setInterval> | null;
91
97
  responseModelFadeGeneration: number;
@@ -123,6 +129,7 @@ function makeFreshState(): SessionState {
123
129
  lastTokenRateSampledAt: 0,
124
130
  lastTokenRateTotal: 0,
125
131
  responseModel: "",
132
+ lastResponseModelRaw: NOT_SENT,
126
133
  responseModelHoldTimer: null,
127
134
  responseModelFadeTimer: null,
128
135
  responseModelFadeGeneration: 0,
@@ -148,6 +155,7 @@ export class SessionManager {
148
155
  #settings = loadSettings();
149
156
  #userPacks: WordPack[] = [];
150
157
  #bundledPacks = loadBundledWordPacks();
158
+ #allPacks: WordPack[] = [...this.#bundledPacks];
151
159
  #currentCtx: ExtensionContext | null = null;
152
160
  readonly #pi: ExtensionAPI;
153
161
 
@@ -163,6 +171,7 @@ export class SessionManager {
163
171
  this.#state = makeFreshState();
164
172
  this.#settings = loadSettings();
165
173
  this.#userPacks = loadUserWordPacks();
174
+ this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
166
175
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
167
176
  if (this.usable(ctx)) {
168
177
  this.applyIndicator(ctx);
@@ -194,6 +203,7 @@ export class SessionManager {
194
203
  model: ctx.model?.id,
195
204
  borderColor: this.#settings.decorations.borderColor,
196
205
  borderStyle: this.#settings.decorations.borderStyle,
206
+ thinkingLevel: ctx.thinkingLevel,
197
207
  },
198
208
  },
199
209
  { triggerTurn: true },
@@ -212,6 +222,7 @@ export class SessionManager {
212
222
  if (!this.usable(ctx)) return;
213
223
  if (!this.#state.startTime) this.resetTurn(Date.now());
214
224
  this.#state.busy = true;
225
+ if (!this.#state.waiting) this.applyIndicator(ctx);
215
226
  this.startTimer();
216
227
  this.tick();
217
228
  };
@@ -227,19 +238,17 @@ export class SessionManager {
227
238
 
228
239
  #onMessageUpdate = async (event: MessageUpdateEvent, ctx: ExtensionContext): Promise<void> => {
229
240
  this.#currentCtx = ctx;
241
+ if (!this.usable(ctx)) return;
230
242
  const assistantEvent = event.assistantMessageEvent;
231
243
  if (
232
- this.usable(ctx) &&
233
244
  assistantEvent &&
234
245
  (assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta")
235
246
  ) {
236
247
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
237
248
  }
238
- if (this.usable(ctx)) {
239
- const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
240
- ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
241
- this.updateResponseModel(rawResponseModel);
242
- }
249
+ const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
250
+ ?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
251
+ this.updateResponseModel(rawResponseModel);
243
252
  };
244
253
 
245
254
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
@@ -314,6 +323,7 @@ export class SessionManager {
314
323
  elapsedMs,
315
324
  tokens: this.#state.confirmTokens,
316
325
  midTurnInputs: this.#state.midTurnInputs,
326
+ thinkingLevel: ctx.thinkingLevel,
317
327
  });
318
328
  }
319
329
  this.#currentCtx = null;
@@ -331,8 +341,8 @@ export class SessionManager {
331
341
  const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
332
342
  const features = this.#settings.features;
333
343
  const details: string[] = [];
334
- if (features.doneMarkerTokens && typeof entry.data.tokens === "number") details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
335
- if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && entry.data.midTurnInputs) {
344
+ if (features.doneMarkerTokens && typeof entry.data.tokens === "number" && Number.isFinite(entry.data.tokens) && entry.data.tokens >= 0) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
345
+ if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && Number.isInteger(entry.data.midTurnInputs) && entry.data.midTurnInputs > 0) {
336
346
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
337
347
  }
338
348
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
@@ -340,6 +350,7 @@ export class SessionManager {
340
350
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
341
351
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
342
352
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
353
+ const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
343
354
  if (borderStyle === "none") return new Text(markerContent);
344
355
 
345
356
  let cachedWidth: number | undefined;
@@ -348,7 +359,7 @@ export class SessionManager {
348
359
  render(width: number): string[] {
349
360
  if (cachedWidth !== width) {
350
361
  cachedWidth = width;
351
- cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle), ""];
362
+ cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle, thinkingLevel), ""];
352
363
  }
353
364
  return cachedLines!;
354
365
  },
@@ -407,11 +418,6 @@ export class SessionManager {
407
418
  return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
408
419
  }
409
420
 
410
- private spinnerColor(): SettingColor {
411
- const decorations = this.#settings.decorations;
412
- return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
413
- }
414
-
415
421
  private indicatorFingerprint(): string {
416
422
  const decorations = this.#settings.decorations;
417
423
  return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
@@ -430,12 +436,10 @@ export class SessionManager {
430
436
  return;
431
437
  }
432
438
 
433
- const color = this.spinnerColor();
434
- if (color === "accent") {
435
- ctx.ui.setWorkingIndicator(undefined);
436
- return;
437
- }
438
- ctx.ui.setWorkingIndicator({ frames: SPINNER_FRAMES.map((frame) => ctx.ui.theme.fg(color, frame)) });
439
+ const color = this.#settings.decorations.spinnerColor;
440
+ ctx.ui.setWorkingIndicator({
441
+ frames: SPINNER_FRAMES.map((frame) => getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel)(frame)),
442
+ });
439
443
  }
440
444
 
441
445
  private stopTimer(): void {
@@ -446,7 +450,17 @@ export class SessionManager {
446
450
  }
447
451
 
448
452
  private updateResponseModel(raw: unknown): void {
453
+ if (raw === this.#state.lastResponseModelRaw) return;
454
+ this.#state.lastResponseModelRaw = raw;
449
455
  const responseModel = typeof raw === "string" ? stripControlChars(raw).trim() : "";
456
+ const selectedModel = this.#currentCtx?.model?.id;
457
+ if (responseModel && modelsResemble(selectedModel, responseModel)) {
458
+ if (this.#state.responseModel) {
459
+ this.#state.responseModel = "";
460
+ this.tick();
461
+ }
462
+ return;
463
+ }
450
464
  if (responseModel && responseModel !== this.#state.responseModel) {
451
465
  this.#state.responseModel = responseModel;
452
466
  this.tick();
@@ -470,13 +484,19 @@ export class SessionManager {
470
484
  }
471
485
  }
472
486
 
473
- private startResponseModelFade(ctx: ExtensionContext, model: string, color: SettingColor, dimmed: boolean): void {
487
+ private startResponseModelFade(ctx: ExtensionContext, model: string, color: ThinkingLevelColor, dimmed: boolean): void {
474
488
  this.cancelResponseModelFade(ctx);
475
489
  const generation = this.#state.responseModelFadeGeneration;
490
+ const colorizer = getThinkingLevelColorizer(ctx.ui.theme, color, ctx.thinkingLevel);
476
491
  const render = (shade?: number): void => {
477
492
  const colored = shade === undefined
478
- ? ctx.ui.theme.fg(color, model)
479
- : fadeThemeColorString(model, shade, ctx.ui.theme, color);
493
+ ? colorizer(model)
494
+ : fadeThemeColorString(
495
+ model,
496
+ shade,
497
+ ctx.ui.theme,
498
+ colorizer,
499
+ );
480
500
  const responseModel = dimmed ? dimAttribute(colored) : colored;
481
501
  ctx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, responseModel);
482
502
  };
@@ -511,7 +531,7 @@ export class SessionManager {
511
531
  }
512
532
 
513
533
  private pickWorkingWord(): WorkingTextSelection {
514
- return pickWorkingTextSelection(this.#settings.wordPacks, [...this.#bundledPacks, ...this.#userPacks]);
534
+ return pickWorkingTextSelection(this.#settings.wordPacks, this.#allPacks);
515
535
  }
516
536
 
517
537
  private resetTurn(now: number): void {
@@ -523,6 +543,7 @@ export class SessionManager {
523
543
  state.confirmTokens = 0;
524
544
  state.liveTokens = 0;
525
545
  state.responseModel = "";
546
+ state.lastResponseModelRaw = NOT_SENT;
526
547
  state.activityMeter.reset();
527
548
  resetTokenRateState(state);
528
549
  state.midTurnInputs = 0;
@@ -557,10 +578,10 @@ export class SessionManager {
557
578
  state.lastTokenRateSampledAt = now;
558
579
  }
559
580
  const spinner = this.spinnerInMessage()
560
- ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
581
+ ? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
561
582
  : "";
562
583
  const responseModelColored = features.responseModel && state.responseModel
563
- ? ctx.ui.theme.fg(decorations.responseModelColor, state.responseModel)
584
+ ? getThinkingLevelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel)(state.responseModel)
564
585
  : "";
565
586
  const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
566
587
  if (isFullyDefaultAppearance(features, decorations)) {
@@ -578,17 +599,13 @@ export class SessionManager {
578
599
  const styled = decorations.shimmer
579
600
  ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
580
601
  : ctx.ui.theme.fg("text", word);
581
- const meter = decorations.tokenActivityMonitor
582
- ? state.activityMeter.render((level, char) =>
583
- ActivityMeter.colorizeCell(
584
- level,
585
- char,
586
- ctx.ui.theme,
587
- decorations.meterColorEnabled ? decorations.meterColor : "accent",
588
- decorations.meterDimmed,
589
- ),
590
- )
591
- : "";
602
+ let meter = "";
603
+ if (decorations.tokenActivityMonitor) {
604
+ const meterColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.meterColor, ctx.thinkingLevel);
605
+ meter = state.activityMeter.render((level, char) =>
606
+ ActivityMeter.colorizeCell(level, char, ctx.ui.theme, meterColorizer, decorations.meterDimmed),
607
+ );
608
+ }
592
609
  let tokenRateText = "";
593
610
  if (features.tokenRate) {
594
611
  const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
@@ -600,18 +617,22 @@ export class SessionManager {
600
617
  }
601
618
  tokenRateText = state.tokenRateText;
602
619
  }
603
- const tokenRateSegment = !features.tokenRate
604
- ? ""
605
- : !tokenRateText
606
- ? ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER)
607
- : now < state.tokenRateFadeStartsAt
608
- ? ctx.ui.theme.fg(decorations.tokenRateColor, tokenRateText)
620
+ let tokenRateSegment = "";
621
+ if (features.tokenRate) {
622
+ if (!tokenRateText) {
623
+ tokenRateSegment = ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
624
+ } else {
625
+ const tokenRateColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.tokenRateColor, ctx.thinkingLevel);
626
+ tokenRateSegment = now < state.tokenRateFadeStartsAt
627
+ ? tokenRateColorizer(tokenRateText)
609
628
  : fadeThemeColorString(
610
629
  tokenRateText,
611
630
  Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
612
631
  ctx.ui.theme,
613
- decorations.tokenRateColor,
632
+ tokenRateColorizer,
614
633
  );
634
+ }
635
+ }
615
636
  const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
616
637
  const msg = buildWorkingMessage(
617
638
  ctx.ui.theme,
@@ -639,8 +660,9 @@ export class SessionManager {
639
660
  }
640
661
 
641
662
  this.#userPacks = loadUserWordPacks();
663
+ this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
642
664
  const before = this.indicatorFingerprint();
643
- const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
665
+ const preview = new PreviewRenderer(ctx, this.#allPacks);
644
666
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
645
667
  title: "Pi Topping: Settings",
646
668
  maxHeight: "75%",