@dotdrelle/wiki-manager 0.14.12 → 0.14.16

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.
Files changed (43) hide show
  1. package/.env.example +11 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/activity/activityAggregator.js +43 -13
  5. package/src/activity/activityAggregator.test.js +51 -2
  6. package/src/agent/graph.js +79 -11
  7. package/src/agent/graph.test.js +43 -3
  8. package/src/cli/wiki-manager.js +44 -3
  9. package/src/commands/slash.js +10 -3
  10. package/src/commands/slash.test.js +24 -0
  11. package/src/core/activity.js +4 -0
  12. package/src/core/activity.test.js +31 -0
  13. package/src/core/agentEvents.js +11 -2
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +4 -0
  16. package/src/core/env.js +16 -4
  17. package/src/core/env.test.js +3 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/wikiSetup.js +43 -1
  20. package/src/core/wikiWorkspace.test.js +20 -0
  21. package/src/core/workspaces.js +10 -2
  22. package/src/orchestrator/dependencyResolver.js +19 -1
  23. package/src/orchestrator/objectiveResolver.js +24 -0
  24. package/src/orchestrator/objectiveResolver.test.js +23 -1
  25. package/src/orchestrator/scheduler.test.js +22 -1
  26. package/src/runtime/auth.test.js +65 -1
  27. package/src/runtime/client.js +4 -0
  28. package/src/runtime/donna-contract.test.js +2 -0
  29. package/src/runtime/lifecycle.js +21 -12
  30. package/src/runtime/runner.js +130 -17
  31. package/src/runtime/runner.test.js +62 -1
  32. package/src/runtime/server.js +13 -2
  33. package/src/runtime/server.test.js +30 -1
  34. package/src/shell/FileEditorDialog.tsx +2 -2
  35. package/src/shell/LeftPane.tsx +60 -15
  36. package/src/shell/RightPane.tsx +147 -54
  37. package/src/shell/StartupScreen.tsx +3 -7
  38. package/src/shell/renderer.ts +1 -0
  39. package/src/shell/repl.js +7 -81
  40. package/src/shell/repl.test.js +128 -38
  41. package/src/shell/tui.tsx +59 -64
  42. package/src/shell/useSession.ts +45 -6
  43. package/wiki-workspace +36 -3
@@ -7,6 +7,8 @@ import { createAssignmentManager } from '../orchestrator/assignmentManager.js';
7
7
  import { createAttemptManager } from '../orchestrator/attemptManager.js';
8
8
  import { createBudgetManager, BudgetExceededError } from '../orchestrator/budgetManager.js';
9
9
  import { createDispatcher } from '../orchestrator/dispatcher.js';
10
+ import { approvalRequestForTask } from '../orchestrator/approvalPolicy.js';
11
+ import { PENDING_STATUSES, tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
12
  import { assertValidatedFragment } from '../orchestrator/planValidator.js';
11
13
  import { createResultAggregator } from '../orchestrator/resultAggregator.js';
12
14
  import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
@@ -139,9 +141,14 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
139
141
  // loop would re-ingest this run's own turns and duplicate them.
140
142
  const runConversationSeed = conversationSeed(session, currentInput);
141
143
 
144
+ // The conversational loop path ends with the agent's own natural-language
145
+ // reply; the deterministic parallel scheduler has no agent voice, so only
146
+ // that path gets a synthesized outcome summary (announceRunOutcome).
147
+ let usedParallelScheduler = false;
142
148
  while (true) {
143
149
  sanitizeSessionPlanForExecution(session, runId);
144
- const result = shouldUseParallelScheduler(session.headlessPlan)
150
+ usedParallelScheduler = shouldUseParallelScheduler(session.headlessPlan);
151
+ const result = usedParallelScheduler
145
152
  ? await runRuntimeParallelPlan(agent, session, input, {
146
153
  signal,
147
154
  timeoutMs,
@@ -188,6 +195,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
188
195
  emitRuntimeLog(session, 'runtime: run ended by user cancellation (no replan)');
189
196
  return { ok: false, result, cancelled: true };
190
197
  }
198
+ if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: false, signal });
191
199
  dispatchAgentEvent(session, createAgentEvent('run_error', {
192
200
  origin: 'runtime',
193
201
  runId,
@@ -267,6 +275,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
267
275
  }
268
276
  }
269
277
 
278
+ if (usedParallelScheduler) await announceRunOutcome(session, { runId, ok: true, signal });
270
279
  dispatchAgentEvent(session, createAgentEvent('run_done', {
271
280
  origin: 'runtime',
272
281
  runId,
@@ -276,6 +285,62 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
276
285
  }
277
286
  }
278
287
 
288
+ // Emit ONE natural-language Donna message summarizing how the run finished,
289
+ // instead of the client streaming a per-job line for every task. Uses the
290
+ // workspace LLM to phrase it, degrading to a plain templated fact line if the
291
+ // LLM is unavailable or errors — the run must never block on this summary.
292
+ async function announceRunOutcome(session, { runId, ok, signal = null } = {}) {
293
+ const plan = Array.isArray(session.headlessPlan) ? session.headlessPlan : [];
294
+ if (plan.length === 0) return;
295
+ let failed = 0;
296
+ let cancelled = 0;
297
+ let completed = 0;
298
+ let firstError = null;
299
+ for (const step of plan) {
300
+ const status = String(step?.status ?? '').toLowerCase();
301
+ if (['failed', 'error', 'stalled'].includes(status)) {
302
+ failed += 1;
303
+ firstError ??= String(
304
+ step?.error?.message ?? step?.error?.code ?? step?.error
305
+ ?? step?.result?.error?.message ?? step?.result?.error?.code ?? '',
306
+ ).trim() || null;
307
+ } else if (['cancelled', 'canceled'].includes(status)) {
308
+ cancelled += 1;
309
+ } else if (['done', 'complete', 'completed', 'success', 'succeeded'].includes(status)) {
310
+ completed += 1;
311
+ }
312
+ }
313
+ const total = plan.length;
314
+ const factLine = ok && failed === 0
315
+ ? `Plan terminé avec succès — ${completed}/${total} tâche(s) réussie(s).`
316
+ : `Plan terminé en erreur — ${completed}/${total} tâche(s) réussie(s), ${failed} en erreur${cancelled ? `, ${cancelled} annulée(s)` : ''}.${firstError ? ` Première erreur : ${firstError}.` : ''}`;
317
+ let content = factLine;
318
+ const llm = session.llm;
319
+ if (llm && typeof llm.completeWithTools === 'function') {
320
+ try {
321
+ const result = await llm.completeWithTools({
322
+ system: [
323
+ 'You are Donna, an orchestration assistant reporting a run result to the user.',
324
+ 'Rephrase the outcome facts in ONE short, natural sentence, in the same language as the facts.',
325
+ 'No lists, no headers, no raw job ids — just a concise human summary.',
326
+ ].join('\n'),
327
+ tools: [],
328
+ messages: [{ role: 'user', content: `Run outcome facts:\n${factLine}` }],
329
+ signal,
330
+ });
331
+ const phrased = String(result?.content ?? '').trim();
332
+ if (phrased) content = phrased;
333
+ } catch {
334
+ // Degrade to the templated fact line — never fail the run on the summary.
335
+ }
336
+ }
337
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
338
+ origin: 'runtime',
339
+ runId,
340
+ payload: { content },
341
+ }));
342
+ }
343
+
279
344
  export async function runRuntimeParallelPlan(agent, session, input, {
280
345
  signal = null,
281
346
  timeoutMs,
@@ -328,7 +393,6 @@ export async function runRuntimeParallelPlan(agent, session, input, {
328
393
  sanitizeSessionPlanForExecution(session, runId);
329
394
  ensurePlanProjection(session, runId);
330
395
  emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
331
- let approvalNoticeSent = false;
332
396
  // Interactive approvals do NOT expire: the user has /approve, "valide
333
397
  // tout", /cancel and /run kill — an arbitrary timer only created mystery
334
398
  // failures. A deadline exists only when explicitly configured (headless
@@ -431,26 +495,60 @@ export async function runRuntimeParallelPlan(agent, session, input, {
431
495
  emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
432
496
  return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
433
497
  }
434
- const needingApproval = pending.filter((step) => step.requiresApproval === true);
498
+ // Only wait for a human when approval is the sole remaining blocker.
499
+ // A task whose dependency failed cannot become runnable by approving
500
+ // it; treating it as an approval wait leaves the run alive forever.
501
+ const approvalContext = {
502
+ runId,
503
+ workspace: session.workspace ?? null,
504
+ planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
505
+ tasks: session.headlessPlan ?? [],
506
+ };
507
+ // One snapshot of the approvals list, reused for both the
508
+ // awaiting-approval computation and the per-task dedup below so the two
509
+ // can never diverge mid-iteration.
510
+ const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
511
+ const needingApproval = tasksAwaitingApproval(approvalContext, { approvals });
435
512
  if (needingApproval.length > 0) {
436
513
  // The plan is only blocked on a HUMAN decision — wait for it
437
514
  // (bounded) instead of declaring the run stalled. Announce once in
438
515
  // the chat: users cannot approve what they never saw asked.
439
- if (!approvalNoticeSent) {
440
- approvalNoticeSent = true;
516
+ const newlyRequested = [];
517
+ for (const task of needingApproval) {
518
+ const taskId = String(task.id ?? task.taskId ?? task.step ?? '');
519
+ const alreadyRequested = approvals.some((approval) =>
520
+ approval.status === 'pending_approval'
521
+ && String(approval.taskId ?? approval.itemId ?? '') === taskId
522
+ && Number(approval.planRevision ?? approvalContext.planRevision) === Number(approvalContext.planRevision));
523
+ if (alreadyRequested) continue;
524
+ newlyRequested.push(task);
525
+ const request = approvalRequestForTask(task, {
526
+ runId,
527
+ workspaceId: session.workspace ?? null,
528
+ planRevision: approvalContext.planRevision,
529
+ });
530
+ dispatchAgentEvent(session, createAgentEvent('approval.requested', {
531
+ origin: 'runtime',
532
+ runId,
533
+ taskId: request.taskId,
534
+ workspace: session.workspace ?? null,
535
+ payload: request,
536
+ }));
537
+ }
538
+ if (newlyRequested.length > 0) {
441
539
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
442
540
  origin: 'runtime',
443
541
  runId,
444
542
  payload: {
445
543
  content: [
446
- `⏸ Approbation requise avant exécution : ${needingApproval.length} tâche(s) mutante(s) en attente.`,
447
- ...needingApproval.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
448
- needingApproval.length > 5 ? ` … et ${needingApproval.length - 5} autre(s).` : null,
544
+ `⏸ Approbation requise avant exécution : ${newlyRequested.length} tâche(s) mutante(s) en attente.`,
545
+ ...newlyRequested.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
546
+ newlyRequested.length > 5 ? ` … et ${newlyRequested.length - 5} autre(s).` : null,
449
547
  'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
450
548
  ].filter(Boolean).join('\n'),
451
549
  },
452
550
  }));
453
- emitRuntimeLog(session, `scheduler: waiting for approval (${needingApproval.length} task(s))`);
551
+ emitRuntimeLog(session, `scheduler: waiting for approval (${newlyRequested.length} new task(s))`);
454
552
  }
455
553
  if (Date.now() < approvalDeadline) {
456
554
  await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
@@ -469,7 +567,9 @@ export async function runRuntimeParallelPlan(agent, session, input, {
469
567
  }));
470
568
  return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
471
569
  }
472
- const reason = pending.every((step) => approvalWaitingStatus(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
570
+ // Any genuine approval-only block returned above. Remaining tasks are
571
+ // unschedulable for another reason (most commonly a failed dependency).
572
+ const reason = 'no_ready_plan_task';
473
573
  emitRuntimeLog(session, `scheduler: stalled (${reason})`);
474
574
  return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
475
575
  }
@@ -585,11 +685,7 @@ function abortCancelledActiveTasks(session, active) {
585
685
  }
586
686
 
587
687
  function pendingSchedulerStatus(status) {
588
- return ['pending', 'pending_approval', 'waiting_approval'].includes(String(status ?? ''));
589
- }
590
-
591
- function approvalWaitingStatus(status) {
592
- return ['pending_approval', 'waiting_approval'].includes(String(status ?? ''));
688
+ return PENDING_STATUSES.has(String(status ?? ''));
593
689
  }
594
690
 
595
691
  // Scope the evaluator/replanner's view of "completed" activities to the
@@ -607,8 +703,25 @@ function failedTaskActivities(session) {
607
703
  return failedKeys.size > 0 ? all.filter((activity) => failedKeys.has(activity.key)) : all;
608
704
  }
609
705
 
610
- function ensurePlanProjection(session, runId) {
611
- if (!session.headlessPlan || session.agentProjection?.plan) return;
706
+ // Identity of a plan as displayed: the ordered list of task ids. Two plans
707
+ // with the same tasks in the same order are the same plan for projection
708
+ // purposes (statuses update through plan_step_updated, not a re-projection).
709
+ function planIdentitySignature(plan) {
710
+ return Array.isArray(plan) ? plan.map(planTaskId).join('|') : '';
711
+ }
712
+
713
+ export function ensurePlanProjection(session, runId) {
714
+ if (!session.headlessPlan) return;
715
+ // Re-emit plan_set not only when the projection has no plan yet, but also
716
+ // when the session plan has CHANGED shape since the last projection. A
717
+ // chained run (e.g. /skill pipeline) sets a fresh headlessPlan for step 2
718
+ // while the projection still holds step 1's plan; the old guard
719
+ // (`agentProjection?.plan` truthy → skip) then left the UI stuck on the
720
+ // step 1 plan. Compare task identities and re-project when they differ.
721
+ const projected = session.agentProjection?.plan ?? null;
722
+ if (projected && planIdentitySignature(projected) === planIdentitySignature(session.headlessPlan)) {
723
+ return;
724
+ }
612
725
  dispatchAgentEvent(session, createAgentEvent('plan_set', {
613
726
  origin: 'runtime',
614
727
  runId,
@@ -1,7 +1,38 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
- import { evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
4
+ import { ensurePlanProjection, evaluateRuntimeRun, finishRuntimeRun, materializeTaskInputs, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan, shouldUseParallelScheduler } from './runner.js';
5
+
6
+ test('ensurePlanProjection re-projects when the chained plan changes shape (step 2)', () => {
7
+ const session = { agentEvents: [], agentProjection: null };
8
+ // Step 1: a fresh plan is projected.
9
+ session.headlessPlan = [{ id: 'a', description: 'ingest', status: 'pending' }];
10
+ ensurePlanProjection(session, 'run-1');
11
+ assert.deepEqual(
12
+ (session.agentProjection?.plan ?? []).map((step) => step.id),
13
+ ['a'],
14
+ );
15
+
16
+ // Step 1 completes in place — same plan shape, no re-projection needed.
17
+ session.headlessPlan = [{ id: 'a', description: 'ingest', status: 'done' }];
18
+ const eventsAfterStep1 = session.agentEvents.length;
19
+ ensurePlanProjection(session, 'run-1');
20
+ assert.equal(session.agentEvents.length, eventsAfterStep1, 'unchanged shape must not re-emit plan_set');
21
+
22
+ // Step 2 adds a task — the projection must now reflect the new plan.
23
+ session.headlessPlan = [
24
+ { id: 'a', description: 'ingest', status: 'done' },
25
+ { id: 'b', description: 'build', status: 'pending' },
26
+ ];
27
+ ensurePlanProjection(session, 'run-1');
28
+ assert.deepEqual(
29
+ (session.agentProjection?.plan ?? []).map((step) => step.id),
30
+ ['a', 'b'],
31
+ 'step 2 plan must be projected instead of staying on step 1',
32
+ );
33
+ // Completed step 1 status is preserved through the re-projection.
34
+ assert.equal(session.agentProjection.plan.find((step) => step.id === 'a').status, 'done');
35
+ });
5
36
 
6
37
  test('validated tasks waiting for approval stay in the scheduler instead of falling back to Donna', () => {
7
38
  assert.equal(shouldUseParallelScheduler([
@@ -781,6 +812,36 @@ test('runRuntimeParallelPlan fails cleanly when scheduler budget is exceeded', a
781
812
  assert.equal(session.headlessPlan[0].status, 'cancelled');
782
813
  });
783
814
 
815
+ test('runRuntimeParallelPlan does not wait for approval behind a failed dependency', async () => {
816
+ const session = {
817
+ workspace: 'demo-workspace',
818
+ mcp: { tools: {} },
819
+ agentEvents: [],
820
+ headlessPlan: [
821
+ { ...plannedDoctorTask('a', []), status: 'failed' },
822
+ {
823
+ ...plannedDoctorTask('b', ['a']),
824
+ status: 'waiting_approval',
825
+ requiresApproval: true,
826
+ approvalClass: 'mutation',
827
+ },
828
+ ],
829
+ };
830
+
831
+ const result = await runRuntimeParallelPlan(
832
+ { invoke: async () => assert.fail('blocked work must not invoke an agent') },
833
+ session,
834
+ 'Apply after failed plan',
835
+ { runId: 'run-failed-dependency', timeoutMs: 1000, maxTurns: 1 },
836
+ );
837
+
838
+ assert.equal(result.ok, false);
839
+ assert.equal(result.stalled, true);
840
+ assert.equal(result.reason, 'no_ready_plan_task');
841
+ assert.equal(session.agentEvents.some((event) => event.type === 'assistant_message'
842
+ && /Approbation requise/.test(event.payload?.content ?? '')), false);
843
+ });
844
+
784
845
  test('runRuntimeParallelPlan retries a retryable task on a fallback agent', async () => {
785
846
  const executeServers = [];
786
847
  const session = {
@@ -6,6 +6,8 @@ import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
7
7
  import { runtimeTokenFromEnv } from './auth.js';
8
8
  import { controlMessage } from './controlMessages.js';
9
+ import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
10
+ import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
9
11
 
10
12
  export function startRuntimeServer({
11
13
  host = '127.0.0.1',
@@ -679,10 +681,19 @@ function readOnlyControlResponse(kind, classification, status, explanation, { ac
679
681
  };
680
682
  }
681
683
 
682
- function approvalRequestFromStatus(status) {
684
+ export function approvalRequestFromStatus(status) {
683
685
  const runId = status.runId ?? status.runs?.find((run) => run.status === 'running' || run.status === 'pending_approval')?.id ?? null;
684
686
  const pending = (status.approvals ?? []).filter((approval) => approval.status === 'pending_approval');
685
- const classes = [...new Set(pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)))];
687
+ const waitingTasks = tasksAwaitingApproval({
688
+ runId,
689
+ workspace: status.workspace ?? null,
690
+ planRevision: status.planRevision ?? null,
691
+ tasks: status.plan ?? [],
692
+ }, { approvals: status.approvals ?? [] });
693
+ const classes = [...new Set([
694
+ ...pending.flatMap((approval) => readOptionalList(approval.approvalClasses ?? approval.approvalClass)),
695
+ ...waitingTasks.map((task) => approvalClassForTask(task)),
696
+ ].filter(Boolean))];
686
697
  return {
687
698
  workspace: status.workspace ?? null,
688
699
  workspaceId: status.workspace ?? null,
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
5
- import { runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
5
+ import { approvalRequestFromStatus, runtimeState, startRuntimeServer as startRuntimeServerImpl } from './server.js';
6
6
 
7
7
  // Most server tests exercise endpoint behavior rather than authentication. Keep
8
8
  // them independent from a developer's WIKI_MANAGER_RUNTIME_TOKEN environment;
@@ -11,6 +11,35 @@ function startRuntimeServer(options) {
11
11
  return startRuntimeServerImpl({ token: '', ...options });
12
12
  }
13
13
 
14
+ test('approval fallback derives classes from waiting tasks when the approval queue is missing', () => {
15
+ const request = approvalRequestFromStatus({
16
+ workspace: 'acme',
17
+ runId: 'run-1',
18
+ planRevision: 4,
19
+ approvals: [],
20
+ plan: [
21
+ { id: 'apply', status: 'waiting_approval', requiresApproval: true, approvalClass: 'mutation' },
22
+ { id: 'export', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write' },
23
+ ],
24
+ });
25
+
26
+ assert.deepEqual(request.approvalClasses, ['mutation', 'external-write']);
27
+ assert.equal(request.scope, 'run');
28
+ assert.equal(request.planRevision, 4);
29
+ });
30
+
31
+ test('approval fallback excludes tasks blocked by failed dependencies', () => {
32
+ const request = approvalRequestFromStatus({
33
+ workspace: 'acme', runId: 'run-1', planRevision: 4, approvals: [],
34
+ plan: [
35
+ { id: 'failed-source', status: 'failed' },
36
+ { id: 'blocked-write', status: 'waiting_approval', requiresApproval: true, approvalClass: 'external-write', dependsOn: ['failed-source'] },
37
+ ],
38
+ });
39
+
40
+ assert.deepEqual(request.approvalClasses, ['default']);
41
+ });
42
+
14
43
  test('interactive runtime sessions isolate canonical run state', () => {
15
44
  const mcp = { wiki: { status: 'connected' } };
16
45
  const session = createInteractiveSession({ session: {
@@ -83,9 +83,9 @@ export function FileEditorDialog(props: {
83
83
  {(message) => <text height={1} fg="#F87171">{message()}</text>}
84
84
  </Show>
85
85
  <box height={1} flexDirection="row">
86
- <text fg="#111318" bg="#8BD5CA"> Save Ctrl+S </text>
86
+ <text fg="#111318" bg="#8BD5CA" onMouseUp={save}> Save Ctrl+S </text>
87
87
  <text fg="#7F8C8D"> </text>
88
- <text fg="#D6DEE8" bg="#4B5563"> Cancel Esc </text>
88
+ <text fg="#D6DEE8" bg="#4B5563" onMouseUp={props.onCancel}> Cancel Esc </text>
89
89
  </box>
90
90
  </box>
91
91
  )}
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
+ import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
3
4
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
4
5
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
5
6
 
@@ -49,8 +50,20 @@ function wrapLine(line: string, width: number) {
49
50
  }
50
51
 
51
52
  type Segment = { text: string; color: string; width?: number; bg?: string; url?: string };
53
+
54
+ function styledSegments(segments: Segment[]) {
55
+ return new StyledText(segments.map((segment) => {
56
+ const text = segment.width ? segment.text.padEnd(segment.width) : segment.text;
57
+ let chunk = styledFg(segment.color)(text);
58
+ if (segment.bg) chunk = styledBg(segment.bg)(chunk);
59
+ if (segment.url) chunk = styledLink(segment.url)(chunk);
60
+ return chunk;
61
+ }));
62
+ }
52
63
  type RenderedLine = {
53
64
  segments: Segment[];
65
+ role?: string;
66
+ userBubbleWidth?: number;
54
67
  status?: boolean;
55
68
  statusLeft?: string;
56
69
  statusRight?: string;
@@ -376,19 +389,35 @@ function statusColumns(line: string): { left: string; right: string } {
376
389
  return { left: left || ' ', right };
377
390
  }
378
391
 
392
+ // Consecutive messages that render under the same header ([shell]/[user]/
393
+ // [donna]) are grouped: only the first one shows the label + separator, so a
394
+ // run of same-type messages doesn't repeat the header on every entry.
395
+ function headerGroupKey(role: string) {
396
+ return isDonnaRole(role) ? 'donna' : roleLabel(role);
397
+ }
398
+
379
399
  function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
380
- return messages.flatMap((message) => {
400
+ return messages.flatMap((message, index) => {
381
401
  const raw = String(message.content || '');
402
+ const previous = index > 0 ? messages[index - 1] : null;
403
+ const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
404
+ const headerLines: RenderedLine[] = showHeader
405
+ ? [
406
+ { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
407
+ { segments: [{ text: ' ', color: '#D6DEE8' }] },
408
+ ]
409
+ : [];
410
+ const contentColumns = message.role === 'user' ? Math.max(12, Math.floor(columns * 0.6) - 2) : columns;
382
411
  if (isStatusOutput(message)) {
383
- return [
384
- { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
385
- { segments: [{ text: ' ', color: '#D6DEE8' }] },
412
+ const statusLines: RenderedLine[] = [
413
+ ...headerLines,
386
414
  ...raw.split('\n').map((line) => {
387
415
  const { left, right } = statusColumns(line || ' ');
388
416
  return { status: true, statusLeft: left, statusRight: right, segments: [] };
389
417
  }),
390
418
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
391
419
  ];
420
+ return statusLines.map((line) => ({ ...line, role: message.role }));
392
421
  }
393
422
  let inFence = false;
394
423
  const lines: Array<{ text: string; isCode: boolean }> = [];
@@ -399,12 +428,19 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
399
428
  if (/^\s*(`{3,}|~{3,})/.test(line)) { inFence = !inFence; continue; }
400
429
  lines.push({ text: line, isCode: inFence });
401
430
  }
402
- return [
403
- { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
404
- { segments: [{ text: ' ', color: '#D6DEE8' }] },
405
- ...renderMarkdownLines(lines, message.role, columns),
431
+ const renderedBody = renderMarkdownLines(lines, message.role, contentColumns);
432
+ const userBubbleWidth = message.role === 'user'
433
+ ? Math.min(
434
+ Math.max(1, ...renderedBody.map((line) => line.segments.reduce((sum, segment) => sum + segment.text.length, 0))) + 2,
435
+ Math.max(12, Math.floor(columns * 0.6)),
436
+ )
437
+ : undefined;
438
+ const bodyLines: RenderedLine[] = [
439
+ ...headerLines,
440
+ ...renderedBody.map((line) => ({ ...line, userBubbleWidth })),
406
441
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
407
442
  ];
443
+ return bodyLines.map((line) => ({ ...line, role: message.role }));
408
444
  });
409
445
  }
410
446
 
@@ -462,6 +498,21 @@ export function ConversationView(props: {
462
498
  </For>
463
499
  <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
464
500
  </box>
501
+ ) : line.role === 'user' && line.userBubbleWidth ? (
502
+ <box height={1} flexDirection="row" overflow="hidden">
503
+ <box flexGrow={1} />
504
+ <box
505
+ width={line.userBubbleWidth}
506
+ height={1}
507
+ flexDirection="row"
508
+ flexShrink={1}
509
+ justifyContent="flex-start"
510
+ paddingX={1}
511
+ overflow="hidden"
512
+ >
513
+ <text>{styledSegments(line.segments)}</text>
514
+ </box>
515
+ </box>
465
516
  ) : line.status ? (
466
517
  <box height={1} flexDirection="row" gap={2} overflow="hidden">
467
518
  <box
@@ -499,13 +550,7 @@ export function ConversationView(props: {
499
550
  </box>
500
551
  ) : (
501
552
  <box height={1} flexDirection="row" overflow="hidden">
502
- <For each={line.segments}>
503
- {(seg: Segment) => (
504
- <text width={seg.width} fg={seg.color} bg={seg.bg}>
505
- {seg.url ? <a href={seg.url}>{seg.text}</a> : seg.text}
506
- </text>
507
- )}
508
- </For>
553
+ <text>{styledSegments(line.segments)}</text>
509
554
  </box>
510
555
  )
511
556
  )}