@adeildo/pi-look 5.2.0 → 5.4.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/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## The screen
26
26
 
27
- A finished answer takes a box of its own, so you can see what the model wants to run before it runs. The mark of the tool and its name sit on the top line, the command under them, and the reason it was allowed under that, when another model answered it. A line cuts the command off from what it printed, and the bottom line says how it went: a check, a cross, and how long it took. A command still running says `running` with the time so far. A tool from an MCP server shows its server and tool instead of `mcp__server__tool`.
27
+ A finished answer takes a box of its own, so you can see what the model wants to run before it runs. The mark of the tool and its name sit on the top line, the command under them, and the reason it was allowed under that, when another model answered it. A line cuts the command off from what it printed, and the bottom line says how it went: a check, a cross, and how long it took. A command still running says `running` with the time so far. A tool from an MCP server shows its server and tool instead of `mcp__server__tool`. A tool with no drawing of its own, like the memory calls, gets the same box with its name and arguments on top and its text under the cut.
28
28
 
29
29
  <!-- docs:look/call -->
30
30
 
@@ -65,8 +65,6 @@ Pi records usage without a duration, so every finished answer leaves one small e
65
65
 
66
66
  The look never names a colour. Every piece names a theme token, so it follows whatever theme pi runs, and it moves with it when you change themes. The [reference](docs/reference.md#colours) maps each piece to its token.
67
67
 
68
- If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper. The look turns it into the pi theme `desktop` and rewrites that file when the palette changes, so a new wallpaper recolours a running pi. `Switch to it now`, in the settings, does that at once.
69
-
70
68
  ## Settings
71
69
 
72
70
  `Alt+S` opens them on the Look tab. You can reorder every piece of the strip, the frame and the footer, pick the frame style, the icons and the separator, and switch off what you do not want. The [reference](docs/reference.md#settings) lists every key and every segment.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeildo/pi-look",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Pi's start screen, a framed editor and the footer, redrawn around the data that matters and painted only with the theme's colours.",
5
5
  "keywords": [
6
6
  "pi",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@adeildo/pi-kit": "5.2.0"
41
+ "@adeildo/pi-kit": "5.4.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-ai": "*",
@@ -1,5 +1,6 @@
1
1
  import { hostname } from "node:os";
2
2
 
3
+ import type { AccountState } from "@adeildo/pi-kit";
3
4
  import { VERSION, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
4
5
 
5
6
  import type { GitState } from "./git.ts";
@@ -36,6 +37,7 @@ export interface Snapshot {
36
37
  sessionName: string | undefined;
37
38
  version: string;
38
39
  model: ModelInfo | undefined;
40
+ account: AccountState | undefined;
39
41
  thinking: string | undefined;
40
42
  context: ContextInfo | undefined;
41
43
  totals: Totals;
@@ -60,6 +62,7 @@ export function emptySnapshot(): Snapshot {
60
62
  sessionName: undefined,
61
63
  version: VERSION,
62
64
  model: undefined,
65
+ account: undefined,
63
66
  thinking: undefined,
64
67
  context: undefined,
65
68
  totals: emptyTotals(),
@@ -77,6 +80,7 @@ export function emptySnapshot(): Snapshot {
77
80
  export interface LiveInputs {
78
81
  branch: string | null;
79
82
  git: GitState | undefined;
83
+ account?: AccountState | undefined;
80
84
  request: RequestView | undefined;
81
85
  last: RequestView | undefined;
82
86
  run: RunView | undefined;
@@ -117,6 +121,7 @@ export class SnapshotReader {
117
121
  reasoning: model.reasoning,
118
122
  },
119
123
  thinking: ctx.thinkingLevel,
124
+ account: live.account,
120
125
  context: contextOf(ctx),
121
126
  ...derived,
122
127
  subscription: model === undefined ? false : ctx.modelRegistry.isUsingOAuth(model),
package/src/index.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { createApp, defineFeature, type FeatureScope } from "@adeildo/pi-kit";
1
+ import {
2
+ accountStateOf,
3
+ createApp,
4
+ defineFeature,
5
+ drawSkillRefs,
6
+ type FeatureScope,
7
+ SKILL_REF,
8
+ skillNames,
9
+ } from "@adeildo/pi-kit";
2
10
  import type {
3
11
  ExtensionAPI,
4
12
  ExtensionContext,
@@ -11,18 +19,15 @@ import { emptySnapshot, type Snapshot, SnapshotReader } from "./data/snapshot.ts
11
19
  import { Telemetry } from "./data/telemetry.ts";
12
20
  import type { DeltaKind } from "./data/telemetry.ts";
13
21
  import { REQUEST_ENTRY, type RequestRecord } from "./data/totals.ts";
14
- import { DESKTOP_THEME } from "./desktop/palette.ts";
15
- import { syncDesktopTheme, watchDesktopPalette } from "./desktop/sync.ts";
16
22
  import { resolveIcons } from "./render/glyphs.ts";
17
23
  import { PLAIN, themePaint } from "./render/paint.ts";
18
24
  import { claimedStatuses, renderSegments, SEGMENTS } from "./render/segments.ts";
19
25
  import {
20
26
  below,
27
+ belowRight,
21
28
  bottomLeft,
22
29
  bottomRight,
23
30
  cursor,
24
- desktop,
25
- desktopSource,
26
31
  frame,
27
32
  gaugeCells,
28
33
  header,
@@ -47,7 +52,6 @@ import { type Activity, describe, draftedTool } from "./ui/working.ts";
47
52
 
48
53
  export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
49
54
  export { ROLE_TOKENS } from "./render/paint.ts";
50
- export { desktopTheme, parseDesktopColors } from "./desktop/palette.ts";
51
55
 
52
56
  const STRIP_WIDGET = "pi-look:strip";
53
57
  const TICK_MS = 500;
@@ -60,7 +64,6 @@ interface Live {
60
64
  git: GitState | undefined;
61
65
  probing: boolean;
62
66
  ticker: ReturnType<typeof setInterval> | undefined;
63
- stopWatching: (() => void) | undefined;
64
67
  frame: Snapshot | undefined;
65
68
  activity: string | undefined;
66
69
  }
@@ -82,7 +85,6 @@ export const look = defineFeature({
82
85
  git: undefined,
83
86
  probing: false,
84
87
  ticker: undefined,
85
- stopWatching: undefined,
86
88
  frame: undefined,
87
89
  activity: undefined,
88
90
  };
@@ -97,7 +99,7 @@ export const look = defineFeature({
97
99
  cursor.listen(scope, (style) => live.tui && applyCursor(live.tui, style));
98
100
  peek.listen(scope, (on) => !on && live.ctx?.ui.setWorkingMessage());
99
101
 
100
- scope.onSessionStart(async (ctx) => {
102
+ scope.onSessionStart((ctx) => {
101
103
  live.ctx = ctx;
102
104
  telemetry.reset();
103
105
  void probeGit(live);
@@ -121,13 +123,10 @@ export const look = defineFeature({
121
123
  });
122
124
  installEditor(ctx, screen, live, scope);
123
125
  installHeader(ctx, screen, scope);
124
- if (desktop.get(scope)) await startDesktopSync(ctx, scope, live);
125
126
  });
126
127
 
127
128
  scope.onShutdown(() => {
128
129
  stopTicking(live);
129
- live.stopWatching?.();
130
- live.stopWatching = undefined;
131
130
  const ctx = live.ctx;
132
131
  if (ctx?.mode === "tui") {
133
132
  ctx.ui.setFooter(undefined);
@@ -214,28 +213,9 @@ export const look = defineFeature({
214
213
  description: "Every piece the slots show right now, and what it means.",
215
214
  run: () => explain(screen),
216
215
  });
217
-
218
- scope.screen.action({
219
- id: "desktop.apply",
220
- section: "Desktop theme",
221
- label: "Switch to it now",
222
- description: "Builds the desktop theme from the palette file and makes it pi's theme.",
223
- run: (ctx) => switchToDesktopTheme(ctx, scope),
224
- });
225
216
  },
226
217
  });
227
218
 
228
- async function switchToDesktopTheme(ctx: ExtensionContext, scope: FeatureScope): Promise<string> {
229
- const source = desktopSource.get(scope);
230
- const result = await syncDesktopTheme(source);
231
- if (result.kind === "missing") throw new Error(`no palette at ${source}`);
232
- if (result.kind === "invalid") throw new Error(`cannot build the theme: ${result.reason}`);
233
- const switched = ctx.ui.setTheme(DESKTOP_THEME);
234
- if (!switched.success)
235
- throw new Error(`wrote ${result.path}, but pi did not load it: ${switched.error ?? "unknown"}`);
236
- return `Now on the ${DESKTOP_THEME} theme, built from ${source}.`;
237
- }
238
-
239
219
  const SLOT_TITLES: Record<SlotName, string> = {
240
220
  strip: "Above the editor, this answer",
241
221
  topLeft: "Frame, top left",
@@ -243,6 +223,7 @@ const SLOT_TITLES: Record<SlotName, string> = {
243
223
  bottomLeft: "Frame, bottom left",
244
224
  bottomRight: "Frame, bottom right",
245
225
  below: "Below the editor, the session",
226
+ belowRight: "Below the editor, on the right",
246
227
  };
247
228
 
248
229
  function explain(screen: Screen): string {
@@ -290,6 +271,7 @@ function createScreen(
290
271
  bottomLeft,
291
272
  bottomRight,
292
273
  below,
274
+ belowRight,
293
275
  };
294
276
 
295
277
  return {
@@ -300,6 +282,7 @@ function createScreen(
300
282
  const snapshot = reader.read(ctx, {
301
283
  branch: live.footerData?.getGitBranch() ?? null,
302
284
  git: live.git,
285
+ account: accountStateOf(scope.events, ctx.model?.provider),
303
286
  request: telemetry.request(),
304
287
  last: telemetry.last(),
305
288
  run: telemetry.run(),
@@ -323,6 +306,14 @@ function createScreen(
323
306
  slot: (name) => slots[name].get(scope),
324
307
  frameStyle: () => frame.get(scope),
325
308
  cursor: () => cursor.get(scope),
309
+ decorate: (line) => {
310
+ const theme = live.ctx?.ui.theme;
311
+ if (theme === undefined) return line;
312
+ return drawSkillRefs(line, skillNames(scope.events), (name) =>
313
+ theme.fg("customMessageLabel", `${SKILL_REF}${name}`),
314
+ );
315
+ },
316
+ skills: () => skillNames(scope.events).length > 0,
326
317
  };
327
318
  }
328
319
 
@@ -377,23 +368,6 @@ function loadedCounts(pi: ExtensionAPI): LoadedCounts {
377
368
  };
378
369
  }
379
370
 
380
- async function startDesktopSync(ctx: ExtensionContext, scope: FeatureScope, live: Live) {
381
- const source = desktopSource.get(scope);
382
- const sync = async () => {
383
- const result = await syncDesktopTheme(source);
384
- if (result.kind === "invalid") scope.warn(`desktop theme: ${result.reason}`);
385
- return result;
386
- };
387
-
388
- const first = await sync();
389
- if (first.kind === "missing") return;
390
- if (first.kind === "written" && ctx.ui.theme.name !== DESKTOP_THEME) {
391
- ctx.ui.notify(`look: wrote the ${DESKTOP_THEME} theme. /look theme switches to it.`, "info");
392
- }
393
- live.stopWatching?.();
394
- live.stopWatching = watchDesktopPalette(source, () => void sync());
395
- }
396
-
397
371
  function setActivity(live: Live, scope: FeatureScope, activity: Activity): void {
398
372
  if (!peek.get(scope)) return;
399
373
  const message = describe(activity);
@@ -56,6 +56,7 @@ export function latency(milliseconds: number): string {
56
56
  }
57
57
 
58
58
  export function money(amount: number): string {
59
+ if (amount === 0) return "$0.00";
59
60
  return amount >= 10 ? `$${amount.toFixed(2)}` : `$${amount.toFixed(3)}`;
60
61
  }
61
62
 
@@ -1,3 +1,5 @@
1
+ import type { AccountWindow } from "@adeildo/pi-kit";
2
+
1
3
  import type { Snapshot } from "../data/snapshot.ts";
2
4
  import { promptOf, tokensOf, type Totals } from "../data/totals.ts";
3
5
  import type { Piece } from "./fit.ts";
@@ -38,6 +40,7 @@ export const SEGMENT_IDS = [
38
40
  "request",
39
41
  "costRate",
40
42
  "cost",
43
+ "quota",
41
44
  "tokens",
42
45
  "cache",
43
46
  "average",
@@ -58,11 +61,19 @@ export function isSegmentId(value: string): value is SegmentId {
58
61
  );
59
62
  }
60
63
 
64
+ /** Status keys a builtin segment already draws, so the statuses segment skips them. */
65
+ const CLAIMED_BY_SEGMENT: Partial<Record<BuiltinSegment, string>> = {
66
+ model: "pi-providers:account",
67
+ };
68
+
61
69
  export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<string> {
62
70
  const claimed = new Set<string>();
63
71
  for (const ids of slots) {
64
- for (const id of ids)
72
+ for (const id of ids) {
65
73
  if (id.startsWith(STATUS_PREFIX)) claimed.add(id.slice(STATUS_PREFIX.length));
74
+ const bySegment = CLAIMED_BY_SEGMENT[id as BuiltinSegment];
75
+ if (bySegment !== undefined) claimed.add(bySegment);
76
+ }
66
77
  }
67
78
  return claimed;
68
79
  }
@@ -180,13 +191,32 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
180
191
  },
181
192
  model: {
182
193
  priority: 100,
183
- describe: "the model doing the work, after its provider",
194
+ describe: "the model doing the work, after its provider and the account it runs on",
184
195
  render: ({ snapshot, glyphs, paint }) => {
185
196
  const icon = mark(glyphs.model, paint, "provider");
186
197
  if (snapshot.model === undefined) return { text: `${icon}${paint.dim("no model")}` };
187
198
  const name = paint.bold(paint.role("model", snapshot.model.name));
188
- const provider = `${paint.role("provider", snapshot.model.provider)}${paint.dim("/")}`;
189
- return { text: `${icon}${provider}${name}`, compact: `${icon}${name}` };
199
+ const account =
200
+ snapshot.account === undefined
201
+ ? ""
202
+ : `${paint.dim("(")}${paint.role("provider", snapshot.account.label)}${paint.dim(")")}`;
203
+ const provider = `${paint.role("provider", snapshot.model.provider)}${account}${paint.dim("/")}`;
204
+ return {
205
+ text: `${icon}${provider}${name}`,
206
+ compact: account === "" ? `${icon}${name}` : `${icon}${name} ${account}`,
207
+ };
208
+ },
209
+ },
210
+ quota: {
211
+ priority: 58,
212
+ describe: "what each window of the plan spent, and when it resets",
213
+ render: ({ snapshot, paint }) => {
214
+ const account = snapshot.account;
215
+ if (account === undefined) return undefined;
216
+ if (account.needsLogin === true) return { text: paint.stress(100, "sign in again") };
217
+ if (account.windows.length === 0) return undefined;
218
+ const text = account.windows.map((window) => quotaWindow(paint, window)).join(" ");
219
+ return { text };
190
220
  },
191
221
  },
192
222
  effort: {
@@ -350,6 +380,7 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
350
380
  "what the session cost; `sub` means a subscription, so it is what the tokens would have cost",
351
381
  render: ({ snapshot, glyphs, paint }) => {
352
382
  const amount = snapshot.totals.cost;
383
+ // A session that spent nothing has nothing to say yet.
353
384
  if (amount === 0) return undefined;
354
385
  const value = paint.money(amount, money(amount));
355
386
  const sub = snapshot.subscription ? paint.dim(" sub") : "";
@@ -445,6 +476,14 @@ export function renderSegments(ids: readonly SegmentId[], input: SegmentInput):
445
476
  });
446
477
  }
447
478
 
479
+ /** One plan window, like `5h 62% resets in 2h`. The percent carries the color. */
480
+ function quotaWindow(paint: Paint, window: AccountWindow): string {
481
+ const used = paint.stress(window.used, `${window.used}%`);
482
+ const reset =
483
+ window.resetsIn === undefined ? "" : ` ${paint.muted(`resets in ${window.resetsIn}`)}`;
484
+ return `${paint.dim(window.name)} ${used}${reset}`;
485
+ }
486
+
448
487
  function mark(glyph: string, paint: Paint, role: Role): string {
449
488
  return glyph === "" ? "" : `${paint.role(role, glyph)} `;
450
489
  }
package/src/settings.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  pass,
8
8
  problem,
9
9
  setting,
10
- string,
11
10
  } from "@adeildo/pi-kit";
12
11
 
13
12
  import type { FrameStyle } from "./render/frame.ts";
@@ -15,21 +14,12 @@ import type { IconMode } from "./render/glyphs.ts";
15
14
  import { isSegmentId, SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
16
15
 
17
16
  const PERMISSION_STATUS: SegmentId = "status:pi-ask-permission:mode";
18
- const ACCOUNT_STATUS: SegmentId = "status:pi-providers:account";
19
17
 
20
18
  const STATUS_LABELS: Partial<Record<SegmentId, string>> = {
21
19
  [PERMISSION_STATUS]: "permission mode",
22
- [ACCOUNT_STATUS]: "account",
23
20
  };
24
21
 
25
- export const SECTIONS = [
26
- "Editor",
27
- "Slots",
28
- "Footer",
29
- "Start screen",
30
- "Working line",
31
- "Desktop theme",
32
- ];
22
+ export const SECTIONS = ["Editor", "Slots", "Footer", "Start screen", "Working line"];
33
23
 
34
24
  export type HeaderStyle = "card" | "compact" | "off";
35
25
  export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
@@ -65,11 +55,7 @@ function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
65
55
 
66
56
  // Any other package's status can have a slot of its own, so the ones already placed are offered too.
67
57
  function slotControl(fallback: readonly SegmentId[]): Control {
68
- const statuses = [
69
- PERMISSION_STATUS,
70
- ACCOUNT_STATUS,
71
- ...fallback.filter((id) => id.startsWith("status:")),
72
- ];
58
+ const statuses = [PERMISSION_STATUS, ...fallback.filter((id) => id.startsWith("status:"))];
73
59
  return {
74
60
  type: "list",
75
61
  options: [
@@ -113,7 +99,7 @@ export const bottomLeft = slot(
113
99
  "frame.bottomLeft",
114
100
  "Frame, bottom left",
115
101
  "How what you type is handled, and who does the work.",
116
- [PERMISSION_MODE, "model", ACCOUNT_STATUS, "effort"],
102
+ [PERMISSION_MODE, "model", "effort"],
117
103
  );
118
104
  export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
119
105
  "context",
@@ -124,6 +110,12 @@ export const below = slot(
124
110
  "The session: cost, tokens, cache, average speeds, and what other packages report, last because it changes the most.",
125
111
  ["cost", "tokens", "cache", "average", "statuses"],
126
112
  );
113
+ export const belowRight = slot(
114
+ "belowRight",
115
+ "Below the editor, on the right",
116
+ "The plan's quota: what each window spent, and when it resets.",
117
+ ["quota"],
118
+ );
127
119
 
128
120
  export const frame = setting<FrameStyle | "off">({
129
121
  id: "look.frame.style",
@@ -225,29 +217,6 @@ export const peek = setting({
225
217
  },
226
218
  });
227
219
 
228
- export const desktop = setting({
229
- id: "look.desktop.theme",
230
- default: true,
231
- decoder: boolean,
232
- ui: {
233
- section: "Desktop theme",
234
- label: "Desktop theme",
235
- description:
236
- "Write the matugen palette from DankMaterialShell as the pi theme `desktop`, and keep it in sync.",
237
- },
238
- });
239
-
240
- export const desktopSource = setting({
241
- id: "look.desktop.source",
242
- default: "~/.cache/DankMaterialShell/dms-colors.json",
243
- decoder: string,
244
- ui: {
245
- section: "Desktop theme",
246
- label: "Desktop palette",
247
- description: "The DankMaterialShell colour file the desktop theme is made from.",
248
- },
249
- });
250
-
251
220
  export const LOOK_SETTINGS = [
252
221
  strip,
253
222
  topLeft,
@@ -255,6 +224,7 @@ export const LOOK_SETTINGS = [
255
224
  bottomLeft,
256
225
  bottomRight,
257
226
  below,
227
+ belowRight,
258
228
  frame,
259
229
  cursor,
260
230
  header,
@@ -264,6 +234,4 @@ export const LOOK_SETTINGS = [
264
234
  gaugeCells,
265
235
  labels,
266
236
  peek,
267
- desktop,
268
- desktopSource,
269
237
  ];
package/src/ui/editor.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { skillQueryAt } from "@adeildo/pi-kit";
1
2
  import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  type EditorTheme,
@@ -59,6 +60,14 @@ export class LookEditor extends CustomEditor {
59
60
  this.tui.requestRender();
60
61
  }
61
62
 
63
+ /** Pi opens its palette on `/` only at the start of a message. Further in, `/` lists the skills. */
64
+ override handleInput(data: string): void {
65
+ super.handleInput(data);
66
+ if (data !== "/" || this.isShowingAutocomplete() || !this.#screen.skills()) return;
67
+ const { line, col } = this.getCursor();
68
+ if (skillQueryAt(this.getLines(), line, col) !== undefined) openAutocomplete(this);
69
+ }
70
+
62
71
  override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
63
72
  if (this.#inset === 0) return super.handleMouse(event);
64
73
  return super.handleMouse({
@@ -146,7 +155,7 @@ export class LookEditor extends CustomEditor {
146
155
  );
147
156
  inside = false;
148
157
  } else if (inside) {
149
- const text = cursor === "block" ? line : withoutSoftwareCursor(line);
158
+ const text = this.#screen.decorate(cursor === "block" ? line : withoutSoftwareCursor(line));
150
159
  result.push(`${left}${fill(text, inner)}${right}`);
151
160
  } else {
152
161
  result.push(`${" ".repeat(this.#inset)}${line}`);
@@ -165,6 +174,12 @@ export class LookEditor extends CustomEditor {
165
174
  }
166
175
  }
167
176
 
177
+ // The editor keeps the method that opens its list private. Nothing opens if a pi release renames it.
178
+ function openAutocomplete(editor: CustomEditor): void {
179
+ const open: unknown = Reflect.get(editor, "tryTriggerAutocomplete");
180
+ if (typeof open === "function") open.call(editor);
181
+ }
182
+
168
183
  /** With the terminal's cursor showing, pi's inverted cell would be a second cursor. */
169
184
  function withoutSoftwareCursor(line: string): string {
170
185
  return line.replace(INVERTED_CELL, "$1");
package/src/ui/footer.ts CHANGED
@@ -57,7 +57,7 @@ export class FooterComponent implements Component {
57
57
  lines.push(spread(screen, "topLeft", "topRight", room));
58
58
  lines.push(spread(screen, "bottomLeft", "bottomRight", room));
59
59
  }
60
- lines.push(renderSlot(screen, "below", room));
60
+ lines.push(spread(screen, "below", "belowRight", room));
61
61
 
62
62
  const kept = lines.filter((line) => line !== "").map((line) => `${pad}${line}`);
63
63
  return kept.length === 0 ? [""] : kept;
package/src/ui/screen.ts CHANGED
@@ -6,7 +6,14 @@ import type { Paint } from "../render/paint.ts";
6
6
  import { renderSegments, type SegmentId, type SegmentOptions } from "../render/segments.ts";
7
7
  import type { CursorStyle } from "../settings.ts";
8
8
 
9
- export type SlotName = "strip" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "below";
9
+ export type SlotName =
10
+ | "strip"
11
+ | "topLeft"
12
+ | "topRight"
13
+ | "bottomLeft"
14
+ | "bottomRight"
15
+ | "below"
16
+ | "belowRight";
10
17
 
11
18
  export interface Screen {
12
19
  snapshot(): Snapshot;
@@ -17,6 +24,10 @@ export interface Screen {
17
24
  slot(name: SlotName): readonly SegmentId[];
18
25
  frameStyle(): FrameStyle | "off";
19
26
  cursor(): CursorStyle;
27
+ /** A line of what you type, with the skills it names colored. */
28
+ decorate(line: string): string;
29
+ /** True when a skills feature expands `/skill:` references. */
30
+ skills(): boolean;
20
31
  }
21
32
 
22
33
  export function slotPieces(
@@ -12,7 +12,9 @@ import {
12
12
  import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  type Component,
15
+ getKeybindings,
15
16
  stripTerminalSequences,
17
+ Text,
16
18
  truncateToWidth,
17
19
  visibleWidth,
18
20
  } from "@earendil-works/pi-tui";
@@ -32,6 +34,9 @@ const PHASE_COLORS = {
32
34
  failed: "error",
33
35
  } as const;
34
36
  const BODY_PAD = 4;
37
+ const COLLAPSED_ARGS_CHARS = 100;
38
+ /** How many lines of a result the frame prints before telling you the rest is there. */
39
+ const RESULT_PREVIEW_LINES = 10;
35
40
 
36
41
  export function registerToolFrames(scope: FeatureScope): void {
37
42
  const redraws = new Map<string, () => void>();
@@ -61,12 +66,11 @@ function framed(
61
66
  scope: FeatureScope,
62
67
  base: ToolRenderers | undefined,
63
68
  frame: FrameContext,
64
- ): ToolRenderers | undefined {
65
- // Pi drops the whole chain when a resolver answers nothing, so a tool it cannot frame keeps
66
- // whatever the next resolver drew.
67
- const call = base?.renderCall;
68
- const result = base?.renderResult;
69
- if (base === undefined || call === undefined || result === undefined) return base;
69
+ ): ToolRenderers {
70
+ // A tool may bring renderers of its own, one of them, or none: the frame wraps whichever half
71
+ // exists, and the arguments or the text of the result stand in for the rest.
72
+ const call = base?.renderCall ?? genericCall;
73
+ const result = base?.renderResult ?? genericResult;
70
74
 
71
75
  // A renderer of pi's own mutates the component it returned last time, which sits inside the
72
76
  // frame, so it gets that one back instead of the frame.
@@ -94,7 +98,7 @@ function framed(
94
98
  body,
95
99
  ruling: rulingOf(scope.events, context.toolCallId),
96
100
  expanded: context.expanded,
97
- phase: context.executionStarted ? undefined : context.argsComplete ? "waiting" : "writing",
101
+ phase: phaseOf(context),
98
102
  mark: stateMark,
99
103
  set: frame.set,
100
104
  elapsed: elapsedOf(clock, context),
@@ -130,6 +134,84 @@ function framed(
130
134
  };
131
135
  }
132
136
 
137
+ /*
138
+ * The stand-in for a tool that brings no renderer of its own: the arguments, under the top rule
139
+ * that already names the tool, and the text of the result.
140
+ */
141
+ const genericCall: NonNullable<ToolRenderers["renderCall"]> = (args, theme, context) =>
142
+ new Text(callText(args, theme, context.expanded), 0, 0);
143
+
144
+ const genericResult: NonNullable<ToolRenderers["renderResult"]> = (result, options, theme) =>
145
+ new Text(resultText(result, options.expanded, theme), 0, 0);
146
+
147
+ /**
148
+ * The phase the call closes with while it still has no result: writing the arguments, then waiting
149
+ * to run. A call with a result, live or replayed, stays open for the result half to close.
150
+ */
151
+ function phaseOf(context: {
152
+ isPartial: boolean;
153
+ executionStarted: boolean;
154
+ argsComplete: boolean;
155
+ }): Phase | undefined {
156
+ if (!context.isPartial || context.executionStarted) return undefined;
157
+ return context.argsComplete ? "waiting" : "writing";
158
+ }
159
+
160
+ function callText(args: unknown, theme: Theme, expanded: boolean): string {
161
+ if (args === null || args === undefined) return "";
162
+
163
+ const entries: [string, unknown][] =
164
+ typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]];
165
+ if (entries.length === 0) return "";
166
+
167
+ if (expanded) {
168
+ const lines = entries.map(([key, value]) => {
169
+ const text =
170
+ typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
171
+ return ` ${key}: ${replaceTabs(text).split("\n").join("\n ")}`;
172
+ });
173
+ return theme.fg("muted", lines.join("\n"));
174
+ }
175
+
176
+ const pairs = entries
177
+ .map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`)
178
+ .join(" ");
179
+ const preview =
180
+ pairs.length > COLLAPSED_ARGS_CHARS ? `${pairs.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : pairs;
181
+ return theme.fg("muted", preview);
182
+ }
183
+
184
+ function resultText(
185
+ result: { content: readonly { type: string; text?: string }[] },
186
+ expanded: boolean,
187
+ theme: Theme,
188
+ ): string {
189
+ const text = result.content
190
+ .filter((block) => block.type === "text")
191
+ .map((block) => stripTerminalSequences(block.text ?? "").replace(/\r/g, ""))
192
+ .join("\n");
193
+ if (text === "") return "";
194
+
195
+ const lines = text.split("\n");
196
+ const shown = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
197
+ const printed = shown.map((line) => theme.fg("toolOutput", line));
198
+ if (shown.length < lines.length)
199
+ printed.push(
200
+ theme.fg("muted", `... (${lines.length - shown.length} more lines, ${expandHint(theme)})`),
201
+ );
202
+ return printed.join("\n");
203
+ }
204
+
205
+ function expandHint(theme: Theme): string {
206
+ const keys = getKeybindings().getKeys("app.tools.expand");
207
+ if (keys.length === 0) return theme.fg("muted", "expand");
208
+ return `${theme.fg("dim", keys.join("/"))}${theme.fg("muted", " to expand")}`;
209
+ }
210
+
211
+ function replaceTabs(text: string): string {
212
+ return text.replace(/\t/g, " ");
213
+ }
214
+
133
215
  interface FrameParts {
134
216
  theme: Theme;
135
217
  body: Component;
@@ -1,193 +0,0 @@
1
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
-
3
- export const DESKTOP_THEME = "desktop";
4
-
5
- export type Mode = "dark" | "light";
6
-
7
- export interface DesktopColors {
8
- mode: Mode;
9
- roles: Readonly<Record<string, string>>;
10
- terminal: Readonly<Record<string, string>>;
11
- }
12
-
13
- export interface ThemeJson {
14
- $schema: string;
15
- name: string;
16
- vars: Record<string, string>;
17
- colors: Record<string, string>;
18
- export: { pageBg: string; cardBg: string; infoBg: string };
19
- }
20
-
21
- const HEX = /^#[0-9a-f]{6}$/i;
22
-
23
- export function parseDesktopColors(json: unknown, mode?: Mode): DesktopColors | undefined {
24
- if (!isRecord(json) || !isRecord(json.colors) || !isRecord(json.dank16)) return undefined;
25
- const chosen: Mode = mode ?? (json.mode === "light" ? "light" : "dark");
26
- const roles = json.colors[chosen];
27
- if (!isRecord(roles)) return undefined;
28
-
29
- const terminal: Record<string, string> = {};
30
- for (const [name, value] of Object.entries(json.dank16)) {
31
- const hex = isRecord(value) ? (value[chosen] ?? value.default) : value;
32
- if (typeof hex === "string" && HEX.test(hex)) terminal[name] = hex;
33
- }
34
-
35
- const material: Record<string, string> = {};
36
- for (const [name, value] of Object.entries(roles)) {
37
- if (typeof value === "string" && HEX.test(value)) material[name] = value;
38
- }
39
- return { mode: chosen, roles: material, terminal };
40
- }
41
-
42
- const REQUIRED_ROLES = [
43
- "primary",
44
- "secondary",
45
- "tertiary",
46
- "error",
47
- "error_container",
48
- "outline",
49
- "outline_variant",
50
- "on_surface",
51
- "on_surface_variant",
52
- "surface",
53
- "surface_container_low",
54
- "surface_container",
55
- "surface_container_high",
56
- "surface_container_highest",
57
- ];
58
- const REQUIRED_TERMINAL = ["color1", "color2", "color3", "color6", "color9"];
59
-
60
- export function missingColors(colors: DesktopColors): string[] {
61
- return [
62
- ...REQUIRED_ROLES.filter((role) => colors.roles[role] === undefined),
63
- ...REQUIRED_TERMINAL.filter((name) => colors.terminal[name] === undefined),
64
- ];
65
- }
66
-
67
- export function desktopTheme(colors: DesktopColors): ThemeJson {
68
- const role = (name: string): string => colors.roles[name] ?? "#808080";
69
- const term = (name: string): string => colors.terminal[name] ?? "#808080";
70
-
71
- const vars: Record<string, string> = {
72
- primary: role("primary"),
73
- secondary: role("secondary"),
74
- tertiary: role("tertiary"),
75
- error: role("error"),
76
- outline: role("outline"),
77
- outlineVariant: role("outline_variant"),
78
- text: role("on_surface"),
79
- subtext: role("on_surface_variant"),
80
- surface: role("surface"),
81
- containerLow: role("surface_container_low"),
82
- container: role("surface_container"),
83
- containerHigh: role("surface_container_high"),
84
- containerHighest: role("surface_container_highest"),
85
- red: term("color1"),
86
- green: term("color2"),
87
- yellow: term("color3"),
88
- cyan: term("color6"),
89
- brightRed: term("color9"),
90
- successBg: mix(role("surface_container_low"), term("color2"), 0.12),
91
- errorBg: mix(role("surface_container_low"), role("error"), 0.14),
92
- infoBg: mix(role("surface_container"), term("color3"), 0.1),
93
- };
94
-
95
- const colorsByToken: Record<ThemeColor | BackgroundToken, string> = {
96
- accent: "primary",
97
- border: "outline",
98
- borderAccent: "primary",
99
- borderMuted: "outlineVariant",
100
- success: "green",
101
- error: "error",
102
- warning: "yellow",
103
- muted: "subtext",
104
- dim: "outline",
105
- text: "text",
106
- thinkingText: "subtext",
107
-
108
- selectedBg: "containerHigh",
109
- scrollbarTrack: "outlineVariant",
110
- scrollbarThumb: "subtext",
111
- searchMatchBg: "containerHighest",
112
- searchMatchText: "text",
113
- userMessageBg: "container",
114
- userMessageText: "text",
115
- customMessageBg: "containerLow",
116
- customMessageText: "text",
117
- customMessageLabel: "tertiary",
118
- toolPendingBg: "containerLow",
119
- toolSuccessBg: "successBg",
120
- toolErrorBg: "errorBg",
121
- toolTitle: "text",
122
- toolOutput: "subtext",
123
-
124
- mdHeading: "tertiary",
125
- mdLink: "primary",
126
- mdLinkUrl: "outline",
127
- mdCode: "secondary",
128
- mdCodeBlock: "text",
129
- mdCodeBlockBorder: "outlineVariant",
130
- mdQuote: "subtext",
131
- mdQuoteBorder: "outlineVariant",
132
- mdHr: "outlineVariant",
133
- mdListBullet: "primary",
134
-
135
- toolDiffAdded: "green",
136
- toolDiffRemoved: "red",
137
- toolDiffContext: "subtext",
138
-
139
- syntaxComment: "outline",
140
- syntaxKeyword: "primary",
141
- syntaxFunction: "tertiary",
142
- syntaxVariable: "text",
143
- syntaxString: "green",
144
- syntaxNumber: "yellow",
145
- syntaxType: "cyan",
146
- syntaxOperator: "subtext",
147
- syntaxPunctuation: "subtext",
148
-
149
- thinkingOff: "outlineVariant",
150
- thinkingMinimal: "outline",
151
- thinkingLow: "secondary",
152
- thinkingMedium: "primary",
153
- thinkingHigh: "tertiary",
154
- thinkingXhigh: "brightRed",
155
- thinkingMax: "red",
156
-
157
- bashMode: "green",
158
- };
159
-
160
- return {
161
- $schema:
162
- "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
163
- name: DESKTOP_THEME,
164
- vars,
165
- colors: colorsByToken,
166
- export: { pageBg: vars.surface ?? "", cardBg: vars.container ?? "", infoBg: vars.infoBg ?? "" },
167
- };
168
- }
169
-
170
- type BackgroundToken =
171
- | "selectedBg"
172
- | "searchMatchBg"
173
- | "userMessageBg"
174
- | "customMessageBg"
175
- | "toolPendingBg"
176
- | "toolSuccessBg"
177
- | "toolErrorBg";
178
-
179
- export function mix(base: string, tint: string, amount: number): string {
180
- const a = channels(base);
181
- const b = channels(tint);
182
- const mixed = a.map((value, index) => Math.round(value + ((b[index] ?? value) - value) * amount));
183
- return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
184
- }
185
-
186
- function channels(hex: string): number[] {
187
- const value = HEX.test(hex) ? hex.slice(1) : "808080";
188
- return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16));
189
- }
190
-
191
- function isRecord(value: unknown): value is Record<string, unknown> {
192
- return typeof value === "object" && value !== null && !Array.isArray(value);
193
- }
@@ -1,79 +0,0 @@
1
- import { type FSWatcher, watch } from "node:fs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { basename, dirname, join } from "node:path";
5
-
6
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
-
8
- import { DESKTOP_THEME, desktopTheme, missingColors, parseDesktopColors } from "./palette.ts";
9
-
10
- const SETTLE_MS = 250;
11
-
12
- export type SyncResult =
13
- | { kind: "written"; path: string }
14
- | { kind: "unchanged"; path: string }
15
- | { kind: "missing" }
16
- | { kind: "invalid"; reason: string };
17
-
18
- export function themePath(): string {
19
- return join(getAgentDir(), "themes", `${DESKTOP_THEME}.json`);
20
- }
21
-
22
- export function expandHome(path: string): string {
23
- return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
24
- }
25
-
26
- export async function syncDesktopTheme(source: string): Promise<SyncResult> {
27
- let raw: string;
28
- try {
29
- raw = await readFile(expandHome(source), "utf8");
30
- } catch {
31
- return { kind: "missing" };
32
- }
33
-
34
- let json: unknown;
35
- try {
36
- json = JSON.parse(raw);
37
- } catch {
38
- return { kind: "invalid", reason: "the palette file is not JSON" };
39
- }
40
-
41
- const colors = parseDesktopColors(json);
42
- if (colors === undefined) return { kind: "invalid", reason: "not a DankMaterialShell palette" };
43
- const missing = missingColors(colors);
44
- if (missing.length > 0) return { kind: "invalid", reason: `missing ${missing.join(", ")}` };
45
-
46
- const path = themePath();
47
- const next = `${JSON.stringify(desktopTheme(colors), null, "\t")}\n`;
48
- const current = await readFile(path, "utf8").catch(() => undefined);
49
- if (current === next) return { kind: "unchanged", path };
50
-
51
- await mkdir(dirname(path), { recursive: true });
52
- await writeFile(path, next);
53
- return { kind: "written", path };
54
- }
55
-
56
- /** Watches the folder: an atomic write replaces the file, and a watch on the old one goes quiet. */
57
- export function watchDesktopPalette(source: string, changed: () => void): () => void {
58
- const path = expandHome(source);
59
- let timer: ReturnType<typeof setTimeout> | undefined;
60
- let watcher: FSWatcher | undefined;
61
-
62
- try {
63
- watcher = watch(dirname(path), (_event, file) => {
64
- if (file !== null && file !== basename(path)) return;
65
- if (timer !== undefined) clearTimeout(timer);
66
- timer = setTimeout(changed, SETTLE_MS);
67
- timer.unref?.();
68
- });
69
- watcher.unref?.();
70
- watcher.on("error", () => watcher?.close());
71
- } catch {
72
- return () => {};
73
- }
74
-
75
- return () => {
76
- if (timer !== undefined) clearTimeout(timer);
77
- watcher?.close();
78
- };
79
- }