@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.15

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.
@@ -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
@@ -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