@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -59,9 +59,22 @@ const DEFAULT_ACTION_KEYS = Object.fromEntries(
59
59
 
60
60
  const PASTE_DECISION_TIMEOUT_MS = 5_000;
61
61
  const PENDING_PASTE_INPUT_MAX = 64;
62
+ const PENDING_PASTE_INPUT_MAX_BYTES = 256 * 1024;
62
63
 
63
64
  type PastePendingClearReason = "timeout" | "queue-limit";
64
65
 
66
+ export interface PasteTextContext {
67
+ readonly signal: AbortSignal;
68
+ /** Run one synchronous composer mutation only while this paste transaction is current. */
69
+ commit(commit: () => boolean): boolean;
70
+ }
71
+
72
+ interface PendingPasteDecision {
73
+ readonly controller: AbortController;
74
+ readonly pasteContent: string;
75
+ readonly remaining: string;
76
+ }
77
+
65
78
  /**
66
79
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
67
80
  */
@@ -94,9 +107,9 @@ export class CustomEditor extends Editor {
94
107
  /** Called when the configured image-paste shortcut is pressed. */
95
108
  onPasteImage?: () => Promise<boolean>;
96
109
  /** Called before bracketed paste content is inserted. Return true to consume it. */
97
- onPasteText?: (text: string) => boolean | Promise<boolean>;
98
- /** Called when async paste handling drops queued input instead of replaying it. */
99
- onPastePendingInputCleared?: (reason: PastePendingClearReason, droppedInputCount: number) => void;
110
+ onPasteText?: (text: string, context: PasteTextContext) => boolean | Promise<boolean>;
111
+ /** Called when pending paste handling is cancelled after restoring queued input. */
112
+ onPastePendingInputCleared?: (reason: PastePendingClearReason, restoredInputCount: number) => void;
100
113
  /** Called when the configured dequeue shortcut is pressed. */
101
114
  onDequeue?: () => void;
102
115
  /** Called when the configured queue shortcut is pressed. */
@@ -117,7 +130,9 @@ export class CustomEditor extends Editor {
117
130
  #pasteDecisionPending = false;
118
131
  #pasteDecisionToken = 0;
119
132
  #pasteDecisionTimeout: NodeJS.Timeout | undefined;
133
+ #pendingPasteDecision: PendingPasteDecision | undefined;
120
134
  #pendingPasteInput: string[] = [];
135
+ #pendingPasteInputBytes = 0;
121
136
 
122
137
  setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
123
138
  this.#actionKeys.set(action, [...keys]);
@@ -165,75 +180,127 @@ export class CustomEditor extends Editor {
165
180
  }
166
181
  }
167
182
 
168
- #clearPendingPasteState(): number {
183
+ #resetPendingPasteState(abortReason?: Error): string[] {
169
184
  this.#clearPasteDecisionTimeout();
185
+ if (abortReason) this.#pendingPasteDecision?.controller.abort(abortReason);
170
186
  this.#pasteDecisionPending = false;
171
187
  this.#pasteDecisionToken += 1;
172
- const droppedInputCount = this.#pendingPasteInput.length;
188
+ this.#pendingPasteDecision = undefined;
189
+ const queuedInput = this.#pendingPasteInput;
173
190
  this.#pendingPasteInput = [];
174
- return droppedInputCount;
191
+ this.#pendingPasteInputBytes = 0;
192
+ return queuedInput;
193
+ }
194
+
195
+ #pendingPasteEventCount(): number {
196
+ return this.#pendingPasteInput.length + (this.#pendingPasteDecision?.remaining ? 1 : 0);
197
+ }
198
+
199
+ #restoreCancelledPaste(reason: PastePendingClearReason, overflowInput?: string): void {
200
+ const decision = this.#pendingPasteDecision;
201
+ const queuedInput = this.#resetPendingPasteState(new Error(`Paste handling cancelled: ${reason}`));
202
+ if (decision) {
203
+ super.handleInput(`\x1b[200~${decision.pasteContent}\x1b[201~`);
204
+ this.#drainPendingPasteInput(decision.remaining, queuedInput);
205
+ if (overflowInput !== undefined) this.handleInput(overflowInput);
206
+ }
207
+ this.onPastePendingInputCleared?.(
208
+ reason,
209
+ queuedInput.length + (decision?.remaining ? 1 : 0) + (overflowInput === undefined ? 0 : 1),
210
+ );
175
211
  }
176
212
 
177
213
  #startPasteDecisionTimeout(token: number): void {
178
214
  this.#clearPasteDecisionTimeout();
179
215
  this.#pasteDecisionTimeout = setTimeout(() => {
180
216
  if (token !== this.#pasteDecisionToken) return;
181
- const droppedInputCount = this.#clearPendingPasteState();
182
- this.onPastePendingInputCleared?.("timeout", droppedInputCount);
217
+ this.#restoreCancelledPaste("timeout");
183
218
  }, PASTE_DECISION_TIMEOUT_MS);
184
219
  this.#pasteDecisionTimeout.unref?.();
185
220
  }
186
221
 
187
222
  dispose(): void {
188
- this.#clearPendingPasteState();
223
+ this.#resetPendingPasteState(new Error("Editor disposed during paste handling"));
189
224
  this.#pasteHandler = new BracketedPasteHandler();
190
225
  super.dispose();
191
226
  }
192
227
 
193
- #drainPendingPasteInput(initialInput?: string): void {
228
+ #drainPendingPasteInput(initialInput: string | undefined, queuedInput: readonly string[]): void {
194
229
  if (initialInput && initialInput.length > 0) {
195
230
  this.handleInput(initialInput);
196
231
  }
197
- while (!this.#pasteDecisionPending) {
198
- const nextInput = this.#pendingPasteInput.shift();
199
- if (nextInput === undefined) break;
200
- this.handleInput(nextInput);
232
+ for (const input of queuedInput) {
233
+ this.handleInput(input);
201
234
  }
202
235
  }
203
236
 
204
237
  #handleBracketedPaste(pasteContent: string, remaining: string): void {
205
- const applyPasteResult = (token: number, handled: boolean | undefined) => {
238
+ const remainingBytes = Buffer.byteLength(remaining);
239
+ if (remainingBytes > PENDING_PASTE_INPUT_MAX_BYTES) {
240
+ super.handleInput(`\x1b[200~${pasteContent}\x1b[201~`);
241
+ if (remaining.length > 0) this.handleInput(remaining);
242
+ this.onPastePendingInputCleared?.("queue-limit", 1);
243
+ return;
244
+ }
245
+ const token = this.#pasteDecisionToken + 1;
246
+ const controller = new AbortController();
247
+ this.#pasteDecisionToken = token;
248
+ this.#pasteDecisionPending = true;
249
+ this.#pendingPasteDecision = { controller, pasteContent, remaining };
250
+ this.#pendingPasteInputBytes = remainingBytes;
251
+ let commitUsed = false;
252
+
253
+ const applyPasteResult = (handled: boolean | undefined) => {
206
254
  if (token !== this.#pasteDecisionToken) return;
207
- this.#clearPasteDecisionTimeout();
255
+ const queuedInput = this.#resetPendingPasteState();
208
256
  if (!handled) {
209
257
  super.handleInput(`\x1b[200~${pasteContent}\x1b[201~`);
210
258
  }
211
- this.#pasteDecisionPending = false;
212
- this.#drainPendingPasteInput(remaining);
259
+ this.#drainPendingPasteInput(remaining, queuedInput);
213
260
  };
214
- const pasteResult = this.onPasteText?.(pasteContent);
261
+
262
+ let pasteResult: boolean | Promise<boolean> | undefined;
263
+ try {
264
+ pasteResult = this.onPasteText?.(pasteContent, {
265
+ signal: controller.signal,
266
+ commit: commit => {
267
+ if (
268
+ commitUsed ||
269
+ token !== this.#pasteDecisionToken ||
270
+ !this.#pasteDecisionPending ||
271
+ controller.signal.aborted
272
+ ) {
273
+ return false;
274
+ }
275
+ commitUsed = true;
276
+ return commit();
277
+ },
278
+ });
279
+ } catch {
280
+ applyPasteResult(false);
281
+ return;
282
+ }
215
283
 
216
284
  if (pasteResult instanceof Promise) {
217
- const token = this.#pasteDecisionToken + 1;
218
- this.#pasteDecisionToken = token;
219
- this.#pasteDecisionPending = true;
220
285
  this.#startPasteDecisionTimeout(token);
221
- void pasteResult.then(
222
- handled => applyPasteResult(token, handled),
223
- () => applyPasteResult(token, false),
224
- );
286
+ void pasteResult.then(applyPasteResult, () => applyPasteResult(false));
225
287
  } else {
226
- applyPasteResult(this.#pasteDecisionToken, pasteResult);
288
+ applyPasteResult(pasteResult);
227
289
  }
228
290
  }
229
291
 
230
292
  handleInput(data: string): void {
231
293
  if (this.#pasteDecisionPending) {
232
- this.#pendingPasteInput.push(data);
233
- if (this.#pendingPasteInput.length > PENDING_PASTE_INPUT_MAX) {
234
- const droppedInputCount = this.#clearPendingPasteState();
235
- this.onPastePendingInputCleared?.("queue-limit", droppedInputCount);
294
+ const inputBytes = Buffer.byteLength(data);
295
+ if (
296
+ this.#pendingPasteEventCount() >= PENDING_PASTE_INPUT_MAX ||
297
+ this.#pendingPasteInputBytes + inputBytes > PENDING_PASTE_INPUT_MAX_BYTES
298
+ ) {
299
+ this.#restoreCancelledPaste("queue-limit", data);
300
+ return;
236
301
  }
302
+ this.#pendingPasteInput.push(data);
303
+ this.#pendingPasteInputBytes += inputBytes;
237
304
  return;
238
305
  }
239
306
 
@@ -245,8 +312,12 @@ export class CustomEditor extends Editor {
245
312
  }
246
313
 
247
314
  if (this.onPasteText) {
248
- const paste = this.#pasteHandler.process(data);
315
+ const paste =
316
+ data === "\x1b" && !this.#pasteHandler.hasPendingFrame
317
+ ? ({ handled: false } as const)
318
+ : this.#pasteHandler.process(data);
249
319
  if (paste.handled) {
320
+ if (paste.leading.length > 0) this.handleInput(paste.leading);
250
321
  if (paste.pasteContent !== undefined) {
251
322
  this.onViewportFollowLive?.();
252
323
  this.#handleBracketedPaste(paste.pasteContent, paste.remaining);
@@ -41,6 +41,7 @@ export class CustomProviderWizardComponent extends Container {
41
41
  credential: "",
42
42
  models: "",
43
43
  };
44
+ #submitInFlight = false;
44
45
  #onSubmit: (input: CustomProviderWizardSubmit) => void;
45
46
  #onCancel: () => void;
46
47
  #onRender: () => void;
@@ -294,7 +295,7 @@ export class CustomProviderWizardComponent extends Container {
294
295
  this.#step = "credential";
295
296
  } else if (this.#step === "confirm" || this.#step === "force-confirm") {
296
297
  if (this.#selectedIndex === 0) {
297
- this.#onSubmit(this.#buildInput(this.#step === "force-confirm"));
298
+ this.#submit();
298
299
  return;
299
300
  }
300
301
  this.#step = "models";
@@ -303,6 +304,30 @@ export class CustomProviderWizardComponent extends Container {
303
304
  this.#onRender();
304
305
  }
305
306
 
307
+ #submit(): void {
308
+ if (this.#submitInFlight) return;
309
+ this.#submitInFlight = true;
310
+ let submission: unknown;
311
+ try {
312
+ submission = this.#onSubmit(this.#buildInput(this.#step === "force-confirm"));
313
+ } catch (error) {
314
+ this.#submitInFlight = false;
315
+ throw error;
316
+ }
317
+ if (!(submission instanceof Promise)) {
318
+ this.#submitInFlight = false;
319
+ return;
320
+ }
321
+ void submission.then(
322
+ () => {
323
+ this.#submitInFlight = false;
324
+ },
325
+ () => {
326
+ this.#submitInFlight = false;
327
+ },
328
+ );
329
+ }
330
+
306
331
  #buildInput(force: boolean): CustomProviderWizardSubmit {
307
332
  const input = {
308
333
  compatibility: this.#state.compatibility,
@@ -8,10 +8,9 @@ import type { SessionMessageEntry } from "../../session/session-manager";
8
8
  import { parseSessionEntries } from "../../session/session-manager";
9
9
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
10
10
  import { theme } from "../theme/theme";
11
+ import { composeToolText } from "./tool-transcript-format";
11
12
  import { type TranscriptViewerEntry, TranscriptViewerOverlay } from "./transcript-viewer-overlay";
12
13
 
13
- const MAX_TOOL_ARGS_CHARS = 500;
14
-
15
14
  /** Session-observer adapter. The shared viewer owns navigation and fold state. */
16
15
  export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
17
16
  #registry: SessionObserverRegistry;
@@ -191,15 +190,20 @@ function entriesFromMessages(entries: readonly SessionMessageEntry[]): Transcrip
191
190
  .map(part => part.text)
192
191
  .join("\n")
193
192
  .trim() ?? "";
194
- const summary = formatToolArgs(content.name, content.arguments);
195
- const callText = [summary, content.intent].filter(Boolean).join("\n");
196
- const resultDisplay = result?.isError ? `✗ ${resultText || "Error"}` : resultText || "✓ done";
193
+ const text = composeToolText({
194
+ name: content.name,
195
+ args: content.arguments,
196
+ intent: content.intent,
197
+ resultText,
198
+ isError: result?.isError ?? false,
199
+ hasResult: results.has(content.id),
200
+ });
197
201
  output.push({
198
202
  id: `tool:${content.id}`,
199
203
  kind: "tool",
200
204
  label: content.name,
201
205
  payload: {
202
- text: [callText, resultDisplay].filter(Boolean).join("\n"),
206
+ text,
203
207
  metadata: {
204
208
  name: content.name,
205
209
  arguments: content.arguments,
@@ -234,23 +238,6 @@ function entriesFromMessages(entries: readonly SessionMessageEntry[]): Transcrip
234
238
  return output;
235
239
  }
236
240
 
237
- function formatToolArgs(name: string, args: Record<string, unknown>): string {
238
- if (name === "read" || name === "write" || name === "edit") return args.path ? `path: ${args.path}` : "";
239
- if (name === "bash") return typeof args.command === "string" ? args.command.replaceAll("\t", " ") : "";
240
- if (name === "search")
241
- return [
242
- args.pattern ? `pattern: ${args.pattern}` : "",
243
- Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "",
244
- ]
245
- .filter(Boolean)
246
- .join(", ");
247
- return Object.entries(args)
248
- .filter(([key]) => !key.startsWith("_"))
249
- .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
250
- .join(", ")
251
- .slice(0, MAX_TOOL_ARGS_CHARS);
252
- }
253
-
254
241
  function truncateThinking(text: string, expanded: boolean): string {
255
242
  const limit = expanded ? 4_000 : 200;
256
243
  return text.length > limit ? `${text.slice(0, limit)}...` : text;
@@ -0,0 +1,72 @@
1
+ export const MAX_TOOL_ARGS_CHARS = 500;
2
+ export const TOOL_RESULT_MAX_EXPANDED_LINES = 100;
3
+
4
+ type ToolCallFields = {
5
+ name: string;
6
+ args: Record<string, unknown>;
7
+ intent?: string;
8
+ };
9
+
10
+ type ToolResultFields = {
11
+ resultText: string;
12
+ isError: boolean;
13
+ hasResult: boolean;
14
+ };
15
+
16
+ export type ToolTranscriptFields = ToolCallFields & ToolResultFields;
17
+
18
+ export function formatToolArgs(name: string, args: Record<string, unknown>): string {
19
+ if (name === "read" || name === "write" || name === "edit") return args.path ? `path: ${args.path}` : "";
20
+ if (name === "bash") return typeof args.command === "string" ? args.command.replaceAll("\t", " ") : "";
21
+ if (name === "search")
22
+ return [
23
+ args.pattern ? `pattern: ${args.pattern}` : "",
24
+ Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "",
25
+ ]
26
+ .filter(Boolean)
27
+ .join(", ");
28
+ return Object.entries(args)
29
+ .filter(([key]) => !key.startsWith("_"))
30
+ .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
31
+ .join(", ")
32
+ .slice(0, MAX_TOOL_ARGS_CHARS);
33
+ }
34
+
35
+ export function composeToolCall({ name, args, intent }: ToolCallFields): string {
36
+ return [formatToolArgs(name, args), intent].filter(Boolean).join("\n");
37
+ }
38
+
39
+ export function composeToolResult({ resultText, isError, hasResult }: ToolResultFields): string {
40
+ const text = resultText.trim();
41
+ if (!hasResult) return "⏳ pending";
42
+ if (isError) return `✗ ${text || "Error"}`;
43
+ return text || "✓ done";
44
+ }
45
+
46
+ export function composeToolText(fields: ToolTranscriptFields): string {
47
+ return [composeToolCall(fields), composeToolResult(fields)].filter(Boolean).join("\n");
48
+ }
49
+
50
+ /**
51
+ * Caps result output without allocating an array for every source line. The final
52
+ * line count still requires scanning the result so the omitted-line count is exact.
53
+ */
54
+ function expandedToolResult(result: string): string {
55
+ let lineCount = 1;
56
+ let prefixEnd = result.length;
57
+ for (let index = 0; index < result.length; index++) {
58
+ if (result[index] !== "\n") continue;
59
+ if (lineCount === TOOL_RESULT_MAX_EXPANDED_LINES) prefixEnd = index;
60
+ lineCount += 1;
61
+ }
62
+ const prefix = result.slice(0, prefixEnd);
63
+ return lineCount > TOOL_RESULT_MAX_EXPANDED_LINES
64
+ ? `${prefix}\n... ${lineCount - TOOL_RESULT_MAX_EXPANDED_LINES} more lines`
65
+ : prefix;
66
+ }
67
+
68
+ export function toolDisplayText(fields: ToolTranscriptFields, expanded: boolean): string {
69
+ const call = composeToolCall(fields);
70
+ if (!expanded) return call;
71
+ return [call, expandedToolResult(composeToolResult(fields))].filter(Boolean).join("\n");
72
+ }
@@ -11,6 +11,7 @@ import { sanitizeText } from "@gajae-code/utils";
11
11
  import { getMarkdownTheme, theme } from "../theme/theme";
12
12
  import type { TranscriptItemRegistry, TranscriptSourcePayload } from "../transcript-item-registry";
13
13
  import { DynamicBorder } from "./dynamic-border";
14
+ import { toolDisplayText } from "./tool-transcript-format";
14
15
 
15
16
  const INDENT = " ";
16
17
  const PAGE_SIZE = 15;
@@ -363,6 +364,29 @@ export class TranscriptViewerOverlay extends Container {
363
364
  }
364
365
  }
365
366
 
367
+ type ToolTranscriptMetadata = {
368
+ name: string;
369
+ arguments: Record<string, unknown>;
370
+ intent?: unknown;
371
+ resultText: string;
372
+ isError: boolean;
373
+ hasResult: boolean;
374
+ };
375
+
376
+ function hasToolTranscriptMetadata(
377
+ metadata: Readonly<Record<string, unknown>>,
378
+ ): metadata is Readonly<ToolTranscriptMetadata> {
379
+ return (
380
+ typeof metadata.name === "string" &&
381
+ metadata.arguments !== null &&
382
+ typeof metadata.arguments === "object" &&
383
+ !Array.isArray(metadata.arguments) &&
384
+ typeof metadata.resultText === "string" &&
385
+ typeof metadata.isError === "boolean" &&
386
+ typeof metadata.hasResult === "boolean"
387
+ );
388
+ }
389
+
366
390
  /** Main-session adapter over the registry's canonical payload resolver. */
367
391
  export function transcriptViewerEntries(registry: TranscriptItemRegistry): TranscriptViewerEntry[] {
368
392
  return registry.items().flatMap(item => {
@@ -375,10 +399,26 @@ export function transcriptViewerEntries(registry: TranscriptItemRegistry): Trans
375
399
  : item.kind === "assistant-thinking"
376
400
  ? "Thinking"
377
401
  : item.kind === "tool"
378
- ? "Tool"
402
+ ? String(payload.metadata.name ?? "Tool")
379
403
  : item.kind === "user"
380
404
  ? "User"
381
405
  : item.kind;
382
- return [{ id: item.id, kind: item.kind, label, payload, ...capabilities }];
406
+ const toolMetadata = hasToolTranscriptMetadata(payload.metadata) ? payload.metadata : undefined;
407
+ const getDisplayText =
408
+ item.kind === "tool" && toolMetadata
409
+ ? (expanded: boolean) =>
410
+ toolDisplayText(
411
+ {
412
+ name: toolMetadata.name,
413
+ args: toolMetadata.arguments,
414
+ intent: typeof toolMetadata.intent === "string" ? toolMetadata.intent : undefined,
415
+ resultText: toolMetadata.resultText,
416
+ isError: toolMetadata.isError,
417
+ hasResult: toolMetadata.hasResult,
418
+ },
419
+ expanded,
420
+ )
421
+ : undefined;
422
+ return [{ id: item.id, kind: item.kind, label, payload, ...capabilities, getDisplayText }];
383
423
  });
384
424
  }
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { type AgentMessage, ThinkingLevel } from "@gajae-code/agent-core";
4
4
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@gajae-code/tui";
5
- import { $env, sanitizeText } from "@gajae-code/utils";
5
+ import { $env, logger, sanitizeText } from "@gajae-code/utils";
6
6
  import { type AppKeybinding, KEYBINDINGS } from "../../config/keybindings";
7
7
  import { isSettingsInitialized, settings } from "../../config/settings";
8
8
  import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/gjc-plugins";
@@ -22,12 +22,14 @@ import { getUserMessageViewportAnchorIds } from "../../session/session-manager";
22
22
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
23
23
  import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
24
24
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
25
- import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
25
+ import { ensureSupportedImageInput, ImageInputTooLargeError } from "../../utils/image-loading";
26
26
  import { resizeImage } from "../../utils/image-resize";
27
- import { formatPastedImageReference, resolvePastedImagePath } from "../../utils/pasted-image-path";
27
+ import { loadPastedImageBatch, PastedImageBatchError } from "../../utils/pasted-image-loading";
28
+ import { formatPastedImageReference, parsePastedImagePaths } from "../../utils/pasted-image-path";
28
29
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
29
30
  import { ActionRegistry, APP_ACTION_METADATA } from "../action-registry";
30
31
  import { CommandPalette, type CommandPaletteAction, type CommandPaletteEntry } from "../components/command-palette";
32
+ import type { PasteTextContext } from "../components/custom-editor";
31
33
  import { QueuePaneComponent } from "../components/queue-pane";
32
34
  import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
33
35
 
@@ -44,14 +46,23 @@ const EMPTY_EDITOR_DOUBLE_ESCAPE_WINDOW_MS = 500;
44
46
  const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g;
45
47
  const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/;
46
48
 
49
+ interface InputControllerDependencies {
50
+ loadPastedImageBatch?: typeof loadPastedImageBatch;
51
+ }
52
+
47
53
  function isExpandable(obj: unknown): obj is Expandable {
48
54
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
49
55
  }
50
56
 
51
57
  export class InputController {
52
58
  readonly actionRegistry: ActionRegistry<void>;
59
+ readonly #loadPastedImageBatch: typeof loadPastedImageBatch;
53
60
 
54
- constructor(private ctx: InteractiveModeContext) {
61
+ constructor(
62
+ private ctx: InteractiveModeContext,
63
+ dependencies: InputControllerDependencies = {},
64
+ ) {
65
+ this.#loadPastedImageBatch = dependencies.loadPastedImageBatch ?? loadPastedImageBatch;
55
66
  this.actionRegistry = new ActionRegistry({
56
67
  context: undefined,
57
68
  showError: actionId => this.ctx.showError(actionId),
@@ -585,11 +596,11 @@ export class InputController {
585
596
  );
586
597
  this.ctx.editor.onCopyPrompt = () => this.#executeAction("app.clipboard.copyPrompt");
587
598
  this.#registerCommandPaletteAction("app.clipboard.copyPrompt", copyPrompt);
588
- this.ctx.editor.onPasteText = text => this.handleTextPaste(text);
589
- this.ctx.editor.onPastePendingInputCleared = (reason, droppedInputCount) => {
599
+ this.ctx.editor.onPasteText = (text, context) => this.handleTextPaste(text, context);
600
+ this.ctx.editor.onPastePendingInputCleared = (reason, restoredInputCount) => {
590
601
  const reasonText = reason === "timeout" ? "timed out" : "exceeded the input queue limit";
591
602
  this.ctx.showWarning(
592
- `Paste handling ${reasonText}; discarded ${droppedInputCount} buffered input event${droppedInputCount === 1 ? "" : "s"}.`,
603
+ `Paste handling ${reasonText}; restored the original paste and ${restoredInputCount} buffered input event${restoredInputCount === 1 ? "" : "s"}.`,
593
604
  );
594
605
  };
595
606
  const expandTools = () => this.toggleToolOutputExpansion();
@@ -1522,46 +1533,96 @@ export class InputController {
1522
1533
  return true;
1523
1534
  }
1524
1535
 
1525
- handleTextPaste(text: string): boolean | Promise<boolean> {
1526
- const imagePath = resolvePastedImagePath(text, { cwd: this.ctx.sessionManager.getCwd() });
1527
- return imagePath ? this.#attachPastedImagePath(imagePath) : false;
1536
+ handleTextPaste(text: string, context: PasteTextContext): boolean | Promise<boolean> {
1537
+ if (this.ctx.isBashMode || this.ctx.isPythonMode) return false;
1538
+ const parsed = parsePastedImagePaths(text, { cwd: this.ctx.sessionManager.getCwd() });
1539
+ if (!parsed) return false;
1540
+ if (parsed.kind === "too-many") {
1541
+ this.ctx.showStatus(`Cannot attach more than ${parsed.maxCandidates} pasted images.`);
1542
+ return false;
1543
+ }
1544
+ return this.#attachPastedImagePaths(parsed.paths, parsed.requiresConfirmation, context);
1528
1545
  }
1529
1546
 
1530
- /**
1531
- * Returns `false` on every failure path so the editor replays the original
1532
- * bracketed paste — the raw path text must never be lost when attachment
1533
- * is impossible (unsupported content, oversized image, load error).
1534
- */
1535
- async #attachPastedImagePath(imagePath: string): Promise<boolean> {
1547
+ /** Return false on every failure so CustomEditor restores the exact paste. */
1548
+ async #attachPastedImagePaths(
1549
+ imagePaths: readonly string[],
1550
+ requiresConfirmation: boolean,
1551
+ context: PasteTextContext,
1552
+ ): Promise<boolean> {
1553
+ const signal = context.signal;
1554
+ const editor = this.ctx.editor;
1555
+ const editorText = editor.getText();
1556
+ const pendingImages = this.ctx.pendingImages;
1557
+ const pendingImageCount = pendingImages.length;
1536
1558
  try {
1537
- const image = await loadImageInput({
1538
- path: imagePath,
1539
- cwd: this.ctx.sessionManager.getCwd(),
1559
+ if (requiresConfirmation) {
1560
+ const selection = await this.ctx.showHookSelector(
1561
+ `Attach ${imagePaths.length} pasted images?\nOnly attach files you intend to send to the model.`,
1562
+ ["Attach images", "Paste paths literally"],
1563
+ { signal, helpText: "Enter: choose · Esc: paste paths literally" },
1564
+ );
1565
+ signal.throwIfAborted();
1566
+ if (selection !== "Attach images") return false;
1567
+ }
1568
+
1569
+ const loaded = await this.#loadPastedImageBatch({
1570
+ paths: imagePaths,
1540
1571
  autoResize: this.ctx.settings.get("images.autoResize"),
1572
+ sourcePolicy: requiresConfirmation ? "confirmed" : "automatic-temp",
1573
+ signal,
1541
1574
  });
1542
- if (!image) {
1543
- this.ctx.showStatus("Unsupported pasted image file");
1544
- return false;
1545
- }
1575
+ signal.throwIfAborted();
1576
+ return context.commit(() => {
1577
+ if (
1578
+ this.ctx.editor !== editor ||
1579
+ editor.getText() !== editorText ||
1580
+ this.ctx.pendingImages !== pendingImages ||
1581
+ pendingImages.length !== pendingImageCount
1582
+ ) {
1583
+ return false;
1584
+ }
1546
1585
 
1547
- this.ctx.pendingImages = [
1548
- ...this.ctx.pendingImages,
1549
- {
1550
- type: "image",
1551
- data: image.data,
1552
- mimeType: image.mimeType,
1553
- },
1554
- ];
1555
- this.ctx.editor.insertText(`${formatPastedImageReference(this.#nextImagePlaceholder(), image.resolvedPath)} `);
1556
- this.ctx.showStatus(`Attached image: ${path.basename(image.resolvedPath)}`, { dim: true });
1557
- this.ctx.ui.requestRender();
1558
- return true;
1586
+ const firstImageNumber = pendingImages.length + 1;
1587
+ const references = loaded.sourcePaths.map((sourcePath, index) =>
1588
+ formatPastedImageReference(`[image ${firstImageNumber + index}]`, sourcePath),
1589
+ );
1590
+ try {
1591
+ editor.insertText(`${references.join(" ")} `);
1592
+ this.ctx.pendingImages = [...pendingImages, ...loaded.images];
1593
+ } catch (error) {
1594
+ try {
1595
+ editor.setText(editorText);
1596
+ } catch {
1597
+ // Preserve the original mutation error when rollback itself fails.
1598
+ } finally {
1599
+ this.ctx.pendingImages = pendingImages;
1600
+ }
1601
+ throw error;
1602
+ }
1603
+
1604
+ try {
1605
+ this.ctx.showStatus(
1606
+ loaded.images.length === 1
1607
+ ? `Attached image: ${path.basename(loaded.sourcePaths[0] ?? "")}`
1608
+ : `Attached ${loaded.images.length} images`,
1609
+ { dim: true },
1610
+ );
1611
+ this.ctx.ui.requestRender();
1612
+ } catch (error) {
1613
+ logger.warn("Pasted images attached but status rendering failed", { error: String(error) });
1614
+ }
1615
+ return true;
1616
+ });
1559
1617
  } catch (error) {
1560
- if (error instanceof ImageInputTooLargeError) {
1618
+ if (signal.aborted) return false;
1619
+ if (error instanceof ImageInputTooLargeError || error instanceof PastedImageBatchError) {
1561
1620
  this.ctx.showStatus(error.message);
1562
1621
  return false;
1563
1622
  }
1564
- this.ctx.showStatus("Failed to attach pasted image");
1623
+ this.ctx.showStatus(
1624
+ imagePaths.length === 1 ? "Failed to attach pasted image" : "Failed to attach pasted images",
1625
+ );
1565
1626
  return false;
1566
1627
  }
1567
1628
  }
@@ -1060,7 +1060,7 @@ export class SelectorController {
1060
1060
  };
1061
1061
  wizard = new CustomProviderWizardComponent(
1062
1062
  input => {
1063
- void submit(input);
1063
+ return submit(input);
1064
1064
  },
1065
1065
  () => {
1066
1066
  done();