@pugi/cli 0.1.0-alpha.8 → 0.1.0-beta.1
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/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -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/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1909 -13
- package/dist/core/repl/slash-commands.js +59 -32
- 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 +184 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +728 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/web-fetch.js +1 -1
- 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/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +183 -4
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +117 -0
- package/dist/tui/repl.js +108 -11
- package/dist/tui/slash-palette.js +47 -10
- package/dist/tui/status-bar.js +77 -4
- package/dist/tui/tool-stream-pane.js +91 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +11 -5
package/dist/tui/repl.js
CHANGED
|
@@ -20,20 +20,41 @@ 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';
|
|
27
|
+
import { ReplSplash } from './repl-splash.js';
|
|
26
28
|
import { StatusBar } from './status-bar.js';
|
|
29
|
+
import { ToolStreamPane } from './tool-stream-pane.js';
|
|
27
30
|
import { UpdateBanner } from './update-banner.js';
|
|
31
|
+
import { collectWorkspaceContext } from './workspace-context.js';
|
|
28
32
|
import { slugForCwd } from '../core/repl/history.js';
|
|
29
33
|
import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
|
|
30
34
|
const TICK_INTERVAL_MS = 200;
|
|
31
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;
|
|
32
39
|
export function Repl(props) {
|
|
33
40
|
const [state, setState] = useState(props.session.getState());
|
|
34
41
|
const [overlay, setOverlay] = useState('none');
|
|
35
42
|
const [pulsePhase, setPulsePhase] = useState(0);
|
|
36
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);
|
|
49
|
+
// α6.14 wave 3: boot splash visible until first input, first
|
|
50
|
+
// `agent.spawned` event, or 10s idle. The host gates the initial
|
|
51
|
+
// visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
|
|
52
|
+
const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
|
|
53
|
+
const dismissSplash = useCallback(() => setSplashVisible(false), []);
|
|
54
|
+
// α6.14 wave 3: workspace context snapshot for the status bar. We
|
|
55
|
+
// read once at mount and freeze; a brand-new PUGI.md or skill is
|
|
56
|
+
// surfaced on the next REPL boot rather than via a watcher.
|
|
57
|
+
const workspaceContext = useMemo(() => props.workspaceContext ?? collectWorkspaceContext(process.cwd()), [props.workspaceContext]);
|
|
37
58
|
// Subscribe to session state updates. The session module fires the
|
|
38
59
|
// callback synchronously inside `patch` so we mirror without a
|
|
39
60
|
// batching layer.
|
|
@@ -62,9 +83,24 @@ export function Repl(props) {
|
|
|
62
83
|
useEffect(() => {
|
|
63
84
|
props.onOverlayChange?.(overlay);
|
|
64
85
|
}, [overlay, props]);
|
|
86
|
+
// α6.14 wave 3: dismiss the boot splash once the first agent spawns
|
|
87
|
+
// (the operator has clearly engaged the system) or the transcript
|
|
88
|
+
// gains a row. Mirrors the natural attention shift Claude Code /
|
|
89
|
+
// Codex / Gemini CLI all do on their boot screens.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!splashVisible)
|
|
92
|
+
return;
|
|
93
|
+
if (state.agents.length > 0 || state.transcript.length > 0) {
|
|
94
|
+
setSplashVisible(false);
|
|
95
|
+
}
|
|
96
|
+
}, [splashVisible, state.agents.length, state.transcript.length]);
|
|
65
97
|
const personaNames = useMemo(() => buildPersonaNameMap(), []);
|
|
66
98
|
const { exit } = useApp();
|
|
67
99
|
const handleSubmit = useCallback((line) => {
|
|
100
|
+
// Dismiss the boot splash on first operator input. Idempotent —
|
|
101
|
+
// `setSplashVisible(false)` is a no-op once the state already
|
|
102
|
+
// settled to false (timer fired or `agent.spawned` arrived).
|
|
103
|
+
setSplashVisible(false);
|
|
68
104
|
// Run async without awaiting - the session module owns the
|
|
69
105
|
// network call, errors land in the transcript automatically.
|
|
70
106
|
void props.session.handleInput(line).then((verdict) => {
|
|
@@ -96,22 +132,78 @@ export function Repl(props) {
|
|
|
96
132
|
setOverlay('none');
|
|
97
133
|
}
|
|
98
134
|
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
99
|
-
|
|
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
|
+
// α6.9: Ctrl+C abort handler. Forwards to ReplSession.cancel() which
|
|
160
|
+
// aborts the in-flight dispatch, closes the SSE stream, and surfaces
|
|
161
|
+
// "Aborted." in the transcript.
|
|
162
|
+
//
|
|
163
|
+
// Return contract (consumed by InputBox):
|
|
164
|
+
// - true - dispatch was cancelled (keep the buffer + DO arm
|
|
165
|
+
// the press-again-to-exit timer; second Ctrl+C in
|
|
166
|
+
// the window exits).
|
|
167
|
+
// - false - idle / nothing to cancel (legacy: clear buffer +
|
|
168
|
+
// arm the exit timer so the operator sees the hint
|
|
169
|
+
// and can confirm exit on the next press).
|
|
170
|
+
// - undefined - bypassed entirely (e.g. a modal owns the input).
|
|
171
|
+
// InputBox MUST NOT arm the exit timer and MUST
|
|
172
|
+
// NOT clear the buffer. P2 fix: previously this
|
|
173
|
+
// returned `false` and the buffer-clear path wiped
|
|
174
|
+
// the operator's mid-typed modal text on the first
|
|
175
|
+
// Ctrl+C, costing a press of work.
|
|
176
|
+
const handleCancel = useCallback(() => {
|
|
177
|
+
if (modalActive)
|
|
178
|
+
return undefined;
|
|
179
|
+
return props.session.cancel();
|
|
180
|
+
}, [props.session, modalActive]);
|
|
181
|
+
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, onCancel: handleCancel, now: props.now,
|
|
100
182
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
101
183
|
// the same basename do not share history. state.workspaceLabel
|
|
102
184
|
// is the basename only. Codex review P2.
|
|
103
|
-
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
|
|
185
|
+
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 })] })] }));
|
|
104
186
|
}
|
|
105
187
|
function Header({ state }) {
|
|
106
188
|
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('_', ' ') })] }));
|
|
107
189
|
}
|
|
108
|
-
function MainArea({ state, personaNames, nowEpochMs, }) {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
190
|
+
function MainArea({ state, personaNames, nowEpochMs, hideToolStream, toolStreamCollapsed, }) {
|
|
191
|
+
// α6.12: three vertical panes stacked above the input box.
|
|
192
|
+
//
|
|
193
|
+
// 1. Conversation pane (top) - transcript with Markdown render.
|
|
194
|
+
// 2. Tool stream pane (mid) - live Read/Edit/Bash/Grep lines.
|
|
195
|
+
// Hidden when `--no-tool-stream` is
|
|
196
|
+
// set; collapsed via Ctrl+T while
|
|
197
|
+
// the pane is visible.
|
|
198
|
+
// 3. Agent tree pane (bottom) - Cyber-Zoo roster with persona /
|
|
199
|
+
// status / duration / token counts.
|
|
200
|
+
//
|
|
201
|
+
// The window over the transcript is small (last 12 rows) so the
|
|
202
|
+
// bottom of the frame stays anchored to the input box. New agents
|
|
203
|
+
// push the operator line up the screen, mirroring Claude Code /
|
|
204
|
+
// Codex CLI / Gemini CLI rendering.
|
|
205
|
+
const conversationSlice = state.transcript.slice(-CONVERSATION_WINDOW);
|
|
206
|
+
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 }) })] }));
|
|
115
207
|
}
|
|
116
208
|
function HelpOverlay() {
|
|
117
209
|
// Group commands by their `group` field so the operator scans the
|
|
@@ -161,12 +253,17 @@ function applyVerdictSideEffects(verdict, handlers) {
|
|
|
161
253
|
case 'clear':
|
|
162
254
|
case 'version':
|
|
163
255
|
case 'jobs':
|
|
256
|
+
case 'ask':
|
|
257
|
+
case 'consensus':
|
|
164
258
|
case 'diff':
|
|
165
259
|
case 'cost':
|
|
166
260
|
case 'status':
|
|
261
|
+
case 'resume':
|
|
167
262
|
case 'stub':
|
|
168
263
|
// All non-overlay verdicts: the session module already appended
|
|
169
|
-
// any operator-visible system lines
|
|
264
|
+
// any operator-visible system lines (and, for `ask`, set
|
|
265
|
+
// pendingAsk so the modal renders on the next frame). No further
|
|
266
|
+
// UI side effect needed here.
|
|
170
267
|
return;
|
|
171
268
|
}
|
|
172
269
|
}
|
|
@@ -3,16 +3,21 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
4
4
|
export const PALETTE_ROW_LIMIT = 8;
|
|
5
5
|
/**
|
|
6
|
-
* Compute the
|
|
6
|
+
* Compute the FULL filtered candidate list for a given input buffer.
|
|
7
7
|
* Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
|
|
8
8
|
* logic so the input box and the unit test agree on the shape.
|
|
9
9
|
*
|
|
10
|
+
* Wave 4 fix 2026-05-25: returns the FULL filtered set, not just the
|
|
11
|
+
* first PALETTE_ROW_LIMIT rows. The palette renderer now windows the
|
|
12
|
+
* visible slice internally based on `focusedIndex`, so the operator
|
|
13
|
+
* can scroll past row 7 via ↑/↓ on a long list (e.g. 20 commands when
|
|
14
|
+
* the buffer is `/`). `totalBeforeLimit` is preserved on the return
|
|
15
|
+
* shape for backward compatibility but always equals `rows.length`.
|
|
16
|
+
*
|
|
10
17
|
* Behaviour:
|
|
11
18
|
* - Empty / non-slash buffer → empty result; palette stays hidden.
|
|
12
19
|
* - `/` alone → all registry rows (the operator wants to browse).
|
|
13
20
|
* - `/he` → rows whose name starts with `he` (case-insensitive).
|
|
14
|
-
* - Capped at PALETTE_ROW_LIMIT; the input box renders a hint when
|
|
15
|
-
* `totalBeforeLimit > rows.length`.
|
|
16
21
|
*/
|
|
17
22
|
export function filterPalette(buffer) {
|
|
18
23
|
if (!buffer.startsWith('/')) {
|
|
@@ -31,9 +36,33 @@ export function filterPalette(buffer) {
|
|
|
31
36
|
const all = prefix.length === 0
|
|
32
37
|
? SLASH_COMMAND_HELP
|
|
33
38
|
: SLASH_COMMAND_HELP.filter((row) => row.name.toLowerCase().startsWith(prefix));
|
|
39
|
+
// Defensive copy via spread so callers cannot mutate the registry
|
|
40
|
+
// through the returned readonly array (TS-only enforcement, but
|
|
41
|
+
// future refactors might assume the contract).
|
|
42
|
+
const rows = [...all];
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
totalBeforeLimit: rows.length,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function computePaletteWindow(rows, focusedIndex) {
|
|
49
|
+
const total = rows.length;
|
|
50
|
+
if (total <= PALETTE_ROW_LIMIT) {
|
|
51
|
+
return { visible: rows, startIndex: 0, total };
|
|
52
|
+
}
|
|
53
|
+
// Sliding window: anchor the start so the focused row stays inside the
|
|
54
|
+
// PALETTE_ROW_LIMIT span. Clamp at both ends so we never render fewer
|
|
55
|
+
// than PALETTE_ROW_LIMIT rows when the list is long enough to fill them.
|
|
56
|
+
let start = focusedIndex - Math.floor(PALETTE_ROW_LIMIT / 2);
|
|
57
|
+
if (start < 0)
|
|
58
|
+
start = 0;
|
|
59
|
+
const maxStart = total - PALETTE_ROW_LIMIT;
|
|
60
|
+
if (start > maxStart)
|
|
61
|
+
start = maxStart;
|
|
34
62
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
|
|
64
|
+
startIndex: start,
|
|
65
|
+
total,
|
|
37
66
|
};
|
|
38
67
|
}
|
|
39
68
|
/**
|
|
@@ -57,13 +86,21 @@ export function completePalette(buffer, rows, focusedIndex) {
|
|
|
57
86
|
export function SlashPalette(props) {
|
|
58
87
|
if (props.rows.length === 0)
|
|
59
88
|
return null;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
// Wave 4 fix 2026-05-25: compute the visible window so the operator
|
|
90
|
+
// can scroll past row 7 on long lists. Focus indexes the full rows
|
|
91
|
+
// array; the window slides to keep the focused row visible.
|
|
92
|
+
const window = computePaletteWindow(props.rows, props.focusedIndex);
|
|
93
|
+
const overflow = window.total > PALETTE_ROW_LIMIT;
|
|
94
|
+
// Indicator value: focused row is 1-based for human display
|
|
95
|
+
// ("→ 9/20" reads better than "→ 8/20" when the operator is on
|
|
96
|
+
// the ninth entry).
|
|
97
|
+
const focusedDisplayIndex = Math.min(window.total, Math.max(1, props.focusedIndex + 1));
|
|
98
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: [window.visible.map((row, visibleIdx) => {
|
|
99
|
+
const absoluteIdx = window.startIndex + visibleIdx;
|
|
100
|
+
const focused = absoluteIdx === props.focusedIndex;
|
|
64
101
|
const glyph = focused ? '▸' : '·';
|
|
65
102
|
const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
|
|
66
103
|
return (_jsxs(Box, { children: [_jsx(Text, { color: focused ? 'cyan' : 'gray', children: `${glyph} ` }), _jsx(Text, { bold: focused, color: focused ? 'cyan' : undefined, dimColor: !focused, children: cmd }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name));
|
|
67
|
-
}), overflow
|
|
104
|
+
}), overflow ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` → ${focusedDisplayIndex}/${window.total}` }) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' ↑/↓ select · Tab complete · Enter run · Esc close' }) })] }));
|
|
68
105
|
}
|
|
69
106
|
//# sourceMappingURL=slash-palette.js.map
|
package/dist/tui/status-bar.js
CHANGED
|
@@ -12,13 +12,38 @@ export function StatusBar(props) {
|
|
|
12
12
|
const tokenLabel = formatTokens(props.tokensDownstreamTotal);
|
|
13
13
|
const phase = clampPhase(props.pulsePhase);
|
|
14
14
|
const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// α6.9: composite status label — connection problems trump dispatch
|
|
16
|
+
// state because the operator needs to know about a dropped admin-api
|
|
17
|
+
// first. When the connection is healthy (`on_watch` / `connecting`),
|
|
18
|
+
// the FSM dispatch state takes over to show the dispatch lifecycle
|
|
19
|
+
// (`dispatching` / `tool: read` / `aborting` / etc.).
|
|
20
|
+
const status = composeStatusLabel(props.connection, props.dispatchState, props.dispatchToolLabel);
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: status.color, children: `${glyph ?? '●'} ${status.label}` }), _jsx(Text, { dimColor: true, children: ` · ${props.activeAgentCount} agents · ` }), _jsx(Text, { children: `↓ ${tokenLabel} tokens` }), _jsx(Text, { dimColor: true, children: ` · ${elapsedLabel}` })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${formatCount(props.pugiMdCount)} PUGI.md · ${formatCount(props.mcpServerCount)} MCP · ${formatCount(props.skillCount)} skills · ${formatQuota(props.quotaPct)} quota` }) })] }));
|
|
17
22
|
}
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Render a count badge — number if defined, `—` placeholder otherwise.
|
|
25
|
+
* The placeholder mirrors the splash header convention so the operator
|
|
26
|
+
* recognises "not yet known" vs "zero" at a glance.
|
|
27
|
+
*/
|
|
28
|
+
function formatCount(value) {
|
|
29
|
+
return typeof value === 'number' ? value.toString() : '—';
|
|
30
|
+
}
|
|
31
|
+
function formatQuota(pct) {
|
|
32
|
+
if (typeof pct !== 'number' || Number.isNaN(pct))
|
|
33
|
+
return '—';
|
|
34
|
+
return `${Math.round(pct)}%`;
|
|
35
|
+
}
|
|
36
|
+
// Exported for test introspection (status-bar-fsm.spec.tsx asserts
|
|
37
|
+
// connecting vs on_watch render in distinct colors; ink-testing-library
|
|
38
|
+
// strips ANSI from lastFrame() so we read the resolved color directly).
|
|
39
|
+
export function connectionLabel(connection) {
|
|
19
40
|
switch (connection) {
|
|
20
41
|
case 'connecting':
|
|
21
|
-
|
|
42
|
+
// P2 fix: was 'cyan', same as 'on_watch' - operator could not
|
|
43
|
+
// tell boot from stable. Magenta is distinct from every other
|
|
44
|
+
// state in this palette (cyan steady, yellow reconnect, gray
|
|
45
|
+
// offline) so a brief flicker through this state stands out.
|
|
46
|
+
return { label: 'connecting', color: 'magenta' };
|
|
22
47
|
case 'on_watch':
|
|
23
48
|
return { label: 'on watch', color: 'cyan' };
|
|
24
49
|
case 'reconnecting':
|
|
@@ -27,6 +52,54 @@ function connectionLabel(connection) {
|
|
|
27
52
|
return { label: 'offline', color: 'gray' };
|
|
28
53
|
}
|
|
29
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* α6.9: compose the visible status label from connection + FSM state.
|
|
57
|
+
*
|
|
58
|
+
* Priority order:
|
|
59
|
+
*
|
|
60
|
+
* 1. `offline` / `reconnecting` — transport health wins; the
|
|
61
|
+
* operator needs to know about a dropped stream before anything
|
|
62
|
+
* about the dispatch.
|
|
63
|
+
* 2. `aborting` / `aborted` / `failed` — operator-visible terminal
|
|
64
|
+
* states the FSM reached; the colour shifts to amber/red so the
|
|
65
|
+
* anomaly stands out vs the calm cyan baseline.
|
|
66
|
+
* 3. `tool_running` — surfaces the tool label when available
|
|
67
|
+
* (`tool: read`), falls back to `tool` when not.
|
|
68
|
+
* 4. `awaiting_response` — `dispatching` (matches Codex CLI's verb
|
|
69
|
+
* for the same state).
|
|
70
|
+
* 5. `completed` — `shipped` (matches the agent tree status glyph
|
|
71
|
+
* so the operator's eye links the two surfaces).
|
|
72
|
+
* 6. `idle` / unknown — connection label (`on watch` / `connecting`).
|
|
73
|
+
*
|
|
74
|
+
* The dispatch label `dispatchToolLabel` is already shaped as
|
|
75
|
+
* `tool: <kind>` upstream so we just concatenate; null falls through
|
|
76
|
+
* to the bare `tool` placeholder.
|
|
77
|
+
*/
|
|
78
|
+
function composeStatusLabel(connection, dispatchState, toolLabel) {
|
|
79
|
+
// Transport health wins.
|
|
80
|
+
if (connection === 'offline' || connection === 'reconnecting') {
|
|
81
|
+
return connectionLabel(connection);
|
|
82
|
+
}
|
|
83
|
+
// FSM dispatch state overlay (only when the FSM was wired).
|
|
84
|
+
switch (dispatchState) {
|
|
85
|
+
case 'aborting':
|
|
86
|
+
return { label: 'aborting', color: 'yellow' };
|
|
87
|
+
case 'aborted':
|
|
88
|
+
return { label: 'aborted', color: 'gray' };
|
|
89
|
+
case 'failed':
|
|
90
|
+
return { label: 'failed', color: 'red' };
|
|
91
|
+
case 'tool_running':
|
|
92
|
+
return { label: toolLabel ?? 'tool', color: 'cyan' };
|
|
93
|
+
case 'awaiting_response':
|
|
94
|
+
return { label: 'dispatching', color: 'cyan' };
|
|
95
|
+
case 'completed':
|
|
96
|
+
return { label: 'shipped', color: 'green' };
|
|
97
|
+
case 'idle':
|
|
98
|
+
case undefined:
|
|
99
|
+
default:
|
|
100
|
+
return connectionLabel(connection);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
30
103
|
function formatElapsed(startedAt, now) {
|
|
31
104
|
if (typeof startedAt !== 'number')
|
|
32
105
|
return 'idle';
|
|
@@ -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
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-context badges for the REPL bottom status bar (α6.14
|
|
3
|
+
* wave 3). Mirrors the Gemini CLI pattern where the operator sees
|
|
4
|
+
* `N GEMINI.md · N MCP · N skills · N% quota` at a glance.
|
|
5
|
+
*
|
|
6
|
+
* Pure-IO helpers: each function reads disk once, swallows every error
|
|
7
|
+
* (a missing directory is the common case for a fresh workspace), and
|
|
8
|
+
* returns a count. The REPL host calls these at mount, caches the
|
|
9
|
+
* result in component state, and passes them to `<StatusBar />`. We
|
|
10
|
+
* intentionally do NOT refresh on every keystroke — a brand-new
|
|
11
|
+
* PUGI.md does not appear mid-session often enough to warrant a
|
|
12
|
+
* watcher.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Count PUGI.md files in the workspace root. We do NOT walk
|
|
19
|
+
* subdirectories — a deep grep would burn IO on every REPL boot and
|
|
20
|
+
* the convention is one root file. Mirrors how Gemini CLI counts
|
|
21
|
+
* `GEMINI.md` at the project root only.
|
|
22
|
+
*/
|
|
23
|
+
export function countPugiMdFiles(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isFile())
|
|
29
|
+
continue;
|
|
30
|
+
// Case-insensitive so PUGI.md, Pugi.md, pugi.md all count.
|
|
31
|
+
if (entry.name.toLowerCase() === 'pugi.md')
|
|
32
|
+
count += 1;
|
|
33
|
+
}
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Count MCP servers wired into `.pugi/mcp.json` at the workspace root.
|
|
42
|
+
* Reads the file, parses JSON, returns the `servers` array length when
|
|
43
|
+
* present. Returns 0 on any failure (file missing, malformed JSON,
|
|
44
|
+
* wrong shape) — the status bar treats 0 and "error" the same.
|
|
45
|
+
*/
|
|
46
|
+
export function countMcpServers(cwd) {
|
|
47
|
+
const path = join(cwd, '.pugi', 'mcp.json');
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return 0;
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(path, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed && typeof parsed === 'object') {
|
|
54
|
+
const servers = parsed.servers;
|
|
55
|
+
if (Array.isArray(servers))
|
|
56
|
+
return servers.length;
|
|
57
|
+
// Also support the `{ "<name>": { ... } }` map shape used by
|
|
58
|
+
// the Anthropic / Claude Code mcp config convention.
|
|
59
|
+
const entries = Object.keys(parsed);
|
|
60
|
+
return entries.length;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Count installed skills across the project-local + user-global
|
|
70
|
+
* directories: `.pugi/skills/` (per-workspace) + `~/.pugi/skills/`
|
|
71
|
+
* (per-machine). Each immediate subdirectory counts as one skill;
|
|
72
|
+
* matches the `skill-creator` convention.
|
|
73
|
+
*/
|
|
74
|
+
export function countSkills(cwd, home = homedir()) {
|
|
75
|
+
const projectDir = resolve(cwd, '.pugi', 'skills');
|
|
76
|
+
const userDir = resolve(home, '.pugi', 'skills');
|
|
77
|
+
return countSubdirs(projectDir) + countSubdirs(userDir);
|
|
78
|
+
}
|
|
79
|
+
function countSubdirs(dir) {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(dir))
|
|
82
|
+
return 0;
|
|
83
|
+
const stat = statSync(dir);
|
|
84
|
+
if (!stat.isDirectory())
|
|
85
|
+
return 0;
|
|
86
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
87
|
+
let count = 0;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.isDirectory())
|
|
90
|
+
count += 1;
|
|
91
|
+
}
|
|
92
|
+
return count;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function collectWorkspaceContext(cwd, home = homedir()) {
|
|
99
|
+
return {
|
|
100
|
+
pugiMdCount: countPugiMdFiles(cwd),
|
|
101
|
+
mcpServerCount: countMcpServers(cwd),
|
|
102
|
+
skillCount: countSkills(cwd, home),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=workspace-context.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-
|
|
4
|
-
"description": "Pugi CLI
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -28,30 +28,36 @@
|
|
|
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
|
},
|
|
38
|
+
"//publishConfig": "CEO sign-off 2026-05-23 alpha launch: @pugi/cli is the customer-facing CLI and ships PUBLIC so `npm i -g @pugi/cli` resolves anonymously. The HARD default-restricted memory rule (2026-05-25) applies to INTERNAL @pugi/* packages (db-client, telegram bot helpers, etc.) - those must ship access:restricted unless an additional CEO sign-off granted. @pugi/sdk + @pugi/personas were published public alongside the CLI under the same alpha-launch sign-off because the CLI's customer install pulls them as transitive deps; review before α7 GA.",
|
|
37
39
|
"publishConfig": {
|
|
38
40
|
"access": "public"
|
|
39
41
|
},
|
|
40
42
|
"dependencies": {
|
|
41
43
|
"@mozilla/readability": "^0.6.0",
|
|
44
|
+
"chokidar": "^3.6.0",
|
|
45
|
+
"ignore": "^5.3.2",
|
|
42
46
|
"ink": "^5.0.1",
|
|
43
47
|
"linkedom": "^0.18.12",
|
|
44
48
|
"react": "^18.3.1",
|
|
49
|
+
"tar": "^6.2.1",
|
|
45
50
|
"tinyglobby": "^0.2.16",
|
|
46
51
|
"turndown": "^7.2.4",
|
|
47
52
|
"undici": "^8.3.0",
|
|
48
53
|
"zod": "^3.23.0",
|
|
49
|
-
"@pugi/personas": "0.1.
|
|
50
|
-
"@pugi/sdk": "0.1.0-
|
|
54
|
+
"@pugi/personas": "0.1.1",
|
|
55
|
+
"@pugi/sdk": "0.1.0-beta.1"
|
|
51
56
|
},
|
|
52
57
|
"devDependencies": {
|
|
53
58
|
"@types/node": "^22.0.0",
|
|
54
59
|
"@types/react": "^18.3.3",
|
|
60
|
+
"@types/tar": "^6.1.13",
|
|
55
61
|
"@types/turndown": "^5.0.6",
|
|
56
62
|
"ink-testing-library": "^4.0.0",
|
|
57
63
|
"tsx": "^4.19.0",
|