@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.
@@ -1,6 +1,3 @@
1
- // What each piece says. A segment is a pure function of the snapshot: it returns a piece, with an
2
- // optional shorter form and a priority, or nothing when it has nothing to say. Where a segment goes is
3
- // the layout's business, so any segment can sit in any slot.
4
1
  import type { Snapshot } from "../data/snapshot.ts";
5
2
  import { promptOf, tokensOf, type Totals } from "../data/totals.ts";
6
3
  import type { Piece } from "./fit.ts";
@@ -35,6 +32,7 @@ export const SEGMENT_IDS = [
35
32
  "context",
36
33
  "speed",
37
34
  "wait",
35
+ "server",
38
36
  "elapsed",
39
37
  "last",
40
38
  "request",
@@ -47,12 +45,10 @@ export const SEGMENT_IDS = [
47
45
  ] as const;
48
46
 
49
47
  export type BuiltinSegment = (typeof SEGMENT_IDS)[number];
50
- /** One package's status on its own, by the key it passes to `setStatus`. */
51
48
  export type StatusSegment = `status:${string}`;
52
49
  export type SegmentId = BuiltinSegment | StatusSegment;
53
50
 
54
51
  const STATUS_PREFIX = "status:";
55
- /** A status placed on its own is something its package wants seen, like the permission mode. */
56
52
  const STATUS_PRIORITY = 95;
57
53
 
58
54
  export function isSegmentId(value: string): value is SegmentId {
@@ -62,7 +58,6 @@ export function isSegmentId(value: string): value is SegmentId {
62
58
  );
63
59
  }
64
60
 
65
- /** The status keys a layout places on their own, which the `statuses` segment then leaves out. */
66
61
  export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<string> {
67
62
  const claimed = new Set<string>();
68
63
  for (const ids of slots) {
@@ -73,13 +68,9 @@ export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<s
73
68
  }
74
69
 
75
70
  export interface SegmentOptions {
76
- /** Longest the folder may be before it is shortened from the left. 0 keeps all of it. */
77
71
  pathLength: number;
78
- /** Cells in the context gauge. 0 draws no gauge. */
79
72
  gaugeCells: number;
80
- /** Status keys placed on their own somewhere in the layout. */
81
73
  claimed: ReadonlySet<string>;
82
- /** A short word before each number, saying what it is. */
83
74
  labels: boolean;
84
75
  }
85
76
 
@@ -93,9 +84,7 @@ export interface SegmentInput {
93
84
  type Rendered = Omit<Piece, "priority"> | undefined;
94
85
 
95
86
  interface Segment {
96
- /** Higher stays longer when the line runs out of room. */
97
87
  priority: number;
98
- /** One line for the docs and the settings screen. */
99
88
  describe: string;
100
89
  render(input: SegmentInput): Rendered;
101
90
  }
@@ -201,7 +190,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
201
190
  },
202
191
  },
203
192
  effort: {
204
- // The frame is already painted in the effort colour, so the meter can go before the context.
205
193
  priority: 80,
206
194
  describe: "how hard the model thinks, as a meter that warms with the level",
207
195
  render: ({ snapshot, glyphs, paint }) => {
@@ -242,7 +230,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
242
230
  render: ({ snapshot, glyphs, paint, options }) => {
243
231
  const request = snapshot.request;
244
232
  if (request === undefined) return undefined;
245
- // An estimate is drawn quieter until the provider says the real count; a `~` would change the width.
246
233
  const out = speedCell(request.decode);
247
234
  const value = request.estimated ? paint.muted(out) : paint.bold(paint.role("speed", out));
248
235
  const down = `${flow("received", glyphs, paint, options)}${value}`;
@@ -254,11 +241,12 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
254
241
  },
255
242
  wait: {
256
243
  priority: 55,
257
- describe: "time to first token (ttft): how long the model took to start answering",
244
+ describe:
245
+ "time to first token (ttft): from the request leaving to the model writing its first token",
258
246
  render: ({ snapshot, glyphs, paint, options }) => {
259
247
  const request = snapshot.request;
260
248
  if (request === undefined) return undefined;
261
- const ms = request.waiting ? request.elapsedMs : request.firstTokenMs;
249
+ const ms = request.waiting ? request.waitingMs : request.waitMs;
262
250
  const icon = glyphs.wait === "" ? "" : `${paint.role("wait", glyphs.wait)} `;
263
251
  const value = cell(ms === undefined ? UNKNOWN : latency(ms), CELL.latency);
264
252
  return {
@@ -275,38 +263,53 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
275
263
  if (run === undefined) return undefined;
276
264
  const role: Role = run.running ? "running" : "done";
277
265
  const icon = mark(run.running ? glyphs.running : glyphs.done, paint, role);
278
- // A stopwatch keeps its shape as it counts, so the line does not move while it ticks.
279
266
  const time = paint.role(role, stopwatch(run.elapsedMs));
280
267
  const calls =
281
268
  run.requests <= 1
282
269
  ? ""
283
- : paint.dim(options.labels ? ` · ${run.requests} calls` : ` ×${run.requests}`);
270
+ : paint.dim(options.labels ? ` ${run.requests} calls` : ` ×${run.requests}`);
284
271
  return { text: `${icon}${time}${calls}`, compact: `${icon}${time}` };
285
272
  },
286
273
  },
287
274
  last: {
288
275
  priority: 55,
289
276
  describe:
290
- "the last call to the model that finished: its wait for the first token and its writing speed",
277
+ "the last call that finished: how long the model took to start, how long it thought, and how fast it wrote",
291
278
  render: ({ snapshot, glyphs, paint, options }) => {
292
279
  const last = snapshot.last;
293
280
  if (last === undefined) return undefined;
294
281
  const parts: string[] = [];
295
- if (last.firstTokenMs !== undefined) {
296
- const wait = paint.role("wait", latency(last.firstTokenMs));
282
+ if (last.waitMs !== undefined) {
283
+ const wait = paint.role("wait", latency(last.waitMs));
297
284
  parts.push(
298
285
  options.labels
299
286
  ? `${wait}${paint.dim(" wait")}`
300
287
  : `${mark(glyphs.wait, paint, "wait")}${wait}`,
301
288
  );
302
289
  }
290
+ if (last.thoughtMs !== undefined) {
291
+ const thought = paint.role("wait", latency(last.thoughtMs));
292
+ parts.push(
293
+ options.labels ? `${thought}${paint.dim(" thought")}` : `${thought}${paint.dim(" th")}`,
294
+ );
295
+ }
303
296
  if (last.decode !== undefined) {
304
297
  const speed = `${paint.role("received", glyphs.received)}${paint.role("speed", rate(last.decode))}`;
305
298
  parts.push(`${speed}${paint.dim(" tok/s")}`);
306
299
  }
307
300
  if (parts.length === 0) return undefined;
308
301
  const label = options.labels ? paint.dim("last call ") : "";
309
- return { text: `${label}${parts.join(paint.dim(" · "))}`, compact: parts[0] };
302
+ return { text: `${label}${parts.join(" ")}`, compact: parts[0] };
303
+ },
304
+ },
305
+ server: {
306
+ priority: 53,
307
+ describe:
308
+ "how long the provider took to answer the request, before the model wrote anything: send to headers",
309
+ render: ({ snapshot, paint }) => {
310
+ const ms = snapshot.last?.serverMs;
311
+ if (ms === undefined) return undefined;
312
+ return { text: `${paint.role("wait", latency(ms))}${paint.dim(" server")}` };
310
313
  },
311
314
  },
312
315
  request: {
@@ -318,7 +321,6 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
318
321
  const sent = usage.input + usage.cacheRead + usage.cacheWrite;
319
322
  const up = `${flow("sent", glyphs, paint, options)}${tokenCell(sent)}`;
320
323
  const down = `${flow("received", glyphs, paint, options)}${tokenCell(usage.output)}`;
321
- // Both cache cells are always there, so the breakdown never appears in the middle of the line.
322
324
  const cache = cacheParts(usage, glyphs, paint, options, true);
323
325
  return {
324
326
  text: `${up} ${paint.dim("(")}${cache}${paint.dim(")")} ${down}`,
@@ -424,14 +426,13 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
424
426
  if (texts.length === 0) return undefined;
425
427
  const icon = glyphs.plug === "" ? "" : `${paint.dim(glyphs.plug)} `;
426
428
  return {
427
- text: `${icon}${texts.join(paint.dim(" · "))}`,
429
+ text: `${icon}${texts.join(" ")}`,
428
430
  compact: `${icon}${texts[0] ?? ""}`,
429
431
  };
430
432
  },
431
433
  },
432
434
  };
433
435
 
434
- /** The pieces for one slot, in the order the slot lists them. */
435
436
  export function renderSegments(ids: readonly SegmentId[], input: SegmentInput): Piece[] {
436
437
  return ids.flatMap((id): Piece[] => {
437
438
  if (id.startsWith(STATUS_PREFIX)) {
@@ -452,10 +453,6 @@ function counted(value: number, glyph: string, role: Role, paint: Paint): string
452
453
  return value > 0 ? paint.role(role, `${glyph}${value}`) : "";
453
454
  }
454
455
 
455
- /**
456
- * Read from the cache, and written to it. With labels: `cache read 80k · wrote 1.3k`. `fixed` keeps both
457
- * cells with a dash for zero, for a line that must not change shape.
458
- */
459
456
  function cacheParts(
460
457
  usage: Totals,
461
458
  glyphs: Glyphs,
@@ -471,7 +468,7 @@ function cacheParts(
471
468
  read === "" ? "" : `${paint.dim("read ")}${paint.role("cache", read)}`,
472
469
  write === "" ? "" : `${paint.dim("wrote ")}${paint.role("cache", write)}`,
473
470
  ].filter((part) => part !== "");
474
- return parts.length === 0 ? "" : `${paint.dim("cache ")}${parts.join(paint.dim(" · "))}`;
471
+ return parts.length === 0 ? "" : `${paint.dim("cache ")}${parts.join(" ")}`;
475
472
  }
476
473
  return [
477
474
  read === "" ? "" : `${paint.role("cache", glyphs.cacheRead)}${read}`,
@@ -481,10 +478,6 @@ function cacheParts(
481
478
  .join(" ");
482
479
  }
483
480
 
484
- /**
485
- * The direction of a flow of tokens, from where you sit: ↑ in, what is sent to the model, and ↓ out,
486
- * what comes back. With labels the word follows the arrow.
487
- */
488
481
  function flow(
489
482
  role: "sent" | "received",
490
483
  glyphs: Glyphs,
@@ -496,7 +489,6 @@ function flow(
496
489
  return paint.role(role, options.labels ? `${arrow}${name} ` : arrow);
497
490
  }
498
491
 
499
- /** A dim word in front of a value, when labels are on. `skip` when the glyph already is the word. */
500
492
  function word(options: SegmentOptions, text: string, paint: Paint, skip = false): string {
501
493
  return options.labels && !skip && text !== "" ? paint.dim(`${text} `) : "";
502
494
  }
package/src/settings.ts CHANGED
@@ -1,7 +1,6 @@
1
- // Every setting of the look, under `look.` in the shared settings file. The slots are plain lists of
2
- // segment ids, so moving a piece from the frame to the footer is editing one array.
3
1
  import {
4
2
  boolean,
3
+ type Control,
5
4
  type Decoder,
6
5
  integer,
7
6
  literal,
@@ -13,9 +12,24 @@ import {
13
12
 
14
13
  import type { FrameStyle } from "./render/frame.ts";
15
14
  import type { IconMode } from "./render/glyphs.ts";
16
- import { isSegmentId, type SegmentId } from "./render/segments.ts";
15
+ import { isSegmentId, SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
17
16
 
18
- const GROUP = "Look";
17
+ const PERMISSION_STATUS: SegmentId = "status:pi-ask-permission:mode";
18
+ const ACCOUNT_STATUS: SegmentId = "status:pi-providers:account";
19
+
20
+ const STATUS_LABELS: Partial<Record<SegmentId, string>> = {
21
+ [PERMISSION_STATUS]: "permission mode",
22
+ [ACCOUNT_STATUS]: "account",
23
+ };
24
+
25
+ export const SECTIONS = [
26
+ "Editor",
27
+ "Slots",
28
+ "Footer",
29
+ "Start screen",
30
+ "Working line",
31
+ "Desktop theme",
32
+ ];
19
33
 
20
34
  export type HeaderStyle = "card" | "compact" | "off";
21
35
  export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
@@ -28,7 +42,6 @@ export const SEPARATORS: Record<SeparatorStyle, string> = {
28
42
  space: " ",
29
43
  };
30
44
 
31
- /** A list of known segment ids. Unknown ones are dropped with a warning, not the whole list. */
32
45
  function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
33
46
  return {
34
47
  decode(input, path) {
@@ -50,13 +63,33 @@ function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
50
63
  };
51
64
  }
52
65
 
66
+ // Any other package's status can have a slot of its own, so the ones already placed are offered too.
67
+ function slotControl(fallback: readonly SegmentId[]): Control {
68
+ const statuses = [
69
+ PERMISSION_STATUS,
70
+ ACCOUNT_STATUS,
71
+ ...fallback.filter((id) => id.startsWith("status:")),
72
+ ];
73
+ return {
74
+ type: "list",
75
+ options: [
76
+ ...SEGMENT_IDS.map((id) => ({ value: id, description: SEGMENTS[id].describe })),
77
+ ...[...new Set(statuses)].map((id) => ({
78
+ value: id,
79
+ label: STATUS_LABELS[id] ?? id.slice("status:".length),
80
+ description: `what ${id.slice("status:".length)} reports`,
81
+ })),
82
+ ],
83
+ };
84
+ }
85
+
53
86
  function slot(id: string, label: string, description: string, fallback: readonly SegmentId[]) {
54
87
  return setting<SegmentId[]>({
55
88
  id: `look.${id}`,
56
89
  default: [...fallback],
57
90
  decoder: segments(fallback),
58
91
  project: true,
59
- ui: { group: GROUP, label, description },
92
+ ui: { section: "Slots", label, description, control: slotControl(fallback) },
60
93
  });
61
94
  }
62
95
 
@@ -74,14 +107,13 @@ export const topRight = slot("frame.topRight", "Frame, top right", "Where you ar
74
107
  "path",
75
108
  "host",
76
109
  ]);
77
- /** Where pi-ask-permission reports its mode. It says what happens to what you type, so it sits by the editor. */
78
- export const PERMISSION_MODE: SegmentId = "status:pi-ask-permission:mode";
110
+ export const PERMISSION_MODE: SegmentId = PERMISSION_STATUS;
79
111
 
80
112
  export const bottomLeft = slot(
81
113
  "frame.bottomLeft",
82
114
  "Frame, bottom left",
83
115
  "How what you type is handled, and who does the work.",
84
- [PERMISSION_MODE, "model", "effort"],
116
+ [PERMISSION_MODE, "model", ACCOUNT_STATUS, "effort"],
85
117
  );
86
118
  export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
87
119
  "context",
@@ -99,7 +131,7 @@ export const frame = setting<FrameStyle | "off">({
99
131
  decoder: literal("rounded", "square", "heavy", "line", "off"),
100
132
  project: true,
101
133
  ui: {
102
- group: GROUP,
134
+ section: "Editor",
103
135
  label: "Frame",
104
136
  description:
105
137
  "The box around the editor. `line` keeps pi's two rules, `off` leaves the editor alone and moves the frame slots to the footer.",
@@ -111,7 +143,7 @@ export const cursor = setting<CursorStyle>({
111
143
  default: "bar",
112
144
  decoder: literal("block", "bar", "underline"),
113
145
  ui: {
114
- group: GROUP,
146
+ section: "Editor",
115
147
  label: "Cursor",
116
148
  description: "Bar and underline use the terminal's own cursor, so it blinks the way it does.",
117
149
  },
@@ -123,7 +155,7 @@ export const header = setting<HeaderStyle>({
123
155
  decoder: literal("card", "compact", "off"),
124
156
  project: true,
125
157
  ui: {
126
- group: GROUP,
158
+ section: "Start screen",
127
159
  label: "Start screen",
128
160
  description: "The card pi opens with. Off keeps pi's own.",
129
161
  },
@@ -134,7 +166,7 @@ export const icons = setting<IconMode>({
134
166
  default: "auto",
135
167
  decoder: literal("auto", "nerd", "unicode", "ascii"),
136
168
  ui: {
137
- group: GROUP,
169
+ section: "Footer",
138
170
  label: "Icons",
139
171
  description: "Auto uses Nerd Font glyphs locally, plain Unicode over SSH.",
140
172
  },
@@ -142,10 +174,10 @@ export const icons = setting<IconMode>({
142
174
 
143
175
  export const separator = setting<SeparatorStyle>({
144
176
  id: "look.separator",
145
- default: "dot",
177
+ default: "space",
146
178
  decoder: literal("dot", "bar", "slash", "space"),
147
179
  project: true,
148
- ui: { group: GROUP, label: "Separator", description: "Between the pieces of a slot." },
180
+ ui: { section: "Footer", label: "Separator", description: "Between the pieces of a slot." },
149
181
  });
150
182
 
151
183
  export const pathLength = setting({
@@ -154,7 +186,7 @@ export const pathLength = setting({
154
186
  decoder: integer(0, 500),
155
187
  project: true,
156
188
  ui: {
157
- group: GROUP,
189
+ section: "Footer",
158
190
  label: "Path length",
159
191
  description: "Longest the folder may be before it is shortened from the left. 0 keeps it all.",
160
192
  },
@@ -165,7 +197,7 @@ export const gaugeCells = setting({
165
197
  default: 8,
166
198
  decoder: integer(0, 40),
167
199
  project: true,
168
- ui: { group: GROUP, label: "Gauge", description: "Cells in the context gauge. 0 hides it." },
200
+ ui: { section: "Footer", label: "Gauge", description: "Cells in the context gauge. 0 hides it." },
169
201
  });
170
202
 
171
203
  export const labels = setting({
@@ -174,7 +206,7 @@ export const labels = setting({
174
206
  decoder: boolean,
175
207
  project: true,
176
208
  ui: {
177
- group: GROUP,
209
+ section: "Footer",
178
210
  label: "Labels",
179
211
  description:
180
212
  "A short word before each number (out, in, ttft, took, ctx, cache). Off is denser.",
@@ -186,10 +218,10 @@ export const peek = setting({
186
218
  default: true,
187
219
  decoder: boolean,
188
220
  ui: {
189
- group: GROUP,
221
+ section: "Working line",
190
222
  label: "Working line",
191
223
  description:
192
- "The spinner says what the model is doing: the tail of its thinking, the tool it calls.",
224
+ "The spinner names the state: waiting, thinking, writing, drafting a tool call, running it.",
193
225
  },
194
226
  });
195
227
 
@@ -198,7 +230,7 @@ export const desktop = setting({
198
230
  default: true,
199
231
  decoder: boolean,
200
232
  ui: {
201
- group: GROUP,
233
+ section: "Desktop theme",
202
234
  label: "Desktop theme",
203
235
  description:
204
236
  "Write the matugen palette from DankMaterialShell as the pi theme `desktop`, and keep it in sync.",
@@ -210,7 +242,7 @@ export const desktopSource = setting({
210
242
  default: "~/.cache/DankMaterialShell/dms-colors.json",
211
243
  decoder: string,
212
244
  ui: {
213
- group: GROUP,
245
+ section: "Desktop theme",
214
246
  label: "Desktop palette",
215
247
  description: "The DankMaterialShell colour file the desktop theme is made from.",
216
248
  },
package/src/ui/editor.ts CHANGED
@@ -1,6 +1,3 @@
1
- // The editor, framed. Pi's editor draws a rule above and below the text; this one draws a box, writes
2
- // four slots into its borders, and puts the working spinner in the top-left corner. Typing, history, autocomplete and every app key stay pi's: the class only
3
- // changes what the lines look like, and shifts mouse clicks by the width of the frame.
4
1
  import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
5
2
  import {
6
3
  type EditorTheme,
@@ -18,14 +15,12 @@ import { type Screen, type SlotName, slotPieces } from "./screen.ts";
18
15
 
19
16
  type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
20
17
 
21
- /** Pi calls the border hooks inside its render; these stand in for the lines this class draws. */
18
+ /** Pi calls the border hooks inside its render; these mark where this class draws its own lines. */
22
19
  const TOP = "\u0000look:top\u0000";
23
20
  const BOTTOM = "\u0000look:bottom\u0000";
24
21
  // oxlint-disable-next-line no-control-regex -- pi's software cursor is an escape sequence
25
22
  const INVERTED_CELL = /\x1b\[7m([\s\S]*?)\x1b\[0m/;
26
- /** Below this the frame is noise, so pi's plain editor draws instead. */
27
23
  const MIN_WIDTH = 24;
28
- /** The spinner always gets this much of the top border, enough for "⠋ Thinking". */
29
24
  const LEAD_MIN = 10;
30
25
 
31
26
  const CURSOR_SEQUENCES: Record<CursorStyle, string> = {
@@ -40,7 +35,6 @@ export class LookEditor extends CustomEditor {
40
35
  #framing = false;
41
36
  #hiddenAbove = 0;
42
37
  #hiddenBelow = 0;
43
- /** Columns the frame takes on each side, for mouse clicks. */
44
38
  #inset = 0;
45
39
 
46
40
  constructor(
@@ -155,7 +149,6 @@ export class LookEditor extends CustomEditor {
155
149
  const text = cursor === "block" ? line : withoutSoftwareCursor(line);
156
150
  result.push(`${left}${fill(text, inner)}${right}`);
157
151
  } else {
158
- // The autocomplete list, lined up with the text so clicks land where they look.
159
152
  result.push(`${" ".repeat(this.#inset)}${line}`);
160
153
  }
161
154
  }
@@ -163,7 +156,6 @@ export class LookEditor extends CustomEditor {
163
156
  return result.map((line) => truncateToWidth(line, width, ""));
164
157
  }
165
158
 
166
- /** The spinner and its message, in the room the slots left. Just the spinner when it is tight. */
167
159
  #lead(budget: number): string | undefined {
168
160
  const indicator = this.#indicator;
169
161
  if (indicator === undefined) return undefined;
@@ -173,21 +165,16 @@ export class LookEditor extends CustomEditor {
173
165
  }
174
166
  }
175
167
 
176
- /**
177
- * Pi draws its cursor as an inverted cell. With the terminal's own cursor showing, that would be two
178
- * cursors, so the inversion goes and the character stays.
179
- */
168
+ /** With the terminal's cursor showing, pi's inverted cell would be a second cursor. */
180
169
  function withoutSoftwareCursor(line: string): string {
181
170
  return line.replace(INVERTED_CELL, "$1");
182
171
  }
183
172
 
184
- /** Pads a content line to the inner width, so the right rail stays straight. */
185
173
  function fill(line: string, width: number): string {
186
174
  const clipped = truncateToWidth(line, width, "");
187
175
  return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
188
176
  }
189
177
 
190
- /** Sets the terminal cursor shape. Pi draws its own block cursor, so the others need the hardware one. */
191
178
  export function applyCursor(tui: TUI, style: CursorStyle): void {
192
179
  if (style !== "block") tui.setShowHardwareCursor(true);
193
180
  tui.terminal.write(CURSOR_SEQUENCES[style]);
package/src/ui/footer.ts CHANGED
@@ -1,6 +1,3 @@
1
- // The lines around the editor that are not the frame. The strip above it is the answer being written;
2
- // the footer below it is the session. With the frame off, the footer also carries the four frame
3
- // slots, so turning the box off never hides data.
4
1
  import type { ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
5
2
  import { type Component, type TUI, visibleWidth } from "@earendil-works/pi-tui";
6
3
 
@@ -8,16 +5,13 @@ import { fitLine, fitRegions, type Piece } from "../render/fit.ts";
8
5
  import type { Paint } from "../render/paint.ts";
9
6
  import { type Screen, type SlotName, slotPieces } from "./screen.ts";
10
7
 
11
- /** At least this much between a left and a right group sharing a line. */
12
8
  const GAP = 3;
13
9
 
14
- /** Where the text starts, so the strip and the footer line up with the text inside the frame. */
15
10
  export function indentOf(screen: Screen): number {
16
11
  const style = screen.frameStyle();
17
12
  return style === "off" || style === "line" ? 1 : 2;
18
13
  }
19
14
 
20
- /** The strip above the editor. Blank until the first answer, but its line is always there. */
21
15
  export class StripComponent implements Component {
22
16
  readonly #screen: Screen;
23
17
 
@@ -36,7 +30,6 @@ export class StripComponent implements Component {
36
30
  }
37
31
 
38
32
  export interface FooterHooks {
39
- /** The footer data is where the branch and other packages' statuses come from. */
40
33
  attach(data: ReadonlyFooterDataProvider, repaint: () => void): void;
41
34
  detach(): void;
42
35
  }
@@ -67,7 +60,6 @@ export class FooterComponent implements Component {
67
60
  lines.push(renderSlot(screen, "below", room));
68
61
 
69
62
  const kept = lines.filter((line) => line !== "").map((line) => `${pad}${line}`);
70
- // At least one line, for the same reason as the strip: the editor must not jump when it fills.
71
63
  return kept.length === 0 ? [""] : kept;
72
64
  }
73
65
 
@@ -86,7 +78,6 @@ function renderSlot(screen: Screen, name: SlotName, width: number): string {
86
78
  return fitLine(pieces, width, fitOptions(screen, paint));
87
79
  }
88
80
 
89
- /** A left group and a right group on one line, pushed apart. */
90
81
  function spread(screen: Screen, leftSlot: SlotName, rightSlot: SlotName, width: number): string {
91
82
  if (width <= 0) return "";
92
83
  const paint = screen.paint();
package/src/ui/header.ts CHANGED
@@ -1,6 +1,3 @@
1
- // The start screen. A card with the π drawn in blocks, coloured along the theme's effort ramp, next to
2
- // what the session starts with: the model, the folder, the machine, what loaded, and the keys worth
3
- // knowing. Narrow terminals get two plain lines instead.
4
1
  import { keyText } from "@earendil-works/pi-coding-agent";
5
2
  import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
3
 
@@ -10,7 +7,6 @@ import { renderSegments, type SegmentId } from "../render/segments.ts";
10
7
  import type { HeaderStyle } from "../settings.ts";
11
8
  import type { Screen } from "./screen.ts";
12
9
 
13
- /** The π, one character per pixel, drawn two pixels per row with half blocks. */
14
10
  const PIXELS = [
15
11
  "..............",
16
12
  ".############.",
@@ -24,10 +20,8 @@ const PIXELS = [
24
20
  ".##.......####",
25
21
  ];
26
22
  const LOGO = halfBlocks(PIXELS);
27
- /** One effort level per row, from cool at the top to hot at the bottom. */
28
23
  const LOGO_RAMP = EFFORT_LEVELS.slice(1);
29
24
  const LOGO_COLUMN = 20;
30
- /** Under this the right column is too cramped to share with the logo. */
31
25
  const MIN_RIGHT = 34;
32
26
  const MIN_CARD = 48;
33
27
 
@@ -150,7 +144,6 @@ export class HeaderComponent implements Component {
150
144
  return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
151
145
  }
152
146
 
153
- /** Three rows of two hints each, or one column when narrow. */
154
147
  #hints(paint: Paint, width: number): string[] {
155
148
  const hints = HINTS();
156
149
  const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
@@ -171,7 +164,6 @@ export class HeaderComponent implements Component {
171
164
  }
172
165
  }
173
166
 
174
- /** Read on every render, so a remapped key shows the new binding. */
175
167
  function HINTS(): Hint[] {
176
168
  return [
177
169
  { key: keyText("app.model.select") || "/model", does: "model" },
package/src/ui/screen.ts CHANGED
@@ -1,6 +1,3 @@
1
- // What the components read. The feature implements it over the live session and the settings; tests
2
- // implement it over a fixed snapshot. Everything is read at render time, so a setting or theme change
3
- // shows on the next frame with nothing to invalidate.
4
1
  import type { Snapshot } from "../data/snapshot.ts";
5
2
  import type { Piece } from "../render/fit.ts";
6
3
  import type { FrameStyle } from "../render/frame.ts";
@@ -14,7 +11,6 @@ export type SlotName = "strip" | "topLeft" | "topRight" | "bottomLeft" | "bottom
14
11
  export interface Screen {
15
12
  snapshot(): Snapshot;
16
13
  glyphs(): Glyphs;
17
- /** Paints with the active theme. `frame` overrides the border colour, which the editor owns. */
18
14
  paint(frame?: (text: string) => string): Paint;
19
15
  separator(): string;
20
16
  options(): SegmentOptions;
@@ -23,7 +19,6 @@ export interface Screen {
23
19
  cursor(): CursorStyle;
24
20
  }
25
21
 
26
- /** The rendered pieces of one slot. The separator is painted here, once, for everyone. */
27
22
  export function slotPieces(
28
23
  screen: Screen,
29
24
  name: SlotName,
package/src/ui/working.ts CHANGED
@@ -1,73 +1,25 @@
1
- // What the spinner says. Pi's says "Working" for the whole run; this one says what is happening: the
2
- // newest line of the model's thinking, the tool call it is writing, the command running. It only
3
- // speaks through pi's public `setWorkingMessage`, so pi still owns the spinner and where it sits.
4
-
5
1
  export type Activity =
6
2
  | { kind: "waiting" }
7
- | { kind: "thinking"; tail: string }
3
+ | { kind: "thinking" }
8
4
  | { kind: "writing" }
9
- | { kind: "calling"; tool: string }
10
- | { kind: "running"; tool: string; detail: string };
5
+ | { kind: "drafting"; tool: string }
6
+ | { kind: "running"; tool: string };
11
7
 
12
- /** One line, plain text: pi paints the message itself. */
13
8
  export function describe(activity: Activity): string {
14
9
  switch (activity.kind) {
15
10
  case "waiting":
16
- return "Waiting";
17
11
  case "thinking":
18
- return activity.tail === "" ? "Thinking" : `Thinking · ${activity.tail}`;
19
12
  case "writing":
20
- return "Writing";
21
- case "calling":
22
- return `Calling ${activity.tool}`;
13
+ return activity.kind;
14
+ case "drafting":
15
+ return `drafting ${activity.tool.toLowerCase()}`;
23
16
  case "running":
24
- return activity.detail === "" ? activity.tool : `${activity.tool} · ${activity.detail}`;
17
+ return `running ${activity.tool.toLowerCase()}`;
25
18
  }
26
19
  }
27
20
 
28
- interface ContentPart {
29
- type?: unknown;
30
- thinking?: unknown;
31
- name?: unknown;
32
- }
33
-
34
- /** The newest line of the thinking so far. Model output, so control characters go. */
35
- export function thinkingTail(content: unknown): string {
36
- if (!Array.isArray(content)) return "";
37
- for (let index = content.length - 1; index >= 0; index--) {
38
- const part = content[index] as ContentPart | undefined;
39
- if (part?.type !== "thinking" || typeof part.thinking !== "string") continue;
40
- const lines = part.thinking.split("\n").map(clean).filter(Boolean);
41
- return lines.at(-1) ?? "";
42
- }
43
- return "";
44
- }
45
-
46
- /** The name of the tool call being written, when the model is writing one. */
47
- export function callingTool(content: unknown): string | undefined {
21
+ export function draftedTool(content: unknown): string | undefined {
48
22
  if (!Array.isArray(content)) return undefined;
49
- const part = content.at(-1) as ContentPart | undefined;
23
+ const part = content.at(-1) as { type?: unknown; name?: unknown } | undefined;
50
24
  return part?.type === "toolCall" && typeof part.name === "string" ? part.name : undefined;
51
25
  }
52
-
53
- const DETAIL_KEYS = ["command", "path", "file_path", "pattern", "query", "url", "prompt"];
54
-
55
- /** The one argument that says what a tool is doing: the command, the file, the pattern. */
56
- export function toolDetail(args: unknown): string {
57
- if (typeof args !== "object" || args === null) return "";
58
- const record = args as Record<string, unknown>;
59
- for (const key of DETAIL_KEYS) {
60
- const value = record[key];
61
- if (typeof value === "string" && value.trim() !== "") {
62
- return clean(value.split("\n").find((line) => line.trim() !== "") ?? "");
63
- }
64
- }
65
- return "";
66
- }
67
-
68
- // oxlint-disable-next-line no-control-regex -- escape sequences and control characters are the target
69
- const CONTROL = /\x1b\[[0-?]*[ -/]*[@-~]|[\x00-\x1f\x7f]/g;
70
-
71
- function clean(text: string): string {
72
- return text.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
73
- }