Selective wiki dashboard: continuation, freshness, history, local search and user-triggered actions.

A Claude Code mod for a selective Markdown knowledge workspace. It provides continuation/freshness/history tabs, bounded local wiki/source search and previews, local refresh/toggle controls, and explicit research/audit/source-ingest/state-update actions using the user's current model.
Requires terminal Claude Code 2.1.287+ for normal mod loading. No npm install or build step. Load this directory with claude --plugin-dir ./plugins/selective-wiki-ui, or install from the repository's marketplace once and then use normal claude.
The target project needs state.md and index.md; canonical notes live under wiki/. This plugin does not bootstrap or overwrite project memory. The repository's Python hook and project instructions provide startup context and selective writing.
See the complete guide for installation, every command, bounds, tests and known verification limits. Licensed under the repository's MIT license.
hooks/register.mjs 449 lines1// Runtime-independent parsing and bounded filesystem access. No Node imports.
2export const LIMITS = Object.freeze({ state: 16000, log: 32000, note: 24000,
3 files: 160, directories: 80, depth: 6, bytes: 512000, results: 12, query: 500 });
4
5export function clean(value, limit = 4000) {
6 return String(value ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
7 .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').slice(0, limit);
8}
9
10const byteWidth = ch => { const c = ch.codePointAt(0); return c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; };
11export function utf8Length(text) { let n = 0; for (const ch of String(text)) n += byteWidth(ch); return n; }
12// Clip by UTF-8 bytes (not characters); keep the start, or the end for chronological logs.
13export function clipBytes(text, maxBytes, fromEnd = false) {
14 const chars = Array.from(String(text));
15 if (fromEnd) chars.reverse();
16 let used = 0, kept = 0;
17 for (const ch of chars) { const w = byteWidth(ch); if (used + w > maxBytes) break; used += w; kept++; }
18 const part = chars.slice(0, kept);
19 return (fromEnd ? part.reverse() : part).join('');
20}
21
22// Case-, diacritic- and Turkish dotted/dotless-i-insensitive form used only for matching.
23export function fold(text) {
24 return String(text ?? '').replace(/[ıİ]/g, 'i').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
25}
26const WORD = /[\p{L}\p{N}]+/gu;
27const wordSet = folded => new Set(folded.match(WORD) ?? []);
28// A term matches by substring, or as an inflected form of a word present in the text
29// (the text holds the root, the query adds up to 10 suffix characters; roots of 4+ chars).
30export function termMatch(term, folded, words) {
31 if (folded.includes(term)) return 'exact';
32 for (let length = term.length - 1; length >= Math.max(4, term.length - 10); length--)
33 if (words.has(term.slice(0, length))) return 'stem';
34 return '';
35}
36
37export function section(text, title) {
38 const lines = clean(text, LIMITS.log).split('\n');
39 const start = lines.findIndex(line => line.trim().toLowerCase() === `## ${title}`.toLowerCase());
40 if (start < 0) return 'Not recorded.';
41 let end = start + 1;
42 while (end < lines.length && !/^#{1,2}\s/.test(lines[end])) end++;
43 return lines.slice(start + 1, end).join('\n').trim() || 'Not recorded.';
44}
45
46export function freshness(text, now = Date.now()) {
47 const recorded = /^Updated:\s*(.*)$/mi.exec(text)?.[1]?.trim() || 'Not recorded';
48 const date = /\b(\d{4}-\d{2}-\d{2})\b/.exec(recorded)?.[1];
49 const parsed = date && Date.parse(`${date}T00:00:00Z`);
50 const stamp = Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === date ? parsed : NaN;
51 if (!Number.isFinite(stamp)) return { recorded, label: 'Undated — verification unknown' };
52 // Stored dates are calendar dates; compare against the local calendar day.
53 const current = new Date(now);
54 const today = Date.UTC(current.getFullYear(), current.getMonth(), current.getDate());
55 const age = Math.round((today - stamp) / 86400000);
56 if (age < 0) return { recorded, label: 'Future date — check the record' };
57 return { recorded, label: `${age} day(s) since record — live verification unknown` };
58}
59
60export function history(text) {
61 // Accept dated headings or single-line entries; retain multiline entries.
62 // Order by date so oldest-first and newest-first logs both show the newest five.
63 const blocks = clean(text, LIMITS.log).split(/\n(?=(?:#{1,3}\s+|[-*]\s+)?\d{4}-\d{2}-\d{2}\b)/);
64 const entries = blocks.map((block, index) => ({ block: block.trim(), index }))
65 .map(entry => ({ ...entry, date: /^(?:#{1,3}\s+|[-*]\s+)?(\d{4}-\d{2}-\d{2})\b/.exec(entry.block)?.[1] }))
66 .filter(entry => entry.date);
67 if (!entries.length) return [];
68 const newestFirst = entries[0].date > entries[entries.length - 1].date;
69 entries.sort((a, b) => a.date === b.date ? (newestFirst ? 1 : -1) * (a.index - b.index) : a.date < b.date ? 1 : -1);
70 return entries.slice(0, 5).map(entry => clean(entry.block, 800));
71}
72
73export function parent(path) {
74 const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '');
75 const end = normalized.lastIndexOf('/');
76 return end <= 0 ? '/' : normalized.slice(0, end);
77}
78
79const join = (root, relative) => `${root.replace(/\/+$/, '')}/${relative}`;
80export const inside = (root, path) => path === root || path.startsWith(`${root.replace(/\/+$/, '')}/`);
81
82export async function findRoot($, cwd) {
83 let candidate = cwd;
84 for (let i = 0; i < 24; i++) {
85 if (await $.fs.exists(join(candidate, 'state.md')) && await $.fs.exists(join(candidate, 'index.md'))) {
86 const info = await $.fs.stat(candidate, { resolve: true });
87 if (!info.realPath || info.kind !== 'dir') throw new Error('Cannot resolve wiki root.');
88 return info.realPath;
89 }
90 const up = parent(candidate);
91 if (up === candidate) break;
92 candidate = up;
93 }
94 return null;
95}
96
97async function localFile($, root, relative) {
98 if (!relative || relative.startsWith('/') || relative.split(/[\\/]/).some(p => p === '..' || p === '.'))
99 throw new Error('Invalid relative note path.');
100 const path = join(root, relative);
101 const info = await $.fs.stat(path, { resolve: true });
102 if (info.kind !== 'file' || !info.realPath || !inside(root, info.realPath))
103 throw new Error(`File is outside the wiki or unavailable: ${relative}`);
104 return { path, info };
105}
106
107export async function readLocal($, root, relative, maxBytes) {
108 const { path, info } = await localFile($, root, relative);
109 if (info.size > maxBytes) throw new Error(`File exceeds ${maxBytes} bytes: ${relative}`);
110 return clean(await $.fs.read(path), maxBytes);
111}
112
113// Continuation records degrade instead of failing: an oversized file is clipped by bytes
114// (head for state, tail for the chronological log) and the caller is told it was clipped.
115export async function readBounded($, root, relative, maxBytes, fromEnd = false) {
116 const { path, info } = await localFile($, root, relative);
117 if (info.size > LIMITS.bytes) throw new Error(`File exceeds ${LIMITS.bytes} bytes: ${relative}`);
118 const text = await $.fs.read(path);
119 const truncated = info.size > maxBytes || utf8Length(text) > maxBytes;
120 return { text: clean(truncated ? clipBytes(text, maxBytes, fromEnd) : text, maxBytes), truncated };
121}
122
123export async function snapshot($, root) {
124 const state = await readBounded($, root, 'state.md', LIMITS.state);
125 const warnings = [];
126 if (state.truncated) warnings.push(`state.md exceeds ${LIMITS.state} bytes; only its beginning is shown. Shorten it (aim for 200–400 words).`);
127 let changes = [], warning = '', hook = false;
128 try {
129 const log = await readBounded($, root, 'log.md', LIMITS.log, true);
130 changes = history(log.text);
131 if (log.truncated) warning = `log.md exceeds ${LIMITS.log} bytes; only its most recent part was scanned.`;
132 } catch (error) { warning = clean(error.message, 200); }
133 try { hook = await $.fs.exists(join(root, 'scripts/session_start.py')); } catch { hook = false; }
134 return { summary: section(state.text, 'Startup summary'), active: section(state.text, 'Active work'),
135 blockers: section(state.text, 'Blockers and pending decisions'), next: section(state.text, 'Next step'),
136 freshness: freshness(state.text), changes, warning, stateWarning: warnings.join(' '), hook };
137}
138
139export const HOOK_NOTICE = 'Startup hook not found (scripts/session_start.py): this workspace summary is not injected into new sessions. See SETUP.md.';
140
141export function isSourcePath(path) {
142 return typeof path === 'string' && path.length <= 500 && /^(raw|docs)\//.test(path)
143 && !/[\\\x00-\x1f\x7f]/.test(path) && !path.split('/').some(part => !part || part === '.' || part === '..');
144}
145
146export async function sourceInfo($, root, relative) {
147 if (!isSourcePath(relative)) throw new Error('Select a file inside raw/ or docs/.');
148 const info = await $.fs.stat(join(root, relative), { resolve: true });
149 const folder = join(root, relative.split('/')[0]);
150 if (info.kind !== 'file' || info.isLink || !info.realPath || !inside(folder, info.realPath))
151 throw new Error('Source is unavailable or outside raw/ or docs/.');
152 return info;
153}
154
155export async function searchNotes($, root, query, mode = 'wiki') {
156 const sources = mode === 'sources';
157 const terms = fold(clean(query, LIMITS.query)).trim().split(/\s+/).filter(Boolean);
158 if (!terms.length) return { results: [], message: sources ? '' : 'Enter words to search local wiki notes.' };
159 const queue = (sources ? ['raw', 'docs'] : ['wiki']).map(path => ({ path, depth: 0 })), matches = [];
160 let files = 0, directories = 0, bytes = 0, skipped = 0, capped = false;
161 while (queue.length && files < LIMITS.files && directories < LIMITS.directories) {
162 const current = queue.shift();
163 const directory = join(root, current.path);
164 let entries;
165 try {
166 const info = await $.fs.stat(directory, { resolve: true });
167 if (!info.realPath || !inside(root, info.realPath) || info.isLink) { skipped++; continue; }
168 entries = await $.fs.list(directory);
169 } catch { skipped++; continue; }
170 directories++;
171 for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
172 if (entry.isLink || !entry.name || /[\\/\x00-\x1f]/.test(entry.name) || entry.name.startsWith('.')) { skipped++; continue; }
173 const path = `${current.path}/${entry.name}`;
174 if (entry.kind === 'dir') {
175 if (current.depth < LIMITS.depth) queue.push({ path, depth: current.depth + 1 });
176 else capped = true;
177 } else if (entry.kind === 'file' && (sources || /\.md$/i.test(entry.name))) {
178 if (files >= LIMITS.files) { capped = true; continue; }
179 files++;
180 let text = '';
181 const readable = !sources || /\.(md|txt|rst|csv|json|ya?ml|html?)$/i.test(entry.name);
182 try {
183 if (sources) await sourceInfo($, root, path);
184 if (readable && entry.size <= LIMITS.note && bytes + entry.size <= LIMITS.bytes) {
185 text = await readLocal($, root, path, LIMITS.note); bytes += entry.size;
186 } else if (!sources) { skipped++; capped ||= bytes + entry.size > LIMITS.bytes; continue; }
187 else if (readable) capped = true;
188 } catch { skipped++; continue; }
189 const title = /^#\s+(.+)$/m.exec(text)?.[1] || entry.name;
190 const haystack = fold(`${path}\n${title}\n${text}`), words = wordSet(haystack);
191 const kinds = terms.map(term => termMatch(term, haystack, words));
192 if (kinds.some(kind => !kind)) continue;
193 const lines = text.split('\n');
194 const hit = lines.findIndex(line => { const f = fold(line), w = wordSet(f); return terms.some(term => termMatch(term, f, w)); });
195 const foldedTitle = fold(title), foldedPath = fold(path);
196 const score = terms.reduce((n, term, i) => n + (kinds[i] === 'exact' ? 3 : 0)
197 + (foldedTitle.includes(term) ? 5 : 0) + (foldedPath.includes(term) ? 2 : 0), 0);
198 matches.push({ path, title: clean(title, 160), line: Math.max(1, hit + 1),
199 excerpt: text ? clean(lines.slice(Math.max(0, hit), Math.max(0, hit) + 3).join('\n'), 320) : 'Listed by filename; content not searched.', score });
200 }
201 }
202 }
203 capped ||= queue.length > 0;
204 const results = matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, LIMITS.results);
205 return { results, message: `${files} ${sources ? 'source files' : 'notes'} scanned; ${results.length} shown.${capped ? (sources ? ' Scan/content limit reached; narrow by filename.' : ' Scan limit reached; narrow the topic or use Research.') : ''}${skipped ? ` ${skipped} unavailable, large or excluded entries skipped.` : ''}` };
206}
207
208export function actionPrompt(kind, root, scope = '', selected = '') {
209 if (!['research', 'audit', 'save', 'ingest'].includes(kind)) throw new Error('Unknown wiki action.');
210 const topic = clean(scope, LIMITS.query).trim();
211 if (kind === 'research' && !topic) throw new Error('Enter a research question first.');
212 if (kind === 'ingest' && !isSourcePath(selected)) throw new Error('Select a source from raw/ or docs/ first.');
213 const skill = ['save', 'ingest'].includes(kind) ? 'ingest' : kind === 'research' ? 'query' : 'lint';
214 const workflow = `Use the project ${skill} skill at ${JSON.stringify(`${root}/.claude/skills/${skill}/SKILL.md`)} if not already loaded; follow the project CLAUDE.md. If the skill is missing, report it and stop without writing.`;
215 if (kind === 'save') return `Save conversation memory (conversation mode). ${workflow}`;
216 if (kind === 'ingest') return `Ingest source (supplied-source mode). ${workflow} Source: ${JSON.stringify(selected)}. Scope: ${JSON.stringify(topic || 'useful durable project information')}.`;
217 const location = `Selected note: ${JSON.stringify(selected || 'none')}. User scope/question: ${JSON.stringify(topic || 'current continuation and relevant notes')}.`;
218 return `${kind === 'research' ? 'Research local wiki (read-only)' : 'Audit wiki (report mode; no repairs)'}. ${workflow} ${location}`;
219
220}
221
222
223const PANE = 'selective-wiki-dashboard';
224const COMMANDS = [
225 ['wiki-status', 'Open the continuation dashboard (local, no model call)', ''],
226 ['wiki-refresh', 'Refresh saved state and history (local)', ''],
227 ['wiki-toggle', 'Show or hide the dashboard (local)', ''],
228 ['wiki-search', 'Search local wiki notes without a model call', '<words>'],
229 ['wiki-research', 'Research local notes with Claude', '<question>'],
230 ['wiki-audit', 'Audit relevant wiki notes with Claude', '[scope]'],
231 ['wiki-save', 'Save meaningful memory from this conversation', ''],
232 ['wiki-sources', 'Search raw/ and docs/ source files locally', '<words>'],
233];
234
235let root = null, data = null, error = '', tab = 'state', query = '', selected = '', preview = '';
236let results = [], searchMessage = 'Enter words to search local wiki notes.', opened = false;
237let searchMode = 'wiki', actionScope = '';
238let interactive = false, busy = false, pending = false, searchGeneration = 0;
239
240const redraw = $ => $.ui.invalidate('ui.render');
241const report = ($, failure) => {
242 error = clean(failure?.message || failure, 300);
243 $.ui.toast(error);
244 redraw($);
245};
246const refresh = async $ => {
247 // Serialize reads, and erase old data on failure instead of showing it as current.
248 if (busy) return;
249 busy = true;
250 try {
251 const previousRoot = root;
252 root = await findRoot($, await $.session.cwd());
253 if (root !== previousRoot) { searchGeneration++; results = []; selected = ''; preview = ''; }
254 data = root ? await snapshot($, root) : null;
255 error = root ? '' : 'No wiki found. This project needs state.md and index.md.';
256 } catch (failure) { data = null; error = clean(failure.message, 300); }
257 finally { busy = false; redraw($); }
258};
259const open = async ($, focus = false) => {
260 if (!interactive) return;
261 const pane = { id: PANE, title: 'Selective Wiki', closeOnEscape: true, rows: 18 };
262 const placement = await $.ui.open(focus ? { ...pane, focus: true } : pane);
263 opened = placement.isPlaced;
264 if (!opened) $.ui.log('Wiki ready. Open the dashboard with /wiki-status or Open wiki; automatic panes wait for a wider terminal.');
265};
266const search = async ($, value) => {
267 query = clean(value, LIMITS.query);
268 selected = ''; preview = '';
269 const generation = ++searchGeneration;
270 if (!root) { results = []; searchMessage = error || 'No wiki found.'; redraw($); return; }
271 try {
272 const answer = await searchNotes($, root, query, searchMode);
273 if (generation !== searchGeneration) return;
274 results = answer.results; searchMessage = answer.message;
275 } catch (failure) { if (generation === searchGeneration) report($, failure); }
276 redraw($);
277};
278const launch = ($, kind, scope) => {
279 if (!root || !data) { report($, new Error(error || 'Refresh the wiki first.')); return; }
280 if (pending) { $.ui.toast('A wiki action is already queued.'); return; }
281 let text;
282 try { text = actionPrompt(kind, root, kind === 'save' ? '' : scope, kind === 'save' || (kind === 'audit' && searchMode === 'sources') ? '' : selected); }
283 catch (failure) { report($, failure); return; }
284 const targetRoot = root, source = selected;
285 pending = true;
286 $.ui.toast('Wiki action queued.');
287 // Outside the command/render callback: never await an idle turn inside a hook.
288 $.clock.after(0, async () => {
289 try {
290 if (kind === 'ingest') {
291 if (root !== targetRoot) throw new Error('Wiki changed; select the source again.');
292 await sourceInfo($, targetRoot, source);
293 }
294 await $.prompt.submit({ text });
295 }
296 catch (failure) { report($, failure); }
297 finally { pending = false; redraw($); }
298 });
299};
300const notices = () => data ? [data.stateWarning, data.hook ? '' : HOOK_NOTICE].filter(Boolean).join('\n') : '';
301const statusText = () => data ? clean(`Selective Wiki\nRecorded: ${data.freshness.recorded}\n${data.freshness.label}\n\n${data.summary}\n\nActive: ${data.active}\nBlockers: ${data.blockers}\nNext: ${data.next}${notices() ? `\n\n${notices()}` : ''}`, 2800) : error;
302
303export function register(on) {
304 on('session.start', async ($, e, next) => {
305 try {
306 interactive = e.isInteractive && ['terminal', 'desktop'].includes(e.surface);
307 for (const [name, description, argumentHint] of COMMANDS) {
308 await $.command.register({ name, description, argumentHint, immediate: true });
309 }
310 await refresh($);
311 if (root && data) {
312 // Without the project's SessionStart hook nothing else displays the summary.
313 if (!data.hook) $.ui.log(`Session summary — state.md\n${data.summary}\n${HOOK_NOTICE}`);
314 await open($);
315 }
316 } catch (failure) {
317 // A host API change must not break the session; Markdown memory and hook keep working.
318 try { $.ui.log(`Selective Wiki panel unavailable: ${clean(failure?.message || failure, 200)}. Memory files and startup hook are unaffected.`); } catch { /* ignore */ }
319 }
320 return next(e);
321 });
322
323 on('classic.SessionStart', async ($, e, next) => {
324 if (!e.agent_id && ['clear', 'resume', 'fork', 'compact'].includes(e.source)) {
325 searchGeneration++; results = []; selected = ''; preview = '';
326 await refresh($);
327 if (opened) await open($);
328 }
329 return next(e);
330 });
331
332 on('turn.complete', async ($, e, next) => {
333 // Main turn only: two small local files, no archive scan and no model call.
334 if (!e.agentId) await refresh($);
335 return next(e);
336 });
337
338 on('ui.close', async ($, e, next) => {
339 if (e.id === PANE) opened = false;
340 return next(e);
341 });
342
343 for (const [command] of COMMANDS) {
344 on('command.run', { command }, async ($, e) => {
345 try {
346 if (!root || command === 'wiki-refresh' || command === 'wiki-status') await refresh($);
347 if (command === 'wiki-toggle') {
348 if (opened) { await $.ui.close({ id: PANE }); opened = false; }
349 else await open($, true);
350 } else if (command === 'wiki-search' || command === 'wiki-sources') {
351 searchMode = command === 'wiki-sources' ? 'sources' : 'wiki';
352 tab = 'search'; await search($, e.args); await open($, true);
353 if (!interactive) return { text: clean(`${searchMessage}\n${results.map(r => `${r.path}:${r.line} — ${r.title}\n${r.excerpt}`).join('\n\n')}`, 4000) };
354 } else if (['wiki-research', 'wiki-audit', 'wiki-save'].includes(command)) {
355 if (e.origin?.kind === 'plugin' || e.origin?.kind === 'model')
356 return { text: 'Wiki model actions must be started explicitly by the user.' };
357 launch($, command === 'wiki-save' ? 'save' : command.slice(5), command === 'wiki-save' ? '' : e.args);
358 } else { tab = 'state'; await open($, true); }
359 return interactive ? {} : { text: statusText() };
360 } catch (failure) { report($, failure); return { text: clean(failure.message, 300) }; }
361 });
362 }
363
364 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
365 if (e.requestId !== PANE) return next(e);
366 const { Box, Text, Button, Input } = $.ui.resolve(e);
367 const line = (text, bold = false, color = bold ? 'cyan' : undefined) => Text({ children: [clean(text, 4000)], bold, ...(color ? { color } : {}) });
368 const button = (key, label, onPress, color = 'cyan') => Box({ borderStyle: 'quote', borderColor: color, paddingLeft: 1, children: [Button({ key, label, onPress })] });
369 const tabs = Box({ flexDirection: 'row', columnGap: 1, children: ['state', 'history', 'search'].map(name =>
370 Box({ borderStyle: 'single', borderColor: tab === name ? 'cyan' : 'gray', paddingX: 1, children: [
371 Button({ key: `tab-${name}`, label: name, plain: true, dimColor: tab !== name,
372 onPress: () => { tab = name; redraw($); } })] })) });
373 const controls = Box({ flexDirection: 'row', columnGap: 1, children: [
374 button('refresh', 'Refresh', async () => { await refresh($); if (tab === 'search' && (query || searchMode === 'sources')) await search($, query); }),
375 button('close', 'Hide', async () => { await $.ui.close({ id: PANE }); opened = false; }),
376 ] });
377 const body = [];
378 if (error) body.push(line(error, true, 'red'));
379 if (data) {
380 if (tab === 'state' && notices()) body.push(line(notices(), false, 'yellow'));
381 if (tab === 'state') body.push(line(`Recorded: ${data.freshness.recorded}`), line(data.freshness.label, false, 'yellow'),
382 line('Startup summary', true), line(data.summary), line('Active work', true), line(data.active),
383 line('Blockers / pending decisions', true, 'yellow'), line(data.blockers), line('Next step', true, 'green'), line(data.next));
384 if (tab === 'history') body.push(line('Recent meaningful memory changes', true),
385 ...data.changes.map(change => line(change)), ...(data.changes.length ? [] : [line('No dated changes recorded.')]),
386 ...(data.warning ? [line(data.warning, false, 'yellow')] : []));
387 if (tab === 'search') {
388 body.push(Box({ flexDirection: 'row', columnGap: 1, children: ['wiki', 'sources'].map(mode =>
389 button(`filter-${mode}`, `${searchMode === mode ? '● ' : ''}${mode === 'wiki' ? 'Wiki' : 'Sources'}`, async () => {
390 searchMode = mode; actionScope = ''; await search($, '');
391 })) }));
392 body.push(Input({ key: 'search', label: searchMode === 'sources' ? 'Sources in raw/ and docs/' : 'Local notes', placeholder: 'Words (all must match)',
393 value: query, submitLabel: 'search', onInput: value => {
394 query = clean(value, LIMITS.query);
395 if (!query.trim()) return search($, '');
396 },
397 onSubmit: value => search($, value) }));
398 if (searchMessage) body.push(line(searchMessage));
399 for (const result of results) body.push(button(`note-${result.path}`, `${result.title} — ${result.path}:${result.line}`, async () => {
400 try {
401 let text;
402 if (searchMode === 'sources') {
403 const info = await sourceInfo($, root, result.path);
404 text = /\.(md|txt|rst|csv|json|ya?ml|html?)$/i.test(result.path) && info.size <= LIMITS.note
405 ? await readLocal($, root, result.path, LIMITS.note)
406 : `Source file (${info.size} bytes). Content preview unavailable; Claude checks format support during ingest.`;
407 } else text = await readLocal($, root, result.path, LIMITS.note);
408 selected = result.path; preview = clean(text, 3000); redraw($);
409 } catch (failure) { report($, failure); }
410 }));
411 if (selected) body.push(line(`Selected: ${selected}`, true), line(preview), line('Preview capped at 3,000 characters.'));
412 }
413 if (tab === 'state') {
414 body.push(button('save', 'Save conversation memory', () => launch($, 'save', ''), 'magenta'));
415 } else if (tab === 'search' && searchMode === 'sources') {
416 body.push(Input({ key: 'source-scope', label: 'What to retain (optional)', value: actionScope,
417 onInput: value => { actionScope = clean(value, LIMITS.query); },
418 onSubmit: value => { actionScope = clean(value, LIMITS.query); redraw($); } }));
419 if (selected) body.push(button('ingest', 'Ingest source', () => launch($, 'ingest', actionScope), 'green'));
420 } else {
421 body.push(line('Wiki actions', true, 'magenta'),
422 Input({ key: 'scope', label: 'Question / scope', value: query, placeholder: 'Topic or question',
423 onInput: value => { query = clean(value, LIMITS.query); },
424 onSubmit: value => { query = clean(value, LIMITS.query); redraw($); } }));
425 if (tab === 'search') body.push(button('research', 'Research wiki', () => launch($, 'research', query), 'cyan'));
426 body.push(button('audit', 'Audit wiki', () => launch($, 'audit', query), 'yellow'));
427 }
428 if (pending) body.push(line('Action queued; it starts when Claude is idle.'));
429 }
430 return Box({ flexDirection: 'column', gap: 1, children: [tabs, controls, ...body] });
431 });
432
433 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
434 const original = await next(e);
435 if (!data) return original;
436 const { Box, Text, Button } = $.ui.resolve(e);
437 return Box({ flexDirection: 'column', children: [...(original ? [original] : []),
438 Box({ flexDirection: 'row', columnGap: 1, children: [
439 Text({ children: [clean(`Wiki · ${opened ? '' : '/wiki-status · '}${data.freshness.label}`, 130)], color: 'cyan', wrap: 'truncate' }),
440 Button({ key: 'open-wiki', label: opened ? 'Hide wiki' : 'Open wiki', onPress: async () => {
441 if (opened) { await $.ui.close({ id: PANE }); opened = false; }
442 else await open($, true);
443 redraw($);
444 } }),
445 ] }),
446 ] });
447 });
448}
449