@dotdrelle/wiki-manager 0.12.0 → 0.12.7

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 (40) hide show
  1. package/package.json +2 -2
  2. package/src/activity/activityAggregator.js +34 -3
  3. package/src/activity/activityAggregator.test.js +32 -0
  4. package/src/agent/graph.js +307 -29
  5. package/src/agent/graph.test.js +404 -1
  6. package/src/cli/wiki-manager.js +24 -1
  7. package/src/commands/slash.js +74 -1
  8. package/src/commands/slash.test.js +36 -0
  9. package/src/contracts/schemas.test.js +2 -2
  10. package/src/core/activity.js +4 -0
  11. package/src/core/activity.test.js +9 -1
  12. package/src/core/agentEvents.js +30 -0
  13. package/src/core/agentEvents.test.js +42 -1
  14. package/src/core/buildInfo.js +58 -0
  15. package/src/core/buildInfo.json +4 -0
  16. package/src/core/buildInfo.test.js +14 -0
  17. package/src/core/mcp.js +50 -3
  18. package/src/core/mcp.test.js +94 -1
  19. package/src/core/runtimeLog.js +6 -1
  20. package/src/core/runtimeLog.test.js +3 -1
  21. package/src/runtime/client.js +22 -0
  22. package/src/runtime/controlMessages.js +43 -0
  23. package/src/runtime/controlMessages.test.js +21 -0
  24. package/src/runtime/donna-contract.test.js +3 -3
  25. package/src/runtime/recoveryManager.js +54 -0
  26. package/src/runtime/recoveryManager.test.js +73 -0
  27. package/src/runtime/runner.js +49 -13
  28. package/src/runtime/runner.test.js +196 -0
  29. package/src/runtime/server.js +73 -13
  30. package/src/runtime/server.test.js +284 -67
  31. package/src/runtime/store.js +48 -3
  32. package/src/runtime/store.test.js +56 -24
  33. package/src/runtime/supervisor.js +77 -1
  34. package/src/shell/RightPane.tsx +124 -31
  35. package/src/shell/SetupWizard.tsx +13 -1
  36. package/src/shell/repl.js +81 -11
  37. package/src/shell/repl.test.js +115 -2
  38. package/src/shell/tui.tsx +4 -1
  39. package/src/shell/useAgent.ts +37 -4
  40. package/src/shell/useSession.ts +51 -10
@@ -279,6 +279,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
279
279
  SET status = 'interrupted', updated_at = ?
280
280
  WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
281
281
  `);
282
+ const interruptRunByIdStatement = db.prepare(`
283
+ UPDATE runs
284
+ SET status = 'interrupted', updated_at = ?
285
+ WHERE id = ? AND (? IS NULL OR workspace = ?) AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
286
+ `);
282
287
  const upsertQueueItem = db.prepare(`
283
288
  INSERT INTO queue_items (
284
289
  id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
@@ -413,6 +418,25 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
413
418
  `);
414
419
  const deleteTaskDependenciesStatement = db.prepare('DELETE FROM task_dependencies WHERE run_id = ? AND task_id = ?');
415
420
  const updateTaskStatusStatement = db.prepare('UPDATE tasks SET status = ?, declaration = ?, updated_at = ? WHERE run_id = ? AND id = ?');
421
+ const listActiveTasksForInterruptedRunsStatement = db.prepare(`
422
+ SELECT tasks.*
423
+ FROM tasks
424
+ JOIN runs ON runs.id = tasks.run_id
425
+ WHERE runs.workspace = ?
426
+ AND runs.status = 'interrupted'
427
+ AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
428
+ ORDER BY tasks.run_id ASC, tasks.id ASC
429
+ `);
430
+ const listActiveTasksForInterruptedRunByIdStatement = db.prepare(`
431
+ SELECT tasks.*
432
+ FROM tasks
433
+ JOIN runs ON runs.id = tasks.run_id
434
+ WHERE runs.id = ?
435
+ AND (? IS NULL OR runs.workspace = ?)
436
+ AND runs.status = 'interrupted'
437
+ AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
438
+ ORDER BY tasks.run_id ASC, tasks.id ASC
439
+ `);
416
440
  const insertTaskDependencyStatement = db.prepare(`
417
441
  INSERT OR IGNORE INTO task_dependencies (run_id, task_id, depends_on_task_id, created_at)
418
442
  VALUES (?, ?, ?, ?)
@@ -619,13 +643,33 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
619
643
  .map((row) => row.workspace);
620
644
  }
621
645
 
622
- function interruptRuns({ workspace, reason = 'Runtime restart recovery failed.' } = {}) {
623
- if (!workspace) return 0;
646
+ function interruptRuns({ workspace, runId = null, reason = 'Runtime restart recovery failed.' } = {}) {
647
+ if (!workspace && !runId) return 0;
624
648
  const now = new Date().toISOString();
625
- const result = interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
649
+ const result = runId
650
+ ? interruptRunByIdStatement.run(now, String(runId), workspace ?? null, workspace ?? null, ...RECOVERABLE_RUN_STATUSES)
651
+ : interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
626
652
  return Number(result.changes ?? 0);
627
653
  }
628
654
 
655
+ function cancelActiveTasksForInterruptedRuns({ workspace = null, runId = null } = {}) {
656
+ if (!workspace && !runId) return 0;
657
+ const now = new Date().toISOString();
658
+ const tasks = runId
659
+ ? listActiveTasksForInterruptedRunByIdStatement.all(String(runId), workspace ?? null, workspace ?? null).map(rowToTask)
660
+ : listActiveTasksForInterruptedRunsStatement.all(workspace).map(rowToTask);
661
+ for (const task of tasks) {
662
+ updateTaskStatusStatement.run(
663
+ 'cancelled',
664
+ JSON.stringify({ ...task.declaration, status: 'cancelled' }),
665
+ now,
666
+ task.runId,
667
+ task.id,
668
+ );
669
+ }
670
+ return tasks.length;
671
+ }
672
+
629
673
  function saveQueue(queue = [], { workspace = null } = {}) {
630
674
  const items = Array.isArray(queue) ? queue : [];
631
675
  const now = new Date().toISOString();
@@ -1121,6 +1165,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1121
1165
  listRecoverableRuns,
1122
1166
  listRecoverableWorkspaces,
1123
1167
  interruptRuns,
1168
+ cancelActiveTasksForInterruptedRuns,
1124
1169
  saveQueue,
1125
1170
  listQueue,
1126
1171
  listTasks,
@@ -281,8 +281,8 @@ test('runtime store persists duplicate events idempotently', () => {
281
281
  origin: 'test',
282
282
  runId: 'run-1',
283
283
  turnId: 'run-1:turn-0',
284
- workspace: 'juno',
285
- payload: { input: 'build wiki', workspace: 'juno' },
284
+ workspace: 'acme',
285
+ payload: { input: 'build wiki', workspace: 'acme' },
286
286
  });
287
287
 
288
288
  store.persistEvent(event);
@@ -292,7 +292,7 @@ test('runtime store persists duplicate events idempotently', () => {
292
292
  assert.equal(store.listEvents()[0].sequence, 1);
293
293
  assert.equal(store.getState().eventsCursor, 1);
294
294
  assert.equal(store.listRuns()[0].id, 'run-1');
295
- assert.equal(store.listRuns()[0].workspace, 'juno');
295
+ assert.equal(store.listRuns()[0].workspace, 'acme');
296
296
  assert.equal(store.listRuns()[0].status, 'running');
297
297
  store.close();
298
298
  });
@@ -590,22 +590,54 @@ test('runtime store persists cancelled run status', () => {
590
590
  origin: 'test',
591
591
  runId: 'run-1',
592
592
  turnId: 'run-1:turn-0',
593
- workspace: 'juno',
594
- payload: { input: 'build wiki', workspace: 'juno' },
593
+ workspace: 'acme',
594
+ payload: { input: 'build wiki', workspace: 'acme' },
595
595
  }));
596
596
  store.persistEvent(createAgentEvent('run_cancelled', {
597
597
  origin: 'test',
598
598
  runId: 'run-1',
599
599
  turnId: 'run-1:turn-1',
600
- workspace: 'juno',
601
- payload: { runId: 'run-1', workspace: 'juno' },
600
+ workspace: 'acme',
601
+ payload: { runId: 'run-1', workspace: 'acme' },
602
602
  }));
603
603
 
604
604
  assert.equal(store.listRuns()[0].status, 'cancelled');
605
- assert.equal(store.listRuns()[0].workspace, 'juno');
605
+ assert.equal(store.listRuns()[0].workspace, 'acme');
606
606
  store.close();
607
607
  });
608
608
 
609
+ test('runtime store interrupts runs by runId and does not recover killed runs after reopen', () => {
610
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
611
+ const store = openRuntimeStore({ stateDir });
612
+ for (const runId of ['run-kill', 'run-keep']) {
613
+ store.persistEvent(createAgentEvent('run_started', {
614
+ origin: 'test',
615
+ runId,
616
+ workspace: 'docs',
617
+ payload: { input: runId, workspace: 'docs' },
618
+ }));
619
+ store.persistEvent(createAgentEvent('task.created', {
620
+ origin: 'test',
621
+ runId,
622
+ taskId: `${runId}:task`,
623
+ workspace: 'docs',
624
+ payload: { runId, task: { ...plannedTask(`${runId}:task`), status: 'running' } },
625
+ }));
626
+ }
627
+
628
+ assert.equal(store.interruptRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
629
+ assert.equal(store.cancelActiveTasksForInterruptedRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
630
+ assert.equal(store.listRuns().find((run) => run.id === 'run-kill').status, 'interrupted');
631
+ assert.equal(store.listRuns().find((run) => run.id === 'run-keep').status, 'running');
632
+ assert.equal(store.listTasks({ runId: 'run-kill' })[0].status, 'cancelled');
633
+ assert.equal(store.listTasks({ runId: 'run-keep' })[0].status, 'running');
634
+ store.close();
635
+
636
+ const reopened = openRuntimeStore({ stateDir });
637
+ assert.deepEqual(reopened.listRecoverableRuns({ workspace: 'docs' }).map((run) => run.id), ['run-keep']);
638
+ reopened.close();
639
+ });
640
+
609
641
  test('runtime store migrates legacy databases without workspace columns', () => {
610
642
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
611
643
  const db = new DatabaseSync(join(stateDir, 'runtime.db'));
@@ -724,10 +756,10 @@ test('runtime store filters events runs and queue by workspace', () => {
724
756
  const store = openRuntimeStore({ stateDir });
725
757
  store.persistEvent(createAgentEvent('run_started', {
726
758
  origin: 'test',
727
- runId: 'run-juno',
728
- turnId: 'run-juno:turn-0',
729
- workspace: 'juno',
730
- payload: { input: 'juno', workspace: 'juno' },
759
+ runId: 'run-acme',
760
+ turnId: 'run-acme:turn-0',
761
+ workspace: 'acme',
762
+ payload: { input: 'acme', workspace: 'acme' },
731
763
  }));
732
764
  store.persistEvent(createAgentEvent('run_started', {
733
765
  origin: 'test',
@@ -736,12 +768,12 @@ test('runtime store filters events runs and queue by workspace', () => {
736
768
  workspace: 'docs',
737
769
  payload: { input: 'docs', workspace: 'docs' },
738
770
  }));
739
- store.saveQueue([{ id: 'q-juno', workspace: 'juno', status: 'waiting' }], { workspace: 'juno' });
771
+ store.saveQueue([{ id: 'q-acme', workspace: 'acme', status: 'waiting' }], { workspace: 'acme' });
740
772
  store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
741
773
 
742
- assert.deepEqual(store.listEvents({ workspace: 'juno' }).map((event) => event.runId), ['run-juno']);
774
+ assert.deepEqual(store.listEvents({ workspace: 'acme' }).map((event) => event.runId), ['run-acme']);
743
775
  assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-docs']);
744
- assert.deepEqual(store.listQueue({ workspace: 'juno' }).map((item) => item.id), ['q-juno']);
776
+ assert.deepEqual(store.listQueue({ workspace: 'acme' }).map((item) => item.id), ['q-acme']);
745
777
 
746
778
  const session = { activities: {}, headlessPlan: null };
747
779
  store.hydrateSession(session, { workspace: 'docs' });
@@ -794,8 +826,8 @@ test('runtime store persists and replays control queue events without mixing MCP
794
826
  }));
795
827
  store.persistEvent(createAgentEvent('control_enqueued', {
796
828
  origin: 'runtime',
797
- workspace: 'juno',
798
- payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
829
+ workspace: 'acme',
830
+ payload: { id: 'control-acme', workspace: 'acme', input: 'other' },
799
831
  }));
800
832
  store.close();
801
833
 
@@ -925,10 +957,10 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
925
957
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
926
958
  const store = openRuntimeStore({ stateDir });
927
959
  store.persistRun({
928
- id: 'run-juno',
929
- workspace: 'juno',
960
+ id: 'run-acme',
961
+ workspace: 'acme',
930
962
  status: 'running',
931
- input: 'build juno',
963
+ input: 'build acme',
932
964
  });
933
965
  store.persistRun({
934
966
  id: 'run-done',
@@ -938,9 +970,9 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
938
970
  });
939
971
  store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
940
972
 
941
- assert.deepEqual(store.listRecoverableWorkspaces(), ['docs', 'juno']);
942
- assert.deepEqual(store.listRecoverableRuns({ workspace: 'juno' }).map((run) => run.id), ['run-juno']);
943
- assert.equal(store.interruptRuns({ workspace: 'juno' }), 1);
944
- assert.equal(store.listRuns({ workspace: 'juno' })[0].status, 'interrupted');
973
+ assert.deepEqual(store.listRecoverableWorkspaces(), ['acme', 'docs']);
974
+ assert.deepEqual(store.listRecoverableRuns({ workspace: 'acme' }).map((run) => run.id), ['run-acme']);
975
+ assert.equal(store.interruptRuns({ workspace: 'acme' }), 1);
976
+ assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
945
977
  store.close();
946
978
  });
@@ -98,8 +98,55 @@ export async function pollActivitiesOnce(session, {
98
98
  payload: { activity: polledActivity },
99
99
  }));
100
100
  syncQueueWithActivity(session, polledActivity);
101
- emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}`);
101
+ // Only log when something actually changed: the poll fires every
102
+ // second and an unchanged "activity: X -> running" line repeated
103
+ // dozens of times drowned Logs/Trace and looked like a frozen system.
104
+ // The line itself carries the operational signal (current file,
105
+ // percent, batch, last trace event, retry/backoff) so the panel
106
+ // reads as a narrative of the job, not a heartbeat.
107
+ const progress = payload?.progress ?? polledActivity.progress ?? {};
108
+ const percent = Number(progress.percent ?? polledActivity.progress?.percent);
109
+ const detail = progress.detail ?? progress.step ?? '';
110
+ // The trace's batchIndex is 0-based and the human detail already says
111
+ // "Batch 1/2" — printing both gave "Batch 1/2 · batch 0/2". Display
112
+ // 1-based, and only when the detail doesn't already carry it.
113
+ const batch = progress.batchIndex != null && progress.batchCount != null && !/batch/i.test(String(detail))
114
+ ? `batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`
115
+ : null;
116
+ const lastEvent = progress.lastEvent ? String(progress.lastEvent) : null;
117
+ const retry = progress.retryAt
118
+ ? `retry ${progress.retryAt}`
119
+ : (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
120
+ const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
121
+ session._activityLogKeys ??= {};
122
+ if (session._activityLogKeys[key] !== progressKey) {
123
+ session._activityLogKeys[key] = progressKey;
124
+ const progressLabel = [
125
+ Number.isFinite(percent) ? `${Math.round(percent)}%` : null,
126
+ detail ? String(detail) : null,
127
+ batch,
128
+ lastEvent,
129
+ retry,
130
+ ].filter(Boolean).join(' · ');
131
+ emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}${progressLabel ? ` (${progressLabel})` : ''}`);
132
+ }
133
+ // Stream NEW job-log lines into Logs/Trace: document names, chunking,
134
+ // LLM call/token summaries and errors live in the job log, not in the
135
+ // status fields. Diff against the last line already shown.
136
+ const logTail = Array.isArray(payload?.logTail) ? payload.logTail.map(String) : [];
137
+ if (logTail.length > 0) {
138
+ session._activityLogCursors ??= {};
139
+ const lastSeen = session._activityLogCursors[key];
140
+ const lastIndex = lastSeen ? logTail.lastIndexOf(lastSeen) : -1;
141
+ for (const line of logTail.slice(lastIndex + 1)) {
142
+ const clean = line.trim();
143
+ if (clean) emitRuntimeLog(session, `job ${polledActivity.id ?? key}: ${clean}`);
144
+ }
145
+ session._activityLogCursors[key] = logTail.at(-1);
146
+ }
102
147
  if (polledActivity.terminal) {
148
+ delete session._activityLogKeys[key];
149
+ delete session._activityLogCursors?.[key];
103
150
  await startNextQueuedJob(session, {
104
151
  addLog: (message) => emitRuntimeLog(session, message),
105
152
  });
@@ -134,3 +181,32 @@ function registryIntervalFromEnv() {
134
181
  const value = Number(process.env.WIKI_MANAGER_AGENT_REGISTRY_INTERVAL_MS);
135
182
  return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60000;
136
183
  }
184
+
185
+ // Cancelling a runtime run must also cancel the asynchronous jobs it started:
186
+ // the run's abort only stopped the manager-side loop while the agent kept
187
+ // executing (an ingest subprocess ran on for minutes after "cancel requested",
188
+ // with the panels frozen on its last known state). Best effort: prefer the
189
+ // orchestration contract's agent_cancel, fall back to legacy *_cancel_job.
190
+ export async function cancelActiveActivityJobs(session, { callTool = callMcpTool } = {}) {
191
+ const cancelled = [];
192
+ for (const activity of sessionActivities(session)) {
193
+ if (activity.terminal || !activity.poll) continue;
194
+ const jobId = activity.poll.args?.jobId ?? activity.id ?? null;
195
+ const server = activity.poll.server;
196
+ const endpoint = session.mcp?.[server];
197
+ if (!jobId || !endpoint || endpoint.status !== 'connected') continue;
198
+ const names = (endpoint.tools ?? []).map((tool) => String(tool.name ?? ''));
199
+ const cancelTool = names.find((name) => /(^|__)agent_cancel$/.test(name))
200
+ ?? names.find((name) => /_cancel_job$/.test(name))
201
+ ?? names.find((name) => /(^|__)[a-z]*_?cancel$/.test(name));
202
+ if (!cancelTool) continue;
203
+ try {
204
+ await callTool(session.mcp, server, cancelTool, { jobId });
205
+ emitRuntimeLog(session, `cancel: requested ${server}.${cancelTool} for job ${jobId}`);
206
+ cancelled.push({ server, jobId });
207
+ } catch (err) {
208
+ emitRuntimeLog(session, `cancel: ${server}.${cancelTool} failed for ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+ }
211
+ return cancelled;
212
+ }
@@ -16,7 +16,9 @@ type QueueItem = {
16
16
  type QueueInfo = { active: number; current: number; frozen: number };
17
17
  type LogLineParts = { time: string | null; message: string };
18
18
 
19
- const ACTIVITY_SLOTS = Array.from({ length: 6 }, (_, index) => index);
19
+ // 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
20
+ // wrapped status/error), so fewer, readable entries beat more, truncated ones.
21
+ const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
20
22
  const LOG_SLOTS = Array.from({ length: 24 }, (_, index) => index);
21
23
 
22
24
  function wrapLine(value: string, width: number) {
@@ -70,7 +72,18 @@ function queueColor(status: string) {
70
72
  function activityLine(activity: any) {
71
73
  const detail = activity?.progress?.detail ?? null;
72
74
  const phase = activity?.progress?.step ?? activity?.progress?.phase ?? activity?.progress?.currentStep ?? null;
73
- return [activity?.status ?? 'unknown', detail ?? phase].filter(Boolean).join(' · ');
75
+ // On failure the error message is the information that matters — surface
76
+ // it instead of repeating the bare status the label color already conveys.
77
+ const error = activityErrorText(activity);
78
+ return [activity?.status ?? 'unknown', error ?? detail ?? phase].filter(Boolean).join(' · ');
79
+ }
80
+
81
+ function activityErrorText(activity: any): string | null {
82
+ const status = String(activity?.status ?? '').toLowerCase();
83
+ if (!['failed', 'error', 'cancelled', 'canceled'].includes(status)) return null;
84
+ const error = activity?.error ?? activity?.result?.error ?? activity?.progress?.detail ?? null;
85
+ const text = error == null ? '' : String(error).replace(/\s+/g, ' ').trim();
86
+ return text || null;
74
87
  }
75
88
 
76
89
  function activityPercentBadge(activity: any): { text: string; bg: string; fg: string } | null {
@@ -127,13 +140,19 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
127
140
  <box flexShrink={0} flexDirection="column" padding={1}>
128
141
  <text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
129
142
  <Index each={props.plan}>
130
- {(step) => (
131
- <text
132
- width={lineWidth()}
133
- fg={planStepColor(step(), firstPending())}
134
- content={fit(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth())}
135
- />
136
- )}
143
+ {(step) => {
144
+ // Wrap step descriptions over up to 2 lines instead of truncating —
145
+ // "Ingest des 39 documents raw/untrac…" hid the actual target.
146
+ const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, lineWidth()).slice(0, 2);
147
+ return (
148
+ <box flexDirection="column">
149
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
150
+ <Show when={lines()[1]}>
151
+ <text width={lineWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, lineWidth() - 4))}`} />
152
+ </Show>
153
+ </box>
154
+ );
155
+ }}
137
156
  </Index>
138
157
  </box>
139
158
  );
@@ -141,7 +160,7 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
141
160
 
142
161
  export function ActivityPanel(props: { activities: any[]; width: number }) {
143
162
  const lineWidth = () => Math.max(8, props.width - 2);
144
- const visible = () => props.activities.slice(-6).reverse();
163
+ const visible = () => props.activities.slice(-ACTIVITY_SLOTS.length).reverse();
145
164
  const activityAt = (index: number) => visible()[index] ?? null;
146
165
  return (
147
166
  <box flexShrink={0} flexDirection="column" padding={1}>
@@ -150,21 +169,39 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
150
169
  <Index each={ACTIVITY_SLOTS}>
151
170
  {(slot) => {
152
171
  const activity = () => activityAt(slot());
153
- const label = () => {
172
+ // Wrap instead of hard-truncating: a 40-column pane cut labels to
173
+ // "Appliquer la config recommandée (doct…" and hid the one thing
174
+ // that mattered. Labels get up to 2 lines, the status/error line
175
+ // up to 2 lines; empty continuation lines are not rendered.
176
+ const labelLines = () => {
154
177
  const item = activity();
155
- if (!item) return '';
156
- return fit(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`, lineWidth());
178
+ if (!item) return [''];
179
+ const label = String(item.progress?.label ?? item.label ?? `${item.source ?? 'mcp'} ${item.kind ?? 'job'}`);
180
+ const lines = wrapLine(label, lineWidth()).slice(0, 2);
181
+ if (lines.length === 2 && String(label).length > lines[0].length + lines[1].length) {
182
+ lines[1] = fit(lines[1], lineWidth());
183
+ }
184
+ return lines;
157
185
  };
158
186
  const badge = () => activityPercentBadge(activity());
159
187
  const badgeLen = () => badge()?.text.length ?? 0;
188
+ const statusLines = () => {
189
+ const item = activity();
190
+ if (!item) return [''];
191
+ return wrapLine(activityLine(item), Math.max(8, lineWidth() - badgeLen())).slice(0, 2);
192
+ };
193
+ const statusColor = () => (activityErrorText(activity()) ? '#F38BA8' : '#AAB7C4');
160
194
  return (
161
195
  <box flexDirection="column" marginTop={slot() === 0 ? 1 : 0}>
162
- <text width={lineWidth()} fg={activityColor(activity()?.status)} content={label()} />
196
+ <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[0]} />
197
+ <Show when={labelLines()[1]}>
198
+ <text width={lineWidth()} fg={activityColor(activity()?.status)} content={labelLines()[1]} />
199
+ </Show>
163
200
  <box height={1} flexDirection="row">
164
201
  <text
165
202
  width={lineWidth() - badgeLen()}
166
- fg="#AAB7C4"
167
- content={activity() ? fit(activityLine(activity()), lineWidth() - badgeLen()) : ''}
203
+ fg={statusColor()}
204
+ content={activity() ? fit(statusLines()[0], lineWidth() - badgeLen()) : ''}
168
205
  />
169
206
  <text
170
207
  width={badgeLen()}
@@ -173,6 +210,9 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
173
210
  content={badge()?.text ?? ''}
174
211
  />
175
212
  </box>
213
+ <Show when={statusLines()[1]}>
214
+ <text width={lineWidth()} fg={statusColor()} content={fit(statusLines()[1], lineWidth())} />
215
+ </Show>
176
216
  <text width={lineWidth()} fg="#7F8C8D" content={activity() ? fit(updatedLine(activity()), lineWidth()) : ''} />
177
217
  </box>
178
218
  );
@@ -183,31 +223,84 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
183
223
  );
184
224
  }
185
225
 
226
+ type LogSegment = { text: string; fg: string };
227
+
228
+ // Per-line coloring: 'runtime' source tag in violet, HH:MM:SS in blue,
229
+ // message tinted by nature (errors red, warnings amber, activity teal).
230
+ // Continuation lines of a wrapped entry are indented and dimmed so each
231
+ // entry reads as one visual block instead of an undifferentiated wall.
232
+ function logMessageColor(message: string): string {
233
+ if (/\b(error|failed|exception|unavailable|introuvable|HTTP 4\d\d|HTTP 5\d\d)\b/i.test(message)) return '#F38BA8';
234
+ if (/\b(warn|warning|avertissement|fallback|retry|expired|stale)\b/i.test(message)) return '#FBBF24';
235
+ if (/^(activity|job)\b/i.test(message)) return '#8BD5CA';
236
+ return '#AAB7C4';
237
+ }
238
+
239
+ function logRenderLines(logs: string[], width: number): LogSegment[][] {
240
+ // Newest entry FIRST (descending): the freshest information belongs at the
241
+ // top of the pane. Wrapped continuation lines stay attached below their
242
+ // entry's first line.
243
+ const blocks: LogSegment[][][] = [];
244
+ for (const raw of logs) {
245
+ blocks.push(logEntryLines(raw, width));
246
+ }
247
+ return blocks.reverse().flat();
248
+ }
249
+
250
+ function logEntryLines(raw: string, width: number): LogSegment[][] {
251
+ const out: LogSegment[][] = [];
252
+ for (const item of [raw]) {
253
+ const rawLine = item;
254
+ const sourceMatch = String(rawLine).match(/^(runtime)\s+(.*)$/);
255
+ const source = sourceMatch ? sourceMatch[1] : null;
256
+ const rest = sourceMatch ? sourceMatch[2] : String(rawLine);
257
+ const parts = logLineParts(rest);
258
+ const prefix: LogSegment[] = [];
259
+ if (source) prefix.push({ text: `${source} `, fg: '#C6A0F6' });
260
+ if (parts.time) prefix.push({ text: `${parts.time} `, fg: '#89B4FA' });
261
+ const prefixLength = prefix.reduce((total, segment) => total + segment.text.length, 0);
262
+ const messageColor = logMessageColor(parts.message);
263
+ const wrapped = wrapLine(parts.message, Math.max(8, width - prefixLength));
264
+ wrapped.forEach((text, index) => {
265
+ if (index === 0) {
266
+ out.push([...prefix, { text, fg: messageColor }]);
267
+ } else {
268
+ const indent = ' '.repeat(Math.min(prefixLength, 4));
269
+ out.push([{ text: `${indent}${text}`, fg: messageColor === '#F38BA8' ? messageColor : '#7F8C8D' }]);
270
+ }
271
+ });
272
+ }
273
+ return out;
274
+ }
275
+
186
276
  export function LogPanel(props: { logs: string[]; width: number; filter?: string }) {
187
277
  const lineWidth = () => Math.max(8, props.width - 2);
188
278
  const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '');
189
- const visibleLines = () => filteredLogs().flatMap((line) => wrapLine(line, lineWidth())).slice(-24);
190
- const logLineAt = (index: number) => visibleLines()[index] ?? '';
279
+ const visibleLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()).slice(0, 24));
280
+ const segmentsAt = (index: number): LogSegment[] => visibleLines()[index] ?? [];
191
281
  return (
192
282
  <box flexGrow={1} flexDirection="column" padding={1}>
193
283
  <text width={lineWidth()} fg="#D6DEE8" content="Logs / Trace" />
194
284
  <box flexGrow={1} flexDirection="column" overflow="hidden">
195
285
  <Index each={LOG_SLOTS}>
196
286
  {(slot) => {
197
- const line = () => logLineAt(slot());
198
- const parts = createMemo(() => logLineParts(line()));
199
- const timeWidth = () => parts().time ? Math.min(parts().time!.length + 1, lineWidth()) : 0;
200
- const messageWidth = () => Math.max(1, lineWidth() - timeWidth());
287
+ const segments = () => segmentsAt(slot());
288
+ const segmentAt = (position: number): LogSegment => segments()[position] ?? { text: '', fg: '#AAB7C4' };
289
+ const usedWidth = (upTo: number) => segments().slice(0, upTo).reduce((total, segment) => total + segment.text.length, 0);
290
+ const widthFor = (position: number) => {
291
+ const segment = segmentAt(position);
292
+ if (!segment.text) return 0;
293
+ // Last visible segment absorbs the remaining width.
294
+ return position === segments().length - 1
295
+ ? Math.max(1, lineWidth() - usedWidth(position))
296
+ : Math.min(segment.text.length, lineWidth() - usedWidth(position));
297
+ };
201
298
  return (
202
- <Show
203
- when={parts().time}
204
- fallback={<text width={lineWidth()} fg="#AAB7C4" content={line()} />}
205
- >
206
- <box height={1} flexDirection="row" overflow="hidden">
207
- <text width={timeWidth()} fg="#89B4FA" content={`${parts().time} `} />
208
- <text width={messageWidth()} fg="#AAB7C4" content={parts().message} />
209
- </box>
210
- </Show>
299
+ <box height={1} flexDirection="row" overflow="hidden">
300
+ <text width={widthFor(0)} fg={segmentAt(0).fg} content={segmentAt(0).text} />
301
+ <text width={widthFor(1)} fg={segmentAt(1).fg} content={segmentAt(1).text} />
302
+ <text width={widthFor(2)} fg={segmentAt(2).fg} content={segmentAt(2).text} />
303
+ </box>
211
304
  );
212
305
  }}
213
306
  </Index>
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
- import { useKeyboard } from '@opentui/solid';
4
+ import { useKeyboard, usePaste } from '@opentui/solid';
5
5
  import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
6
6
  import { fallbackModels, normalizeProvider } from '../core/modelFetch.js';
7
7
  import {
@@ -606,6 +606,18 @@ export function SetupWizard(props: {
606
606
  } catch { return ''; }
607
607
  }
608
608
 
609
+ // Terminal paste (Cmd+V/middle-click) arrives as a bracketed-paste block.
610
+ // openTUI's key parser swallows the \x1b[200~/\x1b[201~ markers and emits a
611
+ // dedicated `paste` event instead of key events — the text NEVER reaches
612
+ // useKeyboard, so the legacy bracketed-paste branch below can't fire.
613
+ usePaste((event: any) => {
614
+ if (busy() || step().kind !== 'text') return;
615
+ const raw = event?.text
616
+ ?? (event?.bytes != null ? Buffer.from(event.bytes).toString('utf8') : '');
617
+ const pasted = String(raw).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n').join(' ');
618
+ if (pasted) setInput((value) => value + pasted);
619
+ });
620
+
609
621
  useKeyboard((key: any) => {
610
622
  if (busy()) return;
611
623
  const s = step();