@try-works/dsh-recursive-mode 0.2.3 → 0.3.0

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,306 @@
1
+ /**
2
+ * Per-phase run-doc viewer (0.2.4): renders one .recursive/run/<runId>/<file>
3
+ * markdown doc read LAZILY via the host GET /.recursive/api/doc route, inside
4
+ * the Inspector run-detail panel. Read-only: NO approve / changes / comment /
5
+ * quit actions, NO session-answer writes, NO recursive/* session events.
6
+ *
7
+ * ATTRIBUTION — ported from @guillaumemeyer/dsh-plan-approval (MIT),
8
+ * https://github.com/guillaumemeyer/dsh-plan-approval:
9
+ * - the markdown line parser (parseDoc, based on parsePlan), and
10
+ * - the vim-nav + / search key handling (onKey/move/goTop/goBottom/
11
+ * computeMatches/searchNext/searchPrev/openSearch/closeSearch).
12
+ * Reused under MIT. NO planReviewOf / overlay / session-question logic is
13
+ * carried over — this viewer reads run artifacts.
14
+ */
15
+ import { createElement, useEffect, useMemo, useRef, useState } from 'react'
16
+ import type { ReactNode } from 'react'
17
+ import { fetchPhaseDoc } from './host-api.ts'
18
+ import type { BoardTheme } from './theme.ts'
19
+
20
+ /* ============================ parser (pure) ============================ */
21
+
22
+ /** One rendered markdown line (parsePlan base + fenced code + tables). */
23
+ export interface DocLine {
24
+ kind: 'blank' | 'h1' | 'h2' | 'h3' | 'h4' | 'li' | 'plain' | 'code' | 'table';
25
+ text: string;
26
+ /** table: data rows (header separator row dropped); first row is the header. */
27
+ cells?: string[][];
28
+ }
29
+
30
+ /** Inline segment parsed from line text (bold / code span / link). */
31
+ export interface InlineSegment {
32
+ type: 'text' | 'bold' | 'code' | 'link';
33
+ text: string;
34
+ href?: string;
35
+ }
36
+
37
+ /**
38
+ * Markdown -> line tokens. Base is parsePlan (blank/h1-h4/li/plain, MIT),
39
+ * extended for fenced code blocks (one code line per block) and pipe tables
40
+ * (one table line per block, header separator row dropped).
41
+ */
42
+ export function parseDoc(plan: string): DocLine[] {
43
+ const raw = String(plan == null ? '' : plan).split('\n');
44
+ const out: DocLine[] = [];
45
+ let i = 0;
46
+ while (i < raw.length) {
47
+ const line = raw[i];
48
+ // blank
49
+ if (/^\s*$/.test(line)) { out.push({ kind: 'blank', text: '' }); i += 1; continue; }
50
+ // fenced code block: group until the closing fence
51
+ const fence = /^```\s*([\w+-]*)\s*$/.exec(line);
52
+ if (fence) {
53
+ const code: string[] = [];
54
+ i += 1;
55
+ while (i < raw.length && !/^```\s*$/.test(raw[i])) { code.push(raw[i]); i += 1; }
56
+ i += 1; // consume closing fence (or end of input)
57
+ out.push({ kind: 'code', text: code.join('\n') });
58
+ continue;
59
+ }
60
+ // pipe table: group consecutive | rows, drop the |-| separator row
61
+ if (/^\s*\|/.test(line)) {
62
+ const rows: string[][] = [];
63
+ while (i < raw.length && /^\s*\|/.test(raw[i])) {
64
+ const parts = raw[i].split('|').slice(1, -1).map((c) => c.trim());
65
+ rows.push(parts);
66
+ i += 1;
67
+ }
68
+ const data = rows.filter((r) => !r.every((c) => /^:?-{3,}:?$/.test(c)));
69
+ out.push({ kind: 'table', text: rows.map((r) => r.join(' | ')).join('\n'), cells: data });
70
+ continue;
71
+ }
72
+ // headings
73
+ const h = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
74
+ if (h) {
75
+ let kind: DocLine['kind'];
76
+ switch (h[1].length) {
77
+ case 1: kind = 'h1'; break;
78
+ case 2: kind = 'h2'; break;
79
+ case 3: kind = 'h3'; break;
80
+ default: kind = 'h4';
81
+ }
82
+ out.push({ kind, text: h[2] });
83
+ i += 1;
84
+ continue;
85
+ }
86
+ // list item
87
+ const li = /^\s*[-*]\s+(.+)$/.exec(line);
88
+ if (li) { out.push({ kind: 'li', text: li[1] }); i += 1; continue; }
89
+ out.push({ kind: 'plain', text: line });
90
+ i += 1;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Inline scanner: **bold**, `code`, [text](url); unmatched markers stay plain
97
+ * text. Used to build the React children of a line text.
98
+ */
99
+ export function parseInline(text: string): InlineSegment[] {
100
+ const out: InlineSegment[] = [];
101
+ const re = /(\*\*(.+?)\*\*)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)]+)\))/g;
102
+ let last = 0;
103
+ let m: RegExpExecArray | null;
104
+ while ((m = re.exec(text)) !== null) {
105
+ if (m.index > last) out.push({ type: 'text', text: text.slice(last, m.index) });
106
+ if (m[1] !== undefined) out.push({ type: 'bold', text: m[2] });
107
+ if (m[3] !== undefined) out.push({ type: 'code', text: m[4] });
108
+ if (m[5] !== undefined) out.push({ type: 'link', text: m[6], href: m[7] });
109
+ last = re.lastIndex;
110
+ }
111
+ if (last < text.length) out.push({ type: 'text', text: text.slice(last) });
112
+ return out;
113
+ }
114
+
115
+ /* ============================ component ============================ */
116
+
117
+ export interface DocViewerProps {
118
+ runId: string;
119
+ worktreeRoot: string;
120
+ fileName: string;
121
+ theme: BoardTheme;
122
+ onClose: () => void;
123
+ }
124
+
125
+ const SEARCH_HINTS: Array<[string, string]> = [
126
+ ['j/k', 'move'], ['gg/G', 'ends'], ['/', 'search'], ['n/N', 'next/prev'], ['y', 'copy'], ['Esc', 'close'],
127
+ ];
128
+
129
+ /** Inline segments -> ReactNodes (bold/code/link markup). */
130
+ function inlineNodes(segments: InlineSegment[], baseKey: string): ReactNode[] {
131
+ return segments.map((seg, n) => {
132
+ const key = baseKey + '-seg-' + String(n);
133
+ if (seg.type === 'bold') return createElement('strong', { key }, seg.text);
134
+ if (seg.type === 'code') return createElement('code', { key, className: 'rec-doc-inline-code' }, seg.text);
135
+ if (seg.type === 'link') return createElement('a', { key, href: seg.href, target: '_blank', rel: 'noreferrer', className: 'rec-doc-inline-link' }, seg.text);
136
+ return createElement('span', { key }, seg.text);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Render one parsed line as a React element. Headings/bullets get parsePlan
142
+ * sizing; code/table get block layout; inline markup applies to plain-ish text.
143
+ */
144
+ function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
145
+ const cls = 'rec-doc-line rec-doc-' + line.kind + (isCurrent ? ' rec-doc-line-current' : '');
146
+ if (line.kind === 'blank') return createElement('div', { key: i, 'data-line': String(i), className: cls }, null);
147
+ if (line.kind === 'code') return createElement('pre', { key: i, 'data-line': String(i), className: cls + ' rec-doc-pre' }, createElement('code', { className: 'rec-doc-code' }, line.text));
148
+ if (line.kind === 'table') {
149
+ const all = line.cells ?? [];
150
+ const header = all[0] ?? [];
151
+ const body = all.slice(1);
152
+ return createElement('div', { key: i, 'data-line': String(i), className: cls },
153
+ createElement('table', { className: 'rec-doc-table' },
154
+ createElement('thead', null, createElement('tr', null, header.map((c, n) => createElement('th', { key: 'th-' + String(n) }, c)))),
155
+ createElement('tbody', null, body.map((r, n) => createElement('tr', { key: 'tr-' + String(n) }, r.map((c, m) => createElement('td', { key: 'td-' + String(m) }, c))))),
156
+ ),
157
+ );
158
+ }
159
+ const nodes = inlineNodes(parseInline(line.text), String(i));
160
+ if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '•'), createElement('span', { className: 'rec-doc-li-text' }, nodes));
161
+ return createElement('div', { key: i, 'data-line': String(i), className: cls }, nodes);
162
+ }
163
+
164
+ /**
165
+ * The per-phase doc viewer. Fetches the route on mount / fileName change.
166
+ * Vim nav + / search + n/N + Esc; y copies the doc. Esc closes search first,
167
+ * else the viewer. data-theme is passed down by the hoisting Inspector
168
+ * (0.1.10 invariant: useBoardTheme lives in the panel).
169
+ */
170
+ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: DocViewerProps): ReactNode {
171
+ const [text, setText] = useState<string | null>(null);
172
+ const [error, setError] = useState<string | null>(null);
173
+ const [notice, setNotice] = useState<string | null>(null);
174
+ const [cursor, setCursor] = useState(0);
175
+ const [searchOpen, setSearchOpen] = useState(false);
176
+ const [query, setQuery] = useState('');
177
+ const [matches, setMatches] = useState<number[]>([]);
178
+ const [activeMatch, setActiveMatch] = useState(0);
179
+ const ggArmed = useRef(false);
180
+ const rootRef = useRef<HTMLDivElement | null>(null);
181
+ const [copied, setCopied] = useState(false);
182
+
183
+ useEffect(() => {
184
+ let disposed = false;
185
+ setText(null); setError(null); setNotice(null); setCopied(false); setCursor(0);
186
+ fetchPhaseDoc({ root: worktreeRoot, runId, file: fileName })
187
+ .then((t) => { if (!disposed) { setText(t); setCopied(false); } })
188
+ .catch(() => { if (!disposed) setError('Failed to load doc'); });
189
+ return () => { disposed = true; };
190
+ }, [worktreeRoot, runId, fileName]);
191
+
192
+ const docLines = useMemo(() => (text === null ? [] : parseDoc(text)), [text]);
193
+
194
+ const move = (delta: number) => {
195
+ if (docLines.length === 0) return;
196
+ setCursor((c) => Math.max(0, Math.min(docLines.length - 1, c + delta)));
197
+ };
198
+ const goTop = () => setCursor(0);
199
+ const goBottom = () => setCursor(Math.max(0, docLines.length - 1));
200
+ const computeMatches = (q: string) => {
201
+ if (!q) return [];
202
+ const needle = q.toLowerCase();
203
+ const found: number[] = [];
204
+ for (let i = 0; i < docLines.length; i++) {
205
+ if (docLines[i].text.toLowerCase().indexOf(needle) >= 0) found.push(i);
206
+ }
207
+ return found;
208
+ };
209
+ const openSearch = () => { setSearchOpen(true); setError(null); setNotice(null); };
210
+ const closeSearch = () => { setSearchOpen(false); setQuery(''); setMatches([]); setActiveMatch(0); if (rootRef.current) rootRef.current.focus(); };
211
+ const onSearchChange = (e: { target: { value: string } }) => {
212
+ const q = e.target.value;
213
+ setQuery(q);
214
+ setMatches(computeMatches(q));
215
+ setActiveMatch(0);
216
+ };
217
+ const searchNext = () => {
218
+ if (matches.length === 0) return;
219
+ const next = (activeMatch + 1) % matches.length;
220
+ setActiveMatch(next);
221
+ setCursor(matches[next]);
222
+ };
223
+ const searchPrev = () => {
224
+ if (matches.length === 0) return;
225
+ const prev = (activeMatch - 1 + matches.length) % matches.length;
226
+ setActiveMatch(prev);
227
+ setCursor(matches[prev]);
228
+ };
229
+ const onSearchKey = (e: { key: string; shiftKey: boolean; preventDefault: () => void }) => {
230
+ if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); searchPrev(); }
231
+ else if (e.key === 'Enter') { e.preventDefault(); searchNext(); }
232
+ else if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
233
+ };
234
+
235
+ const copyDoc = () => {
236
+ // SAFETY: node/SSR has no clipboard object; the guarded shape matches lib.dom's
237
+ // Navigator.clipboard and falls back to an error status when absent.
238
+ const clip = (globalThis as { navigator?: { clipboard?: { writeText?: (t: string) => Promise<void> } } }).navigator?.clipboard;
239
+ if (clip === undefined || clip.writeText === undefined) { setError('Copy unavailable'); setNotice(null); return; }
240
+ if (clip && clip.writeText) {
241
+ const data = text ?? '';
242
+ clip.writeText(data).then(() => { setNotice('Doc copied'); setError(null); setCopied(true); }).catch(() => { setError('Copy failed'); setNotice(null); });
243
+ } else { setError('Copy unavailable'); setNotice(null); }
244
+ };
245
+
246
+ const onKey = (e: { key: string; preventDefault: () => void; target?: { tagName?: string } | null }) => {
247
+ const tag = (e.target && e.target.tagName) || '';
248
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
249
+ const rawKey = e.key || '';
250
+ const key = rawKey.toLowerCase();
251
+ if (rawKey === 'G') { e.preventDefault(); ggArmed.current = false; goBottom(); return; }
252
+ if (rawKey === 'N') { e.preventDefault(); ggArmed.current = false; searchPrev(); return; }
253
+ if (key === 'g') {
254
+ e.preventDefault();
255
+ if (ggArmed.current) { ggArmed.current = false; goTop(); }
256
+ else ggArmed.current = true;
257
+ return;
258
+ }
259
+ ggArmed.current = false;
260
+ if (key === 'j') { e.preventDefault(); move(1); }
261
+ else if (key === 'k') { e.preventDefault(); move(-1); }
262
+ else if (key === '/') { e.preventDefault(); openSearch(); }
263
+ else if (key === 'n') { e.preventDefault(); searchNext(); }
264
+ else if (key === 'y') { e.preventDefault(); copyDoc(); }
265
+ else if (key === 'escape') {
266
+ e.preventDefault();
267
+ if (searchOpen) closeSearch();
268
+ else onClose();
269
+ }
270
+ };
271
+
272
+ const matchSet = new Set(matches);
273
+ const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
274
+
275
+ const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
276
+
277
+ const searchBar = searchOpen ? createElement('div', { className: 'rec-doc-search' },
278
+ createElement('input', { className: 'rec-doc-search-input', value: query, placeholder: '/ search doc…', onChange: onSearchChange, onKeyDown: onSearchKey }),
279
+ createElement('span', { className: 'rec-doc-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : '')),
280
+ ) : null;
281
+
282
+ const statusText = error || notice || (docLines.length + ' lines');
283
+ const statusCls = 'rec-doc-status' + (error ? ' rec-doc-status-error' : notice ? ' rec-doc-status-ok' : '');
284
+
285
+ return createElement('div', { className: 'rec-doc', 'data-theme': theme, onKeyDown: onKey, ref: rootRef, tabIndex: -1 },
286
+ createElement('header', { className: 'rec-doc-header' },
287
+ createElement('div', { className: 'rec-doc-heading' },
288
+ createElement('span', { className: 'rec-doc-badge' }, 'Phase doc'),
289
+ createElement('h2', { className: 'rec-doc-title' }, fileName),
290
+ createElement('span', { className: 'rec-doc-run' }, runId),
291
+ ),
292
+ createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-copy', onClick: copyDoc, title: 'Copy doc', 'aria-label': 'Copy doc' }, 'Copy doc'),
293
+ createElement('button', { type: 'button', className: 'rec-doc-btn rec-doc-close', onClick: onClose, title: 'Close', 'aria-label': 'Close' }, 'Close'),
294
+ ),
295
+ searchBar,
296
+ createElement('div', { className: 'rec-doc-body' },
297
+ text === null && error === null ? createElement('p', { className: 'rec-doc-text' }, 'Loading doc…') : null,
298
+ error !== null ? createElement('p', { className: 'rec-doc-text rec-doc-error' }, error) : null,
299
+ text !== null ? lineEls : null,
300
+ ),
301
+ createElement('footer', { className: 'rec-doc-footer' },
302
+ createElement('div', { className: statusCls, role: 'status' }, statusText),
303
+ createElement('div', { className: 'rec-doc-hints' }, SEARCH_HINTS.map((h, n) => createElement('span', { key: 'hint-' + String(n) }, createElement('kbd', null, h[0]), ' ' + h[1] + (n < SEARCH_HINTS.length - 1 ? ' |' : '')))),
304
+ ),
305
+ );
306
+ }
@@ -55,6 +55,34 @@ export async function fetchLiveState(scope: LiveScope, signal?: AbortSignal): Pr
55
55
  }
56
56
  }
57
57
 
58
+ /** One doc fetch request: the host-known workspace root + run id + phase doc file. */
59
+ export interface PhaseDocRequest {
60
+ root: string
61
+ runId: string
62
+ file: string
63
+ }
64
+
65
+ /**
66
+ * Fetch one per-phase run doc (0.2.4) via the lazy GET /.recursive/api/doc
67
+ * route. Raw markdown text; the host validates the root (known workspace) and
68
+ * guards the file path (phase-doc basename only, containment under the run
69
+ * dir). Same 15s AbortController timeout as fetchLiveState.
70
+ */
71
+ export async function fetchPhaseDoc(req: PhaseDocRequest): Promise<string> {
72
+ const controller = new AbortController()
73
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
74
+ try {
75
+ const q = 'root=' + encodeURIComponent(req.root)
76
+ + '&runId=' + encodeURIComponent(req.runId)
77
+ + '&file=' + encodeURIComponent(req.file)
78
+ const res = await fetch(RECURSIVE_API_PREFIX + '/doc?' + q, { signal: controller.signal, cache: 'no-store' })
79
+ if (!res.ok) throw new Error('recursive phase doc: HTTP ' + res.status)
80
+ return await res.text()
81
+ } finally {
82
+ clearTimeout(timer)
83
+ }
84
+ }
85
+
58
86
  /**
59
87
  * Subscribe to the SSE events feed. Returns a disposer. The host pushes a full
60
88
  * {revision, root, projection} frame on every fs change + a 15s heartbeat.
@@ -17,6 +17,7 @@ import { claimClientApply, releaseClientApply } from './apply-guard.ts'
17
17
 
18
18
  export { Board, listRuns } from './board.tsx'
19
19
  export { Inspector } from './inspector.tsx'
20
+ export { DocViewer, parseDoc, parseInline } from './doc-viewer.tsx'
20
21
  export { RecursiveView } from './slots.ts'
21
22
  export { RecursiveSettings } from './settings.tsx'
22
23
  export { useLiveProjection } from './use-live.ts'
@@ -4,9 +4,10 @@
4
4
  * toggle, and solid status pills. All from the live host-route snapshot.
5
5
  * useBoardTheme is hoisted ABOVE the not-found early return.
6
6
  */
7
- import { createElement, type ReactNode } from 'react'
7
+ import { createElement, useState, type ReactNode } from 'react'
8
8
  import type { LiveProjectionValue } from './contract.ts'
9
9
  import { cardFacts, expandPhaseRows, cardPill, phaseStatusPill, PILL_LABELS } from './derive.ts'
10
+ import { DocViewer } from './doc-viewer.tsx'
10
11
  import { useBoardTheme, ThemeToggle } from './theme.ts'
11
12
  import type { BoardTheme } from './theme.ts'
12
13
 
@@ -44,6 +45,7 @@ function detailShell(runId: string, onBackToToolDetails: () => void, onClose: ((
44
45
 
45
46
  export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails, onClose }: InspectorProps) {
46
47
  const { theme, toggle } = useBoardTheme()
48
+ const [openFileName, setOpenFileName] = useState<string | null>(null)
47
49
  const card = snapshot?.projection?.[worktreeRoot]?.[runId]
48
50
  if (card === undefined) {
49
51
  return detailShell(runId, onBackToToolDetails, onClose, null,
@@ -55,13 +57,22 @@ export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails,
55
57
  const rows = expandPhaseRows(card)
56
58
  const pill = cardPill(card)
57
59
  const headerExtra = solidPill(pill)
58
- const body = createElement('section', { className: 'rec-detail-section' },
60
+ const body = openFileName !== null
61
+ ? createElement(DocViewer, { runId, worktreeRoot, fileName: openFileName, theme, onClose: () => setOpenFileName(null) })
62
+ : createElement('section', { className: 'rec-detail-section' },
59
63
  createElement('h3', {}, 'Phases'),
60
64
  rows.map((row) => createElement('div', { key: row.phase, className: 'rec-phase-row' },
61
65
  createElement('span', { className: 'rec-phase-id' }, row.phase),
62
66
  createElement('span', { className: 'rec-phase-name' }, row.fileName ?? '—'),
63
67
  solidPill(phaseStatusPill(row.status)),
64
68
  row.lockHash !== undefined && createElement('code', { className: 'rec-lockhash' }, '#' + row.lockHash.slice(0, 8)),
69
+ row.present && row.fileName !== null && createElement('button', {
70
+ type: 'button',
71
+ className: 'rec-phase-view',
72
+ onClick: () => setOpenFileName(row.fileName!),
73
+ title: 'View phase doc',
74
+ 'aria-label': 'View phase doc',
75
+ }, 'View phase'),
65
76
  )),
66
77
  facts.gateBlocked && createElement('div', { className: 'rec-detail-section' },
67
78
  createElement('h3', {}, 'Gate'),