@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2

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.
Files changed (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -240,7 +240,7 @@ function ipv4IsBlocked(ip) {
240
240
  * a literal IP (with brackets stripped). We honor that fast-path and
241
241
  * skip DNS.
242
242
  */
243
- async function validateHostnameForFetch(hostname) {
243
+ export async function validateHostnameForFetch(hostname) {
244
244
  // URL.hostname keeps the brackets off IPv6 literals already.
245
245
  if (!hostname)
246
246
  return 'empty hostname';
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { AgentTree } from './agent-tree.js';
4
+ export function AgentTreePane(props) {
5
+ const onWatch = props.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
6
+ const total = props.agents.length;
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ agents ' }), _jsx(Text, { dimColor: true, children: `(${total} total, ${onWatch} on watch)` })] }), _jsx(AgentTree, { agents: props.agents, nowEpochMs: props.nowEpochMs })] }));
8
+ }
9
+ //# sourceMappingURL=agent-tree-pane.js.map
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, useApp } from 'ink';
3
+ import { AskModal, PlanReviewModal } from './ask-modal.js';
4
+ export async function renderAskCli(options) {
5
+ let resolveOuter;
6
+ const outerPromise = new Promise((resolve) => {
7
+ resolveOuter = resolve;
8
+ });
9
+ function App() {
10
+ const { exit } = useApp();
11
+ return (_jsx(AskModal, { tag: options.tag, onResolve: (verdict) => {
12
+ resolveOuter(verdict);
13
+ // Slight delay so Ink flushes the unmount before the parent
14
+ // CLI prints the verdict line. Otherwise the modal frame and
15
+ // the verdict line can interleave on slow terminals.
16
+ setTimeout(() => exit(), 16);
17
+ } }));
18
+ }
19
+ const instance = render(_jsx(App, {}));
20
+ const verdict = await outerPromise;
21
+ try {
22
+ await instance.waitUntilExit();
23
+ }
24
+ catch {
25
+ // Ink may throw if exit() races with a re-render; the verdict is
26
+ // already captured so we ignore.
27
+ }
28
+ return verdict;
29
+ }
30
+ export async function renderPlanReviewCli(options) {
31
+ let resolveOuter;
32
+ const outerPromise = new Promise((resolve) => {
33
+ resolveOuter = resolve;
34
+ });
35
+ function App() {
36
+ const { exit } = useApp();
37
+ return (_jsx(PlanReviewModal, { tag: options.tag, onResolve: (result) => {
38
+ resolveOuter(result);
39
+ setTimeout(() => exit(), 16);
40
+ } }));
41
+ }
42
+ const instance = render(_jsx(App, {}));
43
+ const result = await outerPromise;
44
+ try {
45
+ await instance.waitUntilExit();
46
+ }
47
+ catch {
48
+ // See renderAskCli — captured verdict supersedes a late Ink throw.
49
+ }
50
+ return result;
51
+ }
52
+ //# sourceMappingURL=ask-cli.js.map
@@ -0,0 +1,211 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Office-hours forcing questions + plan-review modals - Sprint α6.3.
4
+ *
5
+ * Two Ink components feed by the parsed `<pugi-ask>` / `<pugi-plan-review>`
6
+ * tag records the session module extracts from persona output.
7
+ *
8
+ * - <AskModal />: numbered options (1-4) + optional "Other" custom-input
9
+ * fallback. Operator types `1`, `2`, `3`, `4`, or `o <free text>`.
10
+ *
11
+ * - <PlanReviewModal />: numbered steps + optional risk callout +
12
+ * three-way verdict `[a] approve · [m] modify · [c] cancel`.
13
+ * Operator types `a`, `m`, or `c`. `m` opens a free-text editor;
14
+ * the operator's edited text is returned verbatim to the session.
15
+ *
16
+ * Both components are PURE in the Ink sense — they read props + own
17
+ * useState for the input buffer, and emit one final `onResolve` callback
18
+ * when the operator submits. The REPL root handles wiring the verdict
19
+ * back into the session as the next user turn (prefixed with
20
+ * `[ASK-RESPONSE:<value>]` / `[PLAN-VERDICT:approve|modify|cancel] ...`)
21
+ * so the persona transcript stays linear.
22
+ *
23
+ * Brand voice gate: ASCII glyphs only, no em-dashes, no banned brand
24
+ * words (journey, explore, delight, magical, friendly, AI-powered,
25
+ * pug-tastic). The modal copy is power-word neutral so a localized
26
+ * variant lands cleanly later.
27
+ */
28
+ import { useState } from 'react';
29
+ import { Box, Text, useInput } from 'ink';
30
+ export function AskModal(props) {
31
+ const [mode, setMode] = useState('pick');
32
+ const [buffer, setBuffer] = useState('');
33
+ useInput((input, key) => {
34
+ // Esc cancels the modal in either mode.
35
+ if (key.escape) {
36
+ props.onResolve({ value: '', cancelled: true });
37
+ return;
38
+ }
39
+ if (mode === 'pick') {
40
+ // Numeric keys 1..N select the matching option, capped at the
41
+ // total option count. Out-of-range keys are ignored.
42
+ const numeric = Number.parseInt(input, 10);
43
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= props.tag.options.length) {
44
+ const opt = props.tag.options[numeric - 1];
45
+ if (opt) {
46
+ props.onResolve({ value: opt.value, cancelled: false });
47
+ return;
48
+ }
49
+ }
50
+ // The "Other" sentinel is the index = options.length + 1. We
51
+ // also accept `o` (lowercase) as a hotkey since the cursor key
52
+ // for "Other" is always one-past-the-last numeric.
53
+ const otherIndex = props.tag.options.length + 1;
54
+ if ((!Number.isNaN(numeric) && numeric === otherIndex)
55
+ || input === 'o'
56
+ || input === 'O') {
57
+ setMode('custom');
58
+ setBuffer('');
59
+ return;
60
+ }
61
+ // Any other keystroke: ignored. The hint footer tells the
62
+ // operator the legal keys.
63
+ return;
64
+ }
65
+ // Custom-input mode: line editor.
66
+ if (key.return) {
67
+ // Empty buffer + Enter = cancel custom (return to pick).
68
+ if (buffer.trim().length === 0) {
69
+ setMode('pick');
70
+ setBuffer('');
71
+ return;
72
+ }
73
+ props.onResolve({
74
+ value: '',
75
+ customInput: buffer.trim(),
76
+ cancelled: false,
77
+ });
78
+ return;
79
+ }
80
+ if (key.backspace || key.delete) {
81
+ setBuffer((prev) => prev.slice(0, -1));
82
+ return;
83
+ }
84
+ if (input && !key.meta && !key.ctrl) {
85
+ setBuffer((prev) => prev + input);
86
+ }
87
+ }, { isActive: props.inert !== true });
88
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: '? ' }), _jsx(Text, { bold: true, children: 'Need your call before I continue' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: props.tag.question }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [props.tag.options.map((opt, idx) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: opt.label })] }), opt.desc ? (_jsx(Box, { marginLeft: 5, children: _jsx(Text, { dimColor: true, children: opt.desc }) })) : null] }, opt.value))), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${props.tag.options.length + 1}. ` }), _jsx(Text, { dimColor: true, children: 'Other (type a custom answer)' })] })] }), mode === 'pick' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `Press 1-${props.tag.options.length + 1} to choose. Esc cancels.` }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '> ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type your custom answer. Enter submits. Esc cancels.' }) })] }))] }));
89
+ }
90
+ export function PlanReviewModal(props) {
91
+ const [mode, setMode] = useState('pick');
92
+ const [buffer, setBuffer] = useState('');
93
+ useInput((input, key) => {
94
+ if (key.escape) {
95
+ // Escape from EITHER mode means cancel — symmetric with AskModal.
96
+ props.onResolve({ verdict: 'cancel' });
97
+ return;
98
+ }
99
+ if (mode === 'pick') {
100
+ if (input === 'a' || input === 'A') {
101
+ props.onResolve({ verdict: 'approve' });
102
+ return;
103
+ }
104
+ if (input === 'c' || input === 'C') {
105
+ props.onResolve({ verdict: 'cancel' });
106
+ return;
107
+ }
108
+ if (input === 'm' || input === 'M') {
109
+ setMode('modify');
110
+ setBuffer('');
111
+ return;
112
+ }
113
+ return;
114
+ }
115
+ // Modify-mode line editor.
116
+ if (key.return) {
117
+ if (buffer.trim().length === 0) {
118
+ setMode('pick');
119
+ setBuffer('');
120
+ return;
121
+ }
122
+ props.onResolve({ verdict: 'modify', modifyText: buffer.trim() });
123
+ return;
124
+ }
125
+ if (key.backspace || key.delete) {
126
+ setBuffer((prev) => prev.slice(0, -1));
127
+ return;
128
+ }
129
+ if (input && !key.meta && !key.ctrl) {
130
+ setBuffer((prev) => prev + input);
131
+ }
132
+ }, { isActive: props.inert !== true });
133
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: '? ' }), _jsx(Text, { bold: true, children: 'Plan review - approve before I execute' })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: 'Steps:' }), props.tag.steps.map((step, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: step.text })] }, `step-${idx}`)))] }), props.tag.risk ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: 'Risk:' }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { children: props.tag.risk }) })] })) : null, mode === 'pick' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: ' [a] approve ' }), _jsx(Text, { color: "yellow", bold: true, children: '[m] modify ' }), _jsx(Text, { color: "red", bold: true, children: '[c] cancel' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Press a, m, or c. Esc cancels.' }) })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: 'modify > ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type the revision. Enter submits. Esc cancels.' }) })] }))] }));
134
+ }
135
+ /* ------------------------------------------------------------------ */
136
+ /* Verdict serialisation */
137
+ /* ------------------------------------------------------------------ */
138
+ /**
139
+ * Encode an ask-modal verdict into the literal string the session
140
+ * injects as the next operator turn. The persona's prompt teaches it
141
+ * to recognise this prefix, so the conversation stays coherent without
142
+ * a side channel.
143
+ *
144
+ * Examples:
145
+ * { value: 'vercel' } -> "[ASK-RESPONSE:vercel]"
146
+ * { value: '', customInput: 'gcp'} -> "[ASK-RESPONSE:other] gcp"
147
+ * { cancelled: true } -> "[ASK-RESPONSE:cancelled]"
148
+ *
149
+ * customInput is stripped of any leading `[ASK-RESPONSE:...]` /
150
+ * `[PLAN-VERDICT:...]` pattern so a forged operator prefix cannot be
151
+ * read as a different verdict by a prefix-greedy persona (Claude
152
+ * triple-review P1, PR #375).
153
+ */
154
+ export function encodeAskVerdict(verdict) {
155
+ if (verdict.cancelled)
156
+ return '[ASK-RESPONSE:cancelled]';
157
+ if (verdict.value.length > 0)
158
+ return `[ASK-RESPONSE:${verdict.value}]`;
159
+ const customInput = verdict.customInput
160
+ ? sanitiseVerdictText(verdict.customInput)
161
+ : '';
162
+ if (customInput.length > 0) {
163
+ return `[ASK-RESPONSE:other] ${customInput}`;
164
+ }
165
+ return '[ASK-RESPONSE:cancelled]';
166
+ }
167
+ /**
168
+ * Encode a plan-review verdict into the next operator turn. Mirrors
169
+ * the ask encoding so the persona sees a single grammar.
170
+ *
171
+ * modifyText is sanitised against verdict-header forgery the same way
172
+ * customInput is in encodeAskVerdict (Claude triple-review P1).
173
+ */
174
+ export function encodePlanReviewVerdict(result) {
175
+ switch (result.verdict) {
176
+ case 'approve':
177
+ return '[PLAN-VERDICT:approve]';
178
+ case 'cancel':
179
+ return '[PLAN-VERDICT:cancel]';
180
+ case 'modify': {
181
+ const modifyText = result.modifyText
182
+ ? sanitiseVerdictText(result.modifyText)
183
+ : '';
184
+ if (modifyText.length > 0) {
185
+ return `[PLAN-VERDICT:modify] ${modifyText}`;
186
+ }
187
+ return '[PLAN-VERDICT:cancel]';
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
193
+ * pattern from free-text operator input so a malicious or accidental
194
+ * operator string cannot forge a verdict header. Iterates because the
195
+ * operator could prepend several forged headers in a row.
196
+ *
197
+ * Mirrors the same-named helper in core/repl/session.ts. Kept in both
198
+ * modules so the test surfaces of ask-modal and session are
199
+ * independently exercisable without circular imports.
200
+ */
201
+ function sanitiseVerdictText(raw) {
202
+ let cleaned = raw;
203
+ for (let i = 0; i < raw.length + 4; i += 1) {
204
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
205
+ if (stripped === cleaned)
206
+ break;
207
+ cleaned = stripped;
208
+ }
209
+ return cleaned.trim();
210
+ }
211
+ //# sourceMappingURL=ask-modal.js.map
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { MarkdownRender } from './markdown-render.js';
3
4
  const HUE_COLOR_BY_SLUG = {
4
5
  // Mira (Pug) - coordinator
5
6
  main: 'cyan',
@@ -23,10 +24,14 @@ const HUE_COLOR_BY_SLUG = {
23
24
  analyst: 'gray',
24
25
  };
25
26
  export function ConversationPane(props) {
27
+ const showHeader = props.showHeader !== false;
26
28
  if (props.rows.length === 0) {
27
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Brief the workforce to begin. Try a short sentence or /help." }) }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [showHeader ? _jsx(PaneHeader, { count: 0 }) : null, _jsx(Text, { dimColor: true, children: "Brief the workforce to begin. Try a short sentence or /help." })] }));
28
30
  }
29
- return (_jsx(Box, { flexDirection: "column", children: props.rows.map((row) => (_jsx(ConversationRow, { row: row, personaNames: props.personaNames }, row.id))) }));
31
+ return (_jsxs(Box, { flexDirection: "column", children: [showHeader ? _jsx(PaneHeader, { count: props.rows.length }) : null, props.rows.map((row) => (_jsx(ConversationRow, { row: row, personaNames: props.personaNames }, row.id)))] }));
32
+ }
33
+ function PaneHeader({ count }) {
34
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ conversation ' }), _jsx(Text, { dimColor: true, children: `(${count} row${count === 1 ? '' : 's'})` })] }));
30
35
  }
31
36
  function ConversationRow({ row, personaNames, }) {
32
37
  switch (row.source) {
@@ -38,8 +43,48 @@ function ConversationRow({ row, personaNames, }) {
38
43
  const slug = row.personaSlug ?? '';
39
44
  const color = HUE_COLOR_BY_SLUG[slug] ?? 'white';
40
45
  const displayName = personaNames?.get(slug) ?? slug;
41
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), _jsx(Text, { children: row.text })] }));
46
+ // α6.12: persona bodies travel through MarkdownRender so code
47
+ // fences, headings, and inline accents land correctly. A row that
48
+ // carries no Markdown syntax renders as plain text under the same
49
+ // path (the parser falls through to a single paragraph span), so
50
+ // there is no regression for the simple "Mira shipped." baseline.
51
+ const containsMarkdown = looksLikeMarkdown(row.text);
52
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), containsMarkdown ? null : _jsx(Text, { children: row.text })] }), containsMarkdown ? (_jsx(Box, { marginLeft: 2, children: _jsx(MarkdownRender, { source: row.text }) })) : null] }));
42
53
  }
43
54
  }
44
55
  }
56
+ /**
57
+ * Cheap heuristic for "this transcript row will benefit from Markdown
58
+ * rendering". We only pay the parser cost when the row plausibly
59
+ * contains a code fence, heading, list item, or inline accent. A bare
60
+ * "Mira shipped." line takes the legacy fast path.
61
+ *
62
+ * The probe is intentionally generous - false positives just route
63
+ * through the parser, which renders plain text identically.
64
+ */
65
+ function looksLikeMarkdown(text) {
66
+ if (text.length === 0)
67
+ return false;
68
+ if (text.includes('```'))
69
+ return true;
70
+ // Codex P2 PR #369: intro-plus-list shape ("Summary:\n- bullet")
71
+ // must route through renderer. Scan EVERY line, not just the first.
72
+ const lines = text.split('\n');
73
+ for (const raw of lines) {
74
+ const line = raw.trim();
75
+ if (/^#{1,6}\s/.test(line))
76
+ return true;
77
+ if (/^[-*+]\s/.test(line))
78
+ return true;
79
+ if (/^\d+\.\s/.test(line))
80
+ return true;
81
+ }
82
+ if (/\*\*[^*]+\*\*/.test(text))
83
+ return true;
84
+ if (/`[^`]+`/.test(text))
85
+ return true;
86
+ if (/\[[^\]]+\]\([^)]+\)/.test(text))
87
+ return true;
88
+ return false;
89
+ }
45
90
  //# sourceMappingURL=conversation-pane.js.map
@@ -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