@kenkaiiii/ggcoder 4.3.150 → 4.3.151

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/ui/App.js CHANGED
@@ -48,7 +48,7 @@ import { estimateConversationTokens } from "../core/compaction/token-estimator.j
48
48
  import { PROMPT_COMMANDS, getPromptCommand } from "../core/prompt-commands.js";
49
49
  import { loadCustomCommands } from "../core/custom-commands.js";
50
50
  import { buildSystemPrompt } from "../system-prompt.js";
51
- import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, stripDoneMarkers, } from "../utils/plan-steps.js";
51
+ import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, segmentDisplayText, stripDoneMarkers, } from "../utils/plan-steps.js";
52
52
  import { getMCPServers } from "../core/mcp/index.js";
53
53
  import { trimFlushedItems, flushOnTurnText, flushOnTurnEnd, flushOverflow, } from "./live-item-flush.js";
54
54
  import { Buddy } from "./buddy/Buddy.js";
@@ -278,14 +278,17 @@ export function App(props) {
278
278
  // Hoisted before terminal title hook so it can reference them
279
279
  const [lastUserMessage, setLastUserMessage] = useState("");
280
280
  const [exitPending, setExitPending] = useState(false);
281
- const [planMode, setPlanMode] = useState(false);
281
+ // Initialize from planModeRef (lives outside React in cli.ts) so plan
282
+ // mode survives /clear's unmount/remount, matching the prior behavior
283
+ // where /clear didn't toggle plan mode off.
284
+ const [planMode, setPlanMode] = useState(props.planModeRef?.current ?? false);
282
285
  const planModeLocalRef = useRef(false);
283
286
  planModeLocalRef.current = planMode;
284
287
  // Terminal title — updated later after agentLoop is created
285
288
  // (hoisted here so the hook is always called in the same order)
286
289
  const [titleRunning, setTitleRunning] = useState(false);
287
- const [sessionTitle, setSessionTitle] = useState(undefined);
288
- const sessionTitleGeneratedRef = useRef(false);
290
+ const [sessionTitle, setSessionTitle] = useState(() => props.sessionStore?.sessionTitle);
291
+ const sessionTitleGeneratedRef = useRef(props.sessionStore?.sessionTitleGenerated ?? false);
289
292
  useTerminalTitle({
290
293
  isRunning: titleRunning,
291
294
  sessionTitle,
@@ -296,6 +299,11 @@ export function App(props) {
296
299
  // gatsby). Ink's Static (build/components/Static.js) starts with index=0
297
300
  // so slice(0) returns the full array regardless of length.
298
301
  const [history, setHistory] = useState(() => {
302
+ // sessionStore wins (lives across remount). Falls back to initialHistory
303
+ // (loaded from a session file at startup), then a fresh banner-only list.
304
+ const stored = props.sessionStore?.history;
305
+ if (stored && stored.length > 0)
306
+ return stored;
299
307
  if (props.initialHistory && props.initialHistory.length > 0) {
300
308
  return compactHistory(trimFlushedItems(props.initialHistory));
301
309
  }
@@ -303,7 +311,9 @@ export function App(props) {
303
311
  });
304
312
  // Items from the current/last turn — rendered in the live area so they stay visible
305
313
  const [liveItems, setLiveItems] = useState([]);
306
- const [overlay, setOverlay] = useState(props.initialOverlay ?? null);
314
+ // overlay seeded from sessionStore (lives across remount). Falls back to
315
+ // props.initialOverlay (CLI launched with one), then null.
316
+ const [overlay, setOverlay] = useState(props.sessionStore?.overlay ?? props.initialOverlay ?? null);
307
317
  const [taskCount, setTaskCount] = useState(() => getTaskCount(props.cwd));
308
318
  const [eyesCount, setEyesCount] = useState(() => isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
309
319
  const [updatePending, setUpdatePending] = useState(() => getPendingUpdate(props.version) !== null);
@@ -324,14 +334,14 @@ export function App(props) {
324
334
  const [currentProvider, setCurrentProvider] = useState(props.provider);
325
335
  const [currentTools, setCurrentTools] = useState(props.tools);
326
336
  const [thinkingEnabled, setThinkingEnabled] = useState(!!props.thinking);
327
- const messagesRef = useRef(props.messages);
328
- const [planAutoExpand, setPlanAutoExpand] = useState(false);
329
- const approvedPlanPathRef = useRef(undefined);
330
- const planStepsRef = useRef([]);
331
- const [planSteps, setPlanSteps] = useState([]);
337
+ const messagesRef = useRef(props.sessionStore?.messages ?? props.messages);
338
+ const [planAutoExpand, setPlanAutoExpand] = useState(props.sessionStore?.planAutoExpand ?? false);
339
+ const approvedPlanPathRef = useRef(props.sessionStore?.approvedPlanPath);
340
+ const planStepsRef = useRef(props.sessionStore?.planSteps ?? []);
341
+ const [planSteps, setPlanSteps] = useState(props.sessionStore?.planSteps ?? []);
332
342
  const nextIdRef = useRef(0);
333
343
  const sessionManagerRef = useRef(props.sessionsDir ? new SessionManager(props.sessionsDir) : null);
334
- const sessionPathRef = useRef(props.sessionPath);
344
+ const sessionPathRef = useRef(props.sessionStore?.sessionPath ?? props.sessionPath);
335
345
  const persistedIndexRef = useRef(messagesRef.current.length);
336
346
  /** Last actual API-reported input token count (from turn_end). */
337
347
  const lastActualTokensRef = useRef(0);
@@ -351,6 +361,44 @@ export function App(props) {
351
361
  pendingFlushRef.current = [...pendingFlushRef.current, ...items];
352
362
  setFlushGeneration((g) => g + 1);
353
363
  }, []);
364
+ // Mirror runtime state choices (model/provider/thinking) into renderApp's
365
+ // closure so unmount/remount preserves them.
366
+ const onRuntimeStateChange = props.onRuntimeStateChange;
367
+ useEffect(() => {
368
+ onRuntimeStateChange?.({ model: currentModel });
369
+ }, [currentModel, onRuntimeStateChange]);
370
+ useEffect(() => {
371
+ onRuntimeStateChange?.({ provider: currentProvider });
372
+ }, [currentProvider, onRuntimeStateChange]);
373
+ useEffect(() => {
374
+ onRuntimeStateChange?.({
375
+ thinking: thinkingEnabled ? (props.thinking ?? "medium") : undefined,
376
+ });
377
+ }, [thinkingEnabled, props.thinking, onRuntimeStateChange]);
378
+ // Mirror session state into renderApp's closure so resetUI() can re-seed
379
+ // the conversation on remount. Each panel that previously did a bare ANSI
380
+ // screen clear (overlay open/close, plan accept/reject, /clear, startTask)
381
+ // now goes through resetUI; without these mirrors, the chat would vanish.
382
+ const sessionStore = props.sessionStore;
383
+ useEffect(() => {
384
+ if (sessionStore)
385
+ sessionStore.history = history;
386
+ }, [history, sessionStore]);
387
+ useEffect(() => {
388
+ if (sessionStore)
389
+ sessionStore.planSteps = planSteps;
390
+ }, [planSteps, sessionStore]);
391
+ useEffect(() => {
392
+ if (sessionStore)
393
+ sessionStore.sessionTitle = sessionTitle;
394
+ }, [sessionTitle, sessionStore]);
395
+ useEffect(() => {
396
+ if (sessionStore)
397
+ sessionStore.overlay = overlay;
398
+ }, [overlay, sessionStore]);
399
+ // pendingAction is consumed via a useEffect AFTER agentLoop is created
400
+ // — see below where useAgentLoop is set up.
401
+ const pendingActionConsumedRef = useRef(false);
354
402
  // Derive credentials for the current provider
355
403
  const currentCreds = props.credentialsByProvider?.[currentProvider];
356
404
  const activeApiKey = currentCreds?.accessToken ?? props.apiKey;
@@ -422,6 +470,13 @@ export function App(props) {
422
470
  // premature "done" status that fires when the agent loop finishes
423
471
  planOverlayPendingRef.current = true;
424
472
  setTimeout(() => {
473
+ // NOTE: this is the one open-overlay path that does NOT remount via
474
+ // resetUI. It runs while the agent is still mid-turn (after the
475
+ // exit_plan tool returned but before onDone fires), and unmounting
476
+ // here would kill the in-flight agent stream. Keep the bare ANSI
477
+ // clear; the drift bug is tolerable across just the agent's
478
+ // wrap-up turn, and onApprove/onReject both remount cleanly via
479
+ // resetUI when the user resolves the plan.
425
480
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
426
481
  setPlanAutoExpand(true);
427
482
  setOverlay("plan");
@@ -708,17 +763,53 @@ export function App(props) {
708
763
  if (flushed.length > 0) {
709
764
  queueFlush(flushed);
710
765
  }
711
- const displayText = planStepsRef.current.length > 0 ? stripDoneMarkers(text) : text;
712
- return [
713
- {
766
+ // Split text on [DONE:N] markers so each marker renders inline as
767
+ // a styled "✓ Step N: <description>" item at the position the
768
+ // agent emitted it, instead of vanishing into stripped whitespace.
769
+ // Falls back to a single assistant item containing the
770
+ // marker-stripped text when there are no markers (keeps the
771
+ // common case zero-cost).
772
+ const segments = segmentDisplayText(text, planStepsRef.current);
773
+ const items = [];
774
+ let thinkingAttached = false;
775
+ for (const seg of segments) {
776
+ if (seg.kind === "text") {
777
+ items.push({
778
+ kind: "assistant",
779
+ text: stripDoneMarkers(seg.text),
780
+ // Attach thinking only to the first text segment so we
781
+ // don't render duplicate ThinkingBlocks when a turn
782
+ // contains multiple text chunks split by markers.
783
+ thinking: thinkingAttached ? undefined : thinking,
784
+ thinkingMs: thinkingAttached ? undefined : thinkingMs,
785
+ planMode: planModeLocalRef.current,
786
+ id: getId(),
787
+ });
788
+ thinkingAttached = true;
789
+ }
790
+ else {
791
+ items.push({
792
+ kind: "step_done",
793
+ stepNum: seg.stepNum,
794
+ description: seg.description,
795
+ id: getId(),
796
+ });
797
+ }
798
+ }
799
+ // No segments at all (text was empty/whitespace, no markers).
800
+ // Still emit an assistant item so a thinking block renders if
801
+ // there was thinking content for this turn.
802
+ if (items.length === 0) {
803
+ items.push({
714
804
  kind: "assistant",
715
- text: displayText,
805
+ text: "",
716
806
  thinking,
717
807
  thinkingMs,
718
808
  planMode: planModeLocalRef.current,
719
809
  id: getId(),
720
- },
721
- ];
810
+ });
811
+ }
812
+ return items;
722
813
  });
723
814
  }, []),
724
815
  onToolStart: useCallback((toolCallId, name, args) => {
@@ -1188,6 +1279,33 @@ export function App(props) {
1188
1279
  useEffect(() => {
1189
1280
  setTitleRunning(agentLoop.isRunning);
1190
1281
  }, [agentLoop.isRunning]);
1282
+ // Consume sessionStore.pendingAction once on mount. Set by resetUI options
1283
+ // for paths that remount AND immediately drive the agent (plan accept,
1284
+ // plan reject, startTask, pixel fix). The action survives the unmount
1285
+ // because it lives in renderApp's closure (sessionStore), not React state.
1286
+ useEffect(() => {
1287
+ if (pendingActionConsumedRef.current)
1288
+ return;
1289
+ const action = sessionStore?.pendingAction;
1290
+ if (!action)
1291
+ return;
1292
+ pendingActionConsumedRef.current = true;
1293
+ if (sessionStore)
1294
+ sessionStore.pendingAction = undefined;
1295
+ if (action.infoText) {
1296
+ setLiveItems((prev) => [
1297
+ ...prev,
1298
+ { kind: "info", text: action.infoText, id: getId() },
1299
+ ]);
1300
+ }
1301
+ setDoneStatus(null);
1302
+ void agentLoop.run(action.prompt).catch((err) => {
1303
+ const errMsg = err instanceof Error ? err.message : String(err);
1304
+ log("ERROR", "error", errMsg);
1305
+ setLiveItems((prev) => [...prev, { kind: "error", message: errMsg, id: getId() }]);
1306
+ });
1307
+ // Intentional one-shot: run once on mount, never re-fire on re-render.
1308
+ }, []);
1191
1309
  // Refresh eyes badge count when the agent settles (end of a turn) — a turn
1192
1310
  // may have logged new rough/wish/blocked signals. Also covers the case where
1193
1311
  // /eyes was run for the first time (manifest now exists).
@@ -1229,13 +1347,29 @@ export function App(props) {
1229
1347
  if (trimmed === "/quit" || trimmed === "/q" || trimmed === "/exit") {
1230
1348
  process.exit(0);
1231
1349
  }
1232
- // Handle /clear — reset session and clear terminal
1350
+ // Handle /clear — tear down the entire Ink instance and rebuild fresh.
1351
+ // Patching Ink's internal frame tracking in place (log-update reset,
1352
+ // lastOutput cleared, fullStaticOutput dropped, staticKey bump) all
1353
+ // looked correct for one frame but left the live area drifting on
1354
+ // subsequent streaming responses — Ink's cursor math depends on
1355
+ // terminal-state assumptions that ANSI clearing breaks. The reliable
1356
+ // fix is unmount + render again. Runtime state (model, provider,
1357
+ // thinking) survives via renderApp's closure-held `runtimeState`,
1358
+ // mirrored from React state via the useEffects above.
1233
1359
  if (trimmed === "/clear") {
1234
- // Clear terminal screen + scrollback — needed because Ink's <Static>
1235
- // writes directly to stdout and can't be removed by clearing React state
1360
+ if (props.resetUI) {
1361
+ void (async () => {
1362
+ const newPrompt = await buildSystemPrompt(props.cwd, props.skills, planMode, undefined);
1363
+ props.resetUI?.({
1364
+ wipeSession: true,
1365
+ messages: [{ role: "system", content: newPrompt }],
1366
+ });
1367
+ })();
1368
+ return;
1369
+ }
1370
+ // Fallback path (resetUI not wired — e.g. tests). Best-effort: clear
1371
+ // React state in place. The Ink-internal drift bug remains here.
1236
1372
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1237
- // Discard any items queued for two-phase flush so they don't leak
1238
- // into the new session after the Static remount.
1239
1373
  pendingFlushRef.current = [];
1240
1374
  setHistory([{ kind: "banner", id: "banner" }]);
1241
1375
  setLiveItems([]);
@@ -1243,7 +1377,6 @@ export function App(props) {
1243
1377
  approvedPlanPathRef.current = undefined;
1244
1378
  planStepsRef.current = [];
1245
1379
  setPlanSteps([]);
1246
- // Rebuild system prompt without the approved plan
1247
1380
  void (async () => {
1248
1381
  const newPrompt = await buildSystemPrompt(props.cwd, props.skills, planMode, undefined);
1249
1382
  messagesRef.current = [{ role: "system", content: newPrompt }];
@@ -1252,8 +1385,6 @@ export function App(props) {
1252
1385
  agentLoop.reset();
1253
1386
  setSessionTitle(undefined);
1254
1387
  sessionTitleGeneratedRef.current = false;
1255
- // Bump staticKey to force Ink's <Static> to remount, discarding its
1256
- // internal record of previously rendered items so they don't reappear.
1257
1388
  setStaticKey((k) => k + 1);
1258
1389
  setLiveItems([{ kind: "info", text: "Session cleared.", id: getId() }]);
1259
1390
  return;
@@ -1339,9 +1470,16 @@ export function App(props) {
1339
1470
  }
1340
1471
  // Handle /plans — open plan pane
1341
1472
  if (trimmed === "/plans") {
1342
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1343
- setPlanAutoExpand(false);
1344
- setOverlay("plan");
1473
+ if (props.resetUI && props.sessionStore) {
1474
+ props.sessionStore.overlay = "plan";
1475
+ props.sessionStore.planAutoExpand = false;
1476
+ props.resetUI();
1477
+ }
1478
+ else {
1479
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1480
+ setPlanAutoExpand(false);
1481
+ setOverlay("plan");
1482
+ }
1345
1483
  return;
1346
1484
  }
1347
1485
  // Handle prompt-template commands (built-in + custom from .gg/commands/)
@@ -1707,6 +1845,8 @@ export function App(props) {
1707
1845
  return (_jsx(Box, { marginTop: 1, flexShrink: 1, borderStyle: "round", borderColor: theme.success, paddingX: 1, children: _jsxs(Text, { color: theme.success, bold: true, wrap: "wrap", children: ["✨ ", item.text] }) }, item.id));
1708
1846
  case "plan_transition":
1709
1847
  return (_jsx(Box, { marginTop: 1, flexShrink: 1, children: _jsxs(Text, { color: theme.planPrimary, bold: true, wrap: "wrap", children: [item.active ? "● " : "● ", item.text] }) }, item.id));
1848
+ case "step_done":
1849
+ return (_jsx(Box, { marginTop: 1, flexShrink: 1, children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: theme.success, bold: true, children: "✓ " }), _jsx(Text, { color: theme.success, bold: true, children: `Step ${item.stepNum} done` }), item.description ? (_jsx(Text, { color: theme.textDim, children: ` — ${item.description}` })) : null] }) }, item.id));
1710
1850
  case "queued":
1711
1851
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "⏳ Queued: " }), _jsxs(Text, { color: theme.text, wrap: "wrap", children: [item.text, item.imageCount
1712
1852
  ? ` (+${item.imageCount} image${item.imageCount > 1 ? "s" : ""})`
@@ -1724,7 +1864,42 @@ export function App(props) {
1724
1864
  // ── Start a task (shared by manual "work on it" and run-all) ──
1725
1865
  const startTask = useCallback((title, prompt, taskId) => {
1726
1866
  setTaskCount(getTaskCount(props.cwd));
1727
- // Reset to a fresh session before sending the task
1867
+ const shortId = taskId.slice(0, 8);
1868
+ const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
1869
+ `tasks({ action: "done", id: "${shortId}" })`;
1870
+ const fullPrompt = prompt + completionHint;
1871
+ if (props.resetUI && props.sessionStore) {
1872
+ // Preserve the current system prompt (may differ from the launch
1873
+ // config — e.g. plan mode toggled or skills changed).
1874
+ const sysMsg = messagesRef.current[0];
1875
+ const newMessages = sysMsg && sysMsg.role === "system" ? [sysMsg] : messagesRef.current.slice(0, 1);
1876
+ const taskItem = { kind: "task", title, id: String(nextIdRef.current++) };
1877
+ const sm = sessionManagerRef.current;
1878
+ void (async () => {
1879
+ let newSessionPath;
1880
+ if (sm) {
1881
+ try {
1882
+ const s = await sm.create(props.cwd, currentProvider, currentModel);
1883
+ newSessionPath = s.path;
1884
+ log("INFO", "tasks", "New session for task", { path: s.path });
1885
+ }
1886
+ catch {
1887
+ // session creation is best-effort
1888
+ }
1889
+ }
1890
+ if (props.sessionStore)
1891
+ props.sessionStore.overlay = null;
1892
+ props.resetUI?.({
1893
+ wipeSession: true,
1894
+ messages: newMessages,
1895
+ history: [{ kind: "banner", id: "banner" }, taskItem],
1896
+ sessionPath: newSessionPath,
1897
+ pendingAction: { prompt: fullPrompt },
1898
+ });
1899
+ })();
1900
+ return;
1901
+ }
1902
+ // Fallback path (resetUI not wired — tests).
1728
1903
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1729
1904
  setHistory([{ kind: "banner", id: "banner" }]);
1730
1905
  setLiveItems([]);
@@ -1738,12 +1913,6 @@ export function App(props) {
1738
1913
  log("INFO", "tasks", "New session for task", { path: s.path });
1739
1914
  });
1740
1915
  }
1741
- // Inject completion instruction so the agent marks the task done
1742
- const shortId = taskId.slice(0, 8);
1743
- const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
1744
- `tasks({ action: "done", id: "${shortId}" })`;
1745
- const fullPrompt = prompt + completionHint;
1746
- // Show the short title in the TUI, but send the full prompt to the agent
1747
1916
  const taskItem = { kind: "task", title, id: getId() };
1748
1917
  setLastUserMessage(title);
1749
1918
  setDoneStatus(null);
@@ -1762,11 +1931,18 @@ export function App(props) {
1762
1931
  ? { kind: "info", text: "Request was stopped.", id: getId() }
1763
1932
  : { kind: "error", message: msg, id: getId() },
1764
1933
  ]);
1765
- // Stop run-all if a task errors
1766
1934
  setRunAllTasks(false);
1767
1935
  }
1768
1936
  })();
1769
- }, [props.cwd, stdout, agentLoop, currentProvider, currentModel]);
1937
+ }, [
1938
+ props.cwd,
1939
+ props.resetUI,
1940
+ props.sessionStore,
1941
+ stdout,
1942
+ agentLoop,
1943
+ currentProvider,
1944
+ currentModel,
1945
+ ]);
1770
1946
  // Keep refs in sync for access from stale closures (onDone)
1771
1947
  startTaskRef.current = startTask;
1772
1948
  useEffect(() => {
@@ -1850,10 +2026,16 @@ export function App(props) {
1850
2026
  const isPixelView = overlay === "pixel";
1851
2027
  const isOverlayView = isTaskView || isSkillsView || isPlanView || isEyesView || isPixelView;
1852
2028
  return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: isOverlayView ? [] : history, style: { width: "100%" }, children: (item) => (_jsx(Box, { flexDirection: "column", paddingRight: 1, children: renderItem(item) }, item.id)) }, `${resizeKey}-${staticKey}`), isTaskView ? (_jsx(TaskOverlay, { cwd: props.cwd, agentRunning: agentLoop.isRunning, onClose: () => {
1853
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1854
- setTaskCount(getTaskCount(props.cwd));
1855
- setStaticKey((k) => k + 1);
1856
- setOverlay(null);
2029
+ if (props.resetUI && props.sessionStore) {
2030
+ props.sessionStore.overlay = null;
2031
+ props.resetUI();
2032
+ }
2033
+ else {
2034
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2035
+ setTaskCount(getTaskCount(props.cwd));
2036
+ setStaticKey((k) => k + 1);
2037
+ setOverlay(null);
2038
+ }
1857
2039
  }, onWorkOnTask: (title, prompt, taskId) => {
1858
2040
  setOverlay(null);
1859
2041
  startTask(title, prompt, taskId);
@@ -1866,9 +2048,15 @@ export function App(props) {
1866
2048
  startTask(next.title, next.prompt, next.id);
1867
2049
  }
1868
2050
  } })) : isPixelView ? (_jsx(PixelOverlay, { version: props.version, agentRunning: agentLoop.isRunning, onClose: () => {
1869
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1870
- setStaticKey((k) => k + 1);
1871
- setOverlay(null);
2051
+ if (props.resetUI && props.sessionStore) {
2052
+ props.sessionStore.overlay = null;
2053
+ props.resetUI();
2054
+ }
2055
+ else {
2056
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2057
+ setStaticKey((k) => k + 1);
2058
+ setOverlay(null);
2059
+ }
1872
2060
  }, onFixOne: (entry) => {
1873
2061
  setOverlay(null);
1874
2062
  startPixelFix(entry.errorId);
@@ -1880,57 +2068,95 @@ export function App(props) {
1880
2068
  setRunAllPixel(true);
1881
2069
  startPixelFix(first.errorId);
1882
2070
  } })) : isSkillsView ? (_jsx(SkillsOverlay, { cwd: props.cwd, onClose: () => {
1883
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1884
- setStaticKey((k) => k + 1);
1885
- setOverlay(null);
2071
+ if (props.resetUI && props.sessionStore) {
2072
+ props.sessionStore.overlay = null;
2073
+ props.resetUI();
2074
+ }
2075
+ else {
2076
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2077
+ setStaticKey((k) => k + 1);
2078
+ setOverlay(null);
2079
+ }
1886
2080
  } })) : isEyesView ? (_jsx(EyesOverlay, { cwd: props.cwd, onClose: () => {
1887
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1888
- setEyesCount(isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
1889
- setStaticKey((k) => k + 1);
1890
- setOverlay(null);
2081
+ if (props.resetUI && props.sessionStore) {
2082
+ props.sessionStore.overlay = null;
2083
+ props.resetUI();
2084
+ }
2085
+ else {
2086
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2087
+ setEyesCount(isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
2088
+ setStaticKey((k) => k + 1);
2089
+ setOverlay(null);
2090
+ }
1891
2091
  }, onQueueMessage: (msg) => {
1892
2092
  agentLoop.queueMessage(msg);
1893
2093
  } })) : isPlanView ? (_jsx(PlanOverlay, { cwd: props.cwd, autoExpandNewest: planAutoExpand, onClose: () => {
1894
2094
  planOverlayPendingRef.current = false;
1895
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1896
- setStaticKey((k) => k + 1);
1897
- setPlanAutoExpand(false);
1898
- setOverlay(null);
2095
+ if (props.resetUI && props.sessionStore) {
2096
+ props.sessionStore.overlay = null;
2097
+ props.sessionStore.planAutoExpand = false;
2098
+ props.resetUI();
2099
+ }
2100
+ else {
2101
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2102
+ setStaticKey((k) => k + 1);
2103
+ setPlanAutoExpand(false);
2104
+ setOverlay(null);
2105
+ }
1899
2106
  }, onApprove: (planPath) => {
1900
2107
  log("INFO", "plan", "Plan approved — transitioning to implementation", {
1901
2108
  planPath,
1902
2109
  });
1903
- // Plan overlay dismissed — allow future onDone to fire normally
1904
2110
  planOverlayPendingRef.current = false;
1905
- // Store approved plan path — will be injected into the new system prompt
1906
- approvedPlanPathRef.current = planPath;
1907
- // Extract plan steps for progress tracking
1908
- void import("node:fs/promises").then(({ readFile }) => readFile(planPath, "utf-8").then((content) => {
1909
- const steps = extractPlanSteps(content);
1910
- planStepsRef.current = steps;
1911
- setPlanSteps(steps);
1912
- }));
1913
- // Clear session for a fresh context focused on the plan
1914
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1915
- setHistory([{ kind: "banner", id: "banner" }]);
1916
- setLiveItems([]);
1917
- setStaticKey((k) => k + 1);
1918
- setPlanAutoExpand(false);
1919
- setOverlay(null);
1920
- // Rebuild system prompt with the approved plan, then reset the session
1921
2111
  void (async () => {
1922
2112
  try {
2113
+ // Read plan steps for progress tracking — handed to the new
2114
+ // mount via sessionStore.planSteps below.
2115
+ const planContent = await import("node:fs/promises").then(({ readFile }) => readFile(planPath, "utf-8"));
2116
+ const steps = extractPlanSteps(planContent);
2117
+ // Build the new system prompt with the approved plan baked in.
1923
2118
  const newPrompt = await buildSystemPrompt(props.cwd, props.skills, false, planPath);
1924
- messagesRef.current = [{ role: "system", content: newPrompt }];
1925
- agentLoop.reset();
1926
- persistedIndexRef.current = messagesRef.current.length;
1927
- // Create a new session file
2119
+ // Create a new session file BEFORE remount so the new tree
2120
+ // picks it up via sessionStore.sessionPath.
2121
+ let newSessionPath;
1928
2122
  const sm = sessionManagerRef.current;
1929
2123
  if (sm) {
1930
2124
  const s = await sm.create(props.cwd, currentProvider, currentModel);
1931
- sessionPathRef.current = s.path;
2125
+ newSessionPath = s.path;
2126
+ }
2127
+ if (props.resetUI && props.sessionStore) {
2128
+ // Clear the overlay so the new mount lands on the chat,
2129
+ // not back inside the plan pane.
2130
+ props.sessionStore.overlay = null;
2131
+ props.sessionStore.planAutoExpand = false;
2132
+ props.resetUI({
2133
+ wipeSession: true,
2134
+ messages: [{ role: "system", content: newPrompt }],
2135
+ approvedPlanPath: planPath,
2136
+ planSteps: steps,
2137
+ sessionPath: newSessionPath,
2138
+ pendingAction: {
2139
+ prompt: "The plan has been approved. Implement it now, following each step in order.",
2140
+ infoText: "Plan approved — starting fresh session for implementation",
2141
+ },
2142
+ });
2143
+ return;
1932
2144
  }
1933
- // Start implementation with a clean context
2145
+ // Fallback path (resetUI not wired — tests). Mutate in place.
2146
+ approvedPlanPathRef.current = planPath;
2147
+ planStepsRef.current = steps;
2148
+ setPlanSteps(steps);
2149
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2150
+ setHistory([{ kind: "banner", id: "banner" }]);
2151
+ setLiveItems([]);
2152
+ setStaticKey((k) => k + 1);
2153
+ setPlanAutoExpand(false);
2154
+ setOverlay(null);
2155
+ messagesRef.current = [{ role: "system", content: newPrompt }];
2156
+ agentLoop.reset();
2157
+ persistedIndexRef.current = messagesRef.current.length;
2158
+ if (newSessionPath)
2159
+ sessionPathRef.current = newSessionPath;
1934
2160
  setLiveItems([
1935
2161
  {
1936
2162
  kind: "info",
@@ -1949,19 +2175,31 @@ export function App(props) {
1949
2175
  })();
1950
2176
  }, onReject: (planPath, feedback) => {
1951
2177
  planOverlayPendingRef.current = false;
2178
+ const rejectionMsg = `The plan at ${planPath} was rejected.\n\nFeedback: ${feedback}\n\n` +
2179
+ `Please revise the plan based on this feedback.`;
2180
+ if (props.resetUI && props.sessionStore) {
2181
+ props.sessionStore.overlay = null;
2182
+ props.sessionStore.planAutoExpand = false;
2183
+ // No wipeSession — keep history, messages, plan mode etc. The
2184
+ // agent picks up the rejection mid-conversation.
2185
+ props.resetUI({
2186
+ pendingAction: {
2187
+ prompt: rejectionMsg,
2188
+ infoText: `Plan rejected — "${feedback}"`,
2189
+ },
2190
+ });
2191
+ return;
2192
+ }
1952
2193
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1953
2194
  setStaticKey((k) => k + 1);
1954
2195
  setPlanAutoExpand(false);
1955
2196
  setOverlay(null);
1956
2197
  setDoneStatus(null);
1957
- // Send rejection + feedback to the agent
1958
- const msg = `The plan at ${planPath} was rejected.\n\nFeedback: ${feedback}\n\n` +
1959
- `Please revise the plan based on this feedback.`;
1960
2198
  setLiveItems((prev) => [
1961
2199
  ...prev,
1962
2200
  { kind: "info", text: `Plan rejected — "${feedback}"`, id: getId() },
1963
2201
  ]);
1964
- void agentLoop.run(msg).catch((err) => {
2202
+ void agentLoop.run(rejectionMsg).catch((err) => {
1965
2203
  const errMsg = err instanceof Error ? err.message : String(err);
1966
2204
  log("ERROR", "error", errMsg);
1967
2205
  setLiveItems((prev) => [...prev, { kind: "error", message: errMsg, id: getId() }]);
@@ -1970,14 +2208,32 @@ export function App(props) {
1970
2208
  ? THINKING_BORDER_COLORS[thinkingBorderFrame]
1971
2209
  : "transparent", paddingLeft: 1, paddingRight: 1, width: columns, children: _jsx(ActivityIndicator, { phase: agentLoop.activityPhase, elapsedMs: agentLoop.elapsedMs, runStartRef: agentLoop.runStartRef, thinkingMs: agentLoop.thinkingMs, isThinking: agentLoop.isThinking, tokenEstimate: agentLoop.streamedTokenEstimate, charCountRef: agentLoop.charCountRef, realTokensAccumRef: agentLoop.realTokensAccumRef, userMessage: lastUserMessage, activeToolNames: agentLoop.activeToolCalls.map((tc) => tc.name), planMode: planMode, retryInfo: agentLoop.retryInfo, planDone: planSteps.filter((s) => s.completed).length, planTotal: planSteps.length }) })) : agentLoop.stallError ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.warning, children: "⚠ API provider stream interrupted — retries exhausted." }), _jsx(Text, { color: theme.textDim, children: " Your conversation is preserved. Send a message to continue." })] })) : (doneStatus &&
1972
2210
  !agentLoop.isRunning && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.success, children: ["✻ ", doneStatus.verb, " ", formatDuration(doneStatus.durationMs)] }) }))), agentLoop.queuedCount > 0 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.accent, children: ["⏳ ", agentLoop.queuedCount, " message", agentLoop.queuedCount > 1 ? "s" : "", " queued"] }) })), _jsx(InputArea, { onSubmit: handleSubmit, onAbort: handleAbort, disabled: agentLoop.isRunning, isActive: !taskBarFocused && !overlay, onDownAtEnd: handleFocusTaskBar, onShiftTab: handleToggleThinking, onToggleTasks: () => {
1973
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1974
- setOverlay("tasks");
2211
+ if (props.resetUI && props.sessionStore) {
2212
+ props.sessionStore.overlay = "tasks";
2213
+ props.resetUI();
2214
+ }
2215
+ else {
2216
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2217
+ setOverlay("tasks");
2218
+ }
1975
2219
  }, onToggleSkills: () => {
1976
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1977
- setOverlay("skills");
2220
+ if (props.resetUI && props.sessionStore) {
2221
+ props.sessionStore.overlay = "skills";
2222
+ props.resetUI();
2223
+ }
2224
+ else {
2225
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2226
+ setOverlay("skills");
2227
+ }
1978
2228
  }, onTogglePixel: () => {
1979
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1980
- setOverlay("pixel");
2229
+ if (props.resetUI && props.sessionStore) {
2230
+ props.sessionStore.overlay = "pixel";
2231
+ props.resetUI();
2232
+ }
2233
+ else {
2234
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2235
+ setOverlay("pixel");
2236
+ }
1981
2237
  }, onTogglePlanMode: () => {
1982
2238
  const next = !planMode;
1983
2239
  setPlanMode(next);