@gajae-code/coding-agent 0.7.11 → 0.8.1

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 (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +25 -2
  5. package/dist/types/discovery/cursor.d.ts +4 -1
  6. package/dist/types/discovery/index.d.ts +0 -1
  7. package/dist/types/discovery/windsurf.d.ts +3 -1
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +4 -0
  10. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  11. package/dist/types/modes/components/status-line.d.ts +8 -0
  12. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  13. package/dist/types/modes/components/welcome.d.ts +9 -1
  14. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/notifications/telegram-daemon.d.ts +3 -0
  20. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  21. package/dist/types/runtime-mcp/config.d.ts +2 -0
  22. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  24. package/dist/types/runtime-mcp/types.d.ts +6 -0
  25. package/dist/types/system-prompt.d.ts +7 -0
  26. package/dist/types/tools/skill.d.ts +1 -1
  27. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  28. package/package.json +10 -10
  29. package/src/capability/mcp.ts +2 -0
  30. package/src/config/mcp-schema.json +5 -1
  31. package/src/config/settings-schema.ts +18 -2
  32. package/src/coordinator-mcp/server.ts +39 -4
  33. package/src/discovery/builtin.ts +12 -0
  34. package/src/discovery/cursor.ts +5 -84
  35. package/src/discovery/gemini.ts +3 -89
  36. package/src/discovery/index.ts +4 -1
  37. package/src/discovery/mcp-json.ts +11 -0
  38. package/src/discovery/opencode.ts +5 -122
  39. package/src/discovery/windsurf.ts +4 -82
  40. package/src/edit/read-file.ts +9 -1
  41. package/src/extensibility/skills.ts +30 -2
  42. package/src/gjc-runtime/session-state-sidecar.ts +167 -24
  43. package/src/gjc-runtime/team-runtime.ts +56 -51
  44. package/src/internal-urls/docs-index.generated.ts +4 -4
  45. package/src/modes/acp/acp-agent.ts +7 -6
  46. package/src/modes/components/agent-dashboard.ts +1 -34
  47. package/src/modes/components/custom-editor.ts +18 -0
  48. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  49. package/src/modes/components/model-selector.ts +58 -2
  50. package/src/modes/components/session-selector.ts +19 -9
  51. package/src/modes/components/settings-selector.ts +13 -0
  52. package/src/modes/components/status-line.ts +215 -86
  53. package/src/modes/components/tree-selector.ts +28 -8
  54. package/src/modes/components/two-column-body.ts +36 -0
  55. package/src/modes/components/welcome.ts +171 -35
  56. package/src/modes/controllers/command-controller.ts +3 -2
  57. package/src/modes/controllers/event-controller.ts +4 -0
  58. package/src/modes/controllers/input-controller.ts +158 -38
  59. package/src/modes/controllers/selector-controller.ts +25 -28
  60. package/src/modes/interactive-mode.ts +20 -23
  61. package/src/modes/prompt-action-autocomplete.ts +17 -7
  62. package/src/modes/rpc/rpc-types.ts +1 -0
  63. package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
  64. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  65. package/src/modes/types.ts +1 -0
  66. package/src/notifications/index.ts +20 -1
  67. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  68. package/src/notifications/telegram-daemon.ts +14 -0
  69. package/src/prompts/system/system-prompt.md +15 -1
  70. package/src/runtime-mcp/config-writer.ts +28 -0
  71. package/src/runtime-mcp/config.ts +7 -0
  72. package/src/runtime-mcp/loader.ts +3 -0
  73. package/src/runtime-mcp/manager.ts +3 -0
  74. package/src/runtime-mcp/types.ts +6 -0
  75. package/src/sdk.ts +4 -0
  76. package/src/session/agent-session.ts +21 -1
  77. package/src/session/session-manager.ts +58 -18
  78. package/src/system-prompt.ts +8 -0
  79. package/src/task/executor.ts +5 -0
  80. package/src/tools/ast-grep.ts +10 -7
  81. package/src/tools/bash.ts +15 -6
  82. package/src/tools/index.ts +8 -1
  83. package/src/tools/skill.ts +1 -1
  84. package/src/workflow/workflow-intent-diff.ts +199 -0
  85. package/dist/types/discovery/vscode.d.ts +0 -1
  86. package/src/discovery/vscode.ts +0 -105
@@ -14,6 +14,12 @@ export interface LspServerInfo {
14
14
  }
15
15
 
16
16
  export type WelcomeLogoMode = "unicode" | "square" | "ascii";
17
+ export interface WelcomeComponentOptions {
18
+ getViewportRows?: () => number | undefined;
19
+ getReservedBottomRows?: (termWidth: number) => number;
20
+ changelogMarkdown?: string;
21
+ collapseChangelog?: boolean;
22
+ }
17
23
 
18
24
  /**
19
25
  * GJC-native launch surface with compact command affordances, project
@@ -30,6 +36,7 @@ export class WelcomeComponent implements Component {
30
36
  private recentSessions: RecentSession[] = [],
31
37
  private lspServers: LspServerInfo[] = [],
32
38
  private readonly logoMode: WelcomeLogoMode = "unicode",
39
+ private readonly options: WelcomeComponentOptions = {},
33
40
  ) {}
34
41
 
35
42
  invalidate(): void {}
@@ -50,6 +57,11 @@ export class WelcomeComponent implements Component {
50
57
  }
51
58
  requestRender();
52
59
  }, INTRO_TICK_MS);
60
+ this.#animTimer.unref?.();
61
+ }
62
+
63
+ dispose(): void {
64
+ this.#stopAnimation();
53
65
  }
54
66
 
55
67
  #stopAnimation(): void {
@@ -74,15 +86,20 @@ export class WelcomeComponent implements Component {
74
86
  }
75
87
 
76
88
  render(termWidth: number): string[] {
77
- // Box dimensions track the live viewport so wide terminals feel intentionally full-screen.
78
- const boxWidth = Math.max(0, termWidth - 2);
89
+ const boxWidth = Math.max(0, termWidth);
79
90
  if (boxWidth < 4) {
80
91
  return [];
81
92
  }
93
+
94
+ const targetRows = this.#targetRows(termWidth);
95
+ if (targetRows !== undefined && targetRows <= 0) {
96
+ return [];
97
+ }
98
+ const targetContentRows = targetRows === undefined ? undefined : Math.max(0, targetRows - 2);
82
99
  const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
83
- const preferredLeftCol = 36;
84
- const minLeftCol = 18; // claw mark plus GJC identity labels
85
- const minRightCol = 20;
100
+ const preferredLeftCol = 40;
101
+ const minLeftCol = 20; // logo mark plus GJC identity labels
102
+ const minRightCol = 24;
86
103
  const modelPill = this.#pill(theme.icon.model || "model", this.modelName, "statusLineModel");
87
104
  const providerPill = this.#pill(theme.icon.package || "provider", this.providerName, "statusLinePath");
88
105
  const logoLines = this.#logoLines();
@@ -90,12 +107,12 @@ export class WelcomeComponent implements Component {
90
107
  const leftMinContentWidth = Math.max(
91
108
  minLeftCol,
92
109
  logoMinWidth,
93
- visibleWidth("Gajae forge"),
110
+ visibleWidth("GJC Forge"),
94
111
  visibleWidth("shape · act · prove"),
95
112
  visibleWidth(modelPill),
96
113
  visibleWidth(providerPill),
97
114
  );
98
- const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35)));
115
+ const desiredLeftCol = Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.38)));
99
116
  const dualLeftCol =
100
117
  dualContentWidth >= minRightCol + 1
101
118
  ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
@@ -107,36 +124,36 @@ export class WelcomeComponent implements Component {
107
124
 
108
125
  const logoColored = this.#currentLogoFrame(logoLines);
109
126
 
110
- // Left column - centered content
111
127
  const leftLines = [
112
128
  "",
113
- this.#centerText(theme.bold(theme.fg("accent", "Gajae forge")), leftCol),
129
+ this.#centerText(theme.bold(theme.fg("accent", "GJC Forge")), leftCol),
114
130
  this.#centerText(theme.fg("dim", "shape · act · prove"), leftCol),
115
131
  "",
116
132
  ...logoColored.map(l => this.#centerText(l, leftCol)),
117
133
  "",
134
+ this.#centerText(this.#loadingLine(), leftCol),
118
135
  this.#centerText(modelPill, leftCol),
119
136
  this.#centerText(providerPill, leftCol),
137
+ "",
120
138
  ];
121
139
 
122
- // Right column separator
123
- const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
124
- const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
140
+ const buildSeparator = (columnWidth: number): string =>
141
+ ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(Math.max(0, columnWidth - 2)))}`;
142
+
143
+ const rightColumnWidth = showRightColumn ? rightCol : leftCol;
144
+ const separator = buildSeparator(rightColumnWidth);
145
+ const changelogBudget = Math.max(1, Math.min(6, Math.floor((targetContentRows ?? 18) * 0.3)));
125
146
 
126
- // Recent sessions content
127
147
  const sessionLines: string[] = [];
128
148
  if (this.recentSessions.length === 0) {
129
149
  sessionLines.push(` ${theme.fg("dim", "No saved trails")}`);
130
150
  } else {
131
- // Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
132
- // so the relative time is never the part that gets truncated. The name
133
- // absorbs whatever space is left.
134
151
  const bulletPrefix = ` ${theme.md.bullet} `;
135
152
  const prefixWidth = visibleWidth(bulletPrefix);
136
153
  for (const session of this.recentSessions.slice(0, 3)) {
137
154
  const timeSuffixRaw = ` (${session.timeAgo})`;
138
155
  const timeWidth = visibleWidth(timeSuffixRaw);
139
- const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
156
+ const nameBudget = Math.max(1, rightColumnWidth - prefixWidth - timeWidth);
140
157
  const nameVis = visibleWidth(session.name);
141
158
  const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
142
159
  sessionLines.push(
@@ -145,12 +162,11 @@ export class WelcomeComponent implements Component {
145
162
  }
146
163
  }
147
164
 
148
- // LSP servers content
149
165
  const lspLines: string[] = [];
150
166
  if (this.lspServers.length === 0) {
151
167
  lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
152
168
  } else {
153
- for (const server of this.lspServers) {
169
+ for (const server of this.lspServers.slice(0, 4)) {
154
170
  const icon =
155
171
  server.status === "ready"
156
172
  ? theme.styledSymbol("status.success", "success")
@@ -162,8 +178,11 @@ export class WelcomeComponent implements Component {
162
178
  }
163
179
  }
164
180
 
165
- // Right column
166
181
  const rightLines = [
182
+ "",
183
+ ` ${theme.bold(theme.fg("accent", "What's New"))}`,
184
+ ...this.#whatsNewLines(rightColumnWidth, changelogBudget),
185
+ separator,
167
186
  ` ${theme.bold(theme.fg("accent", "Flow keys"))}`,
168
187
  ` ${theme.fg("dim", "/")}${theme.fg("muted", " commands")} ${theme.fg("dim", "·")} ${theme.fg(
169
188
  "dim",
@@ -187,7 +206,12 @@ export class WelcomeComponent implements Component {
187
206
  "",
188
207
  ];
189
208
 
190
- // Border characters (dim)
209
+ const contentRows =
210
+ targetContentRows ??
211
+ (showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length + rightLines.length);
212
+ const outputRows = targetRows === undefined ? Math.max(3, contentRows + 2) : targetRows;
213
+ const bodyRows = Math.max(0, outputRows - 2);
214
+
191
215
  const hChar = theme.boxRound.horizontal;
192
216
  const h = theme.fg("dim", hChar);
193
217
  const v = theme.fg("dim", theme.boxRound.vertical);
@@ -197,9 +221,7 @@ export class WelcomeComponent implements Component {
197
221
  const br = theme.fg("dim", theme.boxRound.bottomRight);
198
222
 
199
223
  const lines: string[] = [];
200
-
201
- // Top border with embedded title
202
- const title = ` ${APP_NAME} v${this.version} · GJC forge `;
224
+ const title = ` ${APP_NAME} v${this.version} · GJC Forge `;
203
225
  const titlePrefixRaw = hChar.repeat(3);
204
226
  const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
205
227
  const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
@@ -210,22 +232,32 @@ export class WelcomeComponent implements Component {
210
232
  const afterTitle = titleSpace - titleVisLen;
211
233
  lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
212
234
  }
235
+ if (outputRows === 1) {
236
+ return lines;
237
+ }
213
238
 
214
- // Content rows
215
- const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
216
- for (let i = 0; i < maxRows; i++) {
217
- const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
218
- if (showRightColumn) {
219
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
239
+ if (showRightColumn) {
240
+ const leftBlock = this.#fitBlock(leftLines, bodyRows, "center");
241
+ const rightBlock = this.#fitBlock(rightLines, bodyRows, "top");
242
+ for (let i = 0; i < bodyRows; i++) {
243
+ const left = this.#fitToWidth(leftBlock[i] ?? "", leftCol);
244
+ const right = this.#fitToWidth(rightBlock[i] ?? "", rightCol);
220
245
  lines.push(v + left + v + right + v);
221
- } else {
222
- lines.push(v + left + v);
223
246
  }
224
- }
225
- // Bottom border
226
- if (showRightColumn) {
227
247
  lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
228
248
  } else {
249
+ const compactLeftLines =
250
+ bodyRows < 14
251
+ ? [
252
+ this.#centerText(theme.bold(theme.fg("accent", "GJC Forge")), leftCol),
253
+ this.#centerText(this.#loadingLine(), leftCol),
254
+ this.#centerText(modelPill, leftCol),
255
+ ]
256
+ : leftLines;
257
+ const singleBlock = this.#fitBlock([...compactLeftLines, separator, ...rightLines], bodyRows, "top");
258
+ for (let i = 0; i < bodyRows; i++) {
259
+ lines.push(v + this.#fitToWidth(singleBlock[i] ?? "", leftCol) + v);
260
+ }
229
261
  lines.push(bl + h.repeat(leftCol) + br);
230
262
  }
231
263
 
@@ -251,6 +283,110 @@ export class WelcomeComponent implements Component {
251
283
  }
252
284
  return str + padding(width - visLen);
253
285
  }
286
+ #targetRows(termWidth: number): number | undefined {
287
+ const viewportRows = this.options.getViewportRows?.();
288
+ if (typeof viewportRows !== "number" || !Number.isFinite(viewportRows) || viewportRows <= 0) {
289
+ return undefined;
290
+ }
291
+ const reservedRows = Math.max(0, Math.floor(this.options.getReservedBottomRows?.(termWidth) ?? 0));
292
+ return Math.max(0, Math.floor(viewportRows) - reservedRows);
293
+ }
294
+
295
+ #loadingLine(): string {
296
+ const frames = theme.spinnerFrames;
297
+ const elapsed = this.#animStart == null ? 0 : performance.now() - this.#animStart;
298
+ const frame = frames.length > 0 ? (frames[Math.floor(elapsed / 100) % frames.length] ?? "*") : "*";
299
+ const label = this.#animStart == null ? "ready" : "warming workspace";
300
+ return `${theme.fg("warning", frame)} ${theme.fg("muted", label)}`;
301
+ }
302
+
303
+ #fitBlock(lines: string[], rows: number, align: "top" | "center"): string[] {
304
+ if (rows <= 0) return [];
305
+ const clipped =
306
+ lines.length > rows
307
+ ? rows === 1
308
+ ? [theme.fg("dim", " …")]
309
+ : [...lines.slice(0, rows - 1), theme.fg("dim", " …")]
310
+ : lines;
311
+ const missingRows = rows - clipped.length;
312
+ if (missingRows <= 0) return clipped;
313
+ const topPad = align === "center" ? Math.floor(missingRows / 2) : 0;
314
+ const bottomPad = missingRows - topPad;
315
+ return [...Array.from({ length: topPad }, () => ""), ...clipped, ...Array.from({ length: bottomPad }, () => "")];
316
+ }
317
+
318
+ #whatsNewLines(width: number, maxItems: number): string[] {
319
+ const changelog = this.options.changelogMarkdown?.trim();
320
+ if (!changelog) {
321
+ return [` ${theme.fg("dim", "Ready for your next prompt")}`];
322
+ }
323
+
324
+ const version = this.#latestChangelogVersion(changelog);
325
+ if (this.options.collapseChangelog) {
326
+ return [
327
+ ` ${theme.fg("muted", `Updated to v${version}`)}`,
328
+ ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
329
+ ];
330
+ }
331
+
332
+ const itemLimit = Math.max(1, Math.floor(maxItems));
333
+ const items = this.#changelogItems(changelog);
334
+ if (items.length === 0) {
335
+ return [
336
+ ` ${theme.fg("muted", `Updated to v${version}`)}`,
337
+ ` ${theme.fg("dim", `Use ${theme.bold("/changelog")} for details`)}`,
338
+ ];
339
+ }
340
+
341
+ const prefix = ` ${theme.md.bullet} `;
342
+ const textWidth = Math.max(1, width - visibleWidth(prefix));
343
+ const visibleItems = items.slice(0, itemLimit).map(item => {
344
+ const text = visibleWidth(item) > textWidth ? truncateToWidth(item, textWidth) : item;
345
+ return `${theme.fg("dim", prefix)}${theme.fg("muted", text)}`;
346
+ });
347
+ if (items.length > visibleItems.length) {
348
+ visibleItems.push(` ${theme.fg("dim", `… ${theme.bold("/changelog")} for full notes`)}`);
349
+ }
350
+ return visibleItems;
351
+ }
352
+
353
+ #latestChangelogVersion(markdown: string): string {
354
+ const versionMatch = markdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
355
+ return versionMatch?.[1] ?? this.version;
356
+ }
357
+
358
+ #changelogItems(markdown: string): string[] {
359
+ const items: string[] = [];
360
+ let inFence = false;
361
+ for (const rawLine of markdown.split(/\r?\n/)) {
362
+ const line = rawLine.trim();
363
+ if (line.startsWith("```")) {
364
+ inFence = !inFence;
365
+ continue;
366
+ }
367
+ if (inFence || !line || /^#{1,6}\s+/.test(line) || /^-{3,}$/.test(line)) {
368
+ continue;
369
+ }
370
+ const withoutBullet = line
371
+ .replace(/^[-*]\s+/, "")
372
+ .replace(/^\d+\.\s+/, "")
373
+ .replace(/^>\s*/, "");
374
+ const cleaned = this.#stripMarkdown(withoutBullet);
375
+ if (cleaned) items.push(cleaned);
376
+ }
377
+ return items;
378
+ }
379
+
380
+ #stripMarkdown(text: string): string {
381
+ return text
382
+ .replace(/`([^`]+)`/g, "$1")
383
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
384
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
385
+ .replace(/__([^_]+)__/g, "$1")
386
+ .replace(/\*([^*]+)\*/g, "$1")
387
+ .replace(/[_~]/g, "")
388
+ .trim();
389
+ }
254
390
 
255
391
  #pill(icon: string, text: string, color: ThemeColor): string {
256
392
  return `${theme.fg("borderMuted", "[")} ${theme.fg(color, icon)} ${theme.fg("muted", text)} ${theme.fg(
@@ -1276,17 +1276,18 @@ export class CommandController {
1276
1276
  async handleContributionPrepCommand(customInstructions?: string): Promise<void> {
1277
1277
  this.ctx.editor.setText("");
1278
1278
  try {
1279
- const result = await this.ctx.session.prepareContributionPrep({ customInstructions, spawnWorker: true });
1279
+ const result = await this.ctx.session.prepareContributionPrep({ customInstructions, spawnWorker: false });
1280
1280
  this.ctx.showStatus(
1281
1281
  [
1282
1282
  "Contribution prep artifacts written.",
1283
1283
  `Manifest: ${result.manifestPath}`,
1284
1284
  `Worker prompt: ${result.workerPromptPath}`,
1285
+ "Open the worker prompt from a separate terminal to keep this TUI session stable.",
1285
1286
  ].join("\n"),
1286
1287
  );
1287
1288
  this.ctx.chatContainer.addChild(
1288
1289
  new Text(
1289
- `${theme.fg("accent", `${theme.status.success} Contribution prep ready`)}\nManifest: ${result.manifestPath}`,
1290
+ `${theme.fg("accent", `${theme.status.success} Contribution prep ready`)}\nWorker prompt: ${result.workerPromptPath}`,
1290
1291
  1,
1291
1292
  1,
1292
1293
  ),
@@ -138,6 +138,7 @@ export class EventController {
138
138
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
139
139
  this.ctx.retryEscapeHandler = undefined;
140
140
  }
141
+ this.ctx.retryEscapePrimed = false;
141
142
  if (this.ctx.retryLoader) {
142
143
  this.ctx.retryLoader.stop();
143
144
  this.ctx.retryLoader = undefined;
@@ -241,6 +242,7 @@ export class EventController {
241
242
  this.ctx.retryLoader = undefined;
242
243
  this.ctx.statusContainer.clear();
243
244
  }
245
+ this.ctx.retryEscapePrimed = false;
244
246
  this.#cancelIdleCompaction();
245
247
  this.ctx.updateEditorBorderColor();
246
248
  this.ctx.ensureLoadingAnimation();
@@ -768,6 +770,7 @@ export class EventController {
768
770
  if (!this.ctx.retryEscapeHandler) {
769
771
  this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
770
772
  }
773
+ this.ctx.retryEscapePrimed = false;
771
774
  let escPressed = false;
772
775
  this.ctx.editor.onEscape = () => {
773
776
  if (!escPressed) {
@@ -818,6 +821,7 @@ export class EventController {
818
821
  this.ctx.retryLoader = undefined;
819
822
  this.ctx.statusContainer.clear();
820
823
  }
824
+ this.ctx.retryEscapePrimed = false;
821
825
  if (!event.success) {
822
826
  this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
823
827
  }
@@ -1,7 +1,7 @@
1
1
  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
- import type { AutocompleteProvider, SlashCommand } from "@gajae-code/tui";
4
+ import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@gajae-code/tui";
5
5
  import { $env, sanitizeText } from "@gajae-code/utils";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
7
  import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/gjc-plugins";
@@ -42,6 +42,127 @@ export class InputController {
42
42
  * so abort cleanup going idle cannot turn the second Esc into an idle action. */
43
43
  #steerConsumePending = false;
44
44
 
45
+ #globalInterruptUnsubscribe: (() => void) | undefined;
46
+
47
+ #matchesInterruptKey(data: string): boolean {
48
+ return this.ctx.keybindings.getKeys("app.interrupt").some(key => matchesKey(data, key));
49
+ }
50
+
51
+ #hasHookDialog(): boolean {
52
+ return Boolean(this.ctx.hookSelector || this.ctx.hookInput || this.ctx.hookEditor);
53
+ }
54
+
55
+ #isRetryBackoffActive(): boolean {
56
+ return Boolean(
57
+ this.ctx.retryLoader ||
58
+ this.ctx.retryEscapeHandler ||
59
+ (this.ctx.session.isRetrying && !this.ctx.session.isStreaming),
60
+ );
61
+ }
62
+
63
+ #handleCancellableWorkEscape(options: {
64
+ loading?: boolean;
65
+ processes?: boolean;
66
+ modes?: boolean;
67
+ maintenance?: boolean;
68
+ retry?: boolean;
69
+ streaming?: boolean;
70
+ }): boolean {
71
+ if (options.loading && this.ctx.loadingAnimation) {
72
+ if (this.ctx.cancelPendingSubmission()) {
73
+ return true;
74
+ }
75
+ this.restoreQueuedMessagesToEditor({ abort: true });
76
+ return true;
77
+ }
78
+ if (options.processes && this.ctx.session.isBashRunning) {
79
+ this.ctx.session.abortBash();
80
+ return true;
81
+ }
82
+ if (options.modes && this.ctx.isBashMode) {
83
+ this.ctx.editor.setText("");
84
+ this.ctx.isBashMode = false;
85
+ this.ctx.isBashNoContext = false;
86
+ this.ctx.updateEditorBorderColor();
87
+ return true;
88
+ }
89
+ if (options.processes && this.ctx.session.isEvalRunning) {
90
+ this.ctx.session.abortEval();
91
+ return true;
92
+ }
93
+ if (options.modes && this.ctx.isPythonMode) {
94
+ this.ctx.editor.setText("");
95
+ this.ctx.isPythonMode = false;
96
+ this.ctx.updateEditorBorderColor();
97
+ return true;
98
+ }
99
+ if (
100
+ options.maintenance &&
101
+ (this.ctx.session.isCompacting || this.ctx.autoCompactionLoader || this.ctx.autoCompactionEscapeHandler)
102
+ ) {
103
+ this.ctx.session.abortCompaction();
104
+ return true;
105
+ }
106
+ if (options.maintenance && this.ctx.session.isGeneratingHandoff) {
107
+ this.ctx.session.abortHandoff();
108
+ return true;
109
+ }
110
+ if (options.retry) {
111
+ if (this.#isRetryBackoffActive()) {
112
+ if (this.ctx.retryEscapePrimed) {
113
+ this.ctx.session.abortRetry();
114
+ } else {
115
+ this.ctx.retryEscapePrimed = true;
116
+ this.ctx.session.retryNow();
117
+ }
118
+ return true;
119
+ }
120
+ this.ctx.retryEscapePrimed = false;
121
+ }
122
+ if (options.streaming && this.ctx.session.isStreaming) {
123
+ if (this.ctx.session.hasQueuedSteering && !this.#steerConsumePending) {
124
+ // First Esc with a queued steer: silently consume it and
125
+ // auto-continue via steer-on-interrupt instead of stalling on
126
+ // "Operation aborted".
127
+ this.#steerConsumePending = true;
128
+ void this.#abortInteractive({ silent: true });
129
+ } else {
130
+ void this.#abortInteractive();
131
+ }
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ #installGlobalInterruptListener(): void {
138
+ if (typeof this.ctx.ui.addInputListener !== "function") {
139
+ return;
140
+ }
141
+ this.#globalInterruptUnsubscribe?.();
142
+ this.#globalInterruptUnsubscribe = this.ctx.ui.addInputListener(data => {
143
+ if (!this.#matchesInterruptKey(data)) {
144
+ return undefined;
145
+ }
146
+ if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
147
+ return { consume: true };
148
+ }
149
+ const hookDialogActive = this.#hasHookDialog();
150
+ if (
151
+ this.#handleCancellableWorkEscape({
152
+ loading: hookDialogActive,
153
+ processes: hookDialogActive,
154
+ modes: false,
155
+ maintenance: true,
156
+ retry: true,
157
+ streaming: hookDialogActive,
158
+ })
159
+ ) {
160
+ return { consume: true };
161
+ }
162
+ return undefined;
163
+ });
164
+ }
165
+
45
166
  #abortInteractive(options?: { silent?: boolean }): Promise<void> {
46
167
  return this.ctx.session.abort({
47
168
  timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS,
@@ -60,6 +181,7 @@ export class InputController {
60
181
  this.ctx.session.isStreaming ||
61
182
  this.ctx.session.isCompacting ||
62
183
  this.ctx.session.isGeneratingHandoff ||
184
+ this.ctx.session.isRetrying ||
63
185
  this.ctx.session.isBashRunning ||
64
186
  this.ctx.session.isEvalRunning ||
65
187
  this.ctx.autoCompactionLoader ||
@@ -67,6 +189,8 @@ export class InputController {
67
189
  this.ctx.autoCompactionEscapeHandler ||
68
190
  this.ctx.retryEscapeHandler,
69
191
  );
192
+ this.#installGlobalInterruptListener();
193
+
70
194
  // An open btw panel must stay dismissable with Esc even while another
71
195
  // controller (auto-compaction, auto-retry, manual compaction, etc.) has
72
196
  // temporarily replaced editor.onEscape. This priority hook is never
@@ -87,6 +211,9 @@ export class InputController {
87
211
  }
88
212
  this.#steerConsumePending = false;
89
213
  }
214
+ if (this.#handleCancellableWorkEscape({ maintenance: true, retry: true })) {
215
+ return;
216
+ }
90
217
  // Normal input state with user-typed text: Esc must not interrupt a
91
218
  // running task (streaming turn, bash/eval). A double Esc within the
92
219
  // 500ms window clears the composer instead. Bash/Python input modes
@@ -101,35 +228,19 @@ export class InputController {
101
228
  }
102
229
  return;
103
230
  }
104
- if (this.ctx.loadingAnimation) {
105
- if (this.ctx.cancelPendingSubmission()) {
106
- return;
107
- }
108
- this.restoreQueuedMessagesToEditor({ abort: true });
109
- } else if (this.ctx.session.isBashRunning) {
110
- this.ctx.session.abortBash();
111
- } else if (this.ctx.isBashMode) {
112
- this.ctx.editor.setText("");
113
- this.ctx.isBashMode = false;
114
- this.ctx.isBashNoContext = false;
115
- this.ctx.updateEditorBorderColor();
116
- } else if (this.ctx.session.isEvalRunning) {
117
- this.ctx.session.abortEval();
118
- } else if (this.ctx.isPythonMode) {
119
- this.ctx.editor.setText("");
120
- this.ctx.isPythonMode = false;
121
- this.ctx.updateEditorBorderColor();
122
- } else if (this.ctx.session.isStreaming) {
123
- if (this.ctx.session.hasQueuedSteering && !this.#steerConsumePending) {
124
- // First Esc with a queued steer: silently consume it and
125
- // auto-continue via steer-on-interrupt instead of stalling on
126
- // "Operation aborted".
127
- this.#steerConsumePending = true;
128
- void this.#abortInteractive({ silent: true });
129
- } else {
130
- void this.#abortInteractive();
131
- }
132
- } else if (!this.ctx.editor.getText().trim()) {
231
+ if (
232
+ this.#handleCancellableWorkEscape({
233
+ loading: true,
234
+ processes: true,
235
+ modes: true,
236
+ maintenance: true,
237
+ retry: true,
238
+ streaming: true,
239
+ })
240
+ ) {
241
+ return;
242
+ }
243
+ if (!this.ctx.editor.getText().trim()) {
133
244
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
134
245
  const action = settings.get("doubleEscapeAction");
135
246
  if (action !== "none") {
@@ -202,9 +313,18 @@ export class InputController {
202
313
  this.ctx.editor.onDequeue = () => this.handleDequeue();
203
314
  this.ctx.editor.setActionKeys("app.message.queue", this.ctx.keybindings.getKeys("app.message.queue"));
204
315
  this.ctx.editor.onQueue = () => void this.handleQueueSubmit();
316
+ this.ctx.editor.onTab = () => {
317
+ if (!this.ctx.session.isStreaming && !this.ctx.session.isCompacting) return false;
318
+ void this.handleQueueSubmit();
319
+ return true;
320
+ };
205
321
  this.ctx.editor.onTabDeclined = () => {
206
322
  if (this.ctx.session.isStreaming || this.ctx.session.isCompacting) void this.handleQueueSubmit();
207
323
  };
324
+ this.ctx.editor.onViewportPageScroll = direction => this.ctx.ui.scrollViewportPages(direction);
325
+ this.ctx.editor.onViewportFollowLive = () => {
326
+ this.ctx.ui.followLiveViewport();
327
+ };
208
328
 
209
329
  this.ctx.editor.clearCustomKeyHandlers();
210
330
  // Wire up extension shortcuts
@@ -524,19 +644,18 @@ export class InputController {
524
644
 
525
645
  /**
526
646
  * Dispatch skill slash invocation(s) (`/skill:<name>`) through custom messages
527
- * using the supplied `streamingBehavior`. Returns true if the text was a
528
- * recognised skill command chain and was dispatched. A failure to load a skill
529
- * file is surfaced via `showError` but still returns true — the editor was
530
- * already cleared on the success path, so falling through to plain-text
531
- * handling at that point would double-submit. Returns false when the text
532
- * isn't a `/skill:` prefix or the command name isn't a registered skill,
533
- * so the caller can fall through to plain-text handling (this branch
647
+ * using the supplied `streamingBehavior`. Returns true if the text contains a
648
+ * recognised canonical skill command or command chain and was dispatched. A
649
+ * failure to load a skill file is surfaced via `showError` but still returns
650
+ * true — the editor was already cleared on the success path, so falling
651
+ * through to plain-text handling at that point would double-submit. Returns
652
+ * false when the text has no registered canonical skill invocation, so the
653
+ * caller can fall through to plain-text handling (this branch
534
654
  * leaves the editor state untouched). `streamingBehavior` is only consulted
535
655
  * while the agent is streaming; the idle path of `promptCustomMessage`
536
656
  * ignores it.
537
657
  */
538
658
  async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
539
- if (!text.startsWith("/")) return false;
540
659
  const invocations = parseSkillInvocations(text, this.ctx.skillCommands ?? new Map());
541
660
  if (invocations.length === 0) return false;
542
661
  this.ctx.editor.addToHistory(text);
@@ -727,6 +846,7 @@ export class InputController {
727
846
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
728
847
  this.ctx.retryEscapeHandler = undefined;
729
848
  }
849
+ this.ctx.retryEscapePrimed = false;
730
850
  this.ctx.statusContainer.clear();
731
851
  this.ctx.statusLine.dispose();
732
852