@prestyj/cli 4.3.96 → 4.3.159

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 (122) hide show
  1. package/README.md +108 -42
  2. package/dist/cli.js +16 -12
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/auth/index.d.ts +13 -0
  5. package/dist/core/auth/index.d.ts.map +1 -0
  6. package/dist/core/auth/index.js +12 -0
  7. package/dist/core/auth/index.js.map +1 -0
  8. package/dist/core/auth-storage.d.ts +6 -0
  9. package/dist/core/auth-storage.d.ts.map +1 -1
  10. package/dist/core/auth-storage.js +14 -0
  11. package/dist/core/auth-storage.js.map +1 -1
  12. package/dist/core/auto-update.js +2 -2
  13. package/dist/core/auto-update.js.map +1 -1
  14. package/dist/core/auto-update.test.js +1 -1
  15. package/dist/core/auto-update.test.js.map +1 -1
  16. package/dist/core/compaction/compactor.js +2 -2
  17. package/dist/core/compaction/compactor.js.map +1 -1
  18. package/dist/core/compaction/token-estimator.d.ts.map +1 -1
  19. package/dist/core/compaction/token-estimator.js +0 -2
  20. package/dist/core/compaction/token-estimator.js.map +1 -1
  21. package/dist/core/logger.js +1 -1
  22. package/dist/core/logger.js.map +1 -1
  23. package/dist/core/mcp/client.d.ts.map +1 -1
  24. package/dist/core/mcp/client.js +26 -2
  25. package/dist/core/mcp/client.js.map +1 -1
  26. package/dist/core/mcp/defaults.js +1 -1
  27. package/dist/core/mcp/defaults.js.map +1 -1
  28. package/dist/core/model-registry.d.ts.map +1 -1
  29. package/dist/core/model-registry.js +3 -23
  30. package/dist/core/model-registry.js.map +1 -1
  31. package/dist/core/pixel-fix.test.js +2 -2
  32. package/dist/core/pixel-fix.test.js.map +1 -1
  33. package/dist/core/prompt-commands.js +5 -5
  34. package/dist/core/prompt-commands.js.map +1 -1
  35. package/dist/modes/agent-home-mode.js +3 -3
  36. package/dist/modes/agent-home-mode.js.map +1 -1
  37. package/dist/session.d.ts +0 -4
  38. package/dist/session.d.ts.map +1 -1
  39. package/dist/session.js +3 -15
  40. package/dist/session.js.map +1 -1
  41. package/dist/system-prompt.d.ts.map +1 -1
  42. package/dist/system-prompt.js +10 -4
  43. package/dist/system-prompt.js.map +1 -1
  44. package/dist/tools/edit.d.ts +1 -1
  45. package/dist/tools/prompt-hints.js +2 -2
  46. package/dist/tools/prompt-hints.js.map +1 -1
  47. package/dist/ui/App.d.ts +64 -1
  48. package/dist/ui/App.d.ts.map +1 -1
  49. package/dist/ui/App.js +349 -96
  50. package/dist/ui/App.js.map +1 -1
  51. package/dist/ui/components/ActivityIndicator.d.ts +14 -1
  52. package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
  53. package/dist/ui/components/ActivityIndicator.js +8 -5
  54. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  55. package/dist/ui/components/AssistantMessage.d.ts +1 -1
  56. package/dist/ui/components/AssistantMessage.d.ts.map +1 -1
  57. package/dist/ui/components/AssistantMessage.js +9 -1
  58. package/dist/ui/components/AssistantMessage.js.map +1 -1
  59. package/dist/ui/components/EyesOverlay.js +2 -2
  60. package/dist/ui/components/EyesOverlay.js.map +1 -1
  61. package/dist/ui/components/Footer.d.ts +18 -1
  62. package/dist/ui/components/Footer.d.ts.map +1 -1
  63. package/dist/ui/components/Footer.js +34 -7
  64. package/dist/ui/components/Footer.js.map +1 -1
  65. package/dist/ui/components/InputArea.d.ts +24 -1
  66. package/dist/ui/components/InputArea.d.ts.map +1 -1
  67. package/dist/ui/components/InputArea.js +19 -4
  68. package/dist/ui/components/InputArea.js.map +1 -1
  69. package/dist/ui/components/Markdown.d.ts +4 -4
  70. package/dist/ui/components/Markdown.d.ts.map +1 -1
  71. package/dist/ui/components/StreamingArea.d.ts +1 -1
  72. package/dist/ui/components/StreamingArea.d.ts.map +1 -1
  73. package/dist/ui/components/StreamingArea.js +7 -3
  74. package/dist/ui/components/StreamingArea.js.map +1 -1
  75. package/dist/ui/components/ThinkingBlock.d.ts +1 -1
  76. package/dist/ui/components/ThinkingBlock.d.ts.map +1 -1
  77. package/dist/ui/components/ToolExecution.d.ts +25 -0
  78. package/dist/ui/components/ToolExecution.d.ts.map +1 -1
  79. package/dist/ui/components/ToolExecution.js +46 -12
  80. package/dist/ui/components/ToolExecution.js.map +1 -1
  81. package/dist/ui/components/index.d.ts +11 -1
  82. package/dist/ui/components/index.d.ts.map +1 -1
  83. package/dist/ui/components/index.js +9 -0
  84. package/dist/ui/components/index.js.map +1 -1
  85. package/dist/ui/hooks/useAgentLoop.d.ts +1 -1
  86. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  87. package/dist/ui/hooks/useAgentLoop.js +1 -1
  88. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  89. package/dist/ui/login.d.ts +13 -1
  90. package/dist/ui/login.d.ts.map +1 -1
  91. package/dist/ui/login.js +21 -9
  92. package/dist/ui/login.js.map +1 -1
  93. package/dist/ui/pixel.js +1 -1
  94. package/dist/ui/pixel.js.map +1 -1
  95. package/dist/ui/pixel.test.js +1 -1
  96. package/dist/ui/render.d.ts +57 -0
  97. package/dist/ui/render.d.ts.map +1 -1
  98. package/dist/ui/render.js +130 -25
  99. package/dist/ui/render.js.map +1 -1
  100. package/dist/ui/utils/highlight.d.ts.map +1 -1
  101. package/dist/ui/utils/highlight.js +13 -1
  102. package/dist/ui/utils/highlight.js.map +1 -1
  103. package/dist/utils/image.d.ts.map +1 -1
  104. package/dist/utils/image.js +13 -1
  105. package/dist/utils/image.js.map +1 -1
  106. package/dist/utils/open-browser.d.ts +14 -0
  107. package/dist/utils/open-browser.d.ts.map +1 -0
  108. package/dist/utils/open-browser.js +31 -0
  109. package/dist/utils/open-browser.js.map +1 -0
  110. package/dist/utils/plan-steps.d.ts +22 -0
  111. package/dist/utils/plan-steps.d.ts.map +1 -1
  112. package/dist/utils/plan-steps.js +32 -0
  113. package/dist/utils/plan-steps.js.map +1 -1
  114. package/package.json +17 -9
  115. package/dist/ui/hooks/useTerminalProgress.d.ts +0 -2
  116. package/dist/ui/hooks/useTerminalProgress.d.ts.map +0 -1
  117. package/dist/ui/hooks/useTerminalProgress.js +0 -101
  118. package/dist/ui/hooks/useTerminalProgress.js.map +0 -1
  119. package/dist/utils/markdown.d.ts +0 -6
  120. package/dist/utils/markdown.d.ts.map +0 -1
  121. package/dist/utils/markdown.js +0 -25
  122. package/dist/utils/markdown.js.map +0 -1
package/dist/ui/App.js CHANGED
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  import { playNotificationSound } from "../utils/sound.js";
12
12
  import { extractImagePaths } from "../utils/image.js";
13
13
  import { useAgentLoop } from "./hooks/useAgentLoop.js";
14
- import { isEyesActive, journalCount } from "@prestyj/ezcoder-eyes";
14
+ import { isEyesActive, journalCount } from "@prestyj/eyes";
15
15
  import { UserMessage } from "./components/UserMessage.js";
16
16
  import { AssistantMessage } from "./components/AssistantMessage.js";
17
17
  import { ToolExecution } from "./components/ToolExecution.js";
@@ -36,7 +36,6 @@ import { BackgroundTasksBar } from "./components/BackgroundTasksBar.js";
36
36
  import { useTheme, useSetTheme } from "./theme/theme.js";
37
37
  import { useAnimationTick, useAnimationActive, deriveFrame, } from "./components/AnimationContext.js";
38
38
  import { useTerminalTitle } from "./hooks/useTerminalTitle.js";
39
- import { useTerminalProgress } from "./hooks/useTerminalProgress.js";
40
39
  import { getGitBranch } from "../utils/git.js";
41
40
  import { getModel, getContextWindow } from "../core/model-registry.js";
42
41
  import { SessionManager } from "../core/session-manager.js";
@@ -49,7 +48,7 @@ import { estimateConversationTokens } from "../core/compaction/token-estimator.j
49
48
  import { PROMPT_COMMANDS, getPromptCommand } from "../core/prompt-commands.js";
50
49
  import { loadCustomCommands } from "../core/custom-commands.js";
51
50
  import { buildSystemPrompt } from "../system-prompt.js";
52
- import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, stripDoneMarkers, } from "../utils/plan-steps.js";
51
+ import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, segmentDisplayText, stripDoneMarkers, } from "../utils/plan-steps.js";
53
52
  import { getMCPServers } from "../core/mcp/index.js";
54
53
  import { trimFlushedItems, flushOnTurnText, flushOnTurnEnd, flushOverflow, } from "./live-item-flush.js";
55
54
  import { Buddy } from "./buddy/Buddy.js";
@@ -98,7 +97,7 @@ const AGGREGATABLE_TOOLS = new Set(["read", "grep", "find", "ls"]);
98
97
  * we can swap out heavy objects for lightweight `{ kind: "tombstone", id }`
99
98
  * entries so GC can reclaim the original data.
100
99
  */
101
- const MAX_LIVE_HISTORY = 150;
100
+ const MAX_LIVE_HISTORY = 200;
102
101
  function compactHistory(items) {
103
102
  if (items.length <= MAX_LIVE_HISTORY)
104
103
  return items;
@@ -231,7 +230,7 @@ const THINKING_BORDER_COLORS = ["#60a5fa", "#818cf8", "#a78bfa", "#818cf8", "#60
231
230
  function getTaskCount(cwd) {
232
231
  try {
233
232
  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
234
- const data = readFileSync(join(homedir(), ".ezcoder-tasks", "projects", hash, "tasks.json"), "utf-8");
233
+ const data = readFileSync(join(homedir(), ".gg-tasks", "projects", hash, "tasks.json"), "utf-8");
235
234
  const tasks = JSON.parse(data);
236
235
  return tasks.filter((t) => t.status !== "done").length;
237
236
  }
@@ -242,7 +241,7 @@ function getTaskCount(cwd) {
242
241
  function getNextPendingTask(cwd) {
243
242
  try {
244
243
  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
245
- const data = readFileSync(join(homedir(), ".ezcoder-tasks", "projects", hash, "tasks.json"), "utf-8");
244
+ const data = readFileSync(join(homedir(), ".gg-tasks", "projects", hash, "tasks.json"), "utf-8");
246
245
  const tasks = JSON.parse(data);
247
246
  const pending = tasks.find((t) => t.status === "pending");
248
247
  if (!pending)
@@ -260,7 +259,7 @@ function getNextPendingTask(cwd) {
260
259
  function markTaskInProgress(cwd, taskId) {
261
260
  try {
262
261
  const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 16);
263
- const filePath = join(homedir(), ".ezcoder-tasks", "projects", hash, "tasks.json");
262
+ const filePath = join(homedir(), ".gg-tasks", "projects", hash, "tasks.json");
264
263
  const data = readFileSync(filePath, "utf-8");
265
264
  const tasks = JSON.parse(data);
266
265
  const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: "in-progress" } : t));
@@ -279,14 +278,17 @@ export function App(props) {
279
278
  // Hoisted before terminal title hook so it can reference them
280
279
  const [lastUserMessage, setLastUserMessage] = useState("");
281
280
  const [exitPending, setExitPending] = useState(false);
282
- 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);
283
285
  const planModeLocalRef = useRef(false);
284
286
  planModeLocalRef.current = planMode;
285
287
  // Terminal title — updated later after agentLoop is created
286
288
  // (hoisted here so the hook is always called in the same order)
287
289
  const [titleRunning, setTitleRunning] = useState(false);
288
- const [sessionTitle, setSessionTitle] = useState(undefined);
289
- const sessionTitleGeneratedRef = useRef(false);
290
+ const [sessionTitle, setSessionTitle] = useState(() => props.sessionStore?.sessionTitle);
291
+ const sessionTitleGeneratedRef = useRef(props.sessionStore?.sessionTitleGenerated ?? false);
290
292
  useTerminalTitle({
291
293
  isRunning: titleRunning,
292
294
  sessionTitle,
@@ -297,6 +299,11 @@ export function App(props) {
297
299
  // gatsby). Ink's Static (build/components/Static.js) starts with index=0
298
300
  // so slice(0) returns the full array regardless of length.
299
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;
300
307
  if (props.initialHistory && props.initialHistory.length > 0) {
301
308
  return compactHistory(trimFlushedItems(props.initialHistory));
302
309
  }
@@ -304,7 +311,9 @@ export function App(props) {
304
311
  });
305
312
  // Items from the current/last turn — rendered in the live area so they stay visible
306
313
  const [liveItems, setLiveItems] = useState([]);
307
- 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);
308
317
  const [taskCount, setTaskCount] = useState(() => getTaskCount(props.cwd));
309
318
  const [eyesCount, setEyesCount] = useState(() => isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
310
319
  const [updatePending, setUpdatePending] = useState(() => getPendingUpdate(props.version) !== null);
@@ -325,14 +334,14 @@ export function App(props) {
325
334
  const [currentProvider, setCurrentProvider] = useState(props.provider);
326
335
  const [currentTools, setCurrentTools] = useState(props.tools);
327
336
  const [thinkingEnabled, setThinkingEnabled] = useState(!!props.thinking);
328
- const messagesRef = useRef(props.messages);
329
- const [planAutoExpand, setPlanAutoExpand] = useState(false);
330
- const approvedPlanPathRef = useRef(undefined);
331
- const planStepsRef = useRef([]);
332
- 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 ?? []);
333
342
  const nextIdRef = useRef(0);
334
343
  const sessionManagerRef = useRef(props.sessionsDir ? new SessionManager(props.sessionsDir) : null);
335
- const sessionPathRef = useRef(props.sessionPath);
344
+ const sessionPathRef = useRef(props.sessionStore?.sessionPath ?? props.sessionPath);
336
345
  const persistedIndexRef = useRef(messagesRef.current.length);
337
346
  /** Last actual API-reported input token count (from turn_end). */
338
347
  const lastActualTokensRef = useRef(0);
@@ -352,6 +361,44 @@ export function App(props) {
352
361
  pendingFlushRef.current = [...pendingFlushRef.current, ...items];
353
362
  setFlushGeneration((g) => g + 1);
354
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);
355
402
  // Derive credentials for the current provider
356
403
  const currentCreds = props.credentialsByProvider?.[currentProvider];
357
404
  const activeApiKey = currentCreds?.accessToken ?? props.apiKey;
@@ -423,6 +470,13 @@ export function App(props) {
423
470
  // premature "done" status that fires when the agent loop finishes
424
471
  planOverlayPendingRef.current = true;
425
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.
426
480
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
427
481
  setPlanAutoExpand(true);
428
482
  setOverlay("plan");
@@ -709,17 +763,53 @@ export function App(props) {
709
763
  if (flushed.length > 0) {
710
764
  queueFlush(flushed);
711
765
  }
712
- const displayText = planStepsRef.current.length > 0 ? stripDoneMarkers(text) : text;
713
- return [
714
- {
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({
715
804
  kind: "assistant",
716
- text: displayText,
805
+ text: "",
717
806
  thinking,
718
807
  thinkingMs,
719
808
  planMode: planModeLocalRef.current,
720
809
  id: getId(),
721
- },
722
- ];
810
+ });
811
+ }
812
+ return items;
723
813
  });
724
814
  }, []),
725
815
  onToolStart: useCallback((toolCallId, name, args) => {
@@ -1189,6 +1279,33 @@ export function App(props) {
1189
1279
  useEffect(() => {
1190
1280
  setTitleRunning(agentLoop.isRunning);
1191
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
+ }, []);
1192
1309
  // Refresh eyes badge count when the agent settles (end of a turn) — a turn
1193
1310
  // may have logged new rough/wish/blocked signals. Also covers the case where
1194
1311
  // /eyes was run for the first time (manifest now exists).
@@ -1197,8 +1314,6 @@ export function App(props) {
1197
1314
  setEyesCount(isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
1198
1315
  }
1199
1316
  }, [agentLoop.isRunning, props.cwd]);
1200
- // Terminal progress bar (OSC 9;4) — pulsing bar in supported terminals
1201
- useTerminalProgress(agentLoop.isRunning, agentLoop.activeToolCalls.length > 0);
1202
1317
  // Animated thinking border — derived from global animation tick
1203
1318
  useAnimationActive();
1204
1319
  const animTick = useAnimationTick();
@@ -1232,13 +1347,29 @@ export function App(props) {
1232
1347
  if (trimmed === "/quit" || trimmed === "/q" || trimmed === "/exit") {
1233
1348
  process.exit(0);
1234
1349
  }
1235
- // 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.
1236
1359
  if (trimmed === "/clear") {
1237
- // Clear terminal screen + scrollback — needed because Ink's <Static>
1238
- // 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.
1239
1372
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1240
- // Discard any items queued for two-phase flush so they don't leak
1241
- // into the new session after the Static remount.
1242
1373
  pendingFlushRef.current = [];
1243
1374
  setHistory([{ kind: "banner", id: "banner" }]);
1244
1375
  setLiveItems([]);
@@ -1246,7 +1377,6 @@ export function App(props) {
1246
1377
  approvedPlanPathRef.current = undefined;
1247
1378
  planStepsRef.current = [];
1248
1379
  setPlanSteps([]);
1249
- // Rebuild system prompt without the approved plan
1250
1380
  void (async () => {
1251
1381
  const newPrompt = await buildSystemPrompt(props.cwd, props.skills, planMode, undefined);
1252
1382
  messagesRef.current = [{ role: "system", content: newPrompt }];
@@ -1255,8 +1385,6 @@ export function App(props) {
1255
1385
  agentLoop.reset();
1256
1386
  setSessionTitle(undefined);
1257
1387
  sessionTitleGeneratedRef.current = false;
1258
- // Bump staticKey to force Ink's <Static> to remount, discarding its
1259
- // internal record of previously rendered items so they don't reappear.
1260
1388
  setStaticKey((k) => k + 1);
1261
1389
  setLiveItems([{ kind: "info", text: "Session cleared.", id: getId() }]);
1262
1390
  return;
@@ -1342,9 +1470,16 @@ export function App(props) {
1342
1470
  }
1343
1471
  // Handle /plans — open plan pane
1344
1472
  if (trimmed === "/plans") {
1345
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1346
- setPlanAutoExpand(false);
1347
- 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
+ }
1348
1483
  return;
1349
1484
  }
1350
1485
  // Handle prompt-template commands (built-in + custom from .ezcoder/commands/)
@@ -1710,6 +1845,8 @@ export function App(props) {
1710
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));
1711
1846
  case "plan_transition":
1712
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));
1713
1850
  case "queued":
1714
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
1715
1852
  ? ` (+${item.imageCount} image${item.imageCount > 1 ? "s" : ""})`
@@ -1727,7 +1864,42 @@ export function App(props) {
1727
1864
  // ── Start a task (shared by manual "work on it" and run-all) ──
1728
1865
  const startTask = useCallback((title, prompt, taskId) => {
1729
1866
  setTaskCount(getTaskCount(props.cwd));
1730
- // 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).
1731
1903
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1732
1904
  setHistory([{ kind: "banner", id: "banner" }]);
1733
1905
  setLiveItems([]);
@@ -1741,12 +1913,6 @@ export function App(props) {
1741
1913
  log("INFO", "tasks", "New session for task", { path: s.path });
1742
1914
  });
1743
1915
  }
1744
- // Inject completion instruction so the agent marks the task done
1745
- const shortId = taskId.slice(0, 8);
1746
- const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
1747
- `tasks({ action: "done", id: "${shortId}" })`;
1748
- const fullPrompt = prompt + completionHint;
1749
- // Show the short title in the TUI, but send the full prompt to the agent
1750
1916
  const taskItem = { kind: "task", title, id: getId() };
1751
1917
  setLastUserMessage(title);
1752
1918
  setDoneStatus(null);
@@ -1765,11 +1931,18 @@ export function App(props) {
1765
1931
  ? { kind: "info", text: "Request was stopped.", id: getId() }
1766
1932
  : { kind: "error", message: msg, id: getId() },
1767
1933
  ]);
1768
- // Stop run-all if a task errors
1769
1934
  setRunAllTasks(false);
1770
1935
  }
1771
1936
  })();
1772
- }, [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
+ ]);
1773
1946
  // Keep refs in sync for access from stale closures (onDone)
1774
1947
  startTaskRef.current = startTask;
1775
1948
  useEffect(() => {
@@ -1853,10 +2026,16 @@ export function App(props) {
1853
2026
  const isPixelView = overlay === "pixel";
1854
2027
  const isOverlayView = isTaskView || isSkillsView || isPlanView || isEyesView || isPixelView;
1855
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: () => {
1856
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1857
- setTaskCount(getTaskCount(props.cwd));
1858
- setStaticKey((k) => k + 1);
1859
- 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
+ }
1860
2039
  }, onWorkOnTask: (title, prompt, taskId) => {
1861
2040
  setOverlay(null);
1862
2041
  startTask(title, prompt, taskId);
@@ -1869,9 +2048,15 @@ export function App(props) {
1869
2048
  startTask(next.title, next.prompt, next.id);
1870
2049
  }
1871
2050
  } })) : isPixelView ? (_jsx(PixelOverlay, { version: props.version, agentRunning: agentLoop.isRunning, onClose: () => {
1872
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1873
- setStaticKey((k) => k + 1);
1874
- 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
+ }
1875
2060
  }, onFixOne: (entry) => {
1876
2061
  setOverlay(null);
1877
2062
  startPixelFix(entry.errorId);
@@ -1883,57 +2068,95 @@ export function App(props) {
1883
2068
  setRunAllPixel(true);
1884
2069
  startPixelFix(first.errorId);
1885
2070
  } })) : isSkillsView ? (_jsx(SkillsOverlay, { cwd: props.cwd, onClose: () => {
1886
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1887
- setStaticKey((k) => k + 1);
1888
- 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
+ }
1889
2080
  } })) : isEyesView ? (_jsx(EyesOverlay, { cwd: props.cwd, onClose: () => {
1890
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1891
- setEyesCount(isEyesActive(props.cwd) ? journalCount({ status: "open" }, props.cwd) : undefined);
1892
- setStaticKey((k) => k + 1);
1893
- 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
+ }
1894
2091
  }, onQueueMessage: (msg) => {
1895
2092
  agentLoop.queueMessage(msg);
1896
2093
  } })) : isPlanView ? (_jsx(PlanOverlay, { cwd: props.cwd, autoExpandNewest: planAutoExpand, onClose: () => {
1897
2094
  planOverlayPendingRef.current = false;
1898
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1899
- setStaticKey((k) => k + 1);
1900
- setPlanAutoExpand(false);
1901
- 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
+ }
1902
2106
  }, onApprove: (planPath) => {
1903
2107
  log("INFO", "plan", "Plan approved — transitioning to implementation", {
1904
2108
  planPath,
1905
2109
  });
1906
- // Plan overlay dismissed — allow future onDone to fire normally
1907
2110
  planOverlayPendingRef.current = false;
1908
- // Store approved plan path — will be injected into the new system prompt
1909
- approvedPlanPathRef.current = planPath;
1910
- // Extract plan steps for progress tracking
1911
- void import("node:fs/promises").then(({ readFile }) => readFile(planPath, "utf-8").then((content) => {
1912
- const steps = extractPlanSteps(content);
1913
- planStepsRef.current = steps;
1914
- setPlanSteps(steps);
1915
- }));
1916
- // Clear session for a fresh context focused on the plan
1917
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1918
- setHistory([{ kind: "banner", id: "banner" }]);
1919
- setLiveItems([]);
1920
- setStaticKey((k) => k + 1);
1921
- setPlanAutoExpand(false);
1922
- setOverlay(null);
1923
- // Rebuild system prompt with the approved plan, then reset the session
1924
2111
  void (async () => {
1925
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.
1926
2118
  const newPrompt = await buildSystemPrompt(props.cwd, props.skills, false, planPath);
1927
- messagesRef.current = [{ role: "system", content: newPrompt }];
1928
- agentLoop.reset();
1929
- persistedIndexRef.current = messagesRef.current.length;
1930
- // 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;
1931
2122
  const sm = sessionManagerRef.current;
1932
2123
  if (sm) {
1933
2124
  const s = await sm.create(props.cwd, currentProvider, currentModel);
1934
- 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;
1935
2144
  }
1936
- // 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;
1937
2160
  setLiveItems([
1938
2161
  {
1939
2162
  kind: "info",
@@ -1952,19 +2175,31 @@ export function App(props) {
1952
2175
  })();
1953
2176
  }, onReject: (planPath, feedback) => {
1954
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
+ }
1955
2193
  stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1956
2194
  setStaticKey((k) => k + 1);
1957
2195
  setPlanAutoExpand(false);
1958
2196
  setOverlay(null);
1959
2197
  setDoneStatus(null);
1960
- // Send rejection + feedback to the agent
1961
- const msg = `The plan at ${planPath} was rejected.\n\nFeedback: ${feedback}\n\n` +
1962
- `Please revise the plan based on this feedback.`;
1963
2198
  setLiveItems((prev) => [
1964
2199
  ...prev,
1965
2200
  { kind: "info", text: `Plan rejected — "${feedback}"`, id: getId() },
1966
2201
  ]);
1967
- void agentLoop.run(msg).catch((err) => {
2202
+ void agentLoop.run(rejectionMsg).catch((err) => {
1968
2203
  const errMsg = err instanceof Error ? err.message : String(err);
1969
2204
  log("ERROR", "error", errMsg);
1970
2205
  setLiveItems((prev) => [...prev, { kind: "error", message: errMsg, id: getId() }]);
@@ -1973,14 +2208,32 @@ export function App(props) {
1973
2208
  ? THINKING_BORDER_COLORS[thinkingBorderFrame]
1974
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 &&
1975
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: () => {
1976
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1977
- 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
+ }
1978
2219
  }, onToggleSkills: () => {
1979
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1980
- 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
+ }
1981
2228
  }, onTogglePixel: () => {
1982
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
1983
- 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
+ }
1984
2237
  }, onTogglePlanMode: () => {
1985
2238
  const next = !planMode;
1986
2239
  setPlanMode(next);