@pugi/cli 0.1.0-beta.46 → 0.1.0-beta.48
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/core/bash-classifier.js +160 -0
- package/dist/runtime/cli.js +91 -32
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +30 -1
- 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
|
|
@@ -346,6 +346,93 @@ const BUILD_TEST_PREFIXES = [
|
|
|
346
346
|
'tsc -p',
|
|
347
347
|
'eslint',
|
|
348
348
|
'prettier --check',
|
|
349
|
+
// P0 fix 2026-05-29 (#37 CRITICAL): customer-blocking gap surfaced
|
|
350
|
+
// during dogfood. Engine emitted `chmod +x build.sh`, `node script.js`,
|
|
351
|
+
// `python3 -m pytest`, `git status`, `pnpm build`, `docker ps`, etc.
|
|
352
|
+
// and the classifier returned `unknown` → permission matrix denied
|
|
353
|
+
// в bypassPermissions mode (which the customer expected to auto-allow
|
|
354
|
+
// basic dev tools). Customers could not run ANY real build/test/git
|
|
355
|
+
// workflow through Pugi.
|
|
356
|
+
//
|
|
357
|
+
// The prefixes below cover three classes of developer tooling that
|
|
358
|
+
// are always allowed in `auto`/`dontAsk`/`bypassPermissions` modes,
|
|
359
|
+
// `ask` in interactive modes, and `deny` in `plan` (read-only) mode:
|
|
360
|
+
// - Language runtimes: `node`, `python`, `python3`, `ruby`, etc.
|
|
361
|
+
// - Native build chains: `gcc`, `clang`, `cmake`, `rustc`, etc.
|
|
362
|
+
// - Container/k8s read-class: `docker ps/inspect/logs`, `kubectl get`.
|
|
363
|
+
//
|
|
364
|
+
// Destructive variants are already gated upstream by DESTRUCTIVE_PATTERNS
|
|
365
|
+
// (e.g. `docker system prune`, `kubectl delete --all`). The first-token
|
|
366
|
+
// gate in classifyComponent runs THIS list before the unknown fallback.
|
|
367
|
+
//
|
|
368
|
+
// Language runtime invocations (first-token match, with or without args).
|
|
369
|
+
'node',
|
|
370
|
+
'python',
|
|
371
|
+
'python3',
|
|
372
|
+
'ruby',
|
|
373
|
+
'perl',
|
|
374
|
+
'php',
|
|
375
|
+
'deno',
|
|
376
|
+
'bun',
|
|
377
|
+
'tsx',
|
|
378
|
+
'ts-node',
|
|
379
|
+
// Native build chains.
|
|
380
|
+
'gcc',
|
|
381
|
+
'g++',
|
|
382
|
+
'clang',
|
|
383
|
+
'clang++',
|
|
384
|
+
'cmake',
|
|
385
|
+
'rustc',
|
|
386
|
+
'javac',
|
|
387
|
+
'java',
|
|
388
|
+
// Container/k8s read-class (the destructive subcommands are pre-empted
|
|
389
|
+
// by DESTRUCTIVE_PATTERNS: `docker system prune`, `kubectl delete --all`,
|
|
390
|
+
// `kubectl delete namespace`).
|
|
391
|
+
'docker ps',
|
|
392
|
+
'docker images',
|
|
393
|
+
'docker inspect',
|
|
394
|
+
'docker logs',
|
|
395
|
+
'docker version',
|
|
396
|
+
'docker info',
|
|
397
|
+
'docker exec',
|
|
398
|
+
'docker run',
|
|
399
|
+
'docker stop',
|
|
400
|
+
'docker start',
|
|
401
|
+
'docker restart',
|
|
402
|
+
'docker rm',
|
|
403
|
+
'docker rmi',
|
|
404
|
+
'docker build',
|
|
405
|
+
'docker tag',
|
|
406
|
+
'docker compose',
|
|
407
|
+
'docker-compose',
|
|
408
|
+
'kubectl get',
|
|
409
|
+
'kubectl describe',
|
|
410
|
+
'kubectl logs',
|
|
411
|
+
'kubectl exec',
|
|
412
|
+
'kubectl apply',
|
|
413
|
+
'kubectl create',
|
|
414
|
+
'kubectl rollout',
|
|
415
|
+
'kubectl port-forward',
|
|
416
|
+
'kubectl config',
|
|
417
|
+
// Git read+write surface (network ops already handled by NETWORK_PREFIXES;
|
|
418
|
+
// destructive ops `reset --hard`/`clean -fdx`/`push --force` blocked above).
|
|
419
|
+
// Note: WRITE_WORKSPACE_PREFIXES already covers `git commit/add/checkout/...`.
|
|
420
|
+
// These entries handle plain `git rev-list`, `git cherry-pick`, `git worktree`,
|
|
421
|
+
// `git submodule`, etc that customer scripts commonly invoke.
|
|
422
|
+
'git rev-list',
|
|
423
|
+
'git cherry-pick',
|
|
424
|
+
'git worktree',
|
|
425
|
+
'git submodule',
|
|
426
|
+
'git blame',
|
|
427
|
+
'git describe',
|
|
428
|
+
'git tag --list',
|
|
429
|
+
'git tag -l',
|
|
430
|
+
'git for-each-ref',
|
|
431
|
+
'git ls-remote',
|
|
432
|
+
// gh CLI (GitHub). `gh repo delete` / `gh release delete` reach into
|
|
433
|
+
// network operations but are non-destructive for the local workspace.
|
|
434
|
+
// Permission matrix asks before allowing in auto.
|
|
435
|
+
'gh',
|
|
349
436
|
];
|
|
350
437
|
/** Single-token read-only commands. Argument-free entries match exact. */
|
|
351
438
|
const READ_TOKENS = new Set([
|
|
@@ -384,6 +471,16 @@ const READ_TOKENS = new Set([
|
|
|
384
471
|
'cut',
|
|
385
472
|
'sort',
|
|
386
473
|
'uniq',
|
|
474
|
+
// P0 fix 2026-05-29 (#37 CRITICAL): structured-data inspection tools
|
|
475
|
+
// are pure stdin/stdout transformers (no FS write, no network) when
|
|
476
|
+
// не paired с `>` redirection (the redirection branch above promotes
|
|
477
|
+
// к write_workspace independently). Common в dev scripts for parsing
|
|
478
|
+
// package.json, tsconfig.json, Helm values.yaml, etc.
|
|
479
|
+
// `tee` is INTENTIONALLY excluded — it writes by definition, even
|
|
480
|
+
// в protected paths (`tee /etc/...` is already in DESTRUCTIVE_PATTERNS).
|
|
481
|
+
'jq',
|
|
482
|
+
'yq',
|
|
483
|
+
'column',
|
|
387
484
|
]);
|
|
388
485
|
const READ_PREFIXES = [
|
|
389
486
|
'git status',
|
|
@@ -418,6 +515,16 @@ const WRITE_WORKSPACE_PREFIXES = [
|
|
|
418
515
|
'git tag',
|
|
419
516
|
'git rebase',
|
|
420
517
|
'git merge',
|
|
518
|
+
// P0 fix 2026-05-29 (#37 CRITICAL): file-permission ops are common
|
|
519
|
+
// в build scripts (`chmod +x build.sh`, `chown $USER file`). The
|
|
520
|
+
// destructive variants (`chmod 777 /`, `chmod -R 777 /`, `chmod -R
|
|
521
|
+
// 777 ~`, `chown -R root /`, `chown -R / ...`) are pre-empted by
|
|
522
|
+
// DESTRUCTIVE_PATTERNS which runs BEFORE this list — safe to add
|
|
523
|
+
// here for the non-destructive path. detectProtectedWrite's `\bchmod\b`
|
|
524
|
+
// / `\bchown\b` regex also catches writes into protected paths
|
|
525
|
+
// regardless of this list.
|
|
526
|
+
'chmod ',
|
|
527
|
+
'chown ',
|
|
421
528
|
];
|
|
422
529
|
/**
|
|
423
530
|
* Protected-write triggers. If a command writes to any of these paths
|
|
@@ -636,6 +743,25 @@ function classifyComponent(cmd, ctx) {
|
|
|
636
743
|
if (trimmed === 'make' || trimmed.startsWith('make ')) {
|
|
637
744
|
return { class: 'build_test', reason: 'make runner', matched: 'make' };
|
|
638
745
|
}
|
|
746
|
+
// 7c. Operator-override safe tokens (P0 fix 2026-05-29 #37).
|
|
747
|
+
// `PUGI_CLASSIFIER_EXTRA_SAFE=tool1,tool2,...` extends the BUILD_TEST
|
|
748
|
+
// first-token list at runtime. This is a security-sensitive escape
|
|
749
|
+
// hatch — operators can add their custom build tools without a
|
|
750
|
+
// recompile. Destructive patterns ALREADY ran above (step 1) so this
|
|
751
|
+
// cannot whitelist `rm`, `mkfs`, `git push --force`, etc. The match
|
|
752
|
+
// is strict first-token equality — not substring — and the env var
|
|
753
|
+
// is read fresh on every classify call so tests can mutate it.
|
|
754
|
+
const extraSafe = readExtraSafeTokens();
|
|
755
|
+
if (extraSafe.size > 0) {
|
|
756
|
+
const firstTokenForExtraSafe = trimmed.split(/\s+/)[0] ?? '';
|
|
757
|
+
if (extraSafe.has(firstTokenForExtraSafe)) {
|
|
758
|
+
return {
|
|
759
|
+
class: 'build_test',
|
|
760
|
+
reason: `PUGI_CLASSIFIER_EXTRA_SAFE override: ${firstTokenForExtraSafe}`,
|
|
761
|
+
matched: firstTokenForExtraSafe,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
639
765
|
// 7c. Bare `cd <path>` (inside workspace — the cwd-escape detector
|
|
640
766
|
// upgrades the class to write_protected when the target is
|
|
641
767
|
// outside). Standalone `cd` (HOME) is escape, also handled by the
|
|
@@ -756,6 +882,40 @@ function nestingDepth(cmd, open, close) {
|
|
|
756
882
|
function escapeRegex(s) {
|
|
757
883
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
758
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* Operator-override safe tokens. Read from `PUGI_CLASSIFIER_EXTRA_SAFE`
|
|
887
|
+
* (comma-separated). Allows operators to extend the BUILD_TEST first-
|
|
888
|
+
* token list at runtime for site-specific tooling без recompile.
|
|
889
|
+
*
|
|
890
|
+
* Security note: destructive substring patterns run BEFORE this gate
|
|
891
|
+
* (step 1 in classifyComponent), so this cannot whitelist `rm`, `mkfs`,
|
|
892
|
+
* `git push --force`, etc. The env var only adds tools to the benign
|
|
893
|
+
* build_test class. Invalid entries (empty strings, тokens containing
|
|
894
|
+
* shell metas) are silently dropped to avoid surprising classifications.
|
|
895
|
+
*
|
|
896
|
+
* Read fresh on every call so per-test mutations work и so operators
|
|
897
|
+
* can update without restarting the agent loop. The cost (one env var
|
|
898
|
+
* read + Set construction per call) is negligible for the classifier's
|
|
899
|
+
* call frequency.
|
|
900
|
+
*/
|
|
901
|
+
function readExtraSafeTokens() {
|
|
902
|
+
const raw = process.env.PUGI_CLASSIFIER_EXTRA_SAFE;
|
|
903
|
+
if (!raw || raw.trim() === '')
|
|
904
|
+
return new Set();
|
|
905
|
+
const tokens = new Set();
|
|
906
|
+
for (const candidate of raw.split(',')) {
|
|
907
|
+
const trimmed = candidate.trim();
|
|
908
|
+
if (trimmed === '')
|
|
909
|
+
continue;
|
|
910
|
+
// Reject anything containing shell metas or whitespace — only bare
|
|
911
|
+
// tool names allowed. Defends against accidental
|
|
912
|
+
// `PUGI_CLASSIFIER_EXTRA_SAFE='rm -rf /'` smuggling.
|
|
913
|
+
if (/[\s;|&<>$`(){}\[\]'"\\]/.test(trimmed))
|
|
914
|
+
continue;
|
|
915
|
+
tokens.add(trimmed);
|
|
916
|
+
}
|
|
917
|
+
return tokens;
|
|
918
|
+
}
|
|
759
919
|
function detectProtectedWrite(cmd, ctx) {
|
|
760
920
|
// Surface every write target this command produces so we can both
|
|
761
921
|
// protected-path-check and outside-workspace-check them uniformly.
|
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.48');
|
|
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
|
|
@@ -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
|
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.48",
|
|
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.48"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|