@pugi/cli 0.1.0-alpha.8 → 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.
@@ -96,6 +96,7 @@ export class ReplSession {
96
96
  const { sessionId } = await this.options.transport.createSession({
97
97
  apiUrl: this.options.apiUrl,
98
98
  apiKey: this.options.apiKey,
99
+ workspace: this.options.workspace,
99
100
  });
100
101
  this.patch({ sessionId, connection: 'connecting' });
101
102
  this.openStream();
@@ -137,7 +138,11 @@ export class ReplSession {
137
138
  // UI overlays - no transport interaction.
138
139
  return verdict;
139
140
  case 'quit':
140
- this.appendSystemLine('Brief it. It ships.');
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.');
141
146
  return verdict;
142
147
  case 'error':
143
148
  this.appendSystemLine(verdict.message);
@@ -403,6 +408,20 @@ export class ReplSession {
403
408
  switch (event.type) {
404
409
  case 'agent.spawned': {
405
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);
406
425
  const node = {
407
426
  taskId: event.taskId,
408
427
  role: event.role,
@@ -414,7 +433,14 @@ export class ReplSession {
414
433
  tokensIn: 0,
415
434
  tokensOut: 0,
416
435
  };
417
- this.patch({ agents: [node, ...this.state.agents] });
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
+ }
418
444
  // The conversation pane already prefixes persona rows with the
419
445
  // persona name in the persona's hue colour. Skip embedding the
420
446
  // name in the body text to avoid the `Marcus Marcus dispatched`
@@ -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
@@ -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.8';
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.',
@@ -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, { session, updateBanner: options.updateBanner ?? null }));
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,8 +23,10 @@ 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
31
  import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
30
32
  const TICK_INTERVAL_MS = 200;
@@ -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('_', ' ') })] }));
@@ -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 visible palette window for a given input buffer.
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
- rows: all.slice(0, PALETTE_ROW_LIMIT),
36
- totalBeforeLimit: all.length,
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
- const total = props.totalBeforeLimit ?? props.rows.length;
61
- const overflow = total - props.rows.length;
62
- return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [props.rows.map((row, idx) => {
63
- const focused = idx === props.focusedIndex;
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 > 0 ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` · ${overflow} more (keep typing to narrow)` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
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
@@ -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.8",
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.8"
50
+ "@pugi/sdk": "0.1.0-alpha.9"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.0.0",