@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.15
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-mascot.ansi +17 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1072 -9
- package/dist/core/repl/slash-commands.js +25 -3
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/runtime/cli.js +504 -10
- package/dist/runtime/commands/config.js +202 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +85 -0
- package/dist/tui/repl-splash-mascot.js +118 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +59 -10
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +4 -3
package/dist/tui/repl-render.js
CHANGED
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
import React from 'react';
|
|
20
20
|
import { render } from 'ink';
|
|
21
21
|
import { Repl } from './repl.js';
|
|
22
|
+
import { printPugMascotPreInk } from './repl-splash-mascot.js';
|
|
22
23
|
import { ReplSession, } from '../core/repl/session.js';
|
|
23
24
|
import { resolveWorkspaceContext } from '../core/repl/workspace-context.js';
|
|
25
|
+
import { SqliteSessionStore } from '../core/repl/store/index.js';
|
|
26
|
+
import { slugForCwd } from '../core/repl/history.js';
|
|
24
27
|
/**
|
|
25
28
|
* Mount the REPL and resolve when the user exits via Ctrl+C × 2 or
|
|
26
29
|
* `/quit`. The session is closed (server-side stays alive; resume via
|
|
@@ -33,6 +36,18 @@ export async function renderRepl(options) {
|
|
|
33
36
|
// best-effort — any FS error falls back to a basename-only summary,
|
|
34
37
|
// never blocks REPL launch. Wave 4 fix 2026-05-25.
|
|
35
38
|
const workspace = options.workspace ?? resolveWorkspaceContext(process.cwd());
|
|
39
|
+
// α6.4: open the local SessionStore for `/resume` persistence. The
|
|
40
|
+
// store lives under `~/.pugi/projects/<slug>/`; failure is fail-safe
|
|
41
|
+
// — we log a one-line warning to stderr and continue with the REPL
|
|
42
|
+
// in memory-only mode. Lock-busy errors get the friendliest message
|
|
43
|
+
// so an operator running two REPLs in the same project understands
|
|
44
|
+
// the constraint.
|
|
45
|
+
const projectSlug = slugForCwd(process.cwd());
|
|
46
|
+
const { store, openedSessionId } = await openLocalStore({
|
|
47
|
+
projectSlug,
|
|
48
|
+
workspaceRoot: process.cwd(),
|
|
49
|
+
resumeLocalSessionId: options.resumeLocalSessionId,
|
|
50
|
+
});
|
|
36
51
|
const session = new ReplSession({
|
|
37
52
|
apiUrl: options.apiUrl,
|
|
38
53
|
apiKey: options.apiKey,
|
|
@@ -40,20 +55,90 @@ export async function renderRepl(options) {
|
|
|
40
55
|
cliVersion: options.cliVersion,
|
|
41
56
|
transport,
|
|
42
57
|
workspace,
|
|
58
|
+
store,
|
|
59
|
+
localSessionId: openedSessionId,
|
|
43
60
|
});
|
|
61
|
+
// Restore the transcript from the JSONL log if we resumed an
|
|
62
|
+
// existing session. The restore is idempotent and bypasses persist
|
|
63
|
+
// (no double-write of replayed rows).
|
|
64
|
+
if (store && openedSessionId && options.resumeLocalSessionId) {
|
|
65
|
+
try {
|
|
66
|
+
const events = await store.loadEvents(openedSessionId, { limit: 500 });
|
|
67
|
+
session.restoreTranscript(events);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
71
|
+
process.stderr.write(`[pugi] Could not restore session ${openedSessionId.slice(0, 13)}: ${msg}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
44
74
|
// Kick off the connect; the Repl renders the connecting state until
|
|
45
75
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
46
76
|
void session.start();
|
|
77
|
+
// α6.14.2 wave 5: paint the chafa-baked brand-pug ANSI render to
|
|
78
|
+
// stdout BEFORE Ink mounts. Ink's layout engine would mis-measure
|
|
79
|
+
// the truecolor escape sequences, so the pug must land verbatim.
|
|
80
|
+
// The flag is passed into <Repl /> so the splash component knows to
|
|
81
|
+
// skip its own hand-crafted PUG_MASCOT column — otherwise the
|
|
82
|
+
// operator sees both the chafa pug AND the ASCII fallback stacked.
|
|
83
|
+
// When skipSplash is true (operator opted out via --no-splash), we
|
|
84
|
+
// suppress the pre-print too so the boot stays silent.
|
|
85
|
+
const mascotPrePrinted = options.skipSplash === true ? false : printPugMascotPreInk(process.stdout);
|
|
47
86
|
const instance = render(React.createElement(Repl, {
|
|
48
87
|
session,
|
|
49
88
|
updateBanner: options.updateBanner ?? null,
|
|
50
89
|
skipSplash: options.skipSplash === true,
|
|
90
|
+
hideToolStream: options.hideToolStream === true,
|
|
91
|
+
mascotPrePrinted,
|
|
51
92
|
}));
|
|
52
93
|
try {
|
|
53
94
|
await instance.waitUntilExit();
|
|
54
95
|
}
|
|
55
96
|
finally {
|
|
56
97
|
session.close();
|
|
98
|
+
if (store) {
|
|
99
|
+
try {
|
|
100
|
+
await store.close();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* idempotent — already closed */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Open the local SessionStore for the REPL bootstrap. Returns
|
|
110
|
+
* `{ store: null, openedSessionId: undefined }` on any error so the
|
|
111
|
+
* caller falls through to memory-only mode rather than failing the
|
|
112
|
+
* launch. The one error we surface verbatim is the lock-busy case —
|
|
113
|
+
* that one is operator-actionable.
|
|
114
|
+
*/
|
|
115
|
+
async function openLocalStore(input) {
|
|
116
|
+
// Honour an explicit opt-out for offline-strict environments / CI.
|
|
117
|
+
// PUGI_DISABLE_SESSION_STORE=1 wipes the integration to zero. Useful
|
|
118
|
+
// for hermetic test runs and for operators who do not want any
|
|
119
|
+
// persistence under $HOME.
|
|
120
|
+
if (process.env.PUGI_DISABLE_SESSION_STORE === '1') {
|
|
121
|
+
return { store: null, openedSessionId: undefined };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const store = new SqliteSessionStore({ projectSlug: input.projectSlug });
|
|
125
|
+
const row = await store.open({
|
|
126
|
+
id: input.resumeLocalSessionId,
|
|
127
|
+
workspaceRoot: input.workspaceRoot,
|
|
128
|
+
projectSlug: input.projectSlug,
|
|
129
|
+
});
|
|
130
|
+
return { store, openedSessionId: row.id };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const code = error?.code;
|
|
134
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
135
|
+
if (code === 'EBUSY_SESSION_LOCK') {
|
|
136
|
+
process.stderr.write(`[pugi] ${msg} Continuing without local session persistence.\n`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
process.stderr.write(`[pugi] Local session store unavailable (${msg}). Continuing in memory-only mode.\n`);
|
|
140
|
+
}
|
|
141
|
+
return { store: null, openedSessionId: undefined };
|
|
57
142
|
}
|
|
58
143
|
}
|
|
59
144
|
/* ------------------------------------------------------------------ */
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chafa-validated brand-pug ANSI loader (α6.14.2 wave 5).
|
|
3
|
+
*
|
|
4
|
+
* CEO dogfood 2026-05-25: the hand-crafted 9-row ASCII pug in
|
|
5
|
+
* `repl-splash-art.ts` reads as "точно не похожа" — too abstract to
|
|
6
|
+
* carry the brand at boot. This module loads a pre-baked truecolor
|
|
7
|
+
* ANSI render of the canonical hero-pug PNG (cyber-zoo pug face with
|
|
8
|
+
* cyan eyes + circuit + chip) so the splash matches the brand glyph
|
|
9
|
+
* the operator already sees on pugi.io.
|
|
10
|
+
*
|
|
11
|
+
* Generation (operator-side, one-shot):
|
|
12
|
+
* chafa --size 32x16 --symbols=block+space --colors=full \
|
|
13
|
+
* apps/clawhost-web/public/brand/hero-pug.png \
|
|
14
|
+
* > apps/pugi-cli/assets/pugi-mascot.ansi
|
|
15
|
+
*
|
|
16
|
+
* The output is committed verbatim to the repo and shipped inside the
|
|
17
|
+
* `@pugi/cli` npm tarball under `assets/pugi-mascot.ansi` (the
|
|
18
|
+
* `package.json` `files` allowlist explicitly opts in). Runtime does
|
|
19
|
+
* NOT need `chafa` installed — we just read the file bytes and write
|
|
20
|
+
* them to stdout. If the file is missing (degraded install, tarball
|
|
21
|
+
* corruption, dev cwd drift), the splash falls back to the hand-crafted
|
|
22
|
+
* `PUG_MASCOT` art so the boot never crashes.
|
|
23
|
+
*
|
|
24
|
+
* The pre-Ink write convention mirrors the Claude Code Chrome plugin
|
|
25
|
+
* splash pattern: raw bytes go to `process.stdout` BEFORE the Ink
|
|
26
|
+
* render mount, so the terminal interprets the truecolor escapes
|
|
27
|
+
* directly instead of Ink trying to layout-engine over them.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the on-disk path to `pugi-mascot.ansi` relative to the
|
|
34
|
+
* compiled module. The CLI ships to `node_modules/@pugi/cli/dist/tui/`
|
|
35
|
+
* so the asset lives at `node_modules/@pugi/cli/assets/pugi-mascot.ansi`
|
|
36
|
+
* — two directory hops up from this file. In a local `pnpm dev`
|
|
37
|
+
* checkout the structure is the same (`src/tui/` ⇒ `../../assets/`)
|
|
38
|
+
* because tsx re-resolves the same relative tree.
|
|
39
|
+
*/
|
|
40
|
+
export function pugMascotAssetPath() {
|
|
41
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
return resolvePath(here, '..', '..', 'assets', 'pugi-mascot.ansi');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the chafa-baked ANSI render of the brand pug. Returns the raw
|
|
46
|
+
* bytes verbatim (UTF-8 string) — the terminal interprets the truecolor
|
|
47
|
+
* escapes directly. Returns null when the file is missing, unreadable,
|
|
48
|
+
* or trivially empty so the caller can fall back to `PUG_MASCOT`.
|
|
49
|
+
*
|
|
50
|
+
* `chafa --colors=full` wraps the render with cursor-hide (`\e[?25l`)
|
|
51
|
+
* on the head and cursor-show (`\e[?25h`) on the tail. We strip those
|
|
52
|
+
* so the splash does not accidentally hide the cursor across the rest
|
|
53
|
+
* of the REPL boot (Ink itself manages the cursor once it mounts).
|
|
54
|
+
*
|
|
55
|
+
* The asset is supply-chain controlled (committed in-repo, shipped in
|
|
56
|
+
* the npm tarball) so an arbitrary attacker cannot inject escapes
|
|
57
|
+
* today. The defence-in-depth strip below still drops categories of
|
|
58
|
+
* escapes that the splash has no legitimate need to emit — OSC window
|
|
59
|
+
* title sets, mouse-tracking enables, screen clears, cursor-position
|
|
60
|
+
* reports — so a future swap of the asset (or a corrupt tarball) cannot
|
|
61
|
+
* disrupt the terminal beyond the splash region. Truecolor (`CSI 38;2;
|
|
62
|
+
* R;G;B m`), reset (`CSI 0 m`), and explicit forms of cursor / line
|
|
63
|
+
* motion the render needs are left in.
|
|
64
|
+
*/
|
|
65
|
+
export function loadPugMascotAnsi() {
|
|
66
|
+
const path = pugMascotAssetPath();
|
|
67
|
+
try {
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
return null;
|
|
70
|
+
const raw = readFileSync(path, 'utf8');
|
|
71
|
+
if (!raw || raw.length === 0)
|
|
72
|
+
return null;
|
|
73
|
+
// 1. Drop OSC sequences. Two terminator forms:
|
|
74
|
+
// ESC ] ... BEL (0x1b 0x5d ... 0x07)
|
|
75
|
+
// ESC ] ... ESC \ (0x1b 0x5d ... 0x1b 0x5c, the ST form)
|
|
76
|
+
// A truecolor splash never needs OSC (those are for window title,
|
|
77
|
+
// icon, clipboard, hyperlinks, color-palette change). Drop them
|
|
78
|
+
// so a corrupted asset cannot rename the operator's terminal tab
|
|
79
|
+
// or smuggle a hyperlink into the splash region.
|
|
80
|
+
// 2. Drop CSI ? <mode> [hl] for mouse-tracking and screen-buffer
|
|
81
|
+
// switch modes (1000, 1001, 1002, 1003, 1004, 1005, 1006, 1015,
|
|
82
|
+
// 1049, 47, 1047, 1048). These would either start swallowing
|
|
83
|
+
// mouse input or flip the terminal into the alternate screen.
|
|
84
|
+
// 3. Drop CSI 6 n (cursor-position report). Would inject a fake
|
|
85
|
+
// CPR into the operator's stdin stream.
|
|
86
|
+
// 4. Drop CSI [23]J / CSI [23]K (full screen / line clear). A
|
|
87
|
+
// chafa render uses cursor-positioning per row, not bulk
|
|
88
|
+
// erases; bulk clears would wipe whatever the operator already
|
|
89
|
+
// had on screen above the splash.
|
|
90
|
+
// The cursor-hide/show wrappers (CSI ? 25 [lh]) are handled by
|
|
91
|
+
// the same CSI-?-mode pattern as the mouse / alt-screen modes.
|
|
92
|
+
const stripped = raw
|
|
93
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
94
|
+
.replace(/\x1b\[\?(?:25|47|1000|1001|1002|1003|1004|1005|1006|1015|1047|1048|1049)[lh]/g, '')
|
|
95
|
+
.replace(/\x1b\[6n/g, '')
|
|
96
|
+
.replace(/\x1b\[[23]?[JK]/g, '');
|
|
97
|
+
if (stripped.trim().length === 0)
|
|
98
|
+
return null;
|
|
99
|
+
return stripped;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Best-effort: any FS / decode error returns null so the splash
|
|
103
|
+
// falls back to the hand-crafted ASCII art. Never throws.
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function printPugMascotPreInk(sink) {
|
|
108
|
+
const ansi = loadPugMascotAnsi();
|
|
109
|
+
if (ansi === null)
|
|
110
|
+
return false;
|
|
111
|
+
// Trailing newline so the Ink header lands on a fresh row rather
|
|
112
|
+
// than smashing into the last pug row.
|
|
113
|
+
sink.write(ansi);
|
|
114
|
+
if (!ansi.endsWith('\n'))
|
|
115
|
+
sink.write('\n');
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=repl-splash-mascot.js.map
|
package/dist/tui/repl-splash.js
CHANGED
|
@@ -61,7 +61,13 @@ export function ReplSplash(props) {
|
|
|
61
61
|
if (props.skipSplash) {
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
// α6.14.2 wave 5: when the host pre-printed the chafa-baked brand-pug
|
|
65
|
+
// ANSI render to stdout before Ink mounted, suppress the hand-crafted
|
|
66
|
+
// PUG_MASCOT column here so the operator does not see two stacked
|
|
67
|
+
// pugs. The header card still renders inline so wordmark + status
|
|
68
|
+
// rows stay attached to the splash flow.
|
|
69
|
+
const showHandCraftedMascot = props.mascotPrePrinted !== true;
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [showHandCraftedMascot ? _jsx(MascotColumn, {}) : null, _jsxs(Box, { flexDirection: "column", marginLeft: showHandCraftedMascot ? 2 : 0, marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` v${props.cliVersion}` })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(HeaderRow, { label: "Plan", value: props.plan ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Model", value: props.model ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Tenant", value: props.tenant ?? PLACEHOLDER }), _jsx(HeaderRow, { label: "Workspace", value: props.workspaceLabel })] })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(40) }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Tips for getting started:" }), _jsx(TipRow, { index: 1, text: "Type a brief, the workforce dispatches" }), _jsx(TipRow, { index: 2, text: "/help for slash commands, /web <url> to pull a page" }), _jsx(TipRow, { index: 3, text: "/skills install <name> for Anthropic / OpenClaw skills" })] })] }));
|
|
65
71
|
}
|
|
66
72
|
/**
|
|
67
73
|
* Renders the multi-line ASCII pug. Each row is split into colored
|
package/dist/tui/repl.js
CHANGED
|
@@ -20,22 +20,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
21
21
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
22
22
|
import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
|
|
23
|
-
import {
|
|
23
|
+
import { AgentTreePane } from './agent-tree-pane.js';
|
|
24
|
+
import { AskModal, PlanReviewModal } from './ask-modal.js';
|
|
24
25
|
import { ConversationPane } from './conversation-pane.js';
|
|
25
26
|
import { InputBox } from './input-box.js';
|
|
26
27
|
import { ReplSplash } from './repl-splash.js';
|
|
27
28
|
import { StatusBar } from './status-bar.js';
|
|
29
|
+
import { ToolStreamPane } from './tool-stream-pane.js';
|
|
28
30
|
import { UpdateBanner } from './update-banner.js';
|
|
29
31
|
import { collectWorkspaceContext } from './workspace-context.js';
|
|
30
32
|
import { slugForCwd } from '../core/repl/history.js';
|
|
31
33
|
import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
|
|
32
34
|
const TICK_INTERVAL_MS = 200;
|
|
33
35
|
const PULSE_INTERVAL_MS = 700;
|
|
36
|
+
// α6.12: maximum transcript rows the conversation pane renders at once.
|
|
37
|
+
// Older rows scroll off the top; full history stays in session state.
|
|
38
|
+
const CONVERSATION_WINDOW = 12;
|
|
34
39
|
export function Repl(props) {
|
|
35
40
|
const [state, setState] = useState(props.session.getState());
|
|
36
41
|
const [overlay, setOverlay] = useState('none');
|
|
37
42
|
const [pulsePhase, setPulsePhase] = useState(0);
|
|
38
43
|
const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
|
|
44
|
+
// α6.12: operator-driven collapse for the tool stream pane. The CLI
|
|
45
|
+
// host can hide the pane entirely via `--no-tool-stream`; this state
|
|
46
|
+
// is the runtime toggle (Ctrl+T) for operators who want the pane on
|
|
47
|
+
// screen but folded to a single row while they read a long reply.
|
|
48
|
+
const [toolStreamCollapsed, setToolStreamCollapsed] = useState(false);
|
|
39
49
|
// α6.14 wave 3: boot splash visible until first input, first
|
|
40
50
|
// `agent.spawned` event, or 10s idle. The host gates the initial
|
|
41
51
|
// visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
|
|
@@ -122,7 +132,31 @@ export function Repl(props) {
|
|
|
122
132
|
setOverlay('none');
|
|
123
133
|
}
|
|
124
134
|
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
125
|
-
|
|
135
|
+
// α6.12: Ctrl+T toggles the tool stream pane between expanded and
|
|
136
|
+
// collapsed states. Active only while no overlay is open, so the
|
|
137
|
+
// toggle never fights the help/roster dismiss handler. The input box
|
|
138
|
+
// owns its own raw-input mode, so this listener only fires on the
|
|
139
|
+
// global Ctrl+T binding rather than every printable keystroke.
|
|
140
|
+
useInput((input, key) => {
|
|
141
|
+
if (key.ctrl && input === 't') {
|
|
142
|
+
setToolStreamCollapsed((prev) => !prev);
|
|
143
|
+
}
|
|
144
|
+
}, { isActive: overlay === 'none' && props.hideToolStream !== true });
|
|
145
|
+
// α6.3 office-hours: a pending ask or plan-review modal pauses input
|
|
146
|
+
// until the operator resolves it. The modal owns its own useInput
|
|
147
|
+
// hook, so the InputBox unmounts while a modal is open to avoid two
|
|
148
|
+
// raw-input listeners competing for the same keystroke. Resolution
|
|
149
|
+
// forwards through ReplSession.resolveAsk / resolvePlanReview.
|
|
150
|
+
const askPending = state.pendingAsk !== null;
|
|
151
|
+
const planPending = state.pendingPlanReview !== null;
|
|
152
|
+
const modalActive = askPending || planPending;
|
|
153
|
+
const handleAskResolve = useCallback((verdict) => {
|
|
154
|
+
void props.session.resolveAsk(verdict);
|
|
155
|
+
}, [props.session]);
|
|
156
|
+
const handlePlanReviewResolve = useCallback((result) => {
|
|
157
|
+
void props.session.resolvePlanReview(result);
|
|
158
|
+
}, [props.session]);
|
|
159
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, 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, now: props.now,
|
|
126
160
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
127
161
|
// the same basename do not share history. state.workspaceLabel
|
|
128
162
|
// is the basename only. Codex review P2.
|
|
@@ -131,13 +165,23 @@ export function Repl(props) {
|
|
|
131
165
|
function Header({ state }) {
|
|
132
166
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
|
133
167
|
}
|
|
134
|
-
function MainArea({ state, personaNames, nowEpochMs, }) {
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
function MainArea({ state, personaNames, nowEpochMs, hideToolStream, toolStreamCollapsed, }) {
|
|
169
|
+
// α6.12: three vertical panes stacked above the input box.
|
|
170
|
+
//
|
|
171
|
+
// 1. Conversation pane (top) - transcript with Markdown render.
|
|
172
|
+
// 2. Tool stream pane (mid) - live Read/Edit/Bash/Grep lines.
|
|
173
|
+
// Hidden when `--no-tool-stream` is
|
|
174
|
+
// set; collapsed via Ctrl+T while
|
|
175
|
+
// the pane is visible.
|
|
176
|
+
// 3. Agent tree pane (bottom) - Cyber-Zoo roster with persona /
|
|
177
|
+
// status / duration / token counts.
|
|
178
|
+
//
|
|
179
|
+
// The window over the transcript is small (last 12 rows) so the
|
|
180
|
+
// bottom of the frame stays anchored to the input box. New agents
|
|
181
|
+
// push the operator line up the screen, mirroring Claude Code /
|
|
182
|
+
// Codex CLI / Gemini CLI rendering.
|
|
183
|
+
const conversationSlice = state.transcript.slice(-CONVERSATION_WINDOW);
|
|
184
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), hideToolStream ? null : (_jsx(Box, { marginTop: 1, children: _jsx(ToolStreamPane, { calls: state.toolCalls, collapsed: toolStreamCollapsed }) })), _jsx(Box, { marginTop: 1, children: _jsx(AgentTreePane, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
|
|
141
185
|
}
|
|
142
186
|
function HelpOverlay() {
|
|
143
187
|
// Group commands by their `group` field so the operator scans the
|
|
@@ -187,12 +231,17 @@ function applyVerdictSideEffects(verdict, handlers) {
|
|
|
187
231
|
case 'clear':
|
|
188
232
|
case 'version':
|
|
189
233
|
case 'jobs':
|
|
234
|
+
case 'ask':
|
|
235
|
+
case 'consensus':
|
|
190
236
|
case 'diff':
|
|
191
237
|
case 'cost':
|
|
192
238
|
case 'status':
|
|
239
|
+
case 'resume':
|
|
193
240
|
case 'stub':
|
|
194
241
|
// All non-overlay verdicts: the session module already appended
|
|
195
|
-
// any operator-visible system lines
|
|
242
|
+
// any operator-visible system lines (and, for `ask`, set
|
|
243
|
+
// pendingAsk so the modal renders on the next frame). No further
|
|
244
|
+
// UI side effect needed here.
|
|
196
245
|
return;
|
|
197
246
|
}
|
|
198
247
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const DEFAULT_COLLAPSE_THRESHOLD = 5;
|
|
4
|
+
const DEFAULT_MAX_ROWS = 8;
|
|
5
|
+
export function ToolStreamPane(props) {
|
|
6
|
+
const calls = props.calls;
|
|
7
|
+
const collapseThreshold = props.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD;
|
|
8
|
+
const maxRows = props.maxRows ?? DEFAULT_MAX_ROWS;
|
|
9
|
+
if (calls.length === 0) {
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PaneHeader, { count: 0, collapsed: props.collapsed }), _jsx(Text, { dimColor: true, children: "No tool calls yet. Dispatch a brief and watch tools land here." })] }));
|
|
11
|
+
}
|
|
12
|
+
if (props.collapsed === true) {
|
|
13
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(PaneHeader, { count: calls.length, collapsed: true }) }));
|
|
14
|
+
}
|
|
15
|
+
// Tail-window the rows so a long-running session does not overflow
|
|
16
|
+
// the bottom half of the frame. Older rows stay in the session state
|
|
17
|
+
// for /jobs and /diff inspection.
|
|
18
|
+
const visible = calls.slice(-maxRows);
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PaneHeader, { count: calls.length, collapsed: false }), visible.map((call) => (_jsx(ToolCallRow, { call: call, collapseThreshold: collapseThreshold }, call.id)))] }));
|
|
20
|
+
}
|
|
21
|
+
function PaneHeader({ count, collapsed }) {
|
|
22
|
+
const verb = collapsed ? 'Ctrl+T to expand' : 'Ctrl+T to collapse';
|
|
23
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ tools ' }), _jsx(Text, { dimColor: true, children: `(${count}) ` }), _jsx(Text, { dimColor: true, children: verb })] }));
|
|
24
|
+
}
|
|
25
|
+
function ToolCallRow({ call, collapseThreshold, }) {
|
|
26
|
+
const glyph = statusGlyph(call.status);
|
|
27
|
+
const color = statusColor(call.status);
|
|
28
|
+
const label = formatToolLabel(call.tool, call.args);
|
|
29
|
+
const summary = formatSummary(call);
|
|
30
|
+
const showHint = (call.resultLines ?? 0) > collapseThreshold;
|
|
31
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: ' ' }), _jsx(Text, { bold: true, children: label }), _jsx(Text, { dimColor: true, children: ` ${summary}` }), showHint ? (_jsx(Text, { dimColor: true, children: ` · ${call.resultLines} lines, Ctrl+O to expand` })) : null] }));
|
|
32
|
+
}
|
|
33
|
+
function statusGlyph(status) {
|
|
34
|
+
switch (status) {
|
|
35
|
+
case 'running':
|
|
36
|
+
return '→';
|
|
37
|
+
case 'ok':
|
|
38
|
+
return '✓';
|
|
39
|
+
case 'error':
|
|
40
|
+
return '✗';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function statusColor(status) {
|
|
44
|
+
switch (status) {
|
|
45
|
+
case 'running':
|
|
46
|
+
return 'cyan';
|
|
47
|
+
case 'ok':
|
|
48
|
+
return 'green';
|
|
49
|
+
case 'error':
|
|
50
|
+
return 'red';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Render the canonical `Tool(args)` form. Tool names are capitalised
|
|
55
|
+
* the way Claude Code shows them; args are truncated to 60 chars so
|
|
56
|
+
* the row stays single-line even on 80-col terminals.
|
|
57
|
+
*/
|
|
58
|
+
function formatToolLabel(tool, args) {
|
|
59
|
+
const name = toolDisplayName(tool);
|
|
60
|
+
const trimmedArgs = args.length > 60 ? `${args.slice(0, 57)}…` : args;
|
|
61
|
+
return `${name}(${trimmedArgs})`;
|
|
62
|
+
}
|
|
63
|
+
function toolDisplayName(tool) {
|
|
64
|
+
switch (tool) {
|
|
65
|
+
case 'read':
|
|
66
|
+
return 'Read';
|
|
67
|
+
case 'edit':
|
|
68
|
+
return 'Edit';
|
|
69
|
+
case 'bash':
|
|
70
|
+
return 'Bash';
|
|
71
|
+
case 'grep':
|
|
72
|
+
return 'Grep';
|
|
73
|
+
case 'glob':
|
|
74
|
+
return 'Glob';
|
|
75
|
+
case 'web_fetch':
|
|
76
|
+
return 'WebFetch';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function formatSummary(call) {
|
|
80
|
+
if (call.status === 'running') {
|
|
81
|
+
return call.detail ?? 'running...';
|
|
82
|
+
}
|
|
83
|
+
if (call.status === 'error') {
|
|
84
|
+
return call.detail ?? 'error';
|
|
85
|
+
}
|
|
86
|
+
// ok
|
|
87
|
+
const duration = typeof call.durationMs === 'number' ? `${call.durationMs}ms` : '';
|
|
88
|
+
const detail = call.detail ?? 'OK';
|
|
89
|
+
return duration.length > 0 ? `${detail} ${duration}` : detail;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=tool-stream-pane.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.15",
|
|
4
4
|
"description": "Pugi CLI — terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"bin/run.js",
|
|
30
30
|
"dist/**/*.js",
|
|
31
|
+
"assets/**/*.ansi",
|
|
31
32
|
"README.md",
|
|
32
33
|
"LICENSE"
|
|
33
34
|
],
|
|
34
35
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
36
|
+
"node": ">=22.5.0"
|
|
36
37
|
},
|
|
37
38
|
"publishConfig": {
|
|
38
39
|
"access": "public"
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"undici": "^8.3.0",
|
|
49
50
|
"zod": "^3.23.0",
|
|
50
51
|
"@pugi/personas": "0.1.0",
|
|
51
|
-
"@pugi/sdk": "0.1.0-alpha.
|
|
52
|
+
"@pugi/sdk": "0.1.0-alpha.15"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@types/node": "^22.0.0",
|