@thoth-agents/pi-thoth-theme 0.1.0 → 0.3.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 +51 -10
- package/dist/index.ts +3967 -0
- package/package.json +7 -9
- package/src/index.ts +0 -53
- package/src/input-box/decorate.ts +0 -304
- package/src/input-box/frame.ts +0 -182
- package/src/input-box/gradient.ts +0 -92
- package/src/input-box/state.ts +0 -90
- package/src/render-kit/index.ts +0 -305
- package/src/render-kit/working.ts +0 -25
- package/src/shared/cache.ts +0 -21
- package/src/shared/config.ts +0 -86
- package/src/shared/duration.ts +0 -1
- package/src/shared/icons.ts +0 -89
- package/src/status-line/cost.ts +0 -56
- package/src/status-line/index.ts +0 -242
- package/src/status-line/layout.ts +0 -272
- package/src/status-line/snapshot.ts +0 -11
- package/src/status-line/throughput.ts +0 -61
- package/src/status-line/tokens.ts +0 -51
- package/src/tools/bash.ts +0 -237
- package/src/tools/border.ts +0 -21
- package/src/tools/box.ts +0 -258
- package/src/tools/edit.ts +0 -228
- package/src/tools/file-icons.ts +0 -139
- package/src/tools/find.ts +0 -227
- package/src/tools/frame.ts +0 -166
- package/src/tools/generic.ts +0 -195
- package/src/tools/grep.ts +0 -371
- package/src/tools/index.ts +0 -135
- package/src/tools/ls.ts +0 -231
- package/src/tools/ownership.ts +0 -70
- package/src/tools/powershell.ts +0 -1
- package/src/tools/read.ts +0 -194
- package/src/tools/ticker.ts +0 -101
- package/src/tools/write.ts +0 -188
- package/src/welcome/age.ts +0 -11
- package/src/welcome/index.ts +0 -132
- package/src/welcome/logo.ts +0 -35
- package/src/welcome/render.ts +0 -323
- package/src/welcome/resources.ts +0 -251
package/src/status-line/index.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
} from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import {
|
|
6
|
-
decorateEditor,
|
|
7
|
-
type EditorDecoration,
|
|
8
|
-
} from '../input-box/decorate.ts';
|
|
9
|
-
import { createWorkingState } from '../input-box/state.ts';
|
|
10
|
-
import type { ThemeConfig } from '../shared/config.ts';
|
|
11
|
-
import { calculateSessionCost } from './cost.ts';
|
|
12
|
-
import { type ActiveThemeLike, formatCwd, renderStatusLine } from './layout.ts';
|
|
13
|
-
import type { StatusSnapshot, StatusSnapshotProvider } from './snapshot.ts';
|
|
14
|
-
import { createThroughputTracker } from './throughput.ts';
|
|
15
|
-
import { calculateSessionTokens } from './tokens.ts';
|
|
16
|
-
|
|
17
|
-
export { calculateSessionCost, formatCost } from './cost.ts';
|
|
18
|
-
export {
|
|
19
|
-
type ActiveThemeLike,
|
|
20
|
-
type ContextUsageInfo,
|
|
21
|
-
formatCwd,
|
|
22
|
-
formatTokens,
|
|
23
|
-
type RenderStatusLineOptions,
|
|
24
|
-
renderStatusLine,
|
|
25
|
-
type StatusData,
|
|
26
|
-
} from './layout.ts';
|
|
27
|
-
export type { StatusSnapshot, StatusSnapshotProvider } from './snapshot.ts';
|
|
28
|
-
export { calculateSessionTokens, type SessionTokenTotals } from './tokens.ts';
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Renders the cached status row below the input box and discovers editors in place.
|
|
32
|
-
* Session aggregation is cached until an event or session leaf change; this
|
|
33
|
-
* extension never registers an editor factory with setEditorComponent.
|
|
34
|
-
*/
|
|
35
|
-
export function registerStatusLine(
|
|
36
|
-
pi: ExtensionAPI,
|
|
37
|
-
ctx: ExtensionContext,
|
|
38
|
-
config: ThemeConfig,
|
|
39
|
-
): void {
|
|
40
|
-
const subscriptionProviders = config.statusLine.subscriptionProviders ?? [
|
|
41
|
-
'claude-bridge',
|
|
42
|
-
];
|
|
43
|
-
const inputBoxEnabled = config.inputBox?.enabled !== false;
|
|
44
|
-
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
45
|
-
const unsubs: Array<() => void> = [];
|
|
46
|
-
let disposed = false;
|
|
47
|
-
const throughput = createThroughputTracker();
|
|
48
|
-
let sessionLeafId = ctx.sessionManager.getLeafId();
|
|
49
|
-
let session = readSession();
|
|
50
|
-
let sessionDirty = false;
|
|
51
|
-
let subagentCost = 0;
|
|
52
|
-
let cachedKey = '';
|
|
53
|
-
let cachedLines: string[] = [];
|
|
54
|
-
let snapshotKey = '';
|
|
55
|
-
let snapshot: Readonly<StatusSnapshot>;
|
|
56
|
-
|
|
57
|
-
// Cheap live fields are sampled by either consumer, never by footer order.
|
|
58
|
-
// Checking the leaf is O(1); unchanged frames do not copy/aggregate entries.
|
|
59
|
-
const getStatusSnapshot: StatusSnapshotProvider = () => {
|
|
60
|
-
if (!disposed) {
|
|
61
|
-
const leafId = ctx.sessionManager.getLeafId();
|
|
62
|
-
if (sessionDirty || leafId !== sessionLeafId) {
|
|
63
|
-
session = readSession();
|
|
64
|
-
sessionLeafId = leafId;
|
|
65
|
-
sessionDirty = false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const data: StatusSnapshot = {
|
|
69
|
-
modelName: ctx.model?.name,
|
|
70
|
-
modelId: ctx.model?.id,
|
|
71
|
-
thinkingLevel: ctx.thinkingLevel,
|
|
72
|
-
gitBranch: footerData?.getGitBranch?.() ?? null,
|
|
73
|
-
cwd: formatCwd(ctx.cwd, process.env.HOME || process.env.USERPROFILE),
|
|
74
|
-
...session,
|
|
75
|
-
subagentCost,
|
|
76
|
-
tokensPerSecond: throughput.tokensPerSecond,
|
|
77
|
-
};
|
|
78
|
-
const key = JSON.stringify(data);
|
|
79
|
-
if (key !== snapshotKey) {
|
|
80
|
-
snapshotKey = key;
|
|
81
|
-
snapshot = data;
|
|
82
|
-
}
|
|
83
|
-
return snapshot;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
function readSession() {
|
|
87
|
-
const usage = ctx.getContextUsage?.();
|
|
88
|
-
const provider = ctx.model?.provider;
|
|
89
|
-
const entries = ctx.sessionManager.getEntries();
|
|
90
|
-
return {
|
|
91
|
-
cost: calculateSessionCost(entries),
|
|
92
|
-
tokenTotals: calculateSessionTokens(entries),
|
|
93
|
-
isSubscription:
|
|
94
|
-
provider !== undefined && subscriptionProviders.includes(provider),
|
|
95
|
-
contextTokens: usage?.tokens ?? null,
|
|
96
|
-
contextPercent: usage?.percent ?? null,
|
|
97
|
-
contextWindow: usage?.contextWindow,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const requestRender = () => {
|
|
102
|
-
if (!disposed) tui.requestRender?.();
|
|
103
|
-
};
|
|
104
|
-
const invalidateRender = () => {
|
|
105
|
-
if (disposed) return;
|
|
106
|
-
cachedKey = '';
|
|
107
|
-
requestRender();
|
|
108
|
-
};
|
|
109
|
-
const working = createWorkingState(invalidateRender);
|
|
110
|
-
const editorDecorations = new Set<EditorDecoration>();
|
|
111
|
-
const refreshSession = () => {
|
|
112
|
-
if (disposed) return;
|
|
113
|
-
// message_end reaches extensions before Pi persists the entry. An early
|
|
114
|
-
// consumer may read now; the leaf key still detects the later append.
|
|
115
|
-
sessionDirty = true;
|
|
116
|
-
invalidateRender();
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
unsubs.push(
|
|
120
|
-
pi.events.on('thoth:subagent-usage', (data) => {
|
|
121
|
-
if (disposed || !data || typeof data !== 'object') return;
|
|
122
|
-
const { parentSessionId, totalCost } = data as {
|
|
123
|
-
parentSessionId?: unknown;
|
|
124
|
-
totalCost?: unknown;
|
|
125
|
-
};
|
|
126
|
-
if (
|
|
127
|
-
parentSessionId !== ctx.sessionManager.getSessionId() ||
|
|
128
|
-
typeof totalCost !== 'number' ||
|
|
129
|
-
!Number.isFinite(totalCost) ||
|
|
130
|
-
totalCost < 0
|
|
131
|
-
)
|
|
132
|
-
return;
|
|
133
|
-
// The publisher sends a cumulative snapshot, not a delta.
|
|
134
|
-
subagentCost = totalCost;
|
|
135
|
-
requestRender();
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
if (footerData?.onBranchChange) {
|
|
140
|
-
unsubs.push(footerData.onBranchChange(invalidateRender));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const requestSubagentUsage = () => {
|
|
144
|
-
pi.events.emit('thoth:subagent-usage:request', {
|
|
145
|
-
parentSessionId: ctx.sessionManager.getSessionId(),
|
|
146
|
-
});
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
if (pi?.on) {
|
|
150
|
-
unsubs.push(
|
|
151
|
-
pi.on('session_start', () => {
|
|
152
|
-
if (disposed) return;
|
|
153
|
-
subagentCost = 0;
|
|
154
|
-
throughput.reset();
|
|
155
|
-
if (inputBoxEnabled) working.end();
|
|
156
|
-
refreshSession();
|
|
157
|
-
requestSubagentUsage();
|
|
158
|
-
}),
|
|
159
|
-
);
|
|
160
|
-
if (inputBoxEnabled) {
|
|
161
|
-
unsubs.push(pi.on('agent_start', working.start));
|
|
162
|
-
unsubs.push(pi.on('agent_end', working.end));
|
|
163
|
-
}
|
|
164
|
-
unsubs.push(
|
|
165
|
-
pi.on('message_start', (event) => {
|
|
166
|
-
if (!disposed && event) throughput.observe(event);
|
|
167
|
-
}),
|
|
168
|
-
);
|
|
169
|
-
unsubs.push(
|
|
170
|
-
pi.on('message_end', (event) => {
|
|
171
|
-
if (disposed) return;
|
|
172
|
-
if (event) throughput.observe(event);
|
|
173
|
-
refreshSession();
|
|
174
|
-
}),
|
|
175
|
-
);
|
|
176
|
-
unsubs.push(pi.on('turn_end', refreshSession));
|
|
177
|
-
unsubs.push(pi.on('agent_end', refreshSession));
|
|
178
|
-
unsubs.push(pi.on('session_compact', refreshSession));
|
|
179
|
-
unsubs.push(pi.on('model_select', refreshSession));
|
|
180
|
-
unsubs.push(pi.on('thinking_level_select', invalidateRender));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Subscribe first: a request responder can publish synchronously.
|
|
184
|
-
requestSubagentUsage();
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
getStatusSnapshot,
|
|
188
|
-
render(width: number): string[] {
|
|
189
|
-
if (!disposed && inputBoxEnabled) {
|
|
190
|
-
// Discovery must precede cached-row returns: focus and editor identity
|
|
191
|
-
// can change without any status data changing.
|
|
192
|
-
const focusedTui = tui as typeof tui & {
|
|
193
|
-
getFocusedComponent?: () => unknown;
|
|
194
|
-
};
|
|
195
|
-
const editor = focusedTui.getFocusedComponent?.();
|
|
196
|
-
const decoration = decorateEditor(editor, {
|
|
197
|
-
theme: theme as ActiveThemeLike,
|
|
198
|
-
working,
|
|
199
|
-
getStatusSnapshot,
|
|
200
|
-
get iconMode() {
|
|
201
|
-
return config.icons;
|
|
202
|
-
},
|
|
203
|
-
});
|
|
204
|
-
if (decoration && !editorDecorations.has(decoration)) {
|
|
205
|
-
editorDecorations.add(decoration);
|
|
206
|
-
requestRender();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (width <= 0) return [];
|
|
210
|
-
const data = getStatusSnapshot();
|
|
211
|
-
const key = `${width}\u0000${JSON.stringify(data)}`;
|
|
212
|
-
if (key === cachedKey) return cachedLines;
|
|
213
|
-
const line = renderStatusLine(data, {
|
|
214
|
-
width,
|
|
215
|
-
mode: config.icons,
|
|
216
|
-
theme: theme as ActiveThemeLike,
|
|
217
|
-
});
|
|
218
|
-
cachedKey = key;
|
|
219
|
-
// Working ticks invalidate styling, but unchanged footer output keeps
|
|
220
|
-
// its identity and avoids needless downstream updates.
|
|
221
|
-
if (line !== cachedLines[0]) cachedLines = line ? [line] : [];
|
|
222
|
-
return cachedLines;
|
|
223
|
-
},
|
|
224
|
-
invalidate: invalidateRender,
|
|
225
|
-
dispose() {
|
|
226
|
-
// Pi may have snapshotted lifecycle handlers before replacing the footer.
|
|
227
|
-
disposed = true;
|
|
228
|
-
working.dispose();
|
|
229
|
-
for (const decoration of editorDecorations) decoration.dispose();
|
|
230
|
-
editorDecorations.clear();
|
|
231
|
-
for (const unsub of unsubs) {
|
|
232
|
-
try {
|
|
233
|
-
unsub();
|
|
234
|
-
} catch {
|
|
235
|
-
// ignore disposal errors during cleanup
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
unsubs.length = 0;
|
|
239
|
-
},
|
|
240
|
-
};
|
|
241
|
-
});
|
|
242
|
-
}
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
-
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
3
|
-
import type { IconMode } from '../shared/config.ts';
|
|
4
|
-
import { icon } from '../shared/icons.ts';
|
|
5
|
-
import type { SessionTokenTotals } from './tokens.ts';
|
|
6
|
-
|
|
7
|
-
export interface ContextUsageInfo {
|
|
8
|
-
percent?: number | null;
|
|
9
|
-
tokens?: number | null;
|
|
10
|
-
contextWindow?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface StatusData {
|
|
14
|
-
modelName?: string;
|
|
15
|
-
modelId?: string;
|
|
16
|
-
thinkingLevel?: string;
|
|
17
|
-
gitBranch?: string | null;
|
|
18
|
-
/** Working directory formatted for display, including home abbreviation. */
|
|
19
|
-
cwd?: string;
|
|
20
|
-
contextTokens?: number | null;
|
|
21
|
-
contextWindow?: number;
|
|
22
|
-
contextPercent?: number | null;
|
|
23
|
-
/** Parent session cost; subagent cost is accounted for separately. */
|
|
24
|
-
cost?: number;
|
|
25
|
-
subagentCost?: number;
|
|
26
|
-
isSubscription?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ActiveThemeLike {
|
|
30
|
-
fg?: (token: string, text: string) => string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface RenderStatusLineOptions {
|
|
34
|
-
width: number;
|
|
35
|
-
mode: IconMode;
|
|
36
|
-
theme?: ActiveThemeLike;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function themeFg(
|
|
40
|
-
theme: ActiveThemeLike | undefined,
|
|
41
|
-
token: string,
|
|
42
|
-
text: string,
|
|
43
|
-
): string {
|
|
44
|
-
return theme?.fg ? theme.fg(token, text) : text;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function formatCwd(cwd: string, home?: string): string {
|
|
48
|
-
if (!home) return cwd;
|
|
49
|
-
const relativeToHome = relative(resolve(home), resolve(cwd));
|
|
50
|
-
if (
|
|
51
|
-
relativeToHome === '..' ||
|
|
52
|
-
relativeToHome.startsWith(`..${sep}`) ||
|
|
53
|
-
isAbsolute(relativeToHome)
|
|
54
|
-
) {
|
|
55
|
-
return cwd;
|
|
56
|
-
}
|
|
57
|
-
return relativeToHome === '' ? '~' : `~${sep}${relativeToHome}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function formatTokens(count: number): string {
|
|
61
|
-
if (count < 1000) return count.toString();
|
|
62
|
-
if (count < 1000000) {
|
|
63
|
-
const k = (count / 1000).toFixed(1).replace(/\.0$/, '');
|
|
64
|
-
return `${k}K`;
|
|
65
|
-
}
|
|
66
|
-
const m = (count / 1000000).toFixed(1).replace(/\.0$/, '');
|
|
67
|
-
return `${m}M`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function thinkingToken(level: string): string {
|
|
71
|
-
const cap = level.charAt(0).toUpperCase() + level.slice(1);
|
|
72
|
-
return `thinking${cap}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface SegmentOptions {
|
|
76
|
-
mode?: IconMode;
|
|
77
|
-
theme?: ActiveThemeLike;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Git branch piece for the input-box border; empty when no branch. */
|
|
81
|
-
export function formatBranchSegment(
|
|
82
|
-
data: StatusData,
|
|
83
|
-
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
84
|
-
): string {
|
|
85
|
-
if (!data.gitBranch) return '';
|
|
86
|
-
return `${themeFg(theme, 'dim', icon('separator', mode))} ${themeFg(
|
|
87
|
-
theme,
|
|
88
|
-
'success',
|
|
89
|
-
`${icon('branch', mode)} ${data.gitBranch}`,
|
|
90
|
-
)}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Model then optional effort pieces; each piece starts a droppable region. */
|
|
94
|
-
export function formatModelSegments(
|
|
95
|
-
data: StatusData,
|
|
96
|
-
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
97
|
-
): string[] {
|
|
98
|
-
const pieces: string[] = [];
|
|
99
|
-
const model = data.modelName || data.modelId;
|
|
100
|
-
if (model) {
|
|
101
|
-
pieces.push(themeFg(theme, 'mdLink', `${icon('model', mode)} ${model}`));
|
|
102
|
-
}
|
|
103
|
-
const level = data.thinkingLevel;
|
|
104
|
-
if (level && level !== 'off') {
|
|
105
|
-
const label =
|
|
106
|
-
level === 'minimal' ? 'min' : level === 'medium' ? 'med' : level;
|
|
107
|
-
const effort = themeFg(
|
|
108
|
-
theme,
|
|
109
|
-
thinkingToken(level),
|
|
110
|
-
`${icon('effort', mode)} ${label}`,
|
|
111
|
-
);
|
|
112
|
-
pieces.push(
|
|
113
|
-
model
|
|
114
|
-
? `${themeFg(theme, 'dim', icon('separator', mode))} ${effort}`
|
|
115
|
-
: effort,
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
return pieces;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Working directory with folder icon, richest first: full path then `…/leaf`. */
|
|
122
|
-
export function formatCwdSegments(
|
|
123
|
-
data: StatusData,
|
|
124
|
-
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
125
|
-
): string[] {
|
|
126
|
-
const cwd = data.cwd;
|
|
127
|
-
if (!cwd) return [];
|
|
128
|
-
const folder = icon('folder', mode);
|
|
129
|
-
const variants = [`${folder} ${cwd}`];
|
|
130
|
-
const leaf = cwd.split(/[/\\]/).filter(Boolean).pop();
|
|
131
|
-
const separator = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/';
|
|
132
|
-
const isDriveRoot = leaf !== undefined && /^[A-Za-z]:$/.test(leaf);
|
|
133
|
-
if (leaf && leaf !== cwd && leaf !== '~' && !isDriveRoot) {
|
|
134
|
-
variants.push(`${folder} ${icon('ellipsis', mode)}${separator}${leaf}`);
|
|
135
|
-
}
|
|
136
|
-
return variants.map((text) => themeFg(theme, 'muted', text));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function contextBar(
|
|
140
|
-
percent: number,
|
|
141
|
-
isAscii: boolean,
|
|
142
|
-
theme: ActiveThemeLike | undefined,
|
|
143
|
-
): { bar: string; pct: string } {
|
|
144
|
-
let token = 'success';
|
|
145
|
-
if (percent >= 90) token = 'error';
|
|
146
|
-
else if (percent >= 70) token = 'warning';
|
|
147
|
-
const filled = Math.min(10, Math.max(0, Math.round((percent / 100) * 10)));
|
|
148
|
-
const fillChar = isAscii ? '#' : '█';
|
|
149
|
-
const emptyChar = isAscii ? '-' : '░';
|
|
150
|
-
const dim = (text: string) => themeFg(theme, 'dim', text);
|
|
151
|
-
return {
|
|
152
|
-
bar: `${dim('[')}${themeFg(theme, token, fillChar.repeat(filled))}${dim(
|
|
153
|
-
emptyChar.repeat(10 - filled),
|
|
154
|
-
)}${dim(']')}`,
|
|
155
|
-
pct: themeFg(theme, token, `${Math.round(percent)}%`),
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Context usage, richest first: bar + percent + tokens, percent + tokens,
|
|
161
|
-
* tokens. Unavailable usage collapses to a dash.
|
|
162
|
-
*/
|
|
163
|
-
export function formatContextSegments(
|
|
164
|
-
data: StatusData,
|
|
165
|
-
{ mode = 'nerd', theme }: SegmentOptions = {},
|
|
166
|
-
): string[] {
|
|
167
|
-
const contextIcon = themeFg(theme, 'muted', icon('context', mode));
|
|
168
|
-
const windowText = data.contextWindow
|
|
169
|
-
? `/${formatTokens(data.contextWindow)}`
|
|
170
|
-
: '';
|
|
171
|
-
const hasTokens =
|
|
172
|
-
data.contextTokens !== null && data.contextTokens !== undefined;
|
|
173
|
-
const hasPercent =
|
|
174
|
-
data.contextPercent !== null && data.contextPercent !== undefined;
|
|
175
|
-
const tokens = themeFg(
|
|
176
|
-
theme,
|
|
177
|
-
'muted',
|
|
178
|
-
`${hasTokens ? formatTokens(data.contextTokens as number) : '—'}${windowText}`,
|
|
179
|
-
);
|
|
180
|
-
if (!hasPercent) {
|
|
181
|
-
const hasAny =
|
|
182
|
-
hasTokens ||
|
|
183
|
-
windowText !== '' ||
|
|
184
|
-
data.contextPercent !== undefined ||
|
|
185
|
-
data.contextTokens !== undefined;
|
|
186
|
-
return hasAny
|
|
187
|
-
? [`${contextIcon} ${tokens}`]
|
|
188
|
-
: [themeFg(theme, 'muted', '—')];
|
|
189
|
-
}
|
|
190
|
-
const { bar, pct } = contextBar(
|
|
191
|
-
data.contextPercent as number,
|
|
192
|
-
mode === 'ascii',
|
|
193
|
-
theme,
|
|
194
|
-
);
|
|
195
|
-
return [
|
|
196
|
-
`${contextIcon} ${bar} ${pct} ${tokens}`,
|
|
197
|
-
`${contextIcon} ${pct} ${tokens}`,
|
|
198
|
-
`${contextIcon} ${tokens}`,
|
|
199
|
-
];
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export interface FooterData extends StatusData {
|
|
203
|
-
tokenTotals?: SessionTokenTotals;
|
|
204
|
-
/** `null` means not yet measured; `undefined` hides the segment. */
|
|
205
|
-
tokensPerSecond?: number | null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function formatRate(rate: number | null): string {
|
|
209
|
-
if (rate === null || !Number.isFinite(rate)) return '—';
|
|
210
|
-
return rate < 10 ? rate.toFixed(1) : Math.round(rate).toString();
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* One-row footer below the editor: cost, total input/output, cache hit %, tok/s.
|
|
215
|
-
* Lowest priority drops first: tok/s, cache, tokens; cost truncates last.
|
|
216
|
-
*/
|
|
217
|
-
export function renderStatusLine(
|
|
218
|
-
data: FooterData,
|
|
219
|
-
options: RenderStatusLineOptions,
|
|
220
|
-
): string {
|
|
221
|
-
const { width, mode, theme } = options;
|
|
222
|
-
if (width <= 0) return '';
|
|
223
|
-
|
|
224
|
-
const sep = ` ${themeFg(theme, 'border', icon('separator', mode))} `;
|
|
225
|
-
const segments: string[] = [];
|
|
226
|
-
|
|
227
|
-
if (data.cost !== undefined) {
|
|
228
|
-
const total = data.cost + (data.subagentCost ?? 0);
|
|
229
|
-
segments.push(
|
|
230
|
-
themeFg(
|
|
231
|
-
theme,
|
|
232
|
-
'accent',
|
|
233
|
-
`${icon('cost', mode)}${mode === 'ascii' ? '' : ' '}${total.toFixed(3)}${data.isSubscription ? ' (sub)' : ''}`,
|
|
234
|
-
),
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
const totals = data.tokenTotals;
|
|
238
|
-
if (totals) {
|
|
239
|
-
const totalInput = totals.input + totals.cacheRead + totals.cacheWrite;
|
|
240
|
-
const cacheHitRatio =
|
|
241
|
-
totalInput === 0
|
|
242
|
-
? '—'
|
|
243
|
-
: `${Math.round((totals.cacheRead / totalInput) * 100)}%`;
|
|
244
|
-
const up = icon('tokensIn', mode);
|
|
245
|
-
const down = icon('tokensOut', mode);
|
|
246
|
-
segments.push(
|
|
247
|
-
themeFg(
|
|
248
|
-
theme,
|
|
249
|
-
'muted',
|
|
250
|
-
`${up}${formatTokens(totalInput)} ${down}${formatTokens(totals.output)}`,
|
|
251
|
-
),
|
|
252
|
-
themeFg(theme, 'muted', `${icon('cache', mode)} ${cacheHitRatio}`),
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
if (data.tokensPerSecond !== undefined) {
|
|
256
|
-
segments.push(
|
|
257
|
-
themeFg(
|
|
258
|
-
theme,
|
|
259
|
-
'muted',
|
|
260
|
-
mode === 'ascii'
|
|
261
|
-
? `${formatRate(data.tokensPerSecond)} ${icon('throughput', mode)}`
|
|
262
|
-
: `${icon('throughput', mode)} ${formatRate(data.tokensPerSecond)} tok/s`,
|
|
263
|
-
),
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
for (let count = segments.length; count >= 1; count--) {
|
|
268
|
-
const line = segments.slice(0, count).join(sep);
|
|
269
|
-
if (visibleWidth(line) <= width) return line;
|
|
270
|
-
}
|
|
271
|
-
return segments.length ? truncateToWidth(segments[0], width, '') : '';
|
|
272
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { StatusData } from './layout.ts';
|
|
2
|
-
import type { SessionTokenTotals } from './tokens.ts';
|
|
3
|
-
|
|
4
|
-
/** Shared nonvisual data for the footer and in-place editor decoration. */
|
|
5
|
-
export interface StatusSnapshot extends StatusData {
|
|
6
|
-
readonly tokenTotals: SessionTokenTotals;
|
|
7
|
-
/** Runtime-measured main-session output tokens per generation second. */
|
|
8
|
-
readonly tokensPerSecond: number | null;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export type StatusSnapshotProvider = () => Readonly<StatusSnapshot>;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
export interface GenerationEvent {
|
|
2
|
-
readonly type: 'message_start' | 'message_end';
|
|
3
|
-
readonly message: {
|
|
4
|
-
readonly role: string;
|
|
5
|
-
readonly id?: string;
|
|
6
|
-
readonly usage?: { readonly output: number };
|
|
7
|
-
};
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Runtime-only main-session assistant generation timing. */
|
|
11
|
-
export function createThroughputTracker() {
|
|
12
|
-
let pending: { at: number; id?: string } | undefined;
|
|
13
|
-
let outputTokens = 0;
|
|
14
|
-
let generationMs = 0;
|
|
15
|
-
const startedIds = new Set<string>();
|
|
16
|
-
const endedIds = new Set<string>();
|
|
17
|
-
let startedMessages = new WeakSet<object>();
|
|
18
|
-
let endedMessages = new WeakSet<object>();
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
observe(event: GenerationEvent, observedAt = Date.now()): void {
|
|
22
|
-
if (event.message.role !== 'assistant') return;
|
|
23
|
-
const id = event.message.id;
|
|
24
|
-
if (event.type === 'message_start') {
|
|
25
|
-
if (startedMessages.has(event.message) || (id && startedIds.has(id)))
|
|
26
|
-
return;
|
|
27
|
-
startedMessages.add(event.message);
|
|
28
|
-
if (id) startedIds.add(id);
|
|
29
|
-
pending = { at: observedAt, id };
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
if (endedMessages.has(event.message) || (id && endedIds.has(id))) return;
|
|
33
|
-
const start = pending;
|
|
34
|
-
if (!start || (start.id && id && start.id !== id)) return;
|
|
35
|
-
pending = undefined;
|
|
36
|
-
endedMessages.add(event.message);
|
|
37
|
-
if (id) endedIds.add(id);
|
|
38
|
-
const elapsed = observedAt - start.at;
|
|
39
|
-
if (!Number.isFinite(elapsed) || elapsed <= 0) return;
|
|
40
|
-
const output = event.message.usage?.output;
|
|
41
|
-
if (typeof output !== 'number' || !Number.isFinite(output) || output < 0)
|
|
42
|
-
return;
|
|
43
|
-
outputTokens += output;
|
|
44
|
-
generationMs += elapsed;
|
|
45
|
-
},
|
|
46
|
-
reset(): void {
|
|
47
|
-
pending = undefined;
|
|
48
|
-
outputTokens = 0;
|
|
49
|
-
generationMs = 0;
|
|
50
|
-
startedIds.clear();
|
|
51
|
-
endedIds.clear();
|
|
52
|
-
startedMessages = new WeakSet<object>();
|
|
53
|
-
endedMessages = new WeakSet<object>();
|
|
54
|
-
},
|
|
55
|
-
get tokensPerSecond(): number | null {
|
|
56
|
-
if (!Number.isFinite(generationMs) || generationMs <= 0) return null;
|
|
57
|
-
const speed = (outputTokens * 1000) / generationMs;
|
|
58
|
-
return Number.isFinite(speed) ? speed : null;
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type { SessionEntriesSource } from './cost.ts';
|
|
3
|
-
|
|
4
|
-
export interface SessionTokenTotals {
|
|
5
|
-
readonly input: number;
|
|
6
|
-
readonly output: number;
|
|
7
|
-
readonly cacheRead: number;
|
|
8
|
-
readonly cacheWrite: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Main-session totals across the same entry types and history scope as cost. */
|
|
12
|
-
export function calculateSessionTokens(
|
|
13
|
-
source: readonly SessionEntry[] | SessionEntriesSource | undefined | null,
|
|
14
|
-
): SessionTokenTotals {
|
|
15
|
-
const entries: readonly SessionEntry[] = Array.isArray(source)
|
|
16
|
-
? source
|
|
17
|
-
: source &&
|
|
18
|
-
typeof (source as SessionEntriesSource).getEntries === 'function'
|
|
19
|
-
? (source as SessionEntriesSource).getEntries()
|
|
20
|
-
: [];
|
|
21
|
-
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
22
|
-
for (const entry of entries) {
|
|
23
|
-
if (!entry) continue;
|
|
24
|
-
const usage =
|
|
25
|
-
entry.type === 'message'
|
|
26
|
-
? (entry.message?.role === 'assistant' ||
|
|
27
|
-
entry.message?.role === 'toolResult') &&
|
|
28
|
-
'usage' in entry.message
|
|
29
|
-
? entry.message.usage
|
|
30
|
-
: undefined
|
|
31
|
-
: (entry.type === 'branch_summary' ||
|
|
32
|
-
entry.type === 'compaction' ||
|
|
33
|
-
entry.type === 'usage') &&
|
|
34
|
-
'usage' in entry
|
|
35
|
-
? entry.usage
|
|
36
|
-
: undefined;
|
|
37
|
-
if (!usage) continue;
|
|
38
|
-
for (const field of [
|
|
39
|
-
'input',
|
|
40
|
-
'output',
|
|
41
|
-
'cacheRead',
|
|
42
|
-
'cacheWrite',
|
|
43
|
-
] as const) {
|
|
44
|
-
const value = usage[field];
|
|
45
|
-
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
46
|
-
totals[field] += value;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return totals;
|
|
51
|
-
}
|