@adeildo/pi-look 4.1.0 → 5.0.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.
package/src/index.ts CHANGED
@@ -1,6 +1,3 @@
1
- // The look: pi's start screen, editor, footer and working line, redrawn around the data I want in
2
- // front of me, in the colours of whatever theme is on. One feature, because the pieces share one clock
3
- // and one git probe; each piece has its own switch in the settings.
4
1
  import { createApp, defineFeature, type FeatureScope } from "@adeildo/pi-kit";
5
2
  import type {
6
3
  ExtensionAPI,
@@ -12,6 +9,7 @@ import type { TUI } from "@earendil-works/pi-tui";
12
9
  import { type GitState, readGit } from "./data/git.ts";
13
10
  import { emptySnapshot, type Snapshot, SnapshotReader } from "./data/snapshot.ts";
14
11
  import { Telemetry } from "./data/telemetry.ts";
12
+ import type { DeltaKind } from "./data/telemetry.ts";
15
13
  import { REQUEST_ENTRY, type RequestRecord } from "./data/totals.ts";
16
14
  import { DESKTOP_THEME } from "./desktop/palette.ts";
17
15
  import { syncDesktopTheme, watchDesktopPalette } from "./desktop/sync.ts";
@@ -34,6 +32,7 @@ import {
34
32
  pathLength,
35
33
  peek,
36
34
  separator,
35
+ SECTIONS,
37
36
  SEPARATORS,
38
37
  strip,
39
38
  topLeft,
@@ -43,7 +42,7 @@ import { applyCursor, LookEditor } from "./ui/editor.ts";
43
42
  import { FooterComponent, StripComponent } from "./ui/footer.ts";
44
43
  import { HeaderComponent, type LoadedCounts } from "./ui/header.ts";
45
44
  import type { Screen, SlotName } from "./ui/screen.ts";
46
- import { type Activity, callingTool, describe, thinkingTail, toolDetail } from "./ui/working.ts";
45
+ import { type Activity, describe, draftedTool } from "./ui/working.ts";
47
46
 
48
47
  export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
49
48
  export { ROLE_TOKENS } from "./render/paint.ts";
@@ -51,8 +50,6 @@ export { desktopTheme, parseDesktopColors } from "./desktop/palette.ts";
51
50
 
52
51
  const STRIP_WIDGET = "pi-look:strip";
53
52
  const TICK_MS = 500;
54
- /** The thinking tail changes on every token; the spinner line does not need to. */
55
- const PEEK_MS = 120;
56
53
 
57
54
  interface Live {
58
55
  ctx: ExtensionContext | undefined;
@@ -63,16 +60,15 @@ interface Live {
63
60
  probing: boolean;
64
61
  ticker: ReturnType<typeof setInterval> | undefined;
65
62
  stopWatching: (() => void) | undefined;
66
- /** The frame's snapshot, shared by every component drawn in the same frame. */
67
63
  frame: Snapshot | undefined;
68
64
  activity: string | undefined;
69
- peekAt: number;
70
65
  }
71
66
 
72
67
  export const look = defineFeature({
73
68
  id: "look",
74
69
  description:
75
70
  "The start screen, a framed editor, the footer and a working line that says what runs",
71
+ sections: SECTIONS,
76
72
  settings: LOOK_SETTINGS,
77
73
  setup(scope) {
78
74
  const telemetry = new Telemetry();
@@ -88,7 +84,6 @@ export const look = defineFeature({
88
84
  stopWatching: undefined,
89
85
  frame: undefined,
90
86
  activity: undefined,
91
- peekAt: 0,
92
87
  };
93
88
 
94
89
  const screen = createScreen(scope, live, reader, telemetry);
@@ -144,6 +139,11 @@ export const look = defineFeature({
144
139
  live.tui = undefined;
145
140
  });
146
141
 
142
+ // These two fire for the calls the agent makes, and not for pi's cache warming, which calls the
143
+ // provider on its own: the marks always belong to the answer being measured.
144
+ scope.on("before_provider_request", () => telemetry.providerRequestSent());
145
+ scope.on("after_provider_response", () => telemetry.providerResponseArrived());
146
+
147
147
  scope.on("agent_start", () => {
148
148
  telemetry.runStarted();
149
149
  setActivity(live, scope, { kind: "waiting" });
@@ -164,39 +164,31 @@ export const look = defineFeature({
164
164
  if (message.role !== "assistant") return;
165
165
  const update = event.assistantMessageEvent;
166
166
  const delta = "delta" in update ? update.delta : "";
167
- telemetry.answerGrew(delta, message.usage);
167
+ const kind: DeltaKind = update.type.startsWith("thinking") ? "thinking" : "writing";
168
+ telemetry.answerGrew(delta, message.usage, kind);
168
169
 
169
- if (update.type === "thinking_delta") {
170
- const now = Date.now();
171
- if (now - live.peekAt < PEEK_MS) return;
172
- live.peekAt = now;
173
- setActivity(live, scope, { kind: "thinking", tail: thinkingTail(message.content) });
170
+ if (update.type === "thinking_start" || update.type === "thinking_delta") {
171
+ setActivity(live, scope, { kind: "thinking" });
174
172
  } else if (update.type === "text_delta" || update.type === "text_start") {
175
173
  setActivity(live, scope, { kind: "writing" });
176
174
  } else if (update.type === "toolcall_start" || update.type === "toolcall_delta") {
177
- const tool = callingTool(message.content);
178
- if (tool !== undefined) setActivity(live, scope, { kind: "calling", tool });
175
+ const tool = draftedTool(message.content);
176
+ if (tool !== undefined) setActivity(live, scope, { kind: "drafting", tool });
179
177
  }
180
178
  });
181
179
 
182
180
  scope.on("message_end", (event) => {
183
181
  if (event.message.role !== "assistant") return;
184
182
  const record = telemetry.answerEnded(event.message.usage);
185
- // Kept in the session, so the average speeds survive a resume, and the usage ledger gets a
186
- // duration, which pi does not record.
183
+ // Pi records usage without a duration, so the session keeps it for the speeds after a resume.
187
184
  if (record !== undefined) scope.appendEntry<RequestRecord>(REQUEST_ENTRY, record);
188
185
  repaint();
189
186
  });
190
187
 
191
188
  scope.on("tool_execution_start", (event) => {
192
- setActivity(live, scope, {
193
- kind: "running",
194
- tool: event.toolName,
195
- detail: toolDetail(event.args),
196
- });
189
+ setActivity(live, scope, { kind: "running", tool: event.toolName });
197
190
  });
198
191
 
199
- // A command may have moved the branch or touched the tree.
200
192
  scope.on("tool_execution_end", () => void probeGit(live));
201
193
 
202
194
  scope.on("agent_end", () => {
@@ -213,41 +205,35 @@ export const look = defineFeature({
213
205
  scope.on("session_compact", repaint);
214
206
  scope.on("session_tree", repaint);
215
207
 
216
- scope.registerCommand("look", {
217
- description:
218
- "Look: `explain` says what every piece on screen is, `theme` switches to the desktop theme",
219
- getArgumentCompletions: (prefix) =>
220
- ["explain", "theme"]
221
- .filter((name) => name.startsWith(prefix.trim()))
222
- .map((name) => ({ value: name, label: name })),
223
- handler: async (args, ctx) => {
224
- const action = args.trim();
225
- if (action === "" || action === "explain") {
226
- ctx.ui.notify(explain(screen), "info");
227
- return;
228
- }
229
- if (action !== "theme") {
230
- ctx.ui.notify("usage: /look explain | /look theme", "info");
231
- return;
232
- }
233
- const result = await syncDesktopTheme(desktopSource.get(scope));
234
- if (result.kind === "missing" || result.kind === "invalid") {
235
- const why = result.kind === "missing" ? "no palette file" : result.reason;
236
- ctx.ui.notify(`look: cannot build the desktop theme: ${why}`, "warning");
237
- return;
238
- }
239
- const switched = ctx.ui.setTheme(DESKTOP_THEME);
240
- ctx.ui.notify(
241
- switched.success
242
- ? `look: now on the ${DESKTOP_THEME} theme, from ${desktopSource.get(scope)}`
243
- : `look: wrote ${result.path}, but pi did not load it: ${switched.error ?? "unknown"}`,
244
- switched.success ? "info" : "warning",
245
- );
246
- },
208
+ scope.screen.action({
209
+ id: "explain",
210
+ section: "Slots",
211
+ label: "What is on screen",
212
+ description: "Every piece the slots show right now, and what it means.",
213
+ run: () => explain(screen),
214
+ });
215
+
216
+ scope.screen.action({
217
+ id: "desktop.apply",
218
+ section: "Desktop theme",
219
+ label: "Switch to it now",
220
+ description: "Builds the desktop theme from the palette file and makes it pi's theme.",
221
+ run: (ctx) => switchToDesktopTheme(ctx, scope),
247
222
  });
248
223
  },
249
224
  });
250
225
 
226
+ async function switchToDesktopTheme(ctx: ExtensionContext, scope: FeatureScope): Promise<string> {
227
+ const source = desktopSource.get(scope);
228
+ const result = await syncDesktopTheme(source);
229
+ if (result.kind === "missing") throw new Error(`no palette at ${source}`);
230
+ if (result.kind === "invalid") throw new Error(`cannot build the theme: ${result.reason}`);
231
+ const switched = ctx.ui.setTheme(DESKTOP_THEME);
232
+ if (!switched.success)
233
+ throw new Error(`wrote ${result.path}, but pi did not load it: ${switched.error ?? "unknown"}`);
234
+ return `Now on the ${DESKTOP_THEME} theme, built from ${source}.`;
235
+ }
236
+
251
237
  const SLOT_TITLES: Record<SlotName, string> = {
252
238
  strip: "Above the editor, this answer",
253
239
  topLeft: "Frame, top left",
@@ -257,7 +243,6 @@ const SLOT_TITLES: Record<SlotName, string> = {
257
243
  below: "Below the editor, the session",
258
244
  };
259
245
 
260
- /** Every piece on screen, where it is, what it shows now and what it means. */
261
246
  function explain(screen: Screen): string {
262
247
  const snapshot = screen.snapshot();
263
248
  const paint = screen.paint();
@@ -309,7 +294,6 @@ function createScreen(
309
294
  snapshot() {
310
295
  const ctx = live.ctx;
311
296
  if (ctx === undefined) return emptySnapshot();
312
- // Every component of one frame renders in the same tick, so they share one read.
313
297
  if (live.frame !== undefined) return live.frame;
314
298
  const snapshot = reader.read(ctx, {
315
299
  branch: live.footerData?.getGitBranch() ?? null,
@@ -373,7 +357,6 @@ function installHeader(ctx: ExtensionContext, screen: Screen, scope: FeatureScop
373
357
  );
374
358
  }
375
359
 
376
- /** What loaded, from pi's own lists: active tools, skill and prompt commands, extension files. */
377
360
  function loadedCounts(pi: ExtensionAPI): LoadedCounts {
378
361
  const commands = pi.getCommands();
379
362
  const extensions = new Set<string>();
@@ -417,7 +400,6 @@ function setActivity(live: Live, scope: FeatureScope, activity: Activity): void
417
400
  live.ctx?.ui.setWorkingMessage(message);
418
401
  }
419
402
 
420
- /** Only one at a time, and never while a render is waiting on it. */
421
403
  async function probeGit(live: Live): Promise<void> {
422
404
  const ctx = live.ctx;
423
405
  if (ctx === undefined || live.probing) return;
@@ -430,7 +412,6 @@ async function probeGit(live: Live): Promise<void> {
430
412
  }
431
413
  }
432
414
 
433
- /** The clocks have to move on their own while the model thinks and nothing else happens. */
434
415
  function startTicking(live: Live): void {
435
416
  stopTicking(live);
436
417
  live.ticker = setInterval(() => live.repaint?.(), TICK_MS);
package/src/render/fit.ts CHANGED
@@ -1,20 +1,13 @@
1
- // Making pieces fit. Every piece has a priority and may have a shorter form. When a line is too wide,
2
- // the least important pieces shorten first, then leave, one at a time, until it fits. Regions that
3
- // share a line (the left and right of a border) compete for the same width, so an unimportant piece on
4
- // the right leaves before an important one on the left.
5
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
2
 
7
3
  export interface Piece {
8
4
  text: string;
9
- /** A shorter form, used before the piece is dropped. */
10
5
  compact?: string | undefined;
11
- /** Higher stays longer. */
12
6
  priority: number;
13
7
  }
14
8
 
15
9
  export interface FitOptions {
16
10
  separator: string;
17
- /** Width a region costs on top of its pieces when it has any, like the padding around it. */
18
11
  regionOverhead?: number;
19
12
  ellipsis?: string;
20
13
  }
@@ -27,7 +20,6 @@ interface Slot {
27
20
  dropped: boolean;
28
21
  }
29
22
 
30
- /** One string per region, each the kept pieces joined by the separator. */
31
23
  export function fitRegions(
32
24
  regions: readonly (readonly Piece[])[],
33
25
  width: number,
@@ -53,7 +45,6 @@ export function fitRegions(
53
45
  const joined = join(regions.length, slots, options.separator);
54
46
  if (measure() <= width) return joined;
55
47
 
56
- // One piece left and still too wide: cut it, keeping the overhead of its region.
57
48
  const overhead = options.regionOverhead ?? 0;
58
49
  return joined.map((text) =>
59
50
  text === ""
@@ -62,7 +53,6 @@ export function fitRegions(
62
53
  );
63
54
  }
64
55
 
65
- /** A single run of pieces, as one string. */
66
56
  export function fitLine(pieces: readonly Piece[], width: number, options: FitOptions): string {
67
57
  return fitRegions([pieces], width, options)[0] ?? "";
68
58
  }
@@ -1,31 +1,22 @@
1
- // Numbers as a person reads them at a glance. Pure, so every rule here has a test.
2
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
3
2
 
4
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
4
 
6
- /**
7
- * The widest each kind of number gets in normal use. A value is padded to its cell, so a number that
8
- * ticks from `9.8` to `114` to `2.8k` does not push the rest of the line around.
9
- */
10
5
  export const CELL = {
11
6
  count: 4,
12
7
  rate: 4,
13
8
  latency: 5,
14
- /** `0:08` to `9:59`, then one more for `59:59`. */
15
9
  clock: 4,
16
10
  percent: 4,
17
11
  money: 6,
18
12
  } as const;
19
13
 
20
- /** Right-aligned in a cell of `width` columns. Longer values keep their length. */
21
14
  export function cell(text: string, width: number): string {
22
15
  return " ".repeat(Math.max(0, width - visibleWidth(text))) + text;
23
16
  }
24
17
 
25
- /** What a cell shows before its value is known, so the cell is there from the start. */
26
18
  export const UNKNOWN = "–";
27
19
 
28
- /** `1.2k`, `43k`, `1.2M`, `22M`, the way pi writes token counts. */
29
20
  export function count(value: number): string {
30
21
  const n = Math.max(0, Math.round(value));
31
22
  if (n < 1000) return String(n);
@@ -35,13 +26,11 @@ export function count(value: number): string {
35
26
  return `${Math.round(n / 1_000_000)}M`;
36
27
  }
37
28
 
38
- /** A speed: `42`, `906`, `1.2k`, `48k` tokens per second. */
39
29
  export function rate(perSecond: number): string {
40
30
  if (perSecond < 10) return trim(perSecond.toFixed(1));
41
31
  return count(perSecond);
42
32
  }
43
33
 
44
- /** `2m 17s`, `1h 4m`, for how long something has been running. */
45
34
  export function duration(milliseconds: number): string {
46
35
  const total = Math.max(0, Math.floor(milliseconds / 1000));
47
36
  const secs = total % 60;
@@ -52,7 +41,6 @@ export function duration(milliseconds: number): string {
52
41
  return `${secs}s`;
53
42
  }
54
43
 
55
- /** `0:08`, `2:17`, `1:04:09`: a stopwatch, which keeps its shape as it counts. */
56
44
  export function stopwatch(milliseconds: number): string {
57
45
  const total = Math.max(0, Math.floor(milliseconds / 1000));
58
46
  const secs = String(total % 60).padStart(2, "0");
@@ -61,14 +49,12 @@ export function stopwatch(milliseconds: number): string {
61
49
  return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
62
50
  }
63
51
 
64
- /** `320ms`, `1.7s`, `12s`, for latencies, where the fraction matters under ten seconds. */
65
52
  export function latency(milliseconds: number): string {
66
53
  if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
67
54
  if (milliseconds < 10_000) return `${(milliseconds / 1000).toFixed(1)}s`;
68
55
  return duration(milliseconds);
69
56
  }
70
57
 
71
- /** Three decimals while it is cents, two once it is dollars. */
72
58
  export function money(amount: number): string {
73
59
  return amount >= 10 ? `$${amount.toFixed(2)}` : `$${amount.toFixed(3)}`;
74
60
  }
@@ -77,7 +63,6 @@ export function percent(value: number): string {
77
63
  return value >= 10 || value === 0 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`;
78
64
  }
79
65
 
80
- /** `~` for the home folder, and the tail kept when it is long, since the tail says where you are. */
81
66
  export function shortenPath(cwd: string, home: string | undefined, maxWidth: number): string {
82
67
  const full = home === undefined ? cwd : underHome(cwd, home);
83
68
  if (maxWidth <= 0 || visibleWidth(full) <= maxWidth) return full;
@@ -97,13 +82,11 @@ export function basename(cwd: string): string {
97
82
  return cwd.split(/[\\/]/).findLast(Boolean) ?? cwd;
98
83
  }
99
84
 
100
- /** A gauge of `cells`, filled to `value` out of 100. */
101
85
  export function gauge(value: number, cells: number): { filled: number; empty: number } {
102
86
  const filled = Math.max(0, Math.min(cells, Math.round((value / 100) * cells)));
103
87
  return { filled, empty: cells - filled };
104
88
  }
105
89
 
106
- /** Clip to the start, keeping the end: for the tail of a thought, which is the newest part. */
107
90
  export function truncateTail(text: string, maxWidth: number): string {
108
91
  if (maxWidth <= 0) return "";
109
92
  if (visibleWidth(text) <= maxWidth) return text;
@@ -1,5 +1,3 @@
1
- // Borders with something written in them: `╭─ left ────── right ─╮`. The frame is the one place
2
- // that knows the box characters, so a new style is one entry here.
3
1
  import { visibleWidth } from "@earendil-works/pi-tui";
4
2
 
5
3
  import { type FitOptions, fitRegions, type Piece } from "./fit.ts";
@@ -40,7 +38,6 @@ export const BOXES: Record<FrameStyle, BoxChars> = {
40
38
  horizontal: "━",
41
39
  vertical: "┃",
42
40
  },
43
- // Only the horizontal rules, the way pi draws its editor, with the data written into them.
44
41
  line: {
45
42
  topLeft: "─",
46
43
  topRight: "─",
@@ -54,23 +51,14 @@ export const BOXES: Record<FrameStyle, BoxChars> = {
54
51
  export interface BorderOptions extends Pick<FitOptions, "separator" | "ellipsis"> {
55
52
  box: BoxChars;
56
53
  edge: "top" | "bottom";
57
- /** Paints the rule and the corners. */
58
54
  paint: (text: string) => string;
59
- /**
60
- * Text that follows the left pieces and takes whatever room is left, like the working spinner.
61
- * It goes after them because it changes width all the time, and the pieces must not move.
62
- */
63
55
  lead?: ((budget: number) => string | undefined) | undefined;
64
- /** Room kept for the lead, so the pieces never squeeze it out. */
65
56
  leadMin?: number;
66
57
  }
67
58
 
68
- /** Corners, one rule cell each side, and one rule cell between the two regions at least. */
69
59
  const FIXED = 5;
70
- /** A space on each side of a region's text. */
71
60
  const PADDING = 2;
72
61
 
73
- /** One border line of exactly `width` columns, with both regions fitted into it. */
74
62
  export function border(
75
63
  width: number,
76
64
  left: readonly Piece[],
@@ -1,5 +1,3 @@
1
- // One glyph per kind of data, in three sets. Nerd Font glyphs live in the private use area, so they
2
- // are written as escapes: a font without them shows a box, and a reviewer sees which code point it is.
3
1
  export type IconMode = "auto" | "nerd" | "unicode" | "ascii";
4
2
  export type IconSet = Exclude<IconMode, "auto">;
5
3
 
@@ -31,10 +29,8 @@ export interface Glyphs {
31
29
  untracked: string;
32
30
  conflicted: string;
33
31
  stashed: string;
34
- /** Full and empty cells of a gauge. Blocks, so the gauge never reads as part of a border rule. */
35
32
  fill: string;
36
33
  empty: string;
37
- /** Six rising bars, one per effort level from minimal to max. */
38
34
  levels: readonly string[];
39
35
  ellipsis: string;
40
36
  }
@@ -77,7 +73,6 @@ export const NERD: Glyphs = {
77
73
  ellipsis: "…",
78
74
  };
79
75
 
80
- /** Symbols every monospace font with decent Unicode coverage has, all one column wide. */
81
76
  export const UNICODE: Glyphs = {
82
77
  ...COMMON,
83
78
  set: "unicode",
@@ -141,10 +136,6 @@ export const ASCII: Glyphs = {
141
136
 
142
137
  const SETS: Record<IconSet, Glyphs> = { nerd: NERD, unicode: UNICODE, ascii: ASCII };
143
138
 
144
- /**
145
- * Auto trusts a local UTF-8 terminal to have a Nerd Font. Over SSH the font lives on the other
146
- * machine, so it drops to plain Unicode, and a terminal that does not speak UTF-8 gets ASCII.
147
- */
148
139
  export function resolveIcons(mode: IconMode, env: NodeJS.ProcessEnv = process.env): Glyphs {
149
140
  if (mode !== "auto") return SETS[mode];
150
141
  if (env.TERM === "dumb") return ASCII;
@@ -1,9 +1,5 @@
1
- // Colour, without a single colour value. Every role names a token of the active pi theme, so the look
2
- // follows whatever theme is on: the built-in ones, a custom JSON, or the desktop palette this package
3
- // writes from matugen. Change the theme and every piece moves with it.
4
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
5
2
 
6
- /** What a piece of text is. The table below says which theme token paints it. */
7
3
  export type Role =
8
4
  | "path"
9
5
  | "branch"
@@ -29,10 +25,6 @@ export type Role =
29
25
  | "clock"
30
26
  | "brand";
31
27
 
32
- /**
33
- * The palette, as theme tokens. Syntax tokens are borrowed on purpose: a theme spends its hues there,
34
- * so they give the line variety that stays in the theme's family.
35
- */
36
28
  export const ROLE_TOKENS: Record<Role, ThemeColor> = {
37
29
  path: "accent",
38
30
  branch: "syntaxFunction",
@@ -59,7 +51,6 @@ export const ROLE_TOKENS: Record<Role, ThemeColor> = {
59
51
  brand: "accent",
60
52
  };
61
53
 
62
- /** Pi gives every effort level its own token, which a theme draws as a ramp from cool to hot. */
63
54
  const EFFORT_TOKENS: Record<string, ThemeColor> = {
64
55
  off: "thinkingOff",
65
56
  minimal: "thinkingMinimal",
@@ -73,21 +64,15 @@ const EFFORT_TOKENS: Record<string, ThemeColor> = {
73
64
  export const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
74
65
 
75
66
  export interface Paint {
76
- /** Separators, units and labels sit back, so the data reads first. */
77
67
  dim(text: string): string;
78
- /** Quieter data. */
79
68
  muted(text: string): string;
80
69
  text(text: string): string;
81
70
  bold(text: string): string;
82
71
  italic(text: string): string;
83
72
  role(role: Role, text: string): string;
84
- /** The colour of one effort level. */
85
73
  effort(level: string, text: string): string;
86
- /** Calm under half, warm past 70%, alarming past 90%. */
87
74
  stress(percent: number, text: string): string;
88
- /** Money reads calm until it adds up. */
89
75
  money(amount: number, text: string): string;
90
- /** The editor frame, which pi recolours with the effort level and for bash mode. */
91
76
  frame(text: string): string;
92
77
  }
93
78
 
@@ -118,7 +103,6 @@ function moneyToken(amount: number): ThemeColor {
118
103
  return ROLE_TOKENS.cost;
119
104
  }
120
105
 
121
- /** Used by tests and by anything drawing without a theme. */
122
106
  export const PLAIN: Paint = {
123
107
  dim: identity,
124
108
  muted: identity,