@dotdrelle/wiki-manager 0.15.27 → 0.15.29

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.
@@ -77,8 +77,6 @@ type RenderedLine = {
77
77
  statusLeft?: string;
78
78
  statusRight?: string;
79
79
  copyContent?: string;
80
- redoContent?: string;
81
- redoIndex?: number;
82
80
  };
83
81
  const STATUS_COLUMN_GAP = 2;
84
82
  type HelpCard = { title: string; text: string; example: string };
@@ -142,14 +140,11 @@ function WelcomeHelpPanels(props: { width: number }) {
142
140
  }
143
141
 
144
142
  const COPY_BTN = ' [ copy ]';
145
- // Only user messages carry it: redo re-asks *this* question, so the action has
146
- // to sit on the message it acts upon.
147
- const REDO_BTN = ' [ redo ]';
148
143
 
149
- function messageHeaderSegments(role: string, columns: number, withRedo = false): Segment[] {
144
+ function messageHeaderSegments(role: string, columns: number): Segment[] {
150
145
  const label = `[${roleLabel(role)}]`;
151
146
  const left = '── ';
152
- const actionsLength = COPY_BTN.length + (withRedo ? REDO_BTN.length : 0);
147
+ const actionsLength = COPY_BTN.length;
153
148
  const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
154
149
  return [
155
150
  { text: left, color: '#4B5563' },
@@ -370,6 +365,13 @@ function statusValueColor(value: string) {
370
365
  function statusSegments(value: string): Segment[] {
371
366
  const text = value || ' ';
372
367
  const trimmed = text.trim();
368
+ const linkedValue = text.match(/^(\s*[A-Za-z][A-Za-z0-9 _./-]{0,34}:\s*)\[([^\]]+)\]\((https?:\/\/[^)]+)\)\s*$/);
369
+ if (linkedValue) {
370
+ return [
371
+ { text: linkedValue[1], color: '#8BD5CA' },
372
+ { text: linkedValue[2], color: '#5DADE2', url: linkedValue[3] },
373
+ ];
374
+ }
373
375
  if (trimmed && !text.startsWith(' ') && !trimmed.includes(':') && !/^[●◐○-]/.test(trimmed)) {
374
376
  return [{ text, color: '#FBBF24' }];
375
377
  }
@@ -416,13 +418,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
416
418
  const raw = String(message.content || '');
417
419
  const previous = index > 0 ? messages[index - 1] : null;
418
420
  const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
419
- const canRedo = message.role === 'user' && raw.trim().length > 0;
420
421
  const headerLines: RenderedLine[] = showHeader
421
422
  ? [
422
423
  {
423
- segments: messageHeaderSegments(message.role, columns, canRedo),
424
+ segments: messageHeaderSegments(message.role, columns),
424
425
  copyContent: raw,
425
- ...(canRedo ? { redoContent: raw, redoIndex: index } : {}),
426
426
  },
427
427
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
428
428
  ]
@@ -433,6 +433,11 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
433
433
  ...headerLines,
434
434
  ...raw.split('\n').map((line) => {
435
435
  const { left, right } = statusColumns(line || ' ');
436
+ // A row with nothing on either side is vertical padding, not data.
437
+ // Rendering it as a status row drew an empty blue-bordered box under
438
+ // the last real line — the leftover visible at the bottom right of
439
+ // /status. Emit a plain spacer so the boxes stop with the content.
440
+ if (!left.trim() && !right.trim()) return { segments: [{ text: ' ', color: '#D6DEE8' }] };
436
441
  return { status: true, statusLeft: left, statusRight: right, segments: [] };
437
442
  }),
438
443
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
@@ -472,15 +477,18 @@ export function ConversationView(props: {
472
477
  onScroll: (delta: number) => void;
473
478
  spinnerFrame: string;
474
479
  onCopy?: (content: string) => void;
475
- onRedo?: (index: number, content: string) => void;
476
480
  onOpenLink?: (url: string) => void;
477
481
  }) {
478
482
  const renderer = useRenderer();
483
+ const [hoveredUrl, setHoveredUrl] = createSignal<string | null>(null);
479
484
  // Terminals have no CSS cursor, but OpenTUI can ask the emulator to swap the
480
485
  // mouse pointer shape. Without it nothing on screen distinguishes a clickable
481
486
  // link from ordinary blue text, so operators never discovered links were
482
487
  // clickable at all.
483
- const hoverLink = (url: string | null) => renderer?.setMousePointer?.(url ? 'pointer' : 'default');
488
+ const hoverLink = (url: string | null) => {
489
+ setHoveredUrl(url);
490
+ renderer?.setMousePointer?.(url ? 'pointer' : 'default');
491
+ };
484
492
  const allLines = createMemo(() => conversationLines(props.messages, props.columns));
485
493
  const visibleLines = () => {
486
494
  const lines = allLines();
@@ -516,7 +524,9 @@ export function ConversationView(props: {
516
524
  event.stopPropagation?.();
517
525
  }}
518
526
  >
519
- <text height={1} fg="#7F8C8D">{scrollHint()}</text>
527
+ <text height={1} fg={hoveredUrl() ? '#5DADE2' : '#7F8C8D'}>
528
+ {hoveredUrl() ? `↗ ${hoveredUrl()}` : scrollHint()}
529
+ </text>
520
530
  <For each={visibleLines()}>
521
531
  {(line) => (
522
532
  line.copyContent !== undefined ? (
@@ -525,13 +535,6 @@ export function ConversationView(props: {
525
535
  {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
526
536
  </For>
527
537
  <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
528
- <Show when={line.redoContent !== undefined}>
529
- <text
530
- fg="#FBBF24"
531
- content={REDO_BTN}
532
- onMouseUp={() => props.onRedo?.(line.redoIndex!, line.redoContent!)}
533
- />
534
- </Show>
535
538
  </box>
536
539
  ) : line.role === 'user' && line.userBubbleWidth ? (
537
540
  <box height={1} flexDirection="row" overflow="hidden">
@@ -562,7 +565,19 @@ export function ConversationView(props: {
562
565
  overflow="hidden"
563
566
  >
564
567
  <For each={statusSegments(line.statusLeft ?? '')}>
565
- {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
568
+ {(seg) => (
569
+ <text
570
+ width={seg.width}
571
+ fg={seg.color}
572
+ bg="#111318"
573
+ onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
574
+ onMouseOut={() => hoverLink(null)}
575
+ onMouseUp={() => {
576
+ const url = seg.url ? normalizeExternalUrl(seg.url) : null;
577
+ if (url) props.onOpenLink?.(url);
578
+ }}
579
+ >{seg.text}</text>
580
+ )}
566
581
  </For>
567
582
  </box>
568
583
  {line.statusRight ? (
@@ -578,7 +593,19 @@ export function ConversationView(props: {
578
593
  overflow="hidden"
579
594
  >
580
595
  <For each={statusSegments(line.statusRight)}>
581
- {(seg) => <text width={seg.width} fg={seg.color} bg="#111318">{seg.text}</text>}
596
+ {(seg) => (
597
+ <text
598
+ width={seg.width}
599
+ fg={seg.color}
600
+ bg="#111318"
601
+ onMouseOver={() => hoverLink(seg.url ? normalizeExternalUrl(seg.url) : null)}
602
+ onMouseOut={() => hoverLink(null)}
603
+ onMouseUp={() => {
604
+ const url = seg.url ? normalizeExternalUrl(seg.url) : null;
605
+ if (url) props.onOpenLink?.(url);
606
+ }}
607
+ >{seg.text}</text>
608
+ )}
582
609
  </For>
583
610
  </box>
584
611
  ) : null}
@@ -774,7 +801,6 @@ export function LeftPane(props: {
774
801
  spinnerFrame: string;
775
802
  onInputHeightChange: (height: number) => void;
776
803
  onCopy?: (content: string) => void;
777
- onRedo?: (index: number, content: string) => void;
778
804
  onOpenLink?: (url: string) => void;
779
805
  }) {
780
806
  const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
@@ -805,7 +831,6 @@ export function LeftPane(props: {
805
831
  onScroll={props.scrollConversation}
806
832
  spinnerFrame={props.spinnerFrame}
807
833
  onCopy={props.onCopy}
808
- onRedo={props.onRedo}
809
834
  onOpenLink={props.onOpenLink}
810
835
  />
811
836
  )}
package/src/shell/repl.js CHANGED
@@ -8,7 +8,7 @@ import { stdin as input, stdout as output } from 'node:process';
8
8
  import { marked } from 'marked';
9
9
  import { markedTerminal } from 'marked-terminal';
10
10
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
11
- import { handleSlashCommand } from '../commands/slash.js';
11
+ import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
12
12
  import { serviceDescription, serviceNames as composeServiceNames } from '../core/compose.js';
13
13
  import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
14
14
  import { syncActivitiesToPlan } from '../core/plan.js';
@@ -74,7 +74,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
74
74
  '/config': 'Inspect or switch .wikirc.yaml profiles.',
75
75
  '/status': 'Show the current workspace and session state.',
76
76
  '/services': 'List workspace Docker Compose services.',
77
- '/start': 'Start one service or the workspace service set.',
77
+ '/start': 'Start everything (all), the agents only, or one service.',
78
78
  '/stop': 'Stop one service or the workspace service set.',
79
79
  '/logs': 'Show recent logs for a service.',
80
80
  '/mcp': 'Inspect or call workspace MCP servers.',
@@ -301,7 +301,8 @@ function completionValuesFor(parts, inputBuffer, session) {
301
301
  if (command === '/wiki' && tokenIndex === 1) return ['run'];
302
302
  if (command === '/skills' && tokenIndex === 1) return ['edit', 'list', 'run', 'show'];
303
303
  if (command === '/skills' && ['edit', 'run', 'show'].includes(previousToken ?? '')) return skillNames(session);
304
- if ((command === '/start' || command === '/stop') && tokenIndex === 1) return ['agents', ...serviceNames()];
304
+ if (command === '/start' && tokenIndex === 1) return ['all', 'agents', 'services', ...serviceNames()];
305
+ if (command === '/stop' && tokenIndex === 1) return ['agents', ...serviceNames()];
305
306
  if (command === '/logs' && tokenIndex === 1) return serviceNames();
306
307
  return [];
307
308
  }
@@ -434,6 +435,7 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
434
435
  return [
435
436
  'You are Donna, the llm-wiki-manager chat assistant: warm, plain-spoken, and helpful — like an attentive colleague, never a raw status dump.',
436
437
  'You have a small READ-ONLY toolset — the tools provided to you for this turn, which may be none. Use them to answer questions about live state (e.g. "le CME est-il configuré", "quelles pages sont en attente"), and answer only from their results.',
438
+ 'Questions about Donna, wikiLLM, the manager, its interfaces, commands, configuration, agents, concurrency, or troubleshooting are product-help questions, not action requests. When PRODUCT HELP REFERENCE content is attached, answer directly from it in chat mode; never redirect such a question to /agent.',
437
439
  'When the conversation already contains attached document content (delimited by BEGIN/END ATTACHED DOCUMENT markers), read and summarize or answer from that content directly — you do NOT need a tool for it, and must not claim you cannot read the document.',
438
440
  'If no provided tool covers the request and no attached content answers it — or the request is an action or mutation (ingest, build, export, configure, send, delete…), or needs a service that is not connected — say plainly you cannot do it in chat mode and to switch to agent mode (/agent). Do not pretend to execute it and never guess.',
439
441
  'Answer directly and concisely. Do not claim to have called tools or changed files beyond the tools actually provided.',
@@ -449,6 +451,50 @@ function buildDirectChatSystemPrompt(session, rawOpenWikiPages) {
449
451
  ].join('\n');
450
452
  }
451
453
 
454
+ export function isProductHelpQuestion(input) {
455
+ const text = String(input ?? '')
456
+ .normalize('NFKD')
457
+ .replace(/\p{Diacritic}/gu, '')
458
+ .toLowerCase();
459
+ if (!text.trim()) return false;
460
+ if (/\b(donna|wikillm|llm-wiki|wiki-manager)\b/.test(text)) return true;
461
+ if (/\/(status|help|chat|agent|start|services|mcp|run|approve|queue)\b/.test(text)) return true;
462
+ if (/\b(manager ceiling|parallelism|throughput|collection concurrency|scheduler workers?)\b/.test(text)) return true;
463
+ const productConcept = /\b(workspaces?|agents?|connecteurs?|connectors?|mcp|runtime|approbations?|approvals?|ingestion|deliverables?|parallelisme|concurrence)\b/.test(text);
464
+ const explanatoryQuestion = /\b(comment|pourquoi|a quoi|qu est ce|que signifie|explique|fonctionne|difference|combien)\b/.test(text);
465
+ return productConcept && explanatoryQuestion;
466
+ }
467
+
468
+ async function productHelpContextMessages(input, session, onStep) {
469
+ if (!isProductHelpQuestion(input)) return [];
470
+ const provider = Object.entries(session?.mcp ?? {}).find(([, entry]) =>
471
+ entry?.status === 'connected'
472
+ && (entry.tools ?? []).some((tool) => String(tool?.name ?? '') === 'help_search'));
473
+ if (!provider) return [];
474
+ const [server] = provider;
475
+ try {
476
+ onStep?.('Chat: consulting product documentation…');
477
+ const result = await callMcpTool(
478
+ session.mcp,
479
+ server,
480
+ 'help_search',
481
+ { query: input },
482
+ session._abortSignal,
483
+ );
484
+ const content = formatMcpToolResult(result).trim();
485
+ if (!content) return [];
486
+ return [{
487
+ role: 'user',
488
+ content:
489
+ 'PRODUCT HELP REFERENCE follows. It is read-only product documentation. '
490
+ + 'Use it to answer the question directly and do not redirect me to agent mode:\n\n'
491
+ + `--- BEGIN PRODUCT HELP REFERENCE ---\n${content}\n--- END PRODUCT HELP REFERENCE ---`,
492
+ }];
493
+ } catch {
494
+ return [];
495
+ }
496
+ }
497
+
452
498
  function commonPrefix(values) {
453
499
  if (values.length === 0) return '';
454
500
  let prefix = values[0];
@@ -486,7 +532,12 @@ export function completionDescription(value, parts) {
486
532
  const subcommand = SUBCOMMAND_COMPLETION_DESCRIPTIONS[`${command}:${value}`];
487
533
  if (subcommand) return subcommand;
488
534
  if (command === '/use') return 'Load this workspace.';
489
- if (command === '/start') return serviceDescription(value) ?? 'Start this Docker Compose service.';
535
+ if (command === '/start') {
536
+ if (value === 'all') return 'Start the workspace services AND the external agents.';
537
+ if (value === 'agents' || value === 'agent') return 'Start the external agents only.';
538
+ if (value === 'services') return 'Start the workspace services only.';
539
+ return serviceDescription(value) ?? 'Start this Docker Compose service.';
540
+ }
490
541
  if (command === '/stop') return serviceDescription(value) ?? 'Stop this Docker Compose service.';
491
542
  if (command === '/logs') return serviceDescription(value) ?? 'Show logs for this Docker Compose service.';
492
543
  if (command === '/workspace') {
@@ -925,20 +976,24 @@ function rememberProductionActivity(session, payload) {
925
976
  export function applyRuntimeStateToShellSession(session, state) {
926
977
  if (!state || typeof state !== 'object') return false;
927
978
  const displayState = sanitizeRuntimeStateForDisplay(state);
979
+ const terminalStateDismissed = session._dismissedTerminalRunId
980
+ && state.runId === session._dismissedTerminalRunId
981
+ && ['done', 'error', 'failed', 'cancelled'].includes(String(displayState.status ?? '').toLowerCase());
982
+ session._lastRuntimeRunId = state.runId ?? session._lastRuntimeRunId ?? null;
928
983
  session.agentProjection = {
929
984
  conversation: Array.isArray(displayState.conversation) ? displayState.conversation.map((message) => ({ ...message })) : [],
930
985
  chain: Array.isArray(displayState.chain) ? displayState.chain.map((step) => ({ ...step })) : [],
931
- plan: Array.isArray(displayState.plan) && displayState.plan.length > 0
986
+ plan: !terminalStateDismissed && Array.isArray(displayState.plan) && displayState.plan.length > 0
932
987
  ? displayState.plan.map((step) => ({ ...step }))
933
988
  : null,
934
- activities: Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
935
- logs: Array.isArray(displayState.logs) ? [...displayState.logs] : [],
936
- summary: displayState.summary ?? null,
989
+ activities: !terminalStateDismissed && Array.isArray(displayState.activities) ? displayState.activities.map((activity) => ({ ...activity })) : [],
990
+ logs: !terminalStateDismissed && Array.isArray(displayState.logs) ? [...displayState.logs] : [],
991
+ summary: terminalStateDismissed ? null : displayState.summary ?? null,
937
992
  status: displayState.status ?? 'idle',
938
993
  planRevision: displayState.planRevision ?? 0,
939
- planPatches: Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
994
+ planPatches: !terminalStateDismissed && Array.isArray(displayState.planPatches) ? displayState.planPatches.map((patch) => ({ ...patch })) : [],
940
995
  };
941
- session.workflow = displayState.workflow && typeof displayState.workflow === 'object'
996
+ session.workflow = !terminalStateDismissed && displayState.workflow && typeof displayState.workflow === 'object'
942
997
  ? {
943
998
  ...state.workflow,
944
999
  nodes: Array.isArray(state.workflow.nodes) ? state.workflow.nodes.map((node) => ({ ...node })) : [],
@@ -1206,11 +1261,26 @@ function renderScreen({ packageJson, session, messages, inputBuffer, busy = fals
1206
1261
  output.write(buf);
1207
1262
  }
1208
1263
 
1209
- async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayInput = input }) {
1264
+ async function runAgentTurn(input, {
1265
+ agent,
1266
+ session,
1267
+ onUpdate,
1268
+ onStep,
1269
+ displayInput = input,
1270
+ synthesisOnly = false,
1271
+ }) {
1210
1272
  const messages = conversationMessages(session);
1211
1273
  const history = toConversationHistory(messages);
1212
1274
  session._onStep = onStep ?? null;
1213
1275
  session.packageJson = session.packageJson ?? {};
1276
+ if (
1277
+ session._lastRuntimeRunId
1278
+ && ['done', 'error', 'failed', 'cancelled'].includes(
1279
+ String(session.agentProjection?.status ?? '').toLowerCase(),
1280
+ )
1281
+ ) {
1282
+ session._dismissedTerminalRunId = session._lastRuntimeRunId;
1283
+ }
1214
1284
  dispatchAgentEvent(session, createAgentEvent('run_started', {
1215
1285
  origin: 'user',
1216
1286
  payload: { input: displayInput },
@@ -1252,6 +1322,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1252
1322
 
1253
1323
  let agentResult;
1254
1324
  try {
1325
+ if (synthesisOnly) session._responseSynthesisOnly = true;
1255
1326
  agentResult = await agent.invoke({ input, session, messages: history });
1256
1327
  } catch (err) {
1257
1328
  if (err.name === 'AbortError') {
@@ -1272,6 +1343,7 @@ async function runAgentTurn(input, { agent, session, onUpdate, onStep, displayIn
1272
1343
  delete session._onStep;
1273
1344
  delete session._onStream;
1274
1345
  delete session._onStreamReset;
1346
+ delete session._responseSynthesisOnly;
1275
1347
  }
1276
1348
 
1277
1349
  if (agentResult.streamedInline) {
@@ -1403,15 +1475,25 @@ async function runDirectChatTurn(input, { session, onUpdate, onStep }) {
1403
1475
  messages.push(donnaMessage);
1404
1476
  onUpdate?.();
1405
1477
  const allowedTools = chatAllowedTools(session);
1478
+ const productHelpMessages = await productHelpContextMessages(input, session, onStep);
1406
1479
  const canUseTools = allowedTools.length > 0 && typeof session.llm.completeWithTools === 'function';
1407
1480
  try {
1408
1481
  if (canUseTools) {
1409
- await runChatToolLoop({ input, session, history, donnaMessage, onUpdate, onStep, allowedTools });
1482
+ await runChatToolLoop({
1483
+ input,
1484
+ session,
1485
+ history,
1486
+ donnaMessage,
1487
+ onUpdate,
1488
+ onStep,
1489
+ allowedTools,
1490
+ contextMessages: productHelpMessages,
1491
+ });
1410
1492
  } else {
1411
1493
  onStep?.('Chat: streaming direct answer…');
1412
1494
  for await (const delta of session.llm.stream({
1413
1495
  system: buildDirectChatSystemPrompt(session),
1414
- messages: [...history, { role: 'user', content: input }],
1496
+ messages: [...history, ...productHelpMessages, { role: 'user', content: input }],
1415
1497
  signal: session._abortSignal,
1416
1498
  })) {
1417
1499
  const cleanDelta = stripDsmlArtifacts(delta);
@@ -1454,7 +1536,10 @@ export async function runHeadlessChatTurn(session, input, { history = [], onStep
1454
1536
  // a read tool is offered or the model chooses to call it. Multiple documents
1455
1537
  // (up to five) are each folded in as their own delimited block.
1456
1538
  const attachedDocs = await readSelectedPageDocuments(session, selectedPages);
1457
- const contextMessages = buildAttachedDocMessages(attachedDocs);
1539
+ const contextMessages = [
1540
+ ...buildAttachedDocMessages(attachedDocs),
1541
+ ...await productHelpContextMessages(input, session, onStep),
1542
+ ];
1458
1543
  const canUseTools = allowedTools.length > 0 && typeof session.llm?.completeWithTools === 'function';
1459
1544
  if (canUseTools) {
1460
1545
  await runChatToolLoop({ input, session, history, donnaMessage, onStep, allowedTools, openWikiPages: selectedPages, contextMessages });
@@ -1532,7 +1617,11 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1532
1617
  // and only for slash commands — free text got a single row.
1533
1618
  const result = await handleSlashCommand(trimmed, { packageJson, session, onStep, runtime });
1534
1619
  const messages = conversationMessages(session);
1535
- const handoffRawToAgent = Boolean(result.rawOutput && result.agentTrigger && agent);
1620
+ const deterministicDisplayOnly = /^\/status(?:\s|$)/.test(trimmed);
1621
+ const commandAgentTrigger = result.output && agent && !deterministicDisplayOnly
1622
+ ? (result.agentTrigger ?? rawCommandAgentPrompt(trimmed, result.output))
1623
+ : null;
1624
+ const handoffToAgent = Boolean(commandAgentTrigger);
1536
1625
  if (result.output) {
1537
1626
  const parts = trimmed.split(/\s+/);
1538
1627
  if (parts[0] === '/mcp' && parts[1] === 'call' && parts[2]) {
@@ -1548,15 +1637,19 @@ export async function runLine(line, { agent, packageJson, session, onUpdate, onS
1548
1637
  }
1549
1638
  }
1550
1639
  }
1551
- if (result.output && !handoffRawToAgent) {
1640
+ if (result.output && !handoffToAgent) {
1552
1641
  messages.push({ role: 'command', content: result.output });
1553
1642
  onUpdate?.();
1554
1643
  }
1555
- if (handoffRawToAgent) {
1556
- const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep, displayInput: trimmed });
1557
- if (agentResult.aborted) return { exit: false, aborted: true };
1558
- } else if (result.agentTrigger && agent) {
1559
- const agentResult = await runAgentTurn(result.agentTrigger, { agent, session, onUpdate, onStep });
1644
+ if (handoffToAgent) {
1645
+ const agentResult = await runAgentTurn(commandAgentTrigger, {
1646
+ agent,
1647
+ session,
1648
+ onUpdate,
1649
+ onStep,
1650
+ displayInput: trimmed,
1651
+ synthesisOnly: true,
1652
+ });
1560
1653
  if (agentResult.aborted) return { exit: false, aborted: true };
1561
1654
  }
1562
1655
  return { exit: Boolean(result.exit), setMode: result.setMode };
@@ -7,6 +7,7 @@ import {
7
7
  applyRuntimeStateToShellSession,
8
8
  chatAllowedTools,
9
9
  createSession,
10
+ isProductHelpQuestion,
10
11
  runHeadlessChatTurn,
11
12
  sanitizeOpenWikiPage,
12
13
  sanitizeOpenWikiPages,
@@ -42,23 +43,30 @@ test('ShellUI renders the user header full-width before constraining only its bo
42
43
  assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
43
44
  assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
44
45
  assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
45
- // The third argument reserves room for [ redo ]; the width stays `columns`.
46
- assert.match(source, /messageHeaderSegments\(message\.role, columns(, canRedo)?\)/);
46
+ assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
47
47
  });
48
48
 
49
- test('redo is offered on user questions only, next to copy', async () => {
49
+ test('ShellUI exposes copy without a reply or redo action', async () => {
50
50
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
51
- assert.match(source, /const canRedo = message\.role === 'user' && raw\.trim\(\)\.length > 0;/);
52
- assert.match(source, /props\.onRedo\?\.\(line\.redoIndex!, line\.redoContent!\)/);
51
+ assert.match(source, /const COPY_BTN = ' \[ copy \]';/);
52
+ assert.doesNotMatch(source, /\[\s*(?:reply|redo)\s*\]/i);
53
+ assert.doesNotMatch(source, /onRedo|redoContent|redoIndex|REDO_BTN/);
54
+ });
53
55
 
56
+ test('redo translates the local thread index into a runtime conversation index', async () => {
54
57
  const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
55
- // The runtime must be truncated first: its conversation is derived from the
56
- // event log and would be merged straight back in otherwise.
57
- const truncateIndex = session.indexOf('postRuntimeConversationTruncate(index');
58
- const spliceIndex = session.indexOf('full.splice(index)');
59
- const submitIndex = session.indexOf('return submitInput(content)');
60
- assert.ok(truncateIndex > 0 && spliceIndex > truncateIndex && submitIndex > spliceIndex);
61
- assert.match(session, /const windowOffset = Math\.max\(0, full\.length - messages\(\)\.length\);/);
58
+ const redo = session.slice(
59
+ session.indexOf('async function redoMessage('),
60
+ session.indexOf('function updateInput('),
61
+ );
62
+ // The local thread holds entries the runtime never recorded (slash-command
63
+ // output, help panels). Passing the raw local index to /conversation/truncate
64
+ // is what produced `index_out_of_range`; `backed` is the translation table.
65
+ assert.match(redo, /const backed = runtimeConversationRefsByWorkspace\.get\(workspaceKey\)/);
66
+ assert.match(redo, /let runtimeIndex = backed\.indexOf\(full\[index\]\)/);
67
+ assert.match(redo, /if \(full\.indexOf\(backed\[i\]\) < index\)/);
68
+ assert.match(redo, /props\.runtime\?\.url && runtimeIndex >= 0/);
69
+ assert.doesNotMatch(redo, /postRuntimeConversationTruncate\(index/);
62
70
  });
63
71
 
64
72
  test('ShellUI renders newest-first order in both Runtime and Agent status', async () => {
@@ -372,6 +380,29 @@ test('applyRuntimeStateToShellSession preserves terminal diagnostics when runtim
372
380
  assert.deepEqual(session.agentProjection.planPatches, [{ id: 'old-patch' }]);
373
381
  });
374
382
 
383
+ test('runtime sync does not resurrect a terminal plan dismissed by a newer user turn', () => {
384
+ const session = createSession();
385
+ session._dismissedTerminalRunId = 'old-run';
386
+ applyRuntimeStateToShellSession(session, {
387
+ runId: 'old-run',
388
+ status: 'error',
389
+ conversation: [{ role: 'assistant', content: 'Old failure' }],
390
+ plan: [{ id: 'old-task', status: 'failed' }],
391
+ activities: [{ key: 'old-job', status: 'failed', terminal: true }],
392
+ logs: ['old error'],
393
+ summary: 'Old run failed',
394
+ workflow: { nodes: [{ id: 'task:old' }], relations: [] },
395
+ planPatches: [{ id: 'old-patch' }],
396
+ });
397
+
398
+ assert.equal(session.headlessPlan, null);
399
+ assert.deepEqual(session.activities, {});
400
+ assert.equal(session.workflow, null);
401
+ assert.deepEqual(session.agentProjection.logs, []);
402
+ assert.equal(session.agentProjection.summary, null);
403
+ assert.deepEqual(session.agentProjection.conversation, [{ role: 'assistant', content: 'Old failure' }]);
404
+ });
405
+
375
406
  test('legacy runtime sync does not synthesize per-job or canned completion messages', () => {
376
407
  const session = createSession();
377
408
  applyRuntimeStateToShellSession(session, {
@@ -551,6 +582,53 @@ test('/agent <question> reports an unavailable runtime without leaving chat mode
551
582
  assert.match(conversationMessages(session).at(-1).content, /runtime stopped/);
552
583
  });
553
584
 
585
+ test('every slash-command result is presented by Donna when an agent is available', async () => {
586
+ const session = createSession();
587
+ let synthesisInput = '';
588
+ const agent = {
589
+ async invoke({ input, session: activeSession }) {
590
+ synthesisInput = input;
591
+ assert.equal(activeSession._responseSynthesisOnly, true);
592
+ return { response: 'Vous utilisez wiki-manager test.' };
593
+ },
594
+ };
595
+
596
+ const result = await runLine('/version', {
597
+ agent,
598
+ packageJson: { version: 'test' },
599
+ session,
600
+ });
601
+
602
+ assert.equal(result.exit, false);
603
+ assert.ok(synthesisInput.includes('commande shell /version'));
604
+ assert.deepEqual(conversationMessages(session).map((message) => message.role), ['user', 'donna']);
605
+ assert.equal(conversationMessages(session)[0].content, '/version');
606
+ assert.equal(conversationMessages(session)[1].content, 'Vous utilisez wiki-manager test.');
607
+ assert.equal(session._responseSynthesisOnly, undefined);
608
+ });
609
+
610
+ test('/status remains an immediate deterministic display without Donna', async () => {
611
+ const session = createSession();
612
+ let invoked = false;
613
+ const agent = {
614
+ async invoke() {
615
+ invoked = true;
616
+ return { response: 'must not run' };
617
+ },
618
+ };
619
+
620
+ const result = await runLine('/status', {
621
+ agent,
622
+ packageJson: { version: 'test' },
623
+ session,
624
+ });
625
+
626
+ assert.equal(result.exit, false);
627
+ assert.equal(invoked, false);
628
+ assert.equal(conversationMessages(session).at(-1)?.role, 'command');
629
+ assert.match(conversationMessages(session).at(-1)?.content ?? '', /Workspace · -/);
630
+ });
631
+
554
632
  test('runLine does not update workspace profile before Donna handles the request', async () => {
555
633
  const workspacePath = mkdtempSync(join(tmpdir(), 'donna-profile-shell-'));
556
634
  mkdirSync(join(workspacePath, '.wiki'), { recursive: true });
@@ -729,9 +807,9 @@ test('chatAllowedTools exposes exactly the declared MCP tools to /chat', () => {
729
807
  };
730
808
  const names = chatAllowedTools(session).map((item) => item.function.name).sort();
731
809
  // cme_setup: not declared. documents_status: server absent from chatAccess.
732
- // cme_export_run: declared, so it is offered — the allow-list is the
733
- // operator's explicit decision, not a suggestion filtered by a heuristic.
734
- assert.deepEqual(names, ['cme__cme_export_run', 'cme__cme_sources_list', 'cme__cme_status']);
810
+ // cme_export_run is an orchestration bypass and stays hidden even when an
811
+ // older allow-list still names it.
812
+ assert.deepEqual(names, ['cme__cme_sources_list', 'cme__cme_status']);
735
813
  });
736
814
 
737
815
  test('chatAllowedTools offers every declared tool, reads and writes alike', () => {
@@ -924,6 +1002,15 @@ test('runHeadlessChatTurn (HTTP /chat) uses the read-tool path and returns text'
924
1002
  assert.doesNotMatch(reply, /STREAM_FALLBACK/);
925
1003
  });
926
1004
 
1005
+ test('product-help questions are detected without treating ordinary domain questions as product help', () => {
1006
+ assert.equal(isProductHelpQuestion('À quoi correspond Parallelism & throughput ?'), true);
1007
+ assert.equal(isProductHelpQuestion('Comment fonctionne Donna ?'), true);
1008
+ assert.equal(isProductHelpQuestion('Explique le panneau /status'), true);
1009
+ assert.equal(isProductHelpQuestion('Comment fonctionnent les approbations ?'), true);
1010
+ assert.equal(isProductHelpQuestion('Que dit le wiki sur la météo ?'), false);
1011
+ assert.equal(isProductHelpQuestion('Résume ce document'), false);
1012
+ });
1013
+
927
1014
  test('sanitizeOpenWikiPage accepts wiki and untracked markdown context paths', () => {
928
1015
  assert.equal(sanitizeOpenWikiPage('wiki/concepts/foo.md'), 'wiki/concepts/foo.md');
929
1016
  assert.equal(sanitizeOpenWikiPage(' wiki/a.md '), 'wiki/a.md');
package/src/shell/tui.tsx CHANGED
@@ -398,7 +398,6 @@ function App(props: {
398
398
  scrollConversation={state.scrollConversation}
399
399
  spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
400
400
  onInputHeightChange={setChatInputHeight}
401
- onRedo={(index, content) => { void state.redoMessage(index, content); }}
402
401
  onCopy={(content) => showCopyHint(
403
402
  copyToClipboard(content, renderer)
404
403
  ? 'Copied.'
@@ -667,10 +667,28 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
667
667
  const index = windowOffset + displayIndex;
668
668
  if (index < 0 || index >= full.length) return { exit: false };
669
669
 
670
- if (props.runtime?.url) {
670
+ const workspaceKey = (session as any).workspace || '__global__';
671
+ // The local thread and the runtime conversation are NOT the same list:
672
+ // slash-command output, help panels and any other local-only entry live in
673
+ // `full` but were never recorded as events. Sending a `full` index straight
674
+ // to /conversation/truncate therefore points at the wrong entry — or past
675
+ // the end (`index_out_of_range`) — as soon as one local entry precedes the
676
+ // redone question. `backed` is index-aligned with the runtime conversation,
677
+ // so it is the only correct translation table.
678
+ const backed = runtimeConversationRefsByWorkspace.get(workspaceKey) ?? [];
679
+ let runtimeIndex = backed.indexOf(full[index]);
680
+ if (runtimeIndex === -1) {
681
+ // Local-only entry: truncate after the last runtime-backed entry that
682
+ // still precedes it, so nothing recorded before the question is lost.
683
+ for (let i = backed.length - 1; i >= 0; i -= 1) {
684
+ if (full.indexOf(backed[i]) < index) { runtimeIndex = i; break; }
685
+ }
686
+ }
687
+
688
+ if (props.runtime?.url && runtimeIndex >= 0) {
671
689
  // The runtime derives the conversation from its event log: deleting
672
690
  // locally alone would be undone by the next /state merge.
673
- const result = await postRuntimeConversationTruncate(index, {
691
+ const result = await postRuntimeConversationTruncate(runtimeIndex, {
674
692
  url: props.runtime.url,
675
693
  workspace: (session as any).workspace ?? null,
676
694
  }) as { truncated?: boolean; reason?: string };
@@ -690,8 +708,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
690
708
  // truncate above — clearing every workspace's refs here would desync
691
709
  // other workspaces' `backed` arrays from their untouched `target`
692
710
  // arrays and duplicate their history on the next merge.
693
- const currentWorkspace = (session as any).workspace || '__global__';
694
- runtimeConversationRefsByWorkspace.get(currentWorkspace)?.splice(0);
711
+ runtimeConversationRefsByWorkspace.get(workspaceKey)?.splice(0);
695
712
  refresh();
696
713
  return submitInput(content);
697
714
  }