@adeildo/pi-look 5.9.2 → 5.10.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 +22 -8
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/data/git.ts +12 -9
- package/src/data/github.ts +141 -0
- package/src/data/runner.ts +25 -0
- package/src/data/snapshot.ts +6 -3
- package/src/data/start-notes.ts +108 -0
- package/src/data/telemetry.ts +6 -1
- package/src/data/totals.ts +12 -11
- package/src/data/visits.ts +38 -0
- package/src/index.ts +147 -71
- package/src/render/fit.ts +2 -1
- package/src/render/format.ts +9 -12
- package/src/render/glyphs.ts +14 -0
- package/src/render/segments.ts +30 -31
- package/src/render/streaming.ts +217 -0
- package/src/render/tools.ts +76 -12
- package/src/settings.ts +61 -210
- package/src/ui/editor.ts +8 -2
- package/src/ui/footer.ts +64 -5
- package/src/ui/header.ts +175 -71
- package/src/ui/outputs.ts +173 -0
- package/src/ui/pi-entries.ts +185 -0
- package/src/ui/previews.ts +76 -0
- package/src/ui/queue.ts +75 -0
- package/src/ui/screen.ts +8 -0
- package/src/ui/tool-frame.ts +118 -39
package/README.md
CHANGED
|
@@ -22,32 +22,46 @@
|
|
|
22
22
|
|
|
23
23
|
<!-- /docs -->
|
|
24
24
|
|
|
25
|
-
## The screen
|
|
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`. 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
|
-
|
|
29
25
|
<!-- docs:look/call -->
|
|
30
26
|
|
|
31
27
|
<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="
|
|
28
|
+
<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="420">
|
|
29
|
+
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/look/assets/settings.png" alt="The settings screen in a frame, titled Settings. The Look tab holds Appearance, Your messages and What is on screen, with Editor frame selected. Under the list, the preview draws the editor's rounded frame with main on the top edge, the folder on the right, the model on the bottom edge and the context on the right. Below, the setting's name and default, and the keys along the bottom edge." width="420">
|
|
33
30
|
<br>
|
|
34
|
-
<em>
|
|
31
|
+
<em>Every command in a box, and a setting shown before you keep it.</em>
|
|
35
32
|
</p>
|
|
36
33
|
|
|
37
34
|
<!-- /docs -->
|
|
38
35
|
|
|
36
|
+
## The screen
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
39
40
|
The strip above the editor is calm on purpose. Nothing moves while the model streams except the stopwatch.
|
|
40
41
|
|
|
41
42
|
| Piece | Means |
|
|
42
43
|
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
43
44
|
| `0:12 3 calls` | How long the agent has worked on this prompt, and how many calls to the model it made |
|
|
44
45
|
| `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 |
|
|
46
|
+
| `answered in 0:47 2 calls $0.31` | Once the run ends: how long it took, how many calls to the model, and what it cost |
|
|
45
47
|
|
|
46
48
|
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.
|
|
47
49
|
|
|
48
50
|
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
51
|
|
|
50
|
-
|
|
52
|
+
Your messages sit in a frame like the tool calls', in the accent colour and named `you`, without pi's background or the blank rows above and below the text. `look.userFrame` turns it off.
|
|
53
|
+
|
|
54
|
+
The start card shows the π, the folder, the branch and the model, and under them only what changed or waits on you since your last session in the folder: an account that needs a sign-in, files in conflict, a branch behind its remote, a failed CI run on the branch, a pull request of yours with changes requested or a new comment, other agents working there, a plan past 70%, a new harness version. On a quiet day it says nothing is new, and its bottom edge reads `ready when you are`. GitHub is asked once per launch, in the background, so the card never waits on it: those two lines appear when the answers arrive, and a missing `gh`, no network or a folder that is not on GitHub leaves them out. The π rises from its feet once per launch. With pi's `quietStartup` on `header`, which the harness sets, pi's list of everything it loaded stays out of the way.
|
|
55
|
+
|
|
56
|
+
The mark of a phase that lasts moves: a clock goes round its hours while a call runs, the hourglass flows while it waits on you, and the pencil fills with ink and empties while the model writes the call, in its own cell. With the Unicode icons a quarter circle goes round and the pencil tilts as it writes instead, and with ASCII a line spins. The mark stands still once the call ends.
|
|
57
|
+
|
|
58
|
+
Pi's own entries sit in the same frame as the tool calls: compactions and branch summaries under `π`, skills, `!` commands with a cut between the command and what it printed, and the messages of extensions that bring no renderer of their own. The messages you send while the agent works wait above the editor in a dashed frame, each marked `steer` (between the agent's calls) or `next` (after its run), with the key that takes them back to the editor in the bottom edge.
|
|
59
|
+
|
|
60
|
+
While the model writes an `edit` or a `write`, its frame shows the end of what has arrived, with a cursor where the model is writing. A write shows its last 8 lines with their numbers, and says whether it makes a new file or how many lines it replaces. An edit shows the lines it takes out and puts in. The lines it repeats around the change stay as context. The bottom rule counts the lines and the size, or the edits and the lines changed. Once the call is written, pi's own view takes over, with the diff read from the file.
|
|
61
|
+
|
|
62
|
+
The footer is the session, each number with its word: the cost, the tokens sent and received, how much of the last prompt was cached, the average speeds, the plan's windows with when they reset, and last what other packages report, because that changes the most.
|
|
63
|
+
|
|
64
|
+
`Alt+O` opens the session's tool output in a frame you scroll, one call at a time, newest first. The arrows up and down scroll it, left and right move to older and newer calls, and the bottom edge says which lines you are on. Pi shows a long output cut in the transcript, and `Ctrl+O` opens every call at once.
|
|
51
65
|
|
|
52
66
|
## It holds still
|
|
53
67
|
|
|
@@ -67,7 +81,7 @@ The look never names a colour. Every piece names a theme token, so it follows wh
|
|
|
67
81
|
|
|
68
82
|
## Settings
|
|
69
83
|
|
|
70
|
-
`Alt+S` opens them on the Look tab.
|
|
84
|
+
`Alt+S` opens them on the Look tab. The look is opinionated, so three things are yours: the icons (Nerd Font, Unicode or ASCII, which depend on your terminal's font), the style of the editor's frame, and whether your messages sit in a frame. Each shows a preview of what it changes as you pick it. The [reference](docs/reference.md#settings) lists them.
|
|
71
85
|
|
|
72
86
|
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).
|
|
73
87
|
|
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.10.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.10.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "*",
|
package/src/data/git.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { processRunner, type Runner } from "./runner.ts";
|
|
2
2
|
|
|
3
3
|
export interface GitState {
|
|
4
4
|
ahead: number;
|
|
@@ -40,13 +40,16 @@ export function parseStatus(output: string): GitState {
|
|
|
40
40
|
return state;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function readGit(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
(
|
|
43
|
+
export async function readGit(
|
|
44
|
+
cwd: string,
|
|
45
|
+
run: Runner = processRunner(cwd, 2000),
|
|
46
|
+
): Promise<GitState | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
return parseStatus(
|
|
49
|
+
await run("git", ["-C", cwd, "status", "--porcelain=v2", "--branch", "--show-stash"]),
|
|
50
50
|
);
|
|
51
|
-
}
|
|
51
|
+
} catch {
|
|
52
|
+
// A folder that is not a repository, or a git that did not answer: the card says nothing.
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
52
55
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The answers GitHub gives the start card: the last run of CI on this branch, and your own open pull
|
|
2
|
+
// requests with something waiting on you. The card asks once per launch, in the background, and shows
|
|
3
|
+
// nothing when gh is missing, the network is down, or the folder is not on GitHub.
|
|
4
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
5
|
+
|
|
6
|
+
import { processRunner, type Runner } from "./runner.ts";
|
|
7
|
+
|
|
8
|
+
/** The conclusions that mean a run did not pass. */
|
|
9
|
+
const FAILED = new Set(["failure", "startup_failure", "timed_out"]);
|
|
10
|
+
/** What gh is asked about a pull request: enough to tell a new comment from an old one. */
|
|
11
|
+
const PR_FIELDS = "number,title,author,reviewDecision,comments";
|
|
12
|
+
|
|
13
|
+
export interface CiFailure {
|
|
14
|
+
branch: string;
|
|
15
|
+
/** The failing commit, short enough to read. */
|
|
16
|
+
sha: string;
|
|
17
|
+
/** The workflow that failed. */
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenPullRequest {
|
|
22
|
+
number: number;
|
|
23
|
+
title: string;
|
|
24
|
+
/** What waits on you there. */
|
|
25
|
+
waits: "changes requested" | "new comment";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GithubState {
|
|
29
|
+
ci: CiFailure | undefined;
|
|
30
|
+
pr: OpenPullRequest | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What GitHub knows about this folder. It never rejects: every question that cannot be answered
|
|
35
|
+
* leaves its note out. `since` is the last session in the folder, the bar for a comment to be news.
|
|
36
|
+
*/
|
|
37
|
+
export async function readGithub(
|
|
38
|
+
cwd: string,
|
|
39
|
+
since: number | undefined,
|
|
40
|
+
run: Runner = processRunner(cwd),
|
|
41
|
+
): Promise<GithubState> {
|
|
42
|
+
const [branch, pr] = await Promise.all([branchOf(run), pullRequest(run, since)]);
|
|
43
|
+
const ci = branch === undefined ? undefined : await failedRun(run, branch);
|
|
44
|
+
return { ci, pr };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function branchOf(run: Runner): Promise<string | undefined> {
|
|
48
|
+
const branch = (await ask(() => run("git", ["branch", "--show-current"])))?.trim();
|
|
49
|
+
return branch === undefined || branch === "" ? undefined : branch;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The last run of CI on the branch, when it failed. */
|
|
53
|
+
async function failedRun(run: Runner, branch: string): Promise<CiFailure | undefined> {
|
|
54
|
+
const out = await ask(() =>
|
|
55
|
+
run("gh", [
|
|
56
|
+
"run",
|
|
57
|
+
"list",
|
|
58
|
+
"--branch",
|
|
59
|
+
branch,
|
|
60
|
+
"--limit",
|
|
61
|
+
"1",
|
|
62
|
+
"--json",
|
|
63
|
+
"conclusion,headSha,name,displayTitle",
|
|
64
|
+
]),
|
|
65
|
+
);
|
|
66
|
+
const [last] = rows(out);
|
|
67
|
+
if (last === undefined) return undefined;
|
|
68
|
+
const conclusion = field(last, "conclusion");
|
|
69
|
+
if (conclusion === undefined || !FAILED.has(conclusion)) return undefined;
|
|
70
|
+
return {
|
|
71
|
+
branch,
|
|
72
|
+
sha: (field(last, "headSha") ?? "").slice(0, 7),
|
|
73
|
+
name: field(last, "name") ?? field(last, "displayTitle") ?? "CI",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The first of your open pull requests, newest first, with a review or a comment waiting on you. */
|
|
78
|
+
async function pullRequest(
|
|
79
|
+
run: Runner,
|
|
80
|
+
since: number | undefined,
|
|
81
|
+
): Promise<OpenPullRequest | undefined> {
|
|
82
|
+
const out = await ask(() =>
|
|
83
|
+
run("gh", ["pr", "list", "--author", "@me", "--limit", "10", "--json", PR_FIELDS]),
|
|
84
|
+
);
|
|
85
|
+
for (const pr of rows(out)) {
|
|
86
|
+
const number = numberOf(pr);
|
|
87
|
+
const title = field(pr, "title");
|
|
88
|
+
if (number === undefined || title === undefined) continue;
|
|
89
|
+
if (field(pr, "reviewDecision") === "CHANGES_REQUESTED")
|
|
90
|
+
return { number, title, waits: "changes requested" };
|
|
91
|
+
if (hasNewComment(pr, since)) return { number, title, waits: "new comment" };
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasNewComment(pr: Record<string, unknown>, since: number | undefined): boolean {
|
|
97
|
+
if (since === undefined) return false;
|
|
98
|
+
const me = loginOf(pr["author"]);
|
|
99
|
+
const comments = pr["comments"];
|
|
100
|
+
if (!Array.isArray(comments)) return false;
|
|
101
|
+
return comments.some((comment) => {
|
|
102
|
+
if (!isObject(comment)) return false;
|
|
103
|
+
const at = Date.parse(field(comment, "createdAt") ?? "");
|
|
104
|
+
const login = loginOf(comment["author"]);
|
|
105
|
+
return Number.isFinite(at) && at > since && login !== undefined && login !== me;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loginOf(value: unknown): string | undefined {
|
|
110
|
+
return isObject(value) ? field(value, "login") : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The stdout of a question that could not be asked is no answer at all. */
|
|
114
|
+
async function ask(question: () => Promise<string>): Promise<string | undefined> {
|
|
115
|
+
try {
|
|
116
|
+
return await question();
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rows(out: string | undefined): Record<string, unknown>[] {
|
|
123
|
+
if (out === undefined) return [];
|
|
124
|
+
let value: unknown;
|
|
125
|
+
try {
|
|
126
|
+
value = JSON.parse(out);
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
return Array.isArray(value) ? value.filter(isObject) : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function field(row: Record<string, unknown>, key: string): string | undefined {
|
|
134
|
+
const value = row[key];
|
|
135
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function numberOf(pr: Record<string, unknown>): number | undefined {
|
|
139
|
+
const value = pr["number"];
|
|
140
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
141
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** How long one command may take before it is given up on. The start card is never worth a longer wait. */
|
|
4
|
+
const TIMEOUT_MS = 3000;
|
|
5
|
+
/** Room for a page of an answer, like your open pull requests with their comments. Node's own
|
|
6
|
+
* default, so a command that answered before keeps answering. */
|
|
7
|
+
const MAX_OUTPUT = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Runs one command in the folder and returns its stdout. Rejects when it fails or takes too long. */
|
|
10
|
+
export type Runner = (file: string, args: readonly string[]) => Promise<string>;
|
|
11
|
+
|
|
12
|
+
export function processRunner(cwd: string, timeoutMs: number = TIMEOUT_MS): Runner {
|
|
13
|
+
return (file, args) =>
|
|
14
|
+
new Promise((resolve, reject) => {
|
|
15
|
+
execFile(
|
|
16
|
+
file,
|
|
17
|
+
[...args],
|
|
18
|
+
{ cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT },
|
|
19
|
+
(error, stdout) => {
|
|
20
|
+
if (error === null) resolve(stdout);
|
|
21
|
+
else reject(error instanceof Error ? error : new Error(String(error)));
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/data/snapshot.ts
CHANGED
|
@@ -104,6 +104,7 @@ interface Derived {
|
|
|
104
104
|
cacheHit: number | undefined;
|
|
105
105
|
cacheWarming: boolean;
|
|
106
106
|
averages: Averages;
|
|
107
|
+
context: ContextInfo | undefined;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export class SnapshotReader {
|
|
@@ -134,7 +135,6 @@ export class SnapshotReader {
|
|
|
134
135
|
},
|
|
135
136
|
thinking: ctx.thinkingLevel,
|
|
136
137
|
account: live.account,
|
|
137
|
-
context: contextOf(ctx),
|
|
138
138
|
...derived,
|
|
139
139
|
subscription: model === undefined ? false : ctx.modelRegistry.isUsingOAuth(model),
|
|
140
140
|
request: live.request,
|
|
@@ -153,8 +153,10 @@ export class SnapshotReader {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
#derive(ctx: ExtensionContext): Derived {
|
|
156
|
-
// Every entry is appended at the leaf, so the leaf moving is the only way the sums change.
|
|
157
|
-
|
|
156
|
+
// Every entry is appended at the leaf, so the leaf moving is the only way the sums change. The
|
|
157
|
+
// context also depends on the model's window. Pi counts it over the whole branch, which is too
|
|
158
|
+
// slow to do on every keystroke.
|
|
159
|
+
const key = `${ctx.sessionManager.getLeafId() ?? ""}\u0000${ctx.model?.provider}/${ctx.model?.id}`;
|
|
158
160
|
if (this.#derived !== undefined && key === this.#key) return this.#derived;
|
|
159
161
|
|
|
160
162
|
const entries: readonly SessionEntry[] = ctx.sessionManager.getBranch();
|
|
@@ -164,6 +166,7 @@ export class SnapshotReader {
|
|
|
164
166
|
cacheHit: cacheHitPercent(entries),
|
|
165
167
|
cacheWarming: cacheWarming(entries),
|
|
166
168
|
averages: averagesOf(entries),
|
|
169
|
+
context: contextOf(ctx),
|
|
167
170
|
};
|
|
168
171
|
return this.#derived;
|
|
169
172
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What the start screen says under the folder and the model: only what changed or waits on you since
|
|
2
|
+
// the last session here. On a quiet day the list is empty and the screen says so in one line.
|
|
3
|
+
import { formatClock, GLYPH } from "@adeildo/pi-kit";
|
|
4
|
+
|
|
5
|
+
import type { GithubState } from "./github.ts";
|
|
6
|
+
import type { Snapshot } from "./snapshot.ts";
|
|
7
|
+
|
|
8
|
+
/** Plans used past this percent show on the start screen. */
|
|
9
|
+
const PLAN_LIMIT = 70;
|
|
10
|
+
|
|
11
|
+
/** The last session in this folder, as the start screen remembers it. */
|
|
12
|
+
export interface LastVisit {
|
|
13
|
+
at: number;
|
|
14
|
+
version: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StartNote {
|
|
18
|
+
mark: string;
|
|
19
|
+
/** A theme color for the mark. */
|
|
20
|
+
color: string;
|
|
21
|
+
text: string;
|
|
22
|
+
detail?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function startNotes(
|
|
26
|
+
snapshot: Pick<Snapshot, "peers" | "account" | "git" | "branch">,
|
|
27
|
+
version: string,
|
|
28
|
+
last: LastVisit | undefined,
|
|
29
|
+
github?: GithubState,
|
|
30
|
+
): StartNote[] {
|
|
31
|
+
const notes: StartNote[] = [];
|
|
32
|
+
const { account, git } = snapshot;
|
|
33
|
+
|
|
34
|
+
if (account?.needsLogin === true)
|
|
35
|
+
notes.push({
|
|
36
|
+
mark: GLYPH.cross,
|
|
37
|
+
color: "error",
|
|
38
|
+
text: `${account.label} needs a sign-in`,
|
|
39
|
+
detail: "/accounts",
|
|
40
|
+
});
|
|
41
|
+
if (git !== undefined && git.conflicted > 0)
|
|
42
|
+
notes.push({ mark: GLYPH.cross, color: "error", text: `${git.conflicted} files in conflict` });
|
|
43
|
+
if (git !== undefined && git.behind > 0)
|
|
44
|
+
notes.push({
|
|
45
|
+
mark: GLYPH.down,
|
|
46
|
+
color: "warning",
|
|
47
|
+
text: `${snapshot.branch ?? "this branch"} is ${git.behind} behind`,
|
|
48
|
+
detail: "pull before you start",
|
|
49
|
+
});
|
|
50
|
+
if (github?.ci !== undefined)
|
|
51
|
+
notes.push({
|
|
52
|
+
mark: GLYPH.cross,
|
|
53
|
+
color: "error",
|
|
54
|
+
text: `CI failed on ${github.ci.branch}`,
|
|
55
|
+
detail: `${github.ci.sha} ${github.ci.name}`,
|
|
56
|
+
});
|
|
57
|
+
if (github?.pr !== undefined)
|
|
58
|
+
notes.push({
|
|
59
|
+
mark: GLYPH.flag,
|
|
60
|
+
color: "warning",
|
|
61
|
+
text: `PR #${github.pr.number} ${github.pr.title}`,
|
|
62
|
+
detail: github.pr.waits,
|
|
63
|
+
});
|
|
64
|
+
for (const peer of snapshot.peers)
|
|
65
|
+
notes.push({
|
|
66
|
+
mark: GLYPH.diamond,
|
|
67
|
+
color: peer.color,
|
|
68
|
+
text: `@${peer.handle}`,
|
|
69
|
+
detail: peerState(peer.state),
|
|
70
|
+
});
|
|
71
|
+
for (const window of account?.windows ?? []) {
|
|
72
|
+
if (window.used < PLAN_LIMIT) continue;
|
|
73
|
+
notes.push({
|
|
74
|
+
mark: GLYPH.up,
|
|
75
|
+
color: window.used >= 90 ? "error" : "warning",
|
|
76
|
+
text: `${window.name} at ${Math.round(window.used)}%`,
|
|
77
|
+
detail: window.resetsIn === undefined ? undefined : `resets in ${window.resetsIn}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (last !== undefined && last.version !== version)
|
|
81
|
+
notes.push({
|
|
82
|
+
mark: GLYPH.up,
|
|
83
|
+
color: "accent",
|
|
84
|
+
text: `${GLYPH.pi}-harness ${version} is new`,
|
|
85
|
+
detail: `was ${last.version}`,
|
|
86
|
+
});
|
|
87
|
+
return notes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function peerState(state: "running" | "idle" | "waiting"): string {
|
|
91
|
+
switch (state) {
|
|
92
|
+
case "running":
|
|
93
|
+
return "works here";
|
|
94
|
+
case "waiting":
|
|
95
|
+
return "waits for you";
|
|
96
|
+
case "idle":
|
|
97
|
+
return "is open here";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Local time, hours and minutes, or the day when it was not today. */
|
|
102
|
+
export function since(at: number, now: number): string {
|
|
103
|
+
const then = new Date(at);
|
|
104
|
+
const time = formatClock(at);
|
|
105
|
+
if (new Date(now).toDateString() === then.toDateString()) return time;
|
|
106
|
+
const days = Math.round((now - at) / 86_400_000);
|
|
107
|
+
return days <= 1 ? `yesterday ${time}` : `${days} days ago`;
|
|
108
|
+
}
|
package/src/data/telemetry.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface RunView {
|
|
|
38
38
|
running: boolean;
|
|
39
39
|
elapsedMs: number;
|
|
40
40
|
requests: number;
|
|
41
|
+
/** What the run's answers cost so far, at the provider's prices. */
|
|
42
|
+
cost: number;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
interface Request {
|
|
@@ -56,6 +58,7 @@ interface Run {
|
|
|
56
58
|
startedAt: number;
|
|
57
59
|
endedAt: number | undefined;
|
|
58
60
|
requests: number;
|
|
61
|
+
cost: number;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
interface UsageLike {
|
|
@@ -86,7 +89,7 @@ export class Telemetry {
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
runStarted(): void {
|
|
89
|
-
this.#run = { startedAt: this.#now(), endedAt: undefined, requests: 0 };
|
|
92
|
+
this.#run = { startedAt: this.#now(), endedAt: undefined, requests: 0, cost: 0 };
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
runEnded(): void {
|
|
@@ -137,6 +140,7 @@ export class Telemetry {
|
|
|
137
140
|
const now = this.#now();
|
|
138
141
|
request.endedAt = now;
|
|
139
142
|
request.usage = usageTotals(usage);
|
|
143
|
+
if (this.#run !== undefined) this.#run.cost += usage.cost.total;
|
|
140
144
|
if (request.usage.output === 0 && request.firstTokenAt === undefined) return undefined;
|
|
141
145
|
|
|
142
146
|
if (request.characters > 0 && request.usage.output > 0) {
|
|
@@ -172,6 +176,7 @@ export class Telemetry {
|
|
|
172
176
|
running,
|
|
173
177
|
elapsedMs: (run.endedAt ?? this.#now()) - run.startedAt,
|
|
174
178
|
requests: run.requests,
|
|
179
|
+
cost: run.cost,
|
|
175
180
|
};
|
|
176
181
|
}
|
|
177
182
|
|
package/src/data/totals.ts
CHANGED
|
@@ -68,25 +68,26 @@ export function totalsOf(entries: readonly SessionEntry[]): Totals {
|
|
|
68
68
|
return totals;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** The usage of the last answer the model gave, which is what the cache lines talk about. */
|
|
72
|
+
function lastAssistantUsage(entries: readonly SessionEntry[]): UsageLike | undefined {
|
|
72
73
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
73
74
|
const entry = entries[index];
|
|
74
75
|
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
75
|
-
|
|
76
|
-
const usage = entry.message.usage;
|
|
77
|
-
const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
78
|
-
return prompt === 0 ? undefined : (usage.cacheRead / prompt) * 100;
|
|
76
|
+
return entry.message.usage;
|
|
79
77
|
}
|
|
80
78
|
return undefined;
|
|
81
79
|
}
|
|
82
80
|
|
|
81
|
+
export function cacheHitPercent(entries: readonly SessionEntry[]): number | undefined {
|
|
82
|
+
const usage = lastAssistantUsage(entries);
|
|
83
|
+
if (usage === undefined) return undefined;
|
|
84
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
85
|
+
return prompt === 0 ? undefined : (usage.cacheRead / prompt) * 100;
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
export function cacheWarming(entries: readonly SessionEntry[]): boolean {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
87
|
-
return entry.message.usage.cacheRead === 0 && entry.message.usage.cacheWrite > 0;
|
|
88
|
-
}
|
|
89
|
-
return false;
|
|
89
|
+
const usage = lastAssistantUsage(entries);
|
|
90
|
+
return usage !== undefined && usage.cacheRead === 0 && usage.cacheWrite > 0;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export function averagesOf(entries: readonly SessionEntry[]): Averages {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The start screen says what changed since the last session in a folder, so it remembers when that
|
|
2
|
+
// was and which harness version ran it. One small file for every folder.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
7
|
+
|
|
8
|
+
import type { LastVisit } from "./start-notes.ts";
|
|
9
|
+
|
|
10
|
+
type Visits = Record<string, LastVisit>;
|
|
11
|
+
|
|
12
|
+
/** The last visit to `cwd`, and records this one. */
|
|
13
|
+
export function visit(
|
|
14
|
+
file: string,
|
|
15
|
+
cwd: string,
|
|
16
|
+
version: string,
|
|
17
|
+
now: number,
|
|
18
|
+
): LastVisit | undefined {
|
|
19
|
+
const visits = read(file);
|
|
20
|
+
const last = visits[cwd];
|
|
21
|
+
visits[cwd] = { at: now, version };
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
writeFileSync(file, JSON.stringify(visits));
|
|
25
|
+
} catch {
|
|
26
|
+
// A start screen that cannot remember only loses "since when".
|
|
27
|
+
}
|
|
28
|
+
return last;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function read(file: string): Visits {
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
34
|
+
return isObject(value) ? (value as Visits) : {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|