@dotdrelle/wiki-manager 0.6.17

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,1490 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import { Transform } from 'node:stream';
4
+ import { stdin as input, stdout as output } from 'node:process';
5
+ import { marked } from 'marked';
6
+ import { markedTerminal } from 'marked-terminal';
7
+ import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
8
+ import { handleSlashCommand } from '../commands/slash.js';
9
+ import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
10
+ import { extractActivity, sessionActivities } from '../core/activity.js';
11
+ import { syncActivitiesToPlan } from '../core/plan.js';
12
+ import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
13
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
14
+ import { listSkills } from '../core/skills.js';
15
+ import { listWikircProfiles } from '../core/wikirc.js';
16
+ import { listWorkspaces } from '../core/workspaces.js';
17
+
18
+ marked.use(markedTerminal());
19
+ // marked-terminal's text renderer extracts token.text (raw string) instead of
20
+ // calling parseInline(token.tokens), so inline Markdown inside list items is
21
+ // silently dropped. Patch it to call parseInline when tokens are available.
22
+ marked.use({
23
+ useNewRenderer: true,
24
+ renderer: {
25
+ text(token) {
26
+ if (typeof token === 'object' && token.tokens) {
27
+ return this.parser.parseInline(token.tokens);
28
+ }
29
+ return token.text ?? String(token);
30
+ },
31
+ table(token) {
32
+ const cols = output.columns || 100;
33
+ const numCols = token.header.length || 1;
34
+ const colWidth = Math.max(6, Math.floor((cols - 1 - numCols * 3) / numCols));
35
+ const parseCell = (cell) => {
36
+ if (!cell) return '';
37
+ if (cell.tokens) return stripAnsi(this.parser.parseInline(cell.tokens)).replace(/\s+/g, ' ').trim();
38
+ return String(cell.text ?? cell ?? '').trim();
39
+ };
40
+ return renderTerminalTable(token, parseCell, colWidth);
41
+ },
42
+ },
43
+ });
44
+
45
+ const GLOBAL_CONVERSATION_KEY = '__global__';
46
+ const LEGACY_DONNA_ROLE = 'do' + 't';
47
+ const LOWER_DETAIL_ROWS = 8;
48
+ const COMPLETION_PANEL_ROWS = 5;
49
+ const LOWER_PANEL_SEPARATOR_ROWS = 1;
50
+ const LOWER_PANEL_ROWS = LOWER_PANEL_SEPARATOR_ROWS + LOWER_DETAIL_ROWS;
51
+ const BOTTOM_PADDING_ROWS = 3;
52
+ const MOUSE_SELECTION_RESUME_MS = 5000;
53
+ const COMMAND_COMPLETION_DESCRIPTIONS = {
54
+ '/help': 'Show shell commands.',
55
+ '/version': 'Print the wiki-manager version.',
56
+ '/exit': 'Exit the shell.',
57
+ '/workspaces': 'List configured workspaces.',
58
+ '/new': 'Create or configure a new workspace.',
59
+ '/use': 'Load a workspace and its default config.',
60
+ '/config': 'Inspect or switch .wikirc.yaml profiles.',
61
+ '/status': 'Show the current workspace and session state.',
62
+ '/services': 'List workspace Docker Compose services.',
63
+ '/start': 'Start one service or the workspace service set.',
64
+ '/stop': 'Stop one service or the workspace service set.',
65
+ '/logs': 'Show recent logs for a service.',
66
+ '/mcp': 'Inspect or call workspace MCP servers.',
67
+ '/wiki': 'Run llm-wiki commands for the active workspace.',
68
+ '/skills': 'List workspace skills.',
69
+ '/upload': 'Upload a document — /upload <path>',
70
+ '/uploads': 'List or clean uploaded documents.',
71
+ '/clear': 'Clear the conversation screen.',
72
+ '/chat': 'Switch free text to direct LLM chat without tools.',
73
+ '/agent': 'Switch free text to the LangGraph agent with tools.',
74
+ '/openui': 'Open the workspace web UI in the browser.',
75
+ };
76
+
77
+ const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
78
+ '/config:list': 'List .wikirc.yaml profiles.',
79
+ '/config:status': 'Show the active wikirc profile.',
80
+ '/config:use': 'Reload session config from a profile.',
81
+ '/config:edit': 'Edit one .wikirc.yaml profile.',
82
+ '/mcp:call': 'Call one MCP tool with optional JSON.',
83
+ '/mcp:endpoints': 'Show MCP URLs and token presence.',
84
+ '/mcp:status': 'Show MCP connection status.',
85
+ '/mcp:tools': 'Show discovered MCP tools.',
86
+ '/upload:convert': 'Convert one stored upload or all pending uploads.',
87
+ '/uploads:clean': 'Clean old stored document uploads.',
88
+ '/uploads:list': 'List uploaded documents.',
89
+ '/queue': 'Inspect or cancel queued MCP jobs.',
90
+ '/queue:cancel': 'Cancel a queued or running queue item.',
91
+ '/queue:clear': 'Clear finished queue items.',
92
+ '/workspace:init': 'Legacy form of /new.',
93
+ '/wiki:run': 'Use the low-level llm-wiki CLI fallback.',
94
+ '/skills:edit': 'Edit one workspace skill file.',
95
+ '/skills:list': 'List workspace skills.',
96
+ '/skills:run': 'Prepare one skill for guided execution.',
97
+ '/skills:show': 'Show one workspace skill.',
98
+ };
99
+
100
+ export function createSession() {
101
+ return {
102
+ workspace: null,
103
+ workspacePath: null,
104
+ workspaceEnvFile: null,
105
+ wikirc: null,
106
+ wikircConfig: null,
107
+ language: null,
108
+ mcp: null,
109
+ commands: ['help', 'version', 'exit', 'workspaces', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue'],
110
+ chatMode: false,
111
+ llm: null,
112
+ activities: {},
113
+ jobQueue: [],
114
+ productionActivity: null,
115
+ headlessPlan: null,
116
+ conversations: { [GLOBAL_CONVERSATION_KEY]: [] },
117
+ };
118
+ }
119
+
120
+ export function conversationKey(session) {
121
+ return session.workspace || GLOBAL_CONVERSATION_KEY;
122
+ }
123
+
124
+ export function conversationMessages(session) {
125
+ const key = conversationKey(session);
126
+ session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
127
+ session.conversations[key] ??= [];
128
+ return session.conversations[key];
129
+ }
130
+
131
+ function initialLegacyWelcomeMessage() {
132
+ return [
133
+ 'Orchestrator agent ready.',
134
+ '',
135
+ 'Load a workspace with `/use <workspace>`, then chat or use commands.',
136
+ 'Type `/help` for all commands.',
137
+ ].join('\n');
138
+ }
139
+
140
+ export function promptFor(session) {
141
+ return session.workspace ? `${session.workspace}> ` : 'donna > ';
142
+ }
143
+
144
+ function slashCompletions(session) {
145
+ return session.commands.map((command) => `/${command}`).sort();
146
+ }
147
+
148
+ function tokenCompletions(inputBuffer, values) {
149
+ const lastSpace = inputBuffer.lastIndexOf(' ');
150
+ const prefix = inputBuffer.slice(lastSpace + 1);
151
+ const base = inputBuffer.slice(0, lastSpace + 1);
152
+ const matches = values.filter((value) => value.startsWith(prefix));
153
+ if (matches.length === 0) return { inputBuffer };
154
+ if (matches.length === 1) return { inputBuffer: `${base}${matches[0]} ` };
155
+ const shared = commonPrefix(matches);
156
+ if (shared.length > prefix.length) return { inputBuffer: `${base}${shared}` };
157
+ // Completing an argument (base contains a space): select the first match.
158
+ // Completing the command itself (no space yet): leave unchanged so the user can keep typing.
159
+ if (lastSpace >= 0) return { inputBuffer: `${base}${matches[0]} ` };
160
+ return { inputBuffer };
161
+ }
162
+
163
+ function mcpNames(session) {
164
+ return Object.entries(session.mcp ?? {})
165
+ .filter(([, value]) => value.status === 'connected' || value.status === 'configured')
166
+ .map(([name]) => name)
167
+ .sort();
168
+ }
169
+
170
+ function mcpToolNames(session, serverName) {
171
+ return (session.mcp?.[serverName]?.tools ?? [])
172
+ .map((tool) => tool.name)
173
+ .sort();
174
+ }
175
+
176
+ function workspaceNames() {
177
+ return listWorkspaces().map((workspace) => workspace.name).sort();
178
+ }
179
+
180
+ function wikircProfileNames(session) {
181
+ return session.workspacePath
182
+ ? listWikircProfiles(session.workspacePath).map((profile) => profile.name).sort()
183
+ : [];
184
+ }
185
+
186
+ function serviceNames() {
187
+ return composeServiceNames();
188
+ }
189
+
190
+ function skillNames(session) {
191
+ return listSkills(session).map((skill) => skill.name).sort();
192
+ }
193
+
194
+ function completionValuesFor(parts, inputBuffer, session) {
195
+ const command = parts[0];
196
+ const completingNewToken = inputBuffer.endsWith(' ');
197
+ const tokenIndex = completingNewToken ? parts.length : parts.length - 1;
198
+ const previousToken = completingNewToken ? parts.at(-1) : parts.at(-2);
199
+
200
+ if (tokenIndex === 0) return slashCompletions(session);
201
+ if (command === '/new' && tokenIndex === 1) return [];
202
+ if (command === '/use' && tokenIndex === 1) return workspaceNames();
203
+ if (command === '/config' && tokenIndex === 1) return ['edit', 'list', 'status', 'use'];
204
+ if (command === '/config' && (previousToken === 'use' || previousToken === 'edit')) return wikircProfileNames(session);
205
+ if (command === '/mcp' && tokenIndex === 1) return ['call', 'endpoints', 'status', 'tools'];
206
+ if (command === '/mcp' && previousToken === 'tools') return mcpNames(session);
207
+ if (command === '/mcp' && previousToken === 'call') return mcpNames(session);
208
+ if (command === '/mcp' && parts[1] === 'call' && tokenIndex === 3) return mcpToolNames(session, parts[2]);
209
+ if (command === '/upload' && tokenIndex === 1) return ['convert'];
210
+ if (command === '/upload' && parts[1] === 'convert' && tokenIndex === 2) return ['pending'];
211
+ if (command === '/uploads' && tokenIndex === 1) return ['clean', 'list'];
212
+ if (command === '/uploads' && previousToken === 'clean') return ['--older-than'];
213
+ if (command === '/queue' && tokenIndex === 1) return ['cancel', 'clear'];
214
+ if (command === '/queue' && previousToken === 'cancel') {
215
+ return (session.jobQueue ?? [])
216
+ .filter((item) => ['waiting', 'starting', 'running'].includes(item.status))
217
+ .map((item) => item.id);
218
+ }
219
+ if (command === '/workspace' && tokenIndex === 1) return ['init'];
220
+ if (command === '/wiki' && tokenIndex === 1) return ['run'];
221
+ if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
222
+ if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
223
+ if ((command === '/start' || command === '/stop' || command === '/logs') && tokenIndex === 1) return serviceNames();
224
+ return [];
225
+ }
226
+
227
+ function toConversationHistory(replMessages, maxExchanges = 6) {
228
+ return replMessages
229
+ .filter((m) => m.role === 'user' || isDonnaRole(m.role))
230
+ .slice(-(maxExchanges * 2))
231
+ .map((m) => ({ role: isDonnaRole(m.role) ? 'assistant' : 'user', content: m.content }));
232
+ }
233
+
234
+ function isDonnaRole(role) {
235
+ return role === 'donna' || role === LEGACY_DONNA_ROLE;
236
+ }
237
+
238
+ function buildDirectChatSystemPrompt(session) {
239
+ const workspace = session.workspace ?? 'no workspace selected';
240
+ const wikirc = session.wikirc?.profile ?? 'no profile loaded';
241
+ const language = session.language ?? 'en-US';
242
+ return [
243
+ 'You are Donna, the llm-wiki-manager chat assistant.',
244
+ 'Answer directly and concisely. Do not claim to have called tools or changed files.',
245
+ 'If the user asks for an action that needs workspace commands, MCP tools, services, files, or mutations, say to ask as an agent action instead of pretending to execute it.',
246
+ `Reply language: ${language}.`,
247
+ `Current workspace: ${workspace}.`,
248
+ `Current wikirc profile: ${wikirc}.`,
249
+ ].join('\n');
250
+ }
251
+
252
+ function commonPrefix(values) {
253
+ if (values.length === 0) return '';
254
+ let prefix = values[0];
255
+ for (const value of values.slice(1)) {
256
+ while (!value.startsWith(prefix) && prefix) {
257
+ prefix = prefix.slice(0, -1);
258
+ }
259
+ }
260
+ return prefix;
261
+ }
262
+
263
+ function completeSlashCommand(inputBuffer, session) {
264
+ if (!inputBuffer.startsWith('/')) return null;
265
+ const parts = inputBuffer.trimEnd().split(/\s+/).filter(Boolean);
266
+ const values = completionValuesFor(parts, inputBuffer, session);
267
+ if (values.length === 0) return null;
268
+ return tokenCompletions(inputBuffer, values);
269
+ }
270
+
271
+ export function completionContext(inputBuffer, session) {
272
+ if (!inputBuffer.startsWith('/')) return null;
273
+ const parts = inputBuffer.trimEnd().split(/\s+/).filter(Boolean);
274
+ const values = completionValuesFor(parts, inputBuffer, session);
275
+ if (values.length === 0) return null;
276
+ const lastSpace = inputBuffer.lastIndexOf(' ');
277
+ const prefix = inputBuffer.endsWith(' ') ? '' : inputBuffer.slice(lastSpace + 1);
278
+ const matches = values.filter((value) => value.startsWith(prefix));
279
+ if (matches.length === 0) return null;
280
+ return { parts, matches, prefix };
281
+ }
282
+
283
+ export function completionDescription(value, parts) {
284
+ if (value.startsWith('/')) return COMMAND_COMPLETION_DESCRIPTIONS[value] ?? 'Run this shell command.';
285
+ const command = parts[0];
286
+ const subcommand = SUBCOMMAND_COMPLETION_DESCRIPTIONS[`${command}:${value}`];
287
+ if (subcommand) return subcommand;
288
+ if (command === '/use') return 'Load this workspace.';
289
+ if (command === '/start') return serviceDescription(value) ?? 'Start this Docker Compose service.';
290
+ if (command === '/stop') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
291
+ if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
292
+ if (command === '/mcp') return parts[1] === 'call' ? 'Use this MCP server.' : 'Filter tools to this MCP server.';
293
+ if (command === '/skills') {
294
+ if (parts.at(-1) === 'edit') return 'Edit this skill.';
295
+ if (parts.at(-1) === 'run') return 'Run this skill guide.';
296
+ if (parts.at(-1) === 'show') return 'Show this skill.';
297
+ return 'Choose a skills action.';
298
+ }
299
+ if (command === '/config') {
300
+ if (parts.at(-1) === 'use') return 'Load this wikirc profile.';
301
+ if (parts.at(-1) === 'edit') return 'Edit this wikirc profile.';
302
+ return 'Choose a config action.';
303
+ }
304
+ return 'Complete this argument.';
305
+ }
306
+
307
+ function completionLines(inputBuffer, session, columns) {
308
+ const context = completionContext(inputBuffer, session);
309
+ if (!context) return [];
310
+ if (context.matches.length === 0) {
311
+ return [`${styles.dim}No completions for ${context.prefix || 'current input'}.${styles.reset}`];
312
+ }
313
+
314
+ const items = context.matches.slice(0, 10).map((value) => ({
315
+ value,
316
+ description: completionDescription(value, context.parts),
317
+ }));
318
+ const oneColumn = columns < 96 || items.length <= 3;
319
+ const itemWidth = oneColumn ? columns : Math.floor((columns - 3) / 2);
320
+ const renderItem = (item) => {
321
+ const valueWidth = Math.min(18, Math.max(10, Math.floor(itemWidth * 0.34)));
322
+ const value = `${styles.cyan}${truncateAnsi(item.value, valueWidth)}${styles.reset}`;
323
+ const descWidth = Math.max(8, itemWidth - valueWidth - 2);
324
+ const desc = `${styles.dim}${truncateAnsi(item.description, descWidth)}${styles.reset}`;
325
+ return `${padVisible(value, valueWidth)} ${desc}`;
326
+ };
327
+
328
+ const rendered = items.map(renderItem);
329
+ if (oneColumn) return rendered;
330
+
331
+ const lines = [];
332
+ for (let index = 0; index < rendered.length; index += 2) {
333
+ const left = rendered[index];
334
+ const right = rendered[index + 1] ?? '';
335
+ lines.push(`${left}${' '.repeat(Math.max(3, itemWidth - stripAnsi(left).length + 3))}${right}`);
336
+ }
337
+ return lines;
338
+ }
339
+
340
+ function stripAnsi(value) {
341
+ return value.replace(/\u001b\[[0-9;]*m/g, '');
342
+ }
343
+
344
+ function wrapCellText(text, width) {
345
+ const lines = [];
346
+ for (const para of text.split('\n')) {
347
+ if (!para) { lines.push(''); continue; }
348
+ let line = '';
349
+ for (const word of para.split(' ')) {
350
+ if (!word) continue;
351
+ if (line.length + (line ? 1 : 0) + word.length <= width) {
352
+ line += (line ? ' ' : '') + word;
353
+ } else if (word.length >= width) {
354
+ if (line) { lines.push(line); line = ''; }
355
+ for (let i = 0; i < word.length; i += width) {
356
+ const chunk = word.slice(i, i + width);
357
+ if (i + width >= word.length) line = chunk;
358
+ else lines.push(chunk);
359
+ }
360
+ } else {
361
+ if (line) lines.push(line);
362
+ line = word;
363
+ }
364
+ }
365
+ lines.push(line);
366
+ }
367
+ return lines.length ? lines : [''];
368
+ }
369
+
370
+ function renderTerminalTable(token, parseCell, colWidth) {
371
+ const numCols = token.header.length || 1;
372
+ const border = (l, m, r) =>
373
+ `${styles.gray}${l}${Array.from({ length: numCols }, () => '─'.repeat(colWidth + 2)).join(m)}${r}${styles.reset}`;
374
+ const sep = `${styles.gray}│${styles.reset}`;
375
+ const drawRow = (cells, bold) => {
376
+ const wrapped = cells.map((c) => wrapCellText(parseCell(c), colWidth));
377
+ const height = Math.max(...wrapped.map((c) => c.length));
378
+ return Array.from({ length: height }, (_, i) =>
379
+ `${sep}${wrapped.map((c) => {
380
+ const txt = (c[i] ?? '').padEnd(colWidth, ' ');
381
+ return bold ? ` ${txt} ` : ` ${txt} `;
382
+ }).join(sep)}${sep}`,
383
+ );
384
+ };
385
+ const rows = [border('┌', '┬', '┐'), ...drawRow(token.header, true), border('├', '┼', '┤')];
386
+ for (const row of token.rows) rows.push(...drawRow(row, false));
387
+ rows.push(border('└', '┴', '┘'));
388
+ return rows.join('\n') + '\n\n';
389
+ }
390
+
391
+ function stripHtml(value) {
392
+ return String(value)
393
+ .replace(/<!--[\s\S]*?-->/g, '')
394
+ .replace(/<\/?(script|style|iframe|object|embed|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, '')
395
+ .replace(/<\/?[^>]+>/g, '');
396
+ }
397
+
398
+ function stripDsmlArtifacts(value) {
399
+ return String(value ?? '')
400
+ .replace(/<\s*[||]{2}\s*DSML\s*[||]{2}[^>\r\n]*(?:>|$)/gi, '')
401
+ .replace(/^[^\S\r\n]*.*[||]{2}\s*DSML\s*[||]{2}.*(?:\r?\n|$)/gim, '')
402
+ .replace(/\n{3,}/g, '\n\n');
403
+ }
404
+
405
+ function truncateAnsi(value, maxWidth) {
406
+ let visible = 0;
407
+ let out = '';
408
+ for (let index = 0; index < value.length; index += 1) {
409
+ if (value[index] === '\u001b') {
410
+ const match = value.slice(index).match(/^\u001b\[[0-9;]*m/);
411
+ if (match) {
412
+ out += match[0];
413
+ index += match[0].length - 1;
414
+ continue;
415
+ }
416
+ }
417
+ if (visible >= maxWidth) break;
418
+ out += value[index];
419
+ visible += 1;
420
+ }
421
+ return out;
422
+ }
423
+
424
+ const styles = {
425
+ reset: '\u001b[0m',
426
+ bold: '\u001b[1m',
427
+ magenta: '\u001b[35m',
428
+ dim: '\u001b[2m',
429
+ cyan: '\u001b[36m',
430
+ green: '\u001b[32m',
431
+ yellow: '\u001b[33m',
432
+ red: '\u001b[31m',
433
+ orange: '\u001b[38;5;208m',
434
+ gray: '\u001b[90m',
435
+ blue: '\u001b[34m',
436
+ white: '\u001b[37m',
437
+ inverse: '\u001b[7m',
438
+ };
439
+
440
+ export function colorizeStatus(text) {
441
+ return marked(stripHtml(text)).trimEnd()
442
+ .split('\n')
443
+ .map((line) => {
444
+ if (line.startsWith('●') && /\bconnected\b/.test(line)) return `${styles.green}●${styles.reset}${line.slice(1)}`;
445
+ if (line.startsWith('◐') && /\bconfigured\b/.test(line)) return `${styles.orange}◐${styles.reset}${line.slice(1)}`;
446
+ if (line.startsWith('○') && /\bmissing\b/.test(line)) return `${styles.red}○${styles.reset}${line.slice(1)}`;
447
+ if (line.startsWith('○')) return `${styles.gray}○${styles.reset}${line.slice(1)}`;
448
+ return line;
449
+ })
450
+ .join('\n')
451
+ .replace(/\b(configured|ready|enabled|reinitialized|loaded)\b/g, `${styles.green}$1${styles.reset}`)
452
+ .replace(/\b(missing|limited|disabled|not loaded|not found)\b/g, `${styles.yellow}$1${styles.reset}`);
453
+ }
454
+
455
+ function visibleLength(value) {
456
+ return stripAnsi(String(value)).length;
457
+ }
458
+
459
+ function padVisible(value, width) {
460
+ const text = String(value);
461
+ return `${text}${' '.repeat(Math.max(0, width - visibleLength(text)))}`;
462
+ }
463
+
464
+ function splitTabularBlocks(lines) {
465
+ const blocks = [];
466
+ let current = [];
467
+ let tabular = null;
468
+ for (const line of lines) {
469
+ const isTabular = line.includes('\t');
470
+ if (tabular === null || tabular === isTabular) {
471
+ current.push(line);
472
+ tabular = isTabular;
473
+ continue;
474
+ }
475
+ blocks.push({ tabular, lines: current });
476
+ current = [line];
477
+ tabular = isTabular;
478
+ }
479
+ if (current.length > 0) blocks.push({ tabular, lines: current });
480
+ return blocks;
481
+ }
482
+
483
+ function renderPlainTable(lines, maxWidth) {
484
+ const rows = lines.map((line) => line.split('\t').map((cell) => cell.trim()));
485
+ const columnCount = Math.max(...rows.map((row) => row.length), 1);
486
+ const normalized = rows.map((row) => Array.from({ length: columnCount }, (_, i) => row[i] ?? ''));
487
+ const available = Math.max(24, maxWidth - columnCount - 1);
488
+ const natural = Array.from({ length: columnCount }, (_, i) =>
489
+ Math.max(3, ...normalized.map((row) => visibleLength(row[i]))),
490
+ );
491
+ const totalNatural = natural.reduce((sum, width) => sum + width, 0) + columnCount * 2;
492
+ const widths = totalNatural <= available
493
+ ? natural
494
+ : natural.map((width) => Math.max(6, Math.floor((width / totalNatural) * available)));
495
+ const border = (left, middle, right) =>
496
+ `${styles.gray}${left}${widths.map((width) => '─'.repeat(width + 2)).join(middle)}${right}${styles.reset}`;
497
+ const separator = `${styles.gray}│${styles.reset}`;
498
+ const formatCell = (cell, index) => {
499
+ const clean = cell.length > widths[index] ? `${cell.slice(0, Math.max(1, widths[index] - 1))}…` : cell;
500
+ return ` ${padVisible(clean, widths[index])} `;
501
+ };
502
+
503
+ return [
504
+ border('┌', '┬', '┐'),
505
+ ...normalized.map((row) => `${separator}${row.map(formatCell).join(separator)}${separator}`),
506
+ border('└', '┴', '┘'),
507
+ ];
508
+ }
509
+
510
+ function colorizeCommandLine(line, previousLine = '', nextLine = '') {
511
+ if (line.startsWith('●') && /\bconnected\b/.test(line)) return `${styles.green}●${styles.reset}${line.slice(1)}`;
512
+ if (line.startsWith('◐') && /\bconfigured\b/.test(line)) return `${styles.orange}◐${styles.reset}${line.slice(1)}`;
513
+ if (line.startsWith('○') && /\bmissing\b/.test(line)) return `${styles.red}○${styles.reset}${line.slice(1)}`;
514
+ if (line.startsWith('○')) return `${styles.gray}○${styles.reset}${line.slice(1)}`;
515
+
516
+ const heading = line.match(/^#{1,3}\s+(.+)$/);
517
+ if (heading) return `${styles.bold}${styles.cyan}${heading[1]}${styles.reset}`;
518
+
519
+ if (
520
+ line.trim()
521
+ && !line.startsWith(' ')
522
+ && !line.startsWith('- ')
523
+ && !line.includes(':')
524
+ && !line.includes('=')
525
+ && (nextLine.includes(':') || nextLine.includes('=') || previousLine === '')
526
+ ) {
527
+ return `${styles.bold}${styles.cyan}${line}${styles.reset}`;
528
+ }
529
+
530
+ const keyValue = line.match(/^([A-Za-z][A-Za-z0-9 _./-]{0,34}):\s*(.*)$/);
531
+ if (keyValue) return `${styles.dim}${keyValue[1]}:${styles.reset} ${keyValue[2]}`;
532
+
533
+ const equalsValue = line.match(/^([A-Za-z][A-Za-z0-9 _./-]{0,34})=(.*)$/);
534
+ if (equalsValue) return `${styles.dim}${equalsValue[1]}=${styles.reset}${equalsValue[2]}`;
535
+
536
+ const listItem = line.match(/^(-)\s+(.+)$/);
537
+ if (listItem) return `${styles.gray}-${styles.reset} ${listItem[2]}`;
538
+
539
+ return line;
540
+ }
541
+
542
+ function colorizeCommand(text, maxWidth = output.columns || 100) {
543
+ const lines = String(text)
544
+ .split('\n')
545
+ .map((line) => line.replace(/\s+$/g, ''));
546
+ const out = [];
547
+ for (const block of splitTabularBlocks(lines)) {
548
+ if (block.tabular) {
549
+ out.push(...renderPlainTable(block.lines, maxWidth));
550
+ continue;
551
+ }
552
+ block.lines.forEach((line, index) => {
553
+ out.push(colorizeCommandLine(line, block.lines[index - 1] ?? '', block.lines[index + 1] ?? ''));
554
+ });
555
+ }
556
+ return out.join('\n');
557
+ }
558
+
559
+ function formatMcpStatusForPanel(mcpStatus) {
560
+ const entries = Object.entries(mcpStatus ?? {}).filter(([, value]) => value.status === 'connected');
561
+ if (entries.length === 0) return [];
562
+ return entries.map(([name, value]) => {
563
+ const detail = [value.status, value.detail].filter(Boolean).join(' ');
564
+ return `${styles.green}●${styles.reset} ${name}${detail ? ` ${detail}` : ''}`;
565
+ });
566
+ }
567
+
568
+ function splitAtVisibleWidth(str, width) {
569
+ let visible = 0;
570
+ let index = 0;
571
+ while (index < str.length && visible < width) {
572
+ if (str[index] === '\u001b') {
573
+ const m = str.slice(index).match(/^\u001b\[[0-9;]*m/);
574
+ if (m) { index += m[0].length; continue; }
575
+ }
576
+ visible += 1;
577
+ index += 1;
578
+ }
579
+ return [str.slice(0, index), str.slice(index)];
580
+ }
581
+
582
+ function wrapLine(line, width) {
583
+ const cleanWidth = Math.max(10, width);
584
+ const out = [];
585
+ let rest = line;
586
+ while (stripAnsi(rest).length > cleanWidth) {
587
+ const [head, tail] = splitAtVisibleWidth(rest, cleanWidth);
588
+ out.push(head);
589
+ rest = tail;
590
+ }
591
+ out.push(rest);
592
+ return out;
593
+ }
594
+
595
+ function wrapText(text, width) {
596
+ return String(text)
597
+ .split('\n')
598
+ .flatMap((line) => wrapLine(line, width));
599
+ }
600
+
601
+ function dotBanner(columns) {
602
+ const compact = ['> donna'];
603
+ const full = [
604
+ ' ██╗ ██████╗ ██████╗ ████████╗',
605
+ ' ╚██╗ ██╔══██╗██╔═══██╗╚══██╔══╝',
606
+ ' ╚██╗ ██║ ██║██║ ██║ ██║ ',
607
+ ' ██╔╝ ██║ ██║██║ ██║ ██║ ',
608
+ ' ██╔╝ ██████╔╝╚██████╔╝ ██║ ',
609
+ ' ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ',
610
+ ];
611
+ const lines = columns >= 30 ? full : compact;
612
+ return lines.map((line) => line.slice(0, columns));
613
+ }
614
+
615
+ function renderBannerWithMcpPanel(columns, session) {
616
+ const banner = dotBanner(columns);
617
+ const activeMcpLines = session.workspace ? formatMcpStatusForPanel(session.mcp) : [];
618
+ if (banner.length === 1 || columns < 72 || activeMcpLines.length === 0) {
619
+ return banner.map((line) => `${styles.bold}${styles.white}${line}${styles.reset}`);
620
+ }
621
+
622
+ const panelWidth = Math.min(48, Math.max(34, Math.floor(columns * 0.44)));
623
+ const bannerWidth = columns - panelWidth - 3;
624
+ const columnGap = 2;
625
+ const columnWidth = Math.max(10, Math.floor((panelWidth - columnGap) / 2));
626
+ const maxPanelRows = Math.max(0, banner.length - 2);
627
+ const maxVisible = maxPanelRows * 2;
628
+ const needsSummary = activeMcpLines.length > maxVisible;
629
+ const visibleCount = Math.max(0, maxVisible - (needsSummary ? 1 : 0));
630
+ const visible = activeMcpLines.slice(0, visibleCount);
631
+ const hidden = Math.max(0, activeMcpLines.length - visible.length);
632
+ const mcpRows = [];
633
+ for (let index = 0; index < Math.min(maxPanelRows, Math.ceil(visible.length / 2)); index += 1) {
634
+ const left = truncateAnsi(visible[index * 2] ?? '', columnWidth);
635
+ const right = truncateAnsi(visible[index * 2 + 1] ?? '', columnWidth);
636
+ mcpRows.push(`${padVisible(left, columnWidth)}${' '.repeat(columnGap)}${right}`);
637
+ }
638
+ if (hidden > 0) {
639
+ const summary = `${styles.dim}+${hidden} MCP more${styles.reset}`;
640
+ if (mcpRows.length < maxPanelRows) {
641
+ mcpRows.push(summary);
642
+ } else if (mcpRows.length > 0) {
643
+ mcpRows[mcpRows.length - 1] = summary;
644
+ }
645
+ }
646
+ const mcpLines = ['', 'MCP', ...mcpRows];
647
+
648
+ return banner.map((line, index) => {
649
+ const leftRaw = line.slice(0, bannerWidth).padEnd(bannerWidth, ' ');
650
+ const left = `${styles.bold}${styles.white}${leftRaw}${styles.reset}`;
651
+ const rightRaw = truncateAnsi(mcpLines[index] ?? '', panelWidth);
652
+ const right = `${rightRaw}${' '.repeat(Math.max(0, panelWidth - stripAnsi(rightRaw).length))}`;
653
+ return `${left} ${right}`;
654
+ });
655
+ }
656
+
657
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
658
+
659
+ function parseJsonText(text) {
660
+ try {
661
+ return JSON.parse(text);
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
666
+
667
+ function pathBaseName(value) {
668
+ return String(value ?? '').split('/').filter(Boolean).pop() ?? '';
669
+ }
670
+
671
+ function productionActivityFromPayload(payload) {
672
+ const progress = payload?.progress;
673
+ const job = payload?.job;
674
+ const jobId = payload?.jobId ?? job?.jobId;
675
+ if (!progress && !job && !jobId) return null;
676
+ const status = job?.status ?? payload?.status ?? progress?.status ?? 'running';
677
+ const percent = Number.isFinite(Number(progress?.percent))
678
+ ? `${Math.round(Number(progress.percent))}%`
679
+ : null;
680
+ const sourceCount = Number(progress?.sourceCount);
681
+ const sourceIndex = Number(progress?.sourceIndex);
682
+ const sourceDoneCount = Number(progress?.sourceDoneCount);
683
+ const fileProgress = Number.isFinite(sourceCount) && sourceCount > 0
684
+ ? Number.isFinite(sourceIndex)
685
+ ? `file ${Math.min(sourceCount, sourceIndex + 1)}/${sourceCount}`
686
+ : Number.isFinite(sourceDoneCount)
687
+ ? `files ${Math.min(sourceCount, sourceDoneCount)}/${sourceCount}`
688
+ : null
689
+ : null;
690
+ const batchProgress = progress?.batchCount
691
+ ? `batch ${Number(progress.batchIndex ?? 0) + 1}/${progress.batchCount}`
692
+ : null;
693
+ const progressDetail = batchProgress && /^batch\s+\d+\/\d+/i.test(String(progress?.detail ?? ''))
694
+ ? null
695
+ : progress?.detail;
696
+ const detail = [
697
+ progress?.phase ?? progress?.currentStep ?? job?.type ?? 'production',
698
+ status,
699
+ percent,
700
+ fileProgress,
701
+ batchProgress,
702
+ progress?.source ? pathBaseName(progress.source) : null,
703
+ progress?.template ? pathBaseName(progress.template) : null,
704
+ progress?.deliverable ? pathBaseName(progress.deliverable) : null,
705
+ progressDetail,
706
+ progress?.lastEvent ? `last ${progress.lastEvent}` : null,
707
+ ].filter(Boolean).join(' · ');
708
+ return {
709
+ jobId: jobId ?? null,
710
+ status,
711
+ label: detail ? `Production: ${detail}` : `Production: ${status}`,
712
+ terminal: ['done', 'failed', 'cancelled'].includes(String(status)),
713
+ updatedAt: new Date().toISOString(),
714
+ };
715
+ }
716
+
717
+ function rememberProductionActivity(session, payload) {
718
+ const activity = productionActivityFromPayload(payload);
719
+ if (!activity) return false;
720
+ session.productionActivity = {
721
+ ...(session.productionActivity ?? {}),
722
+ ...activity,
723
+ jobId: activity.jobId ?? session.productionActivity?.jobId ?? null,
724
+ };
725
+ return true;
726
+ }
727
+
728
+ function activityText(session) {
729
+ const activity = sessionActivities(session).find((item) => !item.terminal)
730
+ ?? (session.productionActivity?.label ? session.productionActivity : null);
731
+ if (!activity?.label) return '';
732
+ const color = activity.terminal
733
+ ? (activity.status === 'done' || activity.status === 'success') ? styles.green : styles.red
734
+ : styles.cyan;
735
+ return `${color}${truncateAnsi(activity.label, 72)}${styles.reset}`;
736
+ }
737
+
738
+ function dividerWithActivity(session, columns) {
739
+ const activity = activityText(session);
740
+ if (!activity) return '─'.repeat(columns);
741
+ const plain = stripAnsi(activity);
742
+ const slot = ` ${plain} `;
743
+ const visible = Math.min(columns, slot.length);
744
+ const left = Math.max(0, columns - visible);
745
+ const clipped = truncateAnsi(activity, Math.max(0, columns - left - 2));
746
+ return `${styles.gray}${'─'.repeat(left)}${styles.reset} ${clipped} `;
747
+ }
748
+
749
+ function renderActivityLines(activityLines, columns, rows) {
750
+ return activityLines
751
+ .slice(-rows)
752
+ .map((line) => `${styles.dim}${truncateAnsi(line, Math.max(10, columns - 2))}${styles.reset}`);
753
+ }
754
+
755
+ function isDurableActivityLine(label) {
756
+ return /^[a-z0-9_-]+\.[a-z0-9_-]+:/i.test(String(label).trim());
757
+ }
758
+
759
+ function renderScreen({ packageJson, session, messages, inputBuffer, busy = false, spinnerFrame = 0, scrollOffset = 0, spinnerLabel = 'Thinking…', activityLines = [] }) {
760
+ const columns = output.columns || 100;
761
+ const rows = output.rows || 30;
762
+ const banner = renderBannerWithMcpPanel(columns, session);
763
+ const completions = busy ? [] : completionLines(inputBuffer, session, columns);
764
+ const visibleCompletions = completions.slice(0, COMPLETION_PANEL_ROWS);
765
+ const completionRows = visibleCompletions.length > 0 ? COMPLETION_PANEL_ROWS : 0;
766
+ const activityRows = LOWER_DETAIL_ROWS - completionRows;
767
+ const activity = renderActivityLines(activityLines, columns, activityRows);
768
+ const productionActivityRows = 1;
769
+ const fixedRows = 4 + banner.length + productionActivityRows + 1 + LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS;
770
+ const middleHeight = Math.max(5, rows - fixedRows);
771
+ lastMiddleHeight = middleHeight;
772
+ const prompt = promptFor(session);
773
+ const title = '';
774
+ const context = [
775
+ `wiki-manager ${packageJson.version}`,
776
+ session.workspace ? session.workspace : 'no workspace',
777
+ session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
778
+ session.language ? session.language : 'no language',
779
+ session.llm ? 'llm ready' : 'llm limited',
780
+ ].join(' ');
781
+ const header = `${title}${' '.repeat(Math.max(0, columns - stripAnsi(title).length - context.length))}${context}`;
782
+ const divider = '─'.repeat(columns);
783
+
784
+ const bodyLines = messages.flatMap((message, index) => {
785
+ const label =
786
+ message.role === 'user'
787
+ ? `${styles.cyan}You${styles.reset}`
788
+ : message.role === 'command'
789
+ ? `${styles.gray}Shell${styles.reset}`
790
+ : `${styles.green}donna${styles.reset}`;
791
+ const lines = message.role === 'command'
792
+ ? [
793
+ `${label}:`,
794
+ ...wrapText(colorizeCommand(message.content, columns), columns),
795
+ ]
796
+ : wrapText(
797
+ `${label}: ${isDonnaRole(message.role) ? colorizeStatus(message.content) : message.content}`,
798
+ columns,
799
+ );
800
+ return index === 0 ? lines : ['', ...lines];
801
+ });
802
+ lastBodyLineCount = bodyLines.length;
803
+ const clampedOffset = Math.min(scrollOffset, Math.max(0, bodyLines.length - middleHeight));
804
+ const visibleBody = clampedOffset === 0
805
+ ? bodyLines.slice(-middleHeight)
806
+ : bodyLines.slice(
807
+ Math.max(0, bodyLines.length - middleHeight - clampedOffset),
808
+ bodyLines.length - clampedOffset,
809
+ );
810
+ while (visibleBody.length < middleHeight) visibleBody.unshift('');
811
+
812
+ const linesAbove = Math.max(0, bodyLines.length - middleHeight - clampedOffset);
813
+ const hint = linesAbove > 0 ? ` ↑ ${linesAbove} more — scroll or PgUp ` : '';
814
+ const topDivider = hint
815
+ ? `${styles.gray}${hint}${'─'.repeat(Math.max(0, columns - hint.length))}${styles.reset}`
816
+ : divider;
817
+
818
+ const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
819
+ const inputLine = busy
820
+ ? `${styles.cyan}${spinner}${styles.reset} ${styles.dim}${spinnerLabel}${styles.reset}`
821
+ : `${prompt}${inputBuffer}`;
822
+
823
+ const clippedInputLine = truncateAnsi(inputLine, columns);
824
+ let buf = '\u001b[?25l\u001b[H';
825
+ buf += `${header.slice(0, columns).padEnd(columns, ' ')}\n`;
826
+ buf += `${' '.repeat(columns)}\n`;
827
+ if (banner.length > 0) {
828
+ buf += `${banner.map((line) => line.padEnd(columns, ' ')).join('\n')}\n`;
829
+ }
830
+ buf += `${topDivider}\n`;
831
+ buf += `${visibleBody.map((line) => padVisible(line, columns)).join('\n')}\n`;
832
+ // Keep this line reserved: production jobs publish their progress here.
833
+ buf += `${padVisible(dividerWithActivity(session, columns), columns)}\n`;
834
+ buf += `${padVisible(clippedInputLine, columns)}\n`;
835
+ buf += `${styles.white}${'─'.repeat(columns)}${styles.reset}\n`;
836
+ for (let index = 0; index < activityRows; index += 1) {
837
+ buf += `${padVisible(activity[index] ?? '', columns)}\n`;
838
+ }
839
+ for (let index = 0; index < completionRows; index += 1) {
840
+ buf += `${padVisible(visibleCompletions[index] ?? '', columns)}\n`;
841
+ }
842
+ for (let index = 0; index < BOTTOM_PADDING_ROWS; index += 1) {
843
+ buf += `${' '.repeat(columns)}\n`;
844
+ }
845
+ buf += `\u001b[${LOWER_PANEL_ROWS + BOTTOM_PADDING_ROWS + 1}A`;
846
+ buf += `\u001b[${stripAnsi(clippedInputLine).length + 1}G`;
847
+ buf += '\u001b[?25h';
848
+ output.write(buf);
849
+ }
850
+
851
+ async function runAgentTurn(input, { agent, session, onUpdate, onStep }) {
852
+ const messages = conversationMessages(session);
853
+ const history = toConversationHistory(messages);
854
+ session._onStep = onStep ?? null;
855
+ session.packageJson = session.packageJson ?? {};
856
+ dispatchAgentEvent(session, createAgentEvent('run_started', {
857
+ origin: 'user',
858
+ payload: { input },
859
+ }));
860
+ dispatchAgentEvent(session, createAgentEvent('user_message', {
861
+ origin: 'user',
862
+ payload: { content: input },
863
+ }));
864
+
865
+ // Show the user's input in the conversation (mirrors runDirectChatTurn).
866
+ messages.push({ role: 'user', content: input });
867
+ onUpdate?.();
868
+
869
+ // Create the donna bubble immediately so "Thinking…" is visible during TTFT.
870
+ let donnaMessage = { role: 'donna', content: '' };
871
+ messages.push(donnaMessage);
872
+ onUpdate?.();
873
+
874
+ session._onStream = (delta) => {
875
+ if (!donnaMessage) {
876
+ // Re-create after _onStreamReset removed an empty bubble (tool call on first turn).
877
+ donnaMessage = { role: 'donna', content: '' };
878
+ messages.push(donnaMessage);
879
+ }
880
+ if (delta) {
881
+ donnaMessage.content += delta;
882
+ onUpdate?.();
883
+ }
884
+ };
885
+ session._onStreamReset = () => {
886
+ if (!donnaMessage) return;
887
+ if (donnaMessage.content.trim()) {
888
+ // Intermediate streamed text before tool calls: keep it, add separator.
889
+ donnaMessage.content += '\n\n';
890
+ onUpdate?.();
891
+ } else {
892
+ // Still empty ("Thinking…"): remove it cleanly.
893
+ const index = messages.indexOf(donnaMessage);
894
+ if (index !== -1) messages.splice(index, 1);
895
+ donnaMessage = null;
896
+ onUpdate?.();
897
+ }
898
+ };
899
+
900
+ let agentResult;
901
+ try {
902
+ agentResult = await agent.invoke({ input, session, messages: history });
903
+ } catch (err) {
904
+ if (err.name === 'AbortError') {
905
+ if (donnaMessage) {
906
+ const idx = messages.indexOf(donnaMessage);
907
+ if (idx !== -1) messages.splice(idx, 1);
908
+ }
909
+ return { aborted: true };
910
+ }
911
+ // Non-abort error: surface it in the bubble rather than leaving "Thinking…" stuck.
912
+ if (donnaMessage) {
913
+ const msg = err instanceof Error ? err.message : String(err);
914
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${msg}`);
915
+ onUpdate?.();
916
+ }
917
+ throw err;
918
+ } finally {
919
+ delete session._onStep;
920
+ delete session._onStream;
921
+ delete session._onStreamReset;
922
+ }
923
+
924
+ if (agentResult.streamedInline) {
925
+ if (donnaMessage) {
926
+ donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
927
+ if (!donnaMessage.content.trim()) {
928
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
929
+ }
930
+ } else {
931
+ messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content') });
932
+ }
933
+ onUpdate?.();
934
+ return {};
935
+ }
936
+
937
+ if (agentResult.response != null) {
938
+ const content = stripDsmlArtifacts(agentResult.response);
939
+ if (donnaMessage) {
940
+ donnaMessage.content = content;
941
+ } else {
942
+ messages.push({ role: 'donna', content });
943
+ }
944
+ onUpdate?.();
945
+ return {};
946
+ }
947
+
948
+ if (agentResult.readyToStream && session.llm?.stream) {
949
+ if (!donnaMessage) {
950
+ donnaMessage = { role: 'donna', content: '' };
951
+ messages.push(donnaMessage);
952
+ }
953
+ onUpdate?.();
954
+ const { system, messages: streamMessages = [] } = agentResult.streamContext ?? {};
955
+ try {
956
+ onStep?.('Agent: streaming final answer…');
957
+ for await (const delta of session.llm.stream({
958
+ system: system ?? buildAgentSystemPrompt({ input, session }),
959
+ messages: streamMessages,
960
+ signal: session._abortSignal,
961
+ })) {
962
+ const cleanDelta = stripDsmlArtifacts(delta);
963
+ if (cleanDelta) {
964
+ donnaMessage.content += cleanDelta;
965
+ onUpdate?.();
966
+ }
967
+ }
968
+ donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
969
+ if (!donnaMessage.content.trim()) {
970
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
971
+ onUpdate?.();
972
+ }
973
+ } catch (err) {
974
+ if (err.name === 'AbortError') {
975
+ const idx = messages.indexOf(donnaMessage);
976
+ if (idx !== -1) messages.splice(idx, 1);
977
+ return { aborted: true };
978
+ }
979
+ const message = err instanceof Error ? err.message : String(err);
980
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
981
+ onUpdate?.();
982
+ }
983
+ return {};
984
+ }
985
+
986
+ if (donnaMessage) {
987
+ donnaMessage.content = buildLimitedAgentResponse({ input, session });
988
+ } else {
989
+ messages.push({ role: 'donna', content: buildLimitedAgentResponse({ input, session }) });
990
+ }
991
+ onUpdate?.();
992
+ return {};
993
+ }
994
+
995
+ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
996
+ if (!session.llm?.stream) {
997
+ conversationMessages(session).push({ role: 'command', content: directChatUnavailableText(session) });
998
+ return { exit: false };
999
+ }
1000
+ const messages = conversationMessages(session);
1001
+ const history = toConversationHistory(messages);
1002
+ messages.push({ role: 'user', content: input });
1003
+ onUpdate?.();
1004
+ const donnaMessage = { role: 'donna', content: '' };
1005
+ messages.push(donnaMessage);
1006
+ onUpdate?.();
1007
+ try {
1008
+ onStep?.('Chat: streaming direct answer…');
1009
+ for await (const delta of session.llm.stream({
1010
+ system: buildDirectChatSystemPrompt(session),
1011
+ messages: [...history, { role: 'user', content: input }],
1012
+ signal: session._abortSignal,
1013
+ })) {
1014
+ const cleanDelta = stripDsmlArtifacts(delta);
1015
+ if (cleanDelta) {
1016
+ donnaMessage.content += cleanDelta;
1017
+ onUpdate?.();
1018
+ }
1019
+ }
1020
+ donnaMessage.content = stripDsmlArtifacts(donnaMessage.content).trimEnd();
1021
+ if (!donnaMessage.content.trim()) {
1022
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
1023
+ onUpdate?.();
1024
+ }
1025
+ } catch (err) {
1026
+ if (err.name === 'AbortError') {
1027
+ messages.pop();
1028
+ return { exit: false, aborted: true };
1029
+ }
1030
+ const message = err instanceof Error ? err.message : String(err);
1031
+ donnaMessage.content = buildLimitedAgentResponse({ input, session }, `LLM indisponible: ${message}`);
1032
+ onUpdate?.();
1033
+ }
1034
+ return { exit: false };
1035
+ }
1036
+
1037
+ function directChatUnavailableText(session) {
1038
+ if (!session.workspacePath) {
1039
+ return 'Direct chat unavailable: no workspace loaded. Use /use <workspace>.';
1040
+ }
1041
+ if (!session.wikircConfig) {
1042
+ return 'Direct chat unavailable: no active wikirc profile. Use /config list then /config use <profile>.';
1043
+ }
1044
+ const llm = session.wikircConfig?.llm ?? {};
1045
+ const missing = [
1046
+ !llm.apiKey ? 'llm.apiKey' : null,
1047
+ !llm.model ? 'llm.model' : null,
1048
+ !llm.baseUrl ? 'llm.baseUrl' : null,
1049
+ ].filter(Boolean);
1050
+ if (missing.length > 0) {
1051
+ return [
1052
+ `Direct chat unavailable: missing ${missing.join(', ')} in ${session.wikirc?.fileName ?? 'active wikirc'}.`,
1053
+ 'Use /config list, /config use <profile>, or /config edit <profile>.',
1054
+ ].join('\n');
1055
+ }
1056
+ return 'Direct chat unavailable: no streaming LLM configured.';
1057
+ }
1058
+
1059
+ export async function runLine(line, { agent, packageJson, session, onUpdate, onStep, chatMode = session.chatMode ?? true }) {
1060
+ const trimmed = stripHtml(line).trim();
1061
+ if (!trimmed) return { exit: false };
1062
+
1063
+ if (/^\/chat(?:\s|$)/.test(trimmed) && trimmed.replace(/^\/chat(?:\s+|$)/, '').trim()) {
1064
+ conversationMessages(session).push({ role: 'command', content: 'Usage: /chat\nThen type your message in chat mode.' });
1065
+ return { exit: false };
1066
+ }
1067
+
1068
+ if (trimmed.startsWith('/')) {
1069
+ onStep?.(`Shell: ${trimmed}`);
1070
+ const result = await handleSlashCommand(trimmed, { packageJson, session, onStep });
1071
+ const messages = conversationMessages(session);
1072
+ if (result.output) {
1073
+ const parts = trimmed.split(/\s+/);
1074
+ if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
1075
+ const payload = parseJsonText(result.output);
1076
+ const activity = extractActivity(payload, { server: parts[2], tool: parts[3] });
1077
+ if (activity) {
1078
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
1079
+ origin: 'tool',
1080
+ payload: { activity },
1081
+ }));
1082
+ } else {
1083
+ rememberProductionActivity(session, payload);
1084
+ }
1085
+ }
1086
+ messages.push({ role: 'command', content: result.output });
1087
+ onUpdate?.();
1088
+ }
1089
+ if (result.agentTrigger && agent) {
1090
+ const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep });
1091
+ if (agentResult.aborted) return { exit: false, aborted: true };
1092
+ }
1093
+ return { exit: Boolean(result.exit), setMode: result.setMode };
1094
+ }
1095
+
1096
+ if (chatMode) {
1097
+ return runDirectChatTurn(trimmed, { session, onUpdate, onStep });
1098
+ }
1099
+
1100
+ const agentResult = await runAgentTurn(trimmed, { agent, session, onUpdate, onStep });
1101
+ if (agentResult.aborted) return { exit: false, aborted: true };
1102
+ return { exit: false };
1103
+ }
1104
+
1105
+ async function runPipeShell({ agent, packageJson, session }) {
1106
+ const rl = createInterface({ input, output, prompt: promptFor(session) });
1107
+ console.log(`donna wiki-manager ${packageJson.version} non-interactive`);
1108
+ console.log('─'.repeat(80));
1109
+ console.log('Agent-first shell active. Type /help for commands, /exit to quit.');
1110
+ rl.prompt();
1111
+
1112
+ try {
1113
+ for await (const rawLine of rl) {
1114
+ const beforeMessages = conversationMessages(session);
1115
+ const beforeLength = beforeMessages.length;
1116
+ const result = await runLine(rawLine, { agent, packageJson, session });
1117
+ const afterMessages = conversationMessages(session);
1118
+ const emitted = afterMessages === beforeMessages ? afterMessages.slice(beforeLength) : afterMessages;
1119
+ for (const message of emitted) console.log(message.content);
1120
+ if (result.exit) break;
1121
+ rl.setPrompt(promptFor(session));
1122
+ rl.prompt();
1123
+ }
1124
+ } finally {
1125
+ rl.close();
1126
+ }
1127
+ }
1128
+
1129
+ let lastBodyLineCount = 0;
1130
+ let lastMiddleHeight = 5;
1131
+
1132
+ async function runTuiShell({ agent, packageJson, session }) {
1133
+ const messages = conversationMessages(session);
1134
+ messages.push({
1135
+ role: 'donna',
1136
+ content: [
1137
+ initialLegacyWelcomeMessage(),
1138
+ 'Tip: Ctrl+Y copies the last response.',
1139
+ ].join('\n'),
1140
+ });
1141
+ let inputBuffer = '';
1142
+ const inputHistory = [];
1143
+ let historyIndex = null;
1144
+ let busy = false;
1145
+ let spinnerFrame = 0;
1146
+ let spinnerInterval = null;
1147
+ let spinnerLabel = 'Thinking…';
1148
+ let activityLines = [];
1149
+ let lastCtrlCAt = 0;
1150
+ let ctrlCTimer = null;
1151
+ let currentAbortController = null;
1152
+ let scrollOffset = 0;
1153
+ let mouseScrollEnabled = false;
1154
+ let desiredMouseScrollEnabled = false;
1155
+ let mouseSelectionTimer = null;
1156
+ let done = false;
1157
+ let processing = Promise.resolve();
1158
+ let finish;
1159
+ const finished = new Promise((resolve) => {
1160
+ finish = resolve;
1161
+ });
1162
+
1163
+ input.setRawMode(true);
1164
+ input.resume();
1165
+ output.write('\u001b[?1049h');
1166
+
1167
+ const rerender = () => renderScreen({ packageJson, session, messages: conversationMessages(session), inputBuffer, busy, spinnerFrame, scrollOffset, spinnerLabel, activityLines });
1168
+ busy = true;
1169
+ spinnerLabel = 'Loading wiki-manager shell...';
1170
+ spinnerInterval = setInterval(() => {
1171
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
1172
+ rerender();
1173
+ }, 80);
1174
+ rerender();
1175
+
1176
+ try {
1177
+ const { output: wsOutput } = await handleSlashCommand('/workspaces', { packageJson, session });
1178
+ if (wsOutput) messages.push({ role: 'command', content: wsOutput });
1179
+ } finally {
1180
+ clearInterval(spinnerInterval);
1181
+ spinnerInterval = null;
1182
+ busy = false;
1183
+ spinnerFrame = 0;
1184
+ spinnerLabel = 'Thinking…';
1185
+ rerender();
1186
+ }
1187
+
1188
+ const pollBusy = new Set();
1189
+ const productionPollInterval = setInterval(async () => {
1190
+ const candidates = sessionActivities(session).filter((item) => item.poll && !item.terminal);
1191
+ // Legacy fallback: if no generic activities tracked yet, fall back to productionActivity.
1192
+ if (candidates.length === 0 && session.productionActivity?.jobId && !session.productionActivity.terminal) {
1193
+ candidates.push({
1194
+ key: `production:${session.productionActivity.jobId}`,
1195
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: session.productionActivity.jobId }, intervalMs: 2500 },
1196
+ terminal: false,
1197
+ lastPolledAt: null,
1198
+ });
1199
+ }
1200
+ for (const activity of candidates) {
1201
+ const key = activity.key ?? `${activity.poll.server}:${activity.id ?? 'activity'}`;
1202
+ if (pollBusy.has(key)) continue;
1203
+ const endpoint = session.mcp?.[activity.poll.server];
1204
+ if (!endpoint || endpoint.status !== 'connected') continue;
1205
+ const intervalMs = activity.poll.intervalMs ?? 2500;
1206
+ const lastPolledAt = Date.parse(activity.lastPolledAt ?? '0');
1207
+ if (Date.now() - lastPolledAt < intervalMs) continue;
1208
+ pollBusy.add(key);
1209
+ activity.lastPolledAt = new Date().toISOString();
1210
+ void callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {})
1211
+ .then((result) => {
1212
+ const payload = parseJsonText(formatMcpToolResult(result));
1213
+ const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
1214
+ if (polledActivity) {
1215
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
1216
+ origin: 'poll',
1217
+ payload: { activity: polledActivity },
1218
+ }));
1219
+ }
1220
+ if (!polledActivity && rememberProductionActivity(session, payload)) {
1221
+ syncActivitiesToPlan(session.headlessPlan, sessionActivities(session));
1222
+ }
1223
+ if (polledActivity || session.productionActivity) rerender();
1224
+ })
1225
+ .catch(() => {
1226
+ // Keep the last known status visible; transient MCP errors should not interrupt typing.
1227
+ })
1228
+ .finally(() => {
1229
+ pollBusy.delete(key);
1230
+ });
1231
+ }
1232
+ }, 1000);
1233
+
1234
+ const setMouseScrollEnabled = (enabled) => {
1235
+ mouseScrollEnabled = enabled;
1236
+ output.write(enabled ? '\u001b[?1000h\u001b[?1006h' : '\u001b[?1000l\u001b[?1006l');
1237
+ };
1238
+
1239
+ const suspendMouseForSelection = () => {
1240
+ setMouseScrollEnabled(false);
1241
+ clearTimeout(mouseSelectionTimer);
1242
+ mouseSelectionTimer = setTimeout(() => {
1243
+ if (!done && desiredMouseScrollEnabled) setMouseScrollEnabled(true);
1244
+ }, MOUSE_SELECTION_RESUME_MS);
1245
+ };
1246
+
1247
+ const mouseFilter = new Transform({
1248
+ transform(chunk, _enc, cb) {
1249
+ const str = chunk.toString('utf8');
1250
+ const filtered = str.replace(/\u001b\[<(\d+);\d+;\d+([Mm])/g, (_, btnStr, suffix) => {
1251
+ const btn = parseInt(btnStr, 10);
1252
+ if (btn === 64) {
1253
+ scrollOffset = Math.min(scrollOffset + 3, Math.max(0, lastBodyLineCount - lastMiddleHeight));
1254
+ setImmediate(rerender);
1255
+ } else if (btn === 65) {
1256
+ scrollOffset = Math.max(0, scrollOffset - 3);
1257
+ setImmediate(rerender);
1258
+ } else if (suffix === 'M') {
1259
+ suspendMouseForSelection();
1260
+ } else if (suffix === 'm') {
1261
+ clearTimeout(mouseSelectionTimer);
1262
+ mouseSelectionTimer = null;
1263
+ if (desiredMouseScrollEnabled) setMouseScrollEnabled(true);
1264
+ }
1265
+ return '';
1266
+ });
1267
+ if (filtered.length > 0) cb(null, Buffer.from(filtered, 'utf8'));
1268
+ else cb();
1269
+ },
1270
+ });
1271
+ input.pipe(mouseFilter);
1272
+ emitKeypressEvents(mouseFilter);
1273
+ setMouseScrollEnabled(false);
1274
+
1275
+ const onResize = () => rerender();
1276
+ output.on('resize', onResize);
1277
+ rerender();
1278
+
1279
+ const handleKeypress = async (str, key) => {
1280
+ if (done) return;
1281
+
1282
+ if (key?.ctrl && key.name === 't') {
1283
+ const prevLabel = spinnerLabel;
1284
+ const prevBusy = busy;
1285
+ desiredMouseScrollEnabled = !desiredMouseScrollEnabled;
1286
+ setMouseScrollEnabled(desiredMouseScrollEnabled);
1287
+ spinnerLabel = desiredMouseScrollEnabled
1288
+ ? 'Mouse scroll enabled — click temporarily restores native selection'
1289
+ : 'Mouse scroll disabled — native text selection and copy restored';
1290
+ busy = true;
1291
+ rerender();
1292
+ setTimeout(() => { spinnerLabel = prevLabel; busy = prevBusy; rerender(); }, 1800);
1293
+ return;
1294
+ }
1295
+
1296
+ if (key?.ctrl && key.name === 'y') {
1297
+ const messages = conversationMessages(session);
1298
+ const lastDonna = [...messages].reverse().find((m) => isDonnaRole(m.role));
1299
+ if (lastDonna) {
1300
+ const text = stripAnsi(colorizeStatus(lastDonna.content)).replace(/\[[0-9;]*m/g, '');
1301
+ const clipCmd = process.platform === 'darwin'
1302
+ ? { command: 'pbcopy', args: [] }
1303
+ : { command: 'xclip', args: ['-selection', 'clipboard'] };
1304
+ const { execFileSync } = await import('node:child_process');
1305
+ const prevLabel = spinnerLabel;
1306
+ const prevBusy = busy;
1307
+ try {
1308
+ execFileSync(clipCmd.command, clipCmd.args, { input: text });
1309
+ spinnerLabel = 'Copied to clipboard ✓';
1310
+ } catch {
1311
+ spinnerLabel = 'Copy failed — pbcopy / xclip not available';
1312
+ }
1313
+ busy = true;
1314
+ rerender();
1315
+ setTimeout(() => { spinnerLabel = prevLabel; busy = prevBusy; rerender(); }, 1800);
1316
+ }
1317
+ return;
1318
+ }
1319
+ if (key?.ctrl && key.name === 'c') {
1320
+ if (busy) {
1321
+ currentAbortController?.abort();
1322
+ spinnerLabel = 'Interrupting…';
1323
+ rerender();
1324
+ return;
1325
+ }
1326
+ const now = Date.now();
1327
+ if (now - lastCtrlCAt <= 1500) {
1328
+ done = true;
1329
+ finish();
1330
+ return;
1331
+ }
1332
+ lastCtrlCAt = now;
1333
+ const exitHint = 'Shell: press Ctrl+C again to exit.';
1334
+ activityLines = [...activityLines.filter((line) => line !== exitHint), exitHint].slice(-LOWER_DETAIL_ROWS);
1335
+ rerender();
1336
+ clearTimeout(ctrlCTimer);
1337
+ ctrlCTimer = setTimeout(() => {
1338
+ if (Date.now() - lastCtrlCAt >= 1500) {
1339
+ activityLines = activityLines.filter((line) => line !== exitHint);
1340
+ lastCtrlCAt = 0;
1341
+ rerender();
1342
+ }
1343
+ }, 1600);
1344
+ return;
1345
+ }
1346
+ if (key?.name === 'return' || str === '\n' || str === '\r') {
1347
+ const line = inputBuffer;
1348
+ inputBuffer = '';
1349
+ if (line.trim()) {
1350
+ inputHistory.push(line);
1351
+ }
1352
+ historyIndex = null;
1353
+ scrollOffset = 0;
1354
+ busy = true;
1355
+ activityLines = [];
1356
+ spinnerFrame = 0;
1357
+ spinnerLabel = 'Working…';
1358
+ spinnerInterval = setInterval(() => {
1359
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
1360
+ rerender();
1361
+ }, 80);
1362
+ rerender();
1363
+ const onStep = (label) => {
1364
+ if (!String(label).startsWith('Production:')) {
1365
+ activityLines = [...activityLines, label].slice(-LOWER_DETAIL_ROWS);
1366
+ }
1367
+ rerender();
1368
+ };
1369
+ currentAbortController = new AbortController();
1370
+ session._abortSignal = currentAbortController.signal;
1371
+ let aborted = false;
1372
+ try {
1373
+ const result = await runLine(line, { agent, packageJson, session, onUpdate: rerender, onStep });
1374
+ done = result.exit;
1375
+ aborted = result.aborted ?? false;
1376
+ } catch (err) {
1377
+ if (err.name !== 'AbortError') throw err;
1378
+ aborted = true;
1379
+ } finally {
1380
+ currentAbortController = null;
1381
+ delete session._abortSignal;
1382
+ clearInterval(spinnerInterval);
1383
+ spinnerInterval = null;
1384
+ busy = false;
1385
+ spinnerLabel = 'Thinking…';
1386
+ activityLines = activityLines.filter(isDurableActivityLine).slice(-LOWER_DETAIL_ROWS);
1387
+ scrollOffset = 0;
1388
+ rerender();
1389
+ }
1390
+ if (aborted) {
1391
+ activityLines = ['Interrupted.'];
1392
+ rerender();
1393
+ }
1394
+ if (done) finish();
1395
+ return;
1396
+ }
1397
+ if (key?.name === 'up') {
1398
+ if (inputHistory.length > 0) {
1399
+ historyIndex = historyIndex === null ? inputHistory.length - 1 : Math.max(0, historyIndex - 1);
1400
+ inputBuffer = inputHistory[historyIndex] ?? '';
1401
+ rerender();
1402
+ }
1403
+ return;
1404
+ }
1405
+ if (key?.name === 'down') {
1406
+ if (historyIndex !== null) {
1407
+ historyIndex += 1;
1408
+ if (historyIndex >= inputHistory.length) {
1409
+ historyIndex = null;
1410
+ inputBuffer = '';
1411
+ } else {
1412
+ inputBuffer = inputHistory[historyIndex] ?? '';
1413
+ }
1414
+ rerender();
1415
+ }
1416
+ return;
1417
+ }
1418
+ if (key?.name === 'backspace') {
1419
+ inputBuffer = inputBuffer.slice(0, -1);
1420
+ historyIndex = null;
1421
+ rerender();
1422
+ return;
1423
+ }
1424
+ if (key?.name === 'tab') {
1425
+ const completion = completeSlashCommand(inputBuffer, session);
1426
+ if (completion) {
1427
+ inputBuffer = completion.inputBuffer;
1428
+ historyIndex = null;
1429
+ rerender();
1430
+ }
1431
+ return;
1432
+ }
1433
+ if (key?.name === 'escape') {
1434
+ inputBuffer = '';
1435
+ historyIndex = null;
1436
+ rerender();
1437
+ return;
1438
+ }
1439
+ if (key?.name === 'pageup') {
1440
+ scrollOffset = Math.min(
1441
+ scrollOffset + Math.max(1, lastMiddleHeight - 2),
1442
+ Math.max(0, lastBodyLineCount - lastMiddleHeight),
1443
+ );
1444
+ rerender();
1445
+ return;
1446
+ }
1447
+ if (key?.name === 'pagedown') {
1448
+ scrollOffset = Math.max(0, scrollOffset - Math.max(1, lastMiddleHeight - 2));
1449
+ rerender();
1450
+ return;
1451
+ }
1452
+ if (str && !key?.ctrl && !key?.meta) {
1453
+ inputBuffer += str;
1454
+ historyIndex = null;
1455
+ rerender();
1456
+ }
1457
+ };
1458
+
1459
+ const onKeypress = (str, key) => {
1460
+ processing = processing.then(() => handleKeypress(str, key));
1461
+ };
1462
+
1463
+ mouseFilter.on('keypress', onKeypress);
1464
+
1465
+ try {
1466
+ await finished;
1467
+ await processing;
1468
+ } finally {
1469
+ mouseFilter.off('keypress', onKeypress);
1470
+ clearInterval(productionPollInterval);
1471
+ clearTimeout(ctrlCTimer);
1472
+ clearTimeout(mouseSelectionTimer);
1473
+ output.off('resize', onResize);
1474
+ setMouseScrollEnabled(false);
1475
+ input.unpipe(mouseFilter);
1476
+ mouseFilter.destroy();
1477
+ input.setRawMode(false);
1478
+ input.pause();
1479
+ output.write('\u001b[?25h\u001b[H\u001b[2J\u001b[?1049l');
1480
+ }
1481
+ }
1482
+
1483
+ export async function runShell({ agent, packageJson }) {
1484
+ const session = createSession();
1485
+ if (!input.isTTY || !output.isTTY) {
1486
+ await runPipeShell({ agent, packageJson, session });
1487
+ return;
1488
+ }
1489
+ throw new Error('Interactive TUI requires Bun/OpenTUI. Run: bun ./bin/wiki-manager.js');
1490
+ }