@pugi/cli 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -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/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
package/dist/tui/repl.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* REPL root component - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
|
|
4
|
+
*
|
|
5
|
+
* Three-zone layout:
|
|
6
|
+
*
|
|
7
|
+
* header - `Pugi.io · workspace: <name> · v<X> on watch`
|
|
8
|
+
* main - conversation pane (top half) + agent tree (bottom half)
|
|
9
|
+
* footer - input box + status bar + key hints
|
|
10
|
+
*
|
|
11
|
+
* The component subscribes to a ReplSession instance for state. It does
|
|
12
|
+
* NOT own the SSE client or the transport - the session module does.
|
|
13
|
+
* This keeps the component a pure render over a typed state plus three
|
|
14
|
+
* callbacks (submit, exit, overlay request).
|
|
15
|
+
*
|
|
16
|
+
* Overlays (`/help`, `/agents`) are rendered as full-pane modals that
|
|
17
|
+
* eclipse the main area so they read top-to-bottom even on small
|
|
18
|
+
* terminals. Pressing any key dismisses an overlay.
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
21
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
22
|
+
import { PUGI_TAGLINE, THE_TEN } from '@pugi/personas';
|
|
23
|
+
import { AgentTree } from './agent-tree.js';
|
|
24
|
+
import { ConversationPane } from './conversation-pane.js';
|
|
25
|
+
import { InputBox } from './input-box.js';
|
|
26
|
+
import { ReplSplash } from './repl-splash.js';
|
|
27
|
+
import { StatusBar } from './status-bar.js';
|
|
28
|
+
import { UpdateBanner } from './update-banner.js';
|
|
29
|
+
import { collectWorkspaceContext } from './workspace-context.js';
|
|
30
|
+
import { slugForCwd } from '../core/repl/history.js';
|
|
31
|
+
import { SLASH_COMMAND_HELP, SLASH_COMMAND_GROUPS } from '../core/repl/slash-commands.js';
|
|
32
|
+
const TICK_INTERVAL_MS = 200;
|
|
33
|
+
const PULSE_INTERVAL_MS = 700;
|
|
34
|
+
export function Repl(props) {
|
|
35
|
+
const [state, setState] = useState(props.session.getState());
|
|
36
|
+
const [overlay, setOverlay] = useState('none');
|
|
37
|
+
const [pulsePhase, setPulsePhase] = useState(0);
|
|
38
|
+
const [tickNow, setTickNow] = useState((props.now ?? Date.now)());
|
|
39
|
+
// α6.14 wave 3: boot splash visible until first input, first
|
|
40
|
+
// `agent.spawned` event, or 10s idle. The host gates the initial
|
|
41
|
+
// visibility on `--no-splash` / PUGI_SKIP_SPLASH via `skipSplash`.
|
|
42
|
+
const [splashVisible, setSplashVisible] = useState(props.skipSplash !== true);
|
|
43
|
+
const dismissSplash = useCallback(() => setSplashVisible(false), []);
|
|
44
|
+
// α6.14 wave 3: workspace context snapshot for the status bar. We
|
|
45
|
+
// read once at mount and freeze; a brand-new PUGI.md or skill is
|
|
46
|
+
// surfaced on the next REPL boot rather than via a watcher.
|
|
47
|
+
const workspaceContext = useMemo(() => props.workspaceContext ?? collectWorkspaceContext(process.cwd()), [props.workspaceContext]);
|
|
48
|
+
// Subscribe to session state updates. The session module fires the
|
|
49
|
+
// callback synchronously inside `patch` so we mirror without a
|
|
50
|
+
// batching layer.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const unsubscribe = props.session.subscribe(setState);
|
|
53
|
+
return unsubscribe;
|
|
54
|
+
}, [props.session]);
|
|
55
|
+
// 200ms tick for the status-bar wall-clock + token counter rerender.
|
|
56
|
+
// Cheaper than re-mounting the session subscription and lets the
|
|
57
|
+
// status bar lag the dispatcher events by at most one frame.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const interval = setInterval(() => {
|
|
60
|
+
setTickNow((props.now ?? Date.now)());
|
|
61
|
+
}, TICK_INTERVAL_MS);
|
|
62
|
+
return () => clearInterval(interval);
|
|
63
|
+
}, [props.now]);
|
|
64
|
+
// Pulse the on-watch dot every 700ms. Three glyphs cycle, so the
|
|
65
|
+
// operator sees a gentle three-step animation rather than a binary
|
|
66
|
+
// blink. Subtle by design - brand voice forbids flashing.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const interval = setInterval(() => {
|
|
69
|
+
setPulsePhase((prev) => (prev + 1) % 3);
|
|
70
|
+
}, PULSE_INTERVAL_MS);
|
|
71
|
+
return () => clearInterval(interval);
|
|
72
|
+
}, []);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
props.onOverlayChange?.(overlay);
|
|
75
|
+
}, [overlay, props]);
|
|
76
|
+
// α6.14 wave 3: dismiss the boot splash once the first agent spawns
|
|
77
|
+
// (the operator has clearly engaged the system) or the transcript
|
|
78
|
+
// gains a row. Mirrors the natural attention shift Claude Code /
|
|
79
|
+
// Codex / Gemini CLI all do on their boot screens.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!splashVisible)
|
|
82
|
+
return;
|
|
83
|
+
if (state.agents.length > 0 || state.transcript.length > 0) {
|
|
84
|
+
setSplashVisible(false);
|
|
85
|
+
}
|
|
86
|
+
}, [splashVisible, state.agents.length, state.transcript.length]);
|
|
87
|
+
const personaNames = useMemo(() => buildPersonaNameMap(), []);
|
|
88
|
+
const { exit } = useApp();
|
|
89
|
+
const handleSubmit = useCallback((line) => {
|
|
90
|
+
// Dismiss the boot splash on first operator input. Idempotent —
|
|
91
|
+
// `setSplashVisible(false)` is a no-op once the state already
|
|
92
|
+
// settled to false (timer fired or `agent.spawned` arrived).
|
|
93
|
+
setSplashVisible(false);
|
|
94
|
+
// Run async without awaiting - the session module owns the
|
|
95
|
+
// network call, errors land in the transcript automatically.
|
|
96
|
+
void props.session.handleInput(line).then((verdict) => {
|
|
97
|
+
applyVerdictSideEffects(verdict, {
|
|
98
|
+
showHelp: () => setOverlay('help'),
|
|
99
|
+
showRoster: () => setOverlay('roster'),
|
|
100
|
+
farewell: () => {
|
|
101
|
+
setOverlay('farewell');
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
props.session.close();
|
|
104
|
+
exit();
|
|
105
|
+
}, 800);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}, [props.session, exit]);
|
|
110
|
+
const handleExit = useCallback(() => {
|
|
111
|
+
setOverlay('farewell');
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
props.session.close();
|
|
114
|
+
exit();
|
|
115
|
+
}, 400);
|
|
116
|
+
}, [props.session, exit]);
|
|
117
|
+
// Any keystroke dismisses an overlay; mounting useInput at the root
|
|
118
|
+
// gives us the dismiss without colliding with the input box (which
|
|
119
|
+
// unmounts while an overlay is active).
|
|
120
|
+
useInput((_input, _key) => {
|
|
121
|
+
if (overlay !== 'none' && overlay !== 'farewell') {
|
|
122
|
+
setOverlay('none');
|
|
123
|
+
}
|
|
124
|
+
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
125
|
+
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 })) : 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 })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
126
|
+
// Slug from process.cwd() (full path) so two workspaces with
|
|
127
|
+
// the same basename do not share history. state.workspaceLabel
|
|
128
|
+
// is the basename only. Codex review P2.
|
|
129
|
+
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 })] })] }));
|
|
130
|
+
}
|
|
131
|
+
function Header({ state }) {
|
|
132
|
+
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
|
+
}
|
|
134
|
+
function MainArea({ state, personaNames, nowEpochMs, }) {
|
|
135
|
+
// Show the last 12 transcript rows in the conversation slot so the
|
|
136
|
+
// bottom of the frame stays anchored to the input box. The agent
|
|
137
|
+
// tree drops below the transcript; new agents push the operator
|
|
138
|
+
// line up the screen, mirroring Claude Code / Codex CLI.
|
|
139
|
+
const conversationSlice = state.transcript.slice(-12);
|
|
140
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ConversationPane, { rows: conversationSlice, personaNames: personaNames }), _jsx(Box, { marginTop: 1, children: _jsx(AgentTree, { agents: state.agents, nowEpochMs: nowEpochMs }) })] }));
|
|
141
|
+
}
|
|
142
|
+
function HelpOverlay() {
|
|
143
|
+
// Group commands by their `group` field so the operator scans the
|
|
144
|
+
// palette by intent (dispatch → session → tools → settings → meta).
|
|
145
|
+
// The α6.14 wave-2 expansion grew the surface from 6 to 20 commands;
|
|
146
|
+
// a flat list would force the operator to read 20 rows top-to-bottom
|
|
147
|
+
// every time. Grouping cuts perceived complexity dramatically.
|
|
148
|
+
const grouped = new Map();
|
|
149
|
+
for (const row of SLASH_COMMAND_HELP) {
|
|
150
|
+
const list = grouped.get(row.group);
|
|
151
|
+
if (list) {
|
|
152
|
+
grouped.set(row.group, [...list, row]);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
grouped.set(row.group, [row]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pugi REPL help" }), SLASH_COMMAND_GROUPS.map((group) => {
|
|
159
|
+
const rows = grouped.get(group);
|
|
160
|
+
if (!rows || rows.length === 0)
|
|
161
|
+
return null;
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ` -- ${group} --` }), rows.map((row) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: ` /${row.name} ${row.args}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name)))] }, group));
|
|
163
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `${PUGI_TAGLINE}` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
|
|
164
|
+
}
|
|
165
|
+
function RosterOverlay() {
|
|
166
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "On-watch roster" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: THE_TEN.map((persona) => (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: ` ${persona.name.padEnd(10, ' ')}` }), _jsx(Text, { dimColor: true, children: `${persona.role.padEnd(20, ' ')}` }), _jsx(Text, { children: persona.oneLiner })] }, persona.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: 'Press any key to dismiss.' }) })] }));
|
|
167
|
+
}
|
|
168
|
+
function FarewellOverlay() {
|
|
169
|
+
return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 2, children: _jsx(Text, { bold: true, color: "cyan", children: PUGI_TAGLINE }) }));
|
|
170
|
+
}
|
|
171
|
+
function applyVerdictSideEffects(verdict, handlers) {
|
|
172
|
+
switch (verdict.kind) {
|
|
173
|
+
case 'help':
|
|
174
|
+
handlers.showHelp();
|
|
175
|
+
return;
|
|
176
|
+
case 'roster':
|
|
177
|
+
handlers.showRoster();
|
|
178
|
+
return;
|
|
179
|
+
case 'quit':
|
|
180
|
+
handlers.farewell();
|
|
181
|
+
return;
|
|
182
|
+
case 'dispatch':
|
|
183
|
+
case 'stop':
|
|
184
|
+
case 'web':
|
|
185
|
+
case 'error':
|
|
186
|
+
case 'noop':
|
|
187
|
+
case 'clear':
|
|
188
|
+
case 'version':
|
|
189
|
+
case 'jobs':
|
|
190
|
+
case 'diff':
|
|
191
|
+
case 'cost':
|
|
192
|
+
case 'status':
|
|
193
|
+
case 'stub':
|
|
194
|
+
// All non-overlay verdicts: the session module already appended
|
|
195
|
+
// any operator-visible system lines. No further UI side effect.
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function countActive(state) {
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (const agent of state.agents) {
|
|
202
|
+
if (agent.status === 'queued' || agent.status === 'thinking')
|
|
203
|
+
count += 1;
|
|
204
|
+
}
|
|
205
|
+
return count;
|
|
206
|
+
}
|
|
207
|
+
function buildPersonaNameMap() {
|
|
208
|
+
const map = new Map();
|
|
209
|
+
for (const persona of THE_TEN) {
|
|
210
|
+
map.set(persona.slug, persona.name);
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=repl.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
4
|
+
export const PALETTE_ROW_LIMIT = 8;
|
|
5
|
+
/**
|
|
6
|
+
* Compute the FULL filtered candidate list for a given input buffer.
|
|
7
|
+
* Centralises the "starts-with-slash → filter SLASH_COMMAND_HELP"
|
|
8
|
+
* logic so the input box and the unit test agree on the shape.
|
|
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
|
+
*
|
|
17
|
+
* Behaviour:
|
|
18
|
+
* - Empty / non-slash buffer → empty result; palette stays hidden.
|
|
19
|
+
* - `/` alone → all registry rows (the operator wants to browse).
|
|
20
|
+
* - `/he` → rows whose name starts with `he` (case-insensitive).
|
|
21
|
+
*/
|
|
22
|
+
export function filterPalette(buffer) {
|
|
23
|
+
if (!buffer.startsWith('/')) {
|
|
24
|
+
return { rows: [], totalBeforeLimit: 0 };
|
|
25
|
+
}
|
|
26
|
+
// Treat anything past the first whitespace as args - palette only
|
|
27
|
+
// filters on the command name itself.
|
|
28
|
+
const headEnd = buffer.indexOf(' ');
|
|
29
|
+
const head = headEnd === -1 ? buffer.slice(1) : buffer.slice(1, headEnd);
|
|
30
|
+
const prefix = head.toLowerCase();
|
|
31
|
+
// Lowercase row.name too so a registry entry like `Help` still matches
|
|
32
|
+
// an operator typing `/he`. Today every entry is lowercase, but the
|
|
33
|
+
// type contract on SlashCommandHelp.name does not enforce that, and
|
|
34
|
+
// a future addition would silently disappear from the palette
|
|
35
|
+
// without this guard. Codex P2.
|
|
36
|
+
const all = prefix.length === 0
|
|
37
|
+
? SLASH_COMMAND_HELP
|
|
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;
|
|
62
|
+
return {
|
|
63
|
+
visible: rows.slice(start, start + PALETTE_ROW_LIMIT),
|
|
64
|
+
startIndex: start,
|
|
65
|
+
total,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Compute the auto-complete result for the Tab key. Picks the focused
|
|
70
|
+
* row when there is one, falls back to the longest common prefix
|
|
71
|
+
* otherwise (bash-like behaviour). Returns the new buffer the input
|
|
72
|
+
* box should adopt, or `null` when there is nothing to complete.
|
|
73
|
+
*/
|
|
74
|
+
export function completePalette(buffer, rows, focusedIndex) {
|
|
75
|
+
if (rows.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const target = rows[focusedIndex] ?? rows[0];
|
|
78
|
+
if (!target)
|
|
79
|
+
return null;
|
|
80
|
+
// Preserve any args the operator has already started typing after a
|
|
81
|
+
// space; only swap the head.
|
|
82
|
+
const headEnd = buffer.indexOf(' ');
|
|
83
|
+
const tail = headEnd === -1 ? '' : buffer.slice(headEnd);
|
|
84
|
+
return `/${target.name}${tail}`;
|
|
85
|
+
}
|
|
86
|
+
export function SlashPalette(props) {
|
|
87
|
+
if (props.rows.length === 0)
|
|
88
|
+
return null;
|
|
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;
|
|
101
|
+
const glyph = focused ? '▸' : '·';
|
|
102
|
+
const cmd = `/${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ');
|
|
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));
|
|
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' }) })] }));
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=slash-palette.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { DEFAULT_API_URL, maskApiKey, normalizeApiUrl, readCredentialsFile, resolveActiveCredential, } from '../core/credentials.js';
|
|
3
|
+
/**
|
|
4
|
+
* Decode the JWT payload for display only — no signature check. The
|
|
5
|
+
* splash never sends the token over the wire so a stale or malformed
|
|
6
|
+
* JWT is still safe to show. Mirrors `decodeJwtPrincipal` in
|
|
7
|
+
* runtime/cli.ts, intentionally duplicated rather than imported to
|
|
8
|
+
* keep the TUI layer independent of the giant CLI module.
|
|
9
|
+
*/
|
|
10
|
+
function decodeJwtPayload(token) {
|
|
11
|
+
try {
|
|
12
|
+
const parts = token.split('.');
|
|
13
|
+
if (parts.length < 2)
|
|
14
|
+
return null;
|
|
15
|
+
const payload = parts[1];
|
|
16
|
+
if (!payload)
|
|
17
|
+
return null;
|
|
18
|
+
const padded = payload
|
|
19
|
+
.replace(/-/g, '+')
|
|
20
|
+
.replace(/_/g, '/')
|
|
21
|
+
.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
|
|
22
|
+
const json = Buffer.from(padded, 'base64').toString('utf8');
|
|
23
|
+
const obj = JSON.parse(json);
|
|
24
|
+
if (!obj || typeof obj !== 'object')
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
sub: typeof obj.sub === 'string' ? obj.sub : undefined,
|
|
28
|
+
email: typeof obj.email === 'string' ? obj.email : undefined,
|
|
29
|
+
customerId: typeof obj.customerId === 'string' ? obj.customerId : undefined,
|
|
30
|
+
plan: typeof obj.plan === 'string' ? obj.plan : undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function collectSplashData(input) {
|
|
38
|
+
const env = input.env ?? process.env;
|
|
39
|
+
const home = input.home ?? homedir();
|
|
40
|
+
const credential = resolveActiveCredential(env, home);
|
|
41
|
+
if (!credential) {
|
|
42
|
+
const file = readCredentialsFile(home);
|
|
43
|
+
const apiUrl = normalizeApiUrl(env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
|
|
44
|
+
return {
|
|
45
|
+
cliVersion: input.cliVersion,
|
|
46
|
+
apiUrl,
|
|
47
|
+
isAuthenticated: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const principal = decodeJwtPayload(credential.apiKey);
|
|
51
|
+
return {
|
|
52
|
+
cliVersion: input.cliVersion,
|
|
53
|
+
apiUrl: credential.apiUrl,
|
|
54
|
+
isAuthenticated: true,
|
|
55
|
+
apiKeyMasked: maskApiKey(credential.apiKey),
|
|
56
|
+
...(principal?.email ? { email: principal.email } : {}),
|
|
57
|
+
...(principal?.customerId ? { tenant: principal.customerId } : {}),
|
|
58
|
+
...(principal?.plan ? { plan: principal.plan } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=splash-data.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Bare `pugi` (no args) splash. Rendered only on a real TTY by the
|
|
5
|
+
* CLI dispatcher; non-TTY callers see the existing usage dump.
|
|
6
|
+
*
|
|
7
|
+
* Layout intentionally restrained: bold "pugi.io" wordmark, dim
|
|
8
|
+
* version/endpoint metadata, neutral body. No background colors, no
|
|
9
|
+
* emoji decoration, ASCII separators only.
|
|
10
|
+
*/
|
|
11
|
+
export function Splash({ data }) {
|
|
12
|
+
const accountLine = data.isAuthenticated
|
|
13
|
+
? `${data.email ?? data.apiKeyMasked ?? 'authenticated'}${data.tenant ? ` (tenant: ${data.tenant})` : ''}`
|
|
14
|
+
: 'not signed in';
|
|
15
|
+
const primaryHint = data.isAuthenticated
|
|
16
|
+
? {
|
|
17
|
+
cmd: 'pugi code "fix the bug"',
|
|
18
|
+
gloss: 'Run a one-shot coding task',
|
|
19
|
+
}
|
|
20
|
+
: {
|
|
21
|
+
cmd: 'pugi login',
|
|
22
|
+
gloss: 'Connect this terminal to your Pugi account',
|
|
23
|
+
};
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: "cyan", children: "pugi.io" }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `v${data.cliVersion} · ${data.apiUrl}` }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Account: " }), _jsx(Text, { children: accountLine })] }), data.isAuthenticated && data.plan ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Plan: " }), _jsx(Text, { children: data.plan })] })) : null] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Quick start:" }), _jsx(HintRow, { command: primaryHint.cmd, gloss: primaryHint.gloss }), data.isAuthenticated ? (_jsx(HintRow, { command: 'pugi login', gloss: 'Re-authenticate or switch accounts' })) : (_jsx(HintRow, { command: 'pugi code "fix the bug"', gloss: 'Run a one-shot coding task' })), _jsx(HintRow, { command: 'pugi review --triple', gloss: 'Run the Anvil triple-review gate' }), _jsx(HintRow, { command: 'pugi help', gloss: 'Full command reference' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Docs: https://pugi.dev \u00B7 Status: https://pugi.io/status" }) })] }));
|
|
25
|
+
}
|
|
26
|
+
function HintRow({ command, gloss }) {
|
|
27
|
+
// Pad command names so the gloss column lines up across rows.
|
|
28
|
+
const padded = command.padEnd(28, ' ');
|
|
29
|
+
return (_jsxs(Text, { children: [_jsx(Text, { children: ` ${padded}` }), _jsx(Text, { dimColor: true, children: gloss })] }));
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=splash.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Cyan dot glyphs across the pulse cycle. Three steps keep the motion
|
|
5
|
+
* subtle - a true gradient would force an Ink rerender on every
|
|
6
|
+
* 16ms frame and waste cycles on a status bar.
|
|
7
|
+
*/
|
|
8
|
+
const PULSE_DOTS = ['●', '◉', '○'];
|
|
9
|
+
export function StatusBar(props) {
|
|
10
|
+
const now = props.nowEpochMs ?? Date.now();
|
|
11
|
+
const elapsedLabel = formatElapsed(props.briefStartedAtEpochMs, now);
|
|
12
|
+
const tokenLabel = formatTokens(props.tokensDownstreamTotal);
|
|
13
|
+
const phase = clampPhase(props.pulsePhase);
|
|
14
|
+
const glyph = PULSE_DOTS[Math.min(phase, PULSE_DOTS.length - 1)] ?? PULSE_DOTS[0];
|
|
15
|
+
const status = connectionLabel(props.connection);
|
|
16
|
+
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
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Render a count badge — number if defined, `—` placeholder otherwise.
|
|
20
|
+
* The placeholder mirrors the splash header convention so the operator
|
|
21
|
+
* recognises "not yet known" vs "zero" at a glance.
|
|
22
|
+
*/
|
|
23
|
+
function formatCount(value) {
|
|
24
|
+
return typeof value === 'number' ? value.toString() : '—';
|
|
25
|
+
}
|
|
26
|
+
function formatQuota(pct) {
|
|
27
|
+
if (typeof pct !== 'number' || Number.isNaN(pct))
|
|
28
|
+
return '—';
|
|
29
|
+
return `${Math.round(pct)}%`;
|
|
30
|
+
}
|
|
31
|
+
function connectionLabel(connection) {
|
|
32
|
+
switch (connection) {
|
|
33
|
+
case 'connecting':
|
|
34
|
+
return { label: 'connecting', color: 'cyan' };
|
|
35
|
+
case 'on_watch':
|
|
36
|
+
return { label: 'on watch', color: 'cyan' };
|
|
37
|
+
case 'reconnecting':
|
|
38
|
+
return { label: 'reconnecting', color: 'yellow' };
|
|
39
|
+
case 'offline':
|
|
40
|
+
return { label: 'offline', color: 'gray' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function formatElapsed(startedAt, now) {
|
|
44
|
+
if (typeof startedAt !== 'number')
|
|
45
|
+
return 'idle';
|
|
46
|
+
const ms = Math.max(0, now - startedAt);
|
|
47
|
+
if (ms < 60_000) {
|
|
48
|
+
return `${Math.floor(ms / 1000)}s`;
|
|
49
|
+
}
|
|
50
|
+
const minutes = Math.floor(ms / 60_000);
|
|
51
|
+
const seconds = Math.floor((ms % 60_000) / 1000);
|
|
52
|
+
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Format the downstream token counter as 1.2k / 12.4k / 1.0m. Anvil
|
|
56
|
+
* F1 emits totals in the tens-of-thousands range during a single
|
|
57
|
+
* brief, so anything more than three significant figures is noise.
|
|
58
|
+
*/
|
|
59
|
+
function formatTokens(total) {
|
|
60
|
+
if (total < 1_000)
|
|
61
|
+
return total.toString();
|
|
62
|
+
if (total < 1_000_000)
|
|
63
|
+
return `${(total / 1_000).toFixed(1)}k`;
|
|
64
|
+
return `${(total / 1_000_000).toFixed(1)}m`;
|
|
65
|
+
}
|
|
66
|
+
function clampPhase(phase) {
|
|
67
|
+
if (typeof phase !== 'number' || Number.isNaN(phase))
|
|
68
|
+
return 0;
|
|
69
|
+
return Math.max(0, Math.min(PULSE_DOTS.length - 1, Math.floor(phase)));
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=status-bar.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { upgradeCommand } from '../runtime/update-check.js';
|
|
4
|
+
export function UpdateBanner({ result }) {
|
|
5
|
+
const command = upgradeCommand(result.method);
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: '─ ' }), _jsx(Text, { bold: true, color: "cyan", children: 'Pugi ' }), _jsx(Text, { children: result.installed }), _jsx(Text, { dimColor: true, children: ' (installed) → ' }), _jsx(Text, { bold: true, children: result.latest }), _jsx(Text, { dimColor: true, children: ' (latest)' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Update: ' }), _jsx(Text, { color: "cyan", children: command })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Skip with PUGI_SKIP_UPDATE_BANNER=1' }) })] }));
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=update-banner.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
|