@thoth-agents/pi-thoth-theme 0.0.0-stage → 0.1.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/LICENSE +29 -0
- package/README.md +115 -2
- package/package.json +63 -3
- package/src/index.ts +53 -0
- package/src/input-box/decorate.ts +304 -0
- package/src/input-box/frame.ts +182 -0
- package/src/input-box/gradient.ts +92 -0
- package/src/input-box/state.ts +90 -0
- package/src/render-kit/index.ts +305 -0
- package/src/render-kit/working.ts +25 -0
- package/src/shared/cache.ts +21 -0
- package/src/shared/config.ts +86 -0
- package/src/shared/duration.ts +1 -0
- package/src/shared/icons.ts +89 -0
- package/src/status-line/cost.ts +56 -0
- package/src/status-line/index.ts +242 -0
- package/src/status-line/layout.ts +272 -0
- package/src/status-line/snapshot.ts +11 -0
- package/src/status-line/throughput.ts +61 -0
- package/src/status-line/tokens.ts +51 -0
- package/src/tools/bash.ts +237 -0
- package/src/tools/border.ts +21 -0
- package/src/tools/box.ts +258 -0
- package/src/tools/edit.ts +228 -0
- package/src/tools/file-icons.ts +139 -0
- package/src/tools/find.ts +227 -0
- package/src/tools/frame.ts +166 -0
- package/src/tools/generic.ts +195 -0
- package/src/tools/grep.ts +371 -0
- package/src/tools/index.ts +135 -0
- package/src/tools/ls.ts +231 -0
- package/src/tools/ownership.ts +70 -0
- package/src/tools/powershell.ts +1 -0
- package/src/tools/read.ts +194 -0
- package/src/tools/ticker.ts +101 -0
- package/src/tools/write.ts +188 -0
- package/src/welcome/age.ts +11 -0
- package/src/welcome/index.ts +132 -0
- package/src/welcome/logo.ts +35 -0
- package/src/welcome/render.ts +323 -0
- package/src/welcome/resources.ts +251 -0
- package/themes/thoth.json +104 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
2
|
+
|
|
3
|
+
/** Cache lines per instance and width; invalidate when render inputs change. */
|
|
4
|
+
export function cachedComponent(
|
|
5
|
+
renderFn: (width: number) => string[],
|
|
6
|
+
): Component {
|
|
7
|
+
const cache = new Map<number, string[]>();
|
|
8
|
+
return {
|
|
9
|
+
render(width: number): string[] {
|
|
10
|
+
let lines = cache.get(width);
|
|
11
|
+
if (lines === undefined) {
|
|
12
|
+
lines = renderFn(width);
|
|
13
|
+
cache.set(width, lines);
|
|
14
|
+
}
|
|
15
|
+
return lines;
|
|
16
|
+
},
|
|
17
|
+
invalidate(): void {
|
|
18
|
+
cache.clear();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type IconMode = 'nerd' | 'ascii';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_RESPECT_PACKAGES: readonly string[] = [
|
|
8
|
+
'thoth-agents',
|
|
9
|
+
'@thoth-agents/*',
|
|
10
|
+
'thoth-mem',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export interface ThemeConfig {
|
|
14
|
+
icons: IconMode;
|
|
15
|
+
statusLine: {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
subscriptionProviders?: string[];
|
|
18
|
+
};
|
|
19
|
+
/** Omission enables the input box, preserving the legacy config shape. */
|
|
20
|
+
inputBox?: { enabled: boolean };
|
|
21
|
+
tools: { enabled: boolean; respectPackages?: string[] };
|
|
22
|
+
welcome: { enabled: boolean };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
? (value as Record<string, unknown>)
|
|
28
|
+
: {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function moduleEnabled(value: unknown): boolean {
|
|
32
|
+
const enabled = asObject(value).enabled;
|
|
33
|
+
return typeof enabled === 'boolean' ? enabled : true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseSubscriptionProviders(value: unknown): string[] {
|
|
37
|
+
const obj = asObject(value);
|
|
38
|
+
const providers = obj.subscriptionProviders;
|
|
39
|
+
if (Array.isArray(providers)) {
|
|
40
|
+
return providers.filter(
|
|
41
|
+
(item): item is string =>
|
|
42
|
+
typeof item === 'string' && item.trim().length > 0,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return ['claude-bridge'];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRespectPackages(value: unknown): string[] {
|
|
49
|
+
const packages = asObject(value).respectPackages;
|
|
50
|
+
return Array.isArray(packages) &&
|
|
51
|
+
packages.every(
|
|
52
|
+
(item): item is string =>
|
|
53
|
+
typeof item === 'string' && item.trim().length > 0,
|
|
54
|
+
)
|
|
55
|
+
? packages
|
|
56
|
+
: [...DEFAULT_RESPECT_PACKAGES];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadConfig(
|
|
60
|
+
configPath = join(
|
|
61
|
+
process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent'),
|
|
62
|
+
'pi-thoth-theme.json',
|
|
63
|
+
),
|
|
64
|
+
): ThemeConfig {
|
|
65
|
+
let config: Record<string, unknown> = {};
|
|
66
|
+
try {
|
|
67
|
+
config = asObject(JSON.parse(readFileSync(configPath, 'utf8')));
|
|
68
|
+
} catch {
|
|
69
|
+
// Missing or malformed package configuration uses package defaults.
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
icons: config.icons === 'ascii' ? 'ascii' : 'nerd',
|
|
73
|
+
...(config.inputBox === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { inputBox: { enabled: moduleEnabled(config.inputBox) } }),
|
|
76
|
+
statusLine: {
|
|
77
|
+
enabled: moduleEnabled(config.statusLine),
|
|
78
|
+
subscriptionProviders: parseSubscriptionProviders(config.statusLine),
|
|
79
|
+
},
|
|
80
|
+
tools: {
|
|
81
|
+
enabled: moduleEnabled(config.tools),
|
|
82
|
+
respectPackages: parseRespectPackages(config.tools),
|
|
83
|
+
},
|
|
84
|
+
welcome: { enabled: moduleEnabled(config.welcome) },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { formatDuration } from '@thoth-agents/pi-core';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RenderStatus,
|
|
3
|
+
SemanticFrameName,
|
|
4
|
+
SemanticGlyphName,
|
|
5
|
+
} from '@thoth-agents/pi-core';
|
|
6
|
+
import type { IconMode } from './config.ts';
|
|
7
|
+
|
|
8
|
+
type Variants = Record<IconMode, string>;
|
|
9
|
+
|
|
10
|
+
/** Theme-local tool icon that is not a pi-core semantic name. */
|
|
11
|
+
export type ThemeIconName = SemanticGlyphName | 'powershell';
|
|
12
|
+
export type StatusIconName = RenderStatus | 'warning';
|
|
13
|
+
|
|
14
|
+
/** The single source of rendered semantic glyphs for the theme. */
|
|
15
|
+
const ICONS: Record<ThemeIconName, Variants> = {
|
|
16
|
+
branch: { nerd: '\u{e0a0}', ascii: 'git' },
|
|
17
|
+
folder: { nerd: '\u{f07c}', ascii: 'dir' },
|
|
18
|
+
model: { nerd: '\u{f06a9}', ascii: '*' },
|
|
19
|
+
effort: { nerd: '\u{f09d1}', ascii: 'o' },
|
|
20
|
+
context: { nerd: '\u{f2db}', ascii: 'ctx' },
|
|
21
|
+
cost: { nerd: '\u{f155}', ascii: '$' },
|
|
22
|
+
tokensIn: { nerd: '\u{f062}', ascii: '^' },
|
|
23
|
+
tokensOut: { nerd: '\u{f063}', ascii: 'v' },
|
|
24
|
+
cache: { nerd: '\u{f01bc}', ascii: 'cache' },
|
|
25
|
+
throughput: { nerd: '\u{f04c5}', ascii: 'tok/s' },
|
|
26
|
+
agent: { nerd: '\u{f08c7}', ascii: '@' },
|
|
27
|
+
file: { nerd: '\u{f0214}', ascii: '[file]' },
|
|
28
|
+
tool: { nerd: '\u{f0ad}', ascii: '*' },
|
|
29
|
+
bash: { nerd: '\u{f489}', ascii: '$' },
|
|
30
|
+
powershell: { nerd: '\u{e70f}', ascii: 'PS' },
|
|
31
|
+
read: { nerd: '\u{f06e}', ascii: '[read]' },
|
|
32
|
+
write: { nerd: '\u{f0c7}', ascii: '[write]' },
|
|
33
|
+
edit: { nerd: '\u{f044}', ascii: '[edit]' },
|
|
34
|
+
search: { nerd: '\u{f002}', ascii: '?' },
|
|
35
|
+
separator: { nerd: '·', ascii: '|' },
|
|
36
|
+
ellipsis: { nerd: '…', ascii: '...' },
|
|
37
|
+
arrowUp: { nerd: '↑', ascii: '^' },
|
|
38
|
+
arrowDown: { nerd: '↓', ascii: 'v' },
|
|
39
|
+
arrowLeft: { nerd: '←', ascii: '<' },
|
|
40
|
+
arrowRight: { nerd: '→', ascii: '>' },
|
|
41
|
+
selection: { nerd: '›', ascii: '>' },
|
|
42
|
+
scrollUp: { nerd: '↑', ascii: '^' },
|
|
43
|
+
scrollDown: { nerd: '↓', ascii: 'v' },
|
|
44
|
+
ready: { nerd: '▲', ascii: '^' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const FRAMES: Record<SemanticFrameName, Record<IconMode, readonly string[]>> = {
|
|
48
|
+
spinnerFrames: {
|
|
49
|
+
nerd: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
50
|
+
ascii: ['|', '/', '-', '\\'],
|
|
51
|
+
},
|
|
52
|
+
workingFrames: {
|
|
53
|
+
nerd: ['△', '◭', '▲', '◮'],
|
|
54
|
+
ascii: ['.', 'o', 'O', '0'],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const BAN: Variants = { nerd: '\uf05e', ascii: '/' };
|
|
59
|
+
|
|
60
|
+
const STATUS_ICONS: Record<StatusIconName, Variants> = {
|
|
61
|
+
pending: { nerd: '\u{f10c}', ascii: '-' },
|
|
62
|
+
queued: { nerd: '\u{f051f}', ascii: '~' },
|
|
63
|
+
in_progress: { nerd: '◐', ascii: '*' },
|
|
64
|
+
running: { nerd: '◐', ascii: '*' },
|
|
65
|
+
completed: { nerd: '\u{f00c}', ascii: '+' },
|
|
66
|
+
failed: { nerd: '\u{f00d}', ascii: 'x' },
|
|
67
|
+
cancelled: BAN,
|
|
68
|
+
interrupted: BAN,
|
|
69
|
+
stopping: BAN,
|
|
70
|
+
deleted: BAN,
|
|
71
|
+
blocked: BAN,
|
|
72
|
+
warning: { nerd: '\u{f071}', ascii: '!' },
|
|
73
|
+
unknown: { nerd: '\u{f128}', ascii: '?' },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function icon(name: ThemeIconName, mode: IconMode): string {
|
|
77
|
+
return ICONS[name][mode];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function frames(
|
|
81
|
+
name: SemanticFrameName,
|
|
82
|
+
mode: IconMode,
|
|
83
|
+
): readonly string[] {
|
|
84
|
+
return FRAMES[name][mode];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function statusIcon(status: StatusIconName, mode: IconMode): string {
|
|
88
|
+
return STATUS_ICONS[status][mode];
|
|
89
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { IconMode } from '../shared/config.ts';
|
|
3
|
+
import { icon } from '../shared/icons.ts';
|
|
4
|
+
|
|
5
|
+
export interface SessionEntriesSource {
|
|
6
|
+
getEntries(): readonly SessionEntry[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Aggregates cumulative cost across all session entries.
|
|
11
|
+
* Evaluates assistant messages, tool results, compactions, branch summaries,
|
|
12
|
+
* and usage entries.
|
|
13
|
+
*/
|
|
14
|
+
export function calculateSessionCost(
|
|
15
|
+
source: readonly SessionEntry[] | SessionEntriesSource | undefined | null,
|
|
16
|
+
): number {
|
|
17
|
+
if (!source) return 0;
|
|
18
|
+
const entries: readonly SessionEntry[] = Array.isArray(source)
|
|
19
|
+
? source
|
|
20
|
+
: typeof (source as SessionEntriesSource).getEntries === 'function'
|
|
21
|
+
? (source as SessionEntriesSource).getEntries()
|
|
22
|
+
: [];
|
|
23
|
+
|
|
24
|
+
let totalCost = 0;
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (!entry) continue;
|
|
27
|
+
if (entry.type === 'message') {
|
|
28
|
+
const msg = entry.message;
|
|
29
|
+
if (
|
|
30
|
+
(msg?.role === 'assistant' || msg?.role === 'toolResult') &&
|
|
31
|
+
'usage' in msg &&
|
|
32
|
+
msg.usage?.cost?.total
|
|
33
|
+
) {
|
|
34
|
+
totalCost += msg.usage.cost.total;
|
|
35
|
+
}
|
|
36
|
+
} else if (
|
|
37
|
+
(entry.type === 'branch_summary' ||
|
|
38
|
+
entry.type === 'compaction' ||
|
|
39
|
+
entry.type === 'usage') &&
|
|
40
|
+
'usage' in entry &&
|
|
41
|
+
entry.usage?.cost?.total
|
|
42
|
+
) {
|
|
43
|
+
totalCost += entry.usage.cost.total;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return totalCost;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Formats a cost number (e.g. 1.234) with 3 decimal places and icon prefix
|
|
52
|
+
* ('$' in ascii mode, nerd dollar icon in nerd mode).
|
|
53
|
+
*/
|
|
54
|
+
export function formatCost(cost: number, mode: IconMode): string {
|
|
55
|
+
return `${icon('cost', mode)}${cost.toFixed(3)}`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
}
|