sisyphi 1.1.12 → 1.1.13

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.
package/dist/tui.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  exec,
6
6
  execSafe,
7
7
  loadConfig
8
- } from "./chunk-ZSIYQB45.js";
8
+ } from "./chunk-CAJEBTUE.js";
9
9
  import {
10
10
  buildSessionContext,
11
11
  computeActiveTimeMs,
@@ -226,6 +226,15 @@ function onResize(callback) {
226
226
  }
227
227
 
228
228
  // src/tui/state.ts
229
+ var OPTIONAL_COMPOSE = /* @__PURE__ */ new Set(["resume", "continue"]);
230
+ var COMPOSE_HEADERS = {
231
+ "new-session": "New Session",
232
+ "message-orchestrator": "Message Orchestrator",
233
+ "resume": "Resume Session",
234
+ "continue": "Continue Session",
235
+ "spawn-agent": "Spawn Agent",
236
+ "message-agent": "Message Agent"
237
+ };
229
238
  var INPUT_MODES = /* @__PURE__ */ new Set([
230
239
  "resume",
231
240
  "continue",
@@ -352,6 +361,11 @@ function createAppState(cwd2) {
352
361
  prevNvimFile: null,
353
362
  nvimEditable: false,
354
363
  nvimOpenTabs: /* @__PURE__ */ new Map(),
364
+ composeAction: null,
365
+ composeTempFile: null,
366
+ composeSignalFile: null,
367
+ composePollTimer: null,
368
+ composePrevNvimFile: null,
355
369
  cwd: cwd2
356
370
  };
357
371
  }
@@ -415,8 +429,13 @@ function autoExpandCycle(state2) {
415
429
  }
416
430
 
417
431
  // src/tui/app.ts
418
- import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync, statSync } from "fs";
419
- import { join as join5 } from "path";
432
+ import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync, statSync as statSync2 } from "fs";
433
+ import { join as join6 } from "path";
434
+
435
+ // src/tui/input.ts
436
+ import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from "fs";
437
+ import { join as join2 } from "path";
438
+ import { tmpdir } from "os";
420
439
 
421
440
  // src/tui/lib/tree.ts
422
441
  import { join } from "path";
@@ -806,7 +825,18 @@ function findParentIndex(nodes, index) {
806
825
  // src/tui/input.ts
807
826
  function activateNvimBypass(state2) {
808
827
  setRawBypass((data) => {
828
+ if (!state2.nvimBridge?.ready) {
829
+ deactivateNvimBypass();
830
+ state2.focusPane = "tree";
831
+ if (state2.mode === "compose") cancelCompose(state2);
832
+ requestRender();
833
+ return false;
834
+ }
809
835
  if (data === " ") {
836
+ if (state2.mode === "compose") {
837
+ cancelCompose(state2);
838
+ return true;
839
+ }
810
840
  deactivateNvimBypass();
811
841
  state2.focusPane = state2.showCombinedView ? "logs" : "tree";
812
842
  requestRender();
@@ -819,6 +849,148 @@ function activateNvimBypass(state2) {
819
849
  function deactivateNvimBypass() {
820
850
  setRawBypass(null);
821
851
  }
852
+ var COMPOSE_DIR = join2(tmpdir(), "sisyphus-nvim");
853
+ function enterComposeMode(state2, action, actions) {
854
+ if (!state2.nvimEnabled || !state2.nvimBridge?.ready) return false;
855
+ mkdirSync(COMPOSE_DIR, { recursive: true });
856
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
857
+ const tempFile = join2(COMPOSE_DIR, `compose-${id}.md`);
858
+ const signalFile = join2(COMPOSE_DIR, `compose-signal-${id}`);
859
+ writeFileSync(tempFile, "", "utf-8");
860
+ state2.composePrevNvimFile = state2.prevNvimFile;
861
+ state2.composeAction = action;
862
+ state2.composeTempFile = tempFile;
863
+ state2.composeSignalFile = signalFile;
864
+ state2.mode = "compose";
865
+ state2.focusPane = "detail";
866
+ state2.nvimBridge.openComposeFile(tempFile, signalFile);
867
+ activateNvimBypass(state2);
868
+ state2.composePollTimer = setInterval(() => {
869
+ checkComposeSignal(state2, actions);
870
+ }, 100);
871
+ requestRender();
872
+ return true;
873
+ }
874
+ function cancelCompose(state2) {
875
+ if (state2.composePollTimer !== null) {
876
+ clearInterval(state2.composePollTimer);
877
+ state2.composePollTimer = null;
878
+ }
879
+ if (state2.composeTempFile) {
880
+ try {
881
+ unlinkSync(state2.composeTempFile);
882
+ } catch {
883
+ }
884
+ }
885
+ if (state2.composeSignalFile) {
886
+ try {
887
+ unlinkSync(state2.composeSignalFile);
888
+ } catch {
889
+ }
890
+ }
891
+ state2.prevNvimFile = null;
892
+ state2.composePrevNvimFile = null;
893
+ state2.composeAction = null;
894
+ state2.composeTempFile = null;
895
+ state2.composeSignalFile = null;
896
+ state2.mode = "navigate";
897
+ state2.focusPane = "tree";
898
+ deactivateNvimBypass();
899
+ requestRender();
900
+ }
901
+ function checkComposeSignal(state2, actions) {
902
+ if (!state2.composeSignalFile || !state2.composeAction) return;
903
+ if (!state2.nvimBridge?.ready) {
904
+ cancelCompose(state2);
905
+ return;
906
+ }
907
+ if (!existsSync(state2.composeSignalFile)) return;
908
+ let signalContent = "";
909
+ try {
910
+ signalContent = readFileSync(state2.composeSignalFile, "utf-8").trim();
911
+ } catch {
912
+ }
913
+ if (signalContent === "cancel") {
914
+ cancelCompose(state2);
915
+ return;
916
+ }
917
+ let content = "";
918
+ if (state2.composeTempFile) {
919
+ try {
920
+ content = readFileSync(state2.composeTempFile, "utf-8").trim();
921
+ } catch {
922
+ }
923
+ }
924
+ const action = state2.composeAction;
925
+ const required = !OPTIONAL_COMPOSE.has(action.kind);
926
+ if (required && !content) {
927
+ try {
928
+ unlinkSync(state2.composeSignalFile);
929
+ } catch {
930
+ }
931
+ notify(state2, "Content required");
932
+ return;
933
+ }
934
+ dispatchComposeAction(action, content, state2, actions);
935
+ cancelCompose(state2);
936
+ }
937
+ function dispatchComposeAction(action, content, state2, actions) {
938
+ switch (action.kind) {
939
+ case "new-session":
940
+ actions.sendAndNotify(
941
+ { type: "start", task: content, cwd: state2.cwd },
942
+ "Session created"
943
+ );
944
+ break;
945
+ case "message-orchestrator":
946
+ actions.sendAndNotify(
947
+ { type: "message", sessionId: action.sessionId, content },
948
+ "Message queued"
949
+ );
950
+ break;
951
+ case "resume":
952
+ actions.sendAndNotify(
953
+ { type: "resume", sessionId: action.sessionId, cwd: state2.cwd, message: content || void 0 },
954
+ "Session resumed"
955
+ );
956
+ break;
957
+ case "continue":
958
+ void (async () => {
959
+ try {
960
+ const contRes = await actions.send({ type: "continue", sessionId: action.sessionId });
961
+ if (!contRes.ok) {
962
+ notify(state2, `Error: ${contRes.error}`);
963
+ return;
964
+ }
965
+ actions.sendAndNotify(
966
+ { type: "resume", sessionId: action.sessionId, cwd: state2.cwd, message: content || void 0 },
967
+ "Session continued"
968
+ );
969
+ } catch (err) {
970
+ notify(state2, `Error: ${err.message}`);
971
+ }
972
+ })();
973
+ break;
974
+ case "spawn-agent":
975
+ actions.sendAndNotify(
976
+ {
977
+ type: "spawn",
978
+ sessionId: action.sessionId,
979
+ agentType: "default",
980
+ name: "agent",
981
+ instruction: content
982
+ },
983
+ "Agent spawned"
984
+ );
985
+ break;
986
+ case "message-agent":
987
+ actions.sendAndNotify(
988
+ { type: "message", sessionId: action.sessionId, content, source: { type: "agent", agentId: action.agentId } },
989
+ `Message sent to ${action.agentId}`
990
+ );
991
+ break;
992
+ }
993
+ }
822
994
  function handleCancel(state2) {
823
995
  state2.mode = "navigate";
824
996
  state2.targetAgentId = null;
@@ -1128,6 +1300,7 @@ function handleLeaderAction(action, state2, actions) {
1128
1300
  notify(state2, "No session selected");
1129
1301
  break;
1130
1302
  }
1303
+ if (enterComposeMode(state2, { kind: "spawn-agent", sessionId: selectedSessionId }, actions)) return;
1131
1304
  state2.mode = "spawn-agent";
1132
1305
  requestRender();
1133
1306
  return;
@@ -1138,6 +1311,7 @@ function handleLeaderAction(action, state2, actions) {
1138
1311
  notify(state2, "Cursor must be on an agent");
1139
1312
  break;
1140
1313
  }
1314
+ if (enterComposeMode(state2, { kind: "message-agent", sessionId: selectedSessionId, agentId: agent.id }, actions)) return;
1141
1315
  state2.targetAgentId = agent.id;
1142
1316
  state2.mode = "message-agent";
1143
1317
  requestRender();
@@ -1408,6 +1582,7 @@ function handleNavigateKey(input, key, state2, actions) {
1408
1582
  notify(state2, "No session selected");
1409
1583
  return;
1410
1584
  }
1585
+ if (enterComposeMode(state2, { kind: "message-orchestrator", sessionId: state2.selectedSessionId }, actions)) return;
1411
1586
  const editor = actions.resolveEditor();
1412
1587
  try {
1413
1588
  const content = actions.editInPopup(state2.cwd, editor);
@@ -1427,6 +1602,22 @@ function handleNavigateKey(input, key, state2, actions) {
1427
1602
  notify(state2, "No session selected");
1428
1603
  return;
1429
1604
  }
1605
+ if (session.status === "completed") {
1606
+ const lastCycle = session.orchestratorCycles[session.orchestratorCycles.length - 1];
1607
+ const claudeSessionId = lastCycle?.claudeSessionId;
1608
+ if (!claudeSessionId) {
1609
+ notify(state2, "No orchestrator Claude session ID available");
1610
+ return;
1611
+ }
1612
+ try {
1613
+ const label = session.name ?? state2.selectedSessionId.slice(0, 8);
1614
+ const sessionName = actions.openClaudeResumeSession(state2.cwd, claudeSessionId, label);
1615
+ actions.switchToSession(sessionName);
1616
+ } catch {
1617
+ notify(state2, "Failed to open Claude session");
1618
+ }
1619
+ return;
1620
+ }
1430
1621
  if (state2.paneAlive && session.tmuxWindowId) {
1431
1622
  if (session.tmuxSessionName) actions.switchToSession(session.tmuxSessionName);
1432
1623
  actions.selectWindow(session.tmuxWindowId);
@@ -1487,6 +1678,7 @@ function handleNavigateKey(input, key, state2, actions) {
1487
1678
  return;
1488
1679
  }
1489
1680
  if (input === "n") {
1681
+ if (enterComposeMode(state2, { kind: "new-session" }, actions)) return;
1490
1682
  const editor = actions.resolveEditor();
1491
1683
  try {
1492
1684
  const content = actions.editInPopup(state2.cwd, editor);
@@ -1553,6 +1745,7 @@ function handleNavigateKey(input, key, state2, actions) {
1553
1745
  notify(state2, "Session already active");
1554
1746
  return;
1555
1747
  }
1748
+ if (enterComposeMode(state2, { kind: "resume", sessionId: state2.selectedSessionId }, actions)) return;
1556
1749
  state2.mode = "resume";
1557
1750
  state2.inputText = "";
1558
1751
  state2.inputCursorPos = 0;
@@ -1568,6 +1761,7 @@ function handleNavigateKey(input, key, state2, actions) {
1568
1761
  notify(state2, "Session not completed");
1569
1762
  return;
1570
1763
  }
1764
+ if (enterComposeMode(state2, { kind: "continue", sessionId: state2.selectedSessionId }, actions)) return;
1571
1765
  state2.mode = "continue";
1572
1766
  state2.inputText = "";
1573
1767
  state2.inputCursorPos = 0;
@@ -1638,6 +1832,7 @@ function handleNavigateKey(input, key, state2, actions) {
1638
1832
  }
1639
1833
  }
1640
1834
  function handleKeypress(input, key, state2, actions) {
1835
+ if (state2.mode === "compose") return;
1641
1836
  if (INPUT_MODES.has(state2.mode)) {
1642
1837
  handleInputBarKey(input, key, state2, actions);
1643
1838
  } else if (state2.mode === "leader" || state2.mode === "copy-menu" || state2.mode === "help") {
@@ -2025,9 +2220,9 @@ function send(request) {
2025
2220
 
2026
2221
  // src/tui/lib/tmux.ts
2027
2222
  import { execSync } from "child_process";
2028
- import { join as join2 } from "path";
2029
- import { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync, existsSync, mkdirSync } from "fs";
2030
- import { tmpdir } from "os";
2223
+ import { join as join3 } from "path";
2224
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdtempSync, rmSync, cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
2225
+ import { tmpdir as tmpdir2 } from "os";
2031
2226
  function selectWindow(windowId) {
2032
2227
  execSafe(`tmux select-window -t "${windowId}"`);
2033
2228
  }
@@ -2044,9 +2239,9 @@ function listAllWindowIds() {
2044
2239
  }
2045
2240
  var companionPaneId = null;
2046
2241
  function setupCompanionPlugin() {
2047
- const srcDir = join2(import.meta.dirname, "templates", "companion-plugin");
2048
- const destDir = join2(globalDir(), "companion-plugin");
2049
- if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
2242
+ const srcDir = join3(import.meta.dirname, "templates", "companion-plugin");
2243
+ const destDir = join3(globalDir(), "companion-plugin");
2244
+ if (!existsSync2(destDir)) mkdirSync2(destDir, { recursive: true });
2050
2245
  cpSync(srcDir, destDir, { recursive: true });
2051
2246
  return destDir;
2052
2247
  }
@@ -2059,18 +2254,18 @@ function openCompanionPane(cwd2) {
2059
2254
  return;
2060
2255
  }
2061
2256
  const pluginDir = setupCompanionPlugin();
2062
- const templatePath = join2(import.meta.dirname, "templates", "dashboard-claude.md");
2257
+ const templatePath = join3(import.meta.dirname, "templates", "dashboard-claude.md");
2063
2258
  let template;
2064
2259
  try {
2065
- template = readFileSync(templatePath, "utf-8");
2260
+ template = readFileSync2(templatePath, "utf-8");
2066
2261
  } catch {
2067
2262
  template = `You are a Sisyphus dashboard companion. Help the user manage multi-agent sessions.
2068
2263
  Project: ${cwd2}
2069
2264
  Run \`sisyphus list\` and \`sisyphus status\` to see current state.`;
2070
2265
  }
2071
2266
  const rendered = template.replace(/\{\{CWD\}\}/g, cwd2);
2072
- const promptPath = join2(globalDir(), "dashboard-companion-prompt.md");
2073
- writeFileSync(promptPath, rendered, "utf-8");
2267
+ const promptPath = join3(globalDir(), "dashboard-companion-prompt.md");
2268
+ writeFileSync2(promptPath, rendered, "utf-8");
2074
2269
  const pathEnv = augmentedPath();
2075
2270
  const claudeCmd = `SISYPHUS_COMPANION_CWD=${shellQuote(cwd2)} PATH=${shellQuote(pathEnv)} claude --dangerously-skip-permissions --plugin-dir ${shellQuote(pluginDir)} --append-system-prompt "$(cat ${shellQuote(promptPath)})"`;
2076
2271
  const result = exec(
@@ -2083,12 +2278,12 @@ function switchToSession(sessionName) {
2083
2278
  execSafe(`tmux switch-client -t "${sessionName}"`);
2084
2279
  }
2085
2280
  function editInPopup(cwd2, editor, opts) {
2086
- const tmpDir = mkdtempSync(join2(tmpdir(), "sisyphus-"));
2087
- const filePath = join2(tmpDir, "input.md");
2281
+ const tmpDir = mkdtempSync(join3(tmpdir2(), "sisyphus-"));
2282
+ const filePath = join3(tmpDir, "input.md");
2088
2283
  try {
2089
- writeFileSync(filePath, opts?.content ? opts.content : "", "utf-8");
2284
+ writeFileSync2(filePath, opts?.content ? opts.content : "", "utf-8");
2090
2285
  openEditorPopup(cwd2, editor, filePath, opts?.size);
2091
- const result = readFileSync(filePath, "utf-8").trim();
2286
+ const result = readFileSync2(filePath, "utf-8").trim();
2092
2287
  return result || null;
2093
2288
  } finally {
2094
2289
  rmSync(tmpDir, { recursive: true, force: true });
@@ -2117,6 +2312,19 @@ function openClaudeResumePopup(cwd2, claudeSessionId) {
2117
2312
  { stdio: "inherit", env: EXEC_ENV }
2118
2313
  );
2119
2314
  }
2315
+ function openClaudeResumeSession(cwd2, claudeSessionId, sessionLabel) {
2316
+ const pathEnv = augmentedPath();
2317
+ const cmd = `PATH=${shellQuote(pathEnv)} claude --resume ${shellQuote(claudeSessionId)}`;
2318
+ const sessionName = `sisyphus-${sessionLabel}-resume`;
2319
+ exec(`tmux new-session -d -s ${shellQuote(sessionName)} -c ${shellQuote(cwd2)} ${shellQuote(cmd)}`);
2320
+ execSafe(`tmux set-option -t ${shellQuote(sessionName)} @sisyphus_cwd ${shellQuote(cwd2.replace(/\/+$/, ""))}`);
2321
+ const paneTarget = `${sessionName}:`;
2322
+ execSafe(`tmux set -w -t ${shellQuote(paneTarget)} pane-border-status top`);
2323
+ execSafe(`tmux set -w -t ${shellQuote(paneTarget)} allow-rename off`);
2324
+ execSafe(`tmux set -w -t ${shellQuote(paneTarget)} automatic-rename off`);
2325
+ execSafe(`tmux select-pane -t ${shellQuote(paneTarget)} -T ${shellQuote(`ssph:resume ${sessionLabel}`)}`);
2326
+ return sessionName;
2327
+ }
2120
2328
  function openEditorPopup(cwd2, editor, filePath, size) {
2121
2329
  const { w = "90%", h = "90%" } = size ?? {};
2122
2330
  const editorBin = editor.split(/\s+/)[0].split("/").pop();
@@ -2961,7 +3169,7 @@ function renderLogsRows(rect, state2) {
2961
3169
  }
2962
3170
 
2963
3171
  // src/tui/panels/nvim-detail.ts
2964
- function renderNvimDetailRows(rect, bridge, focused, editable, statusRows) {
3172
+ function renderNvimDetailRows(rect, bridge, focused, editable, statusRows, composing = false) {
2965
3173
  const { w, h } = rect;
2966
3174
  const rows = new Array(h);
2967
3175
  const borderColor = focused ? "cyan" : "gray";
@@ -2969,7 +3177,7 @@ function renderNvimDetailRows(rect, bridge, focused, editable, statusRows) {
2969
3177
  const reset = "\x1B[0m";
2970
3178
  const innerW = w - 4;
2971
3179
  if (focused) {
2972
- const badgeText = editable ? " EDIT " : " NVIM ";
3180
+ const badgeText = composing ? " COMPOSE " : editable ? " EDIT " : " NVIM ";
2973
3181
  const badgeLen = badgeText.length;
2974
3182
  const dashesLeft = 2;
2975
3183
  const dashesRight = Math.max(0, w - 2 - dashesLeft - badgeLen);
@@ -3019,6 +3227,13 @@ function renderNotificationRow(buf, y, notification, error) {
3019
3227
  }
3020
3228
  function renderInputBar(buf, y, state2) {
3021
3229
  const { mode, inputText, inputCursorPos } = state2;
3230
+ if (mode === "compose") {
3231
+ const action = state2.composeAction;
3232
+ const label = action ? COMPOSE_HEADERS[action.kind] : "Compose";
3233
+ const content2 = `\x1B[1;33mCOMPOSE\x1B[0m\x1B[2m ${label} \xB7 :w to submit \xB7 Tab to cancel\x1B[0m`;
3234
+ writeClipped(buf, 1, y, content2, buf.width - 2);
3235
+ return;
3236
+ }
3022
3237
  if (mode === "navigate") {
3023
3238
  const content2 = `\x1B[2mPress [m] to message orchestrator, [n] for new session\x1B[0m`;
3024
3239
  writeClipped(buf, 1, y, content2, buf.width - 2);
@@ -3040,6 +3255,7 @@ var SEP = D("\u2502 ");
3040
3255
  function renderStatusLine(buf, y, state2, cursorNodeType) {
3041
3256
  const { mode, focusPane } = state2;
3042
3257
  if (mode === "report-detail") return;
3258
+ if (mode === "compose") return;
3043
3259
  let content;
3044
3260
  if (mode === "leader") {
3045
3261
  content = `\x1B[1;35mLEADER\x1B[0m` + D(" press a command key or [esc] to cancel");
@@ -3169,9 +3385,17 @@ function renderHelpOverlay(buf, rows, cols) {
3169
3385
 
3170
3386
  // src/tui/lib/nvim-bridge.ts
3171
3387
  import { execSync as execSync3 } from "child_process";
3172
- import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, unlinkSync } from "fs";
3173
- import { join as join3 } from "path";
3174
- import { tmpdir as tmpdir2 } from "os";
3388
+ import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2, readFileSync as readFileSync3, statSync, existsSync as existsSync3 } from "fs";
3389
+ import { join as join4 } from "path";
3390
+ import { tmpdir as tmpdir3 } from "os";
3391
+ function simpleHash(str) {
3392
+ let hash = 0;
3393
+ for (let i = 0; i < str.length; i++) {
3394
+ hash = (hash << 5) - hash + str.charCodeAt(i);
3395
+ hash |= 0;
3396
+ }
3397
+ return Math.abs(hash).toString(36);
3398
+ }
3175
3399
  var NvimBridge = class {
3176
3400
  pty = null;
3177
3401
  xterm = null;
@@ -3183,20 +3407,28 @@ var NvimBridge = class {
3183
3407
  ready = false;
3184
3408
  dirty = true;
3185
3409
  available = false;
3410
+ respawning = false;
3411
+ /** Set true once nvim has been ready at least once — prevents respawn during initial startup */
3412
+ wasReady = false;
3186
3413
  /** DECSCUSR cursor style: 0=default, 1=blinking block, 2=steady block, 3=blinking underline, 4=steady underline, 5=blinking bar, 6=steady bar */
3187
3414
  cursorStyle = 0;
3188
3415
  cachedRows = null;
3189
3416
  nvimPath = "nvim";
3190
3417
  pendingFiles = null;
3191
3418
  fileDebounceTimer = null;
3419
+ cmdDir;
3192
3420
  cmdFile;
3421
+ /** Tracked editable files: path → { basePath (snapshot), mtimeMs } */
3422
+ editableFiles = /* @__PURE__ */ new Map();
3423
+ mergeStatusFile;
3193
3424
  constructor(cols, rows, onRender) {
3194
3425
  this._cols = cols;
3195
3426
  this._rows = rows;
3196
3427
  this.onRender = onRender;
3197
- const cmdDir = join3(tmpdir2(), "sisyphus-nvim");
3198
- mkdirSync2(cmdDir, { recursive: true });
3199
- this.cmdFile = join3(cmdDir, `cmd-${process.pid}.lua`);
3428
+ this.cmdDir = join4(tmpdir3(), "sisyphus-nvim");
3429
+ mkdirSync3(this.cmdDir, { recursive: true });
3430
+ this.cmdFile = join4(this.cmdDir, `cmd-${process.pid}.lua`);
3431
+ this.mergeStatusFile = join4(this.cmdDir, `merge-status-${process.pid}.txt`);
3200
3432
  try {
3201
3433
  this.nvimPath = execSync3("which nvim", { stdio: "pipe" }).toString().trim();
3202
3434
  this.available = true;
@@ -3209,71 +3441,111 @@ var NvimBridge = class {
3209
3441
  this.ready = false;
3210
3442
  });
3211
3443
  }
3212
- async spawn() {
3213
- const { spawn } = await import("node-pty");
3214
- const xtermModule = await import("@xterm/headless");
3215
- const { Terminal } = xtermModule.default;
3216
- this.xterm = new Terminal({
3217
- cols: this._cols,
3218
- rows: this._rows,
3219
- allowProposedApi: true
3220
- });
3221
- const nvimArgs = [
3222
- // Pre-init: only settings needed before user config loads
3223
- "--cmd",
3224
- [
3225
- "set noswapfile",
3226
- "set nobackup",
3227
- "set nowritebackup",
3228
- "set hidden",
3229
- "set autoread"
3230
- ].join(" | "),
3231
- // Post-init: cosmetic overrides applied AFTER user config (LazyVim, etc.)
3232
- "-c",
3233
- [
3234
- "set laststatus=0",
3235
- "set showtabline=2",
3236
- "set signcolumn=no",
3237
- "set nonumber",
3238
- "set noruler",
3239
- "set noshowcmd",
3240
- "set noshowmode",
3241
- "set shortmess+=F",
3242
- "set fillchars=eob:\\ ",
3243
- "set scrolloff=3"
3244
- ].join(" | "),
3245
- // Suppress LSP — prevent servers from ever starting (avoids exit warnings)
3246
- "--cmd",
3247
- "lua vim.lsp.start = function() end",
3248
- // Poll-based command executor: reads lua from temp file no command-line flash
3249
- "-c",
3250
- `lua local _t = vim.loop.new_timer(); _t:start(100, 50, vim.schedule_wrap(function() local f = io.open('${this.cmdFile.replace(/'/g, "\\'")}', 'r'); if not f then return end; local c = f:read('*a'); f:close(); os.remove('${this.cmdFile.replace(/'/g, "\\'")}'); if c and #c > 0 then local fn = loadstring(c); if fn then pcall(fn) end end end))`
3251
- ];
3252
- this.pty = spawn(this.nvimPath, nvimArgs, {
3253
- name: "xterm-256color",
3254
- cols: this._cols,
3255
- rows: this._rows,
3256
- env: { ...process.env, TERM: "xterm-256color" }
3257
- });
3258
- this.pty.onData((data) => {
3259
- const csMatch = data.match(/\x1b\[(\d+) q/);
3260
- if (csMatch) this.cursorStyle = parseInt(csMatch[1], 10);
3261
- this.xterm.write(data);
3262
- this.dirty = true;
3263
- this.cachedRows = null;
3264
- this.debouncedRender();
3265
- });
3266
- this.pty.onExit(() => {
3267
- this.ready = false;
3268
- });
3269
- setTimeout(() => {
3270
- if (this.pty) {
3271
- this.ready = true;
3444
+ spawn() {
3445
+ return Promise.all([
3446
+ import("node-pty"),
3447
+ import("@xterm/headless")
3448
+ ]).then(([nodePty, xtermModule]) => new Promise((resolve) => {
3449
+ const { spawn } = nodePty;
3450
+ const { Terminal } = xtermModule.default;
3451
+ this.xterm = new Terminal({
3452
+ cols: this._cols,
3453
+ rows: this._rows,
3454
+ allowProposedApi: true
3455
+ });
3456
+ const nvimArgs = [
3457
+ // Pre-init: only settings needed before user config loads
3458
+ "--cmd",
3459
+ [
3460
+ "set noswapfile",
3461
+ "set nobackup",
3462
+ "set nowritebackup",
3463
+ "set hidden",
3464
+ "set autoread"
3465
+ ].join(" | "),
3466
+ // Post-init: cosmetic overrides applied AFTER user config (LazyVim, etc.)
3467
+ "-c",
3468
+ [
3469
+ "set laststatus=0",
3470
+ "set showtabline=2",
3471
+ "set signcolumn=no",
3472
+ "set nonumber",
3473
+ "set noruler",
3474
+ "set noshowcmd",
3475
+ "set noshowmode",
3476
+ "set shortmess+=F",
3477
+ "set fillchars=eob:\\ ",
3478
+ "set scrolloff=3"
3479
+ ].join(" | "),
3480
+ // Suppress LSP prevent servers from ever starting (avoids exit warnings)
3481
+ "--cmd",
3482
+ "lua vim.lsp.start = function() end",
3483
+ // Poll-based command executor: reads lua from temp file — no command-line flash
3484
+ "-c",
3485
+ `lua local _t = vim.loop.new_timer(); _t:start(100, 50, vim.schedule_wrap(function() local f = io.open('${this.cmdFile.replace(/'/g, "\\'")}', 'r'); if not f then return end; local c = f:read('*a'); f:close(); os.remove('${this.cmdFile.replace(/'/g, "\\'")}'); if c and #c > 0 then local fn = loadstring(c); if fn then pcall(fn) end end end))`
3486
+ ];
3487
+ this.pty = spawn(this.nvimPath, nvimArgs, {
3488
+ name: "xterm-256color",
3489
+ cols: this._cols,
3490
+ rows: this._rows,
3491
+ env: { ...process.env, TERM: "xterm-256color" }
3492
+ });
3493
+ let settled = false;
3494
+ this.pty.onData((data) => {
3495
+ const csMatch = data.match(/\x1b\[(\d+) q/);
3496
+ if (csMatch) this.cursorStyle = parseInt(csMatch[1], 10);
3497
+ this.xterm.write(data);
3498
+ this.dirty = true;
3499
+ this.cachedRows = null;
3500
+ this.debouncedRender();
3501
+ });
3502
+ this.pty.onExit(() => {
3503
+ this.ready = false;
3272
3504
  this.dirty = true;
3273
3505
  this.cachedRows = null;
3274
3506
  this.onRender();
3507
+ if (!settled) {
3508
+ settled = true;
3509
+ resolve();
3510
+ }
3511
+ });
3512
+ setTimeout(() => {
3513
+ if (this.pty) {
3514
+ this.ready = true;
3515
+ this.wasReady = true;
3516
+ this.dirty = true;
3517
+ this.cachedRows = null;
3518
+ this.onRender();
3519
+ }
3520
+ if (!settled) {
3521
+ settled = true;
3522
+ resolve();
3523
+ }
3524
+ }, 500);
3525
+ }));
3526
+ }
3527
+ /**
3528
+ * Respawn nvim after it exited (e.g. user quit during compose).
3529
+ * Cleans up dead instances and re-runs spawn().
3530
+ */
3531
+ async respawn() {
3532
+ if (!this.available) return;
3533
+ if (this.xterm) {
3534
+ this.xterm.dispose();
3535
+ this.xterm = null;
3536
+ }
3537
+ if (this.pty) {
3538
+ try {
3539
+ this.pty.kill();
3540
+ } catch {
3275
3541
  }
3276
- }, 500);
3542
+ this.pty = null;
3543
+ }
3544
+ this.ready = false;
3545
+ this.dirty = true;
3546
+ this.cachedRows = null;
3547
+ this.currentFile = null;
3548
+ await this.spawn();
3277
3549
  }
3278
3550
  debouncedRender() {
3279
3551
  if (this.renderTimer !== null) return;
@@ -3287,7 +3559,7 @@ var NvimBridge = class {
3287
3559
  * Writes lua to a temp file — a libuv timer in nvim polls and executes it.
3288
3560
  */
3289
3561
  execLua(lua) {
3290
- writeFileSync2(this.cmdFile, lua);
3562
+ writeFileSync3(this.cmdFile, lua);
3291
3563
  }
3292
3564
  openFile(path, readonly = true) {
3293
3565
  if (!this.pty || !this.ready) return;
@@ -3312,6 +3584,7 @@ var NvimBridge = class {
3312
3584
  }
3313
3585
  executeOpenFiles(files) {
3314
3586
  if (!this.pty || !this.ready) return;
3587
+ this.trackEditableFiles(files);
3315
3588
  const escapeLua = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3316
3589
  const stmts = [
3317
3590
  "for _, b in ipairs(vim.api.nvim_list_bufs()) do pcall(vim.api.nvim_buf_delete, b, {force=true}) end"
@@ -3337,6 +3610,37 @@ var NvimBridge = class {
3337
3610
  this.pty.write(":setlocal noreadonly modifiable\r");
3338
3611
  }
3339
3612
  }
3613
+ /**
3614
+ * Open a temp file for compose mode: clears buffers, opens writable,
3615
+ * installs BufWritePost autocmd that writes a signal file on :w,
3616
+ * and enters insert mode.
3617
+ */
3618
+ openComposeFile(tempPath, signalPath) {
3619
+ if (!this.pty || !this.ready) return;
3620
+ if (this.fileDebounceTimer !== null) {
3621
+ clearTimeout(this.fileDebounceTimer);
3622
+ this.fileDebounceTimer = null;
3623
+ this.pendingFiles = null;
3624
+ }
3625
+ const escapeLua = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3626
+ const eSig = escapeLua(signalPath);
3627
+ const lua = [
3628
+ // Clear all existing buffers
3629
+ "for _, b in ipairs(vim.api.nvim_list_bufs()) do pcall(vim.api.nvim_buf_delete, b, {force=true}) end",
3630
+ // Open temp file as writable
3631
+ `vim.cmd('edit! ${escapeLua(tempPath)}')`,
3632
+ "vim.bo.readonly = false",
3633
+ "vim.bo.modifiable = true",
3634
+ // Install BufWritePost autocmd — writes submit signal on :w
3635
+ `vim.api.nvim_create_autocmd('BufWritePost', { buffer = 0, callback = function() local f = io.open('${eSig}', 'w'); if f then f:write('1'); f:close() end end })`,
3636
+ // Install QuitPre autocmd — writes cancel signal if no submit signal exists (quit proceeds, nvim may exit)
3637
+ `vim.api.nvim_create_autocmd('QuitPre', { buffer = 0, callback = function() local sf = io.open('${eSig}', 'r'); if sf then sf:close() else local f = io.open('${eSig}', 'w'); if f then f:write('cancel'); f:close() end end end })`,
3638
+ // Enter insert mode
3639
+ "vim.cmd('startinsert')"
3640
+ ].join("; ");
3641
+ this.execLua(`(function() ${lua} end)()`);
3642
+ this.currentFile = tempPath;
3643
+ }
3340
3644
  closeAllTabs() {
3341
3645
  if (!this.pty || !this.ready) return;
3342
3646
  this.execLua('for _, b in ipairs(vim.api.nvim_list_bufs()) do pcall(vim.api.nvim_buf_delete, b, {force=true}) end; vim.cmd("enew!")');
@@ -3470,6 +3774,113 @@ var NvimBridge = class {
3470
3774
  y: this.xterm.buffer.active.cursorY
3471
3775
  };
3472
3776
  }
3777
+ /**
3778
+ * Snapshot editable files on disk so we have a base for 3-way merge.
3779
+ * Called when files are opened in nvim tabs.
3780
+ */
3781
+ trackEditableFiles(files) {
3782
+ for (const [, info] of this.editableFiles) {
3783
+ try {
3784
+ unlinkSync2(info.basePath);
3785
+ } catch {
3786
+ }
3787
+ }
3788
+ this.editableFiles.clear();
3789
+ for (const file of files) {
3790
+ if (file.readonly) continue;
3791
+ try {
3792
+ const content = readFileSync3(file.path, "utf-8");
3793
+ const mtime = statSync(file.path).mtimeMs;
3794
+ const basePath = join4(this.cmdDir, `base-${simpleHash(file.path)}.md`);
3795
+ writeFileSync3(basePath, content, "utf-8");
3796
+ this.editableFiles.set(file.path, { basePath, mtimeMs: mtime });
3797
+ } catch {
3798
+ }
3799
+ }
3800
+ }
3801
+ /**
3802
+ * Check editable files for external changes and 3-way merge if the buffer
3803
+ * is dirty. Falls back to regular checktime for clean/readonly buffers.
3804
+ *
3805
+ * Returns a merge status string from the *previous* cycle ('clean' or 'union')
3806
+ * if a merge completed, or null.
3807
+ */
3808
+ mergeCheckOrReload() {
3809
+ if (!this.pty || !this.ready) return null;
3810
+ let mergeResult = null;
3811
+ try {
3812
+ if (existsSync3(this.mergeStatusFile)) {
3813
+ const content = readFileSync3(this.mergeStatusFile, "utf-8").trim();
3814
+ unlinkSync2(this.mergeStatusFile);
3815
+ if (content) {
3816
+ const lines = content.split("\n");
3817
+ mergeResult = lines.some((l) => l === "union") ? "union" : "clean";
3818
+ }
3819
+ }
3820
+ } catch {
3821
+ }
3822
+ if (mergeResult) {
3823
+ for (const [filePath, info] of this.editableFiles) {
3824
+ try {
3825
+ info.mtimeMs = statSync(filePath).mtimeMs;
3826
+ } catch {
3827
+ }
3828
+ }
3829
+ this.execLua('vim.cmd("checktime")');
3830
+ return mergeResult;
3831
+ }
3832
+ const changedFiles = [];
3833
+ for (const [filePath, info] of this.editableFiles) {
3834
+ try {
3835
+ const currentMtime = statSync(filePath).mtimeMs;
3836
+ if (currentMtime !== info.mtimeMs) {
3837
+ changedFiles.push({ filePath, basePath: info.basePath });
3838
+ info.mtimeMs = currentMtime;
3839
+ }
3840
+ } catch {
3841
+ }
3842
+ }
3843
+ if (changedFiles.length === 0) {
3844
+ this.execLua('vim.cmd("checktime")');
3845
+ return null;
3846
+ }
3847
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3848
+ const mergeBlocks = changedFiles.map(({ filePath, basePath }) => `
3849
+ do
3850
+ local bufnr = vim.fn.bufnr('${esc(filePath)}')
3851
+ if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then
3852
+ if vim.bo[bufnr].modified then
3853
+ local buf_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
3854
+ local buf_content = table.concat(buf_lines, '\\n') .. '\\n'
3855
+ local tmp = '${esc(this.cmdDir)}/merge-current-${process.pid}.md'
3856
+ local f = io.open(tmp, 'w')
3857
+ if f then f:write(buf_content); f:close() end
3858
+ local result = vim.fn.system({'git', 'merge-file', '-p', '--union', tmp, '${esc(basePath)}', '${esc(filePath)}'})
3859
+ local merged_lines = vim.split(result, '\\n', {trimempty = false})
3860
+ if #merged_lines > 0 and merged_lines[#merged_lines] == '' then
3861
+ table.remove(merged_lines)
3862
+ end
3863
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, merged_lines)
3864
+ local out = io.open('${esc(filePath)}', 'w')
3865
+ if out then out:write(result); out:close() end
3866
+ vim.bo[bufnr].modified = false
3867
+ local bf = io.open('${esc(basePath)}', 'w')
3868
+ if bf then bf:write(result); bf:close() end
3869
+ local sf = io.open('${esc(this.mergeStatusFile)}', 'a')
3870
+ if sf then sf:write(vim.v.shell_error == 0 and 'clean' or 'union'); sf:write('\\n'); sf:close() end
3871
+ else
3872
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
3873
+ local bf = io.open('${esc(basePath)}', 'w')
3874
+ if bf then bf:write(table.concat(lines, '\\n') .. '\\n'); bf:close() end
3875
+ end
3876
+ end
3877
+ end`).join("\n");
3878
+ this.execLua(`(function()
3879
+ pcall(function() vim.cmd('checktime') end)
3880
+ ${mergeBlocks}
3881
+ end)()`);
3882
+ return null;
3883
+ }
3473
3884
  checktime() {
3474
3885
  if (this.pty && this.ready) {
3475
3886
  this.execLua('vim.cmd("checktime")');
@@ -3497,23 +3908,34 @@ var NvimBridge = class {
3497
3908
  }
3498
3909
  this.ready = false;
3499
3910
  try {
3500
- unlinkSync(this.cmdFile);
3911
+ unlinkSync2(this.cmdFile);
3501
3912
  } catch {
3502
3913
  }
3914
+ try {
3915
+ unlinkSync2(this.mergeStatusFile);
3916
+ } catch {
3917
+ }
3918
+ for (const [, info] of this.editableFiles) {
3919
+ try {
3920
+ unlinkSync2(info.basePath);
3921
+ } catch {
3922
+ }
3923
+ }
3924
+ this.editableFiles.clear();
3503
3925
  }
3504
3926
  };
3505
3927
 
3506
3928
  // src/tui/lib/overview-writer.ts
3507
- import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, renameSync, existsSync as existsSync2 } from "fs";
3508
- import { join as join4 } from "path";
3929
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, renameSync, existsSync as existsSync4 } from "fs";
3930
+ import { join as join5 } from "path";
3509
3931
  function atomicWrite(filePath, content) {
3510
3932
  const tmp = filePath + ".tmp." + process.pid;
3511
- writeFileSync3(tmp, content, "utf-8");
3933
+ writeFileSync4(tmp, content, "utf-8");
3512
3934
  renameSync(tmp, filePath);
3513
3935
  }
3514
3936
  function ensureTuiDir(cwd2, sessionId) {
3515
3937
  const dir = tuiScratchDir(cwd2, sessionId);
3516
- mkdirSync3(dir, { recursive: true });
3938
+ mkdirSync4(dir, { recursive: true });
3517
3939
  return dir;
3518
3940
  }
3519
3941
  function formatTimestamp(iso) {
@@ -3691,11 +4113,11 @@ function resolveNvimFile(state2, cursorNode, detailCtx, cwd2) {
3691
4113
  if (!session) return null;
3692
4114
  const files = [];
3693
4115
  const gp = goalPath(cwd2, sessionId);
3694
- if (existsSync2(gp)) files.push({ path: gp, readonly: false });
4116
+ if (existsSync4(gp)) files.push({ path: gp, readonly: false });
3695
4117
  const rp = roadmapPath(cwd2, sessionId);
3696
- if (existsSync2(rp)) files.push({ path: rp, readonly: false });
4118
+ if (existsSync4(rp)) files.push({ path: rp, readonly: false });
3697
4119
  const sp = strategyPath(cwd2, sessionId);
3698
- if (existsSync2(sp)) files.push({ path: sp, readonly: false });
4120
+ if (existsSync4(sp)) files.push({ path: sp, readonly: false });
3699
4121
  if (files.length === 0) return null;
3700
4122
  return { files };
3701
4123
  }
@@ -3706,7 +4128,7 @@ function resolveNvimFile(state2, cursorNode, detailCtx, cwd2) {
3706
4128
  );
3707
4129
  if (!cycle) return null;
3708
4130
  const dir = ensureTuiDir(cwd2, sessionId);
3709
- const filePath = join4(dir, `cycle-${cursorNode.cycleNumber}.md`);
4131
+ const filePath = join5(dir, `cycle-${cursorNode.cycleNumber}.md`);
3710
4132
  atomicWrite(filePath, composeCycleDetail(session, cycle));
3711
4133
  return { files: [{ path: filePath, readonly: true }] };
3712
4134
  }
@@ -3715,7 +4137,7 @@ function resolveNvimFile(state2, cursorNode, detailCtx, cwd2) {
3715
4137
  const agent = session.agents.find((a) => a.id === cursorNode.agentId);
3716
4138
  if (!agent) return null;
3717
4139
  const dir = ensureTuiDir(cwd2, sessionId);
3718
- const filePath = join4(dir, `${agent.id}.md`);
4140
+ const filePath = join5(dir, `${agent.id}.md`);
3719
4141
  atomicWrite(filePath, composeAgentDetail(agent, state2.cachedReportBlocks.get(agent.id) ?? []));
3720
4142
  return { files: [{ path: filePath, readonly: true }] };
3721
4143
  }
@@ -3723,14 +4145,14 @@ function resolveNvimFile(state2, cursorNode, detailCtx, cwd2) {
3723
4145
  const agent = session?.agents.find((a) => a.id === cursorNode.agentId);
3724
4146
  if (agent && agent.reports.length > 0) {
3725
4147
  const report = agent.reports[cursorNode.reportIndex];
3726
- if (report?.filePath && existsSync2(report.filePath)) {
4148
+ if (report?.filePath && existsSync4(report.filePath)) {
3727
4149
  return { files: [{ path: report.filePath, readonly: true }] };
3728
4150
  }
3729
4151
  }
3730
4152
  return null;
3731
4153
  }
3732
4154
  case "context-file": {
3733
- if (cursorNode.filePath && existsSync2(cursorNode.filePath)) {
4155
+ if (cursorNode.filePath && existsSync4(cursorNode.filePath)) {
3734
4156
  return { files: [{ path: cursorNode.filePath, readonly: false }] };
3735
4157
  }
3736
4158
  return null;
@@ -3739,7 +4161,7 @@ function resolveNvimFile(state2, cursorNode, detailCtx, cwd2) {
3739
4161
  case "message": {
3740
4162
  if (!session || session.messages.length === 0) return null;
3741
4163
  const dir = ensureTuiDir(cwd2, sessionId);
3742
- const filePath = join4(dir, "messages.md");
4164
+ const filePath = join5(dir, "messages.md");
3743
4165
  atomicWrite(filePath, composeMessages(session));
3744
4166
  return { files: [{ path: filePath, readonly: true }] };
3745
4167
  }
@@ -3877,28 +4299,28 @@ function startApp(state2, cleanup2) {
3877
4299
  }
3878
4300
  try {
3879
4301
  const pp = roadmapPath(state2.cwd, state2.selectedSessionId);
3880
- if (existsSync3(pp)) {
3881
- planContent = readFileSync2(pp, "utf-8");
4302
+ if (existsSync5(pp)) {
4303
+ planContent = readFileSync4(pp, "utf-8");
3882
4304
  }
3883
4305
  } catch {
3884
4306
  }
3885
4307
  try {
3886
4308
  const gp = goalPath(state2.cwd, state2.selectedSessionId);
3887
- if (existsSync3(gp)) {
3888
- goalContent = readFileSync2(gp, "utf-8");
4309
+ if (existsSync5(gp)) {
4310
+ goalContent = readFileSync4(gp, "utf-8");
3889
4311
  }
3890
4312
  } catch {
3891
4313
  }
3892
4314
  try {
3893
4315
  const sp = strategyPath(state2.cwd, state2.selectedSessionId);
3894
- if (existsSync3(sp)) {
3895
- strategyContent = readFileSync2(sp, "utf-8");
4316
+ if (existsSync5(sp)) {
4317
+ strategyContent = readFileSync4(sp, "utf-8");
3896
4318
  }
3897
4319
  } catch {
3898
4320
  }
3899
4321
  try {
3900
4322
  const ld = logsDir(state2.cwd, state2.selectedSessionId);
3901
- if (existsSync3(ld)) {
4323
+ if (existsSync5(ld)) {
3902
4324
  if (state2.selectedSessionId !== cachedLogSessionId) {
3903
4325
  cachedLogFiles = /* @__PURE__ */ new Map();
3904
4326
  cachedLogSessionId = state2.selectedSessionId;
@@ -3909,13 +4331,13 @@ function startApp(state2, cleanup2) {
3909
4331
  if (!fileSet.has(key)) cachedLogFiles.delete(key);
3910
4332
  }
3911
4333
  for (const f of files) {
3912
- const filePath = join5(ld, f);
3913
- const mtime = statSync(filePath).mtimeMs;
4334
+ const filePath = join6(ld, f);
4335
+ const mtime = statSync2(filePath).mtimeMs;
3914
4336
  const cached = cachedLogFiles.get(f);
3915
4337
  if (!cached || cached.mtime !== mtime) {
3916
4338
  const match = f.match(/cycle-(\d+)\.md$/);
3917
4339
  const cycle = match ? parseInt(match[1], 10) : 0;
3918
- const content = readFileSync2(filePath, "utf-8");
4340
+ const content = readFileSync4(filePath, "utf-8");
3919
4341
  cachedLogFiles.set(f, { mtime, cycle, content });
3920
4342
  }
3921
4343
  }
@@ -3929,7 +4351,7 @@ function startApp(state2, cleanup2) {
3929
4351
  }
3930
4352
  try {
3931
4353
  const cd = contextDir(state2.cwd, state2.selectedSessionId);
3932
- if (existsSync3(cd)) {
4354
+ if (existsSync5(cd)) {
3933
4355
  contextFiles = readdirSync(cd).filter((f) => !f.startsWith(".")).sort();
3934
4356
  }
3935
4357
  } catch {
@@ -3952,7 +4374,12 @@ function startApp(state2, cleanup2) {
3952
4374
  state2.contextFiles = contextFiles;
3953
4375
  state2.error = null;
3954
4376
  if (state2.nvimEnabled && state2.nvimBridge?.ready && state2.prevNvimFile) {
3955
- state2.nvimBridge.checktime();
4377
+ const mergeStatus = state2.nvimBridge.mergeCheckOrReload();
4378
+ if (mergeStatus === "clean") {
4379
+ notify(state2, "Auto-merged external changes");
4380
+ } else if (mergeStatus === "union") {
4381
+ notify(state2, "Auto-merged overlapping edits \u2014 review buffer");
4382
+ }
3956
4383
  }
3957
4384
  requestRender();
3958
4385
  } catch (err) {
@@ -4038,8 +4465,8 @@ function startApp(state2, cleanup2) {
4038
4465
  if (cursorNode.filePath !== cachedContextFilePath) {
4039
4466
  cachedContextFilePath = cursorNode.filePath;
4040
4467
  try {
4041
- if (existsSync3(cursorNode.filePath)) {
4042
- cachedContextFileContent = readFileSync2(cursorNode.filePath, "utf-8");
4468
+ if (existsSync5(cursorNode.filePath)) {
4469
+ cachedContextFileContent = readFileSync4(cursorNode.filePath, "utf-8");
4043
4470
  } else {
4044
4471
  cachedContextFileContent = null;
4045
4472
  }
@@ -4092,19 +4519,42 @@ function startApp(state2, cleanup2) {
4092
4519
  contextFileContent
4093
4520
  };
4094
4521
  let detailRows;
4522
+ const composing = state2.mode === "compose";
4523
+ if (state2.nvimEnabled && state2.nvimBridge && state2.nvimBridge.wasReady && !state2.nvimBridge.ready && !state2.nvimBridge.respawning) {
4524
+ state2.nvimBridge.respawning = true;
4525
+ state2.prevNvimFile = null;
4526
+ state2.nvimBridge.respawn().then(() => {
4527
+ state2.nvimBridge.respawning = false;
4528
+ requestRender();
4529
+ }).catch(() => {
4530
+ state2.nvimBridge.respawning = false;
4531
+ state2.nvimEnabled = false;
4532
+ requestRender();
4533
+ });
4534
+ }
4095
4535
  if (state2.nvimEnabled && state2.nvimBridge?.ready) {
4096
- const result = resolveNvimFile(state2, cursorNode, detailCtx, state2.cwd);
4097
- const resultKey = result ? result.files.map((f) => f.path).join("|") : null;
4098
- if (resultKey && resultKey !== state2.prevNvimFile) {
4099
- state2.nvimBridge.openTabFiles(result.files);
4100
- state2.prevNvimFile = resultKey;
4101
- state2.nvimEditable = result.files.some((f) => !f.readonly);
4102
- } else if (!resultKey) {
4103
- state2.prevNvimFile = null;
4104
- state2.nvimEditable = false;
4536
+ if (composing) {
4537
+ const action = state2.composeAction;
4538
+ const label = action ? COMPOSE_HEADERS[action.kind] : "Compose";
4539
+ const statusRows = [
4540
+ " " + ansiColor(label, "yellow", true),
4541
+ " " + ansiDim(":w to submit \xB7 Tab to cancel")
4542
+ ];
4543
+ detailRows = renderNvimDetailRows(detailRect, state2.nvimBridge, true, false, statusRows, true);
4544
+ } else {
4545
+ const result = resolveNvimFile(state2, cursorNode, detailCtx, state2.cwd);
4546
+ const resultKey = result ? result.files.map((f) => f.path).join("|") : null;
4547
+ if (resultKey && resultKey !== state2.prevNvimFile) {
4548
+ state2.nvimBridge.openTabFiles(result.files);
4549
+ state2.prevNvimFile = resultKey;
4550
+ state2.nvimEditable = result.files.some((f) => !f.readonly);
4551
+ } else if (!resultKey) {
4552
+ state2.prevNvimFile = null;
4553
+ state2.nvimEditable = false;
4554
+ }
4555
+ const statusRows = buildStatusRows(cursorNode, state2.selectedSession, state2);
4556
+ detailRows = renderNvimDetailRows(detailRect, state2.nvimBridge, state2.focusPane === "detail", state2.nvimEditable, statusRows);
4105
4557
  }
4106
- const statusRows = buildStatusRows(cursorNode, state2.selectedSession, state2);
4107
- detailRows = renderNvimDetailRows(detailRect, state2.nvimBridge, state2.focusPane === "detail", state2.nvimEditable, statusRows);
4108
4558
  } else {
4109
4559
  detailRows = renderDetailRows(detailRect, state2, detailCtx);
4110
4560
  }
@@ -4165,6 +4615,7 @@ function startApp(state2, cleanup2) {
4165
4615
  editInPopup,
4166
4616
  openCompanionPane,
4167
4617
  openClaudeResumePopup,
4618
+ openClaudeResumeSession,
4168
4619
  selectWindow,
4169
4620
  selectPane,
4170
4621
  switchToSession,
@@ -4206,6 +4657,7 @@ function startApp(state2, cleanup2) {
4206
4657
  inputActions.cleanup = () => {
4207
4658
  clearInterval(pollInterval);
4208
4659
  if (debouncedPollTimer !== null) clearTimeout(debouncedPollTimer);
4660
+ if (state2.composePollTimer !== null) clearInterval(state2.composePollTimer);
4209
4661
  stopKeypress();
4210
4662
  stopResize();
4211
4663
  state2.detailScroll.destroy();