@dotdrelle/wiki-manager 0.15.25 → 0.15.27

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 (38) hide show
  1. package/.env.example +18 -5
  2. package/README.md +35 -13
  3. package/agents.docker-compose.override.example.yml +82 -0
  4. package/agents.docker-compose.yml +14 -2
  5. package/docker-compose.override.example.yml +81 -0
  6. package/docker-compose.yml +23 -17
  7. package/package.json +4 -2
  8. package/src/cli/wiki-manager.js +3 -1
  9. package/src/commands/slash.js +53 -39
  10. package/src/core/agentEvents.js +20 -0
  11. package/src/core/agentEvents.test.js +38 -1
  12. package/src/core/buildInfo.json +2 -2
  13. package/src/core/commandFailure.js +81 -0
  14. package/src/core/commandFailure.test.js +67 -0
  15. package/src/core/compose.js +20 -3
  16. package/src/core/composeOverrides.test.js +74 -0
  17. package/src/core/dockerCompose.test.js +69 -2
  18. package/src/core/env.js +59 -0
  19. package/src/core/env.test.js +33 -1
  20. package/src/core/mcp.js +1 -1
  21. package/src/core/setEnvValue.test.js +96 -0
  22. package/src/runtime/client.js +18 -0
  23. package/src/runtime/lifecycle.js +5 -1
  24. package/src/runtime/server.js +43 -1
  25. package/src/runtime/server.test.js +117 -0
  26. package/src/runtime/store.js +47 -0
  27. package/src/runtime/store.test.js +89 -1
  28. package/src/shell/LeftPane.tsx +46 -16
  29. package/src/shell/RightPane.tsx +2 -4
  30. package/src/shell/SetupWizard.tsx +19 -7
  31. package/src/shell/externalLinks.js +24 -2
  32. package/src/shell/openExternal.js +53 -0
  33. package/src/shell/repl.js +33 -10
  34. package/src/shell/repl.test.js +91 -9
  35. package/src/shell/setupWizardPlaceholders.test.js +66 -0
  36. package/src/shell/tui.tsx +54 -25
  37. package/src/shell/useSession.ts +42 -6
  38. package/wiki-workspace +84 -6
@@ -150,6 +150,24 @@ export async function postRuntimeCancel({
150
150
  return response.json();
151
151
  }
152
152
 
153
+ // Redo support: keeps the conversation entry at `index` and drops everything
154
+ // the runtime recorded after it. Returns the server's payload so a caller can
155
+ // distinguish "nothing to truncate" from "a run is still active".
156
+ export async function postRuntimeConversationTruncate(index, {
157
+ url = runtimeUrlFromEnv(),
158
+ token = runtimeToken(),
159
+ workspace = null,
160
+ } = {}) {
161
+ const response = await fetch(runtimeEndpoint(url, '/conversation/truncate', workspace), {
162
+ method: 'POST',
163
+ headers: { ...runtimeHeaders(token), 'content-type': 'application/json' },
164
+ body: JSON.stringify({ index, ...(workspace ? { workspace } : {}) }),
165
+ });
166
+ const payload = await response.json().catch(() => ({}));
167
+ if (!response.ok) return { truncated: false, reason: payload?.reason ?? `http_${response.status}` };
168
+ return payload;
169
+ }
170
+
153
171
  export async function postRuntimeKill({
154
172
  url = runtimeUrlFromEnv(),
155
173
  token = runtimeToken(),
@@ -55,7 +55,11 @@ export async function assertRuntimeNode(executable = runtimeNodeExecutable()) {
55
55
  }
56
56
 
57
57
  export async function ensureRuntime({
58
- host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1',
58
+ // 0.0.0.0, like `wiki-workspace runtime up` has always used: the shell's
59
+ // autostarted runtime used to bind 127.0.0.1, so `serve` in Docker got
60
+ // ECONNREFUSED on host.docker.internal and the workspace UI reported no
61
+ // runtime. Exposing the port always generates an auth token.
62
+ host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
59
63
  port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
60
64
  stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
61
65
  url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
3
+ import { conversationEventSequences, createAgentEvent, dispatchAgentEvent, resetSessionProjection, reduceAgentEvents } from '../core/agentEvents.js';
4
4
  import { activeCacertPath } from '../core/cacert.js';
5
5
  import { normalizePlanPatch, rebasePlanPatch } from '../core/planPatch.js';
6
6
  import { validateContractInDev } from '../contracts/schemas.js';
@@ -420,6 +420,48 @@ export function startRuntimeServer({
420
420
  });
421
421
  return;
422
422
  }
423
+ // Redo: keep the conversation entry at `index` (the question) and drop
424
+ // everything the runtime recorded after it. The conversation is derived
425
+ // from the event log, so a UI-side deletion alone would be undone by the
426
+ // next /state merge — the truncation has to happen here.
427
+ if (request.method === 'POST' && url.pathname === '/conversation/truncate') {
428
+ const { body, workspace, context } = await resolveBodyContext(request, url);
429
+ if (context?.running) {
430
+ // Truncating under a live run would delete events it is still
431
+ // appending to. The caller cancels first.
432
+ sendJson(response, 409, { truncated: false, reason: 'run_active' });
433
+ return;
434
+ }
435
+ const resolvedWorkspace = context?.workspace ?? workspace ?? null;
436
+ if (!resolvedWorkspace) {
437
+ // Unscoped, redo would truncate every workspace's event history —
438
+ // never allow the global/no-workspace conversation to reach it.
439
+ sendJson(response, 400, { truncated: false, reason: 'workspace_required' });
440
+ return;
441
+ }
442
+ const index = Number(body.index);
443
+ if (!Number.isInteger(index) || index < 0) {
444
+ sendJson(response, 400, { truncated: false, reason: 'invalid_index' });
445
+ return;
446
+ }
447
+ const events = store.listEvents({ workspace: resolvedWorkspace });
448
+ const sequences = conversationEventSequences(events);
449
+ const boundary = sequences[index];
450
+ if (!Number.isFinite(boundary)) {
451
+ sendJson(response, 400, { truncated: false, reason: 'index_out_of_range' });
452
+ return;
453
+ }
454
+ const removedEvents = store.deleteEventsAfter(boundary, { workspace: resolvedWorkspace });
455
+ // getState prefers the in-memory projection over the event log, so the
456
+ // deleted answers would survive in RAM without this rehydration.
457
+ if (context?.session) {
458
+ resetSessionProjection(context.session);
459
+ store.hydrateSession(context.session, { workspace: resolvedWorkspace });
460
+ }
461
+ publishState(resolvedWorkspace, context);
462
+ sendJson(response, 200, { truncated: true, index, removedEvents });
463
+ return;
464
+ }
423
465
  if (request.method === 'POST' && url.pathname === '/resume') {
424
466
  const workspace = workspaceFromUrl(url);
425
467
  const result = await resume?.({ workspace });
@@ -1614,3 +1614,120 @@ test('runtime health reports active runs across workspaces', async (t) => {
1614
1614
  await handle.close();
1615
1615
  }
1616
1616
  });
1617
+
1618
+ test('redo truncation drops the aftermath of one question and refuses during a run', async (t) => {
1619
+ const events = [
1620
+ { ...createAgentEvent('user_message', { origin: 'user', payload: { content: 'question' } }), sequence: 1 },
1621
+ { ...createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'answer' } }), sequence: 2 },
1622
+ ];
1623
+ let deleted = null;
1624
+ let hydrated = false;
1625
+ let running = true;
1626
+ const store = {
1627
+ dbPath: ':memory:',
1628
+ getState: () => ({ status: 'idle' }),
1629
+ listEvents: () => events,
1630
+ deleteEventsAfter: (sequence, options) => { deleted = { sequence, ...options }; return 1; },
1631
+ hydrateSession: () => { hydrated = true; },
1632
+ };
1633
+
1634
+ let handle;
1635
+ try {
1636
+ handle = await startRuntimeServer({
1637
+ host: '127.0.0.1',
1638
+ port: 0,
1639
+ store,
1640
+ session: {},
1641
+ getContext: async () => ({ workspace: 'demo', session: {}, running }),
1642
+ run: async () => {},
1643
+ });
1644
+ } catch (err) {
1645
+ if (err?.code === 'EPERM') {
1646
+ t.skip('network listen is not permitted in this sandbox');
1647
+ return;
1648
+ }
1649
+ throw err;
1650
+ }
1651
+
1652
+ try {
1653
+ const url = `http://127.0.0.1:${handle.port}/conversation/truncate`;
1654
+ const post = (body) => fetch(url, {
1655
+ method: 'POST',
1656
+ headers: { 'content-type': 'application/json' },
1657
+ body: JSON.stringify(body),
1658
+ });
1659
+
1660
+ // Truncating under a live run would delete events it is still appending.
1661
+ const active = await post({ index: 0 });
1662
+ assert.equal(active.status, 409);
1663
+ assert.equal((await active.json()).reason, 'run_active');
1664
+ assert.equal(deleted, null);
1665
+
1666
+ running = false;
1667
+ const outOfRange = await post({ index: 9 });
1668
+ assert.equal(outOfRange.status, 400);
1669
+ assert.equal((await outOfRange.json()).reason, 'index_out_of_range');
1670
+
1671
+ const invalid = await post({ index: -1 });
1672
+ assert.equal(invalid.status, 400);
1673
+ assert.equal((await invalid.json()).reason, 'invalid_index');
1674
+
1675
+ const ok = await post({ index: 0 });
1676
+ assert.equal(ok.status, 200);
1677
+ assert.deepEqual(await ok.json(), { truncated: true, index: 0, removedEvents: 1 });
1678
+ // Keeps the question (sequence 1) and drops everything recorded after it.
1679
+ assert.deepEqual(deleted, { sequence: 1, workspace: 'demo' });
1680
+ // getState prefers the in-memory projection, so the deleted answers would
1681
+ // survive in RAM without this rehydration.
1682
+ assert.equal(hydrated, true);
1683
+ } finally {
1684
+ await handle.close();
1685
+ }
1686
+ });
1687
+
1688
+ test('redo truncation refuses when no workspace can be resolved', async (t) => {
1689
+ // The global/no-workspace conversation is a real reachable mode (chatting
1690
+ // before /use <workspace>). Without this guard the handler would fall
1691
+ // through to an unscoped store.deleteEventsAfter, wiping every workspace's
1692
+ // event history for one redo click.
1693
+ let deleted = null;
1694
+ const store = {
1695
+ dbPath: ':memory:',
1696
+ getState: () => ({ status: 'idle' }),
1697
+ listEvents: () => [],
1698
+ deleteEventsAfter: (sequence, options) => { deleted = { sequence, ...options }; return 1; },
1699
+ hydrateSession: () => {},
1700
+ };
1701
+
1702
+ let handle;
1703
+ try {
1704
+ handle = await startRuntimeServer({
1705
+ host: '127.0.0.1',
1706
+ port: 0,
1707
+ store,
1708
+ session: {},
1709
+ getContext: async () => ({ workspace: null, session: {}, running: false }),
1710
+ run: async () => {},
1711
+ });
1712
+ } catch (err) {
1713
+ if (err?.code === 'EPERM') {
1714
+ t.skip('network listen is not permitted in this sandbox');
1715
+ return;
1716
+ }
1717
+ throw err;
1718
+ }
1719
+
1720
+ try {
1721
+ const url = `http://127.0.0.1:${handle.port}/conversation/truncate`;
1722
+ const response = await fetch(url, {
1723
+ method: 'POST',
1724
+ headers: { 'content-type': 'application/json' },
1725
+ body: JSON.stringify({ index: 0 }),
1726
+ });
1727
+ assert.equal(response.status, 400);
1728
+ assert.equal((await response.json()).reason, 'workspace_required');
1729
+ assert.equal(deleted, null);
1730
+ } finally {
1731
+ await handle.close();
1732
+ }
1733
+ });
@@ -676,6 +676,52 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
676
676
  return tasks.length;
677
677
  }
678
678
 
679
+ // Drops every event strictly after `sequence`, i.e. everything that happened
680
+ // after one conversation entry. Backs the redo action: the question stays,
681
+ // the answers, plan steps, activities and logs it produced are removed.
682
+ // Scoped to the workspace so a redo in one workspace never touches another —
683
+ // requires a real workspace; callers must resolve one before calling this,
684
+ // an unscoped call would otherwise touch every workspace's history.
685
+ // Destructive: these events also feed listAuditTrail, so a redo erases that
686
+ // stretch of audit history too. Runs entirely produced by the deleted range
687
+ // (no event at/before the boundary) are removed as well, cascading their
688
+ // task_groups/tasks/task_dependencies/plan_revisions via ON DELETE CASCADE;
689
+ // the non-cascading side tables (task_assignments/task_attempts/
690
+ // task_results/approval_grants) are cleared explicitly, same as
691
+ // clearWorkspaceState below.
692
+ function deleteEventsAfter(sequence, { workspace = null } = {}) {
693
+ const boundary = Number(sequence);
694
+ if (!Number.isFinite(boundary) || !workspace) return 0;
695
+ const orphanedRunIds = db.prepare(`
696
+ SELECT DISTINCT run_id FROM events
697
+ WHERE sequence > ? AND workspace = ? AND run_id IS NOT NULL
698
+ AND run_id NOT IN (
699
+ SELECT run_id FROM events WHERE sequence <= ? AND workspace = ? AND run_id IS NOT NULL
700
+ )
701
+ `).all(boundary, workspace, boundary, workspace).map((row) => row.run_id);
702
+ db.exec('BEGIN');
703
+ try {
704
+ const delResults = db.prepare('DELETE FROM task_results WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
705
+ const delAssignments = db.prepare('DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE run_id = ?)');
706
+ const delAttempts = db.prepare('DELETE FROM task_attempts WHERE run_id = ?');
707
+ const delApprovals = db.prepare('DELETE FROM approval_grants WHERE run_id = ?');
708
+ const delRun = db.prepare('DELETE FROM runs WHERE id = ?');
709
+ for (const runId of orphanedRunIds) {
710
+ delResults.run(runId);
711
+ delAssignments.run(runId);
712
+ delAttempts.run(runId);
713
+ delApprovals.run(runId);
714
+ delRun.run(runId);
715
+ }
716
+ const changes = db.prepare('DELETE FROM events WHERE sequence > ? AND workspace = ?').run(boundary, workspace).changes ?? 0;
717
+ db.exec('COMMIT');
718
+ return changes;
719
+ } catch (error) {
720
+ db.exec('ROLLBACK');
721
+ throw error;
722
+ }
723
+ }
724
+
679
725
  // Full wipe of a workspace's persisted runtime state: runs (and, via the
680
726
  // ON DELETE CASCADE foreign keys, their task_groups/tasks/task_dependencies/
681
727
  // plan_revisions/approval_grants), the event log, and the queue. The
@@ -1241,6 +1287,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
1241
1287
  interruptRuns,
1242
1288
  cancelActiveTasksForInterruptedRuns,
1243
1289
  clearWorkspaceState,
1290
+ deleteEventsAfter,
1244
1291
  saveQueue,
1245
1292
  listQueue,
1246
1293
  listTasks,
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import test from 'node:test';
7
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
7
+ import { conversationEventSequences, createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
8
8
  import { openRuntimeStore } from './store.js';
9
9
 
10
10
  function runtimeStateDir() {
@@ -1018,3 +1018,91 @@ test('runtime store identifies recoverable workspaces and interrupts stale runs'
1018
1018
  assert.equal(store.listRuns({ workspace: 'acme' })[0].status, 'interrupted');
1019
1019
  store.close();
1020
1020
  });
1021
+
1022
+ test('deleteEventsAfter removes one question\'s aftermath and nothing else', () => {
1023
+ const stateDir = runtimeStateDir();
1024
+ const store = openRuntimeStore({ stateDir });
1025
+ const persist = (type, payload, workspace) => store.persistEvent({
1026
+ ...createAgentEvent(type, { origin: 'runtime', payload }),
1027
+ workspace,
1028
+ });
1029
+
1030
+ persist('user_message', { content: 'first question' }, 'docs');
1031
+ persist('assistant_message', { content: 'first answer' }, 'docs');
1032
+ persist('user_message', { content: 'second question' }, 'docs');
1033
+ persist('assistant_message', { content: 'second answer' }, 'docs');
1034
+ // A redo in one workspace must never touch another's history.
1035
+ persist('user_message', { content: 'other workspace' }, 'acme');
1036
+
1037
+ const events = store.listEvents({ workspace: 'docs' });
1038
+ const sequences = conversationEventSequences(events);
1039
+ // Keep "second question" (index 2), drop everything recorded after it.
1040
+ const removed = store.deleteEventsAfter(sequences[2], { workspace: 'docs' });
1041
+
1042
+ assert.equal(removed, 1);
1043
+ assert.deepEqual(
1044
+ store.getProjection({ workspace: 'docs' }).conversation.map((message) => message.content),
1045
+ ['first question', 'first answer', 'second question'],
1046
+ );
1047
+ assert.equal(store.listEvents({ workspace: 'acme' }).length, 1);
1048
+ // A boundary that does not exist is a no-op rather than a silent wipe.
1049
+ assert.equal(store.deleteEventsAfter(undefined, { workspace: 'docs' }), 0);
1050
+ assert.equal(store.listEvents({ workspace: 'docs' }).length, 3);
1051
+ store.close();
1052
+ });
1053
+
1054
+ test('deleteEventsAfter without a workspace is a no-op, never an unscoped wipe', () => {
1055
+ const stateDir = runtimeStateDir();
1056
+ const store = openRuntimeStore({ stateDir });
1057
+ store.persistEvent(createAgentEvent('user_message', { origin: 'user', workspace: 'docs', payload: { content: 'q' } }));
1058
+ store.persistEvent(createAgentEvent('user_message', { origin: 'user', workspace: 'acme', payload: { content: 'q' } }));
1059
+
1060
+ assert.equal(store.deleteEventsAfter(0, { workspace: null }), 0);
1061
+ assert.equal(store.deleteEventsAfter(0, {}), 0);
1062
+ assert.equal(store.listEvents({ workspace: 'docs' }).length, 1);
1063
+ assert.equal(store.listEvents({ workspace: 'acme' }).length, 1);
1064
+ store.close();
1065
+ });
1066
+
1067
+ test('deleteEventsAfter removes bookkeeping only for runs entirely produced by the deleted range', () => {
1068
+ const stateDir = runtimeStateDir();
1069
+ const store = openRuntimeStore({ stateDir });
1070
+ const taskEvents = (runId, taskId) => [
1071
+ createAgentEvent('run_started', {
1072
+ origin: 'runtime', runId, workspace: 'docs', payload: { input: 'build docs', workspace: 'docs' },
1073
+ }),
1074
+ createAgentEvent('task.created', {
1075
+ origin: 'plan_integrator', runId, taskId, workspace: 'docs', payload: { runId, task: plannedTask(taskId) },
1076
+ }),
1077
+ createAgentEvent('task.assigned', {
1078
+ origin: 'assignment_manager', runId, taskId, workspace: 'docs',
1079
+ payload: { runId, taskId, attemptId: `${taskId}:attempt-1`, assignment: {
1080
+ attemptId: `${taskId}:attempt-1`, agentInstanceId: 'production-main', agentId: 'production', poolId: 'production-pool',
1081
+ } },
1082
+ }),
1083
+ createAgentEvent('task.started', {
1084
+ origin: 'dispatcher', runId, taskId, workspace: 'docs',
1085
+ payload: { runId, taskId, attemptId: `${taskId}:attempt-1`, jobId: 'job-1', startedAt: '2026-07-07T10:00:00.000Z' },
1086
+ }),
1087
+ ];
1088
+
1089
+ // A run that completed before the redo boundary must survive untouched.
1090
+ for (const event of taskEvents('run-before', 'run-before:build-a')) store.persistEvent(event);
1091
+ const boundarySequence = store.listEvents({ workspace: 'docs' }).at(-1).sequence;
1092
+ // A run triggered by the redone question — entirely after the boundary — is
1093
+ // what redo is dropping, so its bookkeeping should go with it.
1094
+ for (const event of taskEvents('run-after', 'run-after:build-a')) store.persistEvent(event);
1095
+
1096
+ assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM runs').get().n, 2);
1097
+ assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_assignments').get().n, 2);
1098
+ assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 2);
1099
+
1100
+ store.deleteEventsAfter(boundarySequence, { workspace: 'docs' });
1101
+
1102
+ assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-before']);
1103
+ assert.deepEqual(store.listTasks({ runId: 'run-before' }).map((task) => task.id), ['run-before:build-a']);
1104
+ assert.equal(store.listTasks({ runId: 'run-after' }).length, 0);
1105
+ assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_assignments').get().n, 1);
1106
+ assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM task_attempts').get().n, 1);
1107
+ store.close();
1108
+ });
@@ -1,9 +1,10 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { For, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
2
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
3
3
  import { StyledText, bg as styledBg, fg as styledFg, link as styledLink } from '@opentui/core';
4
- import { execFile } from 'node:child_process';
4
+ import { useRenderer } from '@opentui/solid';
5
5
  import { colorForRenderedLine, helpCommandParts, keyValueParts, renderPlainMarkdown } from './renderer';
6
6
  import { httpLinkParts, wrapHttpLinks } from './externalLinks.js';
7
+ import { normalizeExternalUrl } from './openExternal.js';
7
8
 
8
9
  const LEGACY_DONNA_ROLE = 'do' + 't';
9
10
 
@@ -62,17 +63,11 @@ function styledSegments(segments: Segment[]) {
62
63
  }));
63
64
  }
64
65
 
65
- function openSingleLineLink(segments: Segment[]) {
66
+ // A row carries a link only when it points at exactly one target; two links on
67
+ // the same line would make a click ambiguous.
68
+ function singleLineUrl(segments: Segment[]) {
66
69
  const urls = [...new Set(segments.map((segment) => segment.url).filter(Boolean))] as string[];
67
- if (urls.length !== 1) return;
68
- try {
69
- const parsed = new URL(urls[0]);
70
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
71
- const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
72
- execFile(opener, [parsed.toString()], () => {});
73
- } catch {
74
- // Invalid URLs remain copyable text and never reach the system opener.
75
- }
70
+ return urls.length === 1 ? normalizeExternalUrl(urls[0]) : null;
76
71
  }
77
72
  type RenderedLine = {
78
73
  segments: Segment[];
@@ -82,6 +77,8 @@ type RenderedLine = {
82
77
  statusLeft?: string;
83
78
  statusRight?: string;
84
79
  copyContent?: string;
80
+ redoContent?: string;
81
+ redoIndex?: number;
85
82
  };
86
83
  const STATUS_COLUMN_GAP = 2;
87
84
  type HelpCard = { title: string; text: string; example: string };
@@ -145,11 +142,15 @@ function WelcomeHelpPanels(props: { width: number }) {
145
142
  }
146
143
 
147
144
  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
148
 
149
- function messageHeaderSegments(role: string, columns: number): Segment[] {
149
+ function messageHeaderSegments(role: string, columns: number, withRedo = false): Segment[] {
150
150
  const label = `[${roleLabel(role)}]`;
151
151
  const left = '── ';
152
- const rightLength = Math.max(2, columns - left.length - label.length - 1 - COPY_BTN.length);
152
+ const actionsLength = COPY_BTN.length + (withRedo ? REDO_BTN.length : 0);
153
+ const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
153
154
  return [
154
155
  { text: left, color: '#4B5563' },
155
156
  { text: label, color: roleColor(role) },
@@ -415,9 +416,14 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
415
416
  const raw = String(message.content || '');
416
417
  const previous = index > 0 ? messages[index - 1] : null;
417
418
  const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
419
+ const canRedo = message.role === 'user' && raw.trim().length > 0;
418
420
  const headerLines: RenderedLine[] = showHeader
419
421
  ? [
420
- { segments: messageHeaderSegments(message.role, columns), copyContent: raw },
422
+ {
423
+ segments: messageHeaderSegments(message.role, columns, canRedo),
424
+ copyContent: raw,
425
+ ...(canRedo ? { redoContent: raw, redoIndex: index } : {}),
426
+ },
421
427
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
422
428
  ]
423
429
  : [];
@@ -466,7 +472,15 @@ export function ConversationView(props: {
466
472
  onScroll: (delta: number) => void;
467
473
  spinnerFrame: string;
468
474
  onCopy?: (content: string) => void;
475
+ onRedo?: (index: number, content: string) => void;
476
+ onOpenLink?: (url: string) => void;
469
477
  }) {
478
+ const renderer = useRenderer();
479
+ // Terminals have no CSS cursor, but OpenTUI can ask the emulator to swap the
480
+ // mouse pointer shape. Without it nothing on screen distinguishes a clickable
481
+ // link from ordinary blue text, so operators never discovered links were
482
+ // clickable at all.
483
+ const hoverLink = (url: string | null) => renderer?.setMousePointer?.(url ? 'pointer' : 'default');
470
484
  const allLines = createMemo(() => conversationLines(props.messages, props.columns));
471
485
  const visibleLines = () => {
472
486
  const lines = allLines();
@@ -511,6 +525,13 @@ export function ConversationView(props: {
511
525
  {(seg: Segment) => <text width={seg.width} fg={seg.color} bg={seg.bg}>{seg.text}</text>}
512
526
  </For>
513
527
  <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>
514
535
  </box>
515
536
  ) : line.role === 'user' && line.userBubbleWidth ? (
516
537
  <box height={1} flexDirection="row" overflow="hidden">
@@ -567,7 +588,12 @@ export function ConversationView(props: {
567
588
  height={1}
568
589
  flexDirection="row"
569
590
  overflow="hidden"
570
- onMouseUp={() => openSingleLineLink(line.segments)}
591
+ onMouseOver={() => hoverLink(singleLineUrl(line.segments))}
592
+ onMouseOut={() => hoverLink(null)}
593
+ onMouseUp={() => {
594
+ const url = singleLineUrl(line.segments);
595
+ if (url) props.onOpenLink?.(url);
596
+ }}
571
597
  >
572
598
  <text>{styledSegments(line.segments)}</text>
573
599
  </box>
@@ -748,6 +774,8 @@ export function LeftPane(props: {
748
774
  spinnerFrame: string;
749
775
  onInputHeightChange: (height: number) => void;
750
776
  onCopy?: (content: string) => void;
777
+ onRedo?: (index: number, content: string) => void;
778
+ onOpenLink?: (url: string) => void;
751
779
  }) {
752
780
  const modeColor = () => props.chatMode ? '#22C55E' : '#06B6D4';
753
781
  const modeLabel = () => props.chatMode ? 'CHAT MODE direct LLM, no tools' : 'AGENTIC MODE LangGraph + MCP tools';
@@ -777,6 +805,8 @@ export function LeftPane(props: {
777
805
  onScroll={props.scrollConversation}
778
806
  spinnerFrame={props.spinnerFrame}
779
807
  onCopy={props.onCopy}
808
+ onRedo={props.onRedo}
809
+ onOpenLink={props.onOpenLink}
780
810
  />
781
811
  )}
782
812
  <ChatInput
@@ -458,8 +458,7 @@ export function QueuePanel(props: { items: QueueItem[]; info: QueueInfo; width:
458
458
  );
459
459
  }
460
460
 
461
- function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number; width: number; onTabClick: (tab: 'plan' | 'queue') => void }) {
462
- const lineWidth = () => Math.max(8, props.width - 2);
461
+ function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number; onTabClick: (tab: 'plan' | 'queue') => void }) {
463
462
  const planActive = () => props.active === 'plan';
464
463
  return (
465
464
  <box height={1} flexDirection="row" paddingX={1}>
@@ -476,7 +475,6 @@ function TabHeader(props: { active: 'plan' | 'queue'; queueCount: number; width:
476
475
  content={` Queue (${props.queueCount}) `}
477
476
  onMouseUp={() => props.onTabClick('queue')}
478
477
  />
479
- <text fg="#7F8C8D" content={fit(' Ctrl+Q', Math.max(0, lineWidth() - 24))} />
480
478
  </box>
481
479
  );
482
480
  }
@@ -510,7 +508,7 @@ export function RightPane(props: {
510
508
  overflow="hidden"
511
509
  focusable={false}
512
510
  >
513
- <TabHeader active={props.activeTab} queueCount={props.queueInfo.active} width={props.width} onTabClick={props.onTabClick} />
511
+ <TabHeader active={props.activeTab} queueCount={props.queueInfo.active} onTabClick={props.onTabClick} />
514
512
  <Show when={props.pendingApprovals.length > 0}>
515
513
  <box height={2} flexDirection="column" border={['left']} borderStyle="heavy" borderColor="#FBBF24" paddingX={1}>
516
514
  <text fg="#FBBF24" content={`${props.pendingApprovals.length} approbation(s) requise(s)`} />
@@ -29,7 +29,18 @@ type Step =
29
29
  type LogEntry = { icon: string; label: string; detail?: string };
30
30
 
31
31
  const PROVIDERS = ['OpenAI', 'Anthropic', 'Ollama (local)', 'Other (OpenAI-compatible)'];
32
- const PLACEHOLDER_MODEL_RE = /YOUR_|<your/i;
32
+ // The scaffolded .wikirc.yaml ships fake endpoints and secrets so the file
33
+ // documents its own shape (`https://mon-provider.example.com/v1`,
34
+ // `http://infinity.local:7997/v1`, `YOUR_LLM_API_KEY`…). Preloading them into
35
+ // the wizard turned every field into a plausible-looking answer the operator
36
+ // had to notice and delete — and a skipped step silently wrote the fake value
37
+ // to the real config. Treat them as "not configured" instead.
38
+ const PLACEHOLDER_VALUE_RE = /YOUR_|<your|example\.com|infinity\.local/i;
39
+
40
+ function configuredValue(value: unknown) {
41
+ const text = typeof value === 'string' ? value.trim() : '';
42
+ return text && !PLACEHOLDER_VALUE_RE.test(text) ? text : null;
43
+ }
33
44
  const MAIN_MENU = ['Agents', 'Workspaces', 'LLM configuration', 'Vector search', '---', 'Close'];
34
45
 
35
46
  function defaultBaseUrl(provider: string) {
@@ -317,19 +328,20 @@ export function SetupWizard(props: {
317
328
  const { config } = loadWikircProfile(workspacePath, context?.profileName ?? 'default');
318
329
  if (config?.language) setLanguage(String(config.language));
319
330
  if (config?.llm?.provider) {
320
- const model = config.llm.model;
321
331
  setLlm({
322
332
  provider: normalizeProvider(config.llm.provider),
323
- baseUrl: config.llm.baseUrl,
324
- apiKey: config.llm.apiKey,
325
- model: (model && !PLACEHOLDER_MODEL_RE.test(model)) ? model : null,
333
+ baseUrl: configuredValue(config.llm.baseUrl),
334
+ apiKey: configuredValue(config.llm.apiKey),
335
+ model: configuredValue(config.llm.model),
326
336
  });
327
337
  }
328
338
  if (config?.retrieval?.vector) {
329
339
  setVector({
330
340
  provider: config.retrieval.vector.provider,
331
- baseUrl: config.retrieval.vector.baseUrl,
332
- apiKey: config.retrieval.vector.apiKey,
341
+ // Null here is what makes the embeddings step fall back to the base
342
+ // URL the operator just entered, instead of the scaffold's fake one.
343
+ baseUrl: configuredValue(config.retrieval.vector.baseUrl),
344
+ apiKey: configuredValue(config.retrieval.vector.apiKey),
333
345
  embeddingModel: config.retrieval.vector.embeddingModel,
334
346
  rerankEnabled: config.retrieval.vector.rerankEnabled,
335
347
  rerankerModel: config.retrieval.vector.rerankerModel,
@@ -35,14 +35,36 @@ export function httpLinkParts(value) {
35
35
  return parts.length > 0 ? parts : [{ text }];
36
36
  }
37
37
 
38
+ // The label the operator actually reads. It used to be `[link: <hostname>]`,
39
+ // which threw away the port and the path even when the whole URL fitted on the
40
+ // line: `/openui` printing `http://localhost:3100` was displayed as
41
+ // `[link: localhost]`, and nothing on screen said which port to open by hand.
42
+ // Show the real URL, and shorten it only when it genuinely does not fit.
43
+ export function linkLabel(url, width) {
44
+ const columns = Math.max(12, Number(width) || 12);
45
+ const text = String(url ?? '');
46
+ if (text.length <= columns) return text;
47
+ let host = '';
48
+ try {
49
+ // `host`, not `hostname`: the port is the part operators need most.
50
+ host = new URL(text).host;
51
+ } catch {
52
+ return text;
53
+ }
54
+ if (!host) return text;
55
+ const short = `${host}/…`;
56
+ // Never truncate below the host: a clipped `example.tes…` is unusable, while
57
+ // an overflowing host is still a real token the row simply clips.
58
+ return short.length <= columns ? short : host;
59
+ }
60
+
38
61
  export function wrapHttpLinks(value, width) {
39
62
  const columns = Math.max(12, Number(width) || 12);
40
63
  const rows = [[]];
41
64
  let used = 0;
42
65
  for (const part of httpLinkParts(value)) {
43
66
  if (part.url) {
44
- const parsed = new URL(part.url);
45
- const label = `[link: ${parsed.hostname}]`;
67
+ const label = linkLabel(part.url, columns);
46
68
  if (used > 0 && used + label.length > columns) {
47
69
  rows.push([]);
48
70
  used = 0;