@aemonge-dev/pi-stardeck 0.0.0-stage → 0.6.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 +240 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +106 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +19 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/footer/README.md +15 -0
- package/packages/footer/index.ts +37 -0
- package/packages/footer/package.json +32 -0
- package/packages/footer/src/protocol.ts +10 -0
- package/packages/footer/src/quota.ts +26 -0
- package/packages/footer/src/telemetry.ts +59 -0
- package/packages/pi-stardeck/README.md +220 -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 +227 -0
- package/src/index.ts +27 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
readFile,
|
|
6
|
+
rm,
|
|
7
|
+
stat,
|
|
8
|
+
utimes,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export type PromptEditorSurface = {
|
|
15
|
+
getExpandedText(): string;
|
|
16
|
+
setText(text: string): void;
|
|
17
|
+
setBorderOverride(colorizer: ((text: string) => string) | null): void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PromptExternalEditorResult =
|
|
21
|
+
| 'saved'
|
|
22
|
+
| 'unchanged'
|
|
23
|
+
| 'busy'
|
|
24
|
+
| 'inactive'
|
|
25
|
+
| 'failed';
|
|
26
|
+
|
|
27
|
+
type PromptExternalEditorOptions = {
|
|
28
|
+
agentDir: string;
|
|
29
|
+
cwd: string;
|
|
30
|
+
editor: PromptEditorSurface;
|
|
31
|
+
mutedBorder: (text: string) => string;
|
|
32
|
+
notifyError: (message: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const WRITE_SENTINEL = new Date('2000-01-01T00:00:00.000Z');
|
|
36
|
+
|
|
37
|
+
export class PromptExternalEditor {
|
|
38
|
+
private opening = false;
|
|
39
|
+
private active = true;
|
|
40
|
+
private child: ChildProcess | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly options: PromptExternalEditorOptions) {}
|
|
43
|
+
|
|
44
|
+
open(): Promise<PromptExternalEditorResult> {
|
|
45
|
+
if (this.opening) return Promise.resolve('busy');
|
|
46
|
+
if (!this.active) return Promise.resolve('inactive');
|
|
47
|
+
this.opening = true;
|
|
48
|
+
return this.openPrompt();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dispose(): void {
|
|
52
|
+
this.active = false;
|
|
53
|
+
this.child?.kill();
|
|
54
|
+
this.child = null;
|
|
55
|
+
this.options.editor.setBorderOverride(null);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async openPrompt(): Promise<PromptExternalEditorResult> {
|
|
59
|
+
let directory: string | undefined;
|
|
60
|
+
this.options.editor.setBorderOverride(this.options.mutedBorder);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Let Pi paint the muted ownership state before Neovim takes focus.
|
|
64
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
65
|
+
|
|
66
|
+
const command = await resolveExternalEditor(
|
|
67
|
+
this.options.agentDir,
|
|
68
|
+
this.options.cwd,
|
|
69
|
+
);
|
|
70
|
+
directory = await mkdtemp(join(tmpdir(), 'pi-nvim-prompt-'));
|
|
71
|
+
await chmod(directory, 0o700);
|
|
72
|
+
const promptFile = join(directory, 'prompt.md');
|
|
73
|
+
await writeFile(promptFile, this.options.editor.getExpandedText(), {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
mode: 0o600,
|
|
76
|
+
});
|
|
77
|
+
await utimes(promptFile, WRITE_SENTINEL, WRITE_SENTINEL);
|
|
78
|
+
const baselineMtime = (await stat(promptFile)).mtimeMs;
|
|
79
|
+
|
|
80
|
+
await this.runEditor(command, promptFile);
|
|
81
|
+
const [edited, editedStat] = await Promise.all([
|
|
82
|
+
readFile(promptFile, 'utf8'),
|
|
83
|
+
stat(promptFile),
|
|
84
|
+
]);
|
|
85
|
+
if (!this.active) return 'inactive';
|
|
86
|
+
|
|
87
|
+
this.options.editor.setText(edited.replace(/\n$/, ''));
|
|
88
|
+
return editedStat.mtimeMs === baselineMtime ? 'unchanged' : 'saved';
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!this.active) return 'inactive';
|
|
91
|
+
this.options.notifyError(`Neovim prompt editor: ${formatError(error)}`);
|
|
92
|
+
return 'failed';
|
|
93
|
+
} finally {
|
|
94
|
+
this.child = null;
|
|
95
|
+
this.opening = false;
|
|
96
|
+
this.options.editor.setBorderOverride(null);
|
|
97
|
+
if (directory) {
|
|
98
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private runEditor(command: string, promptFile: string): Promise<void> {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const child = spawn(command, [promptFile], {
|
|
106
|
+
env: process.env,
|
|
107
|
+
stdio: 'ignore',
|
|
108
|
+
});
|
|
109
|
+
this.child = child;
|
|
110
|
+
let settled = false;
|
|
111
|
+
|
|
112
|
+
child.once('error', (error) => {
|
|
113
|
+
if (settled) return;
|
|
114
|
+
settled = true;
|
|
115
|
+
reject(error);
|
|
116
|
+
});
|
|
117
|
+
child.once('close', (code, signal) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
if (code === 0) {
|
|
121
|
+
resolve();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const detail = signal ? `signal ${signal}` : `exit ${code ?? 'unknown'}`;
|
|
125
|
+
reject(new Error(`editor closed with ${detail}`));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveExternalEditor(agentDir: string, cwd: string): Promise<string> {
|
|
132
|
+
const globalEditor = await readExternalEditor(join(agentDir, 'settings.json'));
|
|
133
|
+
const projectEditor = await readExternalEditor(join(cwd, '.pi', 'settings.json'));
|
|
134
|
+
const command = projectEditor ?? globalEditor;
|
|
135
|
+
if (!command) throw new Error('externalEditor is not configured');
|
|
136
|
+
return command;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readExternalEditor(path: string): Promise<string | undefined> {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(await readFile(path, 'utf8')) as unknown;
|
|
142
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
|
|
143
|
+
const value = (parsed as { externalEditor?: unknown }).externalEditor;
|
|
144
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatError(error: unknown): string {
|
|
151
|
+
return error instanceof Error ? error.message : String(error);
|
|
152
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
|
|
3
|
+
export const FANCY_FOOTER_WIDGET_CHANNEL = 'pi-fancy-footer:widget';
|
|
4
|
+
|
|
5
|
+
const TRACKED_WIDGET_IDS = new Set([
|
|
6
|
+
'galactica.openspec-progress',
|
|
7
|
+
'galactica.openspec',
|
|
8
|
+
'galactica.work',
|
|
9
|
+
'galactica.goal',
|
|
10
|
+
'galactica.project-capabilities',
|
|
11
|
+
'galactica.session-memory',
|
|
12
|
+
'galactica.session-cpu',
|
|
13
|
+
'galactica.context-usage',
|
|
14
|
+
'galactica.compaction-count',
|
|
15
|
+
'galactica.runtime-state',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const COLORS = [
|
|
19
|
+
'text',
|
|
20
|
+
'accent',
|
|
21
|
+
'muted',
|
|
22
|
+
'dim',
|
|
23
|
+
'success',
|
|
24
|
+
'error',
|
|
25
|
+
'warning',
|
|
26
|
+
'borderAccent',
|
|
27
|
+
'customMessageLabel',
|
|
28
|
+
'thinkingHigh',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type PromptRailColor = (typeof COLORS)[number];
|
|
32
|
+
export type PromptRailColorize = (color: PromptRailColor, text: string) => string;
|
|
33
|
+
|
|
34
|
+
type RailWidget = {
|
|
35
|
+
id: string;
|
|
36
|
+
text: string;
|
|
37
|
+
icon: string;
|
|
38
|
+
color: PromptRailColor;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type PromptRailSnapshot = {
|
|
42
|
+
widgets: ReadonlyMap<string, RailWidget>;
|
|
43
|
+
totalCost: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type StyledText = { plain: string; styled: string };
|
|
47
|
+
|
|
48
|
+
const ANSI_ESCAPE = new RegExp(
|
|
49
|
+
'\\u001b(?:\\][\\s\\S]*?(?:\\u0007|\\u001b\\\\)|\\[[0-?]*[ -/]*[@-~]|[@-_])',
|
|
50
|
+
'gu',
|
|
51
|
+
);
|
|
52
|
+
const CONTROL_CHARACTER = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f]', 'gu');
|
|
53
|
+
|
|
54
|
+
function normalizeInline(value: string, maximum = 128): string {
|
|
55
|
+
return Array.from(
|
|
56
|
+
value
|
|
57
|
+
.replace(/\u2800/gu, ' ')
|
|
58
|
+
.replace(ANSI_ESCAPE, '')
|
|
59
|
+
.replace(CONTROL_CHARACTER, ' ')
|
|
60
|
+
.replace(/\s+/gu, ' ')
|
|
61
|
+
.trim(),
|
|
62
|
+
)
|
|
63
|
+
.slice(0, maximum)
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isColor(value: unknown): value is PromptRailColor {
|
|
68
|
+
return typeof value === 'string' && (COLORS as readonly string[]).includes(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveIcon(value: unknown): string {
|
|
72
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
|
|
73
|
+
const glyphs = (value as Record<string, unknown>).glyphs;
|
|
74
|
+
if (typeof glyphs === 'string') return normalizeInline(glyphs, 16);
|
|
75
|
+
if (!glyphs || typeof glyphs !== 'object' || Array.isArray(glyphs)) return '';
|
|
76
|
+
const choices = glyphs as Record<string, unknown>;
|
|
77
|
+
for (const family of ['nerd', 'unicode', 'ascii']) {
|
|
78
|
+
if (typeof choices[family] === 'string') {
|
|
79
|
+
return normalizeInline(choices[family] as string, 16);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseWidget(value: unknown): RailWidget | null {
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
87
|
+
const widget = value as Record<string, unknown>;
|
|
88
|
+
if (typeof widget.id !== 'string' || !TRACKED_WIDGET_IDS.has(widget.id)) return null;
|
|
89
|
+
|
|
90
|
+
const content = widget.content;
|
|
91
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
|
|
92
|
+
const contentRecord = content as Record<string, unknown>;
|
|
93
|
+
if (contentRecord.type !== 'text' || typeof contentRecord.text !== 'string') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const text = normalizeInline(contentRecord.text);
|
|
97
|
+
const icon = resolveIcon(widget.icon);
|
|
98
|
+
const style =
|
|
99
|
+
widget.style && typeof widget.style === 'object' && !Array.isArray(widget.style)
|
|
100
|
+
? (widget.style as Record<string, unknown>)
|
|
101
|
+
: undefined;
|
|
102
|
+
const iconRecord =
|
|
103
|
+
widget.icon && typeof widget.icon === 'object' && !Array.isArray(widget.icon)
|
|
104
|
+
? (widget.icon as Record<string, unknown>)
|
|
105
|
+
: undefined;
|
|
106
|
+
const color = isColor(style?.textColor)
|
|
107
|
+
? style.textColor
|
|
108
|
+
: isColor(iconRecord?.color)
|
|
109
|
+
? iconRecord.color
|
|
110
|
+
: 'accent';
|
|
111
|
+
|
|
112
|
+
return { id: widget.id, text, icon, color };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class PromptRailStore {
|
|
116
|
+
private readonly widgets = new Map<string, RailWidget>();
|
|
117
|
+
private totalCost = 0;
|
|
118
|
+
|
|
119
|
+
apply(raw: unknown): boolean {
|
|
120
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false;
|
|
121
|
+
const message = raw as Record<string, unknown>;
|
|
122
|
+
if (message.protocol !== 1 || typeof message.type !== 'string') return false;
|
|
123
|
+
|
|
124
|
+
if (message.type === 'remove') {
|
|
125
|
+
return typeof message.id === 'string' && this.widgets.delete(message.id);
|
|
126
|
+
}
|
|
127
|
+
if (message.type !== 'upsert') return false;
|
|
128
|
+
|
|
129
|
+
const widget = parseWidget(message.widget);
|
|
130
|
+
if (!widget) return false;
|
|
131
|
+
const previous = this.widgets.get(widget.id);
|
|
132
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(widget)) return false;
|
|
133
|
+
this.widgets.set(widget.id, widget);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setTotalCost(value: number): boolean {
|
|
138
|
+
const next = Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
139
|
+
if (next === this.totalCost) return false;
|
|
140
|
+
this.totalCost = next;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clear(): void {
|
|
145
|
+
this.widgets.clear();
|
|
146
|
+
this.totalCost = 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
snapshot(): PromptRailSnapshot {
|
|
150
|
+
return { widgets: new Map(this.widgets), totalCost: this.totalCost };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function collectSessionCost(entries: readonly unknown[]): number {
|
|
155
|
+
let total = 0;
|
|
156
|
+
for (const rawEntry of entries) {
|
|
157
|
+
if (!rawEntry || typeof rawEntry !== 'object' || Array.isArray(rawEntry)) continue;
|
|
158
|
+
const entry = rawEntry as Record<string, unknown>;
|
|
159
|
+
if (entry.type !== 'message') continue;
|
|
160
|
+
const message = entry.message;
|
|
161
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) continue;
|
|
162
|
+
const candidate = message as Record<string, unknown>;
|
|
163
|
+
if (candidate.role !== 'assistant') continue;
|
|
164
|
+
const usage = candidate.usage;
|
|
165
|
+
if (!usage || typeof usage !== 'object' || Array.isArray(usage)) continue;
|
|
166
|
+
const cost = (usage as Record<string, unknown>).cost;
|
|
167
|
+
if (!cost || typeof cost !== 'object' || Array.isArray(cost)) continue;
|
|
168
|
+
const value = Number((cost as Record<string, unknown>).total);
|
|
169
|
+
if (Number.isFinite(value) && value > 0) total += value;
|
|
170
|
+
}
|
|
171
|
+
return total;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatCompactCost(cost: number): string {
|
|
175
|
+
const normalized = Number.isFinite(cost) ? Math.max(0, cost) : 0;
|
|
176
|
+
if (normalized === 0) return '$0';
|
|
177
|
+
const decimals =
|
|
178
|
+
normalized >= 0.01
|
|
179
|
+
? 2
|
|
180
|
+
: Math.min(6, Math.max(3, Math.ceil(-Math.log10(normalized)) + 1));
|
|
181
|
+
return `$${normalized.toFixed(decimals).replace(/\.?0+$/u, '')}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function widgetText(
|
|
185
|
+
widget: RailWidget | undefined,
|
|
186
|
+
colorize: PromptRailColorize,
|
|
187
|
+
): StyledText | null {
|
|
188
|
+
if (!widget) return null;
|
|
189
|
+
const plain = [widget.icon, widget.text].filter(Boolean).join(' ');
|
|
190
|
+
return plain ? { plain, styled: colorize(widget.color, plain) } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function literal(
|
|
194
|
+
text: string,
|
|
195
|
+
color: PromptRailColor,
|
|
196
|
+
colorize: PromptRailColorize,
|
|
197
|
+
): StyledText {
|
|
198
|
+
return { plain: text, styled: colorize(color, text) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function join(
|
|
202
|
+
items: Array<StyledText | null | undefined>,
|
|
203
|
+
separator = ' ',
|
|
204
|
+
): StyledText | null {
|
|
205
|
+
const present = items.filter((item): item is StyledText => Boolean(item?.plain));
|
|
206
|
+
if (present.length === 0) return null;
|
|
207
|
+
return {
|
|
208
|
+
plain: present.map((item) => item.plain).join(separator),
|
|
209
|
+
styled: present.map((item) => item.styled).join(separator),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function workWidget(widgets: ReadonlyMap<string, RailWidget>): RailWidget | undefined {
|
|
214
|
+
for (const id of [
|
|
215
|
+
'galactica.openspec-progress',
|
|
216
|
+
'galactica.openspec',
|
|
217
|
+
'galactica.goal',
|
|
218
|
+
'galactica.work',
|
|
219
|
+
]) {
|
|
220
|
+
const widget = widgets.get(id);
|
|
221
|
+
if (widget) return widget;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function railSlice(base: string, width: number): string {
|
|
227
|
+
return truncateToWidth(base, Math.max(0, width), '');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function composeRail(
|
|
231
|
+
base: string,
|
|
232
|
+
width: number,
|
|
233
|
+
left: StyledText | null,
|
|
234
|
+
right: StyledText | null,
|
|
235
|
+
leftInset = 8,
|
|
236
|
+
suffixWidth = 2,
|
|
237
|
+
): string | null {
|
|
238
|
+
if (width <= 0) return '';
|
|
239
|
+
const leftWidth = left ? visibleWidth(left.styled) : 0;
|
|
240
|
+
const rightWidth = right ? visibleWidth(right.styled) : 0;
|
|
241
|
+
const prefix = left ? Math.min(leftInset, Math.max(2, width - 1)) : 0;
|
|
242
|
+
const occupied =
|
|
243
|
+
prefix + (left ? 2 + leftWidth : 0) + (right ? 2 + rightWidth : 0) + suffixWidth;
|
|
244
|
+
const fillWidth = width - occupied;
|
|
245
|
+
if (fillWidth < 2) return null;
|
|
246
|
+
|
|
247
|
+
let output = railSlice(base, prefix + fillWidth);
|
|
248
|
+
if (left) {
|
|
249
|
+
output = `${railSlice(base, prefix)} ${left.styled} ${railSlice(base, fillWidth)}`;
|
|
250
|
+
}
|
|
251
|
+
if (right) {
|
|
252
|
+
output += ` ${right.styled} ${railSlice(base, suffixWidth)}`;
|
|
253
|
+
} else {
|
|
254
|
+
output += railSlice(base, suffixWidth);
|
|
255
|
+
}
|
|
256
|
+
return output;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderPromptRails(options: {
|
|
260
|
+
lines: string[];
|
|
261
|
+
width: number;
|
|
262
|
+
snapshot: PromptRailSnapshot;
|
|
263
|
+
mode: StyledText;
|
|
264
|
+
colorize: PromptRailColorize;
|
|
265
|
+
}): string[] {
|
|
266
|
+
const { lines, width, snapshot, mode, colorize } = options;
|
|
267
|
+
if (lines.length === 0 || width <= 0) return lines;
|
|
268
|
+
|
|
269
|
+
const topBase = lines[0] ?? '';
|
|
270
|
+
const bottomIndex = lines.length - 1;
|
|
271
|
+
const bottomBase = lines[bottomIndex] ?? topBase;
|
|
272
|
+
const widgets = snapshot.widgets;
|
|
273
|
+
|
|
274
|
+
const runtimeWidget = widgets.get('galactica.runtime-state');
|
|
275
|
+
const runtime = literal(
|
|
276
|
+
runtimeWidget?.text || "00:00'00",
|
|
277
|
+
runtimeWidget?.color ?? 'dim',
|
|
278
|
+
colorize,
|
|
279
|
+
);
|
|
280
|
+
const capability = widgetText(
|
|
281
|
+
widgets.get('galactica.project-capabilities'),
|
|
282
|
+
colorize,
|
|
283
|
+
);
|
|
284
|
+
const work = widgetText(workWidget(widgets), colorize);
|
|
285
|
+
const capabilityBoundary =
|
|
286
|
+
capability && work ? literal('⟩', 'thinkingHigh', colorize) : null;
|
|
287
|
+
const topTelemetry = join([capability, capabilityBoundary, work]);
|
|
288
|
+
const topLayouts = [
|
|
289
|
+
{ left: mode, right: topTelemetry },
|
|
290
|
+
{ left: mode, right: work },
|
|
291
|
+
{ left: mode, right: null },
|
|
292
|
+
];
|
|
293
|
+
let renderedTop: string | null = null;
|
|
294
|
+
for (const layout of topLayouts) {
|
|
295
|
+
renderedTop = composeRail(topBase, width, layout.left, layout.right, 1);
|
|
296
|
+
if (renderedTop !== null) break;
|
|
297
|
+
}
|
|
298
|
+
lines[0] = renderedTop ?? railSlice(topBase, width);
|
|
299
|
+
|
|
300
|
+
const memory = widgetText(widgets.get('galactica.session-memory'), colorize);
|
|
301
|
+
const cpu = widgetText(widgets.get('galactica.session-cpu'), colorize);
|
|
302
|
+
const context = widgetText(widgets.get('galactica.context-usage'), colorize);
|
|
303
|
+
const compaction = widgetText(widgets.get('galactica.compaction-count'), colorize);
|
|
304
|
+
const contextTelemetry = join([context, compaction]);
|
|
305
|
+
const cost =
|
|
306
|
+
snapshot.totalCost > 0
|
|
307
|
+
? join([
|
|
308
|
+
literal('⟩', 'thinkingHigh', colorize),
|
|
309
|
+
literal(formatCompactCost(snapshot.totalCost), 'accent', colorize),
|
|
310
|
+
])
|
|
311
|
+
: null;
|
|
312
|
+
const resources = join([memory, cpu]);
|
|
313
|
+
const separatedResources = resources
|
|
314
|
+
? join([literal('⟩', 'thinkingHigh', colorize), resources])
|
|
315
|
+
: null;
|
|
316
|
+
const fullTelemetry = join([contextTelemetry, separatedResources, cost]);
|
|
317
|
+
const compactTelemetry = join([context, separatedResources, cost]);
|
|
318
|
+
const contextAndCost = join([contextTelemetry, cost]);
|
|
319
|
+
const bottomLayouts = [
|
|
320
|
+
{ left: runtime, right: fullTelemetry },
|
|
321
|
+
{ left: runtime, right: compactTelemetry },
|
|
322
|
+
{ left: runtime, right: contextAndCost },
|
|
323
|
+
{ left: runtime, right: context },
|
|
324
|
+
{ left: runtime, right: cost },
|
|
325
|
+
{ left: runtime, right: null },
|
|
326
|
+
];
|
|
327
|
+
let renderedBottom: string | null = null;
|
|
328
|
+
for (const layout of bottomLayouts) {
|
|
329
|
+
renderedBottom = composeRail(bottomBase, width, layout.left, layout.right, 1);
|
|
330
|
+
if (renderedBottom !== null) break;
|
|
331
|
+
}
|
|
332
|
+
lines[bottomIndex] = renderedBottom ?? railSlice(bottomBase, width);
|
|
333
|
+
return lines;
|
|
334
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export const PROMPT_STATUS_CHANNEL = 'galactica-status:prompt-row';
|
|
2
|
+
|
|
3
|
+
const COLORS = [
|
|
4
|
+
'text',
|
|
5
|
+
'accent',
|
|
6
|
+
'muted',
|
|
7
|
+
'dim',
|
|
8
|
+
'success',
|
|
9
|
+
'error',
|
|
10
|
+
'warning',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type PromptStatusColor = (typeof COLORS)[number];
|
|
14
|
+
|
|
15
|
+
export type PromptStatusSnapshot = {
|
|
16
|
+
signals: Array<{ glyph: string; color: PromptStatusColor }>;
|
|
17
|
+
git: {
|
|
18
|
+
branch: string;
|
|
19
|
+
staged: number;
|
|
20
|
+
modified: number;
|
|
21
|
+
untracked: number;
|
|
22
|
+
conflicts: number;
|
|
23
|
+
};
|
|
24
|
+
progress: Array<{
|
|
25
|
+
icon: string;
|
|
26
|
+
completed: number;
|
|
27
|
+
total: number;
|
|
28
|
+
color: PromptStatusColor;
|
|
29
|
+
}>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const EMPTY_PROMPT_STATUS: PromptStatusSnapshot = {
|
|
33
|
+
signals: [],
|
|
34
|
+
git: {
|
|
35
|
+
branch: '',
|
|
36
|
+
staged: 0,
|
|
37
|
+
modified: 0,
|
|
38
|
+
untracked: 0,
|
|
39
|
+
conflicts: 0,
|
|
40
|
+
},
|
|
41
|
+
progress: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function formatModeAnchor(mode: string): string {
|
|
45
|
+
return ` ${mode} `;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function finiteCount(value: unknown): number | null {
|
|
49
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
50
|
+
? Math.max(0, Math.trunc(value))
|
|
51
|
+
: null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isColor(value: unknown): value is PromptStatusColor {
|
|
55
|
+
return typeof value === 'string' && (COLORS as readonly string[]).includes(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parsePromptStatus(raw: unknown): PromptStatusSnapshot | null {
|
|
59
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
60
|
+
const message = raw as Record<string, unknown>;
|
|
61
|
+
if (message.protocol !== 1) return null;
|
|
62
|
+
|
|
63
|
+
const gitRaw = message.git;
|
|
64
|
+
if (!gitRaw || typeof gitRaw !== 'object' || Array.isArray(gitRaw)) return null;
|
|
65
|
+
const gitRecord = gitRaw as Record<string, unknown>;
|
|
66
|
+
const staged = finiteCount(gitRecord.staged);
|
|
67
|
+
const modified = finiteCount(gitRecord.modified);
|
|
68
|
+
const untracked = finiteCount(gitRecord.untracked);
|
|
69
|
+
const conflicts = finiteCount(gitRecord.conflicts);
|
|
70
|
+
if (
|
|
71
|
+
typeof gitRecord.branch !== 'string' ||
|
|
72
|
+
staged === null ||
|
|
73
|
+
modified === null ||
|
|
74
|
+
untracked === null ||
|
|
75
|
+
conflicts === null
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const signals = Array.isArray(message.signals)
|
|
81
|
+
? message.signals.flatMap((rawSignal) => {
|
|
82
|
+
if (!rawSignal || typeof rawSignal !== 'object' || Array.isArray(rawSignal)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const signal = rawSignal as Record<string, unknown>;
|
|
86
|
+
return typeof signal.glyph === 'string' && isColor(signal.color)
|
|
87
|
+
? [{ glyph: signal.glyph.slice(0, 8), color: signal.color }]
|
|
88
|
+
: [];
|
|
89
|
+
})
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
const progress = Array.isArray(message.progress)
|
|
93
|
+
? message.progress.flatMap((rawProgress) => {
|
|
94
|
+
if (
|
|
95
|
+
!rawProgress ||
|
|
96
|
+
typeof rawProgress !== 'object' ||
|
|
97
|
+
Array.isArray(rawProgress)
|
|
98
|
+
) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const item = rawProgress as Record<string, unknown>;
|
|
102
|
+
const completed = finiteCount(item.completed);
|
|
103
|
+
const total = finiteCount(item.total);
|
|
104
|
+
return typeof item.icon === 'string' &&
|
|
105
|
+
completed !== null &&
|
|
106
|
+
total !== null &&
|
|
107
|
+
isColor(item.color)
|
|
108
|
+
? [
|
|
109
|
+
{
|
|
110
|
+
icon: item.icon.slice(0, 8),
|
|
111
|
+
completed,
|
|
112
|
+
total,
|
|
113
|
+
color: item.color,
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
: [];
|
|
117
|
+
})
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
signals,
|
|
122
|
+
git: {
|
|
123
|
+
branch: gitRecord.branch.replace(/\s+/gu, ' ').trim().slice(0, 48),
|
|
124
|
+
staged,
|
|
125
|
+
modified,
|
|
126
|
+
untracked,
|
|
127
|
+
conflicts,
|
|
128
|
+
},
|
|
129
|
+
progress,
|
|
130
|
+
};
|
|
131
|
+
}
|