@adeildo/pi-look 5.11.0 → 5.11.2

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeildo/pi-look",
3
- "version": "5.11.0",
3
+ "version": "5.11.2",
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.11.0"
41
+ "@adeildo/pi-kit": "5.11.2"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@earendil-works/pi-ai": "*",
@@ -7,6 +7,7 @@ import { resolve } from "node:path";
7
7
  // where the model is writing, and what it has written so far on the bottom rule.
8
8
  import { GLYPH, isObject } from "@adeildo/pi-kit";
9
9
  import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
10
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
11
 
11
12
  import { tabs } from "./format.ts";
12
13
 
@@ -19,6 +20,8 @@ export interface Stream {
19
20
  export interface StreamContext {
20
21
  cwd: string;
21
22
  expanded: boolean;
23
+ /** The model is still writing the call, so the cursor shows and the last key may be cut short. */
24
+ writing: boolean;
22
25
  /** Shared by every render of one call, which keeps what was read from disk. */
23
26
  state: { streamTarget?: Target };
24
27
  }
@@ -50,26 +53,33 @@ export function writeStream(
50
53
  theme: Theme,
51
54
  context: StreamContext,
52
55
  ): Stream | undefined {
53
- const path = pathOf(args);
56
+ // The model may write the content before the path, so the stream does not wait for the path.
54
57
  const content = field(args, "content");
55
- if (path === undefined || content === undefined) return undefined;
58
+ if (content === undefined) return undefined;
59
+ const path = pathOf(args);
56
60
 
57
- const target = targetOf(path, context);
58
- const header = `${pathLine("write", path, theme)} ${theme.fg("muted", replaces(target))}`;
61
+ // A path still arriving is not read: it may name another file, or a folder, on the way.
62
+ const target =
63
+ path !== undefined && pathSettled(args, context) ? targetOf(path, context) : undefined;
64
+ const note = target === undefined ? "" : ` ${theme.fg("muted", replaces(target))}`;
59
65
  const lines = content.split("\n");
60
66
  const shown = context.expanded ? lines : lines.slice(-WRITE_TAIL);
61
67
  const first = lines.length - shown.length;
62
68
  const gutter = String(lines.length).length;
63
- const lang = getLanguageFromPath(path);
69
+ const lang = path === undefined ? undefined : getLanguageFromPath(path);
64
70
  const painted = lang
65
- ? highlightCode(tabs(shown.join("\n")), lang)
66
- : shown.map((line) => theme.fg("toolOutput", tabs(line)));
71
+ ? highlightCode(shown.map(printable).join("\n"), lang)
72
+ : shown.map((line) => theme.fg("toolOutput", printable(line)));
67
73
  const body = shown.map(
68
74
  (_, index) =>
69
75
  `${theme.fg("dim", String(first + index + 1).padStart(gutter))} ${painted[index] ?? ""}`,
70
76
  );
71
77
  return {
72
- lines: [header, ...above(first, gutter, theme), ...withCursor(body, theme)],
78
+ lines: [
79
+ pathLine("write", path, theme) + note,
80
+ ...above(first, gutter, theme),
81
+ ...withCursor(body, theme, context.writing),
82
+ ],
73
83
  progress: [`${lines.length} line${lines.length === 1 ? "" : "s"}`, size(content)],
74
84
  };
75
85
  }
@@ -81,7 +91,7 @@ export function editStream(
81
91
  ): Stream | undefined {
82
92
  const path = pathOf(args);
83
93
  const edits = editsOf(args);
84
- if (path === undefined || edits.length === 0) return undefined;
94
+ if (edits.length === 0) return undefined;
85
95
 
86
96
  let removed = 0;
87
97
  let added = 0;
@@ -89,18 +99,22 @@ export function editStream(
89
99
  for (const [index, edit] of edits.entries()) {
90
100
  if (index > 0) diff.push(theme.fg("dim", GLYPH.ellipsisAcross));
91
101
  // Only the last edit can still be arriving, so only the others have a known end.
92
- const lines = editLines(edit, index < edits.length - 1);
93
- for (const line of lines.before) diff.push(theme.fg("toolDiffContext", ` ${tabs(line)}`));
94
- for (const line of lines.out) diff.push(theme.fg("toolDiffRemoved", `-${tabs(line)}`));
95
- for (const line of lines.in) diff.push(theme.fg("toolDiffAdded", `+${tabs(line)}`));
96
- for (const line of lines.after) diff.push(theme.fg("toolDiffContext", ` ${tabs(line)}`));
102
+ const lines = editLines(edit, !context.writing || index < edits.length - 1);
103
+ for (const line of lines.before) diff.push(theme.fg("toolDiffContext", ` ${printable(line)}`));
104
+ for (const line of lines.out) diff.push(theme.fg("toolDiffRemoved", `-${printable(line)}`));
105
+ for (const line of lines.in) diff.push(theme.fg("toolDiffAdded", `+${printable(line)}`));
106
+ for (const line of lines.after) diff.push(theme.fg("toolDiffContext", ` ${printable(line)}`));
97
107
  removed += lines.out.length;
98
108
  added += lines.in.length;
99
109
  }
100
110
  const shown = context.expanded ? diff : diff.slice(-EDIT_TAIL);
101
111
  const hidden = diff.length - shown.length;
102
112
  return {
103
- lines: [pathLine("edit", path, theme), ...above(hidden, 0, theme), ...withCursor(shown, theme)],
113
+ lines: [
114
+ pathLine("edit", path, theme),
115
+ ...above(hidden, 0, theme),
116
+ ...withCursor(shown, theme, context.writing),
117
+ ],
104
118
  progress: [...(edits.length > 1 ? [`edit ${edits.length}`] : []), `-${removed} +${added}`],
105
119
  };
106
120
  }
@@ -136,10 +150,29 @@ export function editLines(
136
150
  }
137
151
 
138
152
  /** The first line pi's own renderer draws, so nothing moves when it takes over. */
139
- function pathLine(tool: string, path: string, theme: Theme): string {
153
+ function pathLine(tool: string, path: string | undefined, theme: Theme): string {
154
+ const title = theme.fg("toolTitle", theme.bold(tool));
155
+ // Pi's own stand-in for a path not written yet.
156
+ if (path === undefined) return `${title} ${theme.fg("toolOutput", "...")}`;
140
157
  const home = homedir();
141
158
  const shown = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
142
- return `${theme.fg("toolTitle", theme.bold(tool))} ${theme.fg("accent", shown)}`;
159
+ return `${title} ${theme.fg("accent", printable(shown))}`;
160
+ }
161
+
162
+ /**
163
+ * A line as the terminal can print it inside the frame: a carriage return would send the cursor
164
+ * back over the border, and a colour code or a bell in the file would reach the terminal.
165
+ */
166
+ function printable(line: string): string {
167
+ // oxlint-disable-next-line no-control-regex -- control characters are what this removes
168
+ return tabs(stripTerminalSequences(line).replace(/[\u0000-\u0008\u000a-\u001f\u007f]/g, ""));
169
+ }
170
+
171
+ /** A key the model has moved past is complete, and so is every key once the call is written. */
172
+ function pathSettled(args: unknown, context: StreamContext): boolean {
173
+ if (!context.writing || !isObject(args)) return true;
174
+ const last = Object.keys(args).at(-1);
175
+ return last !== "path" && last !== "file_path";
143
176
  }
144
177
 
145
178
  function above(hidden: number, gutter: number, theme: Theme): string[] {
@@ -150,8 +183,8 @@ function above(hidden: number, gutter: number, theme: Theme): string[] {
150
183
  ];
151
184
  }
152
185
 
153
- function withCursor(lines: readonly string[], theme: Theme): string[] {
154
- if (lines.length === 0) return [];
186
+ function withCursor(lines: readonly string[], theme: Theme, writing: boolean): string[] {
187
+ if (!writing || lines.length === 0) return [...lines];
155
188
  return [...lines.slice(0, -1), `${lines.at(-1)}${theme.fg("accent", CURSOR)}`];
156
189
  }
157
190
 
@@ -45,6 +45,7 @@ const RESULT_PREVIEW_LINES = 10;
45
45
 
46
46
  export function registerToolFrames(scope: FeatureScope): void {
47
47
  const redraws = new Map<string, () => void>();
48
+ const written = new Map<string, number>();
48
49
  // The set is asked for on every drawing of every frame, and it changes only with the setting.
49
50
  const set = glyphsOf(() => icons.get(scope));
50
51
  scope.events.on(FRAMED, (data) => {
@@ -53,12 +54,20 @@ export function registerToolFrames(scope: FeatureScope): void {
53
54
  scope.events.on(RULING_CHANGED, (data) => {
54
55
  if (isObject(data) && typeof data.toolCallId === "string") redraws.get(data.toolCallId)?.();
55
56
  });
57
+ // Pi completes every call of a message only when the message ends, so a call the model finished
58
+ // before writing the next one would go on writing. The provider says when each one ends. Pi tells
59
+ // the extensions first and hands every call its arguments right after, which draws the frame.
60
+ scope.on("message_update", (event) => {
61
+ const update = event.assistantMessageEvent;
62
+ if (update.type === "toolcall_end") written.set(update.toolCall.id, Date.now());
63
+ });
56
64
 
57
65
  scope.registerToolRenderer((toolName, next) =>
58
66
  framed(scope, next(), {
59
67
  name: toolName,
60
68
  set: () => set().set,
61
69
  redraws,
70
+ written,
62
71
  }),
63
72
  );
64
73
  }
@@ -67,6 +76,8 @@ interface FrameContext {
67
76
  name: string;
68
77
  set: () => IconSet;
69
78
  redraws: Map<string, () => void>;
79
+ /** When the model finished writing each call, until its frame reads it. */
80
+ written: Map<string, number>;
70
81
  }
71
82
 
72
83
  function framed(
@@ -95,37 +106,72 @@ function framed(
95
106
  frame.redraws.set(context.toolCallId, context.invalidate);
96
107
  const clock = clockOf(context);
97
108
  const now = Date.now();
98
- clock.writingAt ??= now;
99
- if (context.argsComplete) clock.writtenAt ??= now;
109
+ const ended = frame.written.get(context.toolCallId);
110
+ frame.written.delete(context.toolCallId);
111
+ // A call replayed from the session, or one already running, was written long ago.
112
+ const done =
113
+ ended !== undefined ||
114
+ context.argsComplete ||
115
+ context.executionStarted ||
116
+ !context.isPartial;
117
+ // Only a call seen while the model wrote it has a time it took to write.
118
+ if (!done) clock.writingAt ??= now;
119
+ else clock.writtenAt ??= ended ?? now;
100
120
  if (context.executionStarted) clock.startedAt ??= now;
121
+ const writing = clock.writtenAt === undefined;
122
+ const ruling = rulingOf(scope.events, context.toolCallId);
123
+
124
+ // Pi hands every call of a message its arguments again on each piece the model writes, so a
125
+ // call that did not change keeps the frame it has, and its renderer is not asked again.
126
+ const inputs = [
127
+ args,
128
+ theme,
129
+ ruling,
130
+ writing,
131
+ context.expanded,
132
+ context.isPartial,
133
+ context.executionStarted,
134
+ ];
135
+ const last = context.lastComponent;
136
+ if (last instanceof CallFrame && last.drawnFrom(inputs)) return last;
137
+
101
138
  const stream = streamer?.(args, theme, {
102
139
  cwd: context.cwd,
103
140
  expanded: context.expanded,
141
+ writing,
104
142
  state: context.state as object,
105
143
  });
106
- const writing = stream !== undefined && !context.argsComplete;
107
- // While the model writes, pi's renderer has nothing new to show, so it is not asked.
108
- const own = writing
144
+ // While the model writes, pi's renderer has nothing new to show, so it is not asked. The
145
+ // frame pads its inside itself, so pi's renderer adds no padding of its own.
146
+ const streaming = writing && stream !== undefined;
147
+ const own = streaming
109
148
  ? undefined
110
- : call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
149
+ : call(args, theme, {
150
+ ...context,
151
+ argsComplete: !writing,
152
+ outputPad: 0,
153
+ lastComponent: passed(last),
154
+ });
111
155
  const body = own === undefined ? drawnBody(stream?.lines ?? []) : orStream(own, stream);
112
156
 
113
- const box = new CallFrame({
114
- theme,
115
- icon: toolIcon(frame.name, frame.set()),
116
- label: toolLabel(frame.name),
117
- body,
118
- ruling: rulingOf(scope.events, context.toolCallId),
119
- expanded: context.expanded,
120
- phase: phaseOf(context),
121
- mark: stateMark,
122
- set: frame.set,
123
- elapsed: elapsedOf(clock, context),
124
- progress: writing ? stream.progress : undefined,
125
- clock,
126
- executing: context.isPartial && context.executionStarted,
127
- runningAs: RUNNING_WORDS[frame.name],
128
- });
157
+ const box = new CallFrame(
158
+ {
159
+ theme,
160
+ icon: toolIcon(frame.name, frame.set()),
161
+ label: toolLabel(frame.name),
162
+ body,
163
+ ruling,
164
+ expanded: context.expanded,
165
+ phase: phaseOf(context, writing),
166
+ mark: stateMark,
167
+ set: frame.set,
168
+ progress: writing ? stream?.progress : undefined,
169
+ clock,
170
+ executing: context.isPartial && context.executionStarted,
171
+ runningAs: RUNNING_WORDS[frame.name],
172
+ },
173
+ inputs,
174
+ );
129
175
  inner.set(box, own);
130
176
  return box;
131
177
  },
@@ -139,6 +185,7 @@ function framed(
139
185
  clock.resulted = true;
140
186
  const body = result(shown, options, theme, {
141
187
  ...context,
188
+ outputPad: 0,
142
189
  lastComponent: passed(context.lastComponent),
143
190
  });
144
191
  const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
@@ -177,13 +224,12 @@ const RUNNING_WORDS: Readonly<Record<string, string>> = {
177
224
  * The phase the call closes with while it still has no result: writing the arguments, then waiting
178
225
  * to run. A call with a result, live or replayed, stays open for the result half to close.
179
226
  */
180
- function phaseOf(context: {
181
- isPartial: boolean;
182
- executionStarted: boolean;
183
- argsComplete: boolean;
184
- }): Phase | undefined {
227
+ function phaseOf(
228
+ context: { isPartial: boolean; executionStarted: boolean },
229
+ writing: boolean,
230
+ ): Phase | undefined {
185
231
  if (!context.isPartial || context.executionStarted) return undefined;
186
- return context.argsComplete ? "waiting" : "writing";
232
+ return writing ? "writing" : "waiting";
187
233
  }
188
234
 
189
235
  function callText(args: unknown, theme: Theme, expanded: boolean): string {
@@ -245,11 +291,26 @@ interface FrameParts {
245
291
  /** The top rule, the call inside it, and the phase while the call has nothing to show yet. */
246
292
  class CallFrame implements Component {
247
293
  #drawn?: Drawn;
248
-
249
- constructor(private readonly parts: FrameParts & CallParts) {}
294
+ /** Set when pi or the terminal asks for a fresh drawing, which asks the renderers again. */
295
+ #stale = false;
296
+
297
+ constructor(
298
+ private readonly parts: FrameParts & CallParts,
299
+ private readonly inputs: readonly unknown[],
300
+ ) {}
301
+
302
+ /** Whether this frame was made from these inputs and nothing asked for it to be made again. */
303
+ drawnFrom(inputs: readonly unknown[]): boolean {
304
+ return (
305
+ !this.#stale &&
306
+ inputs.length === this.inputs.length &&
307
+ inputs.every((input, index) => input === this.inputs[index])
308
+ );
309
+ }
250
310
 
251
311
  invalidate(): void {
252
312
  this.#drawn = undefined;
313
+ this.#stale = true;
253
314
  this.parts.body.invalidate();
254
315
  }
255
316
 
@@ -259,7 +320,8 @@ class CallFrame implements Component {
259
320
  // waits for you does while its dialog is open.
260
321
  const running = this.parts.executing === true && clock?.resulted !== true;
261
322
  const phase = this.parts.phase ?? (running ? "running" : undefined);
262
- const time = running && clock !== undefined ? ranFor(clock, true) : this.parts.elapsed;
323
+ // The time is read on every drawing, so it goes on while nothing new arrives.
324
+ const time = clock === undefined || phase === undefined ? undefined : timeOf(phase, clock);
263
325
  // The mark of a phase that lasts moves, so it is picked on every drawing.
264
326
  const mark = phase === undefined ? undefined : this.parts.mark(phase, this.parts.set());
265
327
  const body = this.parts.body.render(room(width));
@@ -292,7 +354,6 @@ interface CallParts {
292
354
  phase?: Phase;
293
355
  mark: (phase: Phase, set: IconSet) => string;
294
356
  set: () => IconSet;
295
- elapsed?: string;
296
357
  /** What the model has written so far, while it writes. */
297
358
  progress?: readonly string[];
298
359
  clock?: Clock;
@@ -499,11 +560,11 @@ function ranFor(clock: Clock, running: boolean): string | undefined {
499
560
  return end === undefined ? undefined : elapsed(end - clock.startedAt);
500
561
  }
501
562
 
502
- /** How long the call has spent waiting to run, which only the call half of the frame asks for. */
503
- function elapsedOf(clock: Clock, context: { argsComplete: boolean }): string | undefined {
504
- if (clock.writingAt === undefined) return undefined;
505
- const from = context.argsComplete ? (clock.writtenAt ?? clock.writingAt) : clock.writingAt;
506
- return elapsed(Date.now() - from);
563
+ /** How long the call has been in the phase the call half of the frame closes with. */
564
+ function timeOf(phase: Phase, clock: Clock): string | undefined {
565
+ if (phase === "running") return ranFor(clock, true);
566
+ const from = phase === "waiting" ? clock.writtenAt : clock.writingAt;
567
+ return from === undefined ? undefined : elapsed(Date.now() - from);
507
568
  }
508
569
 
509
570
  function elapsed(ms: number): string {