pi-zen 0.1.1 → 0.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 +13 -1
- package/extensions/zen.ts +25 -0
- package/package.json +5 -5
- package/src/backgroundless-theme.ts +31 -6
- package/src/turn-status.ts +101 -0
- package/src/turn-timing.ts +144 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ I built `pi-zen` because I wanted a minimal interface that made it easier to sta
|
|
|
4
4
|
|
|
5
5
|
For real knowledge work, I like being an active participant. I want to understand what is happening between the AI and me so we can be good partners in making decisions. `pi-zen` gives me that space: clear responses, visible progress, and the useful parts of the session in a calm, visually appealing interface.
|
|
6
6
|
|
|
7
|
-
`pi-zen` is a presentation extension for the [Pi](https://pi.dev) terminal interface. It removes visual clutter while preserving conversations, tool activity, reasoning, and session data. It adds compact tool summaries and diffs, a minimal editor rail, a quieter startup,
|
|
7
|
+
`pi-zen` is a presentation extension for the [Pi](https://pi.dev) terminal interface. It removes visual clutter while preserving conversations, tool activity, reasoning, and session data. It adds compact tool summaries and diffs, a minimal editor rail, a quieter startup, a calmer working indicator, and a live reading of where the current request's time is going, without changing tool execution or model prompts.
|
|
8
8
|
|
|
9
9
|
## Showcase
|
|
10
10
|
|
|
@@ -20,8 +20,20 @@ For real knowledge work, I like being an active participant. I want to understan
|
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
23
|
+
## While a request runs
|
|
24
|
+
|
|
25
|
+
Above the editor, one dim line says where the time is going and updates as the request runs:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
7.2s thinking · 3.6s writing · ↑6.5k ↓5.8k
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Thinking is the time the model spends reasoning, including the silence before it streams anything; writing is the time it spends producing text and tool calls. Time a tool spends running belongs to neither and is left out. The token counts follow Pi's footer: tokens sent, excluding cache reads and writes, and tokens produced, reasoning included. Both cover the whole request, however many turns it takes. A half of the work that took no measurable time is left out, so a model that does not stream its reasoning simply reads as writing. The final reading stays until the next request starts.
|
|
32
|
+
|
|
23
33
|
## Quick start
|
|
24
34
|
|
|
35
|
+
Requires Pi 0.84.3 or newer.
|
|
36
|
+
|
|
25
37
|
Install the extension from npm:
|
|
26
38
|
|
|
27
39
|
```sh
|
package/extensions/zen.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { type BuiltinToolOptions, registerCompactTools } from "../src/compact-to
|
|
|
16
16
|
import { squeezeBlankLines } from "../src/markdown-compaction.ts";
|
|
17
17
|
import { installBlankHeader } from "../src/silent-header.ts";
|
|
18
18
|
import { thinkingTail, thinkingTailLineBudget } from "../src/thinking-tail.ts";
|
|
19
|
+
import { TurnStatus } from "../src/turn-status.ts";
|
|
19
20
|
import { installQuietIndicator, restoreDefaultIndicator } from "../src/working-indicator.ts";
|
|
20
21
|
import { ZenEditor } from "../src/zen-editor.ts";
|
|
21
22
|
|
|
@@ -63,6 +64,7 @@ export default function zen(pi: ExtensionAPI): void {
|
|
|
63
64
|
let deferredThemeProjection: ReturnType<typeof setTimeout> | undefined;
|
|
64
65
|
let activeContext: ExtensionContext | undefined;
|
|
65
66
|
const grouper = new CallGrouper();
|
|
67
|
+
const turnStatus = new TurnStatus();
|
|
66
68
|
|
|
67
69
|
// Installing is idempotent on purpose: another extension can take the header
|
|
68
70
|
// or the editor at any time, so `/zen on` has to be able to claim them back.
|
|
@@ -122,6 +124,7 @@ export default function zen(pi: ExtensionAPI): void {
|
|
|
122
124
|
ctx.ui.setHeader(undefined);
|
|
123
125
|
ctx.ui.setEditorComponent(state.previousEditor);
|
|
124
126
|
restoreDefaultIndicator(ctx.ui);
|
|
127
|
+
turnStatus.clear(ctx);
|
|
125
128
|
state = { kind: "off" };
|
|
126
129
|
};
|
|
127
130
|
|
|
@@ -147,6 +150,28 @@ export default function zen(pi: ExtensionAPI): void {
|
|
|
147
150
|
}
|
|
148
151
|
});
|
|
149
152
|
|
|
153
|
+
const zenActive = (ctx: ExtensionContext) => state.kind === "on" && ctx.mode === "tui";
|
|
154
|
+
|
|
155
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
156
|
+
if (zenActive(ctx)) turnStatus.begin(ctx);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
pi.on("message_start", (event, ctx) => {
|
|
160
|
+
if (zenActive(ctx)) turnStatus.open(event, ctx);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.on("message_update", (event, ctx) => {
|
|
164
|
+
if (zenActive(ctx)) turnStatus.update(event, ctx);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.on("message_end", (event, ctx) => {
|
|
168
|
+
if (zenActive(ctx)) turnStatus.commit(event, ctx);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
172
|
+
if (zenActive(ctx)) turnStatus.end(ctx);
|
|
173
|
+
});
|
|
174
|
+
|
|
150
175
|
pi.on("turn_end", () => {
|
|
151
176
|
// The next turn's reads belong to their own line, not to this turn's.
|
|
152
177
|
grouper.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A quiet Pi TUI on one switch: backgroundless messages, a rail editor, compact tool calls, and restrained reasoning",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.21.0",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@earendil-works/pi-coding-agent": "
|
|
50
|
-
"@earendil-works/pi-tui": "
|
|
49
|
+
"@earendil-works/pi-coding-agent": ">=0.84.3",
|
|
50
|
+
"@earendil-works/pi-tui": ">=0.84.3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
54
|
-
"@earendil-works/pi-tui": "0.
|
|
53
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
54
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
55
55
|
"@oxlint/plugins": "^1.79.0",
|
|
56
56
|
"@types/node": "^24.10.0",
|
|
57
57
|
"oxlint": "^1.79.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Theme,
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
/** Name used only while Zen's backgroundless projection is active. */
|
|
4
4
|
export const BACKGROUNDLESS_THEME_NAME = "pi-zen:backgroundless";
|
|
@@ -53,11 +53,14 @@ const THEME_COLORS = [
|
|
|
53
53
|
"bashMode",
|
|
54
54
|
] as const satisfies ReadonlyArray<ThemeColor>;
|
|
55
55
|
|
|
56
|
-
type ThemeBackground = Parameters<Theme["bg"]>[0];
|
|
56
|
+
type ThemeBackground = Parameters<Theme["bg"]>[0] | "scrollbarThumb";
|
|
57
|
+
|
|
58
|
+
type LegacyScrollbarTheme = {
|
|
59
|
+
readonly getBgAnsi: (color: ThemeBackground) => string;
|
|
60
|
+
};
|
|
57
61
|
|
|
58
62
|
const THEME_BACKGROUNDS = [
|
|
59
63
|
"selectedBg",
|
|
60
|
-
"scrollbarThumb",
|
|
61
64
|
"searchMatchBg",
|
|
62
65
|
"userMessageBg",
|
|
63
66
|
"customMessageBg",
|
|
@@ -98,11 +101,32 @@ function emptyBackgrounds(): BackgroundInput {
|
|
|
98
101
|
return Object.fromEntries(THEME_BACKGROUNDS.map((color) => [color, ""])) as BackgroundInput;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
function captureScrollbarColors(
|
|
105
|
+
source: Theme,
|
|
106
|
+
foregroundAnsi: Map<ThemeColor, string>,
|
|
107
|
+
backgroundAnsi: Map<ThemeBackground, string>,
|
|
108
|
+
): void {
|
|
109
|
+
let thumb: string;
|
|
110
|
+
try {
|
|
111
|
+
thumb = source.getFgAnsi("scrollbarThumb");
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!(error instanceof Error) || error.message !== "Unknown theme color: scrollbarThumb") throw error;
|
|
114
|
+
// SAFETY: Pi 0.84.x reports this missing foreground because its thumb is a background token.
|
|
115
|
+
// The current Theme type omits that legacy key; its getter still checks the key at runtime.
|
|
116
|
+
const legacySource = source as LegacyScrollbarTheme;
|
|
117
|
+
backgroundAnsi.set("scrollbarThumb", legacySource.getBgAnsi("scrollbarThumb"));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
foregroundAnsi.set("scrollbarThumb", thumb);
|
|
121
|
+
foregroundAnsi.set("scrollbarTrack", source.getFgAnsi("scrollbarTrack"));
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
function makeThemeSnapshot(source: Theme, options: ThemeSnapshotOptions): Theme {
|
|
102
125
|
const foregroundAnsi = new Map<ThemeColor, string>();
|
|
103
126
|
const backgroundAnsi = new Map<ThemeBackground, string>();
|
|
104
127
|
for (const color of THEME_COLORS) foregroundAnsi.set(color, source.getFgAnsi(color));
|
|
105
128
|
for (const color of THEME_BACKGROUNDS) backgroundAnsi.set(color, source.getBgAnsi(color));
|
|
129
|
+
captureScrollbarColors(source, foregroundAnsi, backgroundAnsi);
|
|
106
130
|
|
|
107
131
|
// SAFETY: Pi created source, so its constructor is the runtime's Theme constructor. Using that exact constructor
|
|
108
132
|
// keeps instanceof checks valid when this source package has a different development copy of Pi installed.
|
|
@@ -120,16 +144,17 @@ function makeThemeSnapshot(source: Theme, options: ThemeSnapshotOptions): Theme
|
|
|
120
144
|
if (ansi === undefined) throw new Error(`Unknown theme color: ${color}`);
|
|
121
145
|
return ansi;
|
|
122
146
|
};
|
|
123
|
-
|
|
147
|
+
const getBgAnsi = (color: ThemeBackground): string => {
|
|
124
148
|
if (options.suppressContentBackgrounds && CONTENT_BACKGROUNDS.has(color)) return "\x1b[49m";
|
|
125
149
|
const ansi = backgroundAnsi.get(color);
|
|
126
150
|
if (ansi === undefined) throw new Error(`Unknown theme background: ${color}`);
|
|
127
151
|
return ansi;
|
|
128
152
|
};
|
|
153
|
+
snapshot.getBgAnsi = getBgAnsi;
|
|
129
154
|
snapshot.fg = (color: ThemeColor, text: string): string => `${snapshot.getFgAnsi(color)}${text}\x1b[39m`;
|
|
130
155
|
snapshot.bg = (color: ThemeBackground, text: string): string => {
|
|
131
156
|
if (options.suppressContentBackgrounds && CONTENT_BACKGROUNDS.has(color)) return text;
|
|
132
|
-
return `${
|
|
157
|
+
return `${getBgAnsi(color)}${text}\x1b[49m`;
|
|
133
158
|
};
|
|
134
159
|
|
|
135
160
|
return snapshot;
|
|
@@ -148,7 +173,7 @@ export function snapshotTheme(source: Theme): Theme {
|
|
|
148
173
|
/**
|
|
149
174
|
* Keep the active theme while removing message and tool backgrounds.
|
|
150
175
|
* Surface-specific body text falls back to the theme's terminal-safe base text;
|
|
151
|
-
* selection
|
|
176
|
+
* selection and search backgrounds, plus scrollbar colors, remain as affordances.
|
|
152
177
|
*
|
|
153
178
|
* @param source - Pi's currently active theme.
|
|
154
179
|
* @returns A backgroundless projection of the active theme.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
MessageEndEvent,
|
|
4
|
+
MessageStartEvent,
|
|
5
|
+
MessageUpdateEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { advance, beginRun, formatTurnStatus, type TurnPhase, type TurnTiming, type TurnTokens } from "./turn-timing.ts";
|
|
9
|
+
import { QUIET_INTERVAL_MS } from "./working-indicator.ts";
|
|
10
|
+
|
|
11
|
+
const WIDGET_KEY = "zen-turn-status";
|
|
12
|
+
const WIDGET_INDENT = 1;
|
|
13
|
+
const FALLBACK_COLUMNS = 80;
|
|
14
|
+
|
|
15
|
+
type StreamedMessage = Extract<MessageUpdateEvent["message"], { role: "assistant" }>;
|
|
16
|
+
|
|
17
|
+
function tokensOf(message: StreamedMessage): TurnTokens {
|
|
18
|
+
return { input: message.usage.input, output: message.usage.output };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function phaseOf(message: StreamedMessage): TurnPhase | undefined {
|
|
22
|
+
const latest = message.content.at(-1);
|
|
23
|
+
if (latest === undefined) return undefined;
|
|
24
|
+
return latest.type === "thinking" ? "thinking" : "writing";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TurnStatus {
|
|
28
|
+
private timing: TurnTiming = beginRun();
|
|
29
|
+
private ticker: ReturnType<typeof setInterval> | undefined;
|
|
30
|
+
private context: ExtensionContext | undefined;
|
|
31
|
+
|
|
32
|
+
begin(ctx: ExtensionContext): void {
|
|
33
|
+
this.context = ctx;
|
|
34
|
+
this.timing = beginRun();
|
|
35
|
+
this.startTicking();
|
|
36
|
+
this.render();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
open(event: MessageStartEvent, ctx: ExtensionContext): void {
|
|
40
|
+
if (event.message.role !== "assistant") return;
|
|
41
|
+
|
|
42
|
+
this.context = ctx;
|
|
43
|
+
this.timing = advance(this.timing, { kind: "opened" }, Date.now());
|
|
44
|
+
this.render();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
update(event: MessageUpdateEvent, ctx: ExtensionContext): void {
|
|
48
|
+
if (event.message.role !== "assistant") return;
|
|
49
|
+
|
|
50
|
+
this.context = ctx;
|
|
51
|
+
const phase = phaseOf(event.message);
|
|
52
|
+
if (phase !== undefined) {
|
|
53
|
+
const live = tokensOf(event.message);
|
|
54
|
+
this.timing = advance(this.timing, { kind: "producing", phase, live }, Date.now());
|
|
55
|
+
}
|
|
56
|
+
this.render();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
commit(event: MessageEndEvent, ctx: ExtensionContext): void {
|
|
60
|
+
if (event.message.role !== "assistant") return;
|
|
61
|
+
|
|
62
|
+
this.context = ctx;
|
|
63
|
+
this.timing = advance(this.timing, { kind: "settled", tokens: tokensOf(event.message) }, Date.now());
|
|
64
|
+
this.render();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
end(ctx: ExtensionContext): void {
|
|
68
|
+
this.context = ctx;
|
|
69
|
+
this.timing = advance(this.timing, { kind: "closed" }, Date.now());
|
|
70
|
+
this.stopTicking();
|
|
71
|
+
this.render();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clear(ctx: ExtensionContext): void {
|
|
75
|
+
this.context = ctx;
|
|
76
|
+
this.timing = beginRun();
|
|
77
|
+
this.stopTicking();
|
|
78
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private startTicking(): void {
|
|
82
|
+
this.stopTicking();
|
|
83
|
+
this.ticker = setInterval(() => this.render(), QUIET_INTERVAL_MS);
|
|
84
|
+
this.ticker.unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private stopTicking(): void {
|
|
88
|
+
if (this.ticker === undefined) return;
|
|
89
|
+
clearInterval(this.ticker);
|
|
90
|
+
this.ticker = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private render(): void {
|
|
94
|
+
const ctx = this.context;
|
|
95
|
+
if (ctx === undefined) return;
|
|
96
|
+
|
|
97
|
+
const columns = process.stdout.columns ?? FALLBACK_COLUMNS;
|
|
98
|
+
const line = formatTurnStatus(this.timing, Date.now(), columns - WIDGET_INDENT);
|
|
99
|
+
ctx.ui.setWidget(WIDGET_KEY, line === "" ? undefined : [ctx.ui.theme.fg("muted", line)]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type TurnPhase = "thinking" | "writing";
|
|
4
|
+
|
|
5
|
+
export type TurnTokens = {
|
|
6
|
+
readonly input: number;
|
|
7
|
+
readonly output: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Accrued = {
|
|
11
|
+
readonly thinkingMs: number;
|
|
12
|
+
readonly writingMs: number;
|
|
13
|
+
readonly committed: TurnTokens;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type TurnTiming =
|
|
17
|
+
| ({ readonly kind: "idle" } & Accrued)
|
|
18
|
+
| ({
|
|
19
|
+
readonly kind: "waiting";
|
|
20
|
+
readonly openedAt: number;
|
|
21
|
+
readonly live: TurnTokens;
|
|
22
|
+
} & Accrued)
|
|
23
|
+
| ({
|
|
24
|
+
readonly kind: "streaming";
|
|
25
|
+
readonly phase: TurnPhase;
|
|
26
|
+
readonly since: number;
|
|
27
|
+
readonly live: TurnTokens;
|
|
28
|
+
} & Accrued);
|
|
29
|
+
|
|
30
|
+
export type TurnSignal =
|
|
31
|
+
| { readonly kind: "opened" }
|
|
32
|
+
| { readonly kind: "producing"; readonly phase: TurnPhase; readonly live: TurnTokens }
|
|
33
|
+
| { readonly kind: "closed" }
|
|
34
|
+
| { readonly kind: "settled"; readonly tokens: TurnTokens };
|
|
35
|
+
|
|
36
|
+
type TurnElapsed = {
|
|
37
|
+
readonly thinkingMs: number;
|
|
38
|
+
readonly writingMs: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SEPARATOR = " · ";
|
|
42
|
+
const ELLIPSIS = "…";
|
|
43
|
+
const LEGIBLE_MS = 50;
|
|
44
|
+
const NO_TOKENS: TurnTokens = { input: 0, output: 0 };
|
|
45
|
+
|
|
46
|
+
function formatSeconds(ms: number): string {
|
|
47
|
+
return `${(Math.max(0, ms) / 1000).toFixed(1)}s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatTokenCount(count: number): string {
|
|
51
|
+
if (count < 1_000) return String(Math.max(0, Math.round(count)));
|
|
52
|
+
if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
53
|
+
if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
|
|
54
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
55
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addTokens(left: TurnTokens, right: TurnTokens): TurnTokens {
|
|
59
|
+
return { input: left.input + right.input, output: left.output + right.output };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function settle(timing: TurnTiming, now: number, waitPhase: TurnPhase): Accrued {
|
|
63
|
+
const totals = { thinkingMs: timing.thinkingMs, writingMs: timing.writingMs, committed: timing.committed };
|
|
64
|
+
if (timing.kind === "idle") return totals;
|
|
65
|
+
|
|
66
|
+
const spent = Math.max(0, now - (timing.kind === "waiting" ? timing.openedAt : timing.since));
|
|
67
|
+
const phase = timing.kind === "waiting" ? waitPhase : timing.phase;
|
|
68
|
+
|
|
69
|
+
return phase === "thinking"
|
|
70
|
+
? { ...totals, thinkingMs: totals.thinkingMs + spent }
|
|
71
|
+
: { ...totals, writingMs: totals.writingMs + spent };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function settleAsThinking(timing: TurnTiming, now: number): Accrued {
|
|
75
|
+
return settle(timing, now, "thinking");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function elapsed(timing: TurnTiming, now: number): TurnElapsed {
|
|
79
|
+
const totals = settleAsThinking(timing, now);
|
|
80
|
+
return { thinkingMs: totals.thinkingMs, writingMs: totals.writingMs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tokens(timing: TurnTiming): TurnTokens {
|
|
84
|
+
return timing.kind === "idle" ? timing.committed : addTokens(timing.committed, timing.live);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function statusSegments(timing: TurnTiming, now: number): string[] {
|
|
88
|
+
const spent = elapsed(timing, now);
|
|
89
|
+
const used = tokens(timing);
|
|
90
|
+
|
|
91
|
+
const segments: string[] = [];
|
|
92
|
+
if (spent.thinkingMs >= LEGIBLE_MS) segments.push(`${formatSeconds(spent.thinkingMs)} thinking`);
|
|
93
|
+
if (spent.writingMs >= LEGIBLE_MS) segments.push(`${formatSeconds(spent.writingMs)} writing`);
|
|
94
|
+
if (used.input > 0 || used.output > 0) {
|
|
95
|
+
segments.push(`↑${formatTokenCount(used.input)} ↓${formatTokenCount(used.output)}`);
|
|
96
|
+
}
|
|
97
|
+
return segments;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fitToWidth(segments: string[], width: number): string {
|
|
101
|
+
let kept = segments.length;
|
|
102
|
+
while (kept > 1 && visibleWidth(segments.slice(0, kept).join(SEPARATOR)) > width) kept -= 1;
|
|
103
|
+
|
|
104
|
+
const body = segments.slice(0, kept).join(SEPARATOR);
|
|
105
|
+
return visibleWidth(body) <= width ? body : truncateToWidth(body, width, ELLIPSIS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function beginRun(): TurnTiming {
|
|
109
|
+
return { kind: "idle", thinkingMs: 0, writingMs: 0, committed: NO_TOKENS };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function advance(timing: TurnTiming, signal: TurnSignal, now: number): TurnTiming {
|
|
113
|
+
switch (signal.kind) {
|
|
114
|
+
case "opened":
|
|
115
|
+
return { kind: "waiting", openedAt: now, live: NO_TOKENS, ...settleAsThinking(timing, now) };
|
|
116
|
+
|
|
117
|
+
case "producing": {
|
|
118
|
+
if (timing.kind === "streaming" && timing.phase === signal.phase) return { ...timing, live: signal.live };
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: "streaming",
|
|
122
|
+
phase: signal.phase,
|
|
123
|
+
since: now,
|
|
124
|
+
live: signal.live,
|
|
125
|
+
...settle(timing, now, signal.phase),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "closed":
|
|
130
|
+
return { kind: "idle", ...settleAsThinking(timing, now) };
|
|
131
|
+
|
|
132
|
+
case "settled": {
|
|
133
|
+
const totals = settleAsThinking(timing, now);
|
|
134
|
+
return { kind: "idle", ...totals, committed: addTokens(totals.committed, signal.tokens) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatTurnStatus(timing: TurnTiming, now: number, width: number): string {
|
|
140
|
+
if (width <= 0) return "";
|
|
141
|
+
|
|
142
|
+
const segments = statusSegments(timing, now);
|
|
143
|
+
return segments.length === 0 ? "" : fitToWidth(segments, width);
|
|
144
|
+
}
|