SLOPSHOPPER

selective-wiki-ui

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

newpanebandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · selective-wiki-ui
│ ┃ Selective Wiki ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ┌──────────────────────────────────────────┐ │ selective-wiki-ui │ │ ┃ │ state │ ⏺ Read(src/auth.ts) │ No wiki found. This project needs state.md │ │ ┃ └──────────────────────────────────────────┘ ⎿ Read 6 lines │ and index.md. │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ┌──────────────────────────────────────────┐ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ │ [ Refresh ] │ ⏺ Bash(bun test) │ selective-wiki-ui │ │ ┃ └──────────────────────────────────────────┘ ⎿ 3 pass, 1 fail │ No wiki found. This project needs state.md │ │ ┃ │ and index.md. │ │ ┃ No wiki found. This project needs state.md ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ and index.md. │ ✻ Worked for 42s · done 4:20 PM │ │ › /wiki-status │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Selective Wiki
┌──────────────────────────────────────────────────────────┐ │ state │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ [ Refresh ] │ └──────────────────────────────────────────────────────────┘ No wiki found. This project needs state.md and index.md.
README

Selective Wiki UI

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.

Source 1 files
hooks/register.mjs 449 lines
1// 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