@pugi/cli 0.1.0-alpha.18 → 0.1.0-alpha.20

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.
@@ -131,14 +131,57 @@ export function InputBox(props) {
131
131
  useInput((input, key) => {
132
132
  if (key.ctrl && input === 'c') {
133
133
  const t = now();
134
- if (typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS) {
134
+ // α6.9: Claude Code-style double-press semantics. First Ctrl+C
135
+ // ALWAYS attempts to cancel an in-flight dispatch (when the
136
+ // session reports non-idle); second Ctrl+C within 1s exits the
137
+ // process. If onCancel is omitted (legacy callers, tests), the
138
+ // old behaviour is preserved: first Ctrl+C clears the buffer +
139
+ // arms the exit timer, second Ctrl+C exits.
140
+ const withinDoubleTapWindow = typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS;
141
+ if (withinDoubleTapWindow) {
142
+ // Second press inside the window — always exit. This matches
143
+ // Claude Code: even mid-dispatch, the second Ctrl+C wins so
144
+ // the operator can always escape a stuck REPL.
135
145
  props.onExit();
136
146
  return;
137
147
  }
148
+ // First press in a fresh window. If the host wired a cancel
149
+ // surface and there is something to cancel, abort the dispatch.
150
+ // The buffer is left untouched on a cancel (the operator's
151
+ // current input is NOT trashed by an accidental Ctrl+C while a
152
+ // tool is running).
153
+ //
154
+ // Three-valued onCancel return (see prop docstring):
155
+ // - true → dispatch cancelled, keep buffer, arm exit timer
156
+ // - false → idle, clear buffer (legacy), arm exit timer
157
+ // - undefined → handler bypassed (modal owns input); NO state
158
+ // change at all. Buffer stays, exit timer NOT
159
+ // armed (otherwise the modal would silently
160
+ // promote a Ctrl+C to "press again to exit",
161
+ // which is wrong context for a modal cancel).
162
+ let cancelResult;
163
+ if (props.onCancel) {
164
+ cancelResult = props.onCancel();
165
+ }
166
+ if (cancelResult === undefined && props.onCancel) {
167
+ // Bypass path - modal owns the input. Drop the press silently
168
+ // so the modal's own cancel surface (Esc / its own Ctrl+C
169
+ // binding inside the modal component) takes effect on its own
170
+ // terms. P2 fix: previously this fell through to the
171
+ // legacy buffer-clear + setLastCtrlCAt path and wiped modal
172
+ // draft text on first Ctrl+C.
173
+ return;
174
+ }
138
175
  setLastCtrlCAt(t);
139
- setLine('');
140
- setCursor(0);
141
- setSearch(undefined);
176
+ // Legacy behaviour: on idle (or no onCancel wired), clear the
177
+ // buffer + reset search so the operator's screen is calm before
178
+ // they confirm exit. When we DID cancel a live dispatch, keep
179
+ // the buffer so a half-typed brief is not lost.
180
+ if (cancelResult !== true) {
181
+ setLine('');
182
+ setCursor(0);
183
+ setSearch(undefined);
184
+ }
142
185
  return;
143
186
  }
144
187
  // Search-mode key handling. Ctrl+R / Ctrl+S cycle, Enter accepts,
@@ -456,7 +499,7 @@ export function InputBox(props) {
456
499
  : Math.min(paletteIndex, paletteView.rows.length - 1);
457
500
  const divider = '─'.repeat(innerWidth);
458
501
  const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
459
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
502
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C abort / ×2 exit' }) })] }));
460
503
  }
461
504
  /**
462
505
  * Render the line with the cursor glyph inserted at `cursor`. The cursor
package/dist/tui/repl.js CHANGED
@@ -156,11 +156,33 @@ export function Repl(props) {
156
156
  const handlePlanReviewResolve = useCallback((result) => {
157
157
  void props.session.resolvePlanReview(result);
158
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,
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,
160
182
  // Slug from process.cwd() (full path) so two workspaces with
161
183
  // the same basename do not share history. state.workspaceLabel
162
184
  // is the basename only. Codex review P2.
163
- 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 })] })] }));
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 })] })] }));
164
186
  }
165
187
  function Header({ state }) {
166
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('_', ' ') })] }));
@@ -12,7 +12,12 @@ 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
- const status = connectionLabel(props.connection);
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);
16
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
  /**
@@ -28,10 +33,17 @@ function formatQuota(pct) {
28
33
  return '—';
29
34
  return `${Math.round(pct)}%`;
30
35
  }
31
- function connectionLabel(connection) {
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) {
32
40
  switch (connection) {
33
41
  case 'connecting':
34
- return { label: 'connecting', color: 'cyan' };
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' };
35
47
  case 'on_watch':
36
48
  return { label: 'on watch', color: 'cyan' };
37
49
  case 'reconnecting':
@@ -40,6 +52,54 @@ function connectionLabel(connection) {
40
52
  return { label: 'offline', color: 'gray' };
41
53
  }
42
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
+ }
43
103
  function formatElapsed(startedAt, now) {
44
104
  if (typeof startedAt !== 'number')
45
105
  return 'idle';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.20",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "undici": "^8.3.0",
53
53
  "zod": "^3.23.0",
54
54
  "@pugi/personas": "0.1.1",
55
- "@pugi/sdk": "0.1.0-alpha.18"
55
+ "@pugi/sdk": "0.1.0-alpha.20"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.0.0",