@pugi/cli 0.1.0-alpha.7 → 0.1.0-alpha.9
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/dist/core/repl/session.js +178 -3
- package/dist/core/repl/slash-commands.js +126 -23
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/runtime/cli.js +8 -1
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/repl-render.js +26 -4
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +59 -4
- package/dist/tui/slash-palette.js +47 -10
- package/dist/tui/status-bar.js +14 -1
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +2 -2
|
@@ -32,6 +32,9 @@ import { evaluateCap, describeVerdict } from './cap-warning.js';
|
|
|
32
32
|
import { parseSlashCommand } from './slash-commands.js';
|
|
33
33
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
34
34
|
import { loadSettings } from '../settings.js';
|
|
35
|
+
import { getJobRegistry } from '../jobs/registry.js';
|
|
36
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
37
|
+
import { resolve as resolvePath } from 'node:path';
|
|
35
38
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
36
39
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
37
40
|
const RECONNECT_BASE_MS = 250;
|
|
@@ -45,6 +48,18 @@ export class ReplSession {
|
|
|
45
48
|
reconnectAttempt = 0;
|
|
46
49
|
reconnectTimer;
|
|
47
50
|
closed = false;
|
|
51
|
+
/**
|
|
52
|
+
* Last non-trivial step.detail recorded per taskId. The server streams
|
|
53
|
+
* the persona reply incrementally via `agent.step` events whose
|
|
54
|
+
* `detail` field carries the cumulative model output. `agent.completed`
|
|
55
|
+
* arrives last and previously overwrote the visible detail to the
|
|
56
|
+
* literal string `'shipped'` while the transcript line said only
|
|
57
|
+
* `shipped.` — the actual reply text was lost. By caching the last
|
|
58
|
+
* non-trivial detail here, we can flush it into the transcript when
|
|
59
|
+
* the agent completes so the operator sees what the persona actually
|
|
60
|
+
* said. CEO wave-2 fix 2026-05-25.
|
|
61
|
+
*/
|
|
62
|
+
lastStepDetail = new Map();
|
|
48
63
|
constructor(options) {
|
|
49
64
|
this.options = options;
|
|
50
65
|
this.state = {
|
|
@@ -81,6 +96,7 @@ export class ReplSession {
|
|
|
81
96
|
const { sessionId } = await this.options.transport.createSession({
|
|
82
97
|
apiUrl: this.options.apiUrl,
|
|
83
98
|
apiKey: this.options.apiKey,
|
|
99
|
+
workspace: this.options.workspace,
|
|
84
100
|
});
|
|
85
101
|
this.patch({ sessionId, connection: 'connecting' });
|
|
86
102
|
this.openStream();
|
|
@@ -122,7 +138,11 @@ export class ReplSession {
|
|
|
122
138
|
// UI overlays - no transport interaction.
|
|
123
139
|
return verdict;
|
|
124
140
|
case 'quit':
|
|
125
|
-
|
|
141
|
+
// UI Designer audit 2026-05-25: "Brief it. It ships." is reserved
|
|
142
|
+
// for identity intro + landing per wave-4 prompt rule. Drop the
|
|
143
|
+
// tagline drift here; tell the operator what happened and how to
|
|
144
|
+
// resume.
|
|
145
|
+
this.appendSystemLine('On watch ended. pugi resume to come back.');
|
|
126
146
|
return verdict;
|
|
127
147
|
case 'error':
|
|
128
148
|
this.appendSystemLine(verdict.message);
|
|
@@ -139,8 +159,112 @@ export class ReplSession {
|
|
|
139
159
|
await this.dispatchWebFetch(verdict.url);
|
|
140
160
|
return verdict;
|
|
141
161
|
}
|
|
162
|
+
case 'clear': {
|
|
163
|
+
this.clearTranscript();
|
|
164
|
+
return verdict;
|
|
165
|
+
}
|
|
166
|
+
case 'version': {
|
|
167
|
+
this.appendSystemLine(`pugi ${this.options.cliVersion}`);
|
|
168
|
+
return verdict;
|
|
169
|
+
}
|
|
170
|
+
case 'jobs': {
|
|
171
|
+
await this.dispatchJobs();
|
|
172
|
+
return verdict;
|
|
173
|
+
}
|
|
174
|
+
case 'diff': {
|
|
175
|
+
this.dispatchDiff();
|
|
176
|
+
return verdict;
|
|
177
|
+
}
|
|
178
|
+
case 'cost': {
|
|
179
|
+
this.dispatchCost();
|
|
180
|
+
return verdict;
|
|
181
|
+
}
|
|
182
|
+
case 'status': {
|
|
183
|
+
this.dispatchStatus();
|
|
184
|
+
return verdict;
|
|
185
|
+
}
|
|
186
|
+
case 'stub': {
|
|
187
|
+
this.appendSystemLine(verdict.message);
|
|
188
|
+
return verdict;
|
|
189
|
+
}
|
|
142
190
|
}
|
|
143
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Reset the conversation transcript. The agent registry stays intact
|
|
194
|
+
* so the operator can `/clear` to declutter the chat pane without
|
|
195
|
+
* losing visibility into running dispatches.
|
|
196
|
+
*/
|
|
197
|
+
clearTranscript() {
|
|
198
|
+
this.patch({ transcript: [] });
|
|
199
|
+
}
|
|
200
|
+
/* ------------- Tier 1 / Tier 2 wired handlers -------------- */
|
|
201
|
+
async dispatchJobs() {
|
|
202
|
+
try {
|
|
203
|
+
const registry = getJobRegistry();
|
|
204
|
+
const entries = await registry.list();
|
|
205
|
+
if (entries.length === 0) {
|
|
206
|
+
this.appendSystemLine('No background jobs tracked.');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.appendSystemLine(`Background jobs (${entries.length}):`);
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const id = entry.id.replace(/^pj-/, '').slice(0, 8);
|
|
212
|
+
const status = entry.status;
|
|
213
|
+
const cmd = entry.command.length > 48 ? `${entry.command.slice(0, 47)}…` : entry.command;
|
|
214
|
+
this.appendSystemLine(` ${id} ${status.padEnd(10)} ${cmd}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
this.appendSystemLine(`/jobs failed: ${this.errorMessage(error)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
dispatchDiff() {
|
|
222
|
+
try {
|
|
223
|
+
const artifactsRoot = resolvePath(process.cwd(), '.pugi', 'artifacts');
|
|
224
|
+
if (!existsSync(artifactsRoot)) {
|
|
225
|
+
this.appendSystemLine('No pending diffs (.pugi/artifacts/ not found).');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const subdirs = readdirSync(artifactsRoot, { withFileTypes: true })
|
|
229
|
+
.filter((d) => d.isDirectory())
|
|
230
|
+
.map((d) => d.name);
|
|
231
|
+
const diffs = [];
|
|
232
|
+
for (const name of subdirs) {
|
|
233
|
+
const candidate = resolvePath(artifactsRoot, name, 'diff.patch');
|
|
234
|
+
if (existsSync(candidate)) {
|
|
235
|
+
const size = statSync(candidate).size;
|
|
236
|
+
diffs.push(` ${name}/diff.patch (${size} bytes)`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (diffs.length === 0) {
|
|
240
|
+
this.appendSystemLine('No pending diffs.');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.appendSystemLine(`Pending diffs (${diffs.length}):`);
|
|
244
|
+
for (const line of diffs)
|
|
245
|
+
this.appendSystemLine(line);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.appendSystemLine(`/diff failed: ${this.errorMessage(error)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
dispatchCost() {
|
|
252
|
+
const { tokensDownstreamTotal, agents } = this.state;
|
|
253
|
+
const active = agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
|
|
254
|
+
const lineTokens = `Tokens this session: ${tokensDownstreamTotal.toLocaleString()} (in+out).`;
|
|
255
|
+
const lineAgents = `Active dispatches: ${active} of cap.`;
|
|
256
|
+
this.appendSystemLine(lineTokens);
|
|
257
|
+
this.appendSystemLine(lineAgents);
|
|
258
|
+
this.appendSystemLine('Full per-persona budget breakdown lands in α6.5.');
|
|
259
|
+
}
|
|
260
|
+
dispatchStatus() {
|
|
261
|
+
const sessionId = this.state.sessionId ?? '(unbound)';
|
|
262
|
+
const reach = this.state.connection;
|
|
263
|
+
this.appendSystemLine(`Backend: ${this.options.apiUrl} (${reach}).`);
|
|
264
|
+
this.appendSystemLine(`Session: ${sessionId}.`);
|
|
265
|
+
this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
|
|
266
|
+
this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
|
|
267
|
+
}
|
|
144
268
|
/**
|
|
145
269
|
* Fetch one URL via the web_fetch tool and inject the resulting
|
|
146
270
|
* Markdown into the transcript as an operator-attributed brief. The
|
|
@@ -284,6 +408,20 @@ export class ReplSession {
|
|
|
284
408
|
switch (event.type) {
|
|
285
409
|
case 'agent.spawned': {
|
|
286
410
|
const persona = safePersonaName(event.role);
|
|
411
|
+
// Wave 4 fix 2026-05-25: the roster collapses to one row per
|
|
412
|
+
// persona slug. The α5.7 reducer pushed a fresh row on every
|
|
413
|
+
// spawn, so after three turns the bottom panel stacked
|
|
414
|
+
// "Mira orchestrator shipped" three times. The new contract:
|
|
415
|
+
// - If a row already exists for this personaSlug, REUSE it.
|
|
416
|
+
// Replace its taskId, reset status to 'queued', clear the
|
|
417
|
+
// detail line, restart the duration clock, zero the token
|
|
418
|
+
// counters. The persona name + slug + role stay stable
|
|
419
|
+
// (they are the row identity).
|
|
420
|
+
// - If no row exists yet, push a new one.
|
|
421
|
+
// Per-task lifecycle (step/tokens/completed/blocked/failed) is
|
|
422
|
+
// keyed off `taskId` everywhere, so the reused row still folds
|
|
423
|
+
// the latest task's events correctly.
|
|
424
|
+
const existing = this.state.agents.find((a) => a.personaSlug === event.personaSlug);
|
|
287
425
|
const node = {
|
|
288
426
|
taskId: event.taskId,
|
|
289
427
|
role: event.role,
|
|
@@ -295,7 +433,14 @@ export class ReplSession {
|
|
|
295
433
|
tokensIn: 0,
|
|
296
434
|
tokensOut: 0,
|
|
297
435
|
};
|
|
298
|
-
|
|
436
|
+
if (existing) {
|
|
437
|
+
this.patch({
|
|
438
|
+
agents: this.state.agents.map((a) => a.personaSlug === event.personaSlug ? node : a),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
this.patch({ agents: [node, ...this.state.agents] });
|
|
443
|
+
}
|
|
299
444
|
// The conversation pane already prefixes persona rows with the
|
|
300
445
|
// persona name in the persona's hue colour. Skip embedding the
|
|
301
446
|
// name in the body text to avoid the `Marcus Marcus dispatched`
|
|
@@ -307,6 +452,12 @@ export class ReplSession {
|
|
|
307
452
|
return;
|
|
308
453
|
}
|
|
309
454
|
case 'agent.step': {
|
|
455
|
+
// Cache the running detail per task so we can surface the
|
|
456
|
+
// model's actual reply when agent.completed lands (otherwise
|
|
457
|
+
// the reply disappears under the literal 'shipped' patch).
|
|
458
|
+
if (event.detail && event.detail.trim().length > 0) {
|
|
459
|
+
this.lastStepDetail.set(event.taskId, event.detail);
|
|
460
|
+
}
|
|
310
461
|
this.patch({
|
|
311
462
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
312
463
|
? { ...a, status: 'thinking', detail: event.detail }
|
|
@@ -330,18 +481,41 @@ export class ReplSession {
|
|
|
330
481
|
}
|
|
331
482
|
case 'agent.completed': {
|
|
332
483
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
484
|
+
const finalDetail = this.lastStepDetail.get(event.taskId);
|
|
485
|
+
this.lastStepDetail.delete(event.taskId);
|
|
333
486
|
this.patch({
|
|
334
487
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
335
488
|
? { ...a, status: 'shipped', detail: 'shipped' }
|
|
336
489
|
: a),
|
|
337
490
|
});
|
|
338
491
|
if (target) {
|
|
339
|
-
|
|
492
|
+
// If the persona actually produced a reply via incremental
|
|
493
|
+
// agent.step events, render that reply in the transcript so
|
|
494
|
+
// the operator sees the full answer. The threshold (>4 chars
|
|
495
|
+
// + not the queued placeholder) filters out the no-op
|
|
496
|
+
// "shipped"/"queued for dispatch" placeholders the dispatcher
|
|
497
|
+
// emits before the model speaks. Multi-line replies are
|
|
498
|
+
// emitted line by line so the conversation pane wraps each
|
|
499
|
+
// sentence on its own row.
|
|
500
|
+
if (finalDetail
|
|
501
|
+
&& finalDetail !== 'queued for dispatch'
|
|
502
|
+
&& finalDetail.trim().length > 4) {
|
|
503
|
+
for (const line of finalDetail.split('\n')) {
|
|
504
|
+
const trimmed = line.trim();
|
|
505
|
+
if (trimmed.length > 0) {
|
|
506
|
+
this.appendPersonaLine(target.personaSlug, trimmed);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
this.appendPersonaLine(target.personaSlug, 'shipped.');
|
|
512
|
+
}
|
|
340
513
|
}
|
|
341
514
|
return;
|
|
342
515
|
}
|
|
343
516
|
case 'agent.blocked': {
|
|
344
517
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
518
|
+
this.lastStepDetail.delete(event.taskId);
|
|
345
519
|
this.patch({
|
|
346
520
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
347
521
|
? { ...a, status: 'blocked', detail: event.detail }
|
|
@@ -354,6 +528,7 @@ export class ReplSession {
|
|
|
354
528
|
}
|
|
355
529
|
case 'agent.failed': {
|
|
356
530
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
531
|
+
this.lastStepDetail.delete(event.taskId);
|
|
357
532
|
this.patch({
|
|
358
533
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
359
534
|
? { ...a, status: 'failed', detail: event.error }
|
|
@@ -1,32 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* REPL slash command registry
|
|
2
|
+
* REPL slash command registry — Sprint α5.7, expanded α6.14 wave 2.
|
|
3
3
|
*
|
|
4
|
-
* The REPL input box surfaces a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* overlay
|
|
8
|
-
* complementary surfaces (`/sync`, `/handoff`, `/budget`) ship as proper
|
|
9
|
-
* subcommands in α5.8+ and stay reachable from a non-REPL `pugi
|
|
10
|
-
* <command>` invocation.
|
|
4
|
+
* The REPL input box surfaces a palette of slash commands the operator
|
|
5
|
+
* can run from inside a persistent session. The wave-2 expansion (CEO
|
|
6
|
+
* 2026-05-25) grows the surface from 6 to 20 commands so the `/help`
|
|
7
|
+
* overlay matches the breadth Claude Code / Codex CLI operators expect.
|
|
11
8
|
*
|
|
12
|
-
* The registry is pure: each
|
|
13
|
-
* describing what the REPL session should do next.
|
|
14
|
-
* owns the side effects (network calls, dispatcher
|
|
15
|
-
* Keeping the surface pure lets
|
|
16
|
-
* without standing up an Ink runtime
|
|
9
|
+
* The registry is pure: each `parseSlashCommand` call returns a
|
|
10
|
+
* `SlashCommandResult` describing what the REPL session should do next.
|
|
11
|
+
* The session module owns the side effects (network calls, dispatcher
|
|
12
|
+
* invocation, exit, transcript clear). Keeping the surface pure lets
|
|
13
|
+
* the unit test exercise every shape without standing up an Ink runtime
|
|
14
|
+
* or an Anvil endpoint.
|
|
15
|
+
*
|
|
16
|
+
* Tiering (per CEO wave-2 spec):
|
|
17
|
+
*
|
|
18
|
+
* Tier 1 — wired against real state (3 + existing 6 = 9 wired):
|
|
19
|
+
* brief, agents, stop, help, quit, web, clear, version, jobs.
|
|
20
|
+
*
|
|
21
|
+
* Tier 2 — best-effort wiring against existing surfaces (3):
|
|
22
|
+
* diff, cost, status.
|
|
23
|
+
*
|
|
24
|
+
* Tier 3 — deterministic stubs ("coming in αX.Y") (8):
|
|
25
|
+
* compact, resume, memory, config, privacy, budget, mcp, undo.
|
|
17
26
|
*
|
|
18
27
|
* Brand voice (brandbook §08): power words `brief / dispatch / stop /
|
|
19
|
-
* agents / quit`. Tagline `Brief it. It ships.` reserved for
|
|
20
|
-
* confirmation and `/help` footer
|
|
28
|
+
* agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
|
|
29
|
+
* `/quit` confirmation and `/help` footer — never inline.
|
|
21
30
|
*/
|
|
22
31
|
import { listRoles } from '../agents/registry.js';
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic stub copy returned by the Tier 3 commands. Spec'd
|
|
34
|
+
* inline so the unit test can pin the exact text without poking at
|
|
35
|
+
* the help overlay. The version tag at the end maps to the sprint we
|
|
36
|
+
* intend to land the real wiring in.
|
|
37
|
+
*/
|
|
38
|
+
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
39
|
+
brief: '',
|
|
40
|
+
agents: '',
|
|
41
|
+
stop: '',
|
|
42
|
+
help: '',
|
|
43
|
+
quit: '',
|
|
44
|
+
web: '',
|
|
45
|
+
clear: '',
|
|
46
|
+
version: '',
|
|
47
|
+
jobs: '',
|
|
48
|
+
diff: '',
|
|
49
|
+
cost: '',
|
|
50
|
+
status: '',
|
|
51
|
+
compact: 'Manual context compaction lands in α6.5.',
|
|
52
|
+
resume: 'Resume last session lands in α6.4 once SQLite session.db lands.',
|
|
53
|
+
memory: 'Session memory editor lands in α6.5.',
|
|
54
|
+
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
55
|
+
privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
|
|
56
|
+
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
57
|
+
mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
|
|
58
|
+
undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
|
|
59
|
+
});
|
|
23
60
|
export const SLASH_COMMAND_HELP = Object.freeze([
|
|
24
|
-
|
|
25
|
-
{ name: '
|
|
26
|
-
{ name: '
|
|
27
|
-
{ name: '
|
|
28
|
-
{ name: '
|
|
29
|
-
|
|
61
|
+
// Workforce dispatch
|
|
62
|
+
{ name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce', group: 'Workforce dispatch' },
|
|
63
|
+
{ name: 'agents', args: '', gloss: 'List the on-watch agent roster', group: 'Workforce dispatch' },
|
|
64
|
+
{ name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug', group: 'Workforce dispatch' },
|
|
65
|
+
{ name: 'jobs', args: '', gloss: 'List background jobs', group: 'Workforce dispatch' },
|
|
66
|
+
// Session
|
|
67
|
+
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
68
|
+
{ name: 'resume', args: '', gloss: 'Resume last session (α6.4)', group: 'Session', stub: true },
|
|
69
|
+
{ name: 'compact', args: '', gloss: 'Manual context compaction (α6.5)', group: 'Session', stub: true },
|
|
70
|
+
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5)', group: 'Session', stub: true },
|
|
71
|
+
// Pugi tools
|
|
72
|
+
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
73
|
+
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
74
|
+
{ name: 'cost', args: '', gloss: 'Token usage + budget', group: 'Pugi tools' },
|
|
75
|
+
{ name: 'status', args: '', gloss: 'Backend + tenant status', group: 'Pugi tools' },
|
|
76
|
+
// Settings
|
|
77
|
+
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
78
|
+
{ name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings', stub: true },
|
|
79
|
+
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
80
|
+
{ name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
|
|
81
|
+
{ name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
|
|
82
|
+
// Meta
|
|
83
|
+
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
84
|
+
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
85
|
+
{ name: 'quit', args: '', gloss: 'Exit the REPL', group: 'Meta' },
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Ordered list of groups. Drives the `/help` overlay sectioning so the
|
|
89
|
+
* operator reads commands by intent (dispatch first, meta last).
|
|
90
|
+
*/
|
|
91
|
+
export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
92
|
+
'Workforce dispatch',
|
|
93
|
+
'Session',
|
|
94
|
+
'Pugi tools',
|
|
95
|
+
'Settings',
|
|
96
|
+
'Meta',
|
|
30
97
|
]);
|
|
31
98
|
/**
|
|
32
99
|
* Parse one line of input from the REPL. The contract:
|
|
@@ -34,13 +101,15 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
34
101
|
* - Empty / whitespace-only input returns `noop` with the original
|
|
35
102
|
* text so the REPL can ignore it without printing anything.
|
|
36
103
|
* - Input that does not start with `/` is treated as an implicit
|
|
37
|
-
* `/brief <text>`
|
|
104
|
+
* `/brief <text>` — the most-common operator action.
|
|
38
105
|
* - `/<name> [args]` resolves the name against the registry; unknown
|
|
39
106
|
* names return `error` so the REPL can render a one-line tip
|
|
40
107
|
* instead of silently dropping the input.
|
|
108
|
+
* - Tier 3 stubs return `{ kind: 'stub', name, message }` so the REPL
|
|
109
|
+
* can render the deterministic "coming in αX.Y" copy uniformly.
|
|
41
110
|
*
|
|
42
111
|
* The function never throws. Bad input maps to a structured result the
|
|
43
|
-
* REPL can render
|
|
112
|
+
* REPL can render — the alternative (throwing from a keystroke handler)
|
|
44
113
|
* would unmount Ink mid-frame.
|
|
45
114
|
*/
|
|
46
115
|
export function parseSlashCommand(input) {
|
|
@@ -98,6 +167,40 @@ export function parseSlashCommand(input) {
|
|
|
98
167
|
}
|
|
99
168
|
return { kind: 'web', url: tail };
|
|
100
169
|
}
|
|
170
|
+
case 'clear':
|
|
171
|
+
case 'cls': {
|
|
172
|
+
return { kind: 'clear' };
|
|
173
|
+
}
|
|
174
|
+
case 'version':
|
|
175
|
+
case 'v': {
|
|
176
|
+
return { kind: 'version' };
|
|
177
|
+
}
|
|
178
|
+
case 'jobs': {
|
|
179
|
+
return { kind: 'jobs' };
|
|
180
|
+
}
|
|
181
|
+
case 'diff': {
|
|
182
|
+
return { kind: 'diff' };
|
|
183
|
+
}
|
|
184
|
+
case 'cost': {
|
|
185
|
+
return { kind: 'cost' };
|
|
186
|
+
}
|
|
187
|
+
case 'status': {
|
|
188
|
+
return { kind: 'status' };
|
|
189
|
+
}
|
|
190
|
+
case 'compact':
|
|
191
|
+
case 'resume':
|
|
192
|
+
case 'memory':
|
|
193
|
+
case 'config':
|
|
194
|
+
case 'privacy':
|
|
195
|
+
case 'budget':
|
|
196
|
+
case 'mcp':
|
|
197
|
+
case 'undo': {
|
|
198
|
+
return {
|
|
199
|
+
kind: 'stub',
|
|
200
|
+
name: name,
|
|
201
|
+
message: SLASH_STUB_MESSAGES[name],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
101
204
|
default: {
|
|
102
205
|
return {
|
|
103
206
|
kind: 'error',
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace context resolver — Sprint α6.14 wave 4.
|
|
3
|
+
*
|
|
4
|
+
* Reads the operator's cwd and synthesises the workspace bundle the CLI
|
|
5
|
+
* forwards to admin-api on POST /api/pugi/sessions. Mira's prompt v1.1
|
|
6
|
+
* consumes the bundle so "what repo is this?" / "а изучи репо…" answers
|
|
7
|
+
* from the live cwd instead of bouncing back "репо не привязано" (CEO
|
|
8
|
+
* dogfood 2026-05-25).
|
|
9
|
+
*
|
|
10
|
+
* Three fields:
|
|
11
|
+
*
|
|
12
|
+
* - `workspaceCwd` — absolute path the CLI was launched from.
|
|
13
|
+
* - `workspaceSlug` — a stable short identifier (slugForCwd).
|
|
14
|
+
* - `workspaceSummary` — first ~200 chars of `.pugi/PUGI.md` if the
|
|
15
|
+
* repo has one, else the directory basename.
|
|
16
|
+
*
|
|
17
|
+
* The helper is pure-ish (reads the filesystem but does not mutate it)
|
|
18
|
+
* so the production caller in `runtime/cli.ts` can call it eagerly at
|
|
19
|
+
* REPL launch without touching the network. Tests pass an explicit cwd
|
|
20
|
+
* + `fs` stub so the resolver stays deterministic.
|
|
21
|
+
*
|
|
22
|
+
* Failure mode: any FS error (permission denied, missing PUGI.md,
|
|
23
|
+
* symlink loop) returns the basename fallback. The CLI never bubbles
|
|
24
|
+
* the error to the operator — workspace context is a best-effort hint,
|
|
25
|
+
* not a precondition for opening the session.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import { basename, resolve as resolvePath } from 'node:path';
|
|
29
|
+
import { slugForCwd } from './history.js';
|
|
30
|
+
/** Cap on the PUGI.md head we forward. Mirrors the admin-api clamp. */
|
|
31
|
+
const PUGI_MD_HEAD_LIMIT = 200;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a `ReplWorkspaceContext` from the operator's working directory.
|
|
34
|
+
* Returns a bundle with at least `workspaceCwd` + `workspaceSlug` +
|
|
35
|
+
* `workspaceSummary` populated. The summary is the PUGI.md head when
|
|
36
|
+
* available, else the directory basename.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveWorkspaceContext(cwd) {
|
|
39
|
+
const normalised = resolvePath(cwd);
|
|
40
|
+
const slug = slugForCwd(normalised);
|
|
41
|
+
const summary = readPugiSummary(normalised) ?? basename(normalised) ?? 'workspace';
|
|
42
|
+
return {
|
|
43
|
+
workspaceCwd: normalised,
|
|
44
|
+
workspaceSlug: slug,
|
|
45
|
+
workspaceSummary: summary,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the first ~200 chars of `.pugi/PUGI.md` if the file exists. The
|
|
50
|
+
* project's own description is the highest-signal one-line summary we
|
|
51
|
+
* can hand to Mira — `pugi init` writes it on workspace creation, and
|
|
52
|
+
* the operator may have edited it since.
|
|
53
|
+
*
|
|
54
|
+
* Returns null on any FS error so the caller falls back to the
|
|
55
|
+
* basename. We never throw — workspace context is best-effort.
|
|
56
|
+
*/
|
|
57
|
+
function readPugiSummary(cwd) {
|
|
58
|
+
const candidate = resolvePath(cwd, '.pugi', 'PUGI.md');
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(candidate))
|
|
61
|
+
return null;
|
|
62
|
+
const st = statSync(candidate);
|
|
63
|
+
if (!st.isFile())
|
|
64
|
+
return null;
|
|
65
|
+
// Cap the read at 2 KB — even a malformed PUGI.md will not be
|
|
66
|
+
// bigger than that's worth on a one-line summary, and capping
|
|
67
|
+
// bounds the slice cost on a giant file.
|
|
68
|
+
const raw = readFileSync(candidate, 'utf8').slice(0, 2048);
|
|
69
|
+
return summariseMarkdown(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a PUGI.md head to one short summary line: strip the front-
|
|
77
|
+
* matter and the leading H1 marker, take the first non-empty line,
|
|
78
|
+
* cap at PUGI_MD_HEAD_LIMIT. Whitespace collapses to single spaces so
|
|
79
|
+
* the summary survives the admin-api clamp without weird wrap.
|
|
80
|
+
*/
|
|
81
|
+
export function summariseMarkdown(raw) {
|
|
82
|
+
if (!raw || raw.trim().length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const body = stripFrontmatter(raw);
|
|
85
|
+
const lines = body.split(/\r?\n/);
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
// Strip leading `#` markers + trim whitespace. A heading like
|
|
88
|
+
// "# My Project" becomes "My Project".
|
|
89
|
+
const stripped = line.replace(/^#+\s*/, '').trim();
|
|
90
|
+
if (stripped.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
const oneLine = stripped.replace(/\s+/g, ' ');
|
|
93
|
+
return oneLine.length > PUGI_MD_HEAD_LIMIT
|
|
94
|
+
? oneLine.slice(0, PUGI_MD_HEAD_LIMIT)
|
|
95
|
+
: oneLine;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drop a YAML front-matter block (`---\n…\n---`) from the head of a
|
|
101
|
+
* Markdown file. Mira does not need to see the metadata; the prose body
|
|
102
|
+
* carries the project description.
|
|
103
|
+
*/
|
|
104
|
+
function stripFrontmatter(raw) {
|
|
105
|
+
if (!raw.startsWith('---'))
|
|
106
|
+
return raw;
|
|
107
|
+
const end = raw.indexOf('\n---', 3);
|
|
108
|
+
if (end === -1)
|
|
109
|
+
return raw;
|
|
110
|
+
const afterFrontmatter = raw.slice(end + 4);
|
|
111
|
+
return afterFrontmatter.replace(/^\r?\n/, '');
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=workspace-context.js.map
|
package/dist/runtime/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ import { runBudgetCommand } from './commands/budget.js';
|
|
|
35
35
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
36
36
|
* three are in lockstep.
|
|
37
37
|
*/
|
|
38
|
-
const PUGI_CLI_VERSION = '0.1.0-alpha.
|
|
38
|
+
const PUGI_CLI_VERSION = '0.1.0-alpha.9';
|
|
39
39
|
const handlers = {
|
|
40
40
|
accounts,
|
|
41
41
|
build: runEngineTask('build_task'),
|
|
@@ -169,6 +169,7 @@ export async function runCli(argv) {
|
|
|
169
169
|
workspaceLabel: workspaceLabel(process.cwd()),
|
|
170
170
|
cliVersion: PUGI_CLI_VERSION,
|
|
171
171
|
updateBanner,
|
|
172
|
+
skipSplash: flags.noSplash,
|
|
172
173
|
});
|
|
173
174
|
return;
|
|
174
175
|
}
|
|
@@ -204,6 +205,7 @@ function parseArgs(argv) {
|
|
|
204
205
|
noTty: false,
|
|
205
206
|
allowFetch: false,
|
|
206
207
|
noUpdateCheck: false,
|
|
208
|
+
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
207
209
|
};
|
|
208
210
|
const args = [];
|
|
209
211
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -245,6 +247,9 @@ function parseArgs(argv) {
|
|
|
245
247
|
else if (arg === '--no-update-check') {
|
|
246
248
|
flags.noUpdateCheck = true;
|
|
247
249
|
}
|
|
250
|
+
else if (arg === '--no-splash') {
|
|
251
|
+
flags.noSplash = true;
|
|
252
|
+
}
|
|
248
253
|
else if (arg.startsWith('--privacy=')) {
|
|
249
254
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
250
255
|
}
|
|
@@ -303,6 +308,8 @@ async function help(_args, flags, _session) {
|
|
|
303
308
|
' recording flows, dumb terminals).',
|
|
304
309
|
' --no-update-check Silence the REPL startup update banner. Pairs',
|
|
305
310
|
' with PUGI_SKIP_UPDATE_BANNER=1.',
|
|
311
|
+
' --no-splash Skip the REPL boot splash. Pairs with',
|
|
312
|
+
' PUGI_SKIP_SPLASH=1.',
|
|
306
313
|
'',
|
|
307
314
|
PUGI_TAGLINE,
|
|
308
315
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
package/dist/tui/input-box.js
CHANGED
|
@@ -456,7 +456,7 @@ export function InputBox(props) {
|
|
|
456
456
|
: Math.min(paletteIndex, paletteView.rows.length - 1);
|
|
457
457
|
const divider = '─'.repeat(innerWidth);
|
|
458
458
|
const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
|
|
459
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, {
|
|
459
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
|
|
460
460
|
}
|
|
461
461
|
/**
|
|
462
462
|
* Render the line with the cursor glyph inserted at `cursor`. The cursor
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
import React from 'react';
|
|
20
20
|
import { render } from 'ink';
|
|
21
21
|
import { Repl } from './repl.js';
|
|
22
|
-
import { ReplSession } from '../core/repl/session.js';
|
|
22
|
+
import { ReplSession, } from '../core/repl/session.js';
|
|
23
|
+
import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
|
|
23
24
|
/**
|
|
24
25
|
* Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
|
|
25
26
|
* `/quit`. The session is closed (server-side stays alive; resume via
|
|
@@ -27,17 +28,27 @@ import { ReplSession } from '../core/repl/session.js';
|
|
|
27
28
|
*/
|
|
28
29
|
export async function renderRepl(options) {
|
|
29
30
|
const transport = createProductionTransport();
|
|
31
|
+
// Auto-bind the workspace context from process.cwd() so Mira knows
|
|
32
|
+
// which repo the operator launched the CLI in. The resolver is
|
|
33
|
+
// best-effort — any FS error falls back to a basename-only summary,
|
|
34
|
+
// never blocks REPL launch. Wave 4 fix 2026-05-25.
|
|
35
|
+
const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
|
|
30
36
|
const session = new ReplSession({
|
|
31
37
|
apiUrl: options.apiUrl,
|
|
32
38
|
apiKey: options.apiKey,
|
|
33
39
|
workspaceLabel: options.workspaceLabel,
|
|
34
40
|
cliVersion: options.cliVersion,
|
|
35
41
|
transport,
|
|
42
|
+
workspace,
|
|
36
43
|
});
|
|
37
44
|
// Kick off the connect; the Repl renders the connecting state until
|
|
38
45
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
39
46
|
void session.start();
|
|
40
|
-
const instance = render(React.createElement(Repl, {
|
|
47
|
+
const instance = render(React.createElement(Repl, {
|
|
48
|
+
session,
|
|
49
|
+
updateBanner: options.updateBanner ?? null,
|
|
50
|
+
skipSplash: options.skipSplash === true,
|
|
51
|
+
}));
|
|
41
52
|
try {
|
|
42
53
|
await instance.waitUntilExit();
|
|
43
54
|
}
|
|
@@ -50,11 +61,22 @@ export async function renderRepl(options) {
|
|
|
50
61
|
/* ------------------------------------------------------------------ */
|
|
51
62
|
function createProductionTransport() {
|
|
52
63
|
return {
|
|
53
|
-
async createSession({ apiUrl, apiKey }) {
|
|
64
|
+
async createSession({ apiUrl, apiKey, workspace }) {
|
|
65
|
+
// Forward the workspace bundle in the POST body so admin-api can
|
|
66
|
+
// surface `<workspace-context>` in Mira's prompt. Older admin-api
|
|
67
|
+
// builds ignore unknown fields, so this stays forward-compatible.
|
|
68
|
+
// Wave 4 fix 2026-05-25.
|
|
69
|
+
const body = {};
|
|
70
|
+
if (workspace?.workspaceCwd)
|
|
71
|
+
body.workspaceCwd = workspace.workspaceCwd;
|
|
72
|
+
if (workspace?.workspaceSlug)
|
|
73
|
+
body.workspaceSlug = workspace.workspaceSlug;
|
|
74
|
+
if (workspace?.workspaceSummary)
|
|
75
|
+
body.workspaceSummary = workspace.workspaceSummary;
|
|
54
76
|
const response = await fetch(joinUrl(apiUrl, '/api/pugi/sessions'), {
|
|
55
77
|
method: 'POST',
|
|
56
78
|
headers: jsonHeaders(apiKey),
|
|
57
|
-
body: JSON.stringify(
|
|
79
|
+
body: JSON.stringify(body),
|
|
58
80
|
});
|
|
59
81
|
const json = await readJson(response);
|
|
60
82
|
const sessionId = json.sessionId;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII pug mascot for the REPL boot splash (α6.14 wave 3).
|
|
3
|
+
*
|
|
4
|
+
* Hand-crafted at 9 rows × 20 columns to read as a pug at a single
|
|
5
|
+
* glance — references the cyber-zoo hero glyph in
|
|
6
|
+
* `apps/clawhost-web/public/brand/hero-pug.png`: blocky pug face with
|
|
7
|
+
* angular ear flaps on either side of the head, forehead crease,
|
|
8
|
+
* angular cyan eyes (`◉`), smushed snout, undershot jaw, and a small
|
|
9
|
+
* cyan circuit chip (`▐■▌`) on the lower-right cheek.
|
|
10
|
+
*
|
|
11
|
+
* Separation of art + cyan mask lets the unit test assert structure
|
|
12
|
+
* (row count, line widths, mask shape, at-least-one cyan pixel per
|
|
13
|
+
* eye row) without coupling to the Ink renderer. The renderer in
|
|
14
|
+
* `repl-splash.tsx` splits each row into runs and colors the masked
|
|
15
|
+
* columns cyan (#3DA9FC, brandbook §05).
|
|
16
|
+
*
|
|
17
|
+
* Convention:
|
|
18
|
+
* - PUG_MASCOT[i] = one row of the silhouette
|
|
19
|
+
* - PUG_MASCOT_CYAN_MASK[i] = parallel boolean array, true => that
|
|
20
|
+
* column renders cyan instead of gray
|
|
21
|
+
*
|
|
22
|
+
* Both arrays MUST stay the same length and each mask row MUST be the
|
|
23
|
+
* same length as the corresponding art row. A unit test enforces this.
|
|
24
|
+
*/
|
|
25
|
+
/* eslint-disable no-irregular-whitespace */
|
|
26
|
+
export const PUG_MASCOT = [
|
|
27
|
+
' ▄▀▀▀▄▄▄▀▀▀▄ ',
|
|
28
|
+
' █▄▄ ▄▄█ ',
|
|
29
|
+
' █ ▀▄▄▄▄▄▀ █ ',
|
|
30
|
+
' █ ◉ ◉ █ ',
|
|
31
|
+
' ▀▄ ▀█▀ ▄▀ ',
|
|
32
|
+
' █▀▀▀▀▀█ ',
|
|
33
|
+
' █▒▒▒▒▒█ ▐■▌ ',
|
|
34
|
+
' ▀▄▄▄▀ ',
|
|
35
|
+
' ▀ ',
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Cyan accents are derived from the source characters so the art file
|
|
39
|
+
* stays the single source of truth. Two glyph classes get colored:
|
|
40
|
+
* - `◉` -> the two cyan eyes on row 3
|
|
41
|
+
* - `▐■▌` -> the cyan chip cluster on row 6 (right cheek)
|
|
42
|
+
*
|
|
43
|
+
* Everything else renders gray. The derivation runs at module load,
|
|
44
|
+
* which keeps the mask trivially auditable from the source array.
|
|
45
|
+
*/
|
|
46
|
+
export const PUG_MASCOT_CYAN_MASK = PUG_MASCOT.map((row) => {
|
|
47
|
+
const mask = new Array(row.length).fill(false);
|
|
48
|
+
for (let column = 0; column < row.length; column += 1) {
|
|
49
|
+
const ch = row.charAt(column);
|
|
50
|
+
if (ch === '◉' || ch === '▐' || ch === '■' || ch === '▌') {
|
|
51
|
+
mask[column] = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return mask;
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* Pre-computed silhouette dimensions for layout math in the splash
|
|
58
|
+
* component. The unit test asserts these stay inside the documented
|
|
59
|
+
* envelope (≤22 chars wide, 9 ≤ rows ≤ 14) so a future edit can not
|
|
60
|
+
* silently bloat the terminal real estate.
|
|
61
|
+
*/
|
|
62
|
+
export const PUG_MASCOT_MAX_WIDTH = PUG_MASCOT.reduce((max, row) => Math.max(max, row.length), 0);
|
|
63
|
+
export const PUG_MASCOT_HEIGHT = PUG_MASCOT.length;
|
|
64
|
+
//# sourceMappingURL=repl-splash-art.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* REPL boot splash (α6.14 wave 3).
|
|
4
|
+
*
|
|
5
|
+
* Rendered on REPL first paint — before the conversation pane, before
|
|
6
|
+
* any operator input lands. Mirrors the Claude Code / Codex / Gemini
|
|
7
|
+
* CLI boot-screen aesthetic while staying Pugi-brand-pure:
|
|
8
|
+
*
|
|
9
|
+
* [PUG ASCII] Pugi.io v0.1.0-alphaN
|
|
10
|
+
* Plan: <plan>
|
|
11
|
+
* Model: <model>
|
|
12
|
+
* Tenant: <customerId>
|
|
13
|
+
* Workspace: <basename>
|
|
14
|
+
*
|
|
15
|
+
* ─────────────────────────────────────
|
|
16
|
+
* Tips for getting started:
|
|
17
|
+
* 1. Type a brief, the workforce dispatches
|
|
18
|
+
* 2. /help for slash commands, /web <url> to pull a page
|
|
19
|
+
* 3. /skills install <name> for Anthropic / OpenClaw skills
|
|
20
|
+
*
|
|
21
|
+
* The splash auto-dismisses on:
|
|
22
|
+
* - first operator keystroke (the REPL `<Repl />` host owns this and
|
|
23
|
+
* calls the `onInteract` callback we expose),
|
|
24
|
+
* - 10s idle timeout (built-in, configurable via `skipSplash`),
|
|
25
|
+
* - `--no-splash` CLI flag or PUGI_SKIP_SPLASH=1 env (host gates the
|
|
26
|
+
* mount entirely; we still respect the `skipSplash` prop as a belt
|
|
27
|
+
* so a stray render in a test environment produces nothing).
|
|
28
|
+
*
|
|
29
|
+
* Brand voice gate: every visible string here is reviewed against the
|
|
30
|
+
* forbidden list (`journey / explore / delight / magical / friendly /
|
|
31
|
+
* AI-powered / pug-tastic`). Power words used: `brief / dispatch /
|
|
32
|
+
* ship / workforce / sentinel / skills`. No em-dashes; box-drawing
|
|
33
|
+
* `─` is OK (matches existing REPL header conventions).
|
|
34
|
+
*/
|
|
35
|
+
import { useEffect } from 'react';
|
|
36
|
+
import { Box, Text } from 'ink';
|
|
37
|
+
import { PUG_MASCOT, PUG_MASCOT_CYAN_MASK, PUG_MASCOT_MAX_WIDTH, } from './repl-splash-art.js';
|
|
38
|
+
const DEFAULT_AUTO_DISMISS_MS = 10_000;
|
|
39
|
+
const PLACEHOLDER = '—';
|
|
40
|
+
export function ReplSplash(props) {
|
|
41
|
+
// Hooks MUST run unconditionally so the React reconciler can keep
|
|
42
|
+
// its hook order. We branch on `skipSplash` AFTER the effect
|
|
43
|
+
// declaration; the effect itself bails early when the splash is
|
|
44
|
+
// suppressed so no stray timer fires in the skip path.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (props.skipSplash)
|
|
47
|
+
return undefined;
|
|
48
|
+
const ms = props.autoDismissMs ?? DEFAULT_AUTO_DISMISS_MS;
|
|
49
|
+
const handle = setTimeout(() => {
|
|
50
|
+
props.onDismiss?.();
|
|
51
|
+
}, ms);
|
|
52
|
+
return () => clearTimeout(handle);
|
|
53
|
+
// Dependency on the onDismiss callback would re-arm the timer on
|
|
54
|
+
// every parent rerender; the host wraps it in useCallback so
|
|
55
|
+
// identity is stable for the splash's lifetime.
|
|
56
|
+
}, [props.autoDismissMs, props.onDismiss, props.skipSplash]);
|
|
57
|
+
// Belt for stray test renders: when the host already knows the
|
|
58
|
+
// operator opted out, we still want a render call to produce nothing
|
|
59
|
+
// visible. The host is the source of truth for mount-or-not; this is
|
|
60
|
+
// the no-op fallback.
|
|
61
|
+
if (props.skipSplash) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(MascotColumn, {}), _jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Renders the multi-line ASCII pug. Each row is split into colored
|
|
68
|
+
* runs based on `PUG_MASCOT_CYAN_MASK` so the eyes + chip come out
|
|
69
|
+
* cyan and the body stays gray. Pure render of the static art array;
|
|
70
|
+
* no IO, no state.
|
|
71
|
+
*/
|
|
72
|
+
function MascotColumn() {
|
|
73
|
+
return (_jsx(Box, { flexDirection: "column", minWidth: PUG_MASCOT_MAX_WIDTH, children: PUG_MASCOT.map((row, rowIndex) => (_jsx(MascotRow, { row: row, mask: PUG_MASCOT_CYAN_MASK[rowIndex] ?? [] }, rowIndex))) }));
|
|
74
|
+
}
|
|
75
|
+
function MascotRow({ row, mask, }) {
|
|
76
|
+
// Split the row into contiguous runs of same-color cells so we emit
|
|
77
|
+
// one <Text> per run instead of one per character. Keeps the Ink
|
|
78
|
+
// render tree shallow and the snapshot diff readable.
|
|
79
|
+
const runs = [];
|
|
80
|
+
let buffer = '';
|
|
81
|
+
let bufferCyan = false;
|
|
82
|
+
for (let column = 0; column < row.length; column += 1) {
|
|
83
|
+
const ch = row.charAt(column);
|
|
84
|
+
const cyan = mask[column] === true;
|
|
85
|
+
if (buffer.length === 0) {
|
|
86
|
+
buffer = ch;
|
|
87
|
+
bufferCyan = cyan;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (cyan === bufferCyan) {
|
|
91
|
+
buffer += ch;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
runs.push({ text: buffer, cyan: bufferCyan });
|
|
95
|
+
buffer = ch;
|
|
96
|
+
bufferCyan = cyan;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (buffer.length > 0) {
|
|
100
|
+
runs.push({ text: buffer, cyan: bufferCyan });
|
|
101
|
+
}
|
|
102
|
+
return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: "cyan", children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
|
|
103
|
+
}
|
|
104
|
+
function HeaderRow({ label, value }) {
|
|
105
|
+
const padded = `${label}:`.padEnd(11, ' ');
|
|
106
|
+
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: padded }), _jsx(Text, { children: value })] }));
|
|
107
|
+
}
|
|
108
|
+
function TipRow({ index, text }) {
|
|
109
|
+
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${index}. ` }), _jsx(Text, { children: text })] }));
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=repl-splash.js.map
|
package/dist/tui/repl.js
CHANGED
|
@@ -23,10 +23,12 @@ import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
|
|
|
23
23
|
import { AgentTree } from './agent-tree.js';
|
|
24
24
|
import { ConversationPane } from './conversation-pane.js';
|
|
25
25
|
import { InputBox } from './input-box.js';
|
|
26
|
+
import { ReplSplash } from './repl-splash.js';
|
|
26
27
|
import { StatusBar } from './status-bar.js';
|
|
27
28
|
import { UpdateBanner } from './update-banner.js';
|
|
29
|
+
import { collectWorkspaceContext } from './workspace-context.js';
|
|
28
30
|
import { slugForCwd } from '../core/repl/history.js';
|
|
29
|
-
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
31
|
+
import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
|
|
30
32
|
const TICK_INTERVAL_MS = 200;
|
|
31
33
|
const PULSE_INTERVAL_MS = 700;
|
|
32
34
|
export function Repl(props) {
|
|
@@ -34,6 +36,15 @@ export function Repl(props) {
|
|
|
34
36
|
const [overlay, setOverlay] = useState('none');
|
|
35
37
|
const [pulsePhase, setPulsePhase] = useState(0);
|
|
36
38
|
const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
|
|
39
|
+
// α6.14 wave 3: boot splash visible until first input, first
|
|
40
|
+
// `agent.spawned` event, or 10s idle. The host gates the initial
|
|
41
|
+
// visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
|
|
42
|
+
const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
|
|
43
|
+
const dismissSplash = useCallback(() => setSplashVisible(false), []);
|
|
44
|
+
// α6.14 wave 3: workspace context snapshot for the status bar. We
|
|
45
|
+
// read once at mount and freeze; a brand-new PUGI.md or skill is
|
|
46
|
+
// surfaced on the next REPL boot rather than via a watcher.
|
|
47
|
+
const workspaceContext = useMemo(() => props.workspaceContext ?? collectWorkspaceContext(process.cwd()), [props.workspaceContext]);
|
|
37
48
|
// Subscribe to session state updates. The session module fires the
|
|
38
49
|
// callback synchronously inside `patch` so we mirror without a
|
|
39
50
|
// batching layer.
|
|
@@ -62,9 +73,24 @@ export function Repl(props) {
|
|
|
62
73
|
useEffect(() => {
|
|
63
74
|
props.onOverlayChange?.(overlay);
|
|
64
75
|
}, [overlay, props]);
|
|
76
|
+
// α6.14 wave 3: dismiss the boot splash once the first agent spawns
|
|
77
|
+
// (the operator has clearly engaged the system) or the transcript
|
|
78
|
+
// gains a row. Mirrors the natural attention shift Claude Code /
|
|
79
|
+
// Codex / Gemini CLI all do on their boot screens.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!splashVisible)
|
|
82
|
+
return;
|
|
83
|
+
if (state.agents.length > 0 || state.transcript.length > 0) {
|
|
84
|
+
setSplashVisible(false);
|
|
85
|
+
}
|
|
86
|
+
}, [splashVisible, state.agents.length, state.transcript.length]);
|
|
65
87
|
const personaNames = useMemo(() => buildPersonaNameMap(), []);
|
|
66
88
|
const { exit } = useApp();
|
|
67
89
|
const handleSubmit = useCallback((line) => {
|
|
90
|
+
// Dismiss the boot splash on first operator input. Idempotent —
|
|
91
|
+
// `setSplashVisible(false)` is a no-op once the state already
|
|
92
|
+
// settled to false (timer fired or `agent.spawned` arrived).
|
|
93
|
+
setSplashVisible(false);
|
|
68
94
|
// Run async without awaiting - the session module owns the
|
|
69
95
|
// network call, errors land in the transcript automatically.
|
|
70
96
|
void props.session.handleInput(line).then((verdict) => {
|
|
@@ -96,11 +122,11 @@ export function Repl(props) {
|
|
|
96
122
|
setOverlay('none');
|
|
97
123
|
}
|
|
98
124
|
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
99
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
100
126
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
101
127
|
// the same basename do not share history. state.workspaceLabel
|
|
102
128
|
// is the basename only. Codex review P2.
|
|
103
|
-
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
|
|
129
|
+
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase, pugiMdCount: workspaceContext.pugiMdCount, mcpServerCount: workspaceContext.mcpServerCount, skillCount: workspaceContext.skillCount, quotaPct: props.quotaPct })] })] }));
|
|
104
130
|
}
|
|
105
131
|
function Header({ state }) {
|
|
106
132
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
|
@@ -114,7 +140,27 @@ function MainArea({ state, personaNames, nowEpochMs, }) {
|
|
|
114
140
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), _jsx(Box, { marginTop: 1, children: _jsx(AgentTree, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
|
|
115
141
|
}
|
|
116
142
|
function HelpOverlay() {
|
|
117
|
-
|
|
143
|
+
// Group commands by their `group` field so the operator scans the
|
|
144
|
+
// palette by intent (dispatch → session → tools → settings → meta).
|
|
145
|
+
// The α6.14 wave-2 expansion grew the surface from 6 to 20 commands;
|
|
146
|
+
// a flat list would force the operator to read 20 rows top-to-bottom
|
|
147
|
+
// every time. Grouping cuts perceived complexity dramatically.
|
|
148
|
+
const grouped = new Map();
|
|
149
|
+
for (const row of SLASH_COMMAND_HELP) {
|
|
150
|
+
const list = grouped.get(row.group);
|
|
151
|
+
if (list) {
|
|
152
|
+
grouped.set(row.group, [...list, row]);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
grouped.set(row.group, [row]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), SLASH_COMMAND_GROUPS.map((group) => {
|
|
159
|
+
const rows = grouped.get(group);
|
|
160
|
+
if (!rows || rows.length === 0)
|
|
161
|
+
return null;
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
|
|
163
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
|
|
118
164
|
}
|
|
119
165
|
function RosterOverlay() {
|
|
120
166
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "On-watch roster" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: THE_TEN.map((persona) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: ` ${persona.name.padEnd(10, ' ')}` }), _jsx(Text, { dimColor: true, children: `${persona.role.padEnd(20, ' ')}` }), _jsx(Text, { children: persona.oneLiner })] }, persona.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
|
|
@@ -138,6 +184,15 @@ function applyVerdictSideEffects(verdict, handlers) {
|
|
|
138
184
|
case 'web':
|
|
139
185
|
case 'error':
|
|
140
186
|
case 'noop':
|
|
187
|
+
case 'clear':
|
|
188
|
+
case 'version':
|
|
189
|
+
case 'jobs':
|
|
190
|
+
case 'diff':
|
|
191
|
+
case 'cost':
|
|
192
|
+
case 'status':
|
|
193
|
+
case 'stub':
|
|
194
|
+
// All non-overlay verdicts: the session module already appended
|
|
195
|
+
// any operator-visible system lines. No further UI side effect.
|
|
141
196
|
return;
|
|
142
197
|
}
|
|
143
198
|
}
|
|
@@ -3,16 +3,21 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
4
4
|
export const PALETTE_ROW_LIMIT = 8;
|
|
5
5
|
/**
|
|
6
|
-
* Compute the
|
|
6
|
+
* Compute the FULL filtered candidate list for a given input buffer.
|
|
7
7
|
* Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
|
|
8
8
|
* logic so the input box and the unit test agree on the shape.
|
|
9
9
|
*
|
|
10
|
+
* Wave 4 fix 2026-05-25: returns the FULL filtered set, not just the
|
|
11
|
+
* first PALETTE_ROW_LIMIT rows. The palette renderer now windows the
|
|
12
|
+
* visible slice internally based on `focusedIndex`, so the operator
|
|
13
|
+
* can scroll past row 7 via ↑/↓ on a long list (e.g. 20 commands when
|
|
14
|
+
* the buffer is `/`). `totalBeforeLimit` is preserved on the return
|
|
15
|
+
* shape for backward compatibility but always equals `rows.length`.
|
|
16
|
+
*
|
|
10
17
|
* Behaviour:
|
|
11
18
|
* - Empty / non-slash buffer → empty result; palette stays hidden.
|
|
12
19
|
* - `/` alone → all registry rows (the operator wants to browse).
|
|
13
20
|
* - `/he` → rows whose name starts with `he` (case-insensitive).
|
|
14
|
-
* - Capped at PALETTE_ROW_LIMIT; the input box renders a hint when
|
|
15
|
-
* `totalBeforeLimit > rows.length`.
|
|
16
21
|
*/
|
|
17
22
|
export function filterPalette(buffer) {
|
|
18
23
|
if (!buffer.startsWith('/')) {
|
|
@@ -31,9 +36,33 @@ export function filterPalette(buffer) {
|
|
|
31
36
|
const all = prefix.length === 0
|
|
32
37
|
? SLASH_COMMAND_HELP
|
|
33
38
|
: SLASH_COMMAND_HELP.filter((row) => row.name.toLowerCase().startsWith(prefix));
|
|
39
|
+
// Defensive copy via spread so callers cannot mutate the registry
|
|
40
|
+
// through the returned readonly array (TS-only enforcement, but
|
|
41
|
+
// future refactors might assume the contract).
|
|
42
|
+
const rows = [...all];
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
totalBeforeLimit: rows.length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function computePaletteWindow(rows, focusedIndex) {
|
|
49
|
+
const total = rows.length;
|
|
50
|
+
if (total <= PALETTE_ROW_LIMIT) {
|
|
51
|
+
return { visible: rows, startIndex: 0, total };
|
|
52
|
+
}
|
|
53
|
+
// Sliding window: anchor the start so the focused row stays inside the
|
|
54
|
+
// PALETTE_ROW_LIMIT span. Clamp at both ends so we never render fewer
|
|
55
|
+
// than PALETTE_ROW_LIMIT rows when the list is long enough to fill them.
|
|
56
|
+
let start = focusedIndex - Math.floor(PALETTE_ROW_LIMIT / 2);
|
|
57
|
+
if (start < 0)
|
|
58
|
+
start = 0;
|
|
59
|
+
const maxStart = total - PALETTE_ROW_LIMIT;
|
|
60
|
+
if (start > maxStart)
|
|
61
|
+
start = maxStart;
|
|
34
62
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
|
|
64
|
+
startIndex: start,
|
|
65
|
+
total,
|
|
37
66
|
};
|
|
38
67
|
}
|
|
39
68
|
/**
|
|
@@ -57,13 +86,21 @@ export function completePalette(buffer, rows, focusedIndex) {
|
|
|
57
86
|
export function SlashPalette(props) {
|
|
58
87
|
if (props.rows.length === 0)
|
|
59
88
|
return null;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
// Wave 4 fix 2026-05-25: compute the visible window so the operator
|
|
90
|
+
// can scroll past row 7 on long lists. Focus indexes the full rows
|
|
91
|
+
// array; the window slides to keep the focused row visible.
|
|
92
|
+
const window = computePaletteWindow(props.rows, props.focusedIndex);
|
|
93
|
+
const overflow = window.total > PALETTE_ROW_LIMIT;
|
|
94
|
+
// Indicator value: focused row is 1-based for human display
|
|
95
|
+
// ("→ 9/20" reads better than "→ 8/20" when the operator is on
|
|
96
|
+
// the ninth entry).
|
|
97
|
+
const focusedDisplayIndex = Math.min(window.total, Math.max(1, props.focusedIndex + 1));
|
|
98
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [window.visible.map((row, visibleIdx) => {
|
|
99
|
+
const absoluteIdx = window.startIndex + visibleIdx;
|
|
100
|
+
const focused = absoluteIdx === props.focusedIndex;
|
|
64
101
|
const glyph = focused ? '▸' : '·';
|
|
65
102
|
const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
|
|
66
103
|
return (_jsxs(Box, { children: [_jsx(Text, { color: focused ? 'cyan' : 'gray', children: `${glyph} ` }), _jsx(Text, { bold: focused, color: focused ? 'cyan' : undefined, dimColor: !focused, children: cmd }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name));
|
|
67
|
-
}), overflow
|
|
104
|
+
}), overflow ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` → ${focusedDisplayIndex}/${window.total}` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
|
|
68
105
|
}
|
|
69
106
|
//# sourceMappingURL=slash-palette.js.map
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -13,7 +13,20 @@ export function StatusBar(props) {
|
|
|
13
13
|
const phase = clampPhase(props.pulsePhase);
|
|
14
14
|
const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
|
|
15
15
|
const status = connectionLabel(props.connection);
|
|
16
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }));
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Render a count badge — number if defined, `—` placeholder otherwise.
|
|
20
|
+
* The placeholder mirrors the splash header convention so the operator
|
|
21
|
+
* recognises "not yet known" vs "zero" at a glance.
|
|
22
|
+
*/
|
|
23
|
+
function formatCount(value) {
|
|
24
|
+
return typeof value === 'number' ? value.toString() : '—';
|
|
25
|
+
}
|
|
26
|
+
function formatQuota(pct) {
|
|
27
|
+
if (typeof pct !== 'number' || Number.isNaN(pct))
|
|
28
|
+
return '—';
|
|
29
|
+
return `${Math.round(pct)}%`;
|
|
17
30
|
}
|
|
18
31
|
function connectionLabel(connection) {
|
|
19
32
|
switch (connection) {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-context badges for the REPL bottom status bar (α6.14
|
|
3
|
+
* wave 3). Mirrors the Gemini CLI pattern where the operator sees
|
|
4
|
+
* `N GEMINI.md · N MCP · N skills · N% quota` at a glance.
|
|
5
|
+
*
|
|
6
|
+
* Pure-IO helpers: each function reads disk once, swallows every error
|
|
7
|
+
* (a missing directory is the common case for a fresh workspace), and
|
|
8
|
+
* returns a count. The REPL host calls these at mount, caches the
|
|
9
|
+
* result in component state, and passes them to `<StatusBar />`. We
|
|
10
|
+
* intentionally do NOT refresh on every keystroke — a brand-new
|
|
11
|
+
* PUGI.md does not appear mid-session often enough to warrant a
|
|
12
|
+
* watcher.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Count PUGI.md files in the workspace root. We do NOT walk
|
|
19
|
+
* subdirectories — a deep grep would burn IO on every REPL boot and
|
|
20
|
+
* the convention is one root file. Mirrors how Gemini CLI counts
|
|
21
|
+
* `GEMINI.md` at the project root only.
|
|
22
|
+
*/
|
|
23
|
+
export function countPugiMdFiles(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isFile())
|
|
29
|
+
continue;
|
|
30
|
+
// Case-insensitive so PUGI.md, Pugi.md, pugi.md all count.
|
|
31
|
+
if (entry.name.toLowerCase() === 'pugi.md')
|
|
32
|
+
count += 1;
|
|
33
|
+
}
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Count MCP servers wired into `.pugi/mcp.json` at the workspace root.
|
|
42
|
+
* Reads the file, parses JSON, returns the `servers` array length when
|
|
43
|
+
* present. Returns 0 on any failure (file missing, malformed JSON,
|
|
44
|
+
* wrong shape) — the status bar treats 0 and "error" the same.
|
|
45
|
+
*/
|
|
46
|
+
export function countMcpServers(cwd) {
|
|
47
|
+
const path = join(cwd, '.pugi', 'mcp.json');
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return 0;
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(path, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed && typeof parsed === 'object') {
|
|
54
|
+
const servers = parsed.servers;
|
|
55
|
+
if (Array.isArray(servers))
|
|
56
|
+
return servers.length;
|
|
57
|
+
// Also support the `{ "<name>": { ... } }` map shape used by
|
|
58
|
+
// the Anthropic / Claude Code mcp config convention.
|
|
59
|
+
const entries = Object.keys(parsed);
|
|
60
|
+
return entries.length;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Count installed skills across the project-local + user-global
|
|
70
|
+
* directories: `.pugi/skills/` (per-workspace) + `~/.pugi/skills/`
|
|
71
|
+
* (per-machine). Each immediate subdirectory counts as one skill;
|
|
72
|
+
* matches the `skill-creator` convention.
|
|
73
|
+
*/
|
|
74
|
+
export function countSkills(cwd, home = homedir()) {
|
|
75
|
+
const projectDir = resolve(cwd, '.pugi', 'skills');
|
|
76
|
+
const userDir = resolve(home, '.pugi', 'skills');
|
|
77
|
+
return countSubdirs(projectDir) + countSubdirs(userDir);
|
|
78
|
+
}
|
|
79
|
+
function countSubdirs(dir) {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(dir))
|
|
82
|
+
return 0;
|
|
83
|
+
const stat = statSync(dir);
|
|
84
|
+
if (!stat.isDirectory())
|
|
85
|
+
return 0;
|
|
86
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
87
|
+
let count = 0;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.isDirectory())
|
|
90
|
+
count += 1;
|
|
91
|
+
}
|
|
92
|
+
return count;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function collectWorkspaceContext(cwd, home = homedir()) {
|
|
99
|
+
return {
|
|
100
|
+
pugiMdCount: countPugiMdFiles(cwd),
|
|
101
|
+
mcpServerCount: countMcpServers(cwd),
|
|
102
|
+
skillCount: countSkills(cwd, home),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=workspace-context.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.9",
|
|
4
4
|
"description": "Pugi CLI — terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"undici": "^8.3.0",
|
|
48
48
|
"zod": "^3.23.0",
|
|
49
49
|
"@pugi/personas": "0.1.0",
|
|
50
|
-
"@pugi/sdk": "0.1.0-alpha.
|
|
50
|
+
"@pugi/sdk": "0.1.0-alpha.9"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.0.0",
|