@kenkaiiii/ggcoder 4.3.206 → 4.3.208

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 (80) hide show
  1. package/dist/cli.js +10 -7
  2. package/dist/cli.js.map +1 -1
  3. package/dist/core/goal-controller.js +13 -13
  4. package/dist/core/goal-controller.js.map +1 -1
  5. package/dist/core/goal-controller.test.js +54 -12
  6. package/dist/core/goal-controller.test.js.map +1 -1
  7. package/dist/core/goal-worker-dev-server-lifecycle.test.d.ts +2 -0
  8. package/dist/core/goal-worker-dev-server-lifecycle.test.d.ts.map +1 -0
  9. package/dist/core/goal-worker-dev-server-lifecycle.test.js +68 -0
  10. package/dist/core/goal-worker-dev-server-lifecycle.test.js.map +1 -0
  11. package/dist/core/goal-worker.d.ts +7 -3
  12. package/dist/core/goal-worker.d.ts.map +1 -1
  13. package/dist/core/goal-worker.js +15 -5
  14. package/dist/core/goal-worker.js.map +1 -1
  15. package/dist/core/goal-worker.test.js +19 -1
  16. package/dist/core/goal-worker.test.js.map +1 -1
  17. package/dist/core/model-registry.test.js +51 -1
  18. package/dist/core/model-registry.test.js.map +1 -1
  19. package/dist/core/process-manager-dev-server-repro.test.d.ts +2 -0
  20. package/dist/core/process-manager-dev-server-repro.test.d.ts.map +1 -0
  21. package/dist/core/process-manager-dev-server-repro.test.js +100 -0
  22. package/dist/core/process-manager-dev-server-repro.test.js.map +1 -0
  23. package/dist/core/process-manager.js +2 -2
  24. package/dist/core/process-manager.js.map +1 -1
  25. package/dist/core/prompt-commands.d.ts.map +1 -1
  26. package/dist/core/prompt-commands.js +2 -1
  27. package/dist/core/prompt-commands.js.map +1 -1
  28. package/dist/core/prompt-commands.test.js +2 -0
  29. package/dist/core/prompt-commands.test.js.map +1 -1
  30. package/dist/core/repomap.js +8 -1
  31. package/dist/core/repomap.js.map +1 -1
  32. package/dist/core/repomap.test.js +32 -0
  33. package/dist/core/repomap.test.js.map +1 -1
  34. package/dist/system-prompt.d.ts.map +1 -1
  35. package/dist/system-prompt.js +1 -0
  36. package/dist/system-prompt.js.map +1 -1
  37. package/dist/tools/edit.d.ts.map +1 -1
  38. package/dist/tools/edit.js +22 -12
  39. package/dist/tools/edit.js.map +1 -1
  40. package/dist/tools/edit.test.js +29 -6
  41. package/dist/tools/edit.test.js.map +1 -1
  42. package/dist/ui/App.d.ts +39 -4
  43. package/dist/ui/App.d.ts.map +1 -1
  44. package/dist/ui/App.js +216 -147
  45. package/dist/ui/App.js.map +1 -1
  46. package/dist/ui/app-state-persistence.test.js +80 -6
  47. package/dist/ui/app-state-persistence.test.js.map +1 -1
  48. package/dist/ui/components/GoalOverlay.d.ts +54 -1
  49. package/dist/ui/components/GoalOverlay.d.ts.map +1 -1
  50. package/dist/ui/components/GoalOverlay.js +392 -53
  51. package/dist/ui/components/GoalOverlay.js.map +1 -1
  52. package/dist/ui/components/InputArea.d.ts.map +1 -1
  53. package/dist/ui/components/InputArea.js +38 -1
  54. package/dist/ui/components/InputArea.js.map +1 -1
  55. package/dist/ui/components/InputArea.test.d.ts +2 -0
  56. package/dist/ui/components/InputArea.test.d.ts.map +1 -0
  57. package/dist/ui/components/InputArea.test.js +79 -0
  58. package/dist/ui/components/InputArea.test.js.map +1 -0
  59. package/dist/ui/components/ToolExecution.d.ts.map +1 -1
  60. package/dist/ui/components/ToolExecution.js +2 -2
  61. package/dist/ui/components/ToolExecution.js.map +1 -1
  62. package/dist/ui/goal-events.d.ts +88 -1
  63. package/dist/ui/goal-events.d.ts.map +1 -1
  64. package/dist/ui/goal-events.js +249 -28
  65. package/dist/ui/goal-events.js.map +1 -1
  66. package/dist/ui/goal-events.test.js +89 -4
  67. package/dist/ui/goal-events.test.js.map +1 -1
  68. package/dist/ui/goal-overlay.test.js +155 -1
  69. package/dist/ui/goal-overlay.test.js.map +1 -1
  70. package/dist/ui/render.d.ts +3 -1
  71. package/dist/ui/render.d.ts.map +1 -1
  72. package/dist/ui/render.js +2 -0
  73. package/dist/ui/render.js.map +1 -1
  74. package/dist/ui/scroll-stabilization.test.js +49 -2
  75. package/dist/ui/scroll-stabilization.test.js.map +1 -1
  76. package/dist/ui/slash-command-images.test.d.ts +2 -0
  77. package/dist/ui/slash-command-images.test.d.ts.map +1 -0
  78. package/dist/ui/slash-command-images.test.js +47 -0
  79. package/dist/ui/slash-command-images.test.js.map +1 -0
  80. package/package.json +5 -5
package/dist/ui/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
3
- import { Box, Text, Static, useStdout } from "ink";
3
+ import { Box, Text, Static } from "ink";
4
4
  import { useTerminalSize } from "./hooks/useTerminalSize.js";
5
5
  import { useDoublePress } from "./hooks/useDoublePress.js";
6
6
  import { useTaskBarStore, useTaskBarPolling, focusTaskBar, exitTaskBar, expandTaskBar, collapseTaskBar, navigateTaskBar, killTask, } from "./stores/taskbar-store.js";
@@ -96,6 +96,64 @@ function toErrorItem(err, id, contextPrefix) {
96
96
  id,
97
97
  };
98
98
  }
99
+ export function routePromptCommandInput(input, promptCommands = PROMPT_COMMANDS, customCommands = []) {
100
+ const trimmed = input.trim();
101
+ if (!trimmed.startsWith("/"))
102
+ return null;
103
+ const parts = trimmed.slice(1).split(" ");
104
+ const cmdName = parts[0];
105
+ const cmdArgs = parts.slice(1).join(" ").trim();
106
+ const builtinCmd = promptCommands.find((c) => c.name === cmdName || c.aliases.includes(cmdName));
107
+ const customCmd = !builtinCmd ? customCommands.find((c) => c.name === cmdName) : undefined;
108
+ const promptText = builtinCmd?.prompt ?? customCmd?.prompt;
109
+ if (!promptText)
110
+ return null;
111
+ return {
112
+ cmdName,
113
+ cmdArgs,
114
+ promptText,
115
+ fullPrompt: cmdArgs ? `${promptText}\n\n## User Instructions\n\n${cmdArgs}` : promptText,
116
+ };
117
+ }
118
+ export function buildUserContentWithAttachments(text, inputImages, modelSupportsImages) {
119
+ if (inputImages.length === 0)
120
+ return text;
121
+ const parts = [];
122
+ if (text) {
123
+ parts.push({ type: "text", text });
124
+ }
125
+ for (const img of inputImages) {
126
+ if (img.kind === "text") {
127
+ parts.push({
128
+ type: "text",
129
+ text: `<file name="${img.fileName}">\n${img.data}\n</file>`,
130
+ });
131
+ }
132
+ else if (modelSupportsImages) {
133
+ parts.push({ type: "image", mediaType: img.mediaType, data: img.data });
134
+ }
135
+ else {
136
+ // GLM models: save image to temp file and instruct model to use vision MCP tool
137
+ const ext = img.mediaType.split("/")[1] ?? "png";
138
+ const tmpPath = `/tmp/ggcoder-img-${Date.now()}.${ext}`;
139
+ try {
140
+ writeFileSync(tmpPath, Buffer.from(img.data, "base64"));
141
+ parts.push({
142
+ type: "text",
143
+ text: `[User attached an image saved at: ${tmpPath} — use the image_analysis tool to view and analyze it]`,
144
+ });
145
+ }
146
+ catch {
147
+ parts.push({
148
+ type: "text",
149
+ text: `[User attached an image but it could not be saved for analysis]`,
150
+ });
151
+ }
152
+ }
153
+ }
154
+ // If only text parts remain after stripping images, simplify to plain string
155
+ return parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
156
+ }
99
157
  /** Tools that get aggregated into a single compact group when concurrent. */
100
158
  const AGGREGATABLE_TOOLS = new Set(["read", "grep", "find", "ls"]);
101
159
  const RUNNING_INDICATOR_ANIMATION_MS = 1_200;
@@ -135,6 +193,64 @@ function formatGoalWorkerFinishedTitle(taskTitle, status) {
135
193
  ? `Worker finished: ${taskTitle}. Reporting back.`
136
194
  : `Worker failed: ${taskTitle}. Reporting back.`;
137
195
  }
196
+ function countGoalTasksByStatus(tasks, status) {
197
+ return tasks.filter((task) => task.status === status).length;
198
+ }
199
+ function firstText(values) {
200
+ return values.find((value) => value !== undefined && value.trim().length > 0)?.trim();
201
+ }
202
+ function truncateGoalSummary(value, maxLength = 90) {
203
+ const normalized = value.replace(/\s+/g, " ").trim();
204
+ if (normalized.length <= maxLength)
205
+ return normalized;
206
+ return `${normalized.slice(0, maxLength - 1)}…`;
207
+ }
208
+ export function buildGoalSummaryRows(run) {
209
+ const rows = [];
210
+ const doneTasks = countGoalTasksByStatus(run.tasks, "done");
211
+ const failedTasks = countGoalTasksByStatus(run.tasks, "failed");
212
+ const blockedTasks = countGoalTasksByStatus(run.tasks, "blocked");
213
+ const taskSuffix = [
214
+ failedTasks > 0 ? `${failedTasks} failed` : undefined,
215
+ blockedTasks > 0 ? `${blockedTasks} blocked` : undefined,
216
+ ].filter((item) => item !== undefined);
217
+ rows.push({
218
+ label: "Tasks",
219
+ value: run.tasks.length > 0 ? `${doneTasks}/${run.tasks.length} done` : "none",
220
+ ...(taskSuffix.length > 0 ? { detail: taskSuffix.join(", ") } : {}),
221
+ });
222
+ const verifierResult = run.verifier?.lastResult;
223
+ const verifierDetail = firstText([verifierResult?.outputPath, run.verifier?.command]);
224
+ rows.push({
225
+ label: "Verifier",
226
+ value: verifierResult?.status ?? (run.verifier?.command ? "ready" : "missing"),
227
+ ...(verifierDetail ? { detail: truncateGoalSummary(verifierDetail) } : {}),
228
+ });
229
+ const latestEvidence = run.evidence.at(-1);
230
+ rows.push({
231
+ label: "Evidence",
232
+ value: `${run.evidence.length} recorded`,
233
+ ...(latestEvidence
234
+ ? { detail: truncateGoalSummary(latestEvidence.path ?? latestEvidence.label) }
235
+ : {}),
236
+ });
237
+ if (run.status === "blocked" || run.status === "paused" || run.blockers.length > 0) {
238
+ rows.push({
239
+ label: run.status === "paused" ? "Paused on" : "Blocked on",
240
+ value: truncateGoalSummary(goalHasBlockingPrerequisites(run)
241
+ ? formatGoalBlockingPrerequisites(run)
242
+ : (run.blockers[0] ?? "manual review"), 110),
243
+ });
244
+ }
245
+ else if (run.successCriteria.length > 0) {
246
+ rows.push({
247
+ label: "Criteria",
248
+ value: `${run.successCriteria.length} checked`,
249
+ detail: truncateGoalSummary(run.successCriteria[0] ?? "", 80),
250
+ });
251
+ }
252
+ return rows.slice(0, 4);
253
+ }
138
254
  export function formatGoalTerminalProgress(run) {
139
255
  switch (run.status) {
140
256
  case "passed":
@@ -143,6 +259,7 @@ export function formatGoalTerminalProgress(run) {
143
259
  phase: "terminal",
144
260
  title: `Goal passed: ${run.title}`,
145
261
  detail: "Verifier evidence is recorded; auto-continuation stopped.",
262
+ summaryRows: buildGoalSummaryRows(run),
146
263
  status: run.status,
147
264
  };
148
265
  case "failed":
@@ -151,6 +268,7 @@ export function formatGoalTerminalProgress(run) {
151
268
  phase: "terminal",
152
269
  title: `Goal failed: ${run.title}`,
153
270
  detail: "Auto-continuation stopped. Check Goal tasks for the failing step.",
271
+ summaryRows: buildGoalSummaryRows(run),
154
272
  status: run.status,
155
273
  };
156
274
  case "blocked":
@@ -161,6 +279,7 @@ export function formatGoalTerminalProgress(run) {
161
279
  detail: goalHasBlockingPrerequisites(run)
162
280
  ? formatGoalBlockingPrerequisites(run)
163
281
  : (run.blockers[0] ?? "A prerequisite or missing verifier blocked progress."),
282
+ summaryRows: buildGoalSummaryRows(run),
164
283
  status: run.status,
165
284
  };
166
285
  case "paused":
@@ -169,6 +288,7 @@ export function formatGoalTerminalProgress(run) {
169
288
  phase: "terminal",
170
289
  title: `Goal paused: ${run.title}`,
171
290
  detail: run.blockers[0] ?? "Auto-continuation paused.",
291
+ summaryRows: buildGoalSummaryRows(run),
172
292
  status: run.status,
173
293
  };
174
294
  case "draft":
@@ -178,19 +298,31 @@ export function formatGoalTerminalProgress(run) {
178
298
  return null;
179
299
  }
180
300
  }
181
- export function shouldHideHistoryForOverlayView(isOverlayView, isAgentRunning) {
182
- // Overlay panes should be clean, full-screen views like Tasks/Plans/Skills.
183
- // When the agent is idle, pane open/close goes through resetUI(), so history
184
- // remains in sessionStore and is reprinted after the pane closes. While the
185
- // agent is running we keep Static mounted because resetUI would abort the run.
186
- return isOverlayView && !isAgentRunning;
301
+ export function shouldHideHistoryForOverlayView(_isOverlayView, _isAgentRunning) {
302
+ // Ink Static is append-only. Passing [] for overlay panes rewrites the Static
303
+ // accumulator and can destroy scrollback when the pane closes. Keep history
304
+ // mounted and let overlays render below it.
305
+ return false;
306
+ }
307
+ export function shouldStabilizeOverlayPaneRerender({ overlayPane, isAgentRunning, }) {
308
+ return isAgentRunning && (overlayPane === "goal" || overlayPane === "plan");
187
309
  }
188
- export function getScrollStabilizationDecision({ isUserScrolled, hasNewOutput, }) {
310
+ export function shouldHideStaticItemsForOverlayView({ shouldHideHistoryForOverlay, stabilizeOverlayPaneRerender, }) {
311
+ return shouldHideHistoryForOverlay && !stabilizeOverlayPaneRerender;
312
+ }
313
+ export function getScrollStabilizationDecision({ isUserScrolled, hasNewOutput, hasTallLiveUserMessage = false, }) {
314
+ const shouldStabilize = isUserScrolled || hasTallLiveUserMessage;
189
315
  return {
190
- preserveStatic: isUserScrolled && hasNewOutput,
191
- autoFollow: !isUserScrolled,
316
+ preserveStatic: shouldStabilize && hasNewOutput,
317
+ autoFollow: !shouldStabilize,
192
318
  };
193
319
  }
320
+ export function isTallLiveUserMessage(text, rows) {
321
+ return text.split("\n").length > Math.max(8, Math.floor(rows * 0.6));
322
+ }
323
+ export function getStaticHistoryKey({ resizeKey }) {
324
+ return `${resizeKey}`;
325
+ }
194
326
  // flushOnTurnText, flushOnTurnEnd are imported from ./live-item-flush.ts
195
327
  /** Check whether an item is still active (running spinner, pending result). */
196
328
  function isActiveItem(item) {
@@ -352,7 +484,6 @@ function markTaskInProgress(cwd, taskId) {
352
484
  export function App(props) {
353
485
  const theme = useTheme();
354
486
  const switchTheme = useSetTheme();
355
- const { stdout } = useStdout();
356
487
  const { columns, resizeKey } = useTerminalSize();
357
488
  // Hoisted before terminal title hook so it can reference them
358
489
  const [lastUserMessage, setLastUserMessage] = useState("");
@@ -414,8 +545,7 @@ export function App(props) {
414
545
  const startPixelFixRef = useRef(() => { });
415
546
  const cwdRef = useRef(props.cwd);
416
547
  const [displayedCwd, setDisplayedCwd] = useState(props.cwd);
417
- const [staticKey, setStaticKey] = useState(0);
418
- const [doneStatus, setDoneStatus] = useState(null);
548
+ const [doneStatus, setDoneStatus] = useState(props.sessionStore?.doneStatus ?? null);
419
549
  // Suppress "done" status when a plan overlay is about to open
420
550
  const planOverlayPendingRef = useRef(false);
421
551
  const [gitBranch, setGitBranch] = useState(null);
@@ -552,6 +682,10 @@ export function App(props) {
552
682
  if (sessionStore)
553
683
  sessionStore.liveItems = liveItems;
554
684
  }, [liveItems, sessionStore]);
685
+ useEffect(() => {
686
+ if (sessionStore)
687
+ sessionStore.doneStatus = doneStatus;
688
+ }, [doneStatus, sessionStore]);
555
689
  useEffect(() => {
556
690
  if (sessionStore)
557
691
  sessionStore.planSteps = planSteps;
@@ -769,14 +903,6 @@ export function App(props) {
769
903
  // premature "done" status that fires when the agent loop finishes
770
904
  planOverlayPendingRef.current = true;
771
905
  setTimeout(() => {
772
- // NOTE: this is the one open-overlay path that does NOT remount via
773
- // resetUI. It runs while the agent is still mid-turn (after the
774
- // exit_plan tool returned but before onDone fires), and unmounting
775
- // here would kill the in-flight agent stream. Keep the bare ANSI
776
- // clear; the drift bug is tolerable across just the agent's
777
- // wrap-up turn, and onApprove/onReject both remount cleanly via
778
- // resetUI when the user resolves the plan.
779
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
780
906
  setPlanAutoExpand(true);
781
907
  setOverlay("plan");
782
908
  // Don't clear planOverlayPendingRef here — keep it true until
@@ -791,7 +917,7 @@ export function App(props) {
791
917
  planPath);
792
918
  };
793
919
  }
794
- }, [props.onExitPlanRef, replaceSystemPrompt, stdout]);
920
+ }, [props.onExitPlanRef, replaceSystemPrompt]);
795
921
  const appendMessagesToSession = useCallback(async (sessionPath, messages, startIndex) => {
796
922
  const sm = sessionManagerRef.current;
797
923
  if (!sm)
@@ -1908,14 +2034,10 @@ export function App(props) {
1908
2034
  process.exit(0);
1909
2035
  }
1910
2036
  // Handle /clear — tear down the entire Ink instance and rebuild fresh.
1911
- // Patching Ink's internal frame tracking in place (log-update reset,
1912
- // lastOutput cleared, fullStaticOutput dropped, staticKey bump) all
1913
- // looked correct for one frame but left the live area drifting on
1914
- // subsequent streaming responses — Ink's cursor math depends on
1915
- // terminal-state assumptions that ANSI clearing breaks. The reliable
1916
- // fix is unmount + render again. Runtime state (model, provider,
1917
- // thinking) survives via renderApp's closure-held `runtimeState`,
1918
- // mirrored from React state via the useEffects above.
2037
+ // Avoid direct ANSI terminal clears here; they can erase scrollback.
2038
+ // Runtime state (model, provider, thinking) survives via renderApp's
2039
+ // closure-held `runtimeState`, mirrored from React state via the
2040
+ // useEffects above.
1919
2041
  if (trimmed === "/clear") {
1920
2042
  if (props.resetUI) {
1921
2043
  void (async () => {
@@ -1928,8 +2050,7 @@ export function App(props) {
1928
2050
  return;
1929
2051
  }
1930
2052
  // Fallback path (resetUI not wired — e.g. tests). Best-effort: clear
1931
- // React state in place. The Ink-internal drift bug remains here.
1932
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2053
+ // React state in place without touching terminal scrollback.
1933
2054
  pendingFlushRef.current = [];
1934
2055
  setHistory([{ kind: "banner", id: "banner" }]);
1935
2056
  setLiveItems([]);
@@ -1945,7 +2066,6 @@ export function App(props) {
1945
2066
  agentLoop.reset();
1946
2067
  setSessionTitle(undefined);
1947
2068
  sessionTitleGeneratedRef.current = false;
1948
- setStaticKey((k) => k + 1);
1949
2069
  setLiveItems([{ kind: "info", text: "Session cleared.", id: getId() }]);
1950
2070
  return;
1951
2071
  }
@@ -2053,8 +2173,6 @@ export function App(props) {
2053
2173
  props.resetUI();
2054
2174
  }
2055
2175
  else {
2056
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2057
- setStaticKey((key) => key + 1);
2058
2176
  if (props.sessionStore) {
2059
2177
  props.sessionStore.overlay = "goal";
2060
2178
  props.sessionStore.planAutoExpand = false;
@@ -2086,49 +2204,49 @@ export function App(props) {
2086
2204
  return;
2087
2205
  }
2088
2206
  // Handle prompt-template commands (built-in + custom from .gg/commands/)
2089
- if (trimmed.startsWith("/")) {
2090
- const parts = trimmed.slice(1).split(" ");
2091
- const cmdName = parts[0];
2092
- const cmdArgs = parts.slice(1).join(" ").trim();
2093
- const builtinCmd = getPromptCommand(cmdName);
2094
- const customCmd = !builtinCmd ? customCommands.find((c) => c.name === cmdName) : undefined;
2095
- const promptText = builtinCmd?.prompt ?? customCmd?.prompt;
2096
- if (promptText) {
2097
- log("INFO", "command", `Prompt command: /${cmdName}${cmdArgs ? ` (args: ${cmdArgs})` : ""}`);
2098
- // Move live items into history before starting
2099
- setLiveItems((prev) => {
2100
- if (prev.length > 0) {
2101
- pendingFlushRef.current = [...pendingFlushRef.current, ...prev];
2102
- }
2103
- return [];
2104
- });
2105
- // Show the command name as the user message
2106
- const userItem = { kind: "user", text: trimmed, id: getId() };
2107
- setLastUserMessage(trimmed);
2108
- setDoneStatus(null);
2109
- setLiveItems([userItem]);
2110
- // Send the full prompt to the agent, with user args appended if provided
2111
- const fullPrompt = cmdArgs
2112
- ? `${promptText}\n\n## User Instructions\n\n${cmdArgs}`
2113
- : promptText;
2114
- try {
2115
- await agentLoop.run(fullPrompt);
2116
- }
2117
- catch (err) {
2118
- const msg = err instanceof Error ? err.message : String(err);
2119
- log("ERROR", "error", msg);
2120
- const isAbort = msg.includes("aborted") || msg.includes("abort");
2121
- setLiveItems((prev) => [
2122
- ...prev,
2123
- isAbort
2124
- ? { kind: "stopped", text: "Request was stopped.", id: getId() }
2125
- : toErrorItem(err, getId()),
2126
- ]);
2207
+ const promptCommandRoute = routePromptCommandInput(trimmed, PROMPT_COMMANDS, customCommands);
2208
+ if (promptCommandRoute) {
2209
+ const { cmdName, cmdArgs, fullPrompt } = promptCommandRoute;
2210
+ log("INFO", "command", `Prompt command: /${cmdName}${cmdArgs ? ` (args: ${cmdArgs})` : ""}`);
2211
+ // Move live items into history before starting
2212
+ setLiveItems((prev) => {
2213
+ if (prev.length > 0) {
2214
+ pendingFlushRef.current = [...pendingFlushRef.current, ...prev];
2127
2215
  }
2128
- // Reload custom commands in case a setup command created new ones
2129
- reloadCustomCommands();
2130
- return;
2216
+ return [];
2217
+ });
2218
+ const hasImages = inputImages.length > 0;
2219
+ const modelInfo = getModel(currentModel);
2220
+ const modelSupportsImages = modelInfo?.supportsImages ?? true;
2221
+ const userContent = buildUserContentWithAttachments(fullPrompt, inputImages, modelSupportsImages);
2222
+ // Show the typed command as the user message
2223
+ const userItem = {
2224
+ kind: "user",
2225
+ text: trimmed,
2226
+ imageCount: hasImages ? inputImages.length : undefined,
2227
+ id: getId(),
2228
+ };
2229
+ setLastUserMessage(trimmed);
2230
+ setDoneStatus(null);
2231
+ setLiveItems([userItem]);
2232
+ // Send the full prompt to the agent, with user args appended if provided
2233
+ try {
2234
+ await agentLoop.run(userContent);
2235
+ }
2236
+ catch (err) {
2237
+ const msg = err instanceof Error ? err.message : String(err);
2238
+ log("ERROR", "error", msg);
2239
+ const isAbort = msg.includes("aborted") || msg.includes("abort");
2240
+ setLiveItems((prev) => [
2241
+ ...prev,
2242
+ isAbort
2243
+ ? { kind: "stopped", text: "Request was stopped.", id: getId() }
2244
+ : toErrorItem(err, getId()),
2245
+ ]);
2131
2246
  }
2247
+ // Reload custom commands in case a setup command created new ones
2248
+ reloadCustomCommands();
2249
+ return;
2132
2250
  }
2133
2251
  // Check slash commands
2134
2252
  if (props.onSlashCommand && input.startsWith("/")) {
@@ -2142,47 +2260,7 @@ export function App(props) {
2142
2260
  const hasImages = inputImages.length > 0;
2143
2261
  const modelInfo = getModel(currentModel);
2144
2262
  const modelSupportsImages = modelInfo?.supportsImages ?? true;
2145
- let userContent;
2146
- if (hasImages) {
2147
- const parts = [];
2148
- if (trimmed) {
2149
- parts.push({ type: "text", text: trimmed });
2150
- }
2151
- for (const img of inputImages) {
2152
- if (img.kind === "text") {
2153
- parts.push({
2154
- type: "text",
2155
- text: `<file name="${img.fileName}">\n${img.data}\n</file>`,
2156
- });
2157
- }
2158
- else if (modelSupportsImages) {
2159
- parts.push({ type: "image", mediaType: img.mediaType, data: img.data });
2160
- }
2161
- else {
2162
- // GLM models: save image to temp file and instruct model to use vision MCP tool
2163
- const ext = img.mediaType.split("/")[1] ?? "png";
2164
- const tmpPath = `/tmp/ggcoder-img-${Date.now()}.${ext}`;
2165
- try {
2166
- writeFileSync(tmpPath, Buffer.from(img.data, "base64"));
2167
- parts.push({
2168
- type: "text",
2169
- text: `[User attached an image saved at: ${tmpPath} — use the image_analysis tool to view and analyze it]`,
2170
- });
2171
- }
2172
- catch {
2173
- parts.push({
2174
- type: "text",
2175
- text: `[User attached an image but it could not be saved for analysis]`,
2176
- });
2177
- }
2178
- }
2179
- }
2180
- // If only text parts remain after stripping images, simplify to plain string
2181
- userContent = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
2182
- }
2183
- else {
2184
- userContent = input;
2185
- }
2263
+ const userContent = buildUserContentWithAttachments(input, inputImages, modelSupportsImages);
2186
2264
  // ── Queue message if agent is already running ──
2187
2265
  if (agentLoop.isRunning) {
2188
2266
  log("INFO", "queue", `Queued message: ${trimmed.length > 80 ? trimmed.slice(0, 80) + "..." : trimmed}`);
@@ -2464,7 +2542,7 @@ export function App(props) {
2464
2542
  ? "◆ "
2465
2543
  : "! "
2466
2544
  : "↻ ";
2467
- return (_jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 1, children: [_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: color, bold: true, children: glyph }), _jsx(Text, { color: color, bold: true, children: item.title }), item.workerId ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 worker ", item.workerId] }) : null] }), item.detail ? (_jsx(Text, { color: theme.textDim, wrap: "wrap", children: ` ${item.detail}` })) : null] }, item.id));
2545
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 1, children: [_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: color, bold: true, children: glyph }), _jsx(Text, { color: color, bold: true, children: item.title }), item.workerId ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 worker ", item.workerId] }) : null] }), item.detail ? (_jsx(Text, { color: theme.textDim, wrap: "wrap", children: ` ${item.detail}` })) : null, item.summaryRows && item.summaryRows.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, flexShrink: 1, children: item.summaryRows.map((row) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.textDim, children: row.label.padEnd(10) }), _jsx(Text, { color: theme.text, children: row.value }), row.detail ? _jsxs(Text, { color: theme.textDim, children: [" \u00B7 ", row.detail] }) : null] }, row.label))) })) : null] }, item.id));
2468
2546
  }
2469
2547
  case "style_pack": {
2470
2548
  const names = item.added.map((id) => LANGUAGE_DISPLAY_NAMES[id]);
@@ -2578,7 +2656,6 @@ export function App(props) {
2578
2656
  return;
2579
2657
  }
2580
2658
  // Fallback path (resetUI not wired — tests).
2581
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
2582
2659
  setHistory([{ kind: "banner", id: "banner" }]);
2583
2660
  setLiveItems([]);
2584
2661
  messagesRef.current = messagesRef.current.slice(0, 1);
@@ -2612,15 +2689,7 @@ export function App(props) {
2612
2689
  setRunAllTasks(false);
2613
2690
  }
2614
2691
  })();
2615
- }, [
2616
- props.cwd,
2617
- props.resetUI,
2618
- props.sessionStore,
2619
- stdout,
2620
- agentLoop,
2621
- currentProvider,
2622
- currentModel,
2623
- ]);
2692
+ }, [props.cwd, props.resetUI, props.sessionStore, agentLoop, currentProvider, currentModel]);
2624
2693
  const openOverlay = useCallback((kind) => {
2625
2694
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
2626
2695
  props.sessionStore.overlay = kind;
@@ -2633,14 +2702,15 @@ export function App(props) {
2633
2702
  props.sessionStore.overlay = kind;
2634
2703
  if (kind !== "plan")
2635
2704
  props.sessionStore.planAutoExpand = false;
2636
- if (agentLoop.isRunning)
2705
+ if (agentLoop.isRunning && kind !== "goal" && kind !== "plan") {
2637
2706
  props.sessionStore.pendingResetUI = true;
2707
+ }
2638
2708
  }
2639
2709
  if (kind !== "plan")
2640
2710
  setPlanAutoExpand(false);
2641
2711
  setOverlay(kind);
2642
2712
  }
2643
- }, [agentLoop.isRunning, props, stdout]);
2713
+ }, [agentLoop.isRunning, props]);
2644
2714
  const closeOverlay = useCallback(() => {
2645
2715
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
2646
2716
  props.sessionStore.overlay = null;
@@ -2649,12 +2719,10 @@ export function App(props) {
2649
2719
  else {
2650
2720
  if (props.sessionStore) {
2651
2721
  props.sessionStore.overlay = null;
2652
- if (agentLoop.isRunning)
2653
- props.sessionStore.pendingResetUI = true;
2654
2722
  }
2655
2723
  setOverlay(null);
2656
2724
  }
2657
- }, [agentLoop.isRunning, overlay, props, stdout]);
2725
+ }, [agentLoop.isRunning, overlay, props]);
2658
2726
  const runGoalSyntheticEvent = useCallback((eventText) => {
2659
2727
  const eventInfo = parseGoalSyntheticEvent(eventText);
2660
2728
  const detail = eventInfo?.kind === "worker"
@@ -2954,6 +3022,7 @@ export function App(props) {
2954
3022
  model: currentModel,
2955
3023
  goalRunId: run.id,
2956
3024
  goalTaskId: decision.task.id,
3025
+ taskTitle: decision.task.title,
2957
3026
  prompt: decision.task.prompt,
2958
3027
  });
2959
3028
  await upsertGoalRun(props.cwd, {
@@ -3267,14 +3336,10 @@ export function App(props) {
3267
3336
  activeLanguages: detectedForPixelFix,
3268
3337
  tools: toolsForPixelFix,
3269
3338
  });
3270
- // Now that the cwd swap is committed, reset chat. Doing this BEFORE
3271
- // the chdir would print a banner with the old cwd, then bumping
3272
- // staticKey would print a second banner with the new cwd — leaving
3273
- // two banners stacked in the scrollback.
3274
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3339
+ // Now that the cwd swap is committed, reset chat. Do not clear the
3340
+ // terminal here; terminal clear sequences can erase saved scrollback.
3275
3341
  setHistory([{ kind: "banner", id: "banner" }]);
3276
3342
  setLiveItems([]);
3277
- setStaticKey((k) => k + 1);
3278
3343
  messagesRef.current = messagesRef.current.slice(0, 1);
3279
3344
  agentLoop.reset();
3280
3345
  persistedIndexRef.current = messagesRef.current.length;
@@ -3306,7 +3371,7 @@ export function App(props) {
3306
3371
  setLiveItems((prev) => [...prev, toErrorItem(err, getId())]);
3307
3372
  }
3308
3373
  })();
3309
- }, [props.cwd, stdout, agentLoop, currentProvider, currentModel]);
3374
+ }, [props.cwd, agentLoop, currentProvider, currentModel]);
3310
3375
  startPixelFixRef.current = startPixelFix;
3311
3376
  // Seed from sessionStore so "Fix All" chaining survives a deferred
3312
3377
  // resetUI() if it fires between pixel fixes (e.g. user toggled a pane).
@@ -3330,7 +3395,17 @@ export function App(props) {
3330
3395
  const isPixelView = overlay === "pixel";
3331
3396
  const isOverlayView = isTaskView || isGoalView || isSkillsView || isPlanView || isEyesView || isPixelView;
3332
3397
  const shouldHideHistoryForOverlay = shouldHideHistoryForOverlayView(isOverlayView, agentLoop.isRunning);
3333
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: shouldHideHistoryForOverlay ? [] : 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: () => {
3398
+ const stabilizeOverlayPaneRerender = shouldStabilizeOverlayPaneRerender({
3399
+ overlayPane: overlay,
3400
+ isAgentRunning: agentLoop.isRunning,
3401
+ });
3402
+ const staticItems = shouldHideStaticItemsForOverlayView({
3403
+ shouldHideHistoryForOverlay,
3404
+ stabilizeOverlayPaneRerender,
3405
+ })
3406
+ ? []
3407
+ : history;
3408
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(Static, { items: staticItems, style: { width: "100%" }, children: (item) => (_jsx(Box, { flexDirection: "column", paddingRight: 1, children: renderItem(item) }, item.id)) }, getStaticHistoryKey({ resizeKey })), isTaskView ? (_jsx(TaskOverlay, { cwd: props.cwd, agentRunning: agentLoop.isRunning, onClose: () => {
3334
3409
  if (props.resetUI && props.sessionStore && !agentLoop.isRunning) {
3335
3410
  props.sessionStore.overlay = null;
3336
3411
  props.resetUI();
@@ -3480,10 +3555,8 @@ export function App(props) {
3480
3555
  approvedPlanPathRef.current = planPath;
3481
3556
  planStepsRef.current = steps;
3482
3557
  setPlanSteps(steps);
3483
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3484
3558
  setHistory([{ kind: "banner", id: "banner" }]);
3485
3559
  setLiveItems([]);
3486
- setStaticKey((k) => k + 1);
3487
3560
  setPlanAutoExpand(false);
3488
3561
  setOverlay(null);
3489
3562
  messagesRef.current = [{ role: "system", content: newPrompt }];
@@ -3524,8 +3597,6 @@ export function App(props) {
3524
3597
  });
3525
3598
  return;
3526
3599
  }
3527
- stdout?.write("\x1b[2J\x1b[3J\x1b[H");
3528
- setStaticKey((k) => k + 1);
3529
3600
  setPlanAutoExpand(false);
3530
3601
  setOverlay(null);
3531
3602
  setDoneStatus(null);
@@ -3540,8 +3611,6 @@ export function App(props) {
3540
3611
  });
3541
3612
  } })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 1, children: [liveItems.map((item) => renderItem(item)), _jsx(StreamingArea, { isRunning: agentLoop.isRunning, streamingText: agentLoop.streamingText, streamingThinking: agentLoop.streamingThinking, thinkingMs: agentLoop.thinkingMs, planMode: planMode })] }), agentLoop.isRunning && agentLoop.activityPhase !== "idle" ? (_jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: agentLoop.activityPhase === "thinking" ? THINKING_BORDER_COLORS[0] : "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, thinkingEnabled: thinkingEnabled, 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, staticDisplay: true }) })) : 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 &&
3542
3613
  !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: () => {
3543
- // While the agent is running, skip the screen-clear + staticKey
3544
- // bump that would otherwise wipe the chat history from scrollback.
3545
3614
  // Just flip the overlay state — Ink's log-update handles the
3546
3615
  // live-area transition (chat input → TaskOverlay) natively, and
3547
3616
  // the chat history above stays in scrollback. When the overlay