@pugi/cli 0.1.0-beta.45 → 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.
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/dist/runtime/cli.js +91 -32
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +106 -27
- package/dist/tui/repl-render.js +20 -0
- package/dist/tui/repl-splash-mascot.js +12 -0
- package/dist/tui/repl.js +25 -2
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/package.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[?25l[0m [0m
|
|
2
|
+
[0m
|
|
3
|
+
[38;2;54;62;72;48;2;59;67;77m▄[38;2;54;61;72;48;2;38;46;57m▄[38;2;89;96;107;48;2;69;76;87m▄[38;2;69;75;86;48;2;83;89;100m▄[38;2;69;76;86;48;2;40;68;89m▄[38;2;23;62;86;48;2;3;44;71m▄[38;2;3;42;67;48;2;34;44;56m▄[38;2;54;62;72;48;2;59;66;76m▄[0m [0m
|
|
4
|
+
[38;2;73;80;90;48;2;64;78;92m▄[38;2;39;62;81;48;2;28;60;82m▄[38;2;57;63;74;48;2;61;68;78m▄[38;2;58;64;75;48;2;60;67;77m▄[38;2;34;66;90;48;2;9;53;81m▄[38;2;3;75;116;48;2;2;66;101m▄[0m [0m
|
|
5
|
+
[7m[38;2;44;49;57m▄[0m[38;2;27;33;41;48;2;51;59;70m▄[38;2;48;60;76m▄[38;2;33;65;94;48;2;49;59;73m▄[38;2;3;49;77;48;2;24;80;118m▄[38;2;1;48;78;48;2;3;107;171m▄[0m [0m
|
|
6
|
+
[38;2;2;7;14m▄[38;2;2;7;13m▄[0m [0m
|
|
7
|
+
[7m[38;2;2;6;13m▄[0m [0m
|
|
8
|
+
[0m
|
|
9
|
+
[?25h
|
package/dist/runtime/cli.js
CHANGED
|
@@ -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
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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-
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
// operator
|
|
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
|
|
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`.
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
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.
|
package/dist/tui/input-box.js
CHANGED
|
@@ -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
|
|
@@ -286,42 +315,92 @@ export function InputBox(props) {
|
|
|
286
315
|
setSearch(initialSearchState(history));
|
|
287
316
|
return;
|
|
288
317
|
}
|
|
289
|
-
// P0 fix (CEO 2026-05-29 dogfood): bare LF (`\n`)
|
|
290
|
-
// brief, same as bare CR (`\r`). Ink's parseKeypress
|
|
291
|
-
// `key.return` and `\n` to `key.name === 'enter'`
|
|
292
|
-
// `key.return`. Most real terminals deliver CR for
|
|
293
|
-
// by default), so the `key.return` branch below
|
|
294
|
-
// when stdin is a PTY whose parent writes raw
|
|
295
|
-
// `pty.fork` + `os.write(fd, b"\n")`, automation
|
|
296
|
-
// certain SSH multiplexers), the LF arrives as a
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
318
|
+
// P0 fix (CEO 2026-05-29 dogfood, second iteration): bare LF (`\n`)
|
|
319
|
+
// MUST submit the brief, same as bare CR (`\r`). Ink's parseKeypress
|
|
320
|
+
// maps `\r` to `key.return` and `\n` to `key.name === 'enter'`
|
|
321
|
+
// WITHOUT setting `key.return`. Most real terminals deliver CR for
|
|
322
|
+
// Enter (ICRNL on by default), so the `key.return` branch below
|
|
323
|
+
// catches them. But when stdin is a PTY whose parent writes raw
|
|
324
|
+
// `\n` (Python's `pty.fork` + `os.write(fd, b"\n")`, automation
|
|
325
|
+
// harnesses, certain SSH multiplexers), the LF arrives as a
|
|
326
|
+
// printable char.
|
|
327
|
+
//
|
|
328
|
+
// PR #697 (beta.45) fixed the case where `input === '\n'` exactly.
|
|
329
|
+
// CEO PTY smoke 2026-05-29 surfaced the REAL shape: when the parent
|
|
330
|
+
// writes the brief AND the Enter as separate `os.write` calls (or
|
|
331
|
+
// even when it doesn't), Node's stdin buffer COALESCES them into
|
|
332
|
+
// ONE chunk before Ink delivers the `useInput` event. The repro
|
|
333
|
+
// confirmed via stderr instrumentation: typing `hi\n` arrives in
|
|
334
|
+
// input-box as `bytes=[68 69 0a] len=3 flags=-` — a SINGLE 3-char
|
|
335
|
+
// chunk "hi\n" with no key flags. The PR #697 branch (`input ===
|
|
336
|
+
// '\n'`) does not match, so `hi\n` falls through to the printable-
|
|
337
|
+
// char branch and the literal newline lands in the buffer as
|
|
338
|
+
// `› hi\n █` (multi-line composer, brief never dispatches, status
|
|
339
|
+
// stays `idle` forever).
|
|
340
|
+
//
|
|
341
|
+
// Fix: detect a TRAILING `\n` in a printable chunk with no
|
|
342
|
+
// modifiers — type the prefix into the buffer, then submit. The
|
|
343
|
+
// discriminator that keeps multi-line paste working: the chunk
|
|
344
|
+
// must contain EXACTLY ONE `\n` (the trailing one) and no other
|
|
345
|
+
// newlines. Multi-line pastes have ≥2 `\n` characters (or arrive
|
|
346
|
+
// wrapped in bracketed-paste markers handled below), so they
|
|
347
|
+
// still preserve interior newlines via the printable-char branch.
|
|
348
|
+
//
|
|
349
|
+
// Detection contract:
|
|
350
|
+
// - `input` ends with `\n`
|
|
351
|
+
// - no Ctrl / Meta / Shift modifiers
|
|
352
|
+
// - exactly ONE `\n` in the chunk (the trailing one)
|
|
353
|
+
// - chunk is not bracketed-paste wrapped (markers stripped below)
|
|
300
354
|
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
355
|
+
// Edge cases covered by `test/input-box-lf-submit.spec.tsx`:
|
|
356
|
+
// - bare `\n` → submit empty (no-op on empty buf)
|
|
357
|
+
// - `hi\n` → splice `hi` + submit
|
|
358
|
+
// - `hi\nthere\n` (multi-line) → printable branch, preserves \n
|
|
359
|
+
// - `\r` (CR) → key.return branch unchanged
|
|
360
|
+
// - `hi\r\n` (CRLF) → key.return branch (CR wins first)
|
|
361
|
+
const endsWithLf = input.length > 0 && input.charCodeAt(input.length - 1) === 0x0a;
|
|
362
|
+
const newlineCount = (input.match(/\n/g) || []).length;
|
|
363
|
+
if (endsWithLf
|
|
364
|
+
&& newlineCount === 1
|
|
365
|
+
&& !key.meta
|
|
366
|
+
&& !key.ctrl
|
|
367
|
+
&& !key.shift) {
|
|
368
|
+
// Splice the prefix (everything before the trailing `\n`) into
|
|
369
|
+
// the buffer at the cursor, then run the canonical submit path.
|
|
370
|
+
// Refs (cursorRef / lineRef) hold the latest committed values so
|
|
371
|
+
// the splice runs against the operator's most recent edits even
|
|
372
|
+
// if a previous async paste / setState is still mid-flight.
|
|
373
|
+
const prefix = input.slice(0, -1);
|
|
374
|
+
let mergedLine = lineRef.current;
|
|
375
|
+
let mergedCursor = cursorRef.current;
|
|
376
|
+
if (prefix.length > 0) {
|
|
377
|
+
// Same sanitisation as the printable-char branch below — strip
|
|
378
|
+
// bracketed-paste markers so a stray escape sequence never
|
|
379
|
+
// lands in the submitted brief.
|
|
380
|
+
const stripped = prefix
|
|
381
|
+
.replace(/\x1b\[200~/g, '')
|
|
382
|
+
.replace(/\x1b\[201~/g, '')
|
|
383
|
+
.replace(/\[200~/g, '')
|
|
384
|
+
.replace(/\[201~/g, '');
|
|
385
|
+
if (stripped.length > 0) {
|
|
386
|
+
mergedLine =
|
|
387
|
+
mergedLine.slice(0, mergedCursor) + stripped + mergedLine.slice(mergedCursor);
|
|
388
|
+
mergedCursor = mergedCursor + stripped.length;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
308
391
|
// Synthesise the same payload-shape the `key.return` branch
|
|
309
392
|
// below uses so palette completion + history dedup + onSubmit
|
|
310
|
-
// dispatch all run identically.
|
|
311
|
-
// synthetic key.return = true would require a refactor; the
|
|
312
|
-
// cheap fix is to inline the submit logic here in a way that
|
|
313
|
-
// mirrors the canonical branch exactly. Kept tight + obvious so
|
|
314
|
-
// future edits to one path get mirrored to the other.
|
|
393
|
+
// dispatch all run identically.
|
|
315
394
|
const paletteHere = !paletteSuppressed
|
|
316
|
-
? filterPalette(
|
|
395
|
+
? filterPalette(mergedLine)
|
|
317
396
|
: { rows: [], totalBeforeLimit: 0 };
|
|
318
397
|
const paletteOpenHere = paletteHere.rows.length > 0;
|
|
319
398
|
const paletteFocusedIndexHere = paletteHere.rows.length === 0
|
|
320
399
|
? 0
|
|
321
400
|
: Math.min(paletteIndex, paletteHere.rows.length - 1);
|
|
322
|
-
let payload =
|
|
401
|
+
let payload = mergedLine;
|
|
323
402
|
if (paletteOpenHere) {
|
|
324
|
-
const completed = completePalette(
|
|
403
|
+
const completed = completePalette(mergedLine, paletteHere.rows, paletteFocusedIndexHere);
|
|
325
404
|
if (completed !== null)
|
|
326
405
|
payload = completed;
|
|
327
406
|
}
|
|
@@ -635,7 +714,7 @@ export function InputBox(props) {
|
|
|
635
714
|
: Math.min(paletteIndex, paletteView.rows.length - 1);
|
|
636
715
|
const divider = '─'.repeat(innerWidth);
|
|
637
716
|
const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
|
|
638
|
-
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' }) })] }));
|
|
639
718
|
}
|
|
640
719
|
/**
|
|
641
720
|
* Render the line with the cursor glyph inserted at `cursor`. The cursor
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -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.
|
|
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.
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.47"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|