@adeildo/pi-look 5.1.0 → 5.2.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 +48 -36
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/render/tools.ts +105 -0
- package/src/ui/tool-frame.ts +392 -0
package/README.md
CHANGED
|
@@ -1,64 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">@adeildo/pi-look</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://www.npmjs.com/package/@adeildo/pi-look"><img src="https://img.shields.io/npm/v/@adeildo/pi-look" alt="npm"></a>
|
|
5
|
+
<a href="https://pi.dev"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffelipeadeildo%2Fpi-harness%2Fmain%2Fpackage.json&query=%24.devDependencies%5B%22%40earendil-works%2Fpi-coding-agent%22%5D&label=pi%20SDK&color=6E56CF" alt="pi SDK"></a>
|
|
6
|
+
</p>
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<strong>Pi's screen, drawn around the numbers that matter while the agent works.</strong><br>
|
|
10
|
+
The start card, the editor framed with the branch and the model, a box around every command, and a footer with what the session costs.
|
|
11
|
+
</p>
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
pi install npm:@adeildo/pi-look
|
|
9
|
-
```
|
|
13
|
+
<p align="center"><code>pi install npm:@adeildo/pi-look</code></p>
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
<!-- docs:look/preview -->
|
|
12
16
|
|
|
13
17
|
<p align="center">
|
|
14
|
-
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/preview.png" alt="
|
|
18
|
+
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/preview.png" alt="Pi with the look: the start card, then the strip with the stopwatch and the last call, the framed editor with the branch, the model and the context, and below it the cost, the tokens, the cache and the average speeds." width="860">
|
|
19
|
+
<br>
|
|
20
|
+
<em>The start card, then a prompt halfway through its answer.</em>
|
|
15
21
|
</p>
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
<!-- /docs -->
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
## The screen
|
|
20
26
|
|
|
21
|
-
|
|
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`.
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
-
| `0:12 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
|
|
26
|
-
| `last call 1.7s wait 900ms thought ↓906 tok/s` | The last call that finished: how long the model took to start, how long it thought before writing, and how fast it wrote. Final numbers |
|
|
29
|
+
<!-- docs:look/call -->
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
<p align="center">
|
|
32
|
+
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/call.png" alt="Three commands, each in its own box: a bash command with the reason it was allowed and the time it took, a file read, and a command still running." width="860">
|
|
33
|
+
<br>
|
|
34
|
+
<em>One command that ran, one a rule let through, and one still going.</em>
|
|
35
|
+
</p>
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
<!-- /docs -->
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
The strip above the editor is calm on purpose. Nothing moves while the model streams except the stopwatch.
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
| Piece | Means |
|
|
42
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `0:12 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
|
|
44
|
+
| `last call 1.7s wait 900ms thought ↓906 tok/s` | The last call: how long the model took to start, how long it thought then wrote, and how fast it writes |
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
The border around the editor carries the branch with its distance and changes, the folder, the permission mode, the model with an effort meter, and the context. It takes pi's own effort colour, so it warms up as the model thinks harder and turns to the shell colour in bash mode.
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- An estimate is drawn quieter and does not gain a `~`.
|
|
42
|
-
- The strip and the footer keep their line from the start, so the editor does not jump when the first answer arrives.
|
|
48
|
+
The spinner names the state instead of saying "Working": `waiting` for the first token, `thinking`, `writing`, `drafting bash` while the model writes the call, `running bash` while it runs.
|
|
49
|
+
|
|
50
|
+
The footer is the session: the cost, the tokens sent and received, the cache of the last prompt, the average speeds, and last what other packages report, because that changes the most.
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
## It holds still
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
Numbers change on every frame, and the screen must not move with them.
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
- Every number sits in a cell of fixed width, so ` 17` and `2.8k` take the same room.
|
|
57
|
+
- A value nobody knows yet shows as `–` and does not appear later in the middle of the line.
|
|
58
|
+
- The working time counts as a stopwatch, `0:59` then `1:00`.
|
|
59
|
+
- An estimate is drawn quieter, so you know it is one.
|
|
60
|
+
- The strip and the footer hold their line from the start, so the editor does not jump when the first answer arrives.
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
Pi records usage without a duration, so every finished answer leaves one small entry in the session with its time to the first token and its writing time. That is what keeps the averages right after a resume.
|
|
51
63
|
|
|
52
|
-
|
|
64
|
+
## Your theme
|
|
53
65
|
|
|
54
|
-
|
|
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.
|
|
55
67
|
|
|
56
|
-
|
|
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.
|
|
57
69
|
|
|
58
|
-
##
|
|
70
|
+
## Settings
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
`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.
|
|
61
73
|
|
|
62
|
-
|
|
74
|
+
The framed editor, and the idea of writing into its borders, come from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), which this replaces. The segments with priorities come from [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
63
75
|
|
|
64
|
-
[
|
|
76
|
+
Part of [pi-harness](https://github.com/felipeadeildo/pi-harness), which brings every piece in one install.
|
package/assets/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-look",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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.
|
|
41
|
+
"@adeildo/pi-kit": "5.2.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "*",
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { applyCursor, LookEditor } from "./ui/editor.ts";
|
|
|
42
42
|
import { FooterComponent, StripComponent } from "./ui/footer.ts";
|
|
43
43
|
import { HeaderComponent, type LoadedCounts } from "./ui/header.ts";
|
|
44
44
|
import type { Screen, SlotName } from "./ui/screen.ts";
|
|
45
|
+
import { registerToolFrames } from "./ui/tool-frame.ts";
|
|
45
46
|
import { type Activity, describe, draftedTool } from "./ui/working.ts";
|
|
46
47
|
|
|
47
48
|
export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
|
|
@@ -87,6 +88,7 @@ export const look = defineFeature({
|
|
|
87
88
|
};
|
|
88
89
|
|
|
89
90
|
const screen = createScreen(scope, live, reader, telemetry);
|
|
91
|
+
registerToolFrames(scope);
|
|
90
92
|
const repaint = () => live.repaint?.();
|
|
91
93
|
|
|
92
94
|
for (const entry of LOOK_SETTINGS) entry.listen(scope, repaint);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The mark and the name a tool call is drawn with. The mark follows the icon set in the look's
|
|
2
|
+
// settings, like every other glyph on the screen.
|
|
3
|
+
import type { IconSet } from "./glyphs.ts";
|
|
4
|
+
|
|
5
|
+
type ToolKind = "shell" | "read" | "search" | "edit" | "web" | "agent" | "mcp" | "other";
|
|
6
|
+
|
|
7
|
+
const ICONS: Record<IconSet, Record<ToolKind, string>> = {
|
|
8
|
+
nerd: {
|
|
9
|
+
shell: "\u{f120}", // nf-fa-terminal
|
|
10
|
+
read: "\u{f15c}", // nf-fa-file_lines
|
|
11
|
+
search: "\u{f002}", // nf-fa-search
|
|
12
|
+
edit: "\u{f044}", // nf-fa-pencil
|
|
13
|
+
web: "\u{f0ac}", // nf-fa-globe
|
|
14
|
+
agent: "\u{f06a9}", // nf-md-robot
|
|
15
|
+
mcp: "\u{f1e6}", // nf-fa-plug
|
|
16
|
+
other: "\u{f013}", // nf-fa-gear
|
|
17
|
+
},
|
|
18
|
+
unicode: {
|
|
19
|
+
shell: "\u276f",
|
|
20
|
+
read: "\u25a4",
|
|
21
|
+
search: "\u2315",
|
|
22
|
+
edit: "\u270e",
|
|
23
|
+
web: "\u25cd",
|
|
24
|
+
agent: "\u25c6",
|
|
25
|
+
mcp: "\u226b",
|
|
26
|
+
other: "\u25cb",
|
|
27
|
+
},
|
|
28
|
+
ascii: {
|
|
29
|
+
shell: "$",
|
|
30
|
+
read: "=",
|
|
31
|
+
search: "?",
|
|
32
|
+
edit: "+",
|
|
33
|
+
web: "@",
|
|
34
|
+
agent: "*",
|
|
35
|
+
mcp: ">",
|
|
36
|
+
other: "-",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type CallPhase = "writing" | "waiting" | "running" | "done" | "failed";
|
|
41
|
+
|
|
42
|
+
const MARKS: Record<IconSet, Record<CallPhase, string>> = {
|
|
43
|
+
nerd: {
|
|
44
|
+
writing: "\u{f044}", // nf-fa-pencil
|
|
45
|
+
waiting: "\u{f252}", // nf-fa-hourglass_half
|
|
46
|
+
running: "\u{f017}", // nf-fa-clock_o
|
|
47
|
+
done: "\u{f00c}", // nf-fa-check
|
|
48
|
+
failed: "\u{f00d}", // nf-fa-times
|
|
49
|
+
},
|
|
50
|
+
unicode: {
|
|
51
|
+
writing: "\u270e",
|
|
52
|
+
waiting: "\u29d6",
|
|
53
|
+
running: "\u25f7",
|
|
54
|
+
done: "\u2713",
|
|
55
|
+
failed: "\u2717",
|
|
56
|
+
},
|
|
57
|
+
ascii: { writing: "~", waiting: ":", running: "...", done: "ok", failed: "!" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function stateMark(phase: CallPhase, set: IconSet): string {
|
|
61
|
+
return MARKS[set][phase];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function toolIcon(toolName: string, set: IconSet): string {
|
|
65
|
+
return ICONS[set][toolKind(toolName)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The name as it reads on the frame: an MCP tool says its server rather than `mcp__server__tool`. */
|
|
69
|
+
export function toolLabel(toolName: string): string {
|
|
70
|
+
const mcp = toolName.match(/^mcp__([^_]+)__(.+)$/u);
|
|
71
|
+
return mcp === null ? toolName : `${mcp[1]}:${mcp[2]}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toolKind(toolName: string): ToolKind {
|
|
75
|
+
if (toolName.startsWith("mcp__")) return "mcp";
|
|
76
|
+
switch (toolName) {
|
|
77
|
+
case "bash":
|
|
78
|
+
case "powershell":
|
|
79
|
+
case "shell":
|
|
80
|
+
return "shell";
|
|
81
|
+
case "read":
|
|
82
|
+
case "ls":
|
|
83
|
+
case "find":
|
|
84
|
+
return "read";
|
|
85
|
+
case "grep":
|
|
86
|
+
case "glob":
|
|
87
|
+
case "tool_search":
|
|
88
|
+
return "search";
|
|
89
|
+
case "edit":
|
|
90
|
+
case "write":
|
|
91
|
+
case "apply_patch":
|
|
92
|
+
return "edit";
|
|
93
|
+
case "web_search":
|
|
94
|
+
case "fetch":
|
|
95
|
+
case "webfetch":
|
|
96
|
+
case "web_fetch":
|
|
97
|
+
return "web";
|
|
98
|
+
case "task":
|
|
99
|
+
case "agent":
|
|
100
|
+
case "subagent":
|
|
101
|
+
return "agent";
|
|
102
|
+
default:
|
|
103
|
+
return "other";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Draws every tool call in a frame of its own: the mark and the name on the top rule, the call and
|
|
2
|
+
// its result inside, and how it went on the bottom rule.
|
|
3
|
+
import {
|
|
4
|
+
type CallRuling,
|
|
5
|
+
type FeatureScope,
|
|
6
|
+
FRAMED,
|
|
7
|
+
isObject,
|
|
8
|
+
RULING_CHANGED,
|
|
9
|
+
type RulingTone,
|
|
10
|
+
rulingOf,
|
|
11
|
+
} from "@adeildo/pi-kit";
|
|
12
|
+
import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
type Component,
|
|
15
|
+
stripTerminalSequences,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
} from "@earendil-works/pi-tui";
|
|
19
|
+
|
|
20
|
+
import { type IconSet, resolveIcons } from "../render/glyphs.ts";
|
|
21
|
+
import { stateMark, toolIcon, toolLabel } from "../render/tools.ts";
|
|
22
|
+
import { icons } from "../settings.ts";
|
|
23
|
+
|
|
24
|
+
/** What the call is doing, which is what the bottom rule says while it has no result yet. */
|
|
25
|
+
type Phase = "writing" | "waiting" | "running" | "done" | "failed";
|
|
26
|
+
|
|
27
|
+
const PHASE_COLORS = {
|
|
28
|
+
writing: "muted",
|
|
29
|
+
waiting: "accent",
|
|
30
|
+
running: "accent",
|
|
31
|
+
done: "success",
|
|
32
|
+
failed: "error",
|
|
33
|
+
} as const;
|
|
34
|
+
const BODY_PAD = 4;
|
|
35
|
+
|
|
36
|
+
export function registerToolFrames(scope: FeatureScope): void {
|
|
37
|
+
const redraws = new Map<string, () => void>();
|
|
38
|
+
scope.events.on(FRAMED, (data) => {
|
|
39
|
+
if (isObject(data)) data.framed = true;
|
|
40
|
+
});
|
|
41
|
+
scope.events.on(RULING_CHANGED, (data) => {
|
|
42
|
+
if (isObject(data) && typeof data.toolCallId === "string") redraws.get(data.toolCallId)?.();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
scope.registerToolRenderer((toolName, next) =>
|
|
46
|
+
framed(scope, next(), {
|
|
47
|
+
name: toolName,
|
|
48
|
+
set: () => resolveIcons(icons.get(scope)).set,
|
|
49
|
+
redraws,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FrameContext {
|
|
55
|
+
name: string;
|
|
56
|
+
set: () => IconSet;
|
|
57
|
+
redraws: Map<string, () => void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function framed(
|
|
61
|
+
scope: FeatureScope,
|
|
62
|
+
base: ToolRenderers | undefined,
|
|
63
|
+
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;
|
|
70
|
+
|
|
71
|
+
// A renderer of pi's own mutates the component it returned last time, which sits inside the
|
|
72
|
+
// frame, so it gets that one back instead of the frame.
|
|
73
|
+
const inner = new WeakMap<Component, Component>();
|
|
74
|
+
const passed = (last: Component | undefined): Component | undefined =>
|
|
75
|
+
last === undefined ? undefined : (inner.get(last) ?? last);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
// The frame is the shell, so pi does not wrap it in a box of its own.
|
|
80
|
+
renderShell: "self",
|
|
81
|
+
renderCall(args, theme, context) {
|
|
82
|
+
frame.redraws.set(context.toolCallId, context.invalidate);
|
|
83
|
+
const clock = clockOf(context);
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
clock.writingAt ??= now;
|
|
86
|
+
if (context.argsComplete) clock.writtenAt ??= now;
|
|
87
|
+
if (context.executionStarted) clock.startedAt ??= now;
|
|
88
|
+
const body = call(args, theme, { ...context, lastComponent: passed(context.lastComponent) });
|
|
89
|
+
|
|
90
|
+
const box = new CallFrame({
|
|
91
|
+
theme,
|
|
92
|
+
icon: toolIcon(frame.name, frame.set()),
|
|
93
|
+
label: toolLabel(frame.name),
|
|
94
|
+
body,
|
|
95
|
+
ruling: rulingOf(scope.events, context.toolCallId),
|
|
96
|
+
expanded: context.expanded,
|
|
97
|
+
phase: context.executionStarted ? undefined : context.argsComplete ? "waiting" : "writing",
|
|
98
|
+
mark: stateMark,
|
|
99
|
+
set: frame.set,
|
|
100
|
+
elapsed: elapsedOf(clock, context),
|
|
101
|
+
});
|
|
102
|
+
inner.set(box, body);
|
|
103
|
+
return box;
|
|
104
|
+
},
|
|
105
|
+
renderResult(shown, options, theme, context) {
|
|
106
|
+
const clock = clockOf(context);
|
|
107
|
+
// A blocked call prints nothing but the reason the line above already gives.
|
|
108
|
+
const blocked = rulingOf(scope.events, context.toolCallId)?.blocked === true;
|
|
109
|
+
// Pi draws the pictures of a result right below the frame, so the frame counts them.
|
|
110
|
+
const images = shown.content.filter((block) => block.type === "image").length;
|
|
111
|
+
if (!options.isPartial) clock.endedAt ??= Date.now();
|
|
112
|
+
const body = result(shown, options, theme, {
|
|
113
|
+
...context,
|
|
114
|
+
lastComponent: passed(context.lastComponent),
|
|
115
|
+
});
|
|
116
|
+
const phase: Phase = options.isPartial ? "running" : context.isError ? "failed" : "done";
|
|
117
|
+
const box = new ResultFrame({
|
|
118
|
+
theme,
|
|
119
|
+
body,
|
|
120
|
+
quiet: blocked,
|
|
121
|
+
phase,
|
|
122
|
+
mark: stateMark(phase, frame.set()),
|
|
123
|
+
elapsed: ranFor(clock, phase === "running"),
|
|
124
|
+
wrote: wroteFor(clock),
|
|
125
|
+
images: images === 0 ? undefined : `${images} image${images === 1 ? "" : "s"}`,
|
|
126
|
+
});
|
|
127
|
+
inner.set(box, body);
|
|
128
|
+
return box;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface FrameParts {
|
|
134
|
+
theme: Theme;
|
|
135
|
+
body: Component;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The top rule, the call inside it, and the phase while the call has nothing to show yet. */
|
|
139
|
+
class CallFrame implements Component {
|
|
140
|
+
#drawn?: Drawn;
|
|
141
|
+
|
|
142
|
+
constructor(private readonly parts: FrameParts & CallParts) {}
|
|
143
|
+
|
|
144
|
+
invalidate(): void {
|
|
145
|
+
this.#drawn = undefined;
|
|
146
|
+
this.parts.body.invalidate();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render(width: number): string[] {
|
|
150
|
+
const { theme, icon, label, ruling, expanded, phase } = this.parts;
|
|
151
|
+
const body = this.parts.body.render(room(width));
|
|
152
|
+
const drawn = { width, body, rest: [ruling, expanded, phase, this.parts.elapsed] };
|
|
153
|
+
if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
|
|
154
|
+
|
|
155
|
+
const title = `${theme.fg("toolTitle", icon)} ${theme.fg("toolTitle", theme.bold(label))}`;
|
|
156
|
+
const lines = [rule("top", title, width, theme), ...inside(withoutBlanks(body), width, theme)];
|
|
157
|
+
// The decision is about the call, so it stays with the call.
|
|
158
|
+
if (ruling !== undefined)
|
|
159
|
+
lines.push(...inside([rulingText(ruling, theme, expanded)], width, theme));
|
|
160
|
+
// The call has nothing else to show yet, so the frame closes with what it is doing.
|
|
161
|
+
if (phase !== undefined) {
|
|
162
|
+
const mark = this.parts.mark(phase, this.parts.set());
|
|
163
|
+
const times = [this.parts.elapsed];
|
|
164
|
+
lines.push(
|
|
165
|
+
rule("bottom", phaseLabel(theme, mark, phase, times), width, theme, PHASE_COLORS[phase]),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
this.#drawn = { ...drawn, lines };
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface CallParts {
|
|
174
|
+
icon: string;
|
|
175
|
+
label: string;
|
|
176
|
+
ruling?: CallRuling;
|
|
177
|
+
expanded: boolean;
|
|
178
|
+
phase?: Phase;
|
|
179
|
+
mark: (phase: Phase, set: IconSet) => string;
|
|
180
|
+
set: () => IconSet;
|
|
181
|
+
elapsed?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The result, behind a cut, and the bottom rule with how it went and how long it took. */
|
|
185
|
+
class ResultFrame implements Component {
|
|
186
|
+
#drawn?: Drawn;
|
|
187
|
+
|
|
188
|
+
constructor(private readonly parts: FrameParts & ResultParts) {}
|
|
189
|
+
|
|
190
|
+
invalidate(): void {
|
|
191
|
+
this.#drawn = undefined;
|
|
192
|
+
this.parts.body.invalidate();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
render(width: number): string[] {
|
|
196
|
+
const { theme, phase, mark, wrote, quiet, images } = this.parts;
|
|
197
|
+
const time = this.parts.elapsed;
|
|
198
|
+
const body = this.parts.body.render(room(width));
|
|
199
|
+
const drawn = { width, body, rest: [phase, mark, time, wrote, images, quiet] };
|
|
200
|
+
if (unchanged(this.#drawn, drawn)) return this.#drawn.lines;
|
|
201
|
+
|
|
202
|
+
const printed = quiet === true ? [] : withoutBlanks(dropTime(body));
|
|
203
|
+
const lines = [
|
|
204
|
+
// The cut opens what the call printed, and a call with nothing to print has no cut.
|
|
205
|
+
...(printed.length === 0 ? [] : [cut(width, theme), ...inside(printed, width, theme)]),
|
|
206
|
+
rule(
|
|
207
|
+
"bottom",
|
|
208
|
+
phaseLabel(theme, mark, phase, [
|
|
209
|
+
time,
|
|
210
|
+
wrote === undefined ? undefined : `wrote ${wrote}`,
|
|
211
|
+
images === undefined ? undefined : `with ${images}`,
|
|
212
|
+
]),
|
|
213
|
+
width,
|
|
214
|
+
theme,
|
|
215
|
+
PHASE_COLORS[phase],
|
|
216
|
+
),
|
|
217
|
+
];
|
|
218
|
+
this.#drawn = { ...drawn, lines };
|
|
219
|
+
return lines;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface ResultParts {
|
|
224
|
+
phase: Phase;
|
|
225
|
+
mark: string;
|
|
226
|
+
elapsed?: string;
|
|
227
|
+
wrote?: string;
|
|
228
|
+
/** How many pictures follow the frame, which pi draws outside it. */
|
|
229
|
+
images?: string;
|
|
230
|
+
/** Nothing worth printing under the line, like a call the gate blocked. */
|
|
231
|
+
quiet?: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** One rule of the frame: two corners, and what sits on the left. */
|
|
235
|
+
function rule(
|
|
236
|
+
edge: "top" | "bottom",
|
|
237
|
+
left: string,
|
|
238
|
+
width: number,
|
|
239
|
+
theme: Theme,
|
|
240
|
+
color: "accent" | "success" | "error" | "muted" | "border" = "border",
|
|
241
|
+
): string {
|
|
242
|
+
const [start, end] = edge === "top" ? ["\u256d", "\u256e"] : ["\u2570", "\u256f"];
|
|
243
|
+
const paint = (text: string) => theme.fg(color, text);
|
|
244
|
+
const cell = left === "" ? "" : ` ${left} `;
|
|
245
|
+
const fill = Math.max(1, width - BODY_PAD - visibleWidth(cell));
|
|
246
|
+
const line =
|
|
247
|
+
paint(`${start}\u2500`) + cell + paint("\u2500".repeat(fill)) + paint(`\u2500${end}`);
|
|
248
|
+
return truncateToWidth(line, width, "");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The line that ends the call and starts what it printed. */
|
|
252
|
+
function cut(width: number, theme: Theme): string {
|
|
253
|
+
const paint = (text: string) => theme.fg("border", text);
|
|
254
|
+
const fill = Math.max(1, width - BODY_PAD);
|
|
255
|
+
const line = `${paint("\u251c")}${paint("\u2500".repeat(fill + 2))}${paint("\u2524")}`;
|
|
256
|
+
return truncateToWidth(line, width, "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The bottom rule: what the call is doing, how long it ran, and how long the model took to write it. */
|
|
260
|
+
function phaseLabel(
|
|
261
|
+
theme: Theme,
|
|
262
|
+
mark: string,
|
|
263
|
+
phase: Phase,
|
|
264
|
+
times: readonly (string | undefined)[],
|
|
265
|
+
): string {
|
|
266
|
+
const cells = times
|
|
267
|
+
.filter((time) => time !== undefined)
|
|
268
|
+
.map((time) => theme.fg("dim", time ?? ""));
|
|
269
|
+
return [`${theme.fg(PHASE_COLORS[phase], mark)} ${theme.fg("muted", phase)}`, ...cells].join(
|
|
270
|
+
" ",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function inside(lines: readonly string[], width: number, theme: Theme): string[] {
|
|
275
|
+
const paint = (text: string) => theme.fg("border", text);
|
|
276
|
+
const inner = room(width);
|
|
277
|
+
return trim(lines).map((line) => {
|
|
278
|
+
// Measuring is cheap, and cutting a line that already fits is not.
|
|
279
|
+
const text = visibleWidth(line) > inner ? truncateToWidth(line, inner, "\u2026", true) : line;
|
|
280
|
+
const pad = " ".repeat(Math.max(0, inner - visibleWidth(text)));
|
|
281
|
+
return `${paint("\u2502")} ${text}${pad} ${paint("\u2502")}`;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface Drawn {
|
|
286
|
+
width: number;
|
|
287
|
+
body: readonly string[];
|
|
288
|
+
/** What the frame drew around the body: the ruling, the phase, the times. */
|
|
289
|
+
rest: readonly unknown[];
|
|
290
|
+
lines: string[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Whether the frame was already drawn for this body and this state, which keeps typing cheap. */
|
|
294
|
+
function unchanged(drawn: Drawn | undefined, next: Omit<Drawn, "lines">): drawn is Drawn {
|
|
295
|
+
if (drawn === undefined || drawn.width !== next.width) return false;
|
|
296
|
+
if (drawn.body.length !== next.body.length || drawn.rest.length !== next.rest.length)
|
|
297
|
+
return false;
|
|
298
|
+
if (drawn.rest.some((part, index) => part !== next.rest[index])) return false;
|
|
299
|
+
return drawn.body.every((line, index) => line === next.body[index]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function room(width: number): number {
|
|
303
|
+
return Math.max(1, width - BODY_PAD);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Pi prints the time of a call itself, and the frame says it on the bottom rule instead. */
|
|
307
|
+
function dropTime(lines: readonly string[]): string[] {
|
|
308
|
+
const at = lines.findLastIndex((line) => !isBlank(line));
|
|
309
|
+
if (at === -1) return [...lines];
|
|
310
|
+
if (!/^(Took|Elapsed) [\d.]/u.test(stripTerminalSequences(lines[at] ?? "").trim()))
|
|
311
|
+
return [...lines];
|
|
312
|
+
return lines.slice(0, at);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface Clock {
|
|
316
|
+
/** When the model started writing the call, and when it finished. */
|
|
317
|
+
writingAt?: number;
|
|
318
|
+
writtenAt?: number;
|
|
319
|
+
/** When the call started running, and when it answered. */
|
|
320
|
+
startedAt?: number;
|
|
321
|
+
endedAt?: number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Pi shares one state object between the two halves of a call. */
|
|
325
|
+
function clockOf(context: { state: unknown }): Clock {
|
|
326
|
+
return context.state as Clock;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** How long the model took to write the call. */
|
|
330
|
+
function wroteFor(clock: Clock): string | undefined {
|
|
331
|
+
if (clock.writingAt === undefined || clock.writtenAt === undefined) return undefined;
|
|
332
|
+
return elapsed(clock.writtenAt - clock.writingAt);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function ranFor(clock: Clock, running: boolean): string | undefined {
|
|
336
|
+
if (clock.startedAt === undefined) return undefined;
|
|
337
|
+
const end = clock.endedAt ?? (running ? Date.now() : undefined);
|
|
338
|
+
return end === undefined ? undefined : elapsed(end - clock.startedAt);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** How long the call has spent waiting to run, which only the call half of the frame asks for. */
|
|
342
|
+
function elapsedOf(clock: Clock, context: { argsComplete: boolean }): string | undefined {
|
|
343
|
+
if (clock.writingAt === undefined) return undefined;
|
|
344
|
+
const from = context.argsComplete ? (clock.writtenAt ?? clock.writingAt) : clock.writingAt;
|
|
345
|
+
return elapsed(Date.now() - from);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function elapsed(ms: number): string {
|
|
349
|
+
const seconds = ms / 1000;
|
|
350
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
351
|
+
const total = Math.floor(seconds);
|
|
352
|
+
if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
|
|
353
|
+
return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Pi's renderers pad their part with blank lines, which the frame does not need. */
|
|
357
|
+
function trim(lines: readonly string[]): string[] {
|
|
358
|
+
let [from, to] = [0, lines.length];
|
|
359
|
+
while (from < to && isBlank(lines[from])) from++;
|
|
360
|
+
while (to > from && isBlank(lines[to - 1])) to--;
|
|
361
|
+
return lines.slice(from, to);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A renderer that separates its parts with a blank line leaves a gap the frame does not need. */
|
|
365
|
+
function withoutBlanks(lines: readonly string[]): string[] {
|
|
366
|
+
return lines.filter((line) => !isBlank(line));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isBlank(line: string | undefined): boolean {
|
|
370
|
+
// A line of nothing but colour codes reads as blank, and pi's renderers end their parts with one.
|
|
371
|
+
return stripTerminalSequences(line ?? "").trim() === "";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function rulingText(ruling: CallRuling, theme: Theme, expanded: boolean): string {
|
|
375
|
+
const color = PHASE_COLORS[stateOf(ruling.tone)];
|
|
376
|
+
let line = `${theme.fg(color, "\u25c8")} ${theme.fg(color, ruling.head)}`;
|
|
377
|
+
if (ruling.note) line += ` ${theme.fg("accent", "\u203a")} ${ruling.note}`;
|
|
378
|
+
if (ruling.why) line += ` ${theme.fg("muted", ruling.why)}`;
|
|
379
|
+
if (expanded && ruling.detail) line += ` ${theme.fg("dim", `(${ruling.detail})`)}`;
|
|
380
|
+
return line;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function stateOf(tone: RulingTone): Phase {
|
|
384
|
+
switch (tone) {
|
|
385
|
+
case "pending":
|
|
386
|
+
return "running";
|
|
387
|
+
case "success":
|
|
388
|
+
return "done";
|
|
389
|
+
default:
|
|
390
|
+
return "failed";
|
|
391
|
+
}
|
|
392
|
+
}
|