@ordewell/cli 0.5.0 → 0.5.1

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 (2) hide show
  1. package/dist/main.js +290 -132
  2. package/package.json +3 -3
package/dist/main.js CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/main.ts
27
- var import_core20 = require("@ordewell/core");
27
+ var import_core21 = require("@ordewell/core");
28
28
 
29
29
  // src/utils/env.ts
30
30
  var import_fs = require("fs");
@@ -94,7 +94,7 @@ Planning:
94
94
  ordewell sessions list|load|delete Manage named sessions
95
95
  ordewell fork Continue in a copy of the conversation and its tasks
96
96
  ordewell compact Condense the conversation into a summary (tasks are kept)
97
- ordewell rewind [<n>] Rewind the conversation to before message <n> (no <n>: list them)
97
+ ordewell rewind [<n>] Fork the conversation from before message <n> (no <n>: list them)
98
98
 
99
99
  Tasks (<id> is an order number or a task ID):
100
100
  ordewell add-task --title "..." Add a task to the current plan
@@ -616,13 +616,17 @@ var ApiClient = class {
616
616
  }
617
617
  return res.data.targets;
618
618
  }
619
- /** Cut the conversation back to just before the user message at `index` (a transcript position). */
619
+ /**
620
+ * Fork the conversation from just before the user message at `index` (a
621
+ * transcript position) into a session the daemon has already adopted. The
622
+ * original is left as it was; `rewoundMessage` is that message in full.
623
+ */
620
624
  async rewindConversation(sessionId, index) {
621
625
  const res = await this.httpRequest("POST", `/api/plans/${sessionId}/conversation/rewind`, { index });
622
626
  if (res.status !== 200) {
623
627
  throw new Error(res.data?.error || "Rewind failed");
624
628
  }
625
- return res.data.plan;
629
+ return { sessionId: res.data.sessionId, goal: res.data.goal, plan: res.data.plan, rewoundMessage: res.data.rewoundMessage };
626
630
  }
627
631
  /**
628
632
  * Condense the conversation into a summary. One planner call, so it can take
@@ -1262,6 +1266,7 @@ var STATE_OF = {
1262
1266
  active: "active",
1263
1267
  merged: "integrated",
1264
1268
  conflict: "conflict",
1269
+ repairing: "repairing",
1265
1270
  // A failed integration keeps its refs exactly as a failed verdict does; the
1266
1271
  // user sees one thing: work that did not land and can be looked at.
1267
1272
  kept: "kept",
@@ -1293,19 +1298,25 @@ function isolationOfPlan(plan) {
1293
1298
  const state = STATE_OF[String(record.status)];
1294
1299
  if (!state) continue;
1295
1300
  const changed = changedRepos(record, legacy, state);
1301
+ const repairedFiles = Array.isArray(record.repairedFiles) && record.repairedFiles.length > 0 ? record.repairedFiles.map(String) : void 0;
1296
1302
  tasks[taskId] = {
1297
1303
  state,
1298
1304
  branch: String(record.branch ?? ""),
1299
1305
  worktree: String(record.workspace ?? record.worktree ?? ""),
1300
1306
  repos: changed,
1301
- ...typeof record.conflictRepo === "string" ? { conflictRepo: record.conflictRepo } : legacy && state === "conflict" ? { conflictRepo: "." } : {}
1307
+ ...typeof record.conflictRepo === "string" ? { conflictRepo: record.conflictRepo } : legacy && state === "conflict" ? { conflictRepo: "." } : {},
1308
+ ...Array.isArray(record.conflictFiles) && record.conflictFiles.length > 0 ? { conflictFiles: record.conflictFiles.map(String) } : {},
1309
+ // No `repair` (attempt/limit): `conflictRepairAttempts` is server config, never
1310
+ // sent in a persisted plan, so it cannot be reconstructed here. A live
1311
+ // `tasksStatus` update fills it in once the stream catches up.
1312
+ ...repairedFiles ? { repairedFiles } : {}
1302
1313
  };
1303
1314
  if (state === "integrated") {
1304
- landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed });
1315
+ landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed, ...repairedFiles ? { repairedFiles } : {} });
1305
1316
  }
1306
1317
  }
1307
1318
  landed.sort((a, b) => a.order - b.order);
1308
- const entry = ({ taskId, order, title }) => ({ taskId, order, title });
1319
+ const entry = ({ taskId, order, title, repairedFiles }) => ({ taskId, order, title, ...repairedFiles ? { repairedFiles } : {} });
1309
1320
  return {
1310
1321
  handoff: {
1311
1322
  repos: repos.map((repo) => ({ ...repo, landed: landed.filter((t) => t.changed.includes(repo.path)).map(entry) })),
@@ -1328,6 +1339,16 @@ function reposWithWork(handoff) {
1328
1339
  const withWork = handoff.repos.filter((r) => r.landed.length > 0);
1329
1340
  return (withWork.length > 0 ? withWork : handoff.repos).map((r) => r.path);
1330
1341
  }
1342
+ function repairedLanded(handoff) {
1343
+ return handoff.landed.filter((t) => t.repairedFiles?.length);
1344
+ }
1345
+ function repairedNotice(handoff) {
1346
+ const repaired = repairedLanded(handoff);
1347
+ if (repaired.length === 0) return "";
1348
+ const named = repaired.map((t) => `${t.title} (${(0, import_core7.capConflictFiles)(t.repairedFiles)})`);
1349
+ const list = named.length === 1 ? named[0] : `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]}`;
1350
+ return ` ${list} landed through ${repaired.length === 1 ? "a conflict repair" : "conflict repairs"}.`;
1351
+ }
1331
1352
  function repoResultLines(handoff) {
1332
1353
  return handoff.repos.map(({ path, landed }) => {
1333
1354
  const n = landed.length;
@@ -1337,12 +1358,12 @@ function repoResultLines(handoff) {
1337
1358
  function taskRepoNames(isolation) {
1338
1359
  return (isolation?.repos ?? []).filter((r) => r !== ".");
1339
1360
  }
1340
- function mergeOutcome(result, branch, group) {
1361
+ function mergeOutcome(result, branch, group, repaired = []) {
1341
1362
  const ok = result.outcome === "merged";
1342
1363
  if (!group && result.outcome === "conflict") {
1343
1364
  return { ok, message: `Merging ${branch} conflicted, so it was aborted \u2014 your tree is as it was. Merge it with git and resolve the conflict there.` };
1344
1365
  }
1345
- const { message } = (0, import_core7.describeMergeResult)(result, branch, group);
1366
+ const { message } = (0, import_core7.describeMergeResult)(result, branch, group, repaired);
1346
1367
  return { ok, message: ok || !group ? message : `${message} Each repository's ${branch} is a plain branch you can merge by hand.` };
1347
1368
  }
1348
1369
 
@@ -2384,7 +2405,7 @@ async function handleCompact(subArgs, injectedApi) {
2384
2405
  async function handleRewind(subArgs, injectedApi) {
2385
2406
  const arg = positionals(subArgs)[0];
2386
2407
  if (arg !== void 0 && !/^\d+$/.test(arg)) fail(REWIND_USAGE);
2387
- const { api, sessionId } = await adopted(subArgs, injectedApi);
2408
+ const { api, sessionId, workspace } = await adopted(subArgs, injectedApi);
2388
2409
  if (arg === void 0) {
2389
2410
  let targets;
2390
2411
  try {
@@ -2398,15 +2419,20 @@ async function handleRewind(subArgs, injectedApi) {
2398
2419
  }
2399
2420
  console.log("Your messages, most recent first:\n");
2400
2421
  for (const t of [...targets].reverse()) console.log(` ${String(t.index).padStart(4)} ${t.preview}`);
2401
- console.log("\n `ordewell rewind <n>` discards message <n> and everything after it. The tasks stay as they are.");
2422
+ console.log("\n `ordewell rewind <n>` forks the conversation from just before message <n>. The original is kept, and the tasks ride along.");
2402
2423
  return;
2403
2424
  }
2425
+ let fork;
2404
2426
  try {
2405
- await api.rewindConversation(sessionId, Number(arg));
2427
+ fork = await api.rewindConversation(sessionId, Number(arg));
2406
2428
  } catch (err) {
2407
2429
  fail(`Failed to rewind: ${err.message}`);
2408
2430
  }
2409
- console.log(`Rewound ${sessionId} to just before message ${arg}. The tasks are unchanged; the next message continues from there.`);
2431
+ saveLastSession(fork.sessionId, fork.goal, fork.plan.runners ?? [], workspace);
2432
+ console.log(`Forked ${sessionId} into ${fork.sessionId} from just before message ${arg} \u2014 the fork is now the current session.`);
2433
+ console.log(` \`ordewell sessions load ${sessionId}\` to go back to the original.`);
2434
+ console.log("\nThe message you rewound to, to resend or edit:\n");
2435
+ console.log(fork.rewoundMessage);
2410
2436
  }
2411
2437
 
2412
2438
  // src/commands/handoff.ts
@@ -2619,12 +2645,12 @@ function runHandoffAction(state, id) {
2619
2645
  overlay: isRepoGroup(handoff) ? {
2620
2646
  kind: "confirm",
2621
2647
  title: "Merge all into your branches?",
2622
- message: `Merge ${handoffBranch(handoff)} into whatever each of ${reposWithWork(handoff).join(", ")} has checked out. Ordewell never does this on its own. It checks every repository first and merges none unless every repository can take it; on git older than 2.38 it goes repository by repository and stops at the first that fails. A merge that conflicts is aborted, and your trees stay as they were.`,
2648
+ message: `Merge ${handoffBranch(handoff)} into whatever each of ${reposWithWork(handoff).join(", ")} has checked out. Ordewell never does this on its own. It checks every repository first and merges none unless every repository can take it; on git older than 2.38 it goes repository by repository and stops at the first that fails. A merge that conflicts is aborted, and your trees stay as they were. Once every repository has merged, the run's worktrees and branches are removed.${repairedNotice(handoff)}`,
2623
2649
  action: { kind: "merge-run" }
2624
2650
  } : {
2625
2651
  kind: "confirm",
2626
2652
  title: "Merge into your branch?",
2627
- message: `Merge ${handoffBranch(handoff)} into whatever you have checked out. Ordewell never does this on its own. If it conflicts the merge is aborted and your tree stays as it was.`,
2653
+ message: `Merge ${handoffBranch(handoff)} into whatever you have checked out. Ordewell never does this on its own. If it conflicts the merge is aborted and your tree stays as it was. Once it has merged, the run's worktrees and branches are removed.${repairedNotice(handoff)}`,
2628
2654
  action: { kind: "merge-run" }
2629
2655
  }
2630
2656
  },
@@ -2649,7 +2675,14 @@ function confirmedHandoff(state, kind) {
2649
2675
  const closed = { ...state, overlay: null };
2650
2676
  if (!state.sessionId || !state.handoff) return { state: closed, effects: [] };
2651
2677
  const { sessionId, handoff } = state;
2652
- const effect = kind === "merge-run" ? { type: "isolationMerge", sessionId, branch: handoffBranch(handoff), ...isRepoGroup(handoff) ? { group: true } : {} } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2678
+ const repaired = repairedLanded(handoff);
2679
+ const effect = kind === "merge-run" ? {
2680
+ type: "isolationMerge",
2681
+ sessionId,
2682
+ branch: handoffBranch(handoff),
2683
+ ...isRepoGroup(handoff) ? { group: true } : {},
2684
+ ...repaired.length > 0 ? { repaired } : {}
2685
+ } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2653
2686
  return { state: closed, effects: [effect] };
2654
2687
  }
2655
2688
  function showDiff(state, diff) {
@@ -2714,7 +2747,7 @@ function chooseBlocked(state, choice) {
2714
2747
  return { state: closed, effects: [{ type: "isolationContinue", sessionId, mode: choice }] };
2715
2748
  }
2716
2749
  function sameIsolation(a, b) {
2717
- return a?.state === b?.state && a?.branch === b?.branch && a?.worktree === b?.worktree && a?.conflictRepo === b?.conflictRepo && (a?.repos ?? []).join("\0") === (b?.repos ?? []).join("\0");
2750
+ return a?.state === b?.state && a?.branch === b?.branch && a?.worktree === b?.worktree && a?.conflictRepo === b?.conflictRepo && (a?.repos ?? []).join("\0") === (b?.repos ?? []).join("\0") && (a?.conflictFiles ?? []).join("\0") === (b?.conflictFiles ?? []).join("\0") && a?.repair?.attempt === b?.repair?.attempt && a?.repair?.limit === b?.repair?.limit && (a?.repairedFiles ?? []).join("\0") === (b?.repairedFiles ?? []).join("\0");
2718
2751
  }
2719
2752
  function mapTasks(tasks, map) {
2720
2753
  return tasks.map((task) => {
@@ -2787,8 +2820,8 @@ async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
2787
2820
  return;
2788
2821
  }
2789
2822
  case "merge": {
2790
- await confirmed(group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it.` : `Merge ${branch} into the branch you have checked out?`);
2791
- const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group);
2823
+ await confirmed((group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it; once all have, the run's worktrees and branches are removed.` : `Merge ${branch} into the branch you have checked out? Once merged, the run's worktrees and branches are removed.`) + repairedNotice(handoff));
2824
+ const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group, repairedLanded(handoff));
2792
2825
  if (ok) console.log(message);
2793
2826
  else fail(message);
2794
2827
  return;
@@ -3355,7 +3388,7 @@ Dependencies \xB7 #${task.order} ${task.title}
3355
3388
  }
3356
3389
 
3357
3390
  // src/tui/index.ts
3358
- var import_core19 = require("@ordewell/core");
3391
+ var import_core20 = require("@ordewell/core");
3359
3392
 
3360
3393
  // src/tui/editor.ts
3361
3394
  function emptyEditor() {
@@ -3525,7 +3558,7 @@ var SLASH_COMMANDS = [
3525
3558
  { name: "sessions", usage: "/sessions", description: "List saved sessions", category: "session" },
3526
3559
  { name: "fork", usage: "/fork", description: "Continue in a copy of this conversation and its tasks; the original stays as it is", category: "session" },
3527
3560
  { name: "compact", usage: "/compact", description: "Condense this conversation into a summary; the last two exchanges and all tasks are kept", category: "session" },
3528
- { name: "rewind", usage: "/rewind [<message>]", description: "Rewind the conversation to just before one of your messages (tasks are kept)", category: "session" },
3561
+ { name: "rewind", usage: "/rewind [<message>]", description: "Fork the conversation from just before one of your messages, after a confirmation (the original is kept)", category: "session" },
3529
3562
  { name: "new", usage: "/new", description: "Start a new session", category: "session" },
3530
3563
  { name: "save", usage: "/save", description: "Save the current session", category: "session" },
3531
3564
  { name: "load", usage: "/load <id>", description: "Load a saved session", category: "session" },
@@ -3951,12 +3984,9 @@ function chatBodyLines(messages, cols) {
3951
3984
  }
3952
3985
  function chatLayout(state, rows, cols) {
3953
3986
  const welcome = state.tasks.length === 0;
3954
- if (welcome && state.messages.length === 0) {
3955
- return { lines: welcomeLines(state, cols), anchor: "top", maxScroll: 0 };
3956
- }
3957
3987
  const body = chatBodyLines(state.messages, cols);
3958
- const lines = welcome ? [...welcomeLines(state, cols), "", ...body] : body;
3959
- return { lines, anchor: "bottom", maxScroll: Math.max(0, lines.length - rows) };
3988
+ const lines = !welcome ? body : state.messages.length === 0 ? welcomeLines(state, cols) : [...welcomeLines(state, cols), "", ...body];
3989
+ return { lines, anchor: lines.length > rows ? "bottom" : "top", maxScroll: Math.max(0, lines.length - rows) };
3960
3990
  }
3961
3991
  function chatScrollMax(state) {
3962
3992
  return chatLayout(state, bodyRows(state), chatPaneWidth(state)).maxScroll;
@@ -4073,33 +4103,69 @@ var STATUS_ICON = {
4073
4103
  pending: "\xB7"
4074
4104
  };
4075
4105
  var IDLE_ICON = "~";
4106
+ var FIRST_ROW_LINE = 2;
4076
4107
  function planLayout(state, rows, cols) {
4077
4108
  const done = state.tasks.filter((t) => t.status === "completed").length;
4078
4109
  const lines = [style.bold(`Plan ${done}/${state.tasks.length}`), ""];
4079
- let selectedLine = 2;
4110
+ const rowSpans = [];
4080
4111
  let editorLine;
4081
4112
  planRows(state).forEach((row, i) => {
4082
4113
  const taskStart = lines.length;
4083
- if (i === state.selectedTask) selectedLine = taskStart;
4084
4114
  const renderedTask = taskLines(state, row, i, cols);
4085
4115
  if (row.task.id === state.expandedTaskId && renderedTask.editorLine !== void 0) {
4086
4116
  editorLine = taskStart + renderedTask.editorLine;
4087
4117
  }
4088
4118
  lines.push(...renderedTask.lines);
4119
+ rowSpans.push({ start: taskStart, end: lines.length - 1 });
4089
4120
  });
4090
- const maxScroll = Math.max(0, lines.length - rows);
4091
- const anchorLine = editorLine ?? selectedLine;
4092
- return { lines, followOffset: Math.min(maxScroll, Math.max(0, anchorLine - rows + 3)), maxScroll };
4121
+ const selected = rowSpans[state.selectedTask] ?? { start: FIRST_ROW_LINE, end: FIRST_ROW_LINE };
4122
+ const anchor = editorLine === void 0 ? selected : { start: editorLine, end: editorLine };
4123
+ return { lines, rowSpans, anchor, rows, maxScroll: Math.max(0, lines.length - rows) };
4124
+ }
4125
+ var clampOffset = (layout, offset) => Math.max(0, Math.min(layout.maxScroll, offset));
4126
+ function revealOffset(layout, offset, span) {
4127
+ const at = clampOffset(layout, offset);
4128
+ const bodyHeight = layout.rows - 1;
4129
+ const first = span.start <= FIRST_ROW_LINE ? 0 : span.start - 1;
4130
+ if (span.start < 1 + at || span.end - span.start + 1 > bodyHeight) return clampOffset(layout, first);
4131
+ if (span.end > at + layout.rows - 1) return clampOffset(layout, span.end - layout.rows + 1);
4132
+ return at;
4093
4133
  }
4094
4134
  function planOffset(layout, planScroll) {
4095
- if (planScroll === null) return layout.followOffset;
4096
- return Math.max(0, Math.min(layout.maxScroll, planScroll));
4135
+ if (planScroll === null) return revealOffset(layout, 0, layout.anchor);
4136
+ return clampOffset(layout, planScroll);
4137
+ }
4138
+ function topRowInView(layout, offset) {
4139
+ const top = 1 + offset;
4140
+ const whole = layout.rowSpans.findIndex((span) => span.start >= top && span.end <= offset + layout.rows - 1);
4141
+ if (whole >= 0) return whole;
4142
+ const covering = layout.rowSpans.findIndex((span) => span.end >= top);
4143
+ return covering >= 0 ? covering : layout.rowSpans.length - 1;
4144
+ }
4145
+ function bottomRowInView(layout, offset) {
4146
+ const bottom = offset + layout.rows - 1;
4147
+ const spans = layout.rowSpans;
4148
+ for (let i = spans.length - 1; i >= 0; i--) {
4149
+ if (spans[i].start >= 1 + offset && spans[i].end <= bottom) return i;
4150
+ }
4151
+ for (let i = spans.length - 1; i >= 0; i--) {
4152
+ if (spans[i].start <= bottom) return i;
4153
+ }
4154
+ return 0;
4155
+ }
4156
+ function rowNearestView(layout, offset, row) {
4157
+ const span = layout.rowSpans[row];
4158
+ if (!span) return row;
4159
+ if (span.end < 1 + offset) return topRowInView(layout, offset);
4160
+ if (span.start > offset + layout.rows - 1) return bottomRowInView(layout, offset);
4161
+ return row;
4097
4162
  }
4098
4163
  function planScrollExtent(state) {
4099
4164
  const cols = planPaneWidth(state);
4100
- if (cols === 0) return { followOffset: 0, maxScroll: 0 };
4101
- const { followOffset, maxScroll } = planLayout(state, bodyRows(state), cols);
4102
- return { followOffset, maxScroll };
4165
+ if (cols === 0) {
4166
+ return { lines: [], rowSpans: [], anchor: { start: FIRST_ROW_LINE, end: FIRST_ROW_LINE }, rows: 0, maxScroll: 0 };
4167
+ }
4168
+ return planLayout(state, bodyRows(state), cols);
4103
4169
  }
4104
4170
  function taskLines(state, row, index, cols) {
4105
4171
  const { task, parent } = row;
@@ -4138,7 +4204,16 @@ function taskLines(state, row, index, cols) {
4138
4204
  if (effort || mode) lines.push(style.grey(truncate(`${bodyPad}${[effort, mode].filter(Boolean).join(" \xB7 ")}`, cols)));
4139
4205
  if (task.isolation?.state === "conflict") {
4140
4206
  const where = task.isolation.conflictRepo && task.isolation.conflictRepo !== "." ? ` in ${task.isolation.conflictRepo}` : "";
4141
- lines.push(style.red(truncate(`${bodyPad}\u26A0 merge conflict${where} \u2014 its work is kept on its own branch`, cols)));
4207
+ const files = task.isolation.conflictFiles?.length ? ` (${(0, import_core16.capConflictFiles)(task.isolation.conflictFiles)})` : "";
4208
+ lines.push(style.red(truncate(`${bodyPad}\u26A0 merge conflict${where}${files} \u2014 its work is kept on its own branch`, cols)));
4209
+ }
4210
+ if (task.isolation?.state === "repairing") {
4211
+ const files = task.isolation.conflictFiles?.length ? ` in ${(0, import_core16.capConflictFiles)(task.isolation.conflictFiles)}` : "";
4212
+ const attempt = task.isolation.repair ? ` (attempt ${task.isolation.repair.attempt}/${task.isolation.repair.limit})` : "";
4213
+ lines.push(style.yellow(truncate(`${bodyPad}\u21BB repairing conflict${files}${attempt}`, cols)));
4214
+ }
4215
+ if (task.isolation?.state === "integrated" && task.isolation.repairedFiles?.length) {
4216
+ lines.push(style.grey(truncate(`${bodyPad}\u21BB landed after repairing conflict in ${(0, import_core16.capConflictFiles)(task.isolation.repairedFiles)}`, cols)));
4142
4217
  }
4143
4218
  let editorLine;
4144
4219
  if (expanded) {
@@ -4223,6 +4298,7 @@ function helpScrollMax(state) {
4223
4298
  }
4224
4299
 
4225
4300
  // src/tui/render.ts
4301
+ var import_core17 = require("@ordewell/core");
4226
4302
  function render(state) {
4227
4303
  const { rows, cols } = state;
4228
4304
  const rowsForBody = bodyRows(state);
@@ -4321,8 +4397,7 @@ function renderSkills(state, cols) {
4321
4397
  return truncate([...badges, auto].join(" "), cols);
4322
4398
  }
4323
4399
  function scrolledBackMark(state) {
4324
- if (state.focus === "plan") return state.planScroll === null ? "" : "\u2191 scrolled back \xB7 \u2191\u2193 to follow";
4325
- return state.scroll > 0 ? "\u2191 scrolled back \xB7 pgdn for live" : "";
4400
+ return state.focus !== "plan" && state.scroll > 0 ? "\u2191 scrolled back \xB7 pgdn for live" : "";
4326
4401
  }
4327
4402
  function renderStatus(state, cols) {
4328
4403
  const mark = scrolledBackMark(state);
@@ -4462,14 +4537,7 @@ function renderOverlay(state, rows, cols) {
4462
4537
  cols
4463
4538
  );
4464
4539
  }
4465
- if (overlay.kind === "confirm") {
4466
- return frame(
4467
- overlay.title,
4468
- [overlay.message, "", style.grey("enter confirms \xB7 esc cancels")],
4469
- rows,
4470
- cols
4471
- );
4472
- }
4540
+ if (overlay.kind === "confirm") return renderConfirm(overlay, rows, cols);
4473
4541
  if (overlay.kind === "prompt") {
4474
4542
  const secret = overlay.action.kind === "api-key";
4475
4543
  const shown = secret ? "\u2022".repeat(overlay.value.length) : overlay.value;
@@ -4493,7 +4561,10 @@ function renderHandoff(state, overlay, rows, cols) {
4493
4561
  const more = scroll + room < lines.length ? "\u2191\u2193 pgup/pgdn scroll \xB7 " : "";
4494
4562
  return frame(`Diff \u2014 ${handoffBranch(handoff)}`, [...shown, "", style.grey(`${more}enter or esc goes back`)], rows, cols);
4495
4563
  }
4496
- const landed = handoff.landed.length === 0 ? [style.grey("Nothing landed on it.")] : handoff.landed.map((task) => ` ${style.green("\u2713")} #${task.order} ${task.title}`);
4564
+ const landed = handoff.landed.length === 0 ? [style.grey("Nothing landed on it.")] : handoff.landed.map((task) => {
4565
+ const repaired = task.repairedFiles?.length ? style.yellow(` \u2014 repaired (${(0, import_core17.capConflictFiles)(task.repairedFiles)})`) : "";
4566
+ return ` ${style.green("\u2713")} #${task.order} ${task.title}${repaired}`;
4567
+ });
4497
4568
  const actions = handoffActions(handoff).map((action, index) => {
4498
4569
  const active = index === overlay.index;
4499
4570
  const caret = active ? style.cyan("\u276F") : " ";
@@ -4553,6 +4624,24 @@ function renderHelp(scroll, rows, cols) {
4553
4624
  const footer = style.grey(offset < maxScroll ? "\u2191\u2193 scroll \xB7 any other key closes" : "any key closes");
4554
4625
  return frame("Commands", [...lines.slice(offset, offset + room), footer], rows, cols);
4555
4626
  }
4627
+ var QUOTE_MAX_LINES = 8;
4628
+ function renderConfirm(overlay, rows, cols) {
4629
+ const { choice, quote, note } = overlay;
4630
+ const inner = Math.max(1, cols - 3);
4631
+ const options = (choice?.options ?? []).map((o, i) => `${i === choice?.index ? style.cyan("\u276F") : " "} ${i + 1}. ${o.label}`);
4632
+ const hint = choice ? style.grey(`\u2191\u2193 move \xB7 enter or 1\u2013${choice.options.length} chooses \xB7 esc cancels`) : style.grey("enter confirms \xB7 esc cancels");
4633
+ const noteLines = note ? ["", ...note.split("\n")] : [];
4634
+ const rest = [...noteLines, ...options.length > 0 ? ["", ...options] : [], "", hint];
4635
+ const quoted = [];
4636
+ if (quote !== void 0) {
4637
+ const fixed = 1 + wrap(overlay.message, inner).length + 1 + 1 + rest.flatMap((l) => wrap(l, inner)).length;
4638
+ const room = Math.max(2, Math.min(QUOTE_MAX_LINES, rows - fixed));
4639
+ const wrapped = wrap(quote, inner - 2);
4640
+ const shown = wrapped.length > room ? [...wrapped.slice(0, room - 1), "\u2026"] : wrapped;
4641
+ quoted.push("", ...shown.map((l) => `${style.grey("\u2502")} ${l}`));
4642
+ }
4643
+ return frame(overlay.title, [overlay.message, ...quoted, ...rest], rows, cols);
4644
+ }
4556
4645
  function frame(title, body, rows, cols) {
4557
4646
  const inner = Math.max(1, cols - 2);
4558
4647
  const lines = [
@@ -4563,9 +4652,9 @@ function frame(title, body, rows, cols) {
4563
4652
  }
4564
4653
 
4565
4654
  // src/tui/reducer.ts
4566
- var import_core17 = require("@ordewell/core");
4655
+ var import_core18 = require("@ordewell/core");
4567
4656
  var import_plan_utils3 = require("@ordewell/core/plan-utils");
4568
- var KNOWN_PROVIDERS2 = Object.keys(import_core17.ALL_PROVIDERS);
4657
+ var KNOWN_PROVIDERS2 = Object.keys(import_core18.ALL_PROVIDERS);
4569
4658
  var DEFAULT_EFFORT = "__runner_default__";
4570
4659
  function step(state, effects = []) {
4571
4660
  return { state, effects };
@@ -4653,7 +4742,8 @@ function reduce(state, action) {
4653
4742
  expandedTaskId,
4654
4743
  taskEditor: expandedTaskId !== null ? state.taskEditor : null
4655
4744
  };
4656
- return step({ ...next, selectedTask: Math.min(state.selectedTask, Math.max(0, planRows(next).length - 1)) });
4745
+ const selectedTask = clampSelection(state.selectedTask, planRows(next).length);
4746
+ return step(settlePlan({ ...next, selectedTask, focus: tasks.length === 0 ? "chat" : state.focus }));
4657
4747
  }
4658
4748
  case "plannerMessage": {
4659
4749
  if (stale(state, action.sessionId)) return step(state);
@@ -4746,7 +4836,7 @@ function reduce(state, action) {
4746
4836
  if (stale(state, action.sessionId)) return step(state);
4747
4837
  return step(showDiff(state, action.diff));
4748
4838
  }
4749
- case "handoffDiscarded": {
4839
+ case "runCleared": {
4750
4840
  if (stale(state, action.sessionId)) return step(state);
4751
4841
  const overlay = state.overlay?.kind === "handoff" ? null : state.overlay;
4752
4842
  return step({ ...clearIsolation(state), overlay });
@@ -4787,6 +4877,7 @@ function reduce(state, action) {
4787
4877
  return step(refillPicker({ ...state, sessions: action.sessions }, ["load-session", "delete-session"]));
4788
4878
  case "rewindTargetsLoaded":
4789
4879
  if (stale(state, action.sessionId)) return step(state);
4880
+ if (action.pick !== void 0) return pickRewindTarget({ ...state, rewindTargets: action.targets }, action.pick);
4790
4881
  return step(refillPicker({ ...state, rewindTargets: action.targets }, ["rewind"]));
4791
4882
  // A fork holds no run, whatever the session it came from was doing — the
4792
4883
  // original keeps its run, and its events are stale from here on.
@@ -4802,6 +4893,15 @@ function reduce(state, action) {
4802
4893
  pendingApprovals: [],
4803
4894
  overlay: state.overlay?.kind === "approval" ? null : state.overlay
4804
4895
  });
4896
+ // The rewound message comes back as a draft to edit and resend, so it lands
4897
+ // as if just typed: history is not being browsed, and nothing is parked.
4898
+ case "inputPrefilled":
4899
+ if (stale(state, action.sessionId)) return step(state);
4900
+ return step({
4901
+ ...state,
4902
+ focus: "chat",
4903
+ editor: { ...state.editor, text: action.text, cursor: action.text.length, historyIndex: state.editor.history.length, draft: "" }
4904
+ });
4805
4905
  case "runnersLoaded":
4806
4906
  return step(refillPicker({
4807
4907
  ...state,
@@ -4827,11 +4927,7 @@ function reduce(state, action) {
4827
4927
  return step(say(state, "system", action.message));
4828
4928
  case "resize": {
4829
4929
  const resized = { ...state, rows: action.rows, cols: action.cols, selection: null };
4830
- return step({
4831
- ...resized,
4832
- scroll: clamp(resized.scroll, chatScrollMax(resized)),
4833
- planScroll: resized.planScroll === null ? null : clamp(resized.planScroll, planScrollExtent(resized).maxScroll)
4834
- });
4930
+ return step(settlePlan({ ...resized, scroll: clamp(resized.scroll, chatScrollMax(resized)) }));
4835
4931
  }
4836
4932
  // Whether there is anything to animate is the app loop's call (it owns the
4837
4933
  // timer); a tick that arrives simply advances the frame.
@@ -4906,11 +5002,11 @@ function modelDetail(m) {
4906
5002
  function providerErrorHint(state) {
4907
5003
  const failed = Object.keys(state.providerErrors ?? {});
4908
5004
  if (failed.length === 0) return void 0;
4909
- const names = failed.map((p) => import_core17.ALL_PROVIDERS[p]?.label ?? p);
5005
+ const names = failed.map((p) => import_core18.ALL_PROVIDERS[p]?.label ?? p);
4910
5006
  return `\u26A0 Unavailable (key or fetch failed): ${names.join(", ")} \u2014 showing working providers only.`;
4911
5007
  }
4912
5008
  function plannerModelItems(state) {
4913
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5009
+ const runner = (0, import_core18.runnerForProvider)(state.plannerProvider);
4914
5010
  if (!runner) return state.orchestratorModels.map((m) => ({ id: m.id, label: m.label, detail: modelDetail(m) }));
4915
5011
  const items = state.models.filter((m) => m.runners?.includes(runner)).map((m) => ({
4916
5012
  id: m.id,
@@ -4924,7 +5020,7 @@ function plannerModelItems(state) {
4924
5020
  return items.length > 0 ? items : [{ id: "", label: `No ${runner} models discovered yet`, detail: "run /refresh", disabled: true }];
4925
5021
  }
4926
5022
  function plannerEffortItems(state) {
4927
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5023
+ const runner = (0, import_core18.runnerForProvider)(state.plannerProvider);
4928
5024
  if (!runner) {
4929
5025
  return [{ id: "", label: "Thinking effort applies to a coding-agent planner", detail: "switch with /planner", disabled: true }];
4930
5026
  }
@@ -5049,7 +5145,7 @@ function pointerPane(state, key) {
5049
5145
  }
5050
5146
  function scrollPointed(state, key) {
5051
5147
  const delta = key.name === "scrollup" ? WHEEL_NOTCH : -WHEEL_NOTCH;
5052
- return pointerPane(state, key) === "plan" ? scrollPlan(state, delta) : scrollChat(state, delta);
5148
+ return pointerPane(state, key) === "plan" ? scrollPlan(state, delta, false) : scrollChat(state, delta);
5053
5149
  }
5054
5150
  var isMouseSelect = (key) => key.name === "mousedown" || key.name === "mousedrag" || key.name === "mouseup";
5055
5151
  function selectionCell(state, pane, key) {
@@ -5082,10 +5178,23 @@ function clamp(value, max) {
5082
5178
  function scrollChat(state, delta) {
5083
5179
  return step({ ...state, scroll: clamp(state.scroll + delta, chatScrollMax(state)) });
5084
5180
  }
5085
- function scrollPlan(state, delta) {
5086
- const { followOffset, maxScroll } = planScrollExtent(state);
5087
- const from = state.planScroll ?? followOffset;
5088
- return step({ ...state, planScroll: clamp(from - delta, maxScroll) });
5181
+ function settlePlan(state) {
5182
+ if (planPaneWidth(state) === 0) return state;
5183
+ const layout = planScrollExtent(state);
5184
+ return { ...state, planScroll: revealOffset(layout, planOffset(layout, state.planScroll), layout.anchor) };
5185
+ }
5186
+ function scrollPlan(state, delta, paged) {
5187
+ const layout = planScrollExtent(state);
5188
+ const from = planOffset(layout, state.planScroll);
5189
+ const to = clamp(from - delta, layout.maxScroll);
5190
+ const scrolled = { ...state, planScroll: to };
5191
+ if (state.taskEditor || layout.rowSpans.length === 0) return step(scrolled);
5192
+ const back = delta > 0;
5193
+ if (!paged) {
5194
+ return step({ ...scrolled, selectedTask: rowNearestView(layout, to, state.selectedTask) });
5195
+ }
5196
+ if (to === from) return step({ ...scrolled, selectedTask: back ? 0 : layout.rowSpans.length - 1 });
5197
+ return step({ ...scrolled, selectedTask: back ? bottomRowInView(layout, to) : topRowInView(layout, to) });
5089
5198
  }
5090
5199
  var plannerInFlight = (state) => state.status === "planning" || state.status === "researching";
5091
5200
  function stopPlanning(state, sessionId) {
@@ -5115,7 +5224,10 @@ function handleKey2(state, key) {
5115
5224
  return step({ ...state, editor: { ...state.editor, text, cursor: token.start + completed.length } });
5116
5225
  }
5117
5226
  }
5118
- if (key.name === "tab") return step({ ...state, focus: state.focus === "chat" ? "plan" : "chat" });
5227
+ if (key.name === "tab") {
5228
+ if (state.focus === "chat" && state.tasks.length === 0) return step(state);
5229
+ return step({ ...state, focus: state.focus === "chat" ? "plan" : "chat" });
5230
+ }
5119
5231
  if (state.focus === "plan") return handlePlanKey(state, key);
5120
5232
  if (key.name === "enter") return submit(state);
5121
5233
  if (key.name === "escape") return step({ ...state, editor: { ...state.editor, text: "", cursor: 0 } });
@@ -5147,35 +5259,32 @@ function handlePlanKey(state, key) {
5147
5259
  const editingTask = state.expandedTaskId ? findTask(state.tasks, state.expandedTaskId) : void 0;
5148
5260
  if (editingTask && state.taskEditor) return handleTaskEditKey(state, editingTask, state.taskEditor, key);
5149
5261
  if (key.name === "escape") {
5150
- if (state.expandedTaskId) return step({ ...state, expandedTaskId: null, planScroll: null });
5262
+ if (state.expandedTaskId) return step(settlePlan({ ...state, expandedTaskId: null }));
5151
5263
  return step({ ...state, focus: "chat" });
5152
5264
  }
5153
5265
  if (key.name === "up") {
5154
- return step({ ...state, selectedTask: Math.max(0, state.selectedTask - 1), planScroll: null });
5266
+ return step(settlePlan({ ...state, selectedTask: Math.max(0, state.selectedTask - 1) }));
5155
5267
  }
5156
5268
  if (key.name === "down") {
5157
- return step({
5158
- ...state,
5159
- selectedTask: Math.min(planRows(state).length - 1, state.selectedTask + 1),
5160
- planScroll: null
5161
- });
5269
+ return step(settlePlan({ ...state, selectedTask: clampSelection(state.selectedTask + 1, planRows(state).length) }));
5162
5270
  }
5163
5271
  const scroll = scrollDelta(key, state);
5164
- if (scroll !== null) return scrollPlan(state, scroll);
5272
+ if (scroll !== null) return scrollPlan(state, scroll, true);
5273
+ if (key.name === "char" && key.char === "a") return addTask(state, "");
5165
5274
  const row = selectedPlanRow(state);
5166
5275
  if (!row) return step(state);
5167
5276
  const task = row.task;
5168
5277
  if (key.name === "enter" || key.name === "right") {
5169
5278
  const hasSubtasks = (task.subtasks?.length ?? 0) > 0;
5170
5279
  if (hasSubtasks && state.expandedTaskId !== task.id) {
5171
- return step({ ...state, expandedTaskId: task.id, taskEditor: null, planScroll: null });
5280
+ return step(settlePlan({ ...state, expandedTaskId: task.id, taskEditor: null }));
5172
5281
  }
5173
5282
  const text = task.prompt ?? task.description ?? task.title;
5174
- return step({
5283
+ return step(settlePlan({
5175
5284
  ...state,
5176
5285
  expandedTaskId: task.id,
5177
5286
  taskEditor: { ...emptyEditor(), text, cursor: text.length }
5178
- });
5287
+ }));
5179
5288
  }
5180
5289
  if (!state.sessionId || key.name !== "char") return step(state);
5181
5290
  if (key.char === "E") return step(state, [{ type: "execute", sessionId: state.sessionId }]);
@@ -5184,7 +5293,6 @@ function handlePlanKey(state, key) {
5184
5293
  return step(state, [taskActionEffect(state, state.sessionId, task.id, action)]);
5185
5294
  }
5186
5295
  if (key.char === "d") return confirmRemoveTask(state, task);
5187
- if (key.char === "a") return addTask(state, "");
5188
5296
  if (key.char === "t") {
5189
5297
  return step(state, [{ type: "openTaskTerminal", sessionId: state.sessionId, taskId: task.id }]);
5190
5298
  }
@@ -5200,13 +5308,13 @@ function handlePlanKey(state, key) {
5200
5308
  }
5201
5309
  function handleTaskEditKey(state, task, editor, key) {
5202
5310
  if (key.name === "enter") return commitTaskEdit(state, task, editor);
5203
- if (key.name === "escape") return step({ ...state, expandedTaskId: null, taskEditor: null });
5311
+ if (key.name === "escape") return step(settlePlan({ ...state, expandedTaskId: null, taskEditor: null }));
5204
5312
  const scroll = scrollDelta(key, state);
5205
- if (scroll !== null) return scrollPlan(state, scroll);
5206
- return step({ ...state, taskEditor: applyKey(editor, key, taskEditorRoom(state)) });
5313
+ if (scroll !== null) return scrollPlan(state, scroll, true);
5314
+ return step(settlePlan({ ...state, taskEditor: applyKey(editor, key, taskEditorRoom(state)) }));
5207
5315
  }
5208
5316
  function commitTaskEdit(state, task, editor) {
5209
- const collapsed = { ...state, expandedTaskId: null, taskEditor: null };
5317
+ const collapsed = settlePlan({ ...state, expandedTaskId: null, taskEditor: null });
5210
5318
  const prompt = editor.text.trim();
5211
5319
  const original = task.prompt ?? task.description ?? task.title;
5212
5320
  if (!prompt || prompt === original || !state.sessionId) return step(collapsed);
@@ -5261,7 +5369,18 @@ function handleOverlayKey(state, overlay, key) {
5261
5369
  }
5262
5370
  function handleConfirmKey(state, overlay, key) {
5263
5371
  if (key.name === "escape") return step({ ...state, overlay: null });
5264
- if (key.name !== "enter") return step(state);
5372
+ const { choice } = overlay;
5373
+ if (!choice) return key.name === "enter" ? runConfirm(state, overlay) : step(state);
5374
+ if (key.name === "up" || key.name === "down") {
5375
+ const index = clamp(choice.index + (key.name === "down" ? 1 : -1), choice.options.length - 1);
5376
+ return step({ ...state, overlay: { ...overlay, choice: { ...choice, index } } });
5377
+ }
5378
+ const chosen = key.name === "enter" ? choice.index : key.name === "char" ? Number(key.char) - 1 : -1;
5379
+ const option = choice.options[chosen];
5380
+ if (!option) return step(state);
5381
+ return option.confirms ? runConfirm(state, overlay) : step({ ...state, overlay: null });
5382
+ }
5383
+ function runConfirm(state, overlay) {
5265
5384
  const closed = { ...state, overlay: null };
5266
5385
  if (overlay.action.kind === "new-session") return newSession(closed);
5267
5386
  if (overlay.action.kind === "remove-task") {
@@ -5274,7 +5393,8 @@ function handleConfirmKey(state, overlay, key) {
5274
5393
  if (overlay.action.kind === "init-workspace") {
5275
5394
  return step({ ...closed, status: "planning" }, [{ type: "startConversation", goal: overlay.action.goal, allowInit: true }]);
5276
5395
  }
5277
- return step(closed);
5396
+ const { index } = overlay.action;
5397
+ return withIdlePlanner(closed, (sessionId) => step(closed, [{ type: "rewindConversation", sessionId, index }]));
5278
5398
  }
5279
5399
  function handleHelpKey(state, scroll, key) {
5280
5400
  const page = pageNotch(bodyRows(state));
@@ -5298,7 +5418,7 @@ function handlePromptKey(state, overlay, key) {
5298
5418
  if (overlay.action.kind === "api-key") {
5299
5419
  return step(closed, [{ type: "setApiKey", provider: overlay.action.provider, key: value2 }]);
5300
5420
  }
5301
- if (!state.sessionId) return fail3(closed, "No active plan to add a task to.");
5421
+ if (!state.sessionId) return fail3(closed, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
5302
5422
  return step(closed, [{ type: "addTask", sessionId: state.sessionId, title: value2 }]);
5303
5423
  }
5304
5424
  const value = editText(overlay.value, key);
@@ -5371,10 +5491,7 @@ function choose(state, picker2, item) {
5371
5491
  case "load-session":
5372
5492
  return step(closed, [{ type: "loadSession", sessionId: item.id }]);
5373
5493
  case "rewind":
5374
- return withSession(
5375
- closed,
5376
- (sessionId) => step(closed, [{ type: "rewindConversation", sessionId, index: Number(item.id) }])
5377
- );
5494
+ return pickRewindTarget(state, Number(item.id));
5378
5495
  case "delete-session":
5379
5496
  return step(closed, [{ type: "deleteSession", sessionId: item.id }]);
5380
5497
  case "set-key":
@@ -5455,7 +5572,7 @@ function runCommand(state, { name, args }) {
5455
5572
  case "quit":
5456
5573
  return step({ ...state, exiting: true }, [{ type: "exit" }]);
5457
5574
  case "refresh":
5458
- return step(state, [{ type: "refresh" }]);
5575
+ return step(state, [{ type: "refresh", announce: true }]);
5459
5576
  case "run":
5460
5577
  return withSession(state, (sessionId) => step(state, [{ type: "execute", sessionId }]));
5461
5578
  case "approve":
@@ -5571,12 +5688,34 @@ function rewind(state, arg) {
5571
5688
  return withIdlePlanner(state, (sessionId) => {
5572
5689
  if (arg === void 0) {
5573
5690
  return step(
5574
- { ...state, rewindTargets: null, overlay: { kind: "picker", picker: picker("Rewind to before\u2026", [], { kind: "rewind" }, { hint: "The chosen message and everything after it are discarded; the tasks stay as they are." }) } },
5691
+ { ...state, rewindTargets: null, overlay: { kind: "picker", picker: picker("Rewind to before\u2026", [], { kind: "rewind" }, { hint: "Rewinding forks the conversation from just before the chosen message; the original is kept and the tasks ride along." }) } },
5575
5692
  [{ type: "loadRewindTargets", sessionId }]
5576
5693
  );
5577
5694
  }
5578
5695
  if (!/^\d+$/.test(arg)) return fail3(state, "Usage: /rewind [<message>] \u2014 or /rewind alone to pick one.");
5579
- return step(state, [{ type: "rewindConversation", sessionId, index: Number(arg) }]);
5696
+ return step(state, [{ type: "loadRewindTargets", sessionId, pick: Number(arg) }]);
5697
+ });
5698
+ }
5699
+ var REWIND_OPTIONS = [
5700
+ { label: "Restore Conversation", confirms: true },
5701
+ { label: "Never mind", confirms: false }
5702
+ ];
5703
+ function pickRewindTarget(state, index) {
5704
+ return withIdlePlanner({ ...state, overlay: null }, () => {
5705
+ const target = state.rewindTargets?.find((t) => t.index === index);
5706
+ if (!target) return fail3({ ...state, overlay: null }, `No message ${index} to rewind to \u2014 /rewind alone lists them.`);
5707
+ return step({
5708
+ ...state,
5709
+ overlay: {
5710
+ kind: "confirm",
5711
+ title: "Rewind",
5712
+ message: "Confirm you want to restore to the point before you sent this message:",
5713
+ quote: target.content,
5714
+ note: "The conversation will be forked.\nThe code will be unchanged.",
5715
+ action: { kind: "rewind", index },
5716
+ choice: { options: REWIND_OPTIONS, index: 0 }
5717
+ }
5718
+ });
5580
5719
  });
5581
5720
  }
5582
5721
  function resolveToggle(arg, current) {
@@ -5618,6 +5757,7 @@ function newSession(state) {
5618
5757
  expandedTaskId: null,
5619
5758
  taskEditor: null,
5620
5759
  scroll: 0,
5760
+ planScroll: null,
5621
5761
  status: "idle",
5622
5762
  busyLabel: "",
5623
5763
  thinkingLine: "",
@@ -5629,7 +5769,7 @@ function newSession(state) {
5629
5769
  }
5630
5770
  function setModel(state, args) {
5631
5771
  if (args[0] === "set" && args[1]) {
5632
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5772
+ const runner = (0, import_core18.runnerForProvider)(state.plannerProvider);
5633
5773
  const known = runner ? modelsForRunner(state.models, runner) : [];
5634
5774
  if (runner && known.length > 0 && !known.some((m) => m.id === args[1])) {
5635
5775
  return fail3(state, `${args[1]} was not discovered for ${runner}.`);
@@ -5651,21 +5791,21 @@ function setModel(state, args) {
5651
5791
  }
5652
5792
  function plannerItems(state) {
5653
5793
  const installed = new Set(state.runners.map((r) => r.id));
5654
- const agents = import_core17.CLI_PROVIDERS.map((id) => {
5655
- const runner = (0, import_core17.runnerForProvider)(id);
5794
+ const agents = import_core18.CLI_PROVIDERS.map((id) => {
5795
+ const runner = (0, import_core18.runnerForProvider)(id);
5656
5796
  const ready = installed.has(runner);
5657
5797
  return {
5658
5798
  id,
5659
- label: import_core17.ALL_PROVIDERS[id].label,
5799
+ label: import_core18.ALL_PROVIDERS[id].label,
5660
5800
  detail: ready ? "coding agent \xB7 no API key needed" : "CLI not installed or not on PATH",
5661
5801
  selected: state.plannerProvider === id,
5662
5802
  disabled: !ready
5663
5803
  };
5664
5804
  });
5665
- const vendors = import_core17.PROVIDER_PRIORITY.filter((id) => state.configuredProviders.includes(id)).map((id) => ({
5805
+ const vendors = import_core18.PROVIDER_PRIORITY.filter((id) => state.configuredProviders.includes(id)).map((id) => ({
5666
5806
  id,
5667
- label: import_core17.ALL_PROVIDERS[id].label,
5668
- detail: import_core17.ALL_PROVIDERS[id].apiKeyEnvVar,
5807
+ label: import_core18.ALL_PROVIDERS[id].label,
5808
+ detail: import_core18.ALL_PROVIDERS[id].apiKeyEnvVar,
5669
5809
  selected: state.plannerProvider === id
5670
5810
  }));
5671
5811
  return vendors.length > 0 ? [...agents, ...vendors] : [...agents, { id: "", label: "No API providers configured", detail: "add a key with /key", disabled: true }];
@@ -5723,16 +5863,16 @@ function setKey(state, args) {
5723
5863
  }
5724
5864
  return step(state, [{ type: "setApiKey", provider, key: args.slice(2).join(" ") }]);
5725
5865
  }
5726
- const items = import_core17.PROVIDER_PRIORITY.map((id) => ({
5866
+ const items = import_core18.PROVIDER_PRIORITY.map((id) => ({
5727
5867
  id,
5728
- label: import_core17.ALL_PROVIDERS[id].label,
5729
- detail: import_core17.ALL_PROVIDERS[id].apiKeyEnvVar,
5868
+ label: import_core18.ALL_PROVIDERS[id].label,
5869
+ detail: import_core18.ALL_PROVIDERS[id].apiKeyEnvVar,
5730
5870
  selected: state.configuredProviders.includes(id)
5731
5871
  }));
5732
5872
  return step({ ...state, overlay: { kind: "picker", picker: picker("API provider key", items, { kind: "set-key" }) } });
5733
5873
  }
5734
5874
  function keyPrompt(provider) {
5735
- const meta = import_core17.ALL_PROVIDERS[provider];
5875
+ const meta = import_core18.ALL_PROVIDERS[provider];
5736
5876
  return {
5737
5877
  kind: "prompt",
5738
5878
  title: `${meta.label} API key`,
@@ -6009,16 +6149,20 @@ function taskEffortCommand(state, args) {
6009
6149
  return assignTaskEffort(state, sessionId, task, value);
6010
6150
  });
6011
6151
  }
6152
+ function clampSelection(index, rows) {
6153
+ return Math.max(0, Math.min(index, rows - 1));
6154
+ }
6012
6155
  function addTask(state, title) {
6013
- return withSession(state, (sessionId) => {
6014
- if (!title) {
6015
- return step({
6016
- ...state,
6017
- overlay: { kind: "prompt", title: "New task title", value: "", action: { kind: "add-task" } }
6018
- });
6019
- }
6020
- return step(state, [{ type: "addTask", sessionId, title }]);
6021
- });
6156
+ if (!state.sessionId) {
6157
+ return fail3(state, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
6158
+ }
6159
+ if (!title) {
6160
+ return step({
6161
+ ...state,
6162
+ overlay: { kind: "prompt", title: "New task title", value: "", action: { kind: "add-task" } }
6163
+ });
6164
+ }
6165
+ return step(state, [{ type: "addTask", sessionId: state.sessionId, title }]);
6022
6166
  }
6023
6167
  function taskCommand(state, token, run) {
6024
6168
  return withSession(state, (sessionId) => {
@@ -6090,7 +6234,7 @@ function createApp(deps) {
6090
6234
 
6091
6235
  // src/tui/effects.ts
6092
6236
  var import_child_process4 = require("child_process");
6093
- var import_core18 = require("@ordewell/core");
6237
+ var import_core19 = require("@ordewell/core");
6094
6238
  var import_plan_utils4 = require("@ordewell/core/plan-utils");
6095
6239
  var ConversationQueue = class {
6096
6240
  tail = Promise.resolve();
@@ -6261,7 +6405,7 @@ async function perform(effect, deps) {
6261
6405
  dispatch({ type: "notice", message: `Orchestrator model set to ${effect.modelId}.` });
6262
6406
  return;
6263
6407
  case "setPlanner": {
6264
- const meta = import_core18.ALL_PROVIDERS[effect.provider];
6408
+ const meta = import_core19.ALL_PROVIDERS[effect.provider];
6265
6409
  if (!meta) throw new Error(`Unknown planner: ${effect.provider}`);
6266
6410
  const env = { AI_PROVIDER: effect.provider };
6267
6411
  const settings = await api.updateSettings({ env });
@@ -6273,7 +6417,7 @@ async function perform(effect, deps) {
6273
6417
  });
6274
6418
  dispatch({
6275
6419
  type: "notice",
6276
- message: `${(0, import_core18.isCliProvider)(effect.provider) ? `Planning with ${meta.label} \u2014 no API key needed.` : `Planner set to ${meta.label}.`} ${plannerModelNotice(recall2)}`
6420
+ message: `${(0, import_core19.isCliProvider)(effect.provider) ? `Planning with ${meta.label} \u2014 no API key needed.` : `Planner set to ${meta.label}.`} ${plannerModelNotice(recall2)}`
6277
6421
  });
6278
6422
  await loadModels(deps);
6279
6423
  return;
@@ -6285,7 +6429,7 @@ async function perform(effect, deps) {
6285
6429
  dispatch({ type: "notice", message: `Planner effort set to ${effect.effort || "the runner default"}.` });
6286
6430
  return;
6287
6431
  case "setApiKey": {
6288
- const meta = import_core18.ALL_PROVIDERS[effect.provider];
6432
+ const meta = import_core19.ALL_PROVIDERS[effect.provider];
6289
6433
  if (!meta) throw new Error(`Unknown provider: ${effect.provider}`);
6290
6434
  await api.updateSettings({ env: { [meta.apiKeyEnvVar]: effect.key } });
6291
6435
  deps.setEnvVar(meta.apiKeyEnvVar, effect.key);
@@ -6352,13 +6496,23 @@ async function perform(effect, deps) {
6352
6496
  return;
6353
6497
  }
6354
6498
  case "loadRewindTargets":
6355
- dispatch({ type: "rewindTargetsLoaded", targets: await api.rewindTargets(effect.sessionId), sessionId: effect.sessionId });
6499
+ dispatch({
6500
+ type: "rewindTargetsLoaded",
6501
+ targets: await api.rewindTargets(effect.sessionId),
6502
+ sessionId: effect.sessionId,
6503
+ ...effect.pick !== void 0 && { pick: effect.pick }
6504
+ });
6356
6505
  return;
6506
+ // A rewind is a fork from an earlier point, so the TUI follows it the way
6507
+ // it follows `/fork`; the original keeps its whole conversation. The
6508
+ // rewound message comes back into the input, to edit and send again.
6357
6509
  case "rewindConversation": {
6358
- const plan = await api.rewindConversation(effect.sessionId, effect.index);
6359
- dispatch({ type: "chatRestored", history: plan.conversationHistory ?? [], sessionId: effect.sessionId });
6360
- dispatch({ type: "planUpdated", plan, sessionId: effect.sessionId });
6361
- dispatch({ type: "notice", message: "Rewound the conversation. The tasks are unchanged; your next message continues from here." });
6510
+ const fork = await api.rewindConversation(effect.sessionId, effect.index);
6511
+ dispatch({ type: "sessionForked", sessionId: fork.sessionId, goal: fork.goal });
6512
+ dispatch({ type: "chatRestored", history: fork.plan.conversationHistory ?? [], sessionId: fork.sessionId });
6513
+ dispatch({ type: "planUpdated", plan: fork.plan, sessionId: fork.sessionId });
6514
+ dispatch({ type: "inputPrefilled", text: fork.rewoundMessage, sessionId: fork.sessionId });
6515
+ dispatch({ type: "notice", message: `Forked ${effect.sessionId} into ${fork.sessionId} from before that message \u2014 the original is kept (/sessions to go back). The message is ready to edit and resend.` });
6362
6516
  return;
6363
6517
  }
6364
6518
  // The redrawn transcript opens with the summary entry, which is what the
@@ -6373,15 +6527,17 @@ async function perform(effect, deps) {
6373
6527
  dispatch({ type: "handoffDiff", diff: await api.reviewRunDiff(effect.sessionId), sessionId: effect.sessionId });
6374
6528
  return;
6375
6529
  // A conflict or a refusal is an answer, not a fault: either way the user's
6376
- // tree is exactly as it was, and the words say what to do next.
6530
+ // tree is exactly as it was, and the words say what to do next. A full
6531
+ // merge leaves nothing to hand over — the daemon has cleared the run up.
6377
6532
  case "isolationMerge": {
6378
- const { ok, message } = mergeOutcome(await api.mergeRun(effect.sessionId), effect.branch, effect.group === true);
6533
+ const { ok, message } = mergeOutcome(await api.mergeRun(effect.sessionId), effect.branch, effect.group === true, effect.repaired ?? []);
6534
+ if (ok) dispatch({ type: "runCleared", sessionId: effect.sessionId });
6379
6535
  dispatch({ type: ok ? "notice" : "failed", message });
6380
6536
  return;
6381
6537
  }
6382
6538
  case "isolationDiscard":
6383
6539
  await api.discardRun(effect.sessionId);
6384
- dispatch({ type: "handoffDiscarded", sessionId: effect.sessionId });
6540
+ dispatch({ type: "runCleared", sessionId: effect.sessionId });
6385
6541
  dispatch({ type: "notice", message: `Discarded the run and ${effect.branch}.` });
6386
6542
  return;
6387
6543
  case "isolationCleanup":
@@ -6418,7 +6574,7 @@ async function perform(effect, deps) {
6418
6574
  return;
6419
6575
  case "refresh":
6420
6576
  await Promise.all([loadRunners(deps), loadSettings(deps), loadModels(deps)]);
6421
- dispatch({ type: "notice", message: "Refreshed runners, settings and models." });
6577
+ if (effect.announce) dispatch({ type: "notice", message: "Refreshed runners, settings and models." });
6422
6578
  return;
6423
6579
  case "exit":
6424
6580
  deps.exit();
@@ -6434,7 +6590,7 @@ function osc52(text) {
6434
6590
  function copySelection(deps, text) {
6435
6591
  if (!text) return;
6436
6592
  const lines = text.split("\n").length;
6437
- const command = (0, import_core18.clipboardCopyCommand)(deps.hasBin);
6593
+ const command = (0, import_core19.clipboardCopyCommand)(deps.hasBin);
6438
6594
  if (command) {
6439
6595
  try {
6440
6596
  (deps.pipeToClipboard ?? pipeToClipboardDefault)(command, text);
@@ -6576,7 +6732,7 @@ function onExecutionEvent(dispatch, event, sessionId) {
6576
6732
  // The extension shows these in its checkpoint panel; here they are
6577
6733
  // transcript lines, which is the TUI's equivalent surface.
6578
6734
  case "checkpoint":
6579
- dispatch({ type: "notice", message: `\xB7 Checkpoint \u2014 ${event.taskTitle}: ${(0, import_core18.truncateCheckpointSummary)(event.summary)}` });
6735
+ dispatch({ type: "notice", message: `\xB7 Checkpoint \u2014 ${event.taskTitle}: ${(0, import_core19.truncateCheckpointSummary)(event.summary)}` });
6580
6736
  return;
6581
6737
  case "approval_decided":
6582
6738
  dispatch({ type: "notice", message: describeApprovalDecision2(event) });
@@ -6632,8 +6788,10 @@ function onExecutionEvent(dispatch, event, sessionId) {
6632
6788
  case "approval_request":
6633
6789
  case "approval_settled":
6634
6790
  return;
6635
- // Its asker already has the result, from the merge request itself.
6791
+ // Its asker already has the words, from the merge request itself; a
6792
+ // viewer that did not ask still has to drop a run that is gone.
6636
6793
  case "isolation_merge":
6794
+ if (event.result.outcome === "merged") dispatch({ type: "runCleared", sessionId });
6637
6795
  return;
6638
6796
  default: {
6639
6797
  const unhandled = event;
@@ -6958,7 +7116,7 @@ async function handleTui(subArgs) {
6958
7116
  }
6959
7117
  const workspace = flag(subArgs, "--workspace") || process.cwd();
6960
7118
  try {
6961
- (0, import_core19.assertWorkspaceExists)(workspace);
7119
+ (0, import_core20.assertWorkspaceExists)(workspace);
6962
7120
  } catch {
6963
7121
  console.error(
6964
7122
  `Workspace "${workspace}" does not exist.
@@ -6967,7 +7125,7 @@ cd into a directory that exists, or pass --workspace <path> naming one that does
6967
7125
  );
6968
7126
  process.exit(1);
6969
7127
  }
6970
- registerSkillCommands((0, import_core19.createSkillsService)(workspace).listSkills());
7128
+ registerSkillCommands((0, import_core20.createSkillsService)(workspace).listSkills());
6971
7129
  const explicitPort = flag(subArgs, "--port") || process.env.ORDEWELL_PORT;
6972
7130
  const targetPort = explicitPort ? resolvePort(subArgs) : await findFreePort();
6973
7131
  const { port, owned: ownedAtLaunch } = await ensureDaemonOwned(targetPort, { detached: false });
@@ -6986,7 +7144,7 @@ cd into a directory that exists, or pass --workspace <path> naming one that does
6986
7144
  conversationQueue,
6987
7145
  port,
6988
7146
  dispatch: (action) => app.dispatch(action),
6989
- newSessionId: () => (0, import_core19.mintSessionId)(),
7147
+ newSessionId: () => (0, import_core20.mintSessionId)(),
6990
7148
  setEnvVar: (key, value) => {
6991
7149
  writeEnvVar(findEnvFile(), key, value);
6992
7150
  process.env[key] = value;
@@ -7096,7 +7254,7 @@ function cliVersion() {
7096
7254
 
7097
7255
  // src/main.ts
7098
7256
  async function main() {
7099
- (0, import_core20.migrateOldConfigDir)();
7257
+ (0, import_core21.migrateOldConfigDir)();
7100
7258
  loadEnvFile();
7101
7259
  const argv = process.argv.slice(2);
7102
7260
  const command = argv[0] ?? (process.stdin.isTTY ? "tui" : "--help");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordewell/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Ordewell CLI and TUI — plan, run and verify coding-agent tasks from the terminal.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Alessandro Costanzo Ciano",
@@ -48,8 +48,8 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@ordewell/core": "^0.5.0",
52
- "@ordewell/web": "^0.5.0",
51
+ "@ordewell/core": "^0.5.1",
52
+ "@ordewell/web": "^0.5.1",
53
53
  "string-width": "^7.2.0",
54
54
  "ws": "^8.18.0"
55
55
  },