@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.
- 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/edits/worktree.js +229 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/prompts.js +4 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/lsp/client.js +631 -0
- 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 +1896 -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 +72 -1
- 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 +767 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/lsp.js +184 -0
- package/dist/runtime/commands/patch.js +111 -0
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/worktree.js +133 -0
- package/dist/tools/apply-patch.js +314 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +18 -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 +185 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
package/dist/tools/web-fetch.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
package/dist/tui/input-box.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|