@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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 +21 -0
- package/README.md +239 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +103 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +17 -0
- package/licenses/pi-fancy-footer-LICENSE +21 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
- package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
- package/packages/pi-fancy-footer-full-palette/README.md +468 -0
- package/packages/pi-fancy-footer-full-palette/package.json +52 -0
- package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
- package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
- package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
- package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
- package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
- package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
- package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
- package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
- package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
- package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
- package/packages/pi-stardeck/README.md +219 -0
- package/packages/pi-stardeck/index.ts +691 -0
- package/packages/pi-stardeck/package.json +32 -0
- package/packages/pi-stardeck/src/activity-age.ts +184 -0
- package/packages/pi-stardeck/src/capabilities.ts +105 -0
- package/packages/pi-stardeck/src/deck.ts +807 -0
- package/packages/pi-stardeck/src/gauge.ts +1323 -0
- package/packages/pi-stardeck/src/git-refresh.ts +5 -0
- package/packages/pi-stardeck/src/process-resources.ts +428 -0
- package/packages/pi-stardeck/src/title-state.ts +195 -0
- package/packages/pi-stardeck/src/title.ts +443 -0
- package/packages/pi-vim-top-border/LICENSE +21 -0
- package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
- package/packages/pi-vim-top-border/README.md +577 -0
- package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
- package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
- package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
- package/packages/pi-vim-top-border/index.ts +4195 -0
- package/packages/pi-vim-top-border/input-keys.ts +49 -0
- package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
- package/packages/pi-vim-top-border/mode-colors.ts +97 -0
- package/packages/pi-vim-top-border/mode-label.ts +62 -0
- package/packages/pi-vim-top-border/motions.ts +297 -0
- package/packages/pi-vim-top-border/package.json +47 -0
- package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
- package/packages/pi-vim-top-border/prompt-status.ts +131 -0
- package/packages/pi-vim-top-border/settings.ts +287 -0
- package/packages/pi-vim-top-border/text-objects.ts +389 -0
- package/packages/pi-vim-top-border/types.ts +51 -0
- package/packages/pi-vim-top-border/visual.ts +65 -0
- package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
- package/packages/status/README.md +398 -0
- package/packages/status/index.ts +1717 -0
- package/packages/status/package.json +33 -0
- package/packages/status/src/active-work.ts +235 -0
- package/packages/status/src/config.ts +218 -0
- package/packages/status/src/diagnostics.ts +238 -0
- package/packages/status/src/goal-block.ts +377 -0
- package/packages/status/src/goal.ts +170 -0
- package/packages/status/src/ledger.ts +101 -0
- package/packages/status/src/openspec.ts +394 -0
- package/packages/status/src/orchestration.ts +237 -0
- package/packages/status/src/publisher.ts +1294 -0
- package/packages/status/src/reducer.ts +190 -0
- package/packages/status/src/runtime-runs.ts +267 -0
- package/packages/status/src/state.ts +178 -0
- package/packages/status/src/title-activity.ts +224 -0
- package/packages/status/src/title-state.ts +218 -0
- package/packages/status/src/turn-cost.ts +63 -0
- package/packages/status/src/types.ts +228 -0
- package/src/deck-surface.ts +46 -0
- package/src/health.ts +228 -0
- package/src/index.ts +28 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
CheckState,
|
|
5
|
+
CommandRunner,
|
|
6
|
+
DiagnosticsState,
|
|
7
|
+
GalacticaStatusConfig,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
|
|
10
|
+
const DIAGNOSTIC_CANDIDATES = [
|
|
11
|
+
'.pi/status/diagnostics.json',
|
|
12
|
+
'.pi/diagnostics.json',
|
|
13
|
+
'.galactica/diagnostics.json',
|
|
14
|
+
'.taskflow/diagnostics.json',
|
|
15
|
+
'status/diagnostics.json',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function count(value: unknown): number | undefined {
|
|
23
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
24
|
+
? Math.max(0, Math.trunc(value))
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstCount(...values: unknown[]): number | undefined {
|
|
29
|
+
for (const value of values) {
|
|
30
|
+
const parsed = count(value);
|
|
31
|
+
if (parsed !== undefined) return parsed;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
37
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
38
|
+
if (typeof value !== 'string') return undefined;
|
|
39
|
+
const parsed = Date.parse(value);
|
|
40
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeCheck(value: unknown): CheckState {
|
|
44
|
+
if (isRecord(value))
|
|
45
|
+
return normalizeCheck(value.status ?? value.state ?? value.result);
|
|
46
|
+
if (typeof value === 'boolean') return value ? 'pass' : 'fail';
|
|
47
|
+
if (typeof value !== 'string') return 'unknown';
|
|
48
|
+
switch (value.trim().toLowerCase()) {
|
|
49
|
+
case 'pass':
|
|
50
|
+
case 'passed':
|
|
51
|
+
case 'passing':
|
|
52
|
+
case 'success':
|
|
53
|
+
case 'successful':
|
|
54
|
+
case 'clean':
|
|
55
|
+
case 'ok':
|
|
56
|
+
return 'pass';
|
|
57
|
+
case 'fail':
|
|
58
|
+
case 'failed':
|
|
59
|
+
case 'failing':
|
|
60
|
+
case 'failure':
|
|
61
|
+
case 'error':
|
|
62
|
+
return 'fail';
|
|
63
|
+
case 'running':
|
|
64
|
+
case 'pending':
|
|
65
|
+
case 'queued':
|
|
66
|
+
case 'in-progress':
|
|
67
|
+
return 'running';
|
|
68
|
+
default:
|
|
69
|
+
return 'unknown';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function normalizeDiagnostics(
|
|
74
|
+
value: unknown,
|
|
75
|
+
options: {
|
|
76
|
+
source: string;
|
|
77
|
+
now?: number;
|
|
78
|
+
fallbackUpdatedAt?: number;
|
|
79
|
+
staleAfterMs?: number;
|
|
80
|
+
},
|
|
81
|
+
): DiagnosticsState {
|
|
82
|
+
if (!isRecord(value)) throw new Error('diagnostics state is not an object');
|
|
83
|
+
const now = options.now ?? Date.now();
|
|
84
|
+
const summary = isRecord(value.summary) ? value.summary : {};
|
|
85
|
+
const checks = isRecord(value.checks) ? value.checks : {};
|
|
86
|
+
|
|
87
|
+
let errors = firstCount(value.errors, summary.errors) ?? 0;
|
|
88
|
+
let blockers = firstCount(value.blockers, summary.blockers) ?? 0;
|
|
89
|
+
let warnings = firstCount(value.warnings, summary.warnings) ?? 0;
|
|
90
|
+
let recognized =
|
|
91
|
+
value.errors !== undefined ||
|
|
92
|
+
value.blockers !== undefined ||
|
|
93
|
+
value.warnings !== undefined ||
|
|
94
|
+
summary.errors !== undefined ||
|
|
95
|
+
summary.blockers !== undefined ||
|
|
96
|
+
summary.warnings !== undefined;
|
|
97
|
+
|
|
98
|
+
const findings = Array.isArray(value.diagnostics)
|
|
99
|
+
? value.diagnostics
|
|
100
|
+
: Array.isArray(value.findings)
|
|
101
|
+
? value.findings
|
|
102
|
+
: [];
|
|
103
|
+
if (findings.length > 0) recognized = true;
|
|
104
|
+
for (const finding of findings) {
|
|
105
|
+
if (!isRecord(finding)) continue;
|
|
106
|
+
const severity = String(finding.severity ?? finding.level ?? '').toLowerCase();
|
|
107
|
+
const semantic = String(finding.semantic ?? finding.status ?? '').toLowerCase();
|
|
108
|
+
if (semantic === 'blocking' || semantic === 'blocker') blockers += 1;
|
|
109
|
+
else if (severity === 'error' || severity === 'fatal') errors += 1;
|
|
110
|
+
else if (severity === 'warning' || severity === 'warn') warnings += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tests = normalizeCheck(
|
|
114
|
+
value.tests ?? value.test ?? checks.tests ?? checks.test,
|
|
115
|
+
);
|
|
116
|
+
const typecheck = normalizeCheck(
|
|
117
|
+
value.typecheck ??
|
|
118
|
+
value.typeCheck ??
|
|
119
|
+
value.types ??
|
|
120
|
+
checks.typecheck ??
|
|
121
|
+
checks.types,
|
|
122
|
+
);
|
|
123
|
+
if (tests !== 'unknown' || typecheck !== 'unknown') recognized = true;
|
|
124
|
+
if (!recognized)
|
|
125
|
+
throw new Error('diagnostics state has no recognized aggregate fields');
|
|
126
|
+
|
|
127
|
+
const updatedAt =
|
|
128
|
+
parseTimestamp(value.updatedAt ?? value.timestamp ?? value.generatedAt) ??
|
|
129
|
+
options.fallbackUpdatedAt ??
|
|
130
|
+
now;
|
|
131
|
+
const staleAfterMs = options.staleAfterMs ?? 600_000;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
errors,
|
|
135
|
+
blockers,
|
|
136
|
+
warnings,
|
|
137
|
+
tests,
|
|
138
|
+
typecheck,
|
|
139
|
+
meta: {
|
|
140
|
+
source: options.source,
|
|
141
|
+
refreshedAt: now,
|
|
142
|
+
attemptedAt: now,
|
|
143
|
+
updatedAt,
|
|
144
|
+
staleAfterMs,
|
|
145
|
+
stale: now - updatedAt > staleAfterMs,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function existingFile(paths: readonly string[]): Promise<string | undefined> {
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
try {
|
|
153
|
+
if ((await fs.stat(path)).isFile()) return path;
|
|
154
|
+
} catch {
|
|
155
|
+
// Optional sources disappear normally between workflow runs.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function detectDiagnosticsPath(
|
|
162
|
+
projectRoot: string,
|
|
163
|
+
configuredPath?: string | null,
|
|
164
|
+
): Promise<string | undefined> {
|
|
165
|
+
if (configuredPath) {
|
|
166
|
+
const path = isAbsolute(configuredPath)
|
|
167
|
+
? configuredPath
|
|
168
|
+
: resolve(projectRoot, configuredPath);
|
|
169
|
+
return (await existingFile([path])) ?? path;
|
|
170
|
+
}
|
|
171
|
+
return existingFile(DIAGNOSTIC_CANDIDATES.map((path) => resolve(projectRoot, path)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function collectDiagnostics(options: {
|
|
175
|
+
projectRoot: string;
|
|
176
|
+
config: GalacticaStatusConfig['diagnostics'];
|
|
177
|
+
runner: CommandRunner;
|
|
178
|
+
runCommand: boolean;
|
|
179
|
+
now?: number;
|
|
180
|
+
}): Promise<{ state?: DiagnosticsState; sourcePath?: string }> {
|
|
181
|
+
const now = options.now ?? Date.now();
|
|
182
|
+
const staleAfterMs = Math.max(options.config.intervalSeconds * 2_000, 300_000);
|
|
183
|
+
|
|
184
|
+
if (options.runCommand && options.config.command) {
|
|
185
|
+
const command = options.config.command;
|
|
186
|
+
const result = await options.runner(command.executable, command.args, {
|
|
187
|
+
cwd: options.projectRoot,
|
|
188
|
+
timeoutMs: options.config.timeoutSeconds * 1_000,
|
|
189
|
+
});
|
|
190
|
+
if (result.code !== 0 || result.killed) {
|
|
191
|
+
const detail = result.stderr.trim();
|
|
192
|
+
throw new Error(
|
|
193
|
+
`diagnostics command failed${result.killed ? ' (timeout)' : ''}${
|
|
194
|
+
detail ? `: ${detail}` : ''
|
|
195
|
+
}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
let parsed: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(result.stdout) as unknown;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
throw new Error(`diagnostics command returned malformed JSON: ${message}`);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
state: normalizeDiagnostics(parsed, {
|
|
207
|
+
source: `command:${command.executable}`,
|
|
208
|
+
now,
|
|
209
|
+
staleAfterMs,
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sourcePath = await detectDiagnosticsPath(
|
|
215
|
+
options.projectRoot,
|
|
216
|
+
options.config.sourceFile,
|
|
217
|
+
);
|
|
218
|
+
if (!sourcePath) return {};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const stat = await fs.stat(sourcePath);
|
|
222
|
+
const parsed = JSON.parse(await fs.readFile(sourcePath, 'utf8')) as unknown;
|
|
223
|
+
return {
|
|
224
|
+
sourcePath,
|
|
225
|
+
state: normalizeDiagnostics(parsed, {
|
|
226
|
+
source: sourcePath,
|
|
227
|
+
now,
|
|
228
|
+
fallbackUpdatedAt: stat.mtimeMs,
|
|
229
|
+
staleAfterMs,
|
|
230
|
+
}),
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
234
|
+
if (code === 'ENOENT' && !options.config.sourceFile) return {};
|
|
235
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
236
|
+
throw new Error(`failed to read diagnostics state: ${message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
7
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import type { GoalHeaderState } from './goal.ts';
|
|
11
|
+
import { GOAL_HEADER_STATUSES } from './goal.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Goal-block takeover for pi-goal-x: replaces its persistent aboveEditor
|
|
15
|
+
* widget ("goal" key) with a compact deck-style block rendered from the
|
|
16
|
+
* goal entries pi-goal-x already appends to the session branch.
|
|
17
|
+
*
|
|
18
|
+
* pi-goal-x registers its widget once per session (one-shot flag), so the
|
|
19
|
+
* first same-key registration after theirs owns the slot for the session.
|
|
20
|
+
* We re-assert briefly after session start and on turn starts until the
|
|
21
|
+
* takeover sticks. pi-goal-x modals, tools, and storage are untouched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const GOAL_WIDGET_KEY = 'goal';
|
|
25
|
+
const GOAL_X_STATE_ENTRY = 'pi-goal-state';
|
|
26
|
+
const GOAL_X_FOCUS_ENTRY = 'pi-goal-focus';
|
|
27
|
+
const REASSERT_DELAYS_MS = [800, 2_000, 5_000, 10_000];
|
|
28
|
+
|
|
29
|
+
export interface GoalBlockTaskCounts {
|
|
30
|
+
pending: number;
|
|
31
|
+
active: number;
|
|
32
|
+
complete: number;
|
|
33
|
+
total: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GoalBlockState {
|
|
37
|
+
id: string;
|
|
38
|
+
objective: string;
|
|
39
|
+
status: string;
|
|
40
|
+
tokensUsed?: number;
|
|
41
|
+
activeSeconds?: number;
|
|
42
|
+
tokenBudget?: number;
|
|
43
|
+
autoContinue?: boolean;
|
|
44
|
+
tasks?: GoalBlockTaskCounts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function countTasks(node: unknown, counts: GoalBlockTaskCounts): void {
|
|
52
|
+
if (!isRecord(node)) return;
|
|
53
|
+
const status = node.status;
|
|
54
|
+
if (status === 'pending' || status === 'active' || status === 'complete') {
|
|
55
|
+
counts.total += 1;
|
|
56
|
+
counts[status] += 1;
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(node.subtasks)) {
|
|
59
|
+
for (const child of node.subtasks) countTasks(child, counts);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseTaskCounts(taskList: unknown): GoalBlockTaskCounts | undefined {
|
|
64
|
+
if (!isRecord(taskList) || !Array.isArray(taskList.tasks)) return undefined;
|
|
65
|
+
const counts: GoalBlockTaskCounts = { pending: 0, active: 0, complete: 0, total: 0 };
|
|
66
|
+
for (const task of taskList.tasks) countTasks(task, counts);
|
|
67
|
+
return counts.total > 0 ? counts : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseNumber(value: unknown): number | undefined {
|
|
71
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
72
|
+
? value
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseGoalRecord(value: unknown): GoalBlockState | null {
|
|
77
|
+
if (!isRecord(value)) return null;
|
|
78
|
+
if (typeof value.id !== 'string' || value.id.trim() === '') return null;
|
|
79
|
+
if (typeof value.objective !== 'string' || value.objective.trim() === '') return null;
|
|
80
|
+
const usage = isRecord(value.usage) ? value.usage : {};
|
|
81
|
+
const budget = parseNumber(usage.tokenBudget) ?? parseNumber(value.tokenBudget);
|
|
82
|
+
return {
|
|
83
|
+
id: value.id.slice(0, 128),
|
|
84
|
+
objective: value.objective.replace(/\s+/gu, ' ').trim().slice(0, 160),
|
|
85
|
+
status: typeof value.status === 'string' ? value.status.slice(0, 24) : 'active',
|
|
86
|
+
tokensUsed: parseNumber(usage.tokensUsed),
|
|
87
|
+
activeSeconds: parseNumber(usage.activeSeconds),
|
|
88
|
+
...(budget !== undefined && budget > 0 ? { tokenBudget: budget } : {}),
|
|
89
|
+
...(value.autoContinue === true ? { autoContinue: true } : {}),
|
|
90
|
+
...(parseTaskCounts(value.taskList)
|
|
91
|
+
? { tasks: parseTaskCounts(value.taskList) }
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readGoalBlockRecord(entries: readonly unknown[]): {
|
|
97
|
+
focusedGoalId: string | null | undefined;
|
|
98
|
+
record: unknown;
|
|
99
|
+
recordSeen: boolean;
|
|
100
|
+
} {
|
|
101
|
+
let focusedGoalId: string | null | undefined;
|
|
102
|
+
let record: unknown;
|
|
103
|
+
let recordSeen = false;
|
|
104
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
105
|
+
const entry = entries[index];
|
|
106
|
+
if (!isRecord(entry) || entry.type !== 'custom') continue;
|
|
107
|
+
if (
|
|
108
|
+
focusedGoalId === undefined &&
|
|
109
|
+
entry.customType === GOAL_X_FOCUS_ENTRY &&
|
|
110
|
+
isRecord(entry.data)
|
|
111
|
+
) {
|
|
112
|
+
focusedGoalId =
|
|
113
|
+
typeof entry.data.focusedGoalId === 'string' ? entry.data.focusedGoalId : null;
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
!recordSeen &&
|
|
117
|
+
entry.customType === GOAL_X_STATE_ENTRY &&
|
|
118
|
+
isRecord(entry.data)
|
|
119
|
+
) {
|
|
120
|
+
record = entry.data.goal;
|
|
121
|
+
recordSeen = true;
|
|
122
|
+
}
|
|
123
|
+
if (focusedGoalId !== undefined && recordSeen) break;
|
|
124
|
+
}
|
|
125
|
+
return { focusedGoalId, record, recordSeen };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isDirectorySafe(path: string): boolean {
|
|
129
|
+
try {
|
|
130
|
+
return statSync(path).isDirectory();
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Mirror pi-goal-x storage resolution: env root, project root, then the
|
|
137
|
+
* agent-level fallback. Broken symlinks are skipped, never followed. */
|
|
138
|
+
export function resolveGoalRoot(ctx: ExtensionContext): string | undefined {
|
|
139
|
+
const candidates: string[] = [];
|
|
140
|
+
if (process.env.PI_GOAL_ROOT) candidates.push(resolve(process.env.PI_GOAL_ROOT));
|
|
141
|
+
if (typeof ctx.cwd === 'string') {
|
|
142
|
+
candidates.push(resolve(ctx.cwd, '.pi/goals'));
|
|
143
|
+
}
|
|
144
|
+
candidates.push(join(homedir(), '.pi', 'goals'));
|
|
145
|
+
return candidates.find((candidate) => isDirectorySafe(candidate));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const poolCache = new Map<string, { mtimeMs: number; goals: unknown[] }>();
|
|
149
|
+
|
|
150
|
+
function snapshotPaths(root: string): string[] {
|
|
151
|
+
return [
|
|
152
|
+
join(root, '.metadata', '.goals-pool-snapshot.json'),
|
|
153
|
+
join(dirname(root), '.goals-pool-snapshot.json'),
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loadPoolGoals(root: string): unknown[] {
|
|
158
|
+
for (const snapshotPath of snapshotPaths(root)) {
|
|
159
|
+
try {
|
|
160
|
+
const stats = statSync(snapshotPath);
|
|
161
|
+
if (!stats.isFile()) continue;
|
|
162
|
+
const cached = poolCache.get(snapshotPath);
|
|
163
|
+
if (cached && cached.mtimeMs === stats.mtimeMs) return cached.goals;
|
|
164
|
+
const parsed: unknown = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
165
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.goals)) continue;
|
|
166
|
+
const goals = parsed.goals.filter(isRecord);
|
|
167
|
+
poolCache.set(snapshotPath, { mtimeMs: stats.mtimeMs, goals });
|
|
168
|
+
return goals;
|
|
169
|
+
} catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readGoalBlockState(ctx: ExtensionContext): GoalBlockState | null {
|
|
177
|
+
const { focusedGoalId, record, recordSeen } = readGoalBlockRecord(
|
|
178
|
+
ctx.sessionManager.getBranch(),
|
|
179
|
+
);
|
|
180
|
+
if (focusedGoalId === null) return null;
|
|
181
|
+
let candidate: unknown = recordSeen ? record : undefined;
|
|
182
|
+
const root = resolveGoalRoot(ctx);
|
|
183
|
+
if (root) {
|
|
184
|
+
const goals = loadPoolGoals(root);
|
|
185
|
+
const fromPool = goals.find((goal) => isRecord(goal) && goal.id === focusedGoalId);
|
|
186
|
+
if (fromPool !== undefined) candidate = fromPool;
|
|
187
|
+
}
|
|
188
|
+
const state = parseGoalRecord(candidate);
|
|
189
|
+
if (!state) return null;
|
|
190
|
+
if (
|
|
191
|
+
focusedGoalId !== undefined &&
|
|
192
|
+
focusedGoalId !== null &&
|
|
193
|
+
focusedGoalId !== state.id
|
|
194
|
+
) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return state;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function readGoalHeadline(ctx: ExtensionContext): GoalHeaderState | null {
|
|
201
|
+
const state = readGoalBlockState(ctx);
|
|
202
|
+
if (!state) return null;
|
|
203
|
+
return {
|
|
204
|
+
id: state.id,
|
|
205
|
+
status: (GOAL_HEADER_STATUSES as readonly string[]).includes(state.status)
|
|
206
|
+
? (state.status as GoalHeaderState['status'])
|
|
207
|
+
: 'active',
|
|
208
|
+
automaticModelTurns: 0,
|
|
209
|
+
waiting: state.status === 'active',
|
|
210
|
+
...(state.objective !== undefined ? { objective: state.objective } : {}),
|
|
211
|
+
...(state.activeSeconds !== undefined
|
|
212
|
+
? { elapsedSeconds: state.activeSeconds }
|
|
213
|
+
: {}),
|
|
214
|
+
...(state.tasks
|
|
215
|
+
? {
|
|
216
|
+
tasksDone: state.tasks.complete,
|
|
217
|
+
tasksActive: state.tasks.active,
|
|
218
|
+
tasksTotal: state.tasks.total,
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
221
|
+
...(state.tokensUsed !== undefined ? { tokensUsed: state.tokensUsed } : {}),
|
|
222
|
+
...(state.tokenBudget !== undefined ? { tokenBudget: state.tokenBudget } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function countOpenGoals(ctx: ExtensionContext): number {
|
|
227
|
+
const root = resolveGoalRoot(ctx);
|
|
228
|
+
if (!root) return 0;
|
|
229
|
+
return loadPoolGoals(root).filter(
|
|
230
|
+
(goal) => isRecord(goal) && goal.status !== 'complete',
|
|
231
|
+
).length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function formatGoalElapsed(seconds: number): string {
|
|
235
|
+
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
236
|
+
const totalMinutes = Math.floor(seconds / 60);
|
|
237
|
+
if (totalMinutes < 60) {
|
|
238
|
+
return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
|
|
239
|
+
}
|
|
240
|
+
return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatTokens(tokens: number): string {
|
|
244
|
+
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
245
|
+
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
246
|
+
return `${Math.floor(tokens)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const STATUS_COLORS: Record<
|
|
250
|
+
string,
|
|
251
|
+
'success' | 'accent' | 'warning' | 'error' | 'dim'
|
|
252
|
+
> = {
|
|
253
|
+
active: 'accent',
|
|
254
|
+
complete: 'success',
|
|
255
|
+
paused: 'warning',
|
|
256
|
+
blocked: 'error',
|
|
257
|
+
usage_limited: 'warning',
|
|
258
|
+
budget_limited: 'warning',
|
|
259
|
+
queued: 'dim',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function renderGoalBlock(
|
|
263
|
+
state: GoalBlockState | null,
|
|
264
|
+
width: number,
|
|
265
|
+
theme: Theme,
|
|
266
|
+
openGoals = 0,
|
|
267
|
+
): string[] {
|
|
268
|
+
if (width < 40) return [];
|
|
269
|
+
const fg = (semantic: string, text: string): string =>
|
|
270
|
+
theme.fg(semantic as never, text);
|
|
271
|
+
|
|
272
|
+
if (!state) {
|
|
273
|
+
if (openGoals > 0 && width >= 48) {
|
|
274
|
+
return [
|
|
275
|
+
fg(
|
|
276
|
+
'dim',
|
|
277
|
+
`◆ ${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`,
|
|
278
|
+
),
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const badge = fg(STATUS_COLORS[state.status] ?? 'text', `● ${state.status}`);
|
|
285
|
+
const elapsed =
|
|
286
|
+
state.activeSeconds !== undefined
|
|
287
|
+
? ` · ${formatGoalElapsed(state.activeSeconds)}`
|
|
288
|
+
: '';
|
|
289
|
+
const headline = `◆ ${state.objective} ${badge}${elapsed}`;
|
|
290
|
+
const rows: string[] = [truncateToWidth(headline, width, '…')];
|
|
291
|
+
|
|
292
|
+
if (state.tasks && width >= 48) {
|
|
293
|
+
const { complete, total, active } = state.tasks;
|
|
294
|
+
rows.push(
|
|
295
|
+
truncateToWidth(
|
|
296
|
+
` ├ tasks ${complete}/${total} done` +
|
|
297
|
+
(active > 0 ? ` · ${active} active` : ''),
|
|
298
|
+
width,
|
|
299
|
+
'…',
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
width >= 60 &&
|
|
306
|
+
(state.tokensUsed !== undefined || state.tokenBudget !== undefined)
|
|
307
|
+
) {
|
|
308
|
+
const parts: string[] = [];
|
|
309
|
+
if (state.tokensUsed !== undefined)
|
|
310
|
+
parts.push(`${formatTokens(state.tokensUsed)} tokens`);
|
|
311
|
+
if (state.tokenBudget !== undefined && state.tokensUsed !== undefined) {
|
|
312
|
+
parts.push(
|
|
313
|
+
`budget ${Math.min(999, Math.round((state.tokensUsed / state.tokenBudget) * 100))}%`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (parts.length > 0) {
|
|
317
|
+
rows.push(truncateToWidth(` └ usage ${parts.join(' · ')}`, width, '…'));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return rows.map((line) => fg('dim', line));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function installGoalBlockTakeover(pi: ExtensionAPI): () => void {
|
|
325
|
+
let activeCtx: ExtensionContext | undefined;
|
|
326
|
+
let stopped = false;
|
|
327
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
328
|
+
|
|
329
|
+
const register = (ctx: ExtensionContext): void => {
|
|
330
|
+
if (stopped || ctx.mode !== 'tui' || !ctx.hasUI) return;
|
|
331
|
+
if (typeof ctx.ui.setWidget !== 'function') return;
|
|
332
|
+
ctx.ui.setWidget(GOAL_WIDGET_KEY, (tui, _theme: Theme) => {
|
|
333
|
+
const requestRender = (): void => tui.requestRender();
|
|
334
|
+
return {
|
|
335
|
+
render(width: number): string[] {
|
|
336
|
+
// Pure suppression: the deck owns every goal display, including
|
|
337
|
+
// the unfocused open-goal hint in its title row.
|
|
338
|
+
const lines: string[] = [];
|
|
339
|
+
return lines.every((line) => visibleWidth(line) <= width)
|
|
340
|
+
? lines
|
|
341
|
+
: lines.map((line) => truncateToWidth(line, width, '…'));
|
|
342
|
+
},
|
|
343
|
+
invalidate() {
|
|
344
|
+
requestRender();
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const scheduleReasserts = (ctx: ExtensionContext): void => {
|
|
351
|
+
for (const delay of REASSERT_DELAYS_MS) {
|
|
352
|
+
const timer = setTimeout(() => {
|
|
353
|
+
if (!stopped && activeCtx === ctx) register(ctx);
|
|
354
|
+
}, delay);
|
|
355
|
+
timer.unref?.();
|
|
356
|
+
timers.push(timer);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
pi.on('session_start', (_event, ctx) => {
|
|
361
|
+
if (!ctx) return;
|
|
362
|
+
activeCtx = ctx;
|
|
363
|
+
register(ctx);
|
|
364
|
+
scheduleReasserts(ctx);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
pi.on('turn_start', () => {
|
|
368
|
+
if (!stopped && activeCtx) register(activeCtx);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return () => {
|
|
372
|
+
stopped = true;
|
|
373
|
+
for (const timer of timers) clearTimeout(timer);
|
|
374
|
+
activeCtx?.ui.setWidget(GOAL_WIDGET_KEY, undefined);
|
|
375
|
+
activeCtx = undefined;
|
|
376
|
+
};
|
|
377
|
+
}
|