@prestyj/cli 4.3.239 → 4.5.0

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 (179) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/auth.d.ts.map +1 -1
  3. package/dist/cli/auth.js +17 -2
  4. package/dist/cli/auth.js.map +1 -1
  5. package/dist/cli/shared.d.ts.map +1 -1
  6. package/dist/cli/shared.js +27 -5
  7. package/dist/cli/shared.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +14 -5
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +5 -2
  14. package/dist/config.js.map +1 -1
  15. package/dist/config.test.js +10 -0
  16. package/dist/config.test.js.map +1 -1
  17. package/dist/core/agent-session.d.ts.map +1 -1
  18. package/dist/core/agent-session.js +34 -15
  19. package/dist/core/agent-session.js.map +1 -1
  20. package/dist/core/auth-storage.d.ts +18 -0
  21. package/dist/core/auth-storage.d.ts.map +1 -1
  22. package/dist/core/auth-storage.js +62 -8
  23. package/dist/core/auth-storage.js.map +1 -1
  24. package/dist/core/auto-update.js +2 -2
  25. package/dist/core/auto-update.js.map +1 -1
  26. package/dist/core/compaction/compactor.d.ts.map +1 -1
  27. package/dist/core/compaction/compactor.js +4 -0
  28. package/dist/core/compaction/compactor.js.map +1 -1
  29. package/dist/core/goal-worker.js +1 -1
  30. package/dist/core/goal-worker.js.map +1 -1
  31. package/dist/core/goal-worker.test.js +1 -1
  32. package/dist/core/goal-worker.test.js.map +1 -1
  33. package/dist/core/mcp/defaults.d.ts.map +1 -1
  34. package/dist/core/mcp/defaults.js +39 -3
  35. package/dist/core/mcp/defaults.js.map +1 -1
  36. package/dist/core/model-registry.d.ts +21 -0
  37. package/dist/core/model-registry.d.ts.map +1 -1
  38. package/dist/core/model-registry.js +47 -16
  39. package/dist/core/model-registry.js.map +1 -1
  40. package/dist/core/model-registry.test.js +13 -0
  41. package/dist/core/model-registry.test.js.map +1 -1
  42. package/dist/core/oauth/gemini.d.ts.map +1 -1
  43. package/dist/core/oauth/gemini.js +14 -5
  44. package/dist/core/oauth/gemini.js.map +1 -1
  45. package/dist/core/oauth/kimi.d.ts +46 -0
  46. package/dist/core/oauth/kimi.d.ts.map +1 -0
  47. package/dist/core/oauth/kimi.js +278 -0
  48. package/dist/core/oauth/kimi.js.map +1 -0
  49. package/dist/core/prompt-commands.d.ts.map +1 -1
  50. package/dist/core/prompt-commands.js +3 -1
  51. package/dist/core/prompt-commands.js.map +1 -1
  52. package/dist/core/settings-manager.d.ts +1 -0
  53. package/dist/core/settings-manager.d.ts.map +1 -1
  54. package/dist/core/settings-manager.js +2 -0
  55. package/dist/core/settings-manager.js.map +1 -1
  56. package/dist/interactive.d.ts.map +1 -1
  57. package/dist/interactive.js +22 -2
  58. package/dist/interactive.js.map +1 -1
  59. package/dist/system-prompt.d.ts.map +1 -1
  60. package/dist/system-prompt.js +54 -23
  61. package/dist/system-prompt.js.map +1 -1
  62. package/dist/system-prompt.test.js +32 -1
  63. package/dist/system-prompt.test.js.map +1 -1
  64. package/dist/tools/enter-plan.d.ts +1 -1
  65. package/dist/tools/enter-plan.d.ts.map +1 -1
  66. package/dist/tools/enter-plan.js +8 -1
  67. package/dist/tools/enter-plan.js.map +1 -1
  68. package/dist/tools/goals.d.ts +1 -1
  69. package/dist/tools/index.d.ts +16 -2
  70. package/dist/tools/index.d.ts.map +1 -1
  71. package/dist/tools/index.js +9 -2
  72. package/dist/tools/index.js.map +1 -1
  73. package/dist/tools/prompt-hints.d.ts +23 -4
  74. package/dist/tools/prompt-hints.d.ts.map +1 -1
  75. package/dist/tools/prompt-hints.js +35 -10
  76. package/dist/tools/prompt-hints.js.map +1 -1
  77. package/dist/tools/read.d.ts +6 -1
  78. package/dist/tools/read.d.ts.map +1 -1
  79. package/dist/tools/read.js +70 -4
  80. package/dist/tools/read.js.map +1 -1
  81. package/dist/ui/App.d.ts +7 -1
  82. package/dist/ui/App.d.ts.map +1 -1
  83. package/dist/ui/App.js +304 -28
  84. package/dist/ui/App.js.map +1 -1
  85. package/dist/ui/app-items.d.ts +4 -2
  86. package/dist/ui/app-items.d.ts.map +1 -1
  87. package/dist/ui/app-items.js +13 -4
  88. package/dist/ui/app-items.js.map +1 -1
  89. package/dist/ui/chat-layout-pinning.test.js +1 -1
  90. package/dist/ui/chat-layout-pinning.test.js.map +1 -1
  91. package/dist/ui/components/ActivityIndicator.d.ts +7 -0
  92. package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
  93. package/dist/ui/components/ActivityIndicator.js +38 -12
  94. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  95. package/dist/ui/components/AssistantMessage.test.js +1 -1
  96. package/dist/ui/components/AssistantMessage.test.js.map +1 -1
  97. package/dist/ui/components/ChatInputStack.d.ts +2 -0
  98. package/dist/ui/components/ChatInputStack.d.ts.map +1 -1
  99. package/dist/ui/components/ChatInputStack.js.map +1 -1
  100. package/dist/ui/components/ChatScreen.d.ts +1 -0
  101. package/dist/ui/components/ChatScreen.d.ts.map +1 -1
  102. package/dist/ui/components/ChatScreen.js +1 -1
  103. package/dist/ui/components/ChatScreen.js.map +1 -1
  104. package/dist/ui/components/ChatStatusRow.d.ts +2 -0
  105. package/dist/ui/components/ChatStatusRow.d.ts.map +1 -1
  106. package/dist/ui/components/ChatStatusRow.js +40 -1
  107. package/dist/ui/components/ChatStatusRow.js.map +1 -1
  108. package/dist/ui/components/InputArea.d.ts +2 -1
  109. package/dist/ui/components/InputArea.d.ts.map +1 -1
  110. package/dist/ui/components/InputArea.js +24 -8
  111. package/dist/ui/components/InputArea.js.map +1 -1
  112. package/dist/ui/components/MessageResponse.d.ts +7 -1
  113. package/dist/ui/components/MessageResponse.d.ts.map +1 -1
  114. package/dist/ui/components/MessageResponse.js +2 -2
  115. package/dist/ui/components/MessageResponse.js.map +1 -1
  116. package/dist/ui/components/ToolExecution.d.ts.map +1 -1
  117. package/dist/ui/components/ToolExecution.js +72 -15
  118. package/dist/ui/components/ToolExecution.js.map +1 -1
  119. package/dist/ui/components/UserMessage.d.ts +2 -1
  120. package/dist/ui/components/UserMessage.d.ts.map +1 -1
  121. package/dist/ui/components/UserMessage.js +6 -2
  122. package/dist/ui/components/UserMessage.js.map +1 -1
  123. package/dist/ui/hooks/useAgentLoop.d.ts +16 -4
  124. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  125. package/dist/ui/hooks/useAgentLoop.js +46 -2
  126. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  127. package/dist/ui/layout-decisions.d.ts +4 -0
  128. package/dist/ui/layout-decisions.d.ts.map +1 -1
  129. package/dist/ui/layout-decisions.js.map +1 -1
  130. package/dist/ui/login.js +2 -2
  131. package/dist/ui/login.js.map +1 -1
  132. package/dist/ui/prompt-routing.d.ts +2 -2
  133. package/dist/ui/prompt-routing.d.ts.map +1 -1
  134. package/dist/ui/prompt-routing.js +50 -1
  135. package/dist/ui/prompt-routing.js.map +1 -1
  136. package/dist/ui/queued-message.test.js +5 -1
  137. package/dist/ui/queued-message.test.js.map +1 -1
  138. package/dist/ui/render.d.ts +10 -1
  139. package/dist/ui/render.d.ts.map +1 -1
  140. package/dist/ui/render.js +35 -0
  141. package/dist/ui/render.js.map +1 -1
  142. package/dist/ui/slash-command-images.test.js +55 -3
  143. package/dist/ui/slash-command-images.test.js.map +1 -1
  144. package/dist/ui/submit-prompt-command.d.ts.map +1 -1
  145. package/dist/ui/submit-prompt-command.js +6 -3
  146. package/dist/ui/submit-prompt-command.js.map +1 -1
  147. package/dist/ui/terminal-history-status-renderers.d.ts +1 -1
  148. package/dist/ui/terminal-history-status-renderers.d.ts.map +1 -1
  149. package/dist/ui/terminal-history-status-renderers.js +1 -0
  150. package/dist/ui/terminal-history-status-renderers.js.map +1 -1
  151. package/dist/ui/terminal-history.js +36 -10
  152. package/dist/ui/terminal-history.js.map +1 -1
  153. package/dist/ui/terminal-history.test.js +3 -3
  154. package/dist/ui/terminal-history.test.js.map +1 -1
  155. package/dist/ui/transcript/TranscriptRenderer.d.ts.map +1 -1
  156. package/dist/ui/transcript/TranscriptRenderer.js +12 -10
  157. package/dist/ui/transcript/TranscriptRenderer.js.map +1 -1
  158. package/dist/ui/transcript/presentation.d.ts.map +1 -1
  159. package/dist/ui/transcript/presentation.js +17 -2
  160. package/dist/ui/transcript/presentation.js.map +1 -1
  161. package/dist/ui/tui-history-parity.test.js +7 -3
  162. package/dist/ui/tui-history-parity.test.js.map +1 -1
  163. package/dist/ui/utils/assistant-stream-split.d.ts +10 -0
  164. package/dist/ui/utils/assistant-stream-split.d.ts.map +1 -1
  165. package/dist/ui/utils/assistant-stream-split.js +19 -0
  166. package/dist/ui/utils/assistant-stream-split.js.map +1 -1
  167. package/dist/ui/utils/assistant-stream-split.test.js +21 -1
  168. package/dist/ui/utils/assistant-stream-split.test.js.map +1 -1
  169. package/dist/utils/image.d.ts +37 -1
  170. package/dist/utils/image.d.ts.map +1 -1
  171. package/dist/utils/image.js +156 -1
  172. package/dist/utils/image.js.map +1 -1
  173. package/dist/utils/plan-steps.d.ts +29 -2
  174. package/dist/utils/plan-steps.d.ts.map +1 -1
  175. package/dist/utils/plan-steps.js +88 -16
  176. package/dist/utils/plan-steps.js.map +1 -1
  177. package/dist/utils/plan-steps.test.js +144 -1
  178. package/dist/utils/plan-steps.test.js.map +1 -1
  179. package/package.json +4 -3
package/dist/ui/App.js CHANGED
@@ -12,7 +12,7 @@ import { useDoublePress } from "./hooks/useDoublePress.js";
12
12
  import { useTaskBarStore, useTaskBarPolling, focusTaskBar, exitTaskBar, expandTaskBar, collapseTaskBar, navigateTaskBar, killTask, } from "./stores/taskbar-store.js";
13
13
  import { playNotificationSound } from "../utils/sound.js";
14
14
  import {} from "@prestyj/ai";
15
- import { downscaleForPreview, extractImagePaths } from "../utils/image.js";
15
+ import { downscaleForPreview, extractMediaPaths } from "../utils/image.js";
16
16
  import { useAgentLoop } from "./hooks/useAgentLoop.js";
17
17
  import { useTranscriptHistory } from "./hooks/useTranscriptHistory.js";
18
18
  import { createWebSearchTool } from "../tools/web-search.js";
@@ -23,7 +23,7 @@ import { SessionSummaryDisplay } from "./components/SessionSummary.js";
23
23
  import { useTheme, useSetTheme } from "./theme/theme.js";
24
24
  import { useTerminalTitle } from "./hooks/useTerminalTitle.js";
25
25
  import { getGitBranch } from "../utils/git.js";
26
- import { getModel } from "../core/model-registry.js";
26
+ import { getModel, getVideoByteLimit } from "../core/model-registry.js";
27
27
  import { SessionManager } from "../core/session-manager.js";
28
28
  import { log } from "../core/logger.js";
29
29
  import { getPendingUpdate, startPeriodicUpdateCheck, stopPeriodicUpdateCheck, } from "../core/auto-update.js";
@@ -36,10 +36,10 @@ import { detectLanguages } from "../core/language-detector.js";
36
36
  import { detectVerifyCommands } from "../core/verify-commands.js";
37
37
  import { RewindOverlay } from "./components/RewindOverlay.js";
38
38
  import { reconcileGoalStatusEntriesWithRuns, removeGoalStatusEntry, syncGoalStatusEntries, } from "./components/GoalStatusBar.js";
39
- import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, segmentDisplayText, stripDoneMarkers, } from "../utils/plan-steps.js";
40
- import { getMCPServers } from "../core/mcp/index.js";
39
+ import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, rebasePlanSteps, segmentDisplayText, stripDoneMarkers, } from "../utils/plan-steps.js";
40
+ import { getAllMcpServers } from "../core/mcp/index.js";
41
41
  import { trimFlushedItems, flushOnTurnText, flushOnTurnEnd, flushOverflow, } from "./live-item-flush.js";
42
- import { splitAssistantStreamingText } from "./utils/assistant-stream-split.js";
42
+ import { splitAssistantStreamingText, estimateRenderedRows, } from "./utils/assistant-stream-split.js";
43
43
  import { getNextPendingTask, markTaskInProgress } from "../core/task-store.js";
44
44
  import { buildUserContentWithAttachments } from "./prompt-routing.js";
45
45
  import { submitPromptCommand } from "./submit-prompt-command.js";
@@ -282,6 +282,10 @@ export function App(props) {
282
282
  const [runAllTasks, setRunAllTasks] = useState(props.sessionStore?.runAllTasks ?? false);
283
283
  const runAllTasksRef = useRef(props.sessionStore?.runAllTasks ?? false);
284
284
  const startTaskRef = useRef(() => { });
285
+ // True while a task-pane task (single or run-all) is executing. Used to
286
+ // suppress the interactive plan-mode approval pane, which would otherwise
287
+ // stall an unattended task run waiting for the user to approve a plan.
288
+ const taskRunningRef = useRef(props.sessionStore?.taskRunning ?? false);
285
289
  const runAllPixelRef = useRef(props.sessionStore?.runAllPixel ?? false);
286
290
  const currentPixelFixRef = useRef(null);
287
291
  const startPixelFixRef = useRef(() => { });
@@ -343,6 +347,8 @@ export function App(props) {
343
347
  createSessionStats({ sessionId: props.sessionStore?.sessionId ?? props.sessionId }));
344
348
  const [idealReviewEnabled, setIdealReviewEnabled] = useState(props.sessionStore?.idealReviewEnabled ?? props.idealReviewEnabled ?? true);
345
349
  const idealReviewEnabledRef = useRef(idealReviewEnabled);
350
+ const [autoApprovePlansEnabled, setAutoApprovePlansEnabled] = useState(props.sessionStore?.autoApprovePlans ?? props.autoApprovePlans ?? true);
351
+ const autoApprovePlansEnabledRef = useRef(autoApprovePlansEnabled);
346
352
  /** Last actual API-reported input token count (from turn_end). */
347
353
  const lastActualTokensRef = useRef(0);
348
354
  /** Timestamp (ms) when lastActualTokensRef was last updated by turn_end. */
@@ -368,11 +374,21 @@ export function App(props) {
368
374
  */
369
375
  const triggerAutoSetupRef = useRef(async () => { });
370
376
  const getId = () => `ui-${nextIdRef.current++}`;
377
+ // Single predicate for "no human is present to approve a plan": task-list
378
+ // runs (single + run-all, via taskRunningRef) and pixel-fix runs (single +
379
+ // run-all-pixel, via currentPixelFixRef). Every autonomous context routes
380
+ // through here so future ones only update one place.
381
+ const isUnattendedRun = () => taskRunningRef.current || currentPixelFixRef.current !== null;
371
382
  useEffect(() => {
372
383
  idealReviewEnabledRef.current = idealReviewEnabled;
373
384
  if (props.sessionStore)
374
385
  props.sessionStore.idealReviewEnabled = idealReviewEnabled;
375
386
  }, [idealReviewEnabled, props.sessionStore]);
387
+ useEffect(() => {
388
+ autoApprovePlansEnabledRef.current = autoApprovePlansEnabled;
389
+ if (props.sessionStore)
390
+ props.sessionStore.autoApprovePlans = autoApprovePlansEnabled;
391
+ }, [autoApprovePlansEnabled, props.sessionStore]);
376
392
  const sessionStore = props.sessionStore;
377
393
  const { planMode, goalMode, rebuildSystemPrompt, replaceSystemPrompt, setPlanModeAndPrompt, setGoalModeAndPrompt, } = useModeState({
378
394
  initialPlanMode: props.sessionStore?.planMode ?? props.planModeRef?.current ?? false,
@@ -577,8 +593,25 @@ export function App(props) {
577
593
  void props
578
594
  .connectInitialMcpTools()
579
595
  .then((mcpTools) => {
580
- if (cancelled || mcpTools.length === 0)
596
+ if (cancelled)
581
597
  return;
598
+ if (mcpTools.length === 0) {
599
+ // No MCP tools connected (e.g. kencode-search failed to boot). The
600
+ // system prompt was built without them, so the model is never told
601
+ // to call tools it doesn't have — but flag it so the user knows
602
+ // public-code research is degraded this session.
603
+ log("WARN", "mcp", "No MCP tools connected at startup");
604
+ setLiveItems((prev) => [
605
+ ...prev,
606
+ {
607
+ kind: "info",
608
+ text: "Research tools (kencode-search) didn't connect — public-code search is unavailable this session.",
609
+ id: getId(),
610
+ },
611
+ ]);
612
+ return;
613
+ }
614
+ log("INFO", "mcp", `MCP tools ready: ${mcpTools.length} tool(s)`);
582
615
  setCurrentTools((prev) => {
583
616
  const next = [...prev.filter((tool) => !tool.name.startsWith("mcp__")), ...mcpTools];
584
617
  currentToolsRef.current = next;
@@ -772,6 +805,8 @@ export function App(props) {
772
805
  tools: currentTools,
773
806
  webSearch: props.webSearch,
774
807
  maxTokens: props.maxTokens,
808
+ supportsImages: getModel(currentModel)?.supportsImages ?? true,
809
+ supportsVideo: getModel(currentModel)?.supportsVideo ?? false,
775
810
  thinking: thinkingLevel,
776
811
  apiKey: activeApiKey,
777
812
  baseUrl: activeBaseUrl,
@@ -889,10 +924,32 @@ export function App(props) {
889
924
  if (planStepsRef.current.length > 0) {
890
925
  const completed = findCompletedMarkers(text);
891
926
  if (completed.size > 0) {
892
- const updated = markStepsCompleted(planStepsRef.current, completed);
893
- if (updated !== planStepsRef.current) {
894
- planStepsRef.current = updated;
895
- setPlanSteps(updated);
927
+ const planPath = approvedPlanPathRef.current;
928
+ // The agent can rewrite/expand the approved plan mid-run, so the
929
+ // snapshot captured at approval goes stale (wrong total, and new
930
+ // [DONE:n] markers match nothing). Re-extract from the live plan
931
+ // file and re-base onto it before applying markers. The file read
932
+ // is async; apply markers inside the same async step to avoid a
933
+ // race with the snapshot. Fall back to the in-memory snapshot if
934
+ // there is no plan path or the read fails.
935
+ const applyMarkers = (base) => {
936
+ const updated = markStepsCompleted(base, completed);
937
+ if (updated !== planStepsRef.current) {
938
+ planStepsRef.current = updated;
939
+ setPlanSteps(updated);
940
+ }
941
+ };
942
+ if (planPath) {
943
+ void import("node:fs/promises")
944
+ .then(({ readFile }) => readFile(planPath, "utf-8"))
945
+ .then((planContent) => {
946
+ const fresh = extractPlanSteps(planContent);
947
+ applyMarkers(rebasePlanSteps(planStepsRef.current, fresh));
948
+ })
949
+ .catch(() => applyMarkers(planStepsRef.current));
950
+ }
951
+ else {
952
+ applyMarkers(planStepsRef.current);
896
953
  }
897
954
  // Real progress happened — reset the stuck-guard so the next
898
955
  // step gets its own fresh nudge budget.
@@ -1229,6 +1286,18 @@ export function App(props) {
1229
1286
  log("INFO", "server_tool", `Server tool call: ${name}`, { id });
1230
1287
  const startedAt = Date.now();
1231
1288
  const animateUntil = startedAt + RUNNING_INDICATOR_ANIMATION_MS;
1289
+ // Feed the pinned LiveToolPanel so provider-side tools (Anthropic's
1290
+ // native web_search) appear in the same rolling window as client
1291
+ // tools. `input` carries the tool args (e.g. { query }) the row reads.
1292
+ setLiveToolFeed((prev) => [
1293
+ ...prev,
1294
+ {
1295
+ id,
1296
+ name,
1297
+ args: (input ?? {}),
1298
+ status: "running",
1299
+ },
1300
+ ].slice(-(LIVE_TOOL_PANEL_ROWS * 2)));
1232
1301
  // Flush completed items (including assistant text) before adding server
1233
1302
  // tool UI — same rationale as onToolStart.
1234
1303
  setLiveItems((prev) => {
@@ -1243,6 +1312,11 @@ export function App(props) {
1243
1312
  if (flushed.length > 0) {
1244
1313
  queueFlush(flushed);
1245
1314
  }
1315
+ // The pre-tool text was just pinned; the hook resets its streaming
1316
+ // buffer at this same boundary. Reset the progressive-flush offset
1317
+ // so post-tool text is measured from zero (stale flushedChars would
1318
+ // otherwise slice into the fresh, shorter buffer).
1319
+ streamedAssistantFlushRef.current = { flushedChars: 0, text: "" };
1246
1320
  return [
1247
1321
  ...remaining,
1248
1322
  {
@@ -1259,6 +1333,9 @@ export function App(props) {
1259
1333
  }, [queueFlush]),
1260
1334
  onServerToolResult: useCallback((toolUseId, resultType, data) => {
1261
1335
  log("INFO", "server_tool", `Server tool result`, { toolUseId, resultType });
1336
+ // Mark the panel entry done. Aborts never reach here (handled in
1337
+ // onAborted), so a result that arrives is always a normal completion.
1338
+ setLiveToolFeed((prev) => prev.map((entry) => entry.id === toolUseId ? { ...entry, status: "done" } : entry));
1262
1339
  setLiveItems((prev) => {
1263
1340
  let updated;
1264
1341
  const startIdx = prev.findIndex((item) => item.kind === "server_tool_start" && item.serverToolCallId === toolUseId);
@@ -1324,11 +1401,16 @@ export function App(props) {
1324
1401
  return remaining;
1325
1402
  });
1326
1403
  }, [queueFlush]),
1327
- onDone: useCallback((durationMs, toolsUsed) => {
1404
+ onDone: useCallback((durationMs, toolsUsed, runStats) => {
1328
1405
  log("INFO", "agent", `Agent done`, {
1329
1406
  duration: `${durationMs}ms`,
1330
1407
  toolsUsed: toolsUsed.join(",") || "none",
1331
1408
  });
1409
+ // The task run (if any) is finished. Run-all re-arms this below when it
1410
+ // auto-starts the next task.
1411
+ taskRunningRef.current = false;
1412
+ if (props.sessionStore)
1413
+ props.sessionStore.taskRunning = false;
1332
1414
  const doneDecision = getDoneFlushDecision({
1333
1415
  planOverlayPending: planOverlayPendingRef.current,
1334
1416
  });
@@ -1337,7 +1419,13 @@ export function App(props) {
1337
1419
  // Still flush live transcript rows before the pane remounts; otherwise
1338
1420
  // setup output remains in ephemeral liveItems and appears to vanish.
1339
1421
  if (doneDecision.showDoneStatus) {
1340
- setDoneStatus({ durationMs, toolsUsed, verb: pickDurationVerb(toolsUsed) });
1422
+ setDoneStatus({
1423
+ durationMs,
1424
+ toolsUsed,
1425
+ verb: pickDurationVerb(toolsUsed),
1426
+ counts: runStats?.counts,
1427
+ tokens: runStats?.tokens,
1428
+ });
1341
1429
  playNotificationSound();
1342
1430
  }
1343
1431
  // Keep the final assistant response mounted in the live frame after a
@@ -1465,10 +1553,14 @@ export function App(props) {
1465
1553
  const imageCount = typeof content === "string"
1466
1554
  ? undefined
1467
1555
  : content.filter((c) => c.type === "image").length || undefined;
1556
+ const videoCount = typeof content === "string"
1557
+ ? undefined
1558
+ : content.filter((c) => c.type === "video").length || undefined;
1468
1559
  const userItem = {
1469
1560
  kind: "user",
1470
1561
  text: displayText,
1471
1562
  imageCount,
1563
+ videoCount,
1472
1564
  id: getId(),
1473
1565
  };
1474
1566
  setLastUserMessage(displayText);
@@ -1505,6 +1597,13 @@ export function App(props) {
1505
1597
  },
1506
1598
  ];
1507
1599
  }, []),
1600
+ onRetry: useCallback(() => {
1601
+ // Roll back any pending progressive flushes from the aborted attempt.
1602
+ // Without this, a stall retry regenerates the preamble and the old
1603
+ // flushed paragraph + the new one both end up in terminal history.
1604
+ pendingHistoryFlushRef.current = pendingHistoryFlushRef.current.filter((item) => item.kind !== "assistant");
1605
+ streamedAssistantFlushRef.current = { flushedChars: 0, text: "" };
1606
+ }, []),
1508
1607
  });
1509
1608
  const continueGoalRun = useCallback((run) => {
1510
1609
  const cwd = cwdRef.current;
@@ -1874,6 +1973,12 @@ export function App(props) {
1874
1973
  if (sessionStore) {
1875
1974
  sessionStore.pendingAction = undefined;
1876
1975
  }
1976
+ // Re-arm the task-running flag after the remount: startTask sets it but the
1977
+ // resetUI() remount drops the plain ref, so without this an unattended task
1978
+ // run would look interactive (and stall on plan-mode approval).
1979
+ if (sessionStore?.taskRunning) {
1980
+ taskRunningRef.current = true;
1981
+ }
1877
1982
  setDoneStatus(null);
1878
1983
  if (action.planEvent) {
1879
1984
  const ev = action.planEvent;
@@ -1891,6 +1996,9 @@ export function App(props) {
1891
1996
  void agentLoop.run(action.prompt).catch((err) => {
1892
1997
  const errMsg = err instanceof Error ? err.message : String(err);
1893
1998
  log("ERROR", "error", errMsg);
1999
+ if (agentLoop.isRunning) {
2000
+ agentLoop.reset();
2001
+ }
1894
2002
  setLiveItems((prev) => [...prev, toErrorItem(err, getId())]);
1895
2003
  });
1896
2004
  // Intentional one-shot: run once on mount, never re-fire on re-render.
@@ -1929,6 +2037,26 @@ export function App(props) {
1929
2037
  ]);
1930
2038
  return;
1931
2039
  }
2040
+ if (trimmed === "/autoplan-on" || trimmed === "/autoplan-off") {
2041
+ const next = trimmed === "/autoplan-on";
2042
+ setAutoApprovePlansEnabled(next);
2043
+ if (props.settingsFile) {
2044
+ const sm = new SettingsManager(props.settingsFile);
2045
+ await sm.load();
2046
+ await sm.set("autoApprovePlans", next);
2047
+ }
2048
+ setLiveItems((prev) => [
2049
+ ...prev,
2050
+ {
2051
+ kind: "info",
2052
+ text: next
2053
+ ? "Auto-approve plans enabled — unattended runs approve plans and keep going. Use /autoplan-off to disable."
2054
+ : "Auto-approve plans disabled — unattended runs decline plan mode instead. Use /autoplan-on to enable.",
2055
+ id: getId(),
2056
+ },
2057
+ ]);
2058
+ return;
2059
+ }
1932
2060
  // /rewind — open the checkpoint picker (needs React state + the store).
1933
2061
  if (trimmed === "/rewind") {
1934
2062
  const store = props.checkpointStore;
@@ -2056,31 +2184,35 @@ export function App(props) {
2056
2184
  }
2057
2185
  // ── Build user content (shared by normal + queued paths) ──
2058
2186
  const hasImages = inputImages.length > 0;
2187
+ const imageCount = inputImages.filter((img) => img.kind === "image").length;
2188
+ const videoCount = inputImages.filter((img) => img.kind === "video").length;
2059
2189
  const modelInfo = getModel(currentModel);
2060
2190
  const modelSupportsImages = modelInfo?.supportsImages ?? true;
2061
- const userContent = buildUserContentWithAttachments(input, inputImages, modelSupportsImages);
2191
+ const modelSupportsVideo = modelInfo?.supportsVideo ?? false;
2192
+ const userContent = buildUserContentWithAttachments(input, inputImages, modelSupportsImages, modelSupportsVideo);
2062
2193
  // ── Queue message if agent is already running ──
2063
2194
  if (agentLoop.isRunning) {
2064
2195
  log("INFO", "queue", `Queued message: ${trimmed.length > 80 ? trimmed.slice(0, 80) + "..." : trimmed}`);
2065
2196
  agentLoop.queueMessage(userContent, input);
2066
2197
  let displayText = input;
2067
2198
  if (hasImages) {
2068
- const { cleanText } = await extractImagePaths(input, props.cwd);
2199
+ const { cleanText } = await extractMediaPaths(input, props.cwd);
2069
2200
  displayText = cleanText;
2070
2201
  }
2071
2202
  const queuedItem = {
2072
2203
  kind: "queued",
2073
2204
  text: displayText,
2074
- imageCount: hasImages ? inputImages.length : undefined,
2205
+ imageCount: imageCount > 0 ? imageCount : undefined,
2206
+ videoCount: videoCount > 0 ? videoCount : undefined,
2075
2207
  id: getId(),
2076
2208
  };
2077
2209
  setLiveItems((prev) => [...prev, queuedItem]);
2078
2210
  return;
2079
2211
  }
2080
- // Build display text — strip image paths, show badges instead
2212
+ // Build display text — strip image/video paths, show badges instead
2081
2213
  let displayText = input;
2082
2214
  if (hasImages) {
2083
- const { cleanText } = await extractImagePaths(input, props.cwd);
2215
+ const { cleanText } = await extractMediaPaths(input, props.cwd);
2084
2216
  displayText = cleanText;
2085
2217
  }
2086
2218
  let imagePreviews;
@@ -2096,7 +2228,8 @@ export function App(props) {
2096
2228
  const userItem = {
2097
2229
  kind: "user",
2098
2230
  text: displayText,
2099
- imageCount: hasImages ? inputImages.length : undefined,
2231
+ imageCount: imageCount > 0 ? imageCount : undefined,
2232
+ videoCount: videoCount > 0 ? videoCount : undefined,
2100
2233
  imagePreviews,
2101
2234
  pasteInfo,
2102
2235
  id: getId(),
@@ -2130,6 +2263,12 @@ export function App(props) {
2130
2263
  const msg = err instanceof Error ? err.message : String(err);
2131
2264
  log("ERROR", "error", msg);
2132
2265
  const isAbort = msg.includes("aborted") || msg.includes("abort");
2266
+ // If the agent loop threw but left isRunning in a stale true state
2267
+ // (can happen when the finally block hasn't been processed by React
2268
+ // yet), reset it so the user isn't deadlocked with a non-working UI.
2269
+ if (agentLoop.isRunning) {
2270
+ agentLoop.reset();
2271
+ }
2133
2272
  setLiveItems((prev) => [
2134
2273
  ...prev,
2135
2274
  isAbort
@@ -2220,8 +2359,14 @@ export function App(props) {
2220
2359
  rebuildPromptWithTools(next);
2221
2360
  return next;
2222
2361
  });
2223
- // Reconnect MCP servers
2224
- if (props.mcpManager) {
2362
+ // Reconnect MCP servers ONLY when the resolved server set actually
2363
+ // changes. GLM is the only provider with a different set (Z.AI
2364
+ // servers), so a switch that doesn't involve GLM on either side
2365
+ // keeps the identical set — tearing down a live stdio child (e.g.
2366
+ // kencode-search) and re-spawning `npx` there only risks a failed
2367
+ // re-spawn that would silently drop the tools.
2368
+ const glmInvolved = newProvider === "glm" || prevProvider === "glm";
2369
+ if (props.mcpManager && glmInvolved) {
2225
2370
  void (async () => {
2226
2371
  // Disconnect old MCP servers
2227
2372
  await props.mcpManager.dispose();
@@ -2240,7 +2385,11 @@ export function App(props) {
2240
2385
  apiKey = props.credentialsByProvider?.["glm"]?.accessToken;
2241
2386
  }
2242
2387
  try {
2243
- const mcpTools = await props.mcpManager.connectAll(getMCPServers(newProvider, apiKey));
2388
+ // Use getAllMcpServers so user-configured servers (from
2389
+ // ~/.ezcoder/mcp.json and ./.gg/mcp.json) survive the reconnect —
2390
+ // getMCPServers returns provider defaults only.
2391
+ const servers = await getAllMcpServers(newProvider, apiKey, props.cwd);
2392
+ const mcpTools = await props.mcpManager.connectAll(servers);
2244
2393
  setCurrentTools((prev) => {
2245
2394
  const next = [...prev.filter((t) => !t.name.startsWith("mcp__")), ...mcpTools];
2246
2395
  rebuildPromptWithTools(next);
@@ -2262,7 +2411,26 @@ export function App(props) {
2262
2411
  }
2263
2412
  return newProvider;
2264
2413
  });
2265
- setCurrentModel(newModelId);
2414
+ // The `read` tool's video capability (its description + native-video
2415
+ // execute path) is baked in at creation from the model's `maxVideoBytes`.
2416
+ // Switching to/from a video-capable model (e.g. text-only MiMo-V2.5-Pro →
2417
+ // omnimodal MiMo-V2.5) must rebuild it, or the tool keeps telling the model
2418
+ // it can't watch video. Rebuild reuses the read tracker, so read-before-edit
2419
+ // history survives. Provider-change rebuilds the prompt above; this covers
2420
+ // same-provider model switches too.
2421
+ setCurrentModel((prevModel) => {
2422
+ if (props.rebuildReadTool &&
2423
+ getVideoByteLimit(prevModel) !== getVideoByteLimit(newModelId)) {
2424
+ const newReadTool = props.rebuildReadTool(newModelId);
2425
+ setCurrentTools((prev) => {
2426
+ const next = prev.map((tool) => (tool.name === "read" ? newReadTool : tool));
2427
+ currentToolsRef.current = next;
2428
+ void replaceSystemPrompt({ tools: next });
2429
+ return next;
2430
+ });
2431
+ }
2432
+ return newModelId;
2433
+ });
2266
2434
  const modelInfo = getModel(newModelId);
2267
2435
  const displayName = modelInfo?.name ?? newModelId;
2268
2436
  setLiveItems((prev) => [
@@ -2277,7 +2445,14 @@ export function App(props) {
2277
2445
  await sm.set("defaultModel", newModelId);
2278
2446
  });
2279
2447
  }
2280
- }, [props.settingsFile, props.mcpManager, props.credentialsByProvider, props.authStorage]);
2448
+ }, [
2449
+ props.settingsFile,
2450
+ props.mcpManager,
2451
+ props.credentialsByProvider,
2452
+ props.authStorage,
2453
+ props.rebuildReadTool,
2454
+ replaceSystemPrompt,
2455
+ ]);
2281
2456
  const handleThemeSelect = useCallback((name) => {
2282
2457
  setOverlay(null);
2283
2458
  if (switchTheme) {
@@ -2339,6 +2514,14 @@ export function App(props) {
2339
2514
  : "Enable pre-final ideal review",
2340
2515
  sectionTitle: "built-in",
2341
2516
  },
2517
+ {
2518
+ name: autoApprovePlansEnabled ? "autoplan-off" : "autoplan-on",
2519
+ aliases: [],
2520
+ description: autoApprovePlansEnabled
2521
+ ? "Disable auto-approving plans in unattended runs"
2522
+ : "Enable auto-approving plans in unattended runs",
2523
+ sectionTitle: "built-in",
2524
+ },
2342
2525
  {
2343
2526
  name: "rewind",
2344
2527
  aliases: [],
@@ -2360,7 +2543,7 @@ export function App(props) {
2360
2543
  sectionTitle: "built-in",
2361
2544
  },
2362
2545
  ];
2363
- }, [customCommands, idealReviewEnabled]);
2546
+ }, [customCommands, idealReviewEnabled, autoApprovePlansEnabled]);
2364
2547
  const renderItem = (item, index, items) => renderTranscriptItem({
2365
2548
  item,
2366
2549
  index,
@@ -2446,6 +2629,9 @@ export function App(props) {
2446
2629
  // onDone can invoke it from stale closures.
2447
2630
  const startTask = useCallback((title, prompt, taskId) => {
2448
2631
  const taskCwd = cwdRef.current;
2632
+ taskRunningRef.current = true;
2633
+ if (props.sessionStore)
2634
+ props.sessionStore.taskRunning = true;
2449
2635
  const shortId = taskId.slice(0, 8);
2450
2636
  const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
2451
2637
  `tasks({ action: "done", id: "${shortId}" })`;
@@ -2497,6 +2683,9 @@ export function App(props) {
2497
2683
  setDoneStatus(null);
2498
2684
  setLiveItems([taskItem]);
2499
2685
  void agentLoop.run(fullPrompt).catch((err) => {
2686
+ if (agentLoop.isRunning) {
2687
+ agentLoop.reset();
2688
+ }
2500
2689
  setLiveItems((prev) => [...prev, toErrorItem(err, getId())]);
2501
2690
  });
2502
2691
  }, [agentLoop, currentModel, currentProvider, props]);
@@ -2545,7 +2734,23 @@ export function App(props) {
2545
2734
  // every chunk boundary. Slicing by the prospective flush here keeps the live
2546
2735
  // frame height monotonic, so the footer never bounces.
2547
2736
  const alreadyFlushedChars = streamedAssistantFlushRef.current.flushedChars;
2548
- const pendingFlushChars = rawVisibleStreamingText
2737
+ // Retry-safety gate: don't commit streamed paragraphs to permanent scrollback
2738
+ // while the text is still small enough to live entirely in the live region.
2739
+ // A silent stall-retry (agent-loop.ts) restarts the LLM call from scratch and
2740
+ // regenerates the opening text — reworded, so the byte-identical dedup can't
2741
+ // catch it. Anything already printed to scrollback can't be un-written, so the
2742
+ // regen appends as a second ⏺ bullet that paraphrases the first. Keeping short
2743
+ // streamed text live (it clears via setStreamingText("") on retry) closes that
2744
+ // hole. We only start flushing once the unflushed text would overflow the live
2745
+ // area — the original anti-jump purpose, which only matters for long responses
2746
+ // that are far less likely to be a stalled preamble. Once flushing has begun
2747
+ // for this turn (alreadyFlushedChars > 0) we keep flushing every boundary so
2748
+ // committed continuation paragraphs stay consistent with the live tail.
2749
+ const unflushedStreamingRows = rawVisibleStreamingText
2750
+ ? estimateRenderedRows(rawVisibleStreamingText.slice(alreadyFlushedChars), columns)
2751
+ : 0;
2752
+ const shouldFlushStreamedText = alreadyFlushedChars > 0 || unflushedStreamingRows > measuredLiveAreaRows;
2753
+ const pendingFlushChars = rawVisibleStreamingText && shouldFlushStreamedText
2549
2754
  ? splitAssistantStreamingText(rawVisibleStreamingText.slice(alreadyFlushedChars)).flushedText
2550
2755
  .length
2551
2756
  : 0;
@@ -2556,6 +2761,13 @@ export function App(props) {
2556
2761
  }
2557
2762
  if (rawVisibleStreamingText === streamedAssistantFlushRef.current.text)
2558
2763
  return;
2764
+ if (!shouldFlushStreamedText) {
2765
+ streamedAssistantFlushRef.current = {
2766
+ ...streamedAssistantFlushRef.current,
2767
+ text: rawVisibleStreamingText,
2768
+ };
2769
+ return;
2770
+ }
2559
2771
  const alreadyFlushed = streamedAssistantFlushRef.current.flushedChars;
2560
2772
  const unflushedText = rawVisibleStreamingText.slice(alreadyFlushed);
2561
2773
  const split = splitAssistantStreamingText(unflushedText);
@@ -2578,7 +2790,7 @@ export function App(props) {
2578
2790
  ...streamedAssistantFlushRef.current,
2579
2791
  text: rawVisibleStreamingText,
2580
2792
  };
2581
- }, [rawVisibleStreamingText, queueFlush]);
2793
+ }, [rawVisibleStreamingText, shouldFlushStreamedText, queueFlush]);
2582
2794
  const visibleStreamingText = stripDoneMarkers(rawVisibleStreamingText.slice(alreadyFlushedChars + pendingFlushChars));
2583
2795
  const lastLiveItem = liveItems.at(-1);
2584
2796
  // For spacing decisions, the previous row is the last item that actually
@@ -2731,13 +2943,48 @@ export function App(props) {
2731
2943
  setOverlay(null);
2732
2944
  };
2733
2945
  const handleEnterPlanMode = useCallback(async (reason) => {
2946
+ // During an unattended run with auto-approve disabled, plan mode would open
2947
+ // an approval pane and stall the loop waiting on a human. Decline plan mode
2948
+ // so the agent implements directly. When auto-approve is enabled we DO allow
2949
+ // plan mode — handleExitPlanMode auto-approves the plan in place and the run
2950
+ // keeps going, preserving the explore→plan→implement process.
2951
+ if (isUnattendedRun() && !autoApprovePlansEnabledRef.current)
2952
+ return false;
2734
2953
  await setPlanModeAndPrompt(true);
2735
2954
  setLiveItems((prev) => [
2736
2955
  ...prev,
2737
2956
  { kind: "plan_transition", text: reason ?? "", id: getId(), active: true },
2738
2957
  ]);
2958
+ return true;
2739
2959
  }, [setPlanModeAndPrompt]);
2740
- const handleExitPlanMode = useCallback(async (_planPath) => {
2960
+ const handleExitPlanMode = useCallback(async (planPath) => {
2961
+ // Unattended run + auto-approve enabled: approve the plan IN PLACE (no
2962
+ // remount, no message wipe) so the appended task-completion hint survives
2963
+ // and run-all can advance. Swap the system prompt from "Plan Mode (ACTIVE)"
2964
+ // to the "Approved Plan" + step-tracking section, surface an auditable
2965
+ // transcript row, and tell the model to implement now.
2966
+ if (isUnattendedRun() && autoApprovePlansEnabledRef.current) {
2967
+ log("INFO", "plan", "Plan auto-approved during unattended run", { planPath });
2968
+ let steps = [];
2969
+ try {
2970
+ const planContent = await import("node:fs/promises").then(({ readFile }) => readFile(planPath, "utf-8"));
2971
+ steps = extractPlanSteps(planContent);
2972
+ }
2973
+ catch {
2974
+ // Plan with no readable steps still proceeds (just no step widget).
2975
+ }
2976
+ approvedPlanPathRef.current = planPath;
2977
+ planStepsRef.current = steps;
2978
+ setPlanSteps(steps);
2979
+ await setPlanModeAndPrompt(false);
2980
+ await replaceSystemPrompt({ approvedPlanPath: planPath, planMode: false });
2981
+ setLiveItems((prev) => [
2982
+ ...prev,
2983
+ { kind: "plan_event", event: "auto-approved", detail: planPath, id: getId() },
2984
+ ]);
2985
+ return (`Plan auto-approved (unattended run). The approved plan is at ${planPath}. ` +
2986
+ `Implement it now, following each step in order.`);
2987
+ }
2741
2988
  await setPlanModeAndPrompt(false);
2742
2989
  planOverlayPendingRef.current = true;
2743
2990
  setPlanAutoExpand(true);
@@ -2747,13 +2994,38 @@ export function App(props) {
2747
2994
  }
2748
2995
  setOverlay("plan");
2749
2996
  return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
2750
- }, [props.sessionStore, setPlanModeAndPrompt]);
2997
+ }, [props.sessionStore, setPlanModeAndPrompt, replaceSystemPrompt]);
2751
2998
  useEffect(() => {
2752
2999
  if (!props.planCallbacks)
2753
3000
  return;
2754
3001
  props.planCallbacks.onEnterPlan = handleEnterPlanMode;
2755
3002
  props.planCallbacks.onExitPlan = handleExitPlanMode;
2756
3003
  }, [handleEnterPlanMode, handleExitPlanMode, props.planCallbacks]);
3004
+ // Toggle auto-approve-plans (Ctrl+O / /autoplan-on|off). Persists the setting
3005
+ // so it survives restarts and pushes a transient info row for feedback.
3006
+ const handleToggleAutoApprove = useCallback(() => {
3007
+ const next = !autoApprovePlansEnabledRef.current;
3008
+ setAutoApprovePlansEnabled(next);
3009
+ if (props.settingsFile) {
3010
+ const sm = new SettingsManager(props.settingsFile);
3011
+ void sm
3012
+ .load()
3013
+ .then(() => sm.set("autoApprovePlans", next))
3014
+ .catch(() => {
3015
+ /* persistence is best-effort */
3016
+ });
3017
+ }
3018
+ setLiveItems((prev) => [
3019
+ ...prev,
3020
+ {
3021
+ kind: "info",
3022
+ text: next
3023
+ ? "Auto-approve plans enabled — unattended runs approve plans and keep going. Use /autoplan-off to disable."
3024
+ : "Auto-approve plans disabled — unattended runs decline plan mode instead. Use /autoplan-on to enable.",
3025
+ id: getId(),
3026
+ },
3027
+ ]);
3028
+ }, [props.settingsFile]);
2757
3029
  const handleCloseGoalOverlay = () => {
2758
3030
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
2759
3031
  props.sessionStore.overlay = null;
@@ -3018,6 +3290,9 @@ export function App(props) {
3018
3290
  void agentLoop.run(rejectionMsg).catch((err) => {
3019
3291
  const errMsg = err instanceof Error ? err.message : String(err);
3020
3292
  log("ERROR", "error", errMsg);
3293
+ if (agentLoop.isRunning) {
3294
+ agentLoop.reset();
3295
+ }
3021
3296
  setLiveItems((prev) => [...prev, toErrorItem(err, getId())]);
3022
3297
  });
3023
3298
  };
@@ -3046,6 +3321,7 @@ export function App(props) {
3046
3321
  onTogglePixel: () => openOverlay("pixel"),
3047
3322
  onToggleGoal: () => openOverlay("goal"),
3048
3323
  onToggleMarkdown: () => setRenderMarkdown((prev) => !prev),
3324
+ onToggleAutoApprove: handleToggleAutoApprove,
3049
3325
  cwd: props.cwd,
3050
3326
  commands: allCommands,
3051
3327
  mouseScroll: props.fullscreen,