@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16
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/assets/pugi-mascot.ansi +17 -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/repl/ask.js +512 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1080 -11
- package/dist/core/repl/slash-commands.js +25 -3
- 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/runtime/cli.js +504 -10
- package/dist/runtime/commands/config.js +202 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- 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/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +85 -0
- package/dist/tui/repl-splash-mascot.js +118 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +59 -10
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +5 -4
|
@@ -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
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const HEADING_COLORS = ['cyan', 'magenta', 'yellow'];
|
|
4
|
+
/**
|
|
5
|
+
* Very small keyword table for cosmetic code accent. The fence label is
|
|
6
|
+
* matched case-insensitively against a handful of languages we expect
|
|
7
|
+
* the personas to emit (ts/tsx/js/jsx/py/sh). Unknown languages render
|
|
8
|
+
* the body in white - no syntax error, no warning. Tests assert that
|
|
9
|
+
* an empty fence label still produces a bordered Box (no crash).
|
|
10
|
+
*/
|
|
11
|
+
const KEYWORDS_BY_LANG = {
|
|
12
|
+
ts: ['const', 'let', 'var', 'function', 'class', 'interface', 'type', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this', 'static', 'public', 'private', 'protected', 'readonly', 'extends', 'implements'],
|
|
13
|
+
tsx: ['const', 'let', 'var', 'function', 'class', 'interface', 'type', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this', 'static', 'public', 'private', 'protected', 'readonly', 'extends', 'implements'],
|
|
14
|
+
js: ['const', 'let', 'var', 'function', 'class', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this'],
|
|
15
|
+
jsx: ['const', 'let', 'var', 'function', 'class', 'import', 'export', 'from', 'return', 'if', 'else', 'for', 'while', 'await', 'async', 'new', 'this'],
|
|
16
|
+
py: ['def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif', 'for', 'while', 'with', 'as', 'try', 'except', 'finally', 'raise', 'lambda', 'pass', 'yield', 'None', 'True', 'False'],
|
|
17
|
+
sh: ['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'return', 'export'],
|
|
18
|
+
bash: ['if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'return', 'export'],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Top-level entry point. Splits the source into lines, runs the
|
|
22
|
+
* block-level state machine, and emits an Ink tree. The component is
|
|
23
|
+
* memo-friendly (pure function of `source`) but we don't wrap in
|
|
24
|
+
* React.memo here - the conversation pane already memoises row keys.
|
|
25
|
+
*/
|
|
26
|
+
export function MarkdownRender(props) {
|
|
27
|
+
const blocks = parseBlocks(props.source);
|
|
28
|
+
const children = blocks.map((block, index) => renderBlock(block, index));
|
|
29
|
+
if (props.inline) {
|
|
30
|
+
return _jsx(_Fragment, { children: children });
|
|
31
|
+
}
|
|
32
|
+
return _jsx(Box, { flexDirection: "column", children: children });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Walk the source line by line and emit one Block per visual unit. The
|
|
36
|
+
* state machine has two modes: default (heading / list / paragraph) and
|
|
37
|
+
* in-code (every line is appended verbatim to the current code block).
|
|
38
|
+
*
|
|
39
|
+
* Inside a code fence we DO NOT process inline syntax - the body is
|
|
40
|
+
* preserved literally so the operator sees code that actually runs.
|
|
41
|
+
*/
|
|
42
|
+
function parseBlocks(source) {
|
|
43
|
+
const lines = source.split(/\r?\n/);
|
|
44
|
+
const blocks = [];
|
|
45
|
+
let inCode = false;
|
|
46
|
+
let codeLang = '';
|
|
47
|
+
let codeBody = [];
|
|
48
|
+
for (const raw of lines) {
|
|
49
|
+
const line = raw;
|
|
50
|
+
if (inCode) {
|
|
51
|
+
if (/^```/.test(line)) {
|
|
52
|
+
blocks.push({ kind: 'code', lang: codeLang, body: codeBody.join('\n') });
|
|
53
|
+
inCode = false;
|
|
54
|
+
codeBody = [];
|
|
55
|
+
codeLang = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
codeBody.push(line);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const fenceMatch = /^```([A-Za-z0-9_+-]*)\s*$/.exec(line);
|
|
62
|
+
if (fenceMatch) {
|
|
63
|
+
inCode = true;
|
|
64
|
+
codeLang = (fenceMatch[1] ?? '').toLowerCase();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (line.trim().length === 0) {
|
|
68
|
+
blocks.push({ kind: 'blank' });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const headingMatch = /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
72
|
+
if (headingMatch) {
|
|
73
|
+
const level = headingMatch[1].length;
|
|
74
|
+
const text = headingMatch[2];
|
|
75
|
+
blocks.push({ kind: 'heading', level, text });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const bulletMatch = /^\s*[-*]\s+(.+)$/.exec(line);
|
|
79
|
+
if (bulletMatch) {
|
|
80
|
+
blocks.push({ kind: 'bullet', text: bulletMatch[1] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const orderedMatch = /^\s*(\d+)\.\s+(.+)$/.exec(line);
|
|
84
|
+
if (orderedMatch) {
|
|
85
|
+
blocks.push({ kind: 'ordered', index: orderedMatch[1], text: orderedMatch[2] });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
blocks.push({ kind: 'paragraph', text: line });
|
|
89
|
+
}
|
|
90
|
+
// Unterminated fence: surface what we have as code so the operator
|
|
91
|
+
// still sees the body. Mirrors GitHub's render behavior.
|
|
92
|
+
if (inCode) {
|
|
93
|
+
blocks.push({ kind: 'code', lang: codeLang, body: codeBody.join('\n') });
|
|
94
|
+
}
|
|
95
|
+
return blocks;
|
|
96
|
+
}
|
|
97
|
+
/* ------------------------------------------------------------------ */
|
|
98
|
+
/* Block renderers */
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
function renderBlock(block, key) {
|
|
101
|
+
switch (block.kind) {
|
|
102
|
+
case 'heading': {
|
|
103
|
+
const color = HEADING_COLORS[block.level - 1] ?? 'cyan';
|
|
104
|
+
const prefix = '#'.repeat(block.level);
|
|
105
|
+
return (_jsx(Text, { bold: true, color: color, children: `${prefix} ${block.text}` }, key));
|
|
106
|
+
}
|
|
107
|
+
case 'paragraph':
|
|
108
|
+
return (_jsx(Text, { children: renderInline(block.text) }, key));
|
|
109
|
+
case 'bullet':
|
|
110
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '• ' }), _jsx(Text, { children: renderInline(block.text) })] }, key));
|
|
111
|
+
case 'ordered':
|
|
112
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: `${block.index}. ` }), _jsx(Text, { children: renderInline(block.text) })] }, key));
|
|
113
|
+
case 'code':
|
|
114
|
+
return renderCodeBlock(block.lang, block.body, key);
|
|
115
|
+
case 'blank':
|
|
116
|
+
// One-line spacer between blocks. We render an empty Text so the
|
|
117
|
+
// height accounting in Ink matches what the operator sees.
|
|
118
|
+
return _jsx(Text, { children: " " }, key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function renderCodeBlock(lang, body, key) {
|
|
122
|
+
const lines = body.split('\n');
|
|
123
|
+
const keywords = KEYWORDS_BY_LANG[lang] ?? [];
|
|
124
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: lines.map((line, index) => (_jsx(Text, { children: renderCodeLine(line, keywords) }, index))) }, key));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Per-line code accent. We do NOT build a real lexer - we split on
|
|
128
|
+
* whitespace + punctuation boundaries and color matched keywords cyan,
|
|
129
|
+
* single/double quoted strings green, and `//` / `#` comment tails gray.
|
|
130
|
+
* The output is a list of Ink Text spans. Anything unmatched stays
|
|
131
|
+
* plain white.
|
|
132
|
+
*/
|
|
133
|
+
function renderCodeLine(line, keywords) {
|
|
134
|
+
// Comment tail wins first - we strip it and color the rest.
|
|
135
|
+
const commentIndex = findCommentStart(line);
|
|
136
|
+
const code = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
|
|
137
|
+
const comment = commentIndex >= 0 ? line.slice(commentIndex) : '';
|
|
138
|
+
const spans = [];
|
|
139
|
+
// Tokenise the code portion: alternate runs of word chars vs the rest.
|
|
140
|
+
// Strings are matched as a whole quoted span. The regex is anchored
|
|
141
|
+
// sticky to avoid catastrophic backtracking on long lines.
|
|
142
|
+
const tokenRe = /("[^"]*"|'[^']*'|`[^`]*`|\w+|\s+|[^\s\w]+)/g;
|
|
143
|
+
let match;
|
|
144
|
+
let key = 0;
|
|
145
|
+
while ((match = tokenRe.exec(code)) !== null) {
|
|
146
|
+
const tok = match[0];
|
|
147
|
+
if (/^["'`].*["'`]$/.test(tok)) {
|
|
148
|
+
spans.push(_jsx(Text, { color: "green", children: tok }, key));
|
|
149
|
+
}
|
|
150
|
+
else if (keywords.includes(tok)) {
|
|
151
|
+
spans.push(_jsx(Text, { color: "cyan", bold: true, children: tok }, key));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
spans.push(_jsx(Text, { children: tok }, key));
|
|
155
|
+
}
|
|
156
|
+
key += 1;
|
|
157
|
+
}
|
|
158
|
+
if (comment.length > 0) {
|
|
159
|
+
spans.push(_jsx(Text, { color: "gray", children: comment }, "comment"));
|
|
160
|
+
}
|
|
161
|
+
return _jsx(_Fragment, { children: spans });
|
|
162
|
+
}
|
|
163
|
+
function findCommentStart(line) {
|
|
164
|
+
// Single-line // or # outside of string literals. The probe is
|
|
165
|
+
// adequate for the persona-emitted snippets - a string literal that
|
|
166
|
+
// contains `//` would mis-color, but the output stays readable.
|
|
167
|
+
let inString = null;
|
|
168
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
169
|
+
const ch = line[i];
|
|
170
|
+
if (inString) {
|
|
171
|
+
if (ch === inString && line[i - 1] !== '\\')
|
|
172
|
+
inString = null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
176
|
+
inString = ch;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '/' && line[i + 1] === '/')
|
|
180
|
+
return i;
|
|
181
|
+
if (ch === '#')
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
|
186
|
+
/* ------------------------------------------------------------------ */
|
|
187
|
+
/* Inline tokeniser */
|
|
188
|
+
/* ------------------------------------------------------------------ */
|
|
189
|
+
/**
|
|
190
|
+
* Parse one paragraph line into a list of inline spans, then render
|
|
191
|
+
* them as Ink Text nodes. We walk the source left-to-right and greedily
|
|
192
|
+
* consume the longest delimiter we find. Unmatched delimiters fall
|
|
193
|
+
* through as literal text - personas type `**` mid-sentence sometimes.
|
|
194
|
+
*/
|
|
195
|
+
function renderInline(source) {
|
|
196
|
+
const spans = tokeniseInline(source);
|
|
197
|
+
return (_jsx(_Fragment, { children: spans.map((span, index) => renderSpan(span, index)) }));
|
|
198
|
+
}
|
|
199
|
+
function tokeniseInline(source) {
|
|
200
|
+
const spans = [];
|
|
201
|
+
let buffer = '';
|
|
202
|
+
let i = 0;
|
|
203
|
+
while (i < source.length) {
|
|
204
|
+
const rest = source.slice(i);
|
|
205
|
+
// Inline code wins over bold/italic so that `**` inside backticks
|
|
206
|
+
// renders literally.
|
|
207
|
+
const codeMatch = /^`([^`]+)`/.exec(rest);
|
|
208
|
+
if (codeMatch) {
|
|
209
|
+
flush(buffer, spans);
|
|
210
|
+
buffer = '';
|
|
211
|
+
spans.push({ kind: 'code', text: codeMatch[1] });
|
|
212
|
+
i += codeMatch[0].length;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Link before bold/italic so `[text **bold**](url)` renders linkish.
|
|
216
|
+
const linkMatch = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest);
|
|
217
|
+
if (linkMatch) {
|
|
218
|
+
flush(buffer, spans);
|
|
219
|
+
buffer = '';
|
|
220
|
+
spans.push({ kind: 'link', text: linkMatch[1], url: linkMatch[2] });
|
|
221
|
+
i += linkMatch[0].length;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Bold (**) before italic (*) so the greedy match wins.
|
|
225
|
+
const boldMatch = /^\*\*([^*]+)\*\*/.exec(rest);
|
|
226
|
+
if (boldMatch) {
|
|
227
|
+
flush(buffer, spans);
|
|
228
|
+
buffer = '';
|
|
229
|
+
spans.push({ kind: 'bold', text: boldMatch[1] });
|
|
230
|
+
i += boldMatch[0].length;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const italicMatch = /^\*([^*]+)\*/.exec(rest);
|
|
234
|
+
if (italicMatch) {
|
|
235
|
+
flush(buffer, spans);
|
|
236
|
+
buffer = '';
|
|
237
|
+
spans.push({ kind: 'italic', text: italicMatch[1] });
|
|
238
|
+
i += italicMatch[0].length;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
buffer += source[i];
|
|
242
|
+
i += 1;
|
|
243
|
+
}
|
|
244
|
+
flush(buffer, spans);
|
|
245
|
+
return spans;
|
|
246
|
+
}
|
|
247
|
+
function flush(buffer, spans) {
|
|
248
|
+
if (buffer.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
spans.push({ kind: 'text', text: buffer });
|
|
251
|
+
}
|
|
252
|
+
function renderSpan(span, key) {
|
|
253
|
+
switch (span.kind) {
|
|
254
|
+
case 'text':
|
|
255
|
+
return _jsx(Text, { children: span.text }, key);
|
|
256
|
+
case 'bold':
|
|
257
|
+
return _jsx(Text, { bold: true, children: span.text }, key);
|
|
258
|
+
case 'italic':
|
|
259
|
+
return _jsx(Text, { italic: true, children: span.text }, key);
|
|
260
|
+
case 'code':
|
|
261
|
+
return _jsx(Text, { color: "green", children: span.text }, key);
|
|
262
|
+
case 'link':
|
|
263
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", underline: true, children: span.text }), _jsx(Text, { dimColor: true, children: ` (${span.url})` })] }, key));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=markdown-render.js.map
|