@pugi/cli 0.1.0-beta.46 → 0.1.0-beta.47

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.
@@ -0,0 +1,9 @@
1
+ [?25l 
2
+ 
3
+ ▄▄▄▄▄▄▄▄ 
4
+ ▄▄▄▄▄▄ 
5
+ ▄▄▄▄▄▄ 
6
+ ▄▄ 
7
+ ▄ 
8
+ 
9
+ [?25h
@@ -32,7 +32,6 @@ import { runStyleCommand } from './commands/style.js';
32
32
  import { runThemeCommand } from './commands/theme.js';
33
33
  import { runOnboardingCommand } from './commands/onboarding.js';
34
34
  import { runVimCommand } from './commands/vim.js';
35
- import { isOnboarded } from '../core/onboarding/marker.js';
36
35
  import { ensureInitialized as ensureInitializedHelper } from '../core/onboarding/ensure-initialized.js';
37
36
  import { ensureAuthenticated as ensureAuthenticatedHelper } from '../core/auth/ensure-authenticated.js';
38
37
  import { runPrivacyCommand } from './commands/privacy.js';
@@ -1140,19 +1139,22 @@ export async function runCli(argv) {
1140
1139
  process.exitCode = exitCode;
1141
1140
  return;
1142
1141
  }
1143
- // Leak L25 (2026-05-27): first-run hint. When the operator types a
1144
- // bare `pugi` on a real TTY AND the onboarding marker is absent, drop
1145
- // a one-line hint on stderr BEFORE the REPL splash mounts. Stderr so
1146
- // the line never lands in a `--json` envelope or a scripted stdout
1147
- // pipe; suppressed when --json is set or the operator already walked
1148
- // the wizard. The marker check is best-effort a fs glitch returns
1149
- // false and we print the hint, which is harmless.
1150
- if (isBareInvocation
1151
- && isInteractive(flags)
1152
- && !flags.json
1153
- && !isOnboarded(process.env)) {
1154
- process.stderr.write('Tip: run `pugi onboarding` to configure defaults.\n');
1155
- }
1142
+ // CEO P0 escalation #2 (2026-05-29) boot polish.
1143
+ //
1144
+ // Leak L25 (2026-05-27) used к drop a one-line stderr hint
1145
+ // ("Tip: run `pugi onboarding` to configure defaults.") BEFORE the
1146
+ // REPL mounted. Combined with beta.46's surfaced "(Y/n)" prompt the
1147
+ // boot read as a noisy three-line vault door instead of Claude
1148
+ // Code's silent, friendly welcome card. The new `WelcomeBanner` in
1149
+ // the REPL surfaces the "/init" tip inline (right column) so this
1150
+ // pre-Ink stderr write is redundant — suppress it on the bare REPL
1151
+ // path. The hint stays available for any future non-REPL bare entry
1152
+ // (no current callers, but keep the helper for symmetry).
1153
+ //
1154
+ // Leaving the suppression here as a hard branch (no env override)
1155
+ // because the welcome banner is the authoritative surface — a stray
1156
+ // stderr line above the alt-screen flicker would race against the
1157
+ // banner paint on slow terminals.
1156
1158
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
1157
1159
  // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
1158
1160
  // that brings Pugi to parity with Claude Code / Codex CLI. When the
@@ -1168,25 +1170,32 @@ export async function runCli(argv) {
1168
1170
  // Propagating via env keeps the session module transport-free.
1169
1171
  if (flags.allowFetch)
1170
1172
  process.env.PUGI_ALLOW_FETCH = '1';
1171
- // CEO P0 (2026-05-28): auto-init pre-flight on the bare REPL
1172
- // boot path. PR #628 wired `runAutoInitPreflight` into every
1173
- // engine command (`pugi code/fix/build/...`) but the bare
1174
- // `pugi` REPL entry boots straight into the workspace label
1175
- // resolver which surfaces the "(not bound — run /init OR cd
1176
- // into project)" banner without ever asking the operator
1177
- // whether к initialise the workspace. CEO escalation: closed
1178
- // multiple times wrong; the operator hits the banner and walks
1179
- // away thinking Pugi is broken. We use the REPL-tuned variant
1180
- // that NEVER throws `n`, `--bare`, `--no-init`, non-TTY all
1181
- // fall through к the legacy "not bound" banner so the existing
1182
- // contract (booting REPL still works without `.pugi/`) holds.
1183
- // The prompt fires ONLY on the happy path: interactive TTY,
1184
- // no `.pugi/`, no opt-out flag. Wrapped in a defensive try/
1185
- // catch the scaffold may legitimately fail (read-only fs,
1186
- // permission denied) but the REPL still needs to boot so the
1187
- // operator gets a usable surface к diagnose the failure.
1173
+ // CEO P0 escalation #2 (2026-05-29) — silent auto-init.
1174
+ //
1175
+ // PR #628 + the first P0 fix wired `runReplAutoInitPreflight`
1176
+ // into the bare REPL boot path. That helper PROMPTED
1177
+ // "Initialize a new Pugi workspace here? (Y/n)" before mounting
1178
+ // Ink, which on a TTY in a fresh dir read as a noisy gate the
1179
+ // operator had to consciously accept just к see Pugi. The CEO
1180
+ // dogfood transcript called this out as visually broken vs
1181
+ // Claude Code, which silently inits and surfaces "/init to
1182
+ // create CLAUDE.md" as an inline tip in the welcome banner.
1183
+ //
1184
+ // The fix swaps the prompt variant for `runReplSilentInitPreflight`
1185
+ // which scaffolds inline без prompt on the happy path
1186
+ // (interactive TTY, project root, no opt-out flag). The
1187
+ // welcome banner (rendered by Ink immediately after) surfaces
1188
+ // a one-line "Initialised Pugi workspace" toast so the
1189
+ // operator still sees the side-effect just без a Y/N stop.
1190
+ //
1191
+ // Opt-outs (`--bare`, `--no-init`, `PUGI_BARE`, `PUGI_NO_AUTO_INIT`)
1192
+ // and non-TTY fall-through preserved verbatim from the prompt
1193
+ // variant. Wrapped in try/catch — a scaffold failure (read-only
1194
+ // fs, perms) still lets the REPL boot so the operator can
1195
+ // diagnose.
1196
+ let silentInitOutcome = null;
1188
1197
  try {
1189
- await runReplAutoInitPreflight(process.cwd(), flags);
1198
+ silentInitOutcome = await runReplSilentInitPreflight(process.cwd(), flags);
1190
1199
  }
1191
1200
  catch (error) {
1192
1201
  // Surface the scaffold error on stderr but proceed to mount
@@ -1225,6 +1234,11 @@ export async function runCli(argv) {
1225
1234
  updateBanner,
1226
1235
  skipSplash: flags.noSplash,
1227
1236
  hideToolStream: flags.noToolStream,
1237
+ // CEO P0 #2 (2026-05-29): forward the silent-init outcome so
1238
+ // the welcome banner can surface a one-line toast on the
1239
+ // "initialized" branch ("Pugi workspace initialised at .pugi/.")
1240
+ // and skip the toast on the "already" / "declined" branches.
1241
+ autoInitStatus: silentInitOutcome?.status ?? null,
1228
1242
  });
1229
1243
  return;
1230
1244
  }
@@ -6520,6 +6534,51 @@ export async function runReplAutoInitPreflight(root, flags, overrides = {}) {
6520
6534
  }),
6521
6535
  });
6522
6536
  }
6537
+ export async function runReplSilentInitPreflight(root, flags, overrides = {}) {
6538
+ const interactive = overrides.interactive ?? isInteractive(flags);
6539
+ return ensureInitializedHelper({
6540
+ cwd: root,
6541
+ interactive,
6542
+ skip: flags.noInit
6543
+ || flags.bare
6544
+ || process.env.PUGI_NO_AUTO_INIT === '1'
6545
+ || process.env.PUGI_BARE === '1',
6546
+ // CC-style silent init: the prompt callback returns 'y' immediately
6547
+ // so the helper proceeds straight to scaffold. The helper's
6548
+ // contract treats empty / 'y' / 'yes' as the default-Y answer, so
6549
+ // returning 'y' is the canonical way to drive the happy path
6550
+ // without surfacing the (Y/n) string on stderr. The
6551
+ // `write` callback is silenced (no-op) so the helper does not
6552
+ // print the legacy "No Pugi workspace found at ..." line either —
6553
+ // the welcome banner surfaces the post-scaffold success toast
6554
+ // instead. Note: the helper's `write` ALSO swallows the
6555
+ // "Initialization declined" footer, but that branch never fires
6556
+ // here because our prompt always returns 'y'.
6557
+ prompt: async () => 'y',
6558
+ write: () => {
6559
+ /* silent — banner owns the operator-visible signal */
6560
+ },
6561
+ scaffold: overrides.scaffold
6562
+ ?? (async (input) => {
6563
+ // CEO P0 #2 (2026-05-29): forward а no-op `log` callback so
6564
+ // the default-skills installer ("[pugi init] installed default
6565
+ // skill …") does not leak к stderr above the Ink welcome
6566
+ // banner. The welcome banner already surfaces the one-line
6567
+ // "Pugi workspace initialised at .pugi/." toast — the per-
6568
+ // skill detail is noise on the cold-start path и kills the
6569
+ // CC-style silent boot the operator expects. The non-silent
6570
+ // `pugi init` command remains noisy by passing the default
6571
+ // stderr writer.
6572
+ await scaffoldPugiWorkspace({
6573
+ cwd: input.cwd,
6574
+ noDefaults: flags.noDefaults,
6575
+ log: () => {
6576
+ /* silent — banner owns the operator-visible signal */
6577
+ },
6578
+ });
6579
+ }),
6580
+ });
6581
+ }
6523
6582
  /**
6524
6583
  * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6525
6584
  * `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.46');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.47');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -83,6 +83,13 @@ export function InputBox(props) {
83
83
  const [history, setHistory] = useState(seededHistory);
84
84
  const [historyIndex, setHistoryIndex] = useState(-1);
85
85
  const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
86
+ // CEO P0 #2 (2026-05-29): Claude Code parity — surface а visible
87
+ // "Press Ctrl+C again to exit" toast on the first Ctrl+C press so
88
+ // the operator knows the second press will terminate the REPL.
89
+ // Auto-clears after CTRL_C_DOUBLE_TAP_MS so it never lingers past
90
+ // the double-tap window.
91
+ const [ctrlCToast, setCtrlCToast] = useState(null);
92
+ const ctrlCToastTimerRef = useRef(null);
86
93
  // Wave 6 BT 8: Esc-Esc walkback double-tap window. Tracks the epoch
87
94
  // ms of the most recent Esc press so the next Esc within
88
95
  // ESCAPE_DOUBLE_TAP_MS triggers the walkback handler instead of
@@ -191,6 +198,28 @@ export function InputBox(props) {
191
198
  return;
192
199
  }
193
200
  setLastCtrlCAt(t);
201
+ // CEO P0 #2 (2026-05-29): surface the "Press Ctrl+C again to
202
+ // exit" toast on the first press so the operator sees the
203
+ // double-tap semantics in the UI, not just в the bottom hint
204
+ // line. Mirrors Claude Code's exit affordance verbatim. The
205
+ // toast string varies by which branch fired (cancel vs idle
206
+ // clear) so the operator learns what the press just did:
207
+ //
208
+ // - cancelResult === true → "Aborted. Press Ctrl+C again to exit."
209
+ // - cancelResult === false → "Press Ctrl+C again to exit."
210
+ //
211
+ // (The undefined branch already returned above — а modal owns
212
+ // input и the toast is suppressed.)
213
+ const toastCopy = cancelResult === true
214
+ ? 'Aborted. Press Ctrl+C again to exit.'
215
+ : 'Press Ctrl+C again to exit.';
216
+ setCtrlCToast(toastCopy);
217
+ if (ctrlCToastTimerRef.current)
218
+ clearTimeout(ctrlCToastTimerRef.current);
219
+ ctrlCToastTimerRef.current = setTimeout(() => {
220
+ setCtrlCToast(null);
221
+ ctrlCToastTimerRef.current = null;
222
+ }, CTRL_C_DOUBLE_TAP_MS);
194
223
  // Legacy behaviour: on idle (or no onCancel wired), clear the
195
224
  // buffer + reset search so the operator's screen is calm before
196
225
  // they confirm exit. When we DID cancel a live dispatch, keep
@@ -685,7 +714,7 @@ export function InputBox(props) {
685
714
  : Math.min(paletteIndex, paletteView.rows.length - 1);
686
715
  const divider = '─'.repeat(innerWidth);
687
716
  const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
688
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", 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: "#3da9fc", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), modeCycleToast ? (_jsx(Box, { children: _jsx(Text, { color: "#3da9fc", bold: true, children: ` ${modeCycleToast}` }) })) : null, 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 · Shift+Tab mode · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
717
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", 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: "#3da9fc", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "#3da9fc", dimColor: true, children: divider }), modeCycleToast ? (_jsx(Box, { children: _jsx(Text, { color: "#3da9fc", bold: true, children: ` ${modeCycleToast}` }) })) : null, ctrlCToast ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", bold: true, children: ` ${ctrlCToast}` }) })) : null, 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 · Shift+Tab mode · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
689
718
  }
690
719
  /**
691
720
  * Render the line with the cursor glyph inserted at `cursor`. The cursor
@@ -22,6 +22,7 @@ import React from 'react';
22
22
  import { render } from 'ink';
23
23
  import { Repl } from './repl.js';
24
24
  import { printPugMascotPreInk } from './repl-splash-mascot.js';
25
+ import { collectWelcomeData } from './welcome-data.js';
25
26
  import { ThemeProvider } from '../core/theme/context.js';
26
27
  import { resolveTheme } from '../core/theme/state.js';
27
28
  import { ReplSession, } from '../core/repl/session.js';
@@ -196,12 +197,31 @@ export async function renderRepl(options) {
196
197
  workspaceRoot: process.cwd(),
197
198
  env: process.env,
198
199
  });
200
+ // CEO P0 #2 (2026-05-29): collect welcome banner data BEFORE Ink
201
+ // mounts so the banner paints on the first frame instead of swapping
202
+ // in mid-render. The collector swallows every IO error so а missing
203
+ // CHANGELOG / unreadable credential / malformed settings never
204
+ // blocks the boot.
205
+ let welcomeData;
206
+ if (options.skipSplash !== true) {
207
+ try {
208
+ welcomeData = collectWelcomeData({
209
+ cliVersion: options.cliVersion,
210
+ cwd: process.cwd(),
211
+ });
212
+ }
213
+ catch {
214
+ welcomeData = undefined;
215
+ }
216
+ }
199
217
  const instance = render(React.createElement(ThemeProvider, { slug: resolvedTheme.slug }, React.createElement(Repl, {
200
218
  session,
201
219
  updateBanner: options.updateBanner ?? null,
202
220
  skipSplash: options.skipSplash === true,
203
221
  hideToolStream: options.hideToolStream === true,
204
222
  mascotPrePrinted,
223
+ welcomeData,
224
+ autoInitStatus: options.autoInitStatus ?? null,
205
225
  })));
206
226
  // Make sure we leave the alt screen on abrupt exits too. Without
207
227
  // this the operator's shell stays "frozen" on the Pugi splash.
@@ -48,9 +48,21 @@ import { fileURLToPath } from 'node:url';
48
48
  * — two directory hops up from this file. In a local `pnpm dev`
49
49
  * checkout the structure is the same (`src/tui/` ⇒ `../../assets/`)
50
50
  * because tsx re-resolves the same relative tree.
51
+ *
52
+ * CEO P0 #2 (2026-05-29) — banner mascot bake. The prozr2 portrait is
53
+ * the canonical brand-pug glyph from `apps/console-web/public/brand/
54
+ * Pugi-prozr2.png`, baked к а 16x8 vhalf truecolor render. We prefer
55
+ * the prozr2 bake when it ships alongside the CLI (small — ~900 bytes,
56
+ * shaped for the compact welcome-banner left column) and fall back к
57
+ * the legacy 40KB `pugi-mascot.ansi` (hero-pug 80x40) when prozr2 is
58
+ * missing — preserves the splash-mascot install surface for any
59
+ * tarball that predates the bake.
51
60
  */
52
61
  export function pugMascotAssetPath() {
53
62
  const here = dirname(fileURLToPath(import.meta.url));
63
+ const prozr2 = resolvePath(here, '..', '..', 'assets', 'pugi-prozr2-mascot.ansi');
64
+ if (existsSync(prozr2))
65
+ return prozr2;
54
66
  return resolvePath(here, '..', '..', 'assets', 'pugi-mascot.ansi');
55
67
  }
56
68
  /**
package/dist/tui/repl.js CHANGED
@@ -26,8 +26,10 @@ import { ConversationPane } from './conversation-pane.js';
26
26
  import { InputBox } from './input-box.js';
27
27
  import { ReplSplash } from './repl-splash.js';
28
28
  import { StatusBar } from './status-bar.js';
29
+ import { ThinkingSpinner } from './thinking-spinner.js';
29
30
  import { ToolStreamPane } from './tool-stream-pane.js';
30
31
  import { UpdateBanner } from './update-banner.js';
32
+ import { WelcomeBanner } from './welcome-banner.js';
31
33
  import { collectWorkspaceContext } from './workspace-context.js';
32
34
  import { useTheme } from '../core/theme/context.js';
33
35
  import { slugForCwd } from '../core/repl/history.js';
@@ -57,6 +59,12 @@ export function Repl(props) {
57
59
  // Tenant block crowding the top.
58
60
  const [splashVisible, setSplashVisible] = useState(false);
59
61
  const dismissSplash = useCallback(() => setSplashVisible(false), []);
62
+ // CEO P0 #2 (2026-05-29): CC-style welcome banner. Visible from boot
63
+ // until the operator submits the first brief OR the session emits
64
+ // its first agent event. The host owns dismissal lifecycle (kept
65
+ // symmetric with the splash) so the welcome card never lingers
66
+ // behind а live transcript.
67
+ const [welcomeVisible, setWelcomeVisible] = useState(Boolean(props.welcomeData));
60
68
  // α6.14 wave 3: workspace context snapshot for the status bar. We
61
69
  // read once at mount and freeze; a brand-new PUGI.md or skill is
62
70
  // surfaced on the next REPL boot rather than via a watcher.
@@ -100,6 +108,17 @@ export function Repl(props) {
100
108
  setSplashVisible(false);
101
109
  }
102
110
  }, [splashVisible, state.agents.length, state.transcript.length]);
111
+ // CEO P0 #2 (2026-05-29): same dismissal contract for the welcome
112
+ // banner. The banner is the "boot card" — once any agent fires or
113
+ // the transcript gains а row, the banner clears так the conversation
114
+ // pane owns the vertical real estate.
115
+ useEffect(() => {
116
+ if (!welcomeVisible)
117
+ return;
118
+ if (state.agents.length > 0 || state.transcript.length > 0) {
119
+ setWelcomeVisible(false);
120
+ }
121
+ }, [welcomeVisible, state.agents.length, state.transcript.length]);
103
122
  const personaNames = useMemo(() => buildPersonaNameMap(), []);
104
123
  const { exit } = useApp();
105
124
  const handleSubmit = useCallback((line) => {
@@ -107,6 +126,10 @@ export function Repl(props) {
107
126
  // `setSplashVisible(false)` is a no-op once the state already
108
127
  // settled to false (timer fired or `agent.spawned` arrived).
109
128
  setSplashVisible(false);
129
+ // CEO P0 #2 (2026-05-29): same dismissal for the welcome banner
130
+ // — the operator engaging the input box is the cleanest signal
131
+ // they have finished reading the boot card.
132
+ setWelcomeVisible(false);
110
133
  // Run async without awaiting - the session module owns the
111
134
  // network call, errors land in the transcript automatically.
112
135
  void props.session.handleInput(line).then((verdict) => {
@@ -232,11 +255,11 @@ export function Repl(props) {
232
255
  // input, and the input stays the sole focusable surface adjacent
233
256
  // to the cursor row, so all keystrokes route through it.
234
257
  const altScreenRows = process.stdout.rows ?? 24;
235
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, 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, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
258
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, welcomeVisible && props.welcomeData ? (_jsx(WelcomeBanner, { data: props.welcomeData, mascotPrePrinted: props.mascotPrePrinted === true, autoInitStatus: props.autoInitStatus ?? null })) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, onCyclePermissionMode: handleCyclePermissionMode, now: props.now,
236
259
  // Slug from process.cwd() (full path) so two workspaces with
237
260
  // the same basename do not share history. state.workspaceLabel
238
261
  // is the basename only. Codex review P2.
239
- 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, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
262
+ workspaceSlug: slugForCwd(process.cwd()) })), _jsx(ThinkingSpinner, { dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel }), _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, dispatchState: state.dispatchState, dispatchToolLabel: state.dispatchToolLabel, lastCompletedOutcome: state.lastCompletedOutcome,
240
263
  // α7 cost-meter sprint — surface accumulated session totals
241
264
  // + per-turn delta flash on the status bar's top row. The
242
265
  // session module owns accumulation; the bar is a pure render.
@@ -0,0 +1,123 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Thinking spinner — animated dispatch indicator (CEO P0 #2, 2026-05-29).
4
+ *
5
+ * Replaces the static `dispatching` / `tool: <name>` strings the bottom
6
+ * status row used к paint during active brief dispatch with а Claude-
7
+ * Code-style rotating verb + glyph pair:
8
+ *
9
+ * ✽ Briefing… (awaiting_response, before the first tool call)
10
+ * ◆ Dispatching… (awaiting_response, после the first tool call)
11
+ * ◇ Reviewing… (tool_running)
12
+ * ✦ Synthesizing… (awaiting_response, late in turn)
13
+ * ❋ Shipping… (tool_running, edit/write/build family tool)
14
+ *
15
+ * The rotation cadence is ~800 ms per step so the verb feels alive but
16
+ * never strobes. The component owns its own timer (cleared on unmount)
17
+ * и takes the active dispatch state + the optional tool label so the
18
+ * "Shipping…" branch can light up specifically для write-class tools.
19
+ *
20
+ * Mount lifecycle: the REPL renders `<ThinkingSpinner />` only while
21
+ * `dispatchState` ∈ {`awaiting_response`, `tool_running`}. The
22
+ * status-bar's legacy `composeStatusLabel` row stays mounted too — they
23
+ * are not mutually exclusive — but the spinner row sits above the
24
+ * status row так the operator's eye lands on the live signal first.
25
+ *
26
+ * Brand discipline:
27
+ * - Verbs from the approved power-words list: `Briefing`,
28
+ * `Dispatching`, `Reviewing`, `Synthesizing`, `Shipping`. No
29
+ * `Thinking…` (forbidden cool word per DESIGN.md §3.2 voice gate).
30
+ * - Glyphs from the Pugi mascot brand glyph kit (`✽ ◆ ◇ ✦ ❋`). No
31
+ * emoji — the spinner must paint in а narrow Bash / WSL terminal
32
+ * где emoji fall back к `?`.
33
+ * - Cyan accent (`#3da9fc`) on the glyph; verb stays default-tone so
34
+ * the rotation does not compete with the cost-meter row above.
35
+ * - Trailing ellipsis is а single Unicode `…` (DESIGN.md §4 — three-
36
+ * dot ellipsis is the ONLY allowed truncation glyph).
37
+ *
38
+ * Test surface: `tickSpinnerFrame` is exported so the spec can drive
39
+ * the frame index without а real-clock timer.
40
+ */
41
+ import { useEffect, useState } from 'react';
42
+ import { Box, Text } from 'ink';
43
+ /* ------------------------------------------------------------------ */
44
+ /* Constants */
45
+ /* ------------------------------------------------------------------ */
46
+ const ACCENT = '#3da9fc';
47
+ const FRAME_INTERVAL_MS = 800;
48
+ const FRAMES = [
49
+ { glyph: '✽', verb: 'Briefing' },
50
+ { glyph: '◆', verb: 'Dispatching' },
51
+ { glyph: '◇', verb: 'Reviewing' },
52
+ { glyph: '✦', verb: 'Synthesizing' },
53
+ { glyph: '❋', verb: 'Shipping' },
54
+ ];
55
+ /**
56
+ * Tool labels that anchor к the `Shipping…` frame regardless of
57
+ * rotation index. These are the write-class tools — the operator sees
58
+ * "shipping" the moment а real file mutation fires так the indicator
59
+ * matches the visible filesystem effect.
60
+ */
61
+ const SHIPPING_TOOLS = new Set([
62
+ 'edit',
63
+ 'write',
64
+ 'build',
65
+ 'multi_edit',
66
+ 'apply_patch',
67
+ ]);
68
+ /* ------------------------------------------------------------------ */
69
+ /* Helpers */
70
+ /* ------------------------------------------------------------------ */
71
+ /**
72
+ * Compute the visible frame для the current tick. The base index is
73
+ * derived from а monotonically-incrementing counter (one increment per
74
+ * `FRAME_INTERVAL_MS` window), и а ship-class tool label overrides the
75
+ * base so write-tools light up `Shipping…` immediately.
76
+ *
77
+ * Exported for the spec so the frame selection is testable without
78
+ * mounting Ink.
79
+ */
80
+ export function tickSpinnerFrame(baseIndex, toolLabel) {
81
+ if (toolLabel && SHIPPING_TOOLS.has(toolLabel.toLowerCase())) {
82
+ return FRAMES[FRAMES.length - 1] ?? FRAMES[0];
83
+ }
84
+ const wrapped = ((baseIndex % FRAMES.length) + FRAMES.length) % FRAMES.length;
85
+ return FRAMES[wrapped] ?? FRAMES[0];
86
+ }
87
+ /**
88
+ * Decide whether the spinner should be visible for а dispatch state.
89
+ * `awaiting_response` и `tool_running` are the only active states; all
90
+ * other states (`idle`, `aborting`, `aborted`, `completed`, `failed`)
91
+ * suppress the spinner так the operator does not see а phantom verb
92
+ * after the dispatch settles.
93
+ */
94
+ export function isSpinnerActive(dispatchState) {
95
+ return dispatchState === 'awaiting_response' || dispatchState === 'tool_running';
96
+ }
97
+ /* ------------------------------------------------------------------ */
98
+ /* Component */
99
+ /* ------------------------------------------------------------------ */
100
+ export function ThinkingSpinner(props) {
101
+ const active = isSpinnerActive(props.dispatchState);
102
+ const [frameIndex, setFrameIndex] = useState(0);
103
+ const intervalMs = props.frameIntervalMs ?? FRAME_INTERVAL_MS;
104
+ useEffect(() => {
105
+ if (!active) {
106
+ // Reset так the next dispatch starts on `Briefing` instead of
107
+ // mid-rotation. Without this the spinner would resume one verb
108
+ // later on every turn и quickly drift to `Synthesizing` even на
109
+ // а fresh brief.
110
+ setFrameIndex(0);
111
+ return;
112
+ }
113
+ const timer = setInterval(() => {
114
+ setFrameIndex((previous) => (previous + 1) % FRAMES.length);
115
+ }, intervalMs);
116
+ return () => clearInterval(timer);
117
+ }, [active, intervalMs]);
118
+ if (!active)
119
+ return null;
120
+ const frame = tickSpinnerFrame(frameIndex, props.dispatchToolLabel ?? null);
121
+ return (_jsxs(Box, { children: [_jsx(Text, { color: ACCENT, children: `${frame.glyph} ` }), _jsx(Text, { children: `${frame.verb}…` })] }));
122
+ }
123
+ //# sourceMappingURL=thinking-spinner.js.map
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { PUG_MASCOT, PUG_MASCOT_CYAN_MASK, PUG_MASCOT_MAX_WIDTH, } from './repl-splash-art.js';
4
+ /* ------------------------------------------------------------------ */
5
+ /* Layout constants */
6
+ /* ------------------------------------------------------------------ */
7
+ /** Default left-column width when the parent box doesn't pin one. */
8
+ const LEFT_COLUMN_WIDTH = 44;
9
+ /** Default right-column width. */
10
+ const RIGHT_COLUMN_WIDTH = 44;
11
+ const ACCENT = '#3da9fc';
12
+ const BULLET = '·';
13
+ /* ------------------------------------------------------------------ */
14
+ /* Component */
15
+ /* ------------------------------------------------------------------ */
16
+ export function WelcomeBanner(props) {
17
+ const { data } = props;
18
+ const showHandCraftedMascot = props.mascotPrePrinted !== true;
19
+ const initToast = props.autoInitStatus === 'initialized'
20
+ ? 'Pugi workspace initialised at .pugi/.'
21
+ : null;
22
+ const accountLine = formatAccountLine(data);
23
+ const modelLine = formatModelLine(data);
24
+ return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { borderStyle: "round", borderColor: ACCENT, paddingX: 1, flexDirection: "row", children: [_jsx(LeftColumn, { greetingName: data.greetingName, cwd: data.cwd, modelLine: modelLine, accountLine: accountLine, cliVersion: data.cliVersion, showHandCraftedMascot: showHandCraftedMascot, initToast: initToast }), _jsx(Box, { width: 2 }), _jsx(RightColumn, { whatsNew: data.whatsNew })] }) }));
25
+ }
26
+ /* ------------------------------------------------------------------ */
27
+ /* Left column — greeting + mascot + status */
28
+ /* ------------------------------------------------------------------ */
29
+ function LeftColumn({ greetingName, cwd, modelLine, accountLine, cliVersion, showHandCraftedMascot, initToast, }) {
30
+ return (_jsxs(Box, { flexDirection: "column", width: LEFT_COLUMN_WIDTH, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: ACCENT, children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${cliVersion}` })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: `Welcome back ${greetingName}!` }) }), showHandCraftedMascot ? (_jsx(Box, { marginTop: 1, children: _jsx(MascotColumn, {}) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { children: modelLine }), _jsx(Text, { dimColor: true, children: accountLine }), _jsx(Text, { dimColor: true, children: truncatePath(cwd, LEFT_COLUMN_WIDTH - 2) })] }), initToast ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: ACCENT, children: initToast }) })) : null] }));
31
+ }
32
+ /* ------------------------------------------------------------------ */
33
+ /* Right column — tips + what's new */
34
+ /* ------------------------------------------------------------------ */
35
+ function RightColumn({ whatsNew, }) {
36
+ return (_jsxs(Box, { flexDirection: "column", width: RIGHT_COLUMN_WIDTH, children: [_jsx(Text, { dimColor: true, children: "Tips for getting started" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(TipRow, { text: "Run /init to scaffold PUGI.md instructions" }), _jsx(TipRow, { text: "Brief Pugi \u2014 the workforce dispatches" }), _jsx(TipRow, { text: "Triple-review gate before push: /review --triple" }), _jsx(TipRow, { text: "/help for every slash command" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(RIGHT_COLUMN_WIDTH - 2, 18)) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "What's new" }) }), _jsx(Box, { flexDirection: "column", children: whatsNew.length > 0 ? (whatsNew.map((line, index) => (_jsx(TipRow, { text: line }, `whatsnew-${index}`)))) : (_jsx(Text, { dimColor: true, children: ` ${BULLET} /release-notes for the full changelog` })) })] }));
37
+ }
38
+ function TipRow({ text }) {
39
+ return (_jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: ` ${BULLET} ` }), _jsx(Text, { children: text })] }));
40
+ }
41
+ /* ------------------------------------------------------------------ */
42
+ /* Mascot column (hand-crafted ASCII fallback path) */
43
+ /* ------------------------------------------------------------------ */
44
+ function MascotColumn() {
45
+ 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] ?? [] }, `mascot-row-${rowIndex}`))) }));
46
+ }
47
+ function MascotRow({ row, mask, }) {
48
+ // Split into contiguous same-color runs so we emit one <Text> per
49
+ // run instead of one per character (keeps the Ink tree shallow и
50
+ // the snapshot diff readable).
51
+ const runs = [];
52
+ let buffer = '';
53
+ let bufferCyan = false;
54
+ for (let column = 0; column < row.length; column += 1) {
55
+ const ch = row.charAt(column);
56
+ const cyan = mask[column] === true;
57
+ if (buffer.length === 0) {
58
+ buffer = ch;
59
+ bufferCyan = cyan;
60
+ continue;
61
+ }
62
+ if (cyan === bufferCyan) {
63
+ buffer += ch;
64
+ }
65
+ else {
66
+ runs.push({ text: buffer, cyan: bufferCyan });
67
+ buffer = ch;
68
+ bufferCyan = cyan;
69
+ }
70
+ }
71
+ if (buffer.length > 0)
72
+ runs.push({ text: buffer, cyan: bufferCyan });
73
+ return (_jsx(Text, { children: runs.map((run, runIndex) => run.cyan ? (_jsx(Text, { color: ACCENT, children: run.text }, runIndex)) : (_jsx(Text, { color: "gray", children: run.text }, runIndex))) }));
74
+ }
75
+ /* ------------------------------------------------------------------ */
76
+ /* Formatters */
77
+ /* ------------------------------------------------------------------ */
78
+ function formatModelLine(data) {
79
+ const tierLabel = data.plan ? ` ${BULLET} ${capitalize(data.plan)} Tier` : '';
80
+ return `${data.model}${tierLabel}`;
81
+ }
82
+ function formatAccountLine(data) {
83
+ if (!data.email) {
84
+ return 'Anonymous (run /login to authenticate)';
85
+ }
86
+ const tenant = data.tenant ? ` ${BULLET} ${data.tenant} Org` : '';
87
+ return `${data.email}${tenant}`;
88
+ }
89
+ function capitalize(value) {
90
+ if (value.length === 0)
91
+ return value;
92
+ return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
93
+ }
94
+ /**
95
+ * Trim the cwd from the LEFT (preserving the basename) so the operator
96
+ * always sees which project they're in even when the column is narrow.
97
+ * Mirrors the Claude Code boot card convention: long paths get an
98
+ * ellipsis on the head, never on the tail.
99
+ */
100
+ export function truncatePath(path, max) {
101
+ if (path.length <= max)
102
+ return path;
103
+ if (max <= 3)
104
+ return path.slice(-max);
105
+ return `…${path.slice(-(max - 1))}`;
106
+ }
107
+ //# sourceMappingURL=welcome-banner.js.map
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Pure data layer for the `<WelcomeBanner />` component (CEO P0 #2,
3
+ * 2026-05-29). Lives in its own module so it can be unit-tested without
4
+ * rendering Ink, and so the banner component itself contains zero IO.
5
+ *
6
+ * The banner is the CC-style 2-column boxed greeting that replaces the
7
+ * α6.14 wave-3 `<ReplSplash />` on the bare REPL boot path. It mirrors
8
+ * Claude Code's boot layout:
9
+ *
10
+ * ╭────── Pugi v0.1.0-beta.46 ──────╮
11
+ * │ │ Tips for getting started
12
+ * │ Welcome back Yurii! │ Run /init to create PUGI.md...
13
+ * │ │ ───────────
14
+ * │ ▗ ▗ ▖ ▖ │ What's new
15
+ * │ ▘▘ ▝▝ │ * 0.1.0-beta.26 — Wave 6 RAG ...
16
+ * │ Sonnet 4.6 (1M context) · Founder
17
+ * │ yuriy.bulah@gmail.com · pugi-io Org
18
+ * │ /Volumes/T9/Web/.../TestRepos2 │
19
+ * ╰──────────────────────────────────╯
20
+ *
21
+ * Data sources, in priority order:
22
+ *
23
+ * - Account email + tenant + plan: JWT principal decoded from the
24
+ * active credential. Falls back to anonymous label when no
25
+ * credential is on disk (operator boots `pugi` before login).
26
+ * - Greeting first-name: best-effort split of the local part of the
27
+ * email. Falls back к "operator" when unauthenticated.
28
+ * - Model: env override `PUGI_ENGINE_MODEL_CODE`, then the operator's
29
+ * workspace settings `defaultModel`, then "Sonnet 4.6 (1M context)"
30
+ * as the locked α7 default.
31
+ * - Cwd: absolute path of `process.cwd()` — banner left column shows
32
+ * the full path so the operator confirms они are в the right repo.
33
+ * - What's new: top 3 release-note titles from `apps/pugi-cli/CHANGELOG.md`
34
+ * newer than `~/.pugi/.last-seen-version`. Falls back к the top 3
35
+ * overall when last-seen marker is missing. Each entry is a one-line
36
+ * headline ("0.1.0-beta.26 — Wave 6 RAG consumer middleware") trimmed
37
+ * к 60 chars so the right column does not wrap on a 100-col terminal.
38
+ *
39
+ * Every resolver swallows its IO error and returns the documented
40
+ * fallback so a partial environment (missing CHANGELOG, malformed JWT,
41
+ * unreadable settings) never blocks the banner.
42
+ */
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+ import { homedir } from 'node:os';
45
+ import { dirname, resolve as resolvePath } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { DEFAULT_API_URL, normalizeApiUrl, readCredentialsFile, resolveActiveCredential, } from '../core/credentials.js';
48
+ import { parseChangelog } from '../core/release-notes/parser.js';
49
+ function decodeJwtPayload(token) {
50
+ try {
51
+ const parts = token.split('.');
52
+ if (parts.length < 2)
53
+ return null;
54
+ const payload = parts[1];
55
+ if (!payload)
56
+ return null;
57
+ const padded = payload
58
+ .replace(/-/g, '+')
59
+ .replace(/_/g, '/')
60
+ .padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
61
+ const json = Buffer.from(padded, 'base64').toString('utf8');
62
+ const obj = JSON.parse(json);
63
+ if (!obj || typeof obj !== 'object')
64
+ return null;
65
+ return {
66
+ ...(typeof obj.sub === 'string' && { sub: obj.sub }),
67
+ ...(typeof obj.email === 'string' && { email: obj.email }),
68
+ ...(typeof obj.customerId === 'string' && { customerId: obj.customerId }),
69
+ ...(typeof obj.plan === 'string' && { plan: obj.plan }),
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /* ------------------------------------------------------------------ */
77
+ /* Resolvers */
78
+ /* ------------------------------------------------------------------ */
79
+ /**
80
+ * Derive a greeting first-name from an email's local part. The split
81
+ * is intentionally aggressive: dot-separated, hyphen-separated, and
82
+ * digit-stripped so `yuriy.bulah@gmail.com` resolves к "Yuriy" instead
83
+ * of "yuriy.bulah". Title-cases the first segment. Falls back к
84
+ * "operator" when no email is available.
85
+ *
86
+ * Export so the spec can lock the heuristic against edge cases (numeric
87
+ * local part, plus-tag aliases, single-letter local part).
88
+ */
89
+ export function deriveGreetingName(email) {
90
+ if (!email || typeof email !== 'string')
91
+ return 'operator';
92
+ const at = email.indexOf('@');
93
+ if (at <= 0)
94
+ return 'operator';
95
+ const local = email.slice(0, at);
96
+ // Strip plus-tag aliases (`name+tag@host` → `name`).
97
+ const noTag = local.split('+')[0] ?? local;
98
+ // First dot / hyphen / underscore segment wins; this is the
99
+ // colloquial "given name" surface for the vast majority of work
100
+ // emails ("first.last@..." / "first-last@...").
101
+ const firstSegment = noTag.split(/[._-]/)[0] ?? noTag;
102
+ // Strip trailing digits some signup flows append (`yurii2@`) so the
103
+ // greeting reads as a name, not a username.
104
+ const stripped = firstSegment.replace(/[0-9]+$/u, '');
105
+ if (stripped.length === 0)
106
+ return 'operator';
107
+ return stripped.charAt(0).toUpperCase() + stripped.slice(1);
108
+ }
109
+ /**
110
+ * Resolve the model display string. Priority:
111
+ *
112
+ * 1. `PUGI_ENGINE_MODEL_CODE` env override (operator-set in shell).
113
+ * 2. `.pugi/settings.json` → `defaultModel` field (per-workspace).
114
+ * 3. Locked α7 default `"Sonnet 4.6 (1M context)"`.
115
+ *
116
+ * Returns the value verbatim — the banner is responsible for trimming
117
+ * if the string is too long for the column.
118
+ */
119
+ export function resolveModelDisplay(env, settingsOverride) {
120
+ const envModel = env.PUGI_ENGINE_MODEL_CODE;
121
+ if (typeof envModel === 'string' && envModel.length > 0)
122
+ return envModel;
123
+ const settingsModel = settingsOverride?.defaultModel;
124
+ if (typeof settingsModel === 'string' && settingsModel.length > 0) {
125
+ return settingsModel;
126
+ }
127
+ return 'Sonnet 4.6 (1M context)';
128
+ }
129
+ /**
130
+ * Locate the bundled CHANGELOG.md. The CLI ships к
131
+ * `node_modules/@pugi/cli/dist/tui/welcome-data.js` so the changelog
132
+ * lives at `node_modules/@pugi/cli/CHANGELOG.md` — two directory hops
133
+ * up from this file. In a local pnpm dev checkout the structure is the
134
+ * same (`src/tui/` ⇒ `../../CHANGELOG.md`) because tsx re-resolves the
135
+ * same relative tree.
136
+ */
137
+ function defaultChangelogPath() {
138
+ const here = dirname(fileURLToPath(import.meta.url));
139
+ return resolvePath(here, '..', '..', 'CHANGELOG.md');
140
+ }
141
+ /**
142
+ * Read top-3 "what's new" headlines from the CLI CHANGELOG.md. Each
143
+ * headline is `<version> — <first non-empty section body line>` trimmed
144
+ * к 60 chars so the right column does not wrap on a 100-col terminal.
145
+ * Returns an empty array when the file is missing / unparseable.
146
+ *
147
+ * The body-line heuristic walks к the first non-empty, non-section-
148
+ * header line below the version header. For Keep-a-Changelog entries
149
+ * the second line is usually `### Added` / `### Fixed`; we skip those
150
+ * headers и land on the first bullet, which is the operator-visible
151
+ * one-liner ("- L30 `pugi theme` ...").
152
+ */
153
+ export function readWhatsNew(changelogPath) {
154
+ const path = changelogPath ?? defaultChangelogPath();
155
+ try {
156
+ if (!existsSync(path))
157
+ return [];
158
+ const raw = readFileSync(path, 'utf8');
159
+ if (!raw || raw.length === 0)
160
+ return [];
161
+ const sections = parseChangelog(raw);
162
+ const headlines = [];
163
+ for (const section of sections) {
164
+ if (headlines.length >= 3)
165
+ break;
166
+ // Skip the "[Unreleased]" / "[Unreleased] - YYYY-MM-DD" section —
167
+ // the banner is meant к surface SHIPPED notes only. The parser
168
+ // captures Unreleased identically к а tagged version, so we
169
+ // filter here.
170
+ if (/^unreleased$/i.test(section.version))
171
+ continue;
172
+ const firstBullet = pickFirstBullet(section.body);
173
+ if (!firstBullet)
174
+ continue;
175
+ const headline = `${section.version} — ${firstBullet}`;
176
+ headlines.push(truncate(headline, 60));
177
+ }
178
+ return headlines;
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
184
+ /**
185
+ * Pull the first bullet text out of a Keep-a-Changelog section body.
186
+ * Skips `### <subsection>` headers, blank lines, и non-bullet prose
187
+ * (release-note footers like "### Notes"). Returns undefined when no
188
+ * bullet is present.
189
+ *
190
+ * Body lines arrive verbatim from the parser; bullets follow the
191
+ * `- ` / `* ` Markdown convention. We strip the leading bullet glyph
192
+ * + the first whitespace run so the rendered string is the bullet
193
+ * content only.
194
+ */
195
+ function pickFirstBullet(body) {
196
+ const lines = body.split('\n');
197
+ for (const line of lines) {
198
+ const trimmed = line.trim();
199
+ if (trimmed.length === 0)
200
+ continue;
201
+ if (trimmed.startsWith('### '))
202
+ continue;
203
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
204
+ // Strip leading bullet glyph + ws and collapse internal MD ticks.
205
+ return trimmed
206
+ .slice(2)
207
+ .replace(/`/g, '')
208
+ .replace(/\*\*/g, '')
209
+ .trim();
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+ function truncate(text, max) {
215
+ if (text.length <= max)
216
+ return text;
217
+ // Reserve 1 char for the ellipsis so we land on EXACTLY `max`
218
+ // visible chars including the dot. Single Unicode ellipsis would be
219
+ // narrower but some terminals render it as а full-width glyph in
220
+ // CJK locales — ASCII three-dot stays predictable.
221
+ return `${text.slice(0, max - 1).trimEnd()}…`;
222
+ }
223
+ /**
224
+ * Resolve the active credential and decode the JWT principal. Returns
225
+ * the email / tenant / plan triple when authenticated, null fields when
226
+ * anonymous. Encapsulates the credential + JWT IO so the spec can drive
227
+ * the resolver via an env override.
228
+ */
229
+ function resolvePrincipal(env, home) {
230
+ const credential = resolveActiveCredential(env, home);
231
+ if (!credential) {
232
+ const file = readCredentialsFile(home);
233
+ return {
234
+ apiUrl: normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL),
235
+ };
236
+ }
237
+ const principal = decodeJwtPayload(credential.apiKey);
238
+ return {
239
+ apiUrl: credential.apiUrl,
240
+ ...(principal?.email && { email: principal.email }),
241
+ ...(principal?.customerId && { tenant: principal.customerId }),
242
+ ...(principal?.plan && { plan: principal.plan }),
243
+ };
244
+ }
245
+ /**
246
+ * Best-effort read of `.pugi/settings.json`. Returns null when the file
247
+ * is missing / unreadable / malformed so the model resolver can fall
248
+ * back to the env override + locked default.
249
+ */
250
+ function readSettingsBlob(cwd) {
251
+ try {
252
+ const path = resolvePath(cwd, '.pugi', 'settings.json');
253
+ if (!existsSync(path))
254
+ return null;
255
+ const raw = readFileSync(path, 'utf8');
256
+ if (!raw || raw.length === 0)
257
+ return null;
258
+ const obj = JSON.parse(raw);
259
+ if (typeof obj?.defaultModel === 'string') {
260
+ return { defaultModel: obj.defaultModel };
261
+ }
262
+ return {};
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }
268
+ /* ------------------------------------------------------------------ */
269
+ /* Entry point */
270
+ /* ------------------------------------------------------------------ */
271
+ export function collectWelcomeData(input) {
272
+ const env = input.env ?? process.env;
273
+ const home = input.home ?? homedir();
274
+ const cwd = input.cwd ?? process.cwd();
275
+ const principal = resolvePrincipal(env, home);
276
+ const settings = input.settingsOverride !== undefined
277
+ ? input.settingsOverride
278
+ : readSettingsBlob(cwd);
279
+ const greetingName = deriveGreetingName(principal.email);
280
+ const model = resolveModelDisplay(env, settings);
281
+ const whatsNew = readWhatsNew(input.changelogPath);
282
+ return {
283
+ greetingName,
284
+ ...(principal.email && { email: principal.email }),
285
+ ...(principal.tenant && { tenant: principal.tenant }),
286
+ ...(principal.plan && { plan: principal.plan }),
287
+ model,
288
+ cwd,
289
+ cliVersion: input.cliVersion,
290
+ whatsNew,
291
+ };
292
+ }
293
+ //# sourceMappingURL=welcome-data.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.46",
3
+ "version": "0.1.0-beta.47",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.46"
58
+ "@pugi/sdk": "0.1.0-beta.47"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",