@gajae-code/coding-agent 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/daemon-cli.d.ts +2 -0
  4. package/dist/types/cli/initial-message.d.ts +1 -1
  5. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  6. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  7. package/dist/types/cli/notify-cli.d.ts +3 -1
  8. package/dist/types/cli/session-picker.d.ts +4 -3
  9. package/dist/types/cli/update-cli.d.ts +22 -3
  10. package/dist/types/cli.d.ts +4 -0
  11. package/dist/types/commands/daemon.d.ts +6 -2
  12. package/dist/types/commands/launch.d.ts +1 -0
  13. package/dist/types/commands/notify.d.ts +6 -0
  14. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  15. package/dist/types/commit/analysis/summary.d.ts +1 -1
  16. package/dist/types/commit/changelog/generate.d.ts +1 -1
  17. package/dist/types/commit/changelog/index.d.ts +2 -2
  18. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  19. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  20. package/dist/types/config/keybindings.d.ts +5 -0
  21. package/dist/types/config/model-profile-activation.d.ts +1 -0
  22. package/dist/types/config/settings-schema.d.ts +47 -7
  23. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  24. package/dist/types/coordinator-mcp/server.d.ts +67 -6
  25. package/dist/types/daemon/control-types.d.ts +10 -0
  26. package/dist/types/daemon/operator-contract.d.ts +47 -0
  27. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  28. package/dist/types/discovery/helpers.d.ts +1 -1
  29. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  30. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  31. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  32. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  33. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  36. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  37. package/dist/types/main.d.ts +66 -2
  38. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  39. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  40. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  42. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  43. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  44. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  45. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  46. package/dist/types/modes/components/session-selector.d.ts +22 -9
  47. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  49. package/dist/types/modes/components/user-message.d.ts +2 -1
  50. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  51. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  52. package/dist/types/modes/interactive-mode.d.ts +24 -2
  53. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  54. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  56. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  57. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  58. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  59. package/dist/types/modes/types.d.ts +28 -2
  60. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  61. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  62. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  63. package/dist/types/notifications/config.d.ts +34 -0
  64. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  65. package/dist/types/notifications/index.d.ts +27 -0
  66. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  67. package/dist/types/notifications/notification-service.d.ts +144 -0
  68. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  69. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  70. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  71. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  72. package/dist/types/notifications/topic-registry.d.ts +24 -4
  73. package/dist/types/registry/agent-registry.d.ts +2 -0
  74. package/dist/types/rlm/index.d.ts +1 -1
  75. package/dist/types/sdk.d.ts +2 -0
  76. package/dist/types/secrets/obfuscator.d.ts +1 -1
  77. package/dist/types/session/agent-session.d.ts +26 -2
  78. package/dist/types/session/cache-economics.d.ts +79 -0
  79. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  80. package/dist/types/session/session-manager.d.ts +124 -4
  81. package/dist/types/session/session-storage.d.ts +155 -13
  82. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  83. package/dist/types/task/executor.d.ts +3 -0
  84. package/dist/types/task/receipt.d.ts +3 -1
  85. package/dist/types/task/types.d.ts +8 -0
  86. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  87. package/dist/types/tools/ask.d.ts +25 -1
  88. package/dist/types/tools/bash.d.ts +1 -1
  89. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  90. package/dist/types/tools/index.d.ts +58 -6
  91. package/dist/types/tools/telegram-send.d.ts +7 -0
  92. package/dist/types/utils/external-editor.d.ts +2 -0
  93. package/package.json +9 -9
  94. package/scripts/compile-args.ts +8 -2
  95. package/scripts/g004-tmux-smoke.ts +196 -36
  96. package/src/cli/daemon-cli.ts +19 -29
  97. package/src/cli/malloc-env-guard.test.ts +104 -0
  98. package/src/cli/malloc-env-guard.ts +149 -0
  99. package/src/cli/notify-cli.ts +78 -14
  100. package/src/cli/session-picker.ts +23 -40
  101. package/src/cli/update-cli.ts +56 -37
  102. package/src/cli.ts +50 -10
  103. package/src/commands/daemon.ts +19 -5
  104. package/src/commands/harness.ts +515 -47
  105. package/src/commands/launch.ts +11 -3
  106. package/src/commands/notify.ts +5 -1
  107. package/src/commands/session.ts +6 -1
  108. package/src/config/keybindings.ts +5 -0
  109. package/src/config/model-profile-activation.ts +1 -1
  110. package/src/config/model-profiles.ts +23 -23
  111. package/src/config/settings-schema.ts +41 -1
  112. package/src/coordinator-mcp/model-preset.ts +100 -0
  113. package/src/coordinator-mcp/server.ts +2653 -425
  114. package/src/daemon/control-types.ts +11 -0
  115. package/src/daemon/operator-contract.ts +121 -0
  116. package/src/deep-interview/render-middleware.ts +67 -2
  117. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  118. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  119. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  120. package/src/gjc-runtime/launch-tmux.ts +702 -135
  121. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  122. package/src/gjc-runtime/team-runtime.ts +6 -0
  123. package/src/gjc-runtime/tmux-common.ts +26 -7
  124. package/src/gjc-runtime/tmux-gc.ts +52 -9
  125. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  126. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  127. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  128. package/src/internal-urls/docs-index.generated.ts +9 -8
  129. package/src/internal-urls/local-protocol.ts +29 -10
  130. package/src/main.ts +298 -75
  131. package/src/modes/acp/acp-agent.ts +728 -98
  132. package/src/modes/acp/acp-mode.ts +29 -2
  133. package/src/modes/components/assistant-message.ts +32 -3
  134. package/src/modes/components/bash-execution.ts +43 -13
  135. package/src/modes/components/custom-editor.ts +5 -0
  136. package/src/modes/components/gajae-pet-widget.ts +360 -0
  137. package/src/modes/components/hook-selector.ts +12 -0
  138. package/src/modes/components/irc-sidebar.ts +206 -0
  139. package/src/modes/components/pet-selector.ts +55 -0
  140. package/src/modes/components/session-selector.ts +222 -221
  141. package/src/modes/components/settings-selector.ts +10 -0
  142. package/src/modes/components/tool-execution.ts +47 -6
  143. package/src/modes/components/user-message.ts +26 -9
  144. package/src/modes/controllers/command-controller.ts +28 -2
  145. package/src/modes/controllers/event-controller.ts +159 -36
  146. package/src/modes/controllers/extension-ui-controller.ts +36 -17
  147. package/src/modes/controllers/input-controller.ts +8 -2
  148. package/src/modes/controllers/selector-controller.ts +62 -15
  149. package/src/modes/interactive-mode.ts +183 -7
  150. package/src/modes/irc-observation-ledger.ts +127 -0
  151. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  152. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  153. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  154. package/src/modes/types.ts +34 -2
  155. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  156. package/src/modes/utils/irc-message.ts +138 -0
  157. package/src/modes/utils/ui-helpers.ts +216 -50
  158. package/src/notifications/config.ts +44 -0
  159. package/src/notifications/daemon-paths.ts +8 -0
  160. package/src/notifications/index.ts +535 -75
  161. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  162. package/src/notifications/notification-service.ts +710 -0
  163. package/src/notifications/rate-limit-pool.ts +46 -12
  164. package/src/notifications/telegram-daemon-cli.ts +2 -0
  165. package/src/notifications/telegram-daemon-control.ts +27 -2
  166. package/src/notifications/telegram-daemon.ts +635 -94
  167. package/src/notifications/telegram-reference.ts +73 -13
  168. package/src/notifications/topic-registry.ts +86 -9
  169. package/src/prompts/system/irc-peer-roster.md +1 -0
  170. package/src/prompts/system/subagent-system-prompt.md +1 -0
  171. package/src/prompts/system/system-prompt.md +1 -0
  172. package/src/prompts/tools/irc.md +1 -1
  173. package/src/registry/agent-registry.ts +3 -0
  174. package/src/sdk.ts +52 -1
  175. package/src/secrets/obfuscator.ts +4 -2
  176. package/src/session/agent-session.ts +396 -145
  177. package/src/session/cache-economics.ts +275 -0
  178. package/src/session/provider-safety-stop.ts +9 -0
  179. package/src/session/session-dump-format.ts +6 -0
  180. package/src/session/session-manager.ts +809 -39
  181. package/src/session/session-storage.ts +582 -38
  182. package/src/slash-commands/builtin-registry.ts +143 -9
  183. package/src/task/executor.ts +12 -3
  184. package/src/task/index.ts +8 -0
  185. package/src/task/receipt.ts +7 -2
  186. package/src/task/types.ts +26 -0
  187. package/src/tools/ask-answer-registry.ts +25 -0
  188. package/src/tools/ask.ts +355 -33
  189. package/src/tools/browser/tab-supervisor.ts +63 -6
  190. package/src/tools/browser.ts +8 -3
  191. package/src/tools/index.ts +81 -6
  192. package/src/tools/sqlite-reader.ts +8 -4
  193. package/src/tui/output-block.ts +18 -6
  194. package/src/utils/external-editor.ts +6 -1
@@ -5,19 +5,46 @@ import { AcpAgent } from "./acp-agent";
5
5
 
6
6
  export type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
7
7
 
8
+ export interface AcpConnectionHandle {
9
+ connection: AgentSideConnection;
10
+ agent: AcpAgent;
11
+ }
12
+
8
13
  export function createAcpConnection(
9
14
  transport: Stream,
10
15
  createSession: AcpSessionFactory,
11
16
  initialSession?: AgentSession,
12
17
  ): AgentSideConnection {
13
- return new AgentSideConnection(conn => new AcpAgent(conn, createSession, initialSession), transport);
18
+ return createAcpConnectionWithAgent(transport, createSession, initialSession).connection;
19
+ }
20
+
21
+ /**
22
+ * Create an ACP connection and return both the {@link AgentSideConnection} and the
23
+ * underlying {@link AcpAgent}. Callers that need to await agent shutdown (terminal
24
+ * delete/dispose work) after transport closure should use this instead of
25
+ * {@link createAcpConnection}.
26
+ */
27
+ export function createAcpConnectionWithAgent(
28
+ transport: Stream,
29
+ createSession: AcpSessionFactory,
30
+ initialSession?: AgentSession,
31
+ ): AcpConnectionHandle {
32
+ let agent: AcpAgent | undefined;
33
+ const connection = new AgentSideConnection(conn => {
34
+ agent = new AcpAgent(conn, createSession, initialSession);
35
+ return agent;
36
+ }, transport);
37
+ return { connection, agent: agent! };
14
38
  }
15
39
 
16
40
  export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never> {
17
41
  const input = stream.Writable.toWeb(process.stdout);
18
42
  const output = stream.Readable.toWeb(process.stdin);
19
43
  const transport = ndJsonStream(input, output);
20
- const connection = createAcpConnection(transport, createSession, initialSession);
44
+ const { connection, agent } = createAcpConnectionWithAgent(transport, createSession, initialSession);
21
45
  await connection.closed;
46
+ // Await connection-local terminal work (active deletes, disposal) so
47
+ // `process.exit` never kills in-flight deletion/cleanup.
48
+ await agent.shutdownPromise;
22
49
  process.exit(0);
23
50
  }
@@ -1,5 +1,16 @@
1
1
  import type { AssistantMessage, ImageContent, Usage } from "@gajae-code/ai";
2
- import { type Component, Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@gajae-code/tui";
2
+ import {
3
+ type Component,
4
+ Container,
5
+ Image,
6
+ ImageProtocol,
7
+ isViewportAnchorSourceRenderer,
8
+ Markdown,
9
+ Spacer,
10
+ TERMINAL,
11
+ Text,
12
+ type ViewportAnchorSource,
13
+ } from "@gajae-code/tui";
3
14
  import { formatNumber } from "@gajae-code/utils";
4
15
  import { settings } from "../../config/settings";
5
16
  import { renderDeepInterviewAssistantText } from "../../deep-interview/render-middleware";
@@ -62,6 +73,7 @@ interface AssistantMessageUpdateOptions {
62
73
  type AssistantChildDescriptor = {
63
74
  key: string;
64
75
  component: Component;
76
+ anchorSource?: ViewportAnchorSource;
65
77
  };
66
78
 
67
79
  /**
@@ -81,15 +93,16 @@ export class AssistantMessageComponent extends Container {
81
93
  #contentBlockKeys = new WeakMap<object, string>();
82
94
  #nextContentBlockKey = 0;
83
95
  #reusableChildren = new WeakSet<Component>();
84
-
96
+ #viewportAnchorId?: string;
85
97
  constructor(
86
98
  message?: AssistantMessage,
87
99
  private hideThinkingBlock = false,
88
100
  private readonly onImageUpdate?: () => void,
101
+ viewportAnchorId?: string,
89
102
  ) {
90
103
  super();
104
+ this.#viewportAnchorId = viewportAnchorId;
91
105
 
92
- // Container for text/thinking content
93
106
  this.#contentContainer = new Container();
94
107
  this.addChild(this.#contentContainer);
95
108
 
@@ -98,6 +111,13 @@ export class AssistantMessageComponent extends Container {
98
111
  }
99
112
  }
100
113
 
114
+ #contentAnchorSource(
115
+ index: number,
116
+ kind: "text" | "thinking" | "thinking-hidden",
117
+ ): ViewportAnchorSource | undefined {
118
+ return this.#viewportAnchorId ? { id: `${this.#viewportAnchorId}:content:${index}:${kind}` } : undefined;
119
+ }
120
+
101
121
  override invalidate(): void {
102
122
  super.invalidate();
103
123
  if (this.#lastMessage) {
@@ -288,6 +308,12 @@ export class AssistantMessageComponent extends Container {
288
308
  }
289
309
 
290
310
  #reconcileChildren(descriptors: AssistantChildDescriptor[]): void {
311
+ for (const child of this.#contentContainer.children) this.#contentContainer.setViewportAnchorSource(child, null);
312
+ for (const descriptor of descriptors) {
313
+ if (descriptor.anchorSource && isViewportAnchorSourceRenderer(descriptor.component)) {
314
+ this.#contentContainer.setViewportAnchorSource(descriptor.component, descriptor.anchorSource);
315
+ }
316
+ }
291
317
  const nextChildren = descriptors.map(descriptor => descriptor.component);
292
318
  if (
293
319
  nextChildren.length === this.#contentContainer.children.length &&
@@ -346,6 +372,7 @@ export class AssistantMessageComponent extends Container {
346
372
  descriptors.push({
347
373
  key: `${blockKey}:text`,
348
374
  component: this.#renderTextBlock(content, streaming && i === activeContentIndex),
375
+ anchorSource: this.#contentAnchorSource(i, "text"),
349
376
  });
350
377
  } else if (content.type === "thinking" && content.thinking.trim()) {
351
378
  // Add spacing only when another visible assistant content block follows.
@@ -358,11 +385,13 @@ export class AssistantMessageComponent extends Container {
358
385
  `${blockKey}:thinking-hidden`,
359
386
  () => new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0),
360
387
  ),
388
+ anchorSource: this.#contentAnchorSource(i, "thinking-hidden"),
361
389
  });
362
390
  } else {
363
391
  descriptors.push({
364
392
  key: `${blockKey}:thinking`,
365
393
  component: this.#renderThinkingBlock(content, streaming && i === activeContentIndex),
394
+ anchorSource: this.#contentAnchorSource(i, "thinking"),
366
395
  });
367
396
  }
368
397
  if (visibleContentAfter[i]) {
@@ -6,6 +6,7 @@ import {
6
6
  Container,
7
7
  Ellipsis,
8
8
  ImageProtocol,
9
+ isTerminalGraphicsFallbackActive,
9
10
  type Loader,
10
11
  TERMINAL,
11
12
  Text,
@@ -16,7 +17,12 @@ import {
16
17
  import { sanitizeText } from "@gajae-code/utils";
17
18
  import { theme } from "../../modes/theme/theme";
18
19
  import type { TruncationMeta } from "../../tools/output-meta";
19
- import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
20
+ import {
21
+ containsSixelSequence,
22
+ getSixelLineMask,
23
+ isSixelPassthroughEnabled,
24
+ sanitizeWithOptionalSixelPassthrough,
25
+ } from "../../utils/sixel";
20
26
  import {
21
27
  buildExecutionFrame,
22
28
  buildStatusFooter,
@@ -41,6 +47,7 @@ export class BashExecutionComponent extends Container {
41
47
  #truncation?: TruncationMeta;
42
48
  #expanded = false;
43
49
  #displayDirty = false;
50
+ #displayBuiltWithGraphicsFallback: boolean | undefined;
44
51
  #chunkGate = false;
45
52
  #contentContainer: Container;
46
53
  #headerText: Text;
@@ -135,7 +142,8 @@ export class BashExecutionComponent extends Container {
135
142
  }
136
143
 
137
144
  override render(width: number): string[] {
138
- if (this.#displayDirty) {
145
+ const fallbackActive = isTerminalGraphicsFallbackActive();
146
+ if (this.#displayDirty || this.#displayBuiltWithGraphicsFallback !== fallbackActive) {
139
147
  this.#displayDirty = false;
140
148
  this.#updateDisplay();
141
149
  }
@@ -143,16 +151,16 @@ export class BashExecutionComponent extends Container {
143
151
  }
144
152
 
145
153
  #updateDisplay(): void {
154
+ const fallbackActive = isTerminalGraphicsFallbackActive();
146
155
  const availableLines = this.#outputLines;
147
-
148
- // Apply preview truncation based on expanded state
149
- const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
150
- const hiddenLineCount = availableLines.length - previewLogicalLines.length;
151
156
  const sixelLineMask =
152
- TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
157
+ fallbackActive || (TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled())
153
158
  ? getSixelLineMask(availableLines)
154
159
  : undefined;
155
160
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
161
+ const selectedLines =
162
+ this.#expanded || (hasSixelOutput && !fallbackActive) ? availableLines : availableLines.slice(-PREVIEW_LINES);
163
+ const hiddenLineCount = availableLines.length - selectedLines.length;
156
164
 
157
165
  // Rebuild content container
158
166
  // Detach (not dispose): #headerText and the running #loader are persistent,
@@ -165,15 +173,36 @@ export class BashExecutionComponent extends Container {
165
173
  this.#contentContainer.addChild(this.#headerText);
166
174
 
167
175
  // Output
168
- if (availableLines.length > 0) {
169
- if (this.#expanded || hasSixelOutput) {
170
- const displayText = availableLines
171
- .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
176
+ if (selectedLines.length > 0) {
177
+ if (fallbackActive && hasSixelOutput) {
178
+ const displayLines: string[] = [];
179
+ const selectedStart = availableLines.length - selectedLines.length;
180
+ for (const [selectedIndex, line] of selectedLines.entries()) {
181
+ const index = selectedStart + selectedIndex;
182
+ if (sixelLineMask?.[index]) {
183
+ // Emit one placeholder per visible SIXEL block: at a true sequence
184
+ // start, at the top of a collapsed slice that begins mid-sequence,
185
+ // or when the line itself opens a new sequence.
186
+ const isBlockStart = selectedIndex === 0 || !sixelLineMask[index - 1] || containsSixelSequence(line);
187
+ if (isBlockStart) {
188
+ displayLines.push(theme.fg("muted", "[SIXEL image hidden while IRC sidebar is visible]"));
189
+ }
190
+ } else {
191
+ displayLines.push(theme.fg("muted", line));
192
+ }
193
+ }
194
+ this.#contentContainer.addChild(new Text(`\n${displayLines.join("\n")}`, 1, 0));
195
+ } else if (this.#expanded || hasSixelOutput) {
196
+ const displayText = selectedLines
197
+ .map((line, selectedIndex) => {
198
+ const index = availableLines.length - selectedLines.length + selectedIndex;
199
+ return sixelLineMask?.[index] ? line : theme.fg("muted", line);
200
+ })
172
201
  .join("\n");
173
202
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
174
203
  } else {
175
204
  // Use shared visual truncation utility, recomputed per render width
176
- const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
205
+ const styledOutput = selectedLines.map(line => theme.fg("muted", line)).join("\n");
177
206
  this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
178
207
  }
179
208
  }
@@ -187,10 +216,11 @@ export class BashExecutionComponent extends Container {
187
216
  exitCode: this.#exitCode,
188
217
  truncation: this.#truncation,
189
218
  hiddenLineCount,
190
- suppressHiddenCount: hasSixelOutput,
219
+ suppressHiddenCount: hasSixelOutput && !fallbackActive,
191
220
  });
192
221
  if (footer) this.#contentContainer.addChild(footer);
193
222
  }
223
+ this.#displayBuiltWithGraphicsFallback = fallbackActive;
194
224
  }
195
225
 
196
226
  #clampDisplayLine(line: string): string {
@@ -123,6 +123,11 @@ export class CustomEditor extends Editor {
123
123
  this.#actionKeys.set(action, [...keys]);
124
124
  }
125
125
 
126
+ /** Returns whether a built-in editor action consumes this configured key before custom handlers. */
127
+ hasActionKey(key: KeyId): boolean {
128
+ return [...this.#actionKeys.values()].some(keys => keys.includes(key));
129
+ }
130
+
126
131
  #matchesAction(data: string, action: ConfigurableEditorAction): boolean {
127
132
  const keys = this.#actionKeys.get(action);
128
133
  if (!keys) return false;
@@ -0,0 +1,360 @@
1
+ import {
2
+ type AnimationRegistration,
3
+ buildGajaePixelFrames,
4
+ type Component,
5
+ type Container,
6
+ type GajaePixelFrameName,
7
+ type GajaePixelFrames,
8
+ getCellDimensions,
9
+ ImageProtocol,
10
+ PARA_PARA_STEPS,
11
+ PET_SKINS,
12
+ type PetMode,
13
+ type PetSkinId,
14
+ petBurstDurationMs,
15
+ petBurstFrame,
16
+ registerAnimationCallback,
17
+ TERMINAL,
18
+ type TUI,
19
+ } from "@gajae-code/tui";
20
+ import type { CustomEditor } from "./custom-editor";
21
+
22
+ /** Re-exported from the tui skin registry so widget-relative imports stay valid. */
23
+ export type { PetMode, PetSkinId };
24
+
25
+ /**
26
+ * Empty columns on each side of the pet: an explicit inset from the right edge,
27
+ * with the composer's own right gutter (setRightGutterWidth(1)) as the left gap.
28
+ */
29
+ const PET_SIDE_MARGIN = 1;
30
+ /** Sub-cell drop after the one-row safety lift, preserving a small bottom gap. */
31
+ const PET_SIXEL_DROP_PX = 9;
32
+ /**
33
+ * Kitty sub-cell drop below the one-row safety lift, as a fraction of the live cell
34
+ * height so it scales with the font. `floor` keeps it inside the cell; the value is
35
+ * clamped to the cell height.
36
+ */
37
+ const KITTY_DROP_FRACTION = 0.45;
38
+ const petKittyDropPx = (cellHeightPx: number): number =>
39
+ Math.min(Math.max(0, cellHeightPx - 1), Math.floor(cellHeightPx * KITTY_DROP_FRACTION));
40
+ const PET_RAISE_ROWS = 1;
41
+ /** Working animation: the shared para-para beats looped end to end. */
42
+ const WORK_LOOP_TOTAL = PARA_PARA_STEPS.reduce((sum, [, ms]) => sum + ms, 0);
43
+ /** Random gap between automatic claw flexes (fires while idle AND working). */
44
+ const AUTO_FLEX_MIN_GAP_MS = 12_000;
45
+ const AUTO_FLEX_MAX_GAP_MS = 40_000;
46
+ // Deterministic idle loop: gaze around with a rare visor flicker.
47
+ const IDLE_LOOP: Array<[GajaePixelFrameName, number]> = [
48
+ ["base", 1100],
49
+ ["gazeL", 350],
50
+ ["base", 500],
51
+ ["gazeR", 350],
52
+ ["base", 800],
53
+ ["flicker", 150],
54
+ ];
55
+ const IDLE_LOOP_TOTAL = IDLE_LOOP.reduce((sum, [, ms]) => sum + ms, 0);
56
+
57
+ /**
58
+ * Selector preview: fire the first burst this soon after a skin is previewed, so the
59
+ * pet shows one idle eye-roll (base -> gazeL -> base -> gazeR) and then its signature
60
+ * flex/cry. Live use keeps the random AUTO_FLEX gap; only preview forces this demo.
61
+ */
62
+ const PREVIEW_INTRO_MS = 2300;
63
+
64
+ /**
65
+ * Wraps the composer editor, reserving a right-side area beside it where the
66
+ * real-pixel pet is drawn. The editor just renders narrower; the pet pixels
67
+ * are emitted separately as an absolute-positioned overlay.
68
+ */
69
+ export class PetFramedEditor implements Component {
70
+ #editor: CustomEditor;
71
+ #reserve = 0;
72
+
73
+ constructor(editor: CustomEditor) {
74
+ this.#editor = editor;
75
+ }
76
+
77
+ setReserve(columns: number): void {
78
+ this.#reserve = columns;
79
+ }
80
+
81
+ canFit(width: number): boolean {
82
+ return this.#reserve > 0 && width > this.#reserve + 8;
83
+ }
84
+
85
+ invalidate(): void {
86
+ this.#editor.invalidate?.();
87
+ }
88
+
89
+ render(width: number): string[] {
90
+ if (!this.canFit(width)) {
91
+ return this.#editor.render(width);
92
+ }
93
+ return this.#editor.render(width - this.#reserve);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The gajae pet: a 16x16 real-pixel sprite living in a reserved area beside
99
+ * the composer. It is nearest-neighbor scaled to the two terminal rows occupied
100
+ * by an empty one-line composer and lifted one row so its feet meet the input
101
+ * box's bottom edge.
102
+ *
103
+ * Rendering has two paths that share one payload builder:
104
+ * - a post-render emitter re-draws the sprite after every TUI write (line
105
+ * renders clear the pet cells, so the overlay must be re-applied), and
106
+ * - frame advances write the payload directly to the terminal, because the
107
+ * TUI skips writes entirely when no component line changed.
108
+ *
109
+ * Requires a sixel- or kitty-graphics terminal (`pixelProtocol()`).
110
+ */
111
+ export class GajaePetWidget {
112
+ #ui: TUI;
113
+ #editor: CustomEditor;
114
+ #editorContainer: Container;
115
+ #floorContainer: Container;
116
+ #framedEditor: PetFramedEditor;
117
+ #isWorking: () => boolean;
118
+ #getComposerBottomOffset: () => number;
119
+ #mode: PetMode = "off";
120
+ #pixel: GajaePixelFrames | undefined;
121
+ #frame: GajaePixelFrameName = "base";
122
+ #animation: AnimationRegistration | undefined;
123
+ #flexUntil = 0;
124
+ #nextAutoFlexAt = 0;
125
+ #autoFlexGapMs: [number, number] | null;
126
+ #forcedProtocol: "sixel" | "kitty" | undefined;
127
+ /** Cell metrics the current frames were built for; a change triggers a rebuild. */
128
+ #builtCellW = 0;
129
+ #builtCellH = 0;
130
+
131
+ constructor(options: {
132
+ ui: TUI;
133
+ editor: CustomEditor;
134
+ editorContainer: Container;
135
+ floorContainer: Container;
136
+ isWorking: () => boolean;
137
+ /** Rows rendered below the composer box (pet floor + hook widgets). */
138
+ getComposerBottomOffset: () => number;
139
+ forcePixelProtocol?: "sixel" | "kitty";
140
+ /** Random [min, max] ms between auto-flexes; null disables. */
141
+ autoFlexGapMs?: [number, number] | null;
142
+ }) {
143
+ this.#ui = options.ui;
144
+ this.#editor = options.editor;
145
+ this.#editorContainer = options.editorContainer;
146
+ this.#floorContainer = options.floorContainer;
147
+ this.#framedEditor = new PetFramedEditor(options.editor);
148
+ this.#isWorking = options.isWorking;
149
+ this.#getComposerBottomOffset = options.getComposerBottomOffset;
150
+ this.#forcedProtocol = options.forcePixelProtocol;
151
+ this.#autoFlexGapMs =
152
+ options.autoFlexGapMs === undefined ? [AUTO_FLEX_MIN_GAP_MS, AUTO_FLEX_MAX_GAP_MS] : options.autoFlexGapMs;
153
+ }
154
+
155
+ /** Protocol available for the real-pixel pet, if any. */
156
+ static pixelProtocol(): "sixel" | "kitty" | null {
157
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
158
+ if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
159
+ return null;
160
+ }
161
+
162
+ get mode(): PetMode {
163
+ return this.#mode;
164
+ }
165
+
166
+ get isFlexing(): boolean {
167
+ return performance.now() < this.#flexUntil;
168
+ }
169
+
170
+ setMode(mode: PetMode): void {
171
+ this.#applyMode(mode, true);
172
+ }
173
+
174
+ /** Live preview during a selector: change the sprite without re-mounting the
175
+ * composer editor (that would tear down the open overlay). After a short idle
176
+ * eye-roll it fires the signature burst once (RedGajae flex, BlueGajae para-para
177
+ * then sob) so the selector demos the animation instead of waiting the random gap. */
178
+ previewMode(mode: PetMode): void {
179
+ this.#applyMode(mode, false);
180
+ if (mode !== "off" && this.#autoFlexGapMs) {
181
+ this.#nextAutoFlexAt = performance.now() + PREVIEW_INTRO_MS;
182
+ }
183
+ }
184
+
185
+ #applyMode(mode: PetMode, mountComposer: boolean): void {
186
+ if (mode === this.#mode) return;
187
+
188
+ if (mode === "off") {
189
+ this.#mode = "off";
190
+ this.#animation?.unregister();
191
+ this.#animation = undefined;
192
+ this.#ui.setPostRenderEmitter(undefined);
193
+ this.#floorContainer.clear();
194
+ this.#pixel = undefined;
195
+ this.#framedEditor.setReserve(0);
196
+ if (mountComposer) this.#mountEditor(false);
197
+ this.#ui.requestRender(true);
198
+ return;
199
+ }
200
+
201
+ const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol();
202
+ if (!protocol) return;
203
+ this.#mode = mode;
204
+ this.#frame = "base";
205
+ this.#flexUntil = 0;
206
+ this.#nextAutoFlexAt = 0;
207
+ this.#buildPixel(protocol);
208
+ if (mountComposer) this.#mountEditor(true);
209
+ // The pet overlays the composer's bottom rows; no floor row is reserved, so
210
+ // the composer stays pinned to the terminal bottom.
211
+ this.#floorContainer.clear();
212
+ this.#ui.setPostRenderEmitter(() => this.#overlayPayload());
213
+ this.#animation ??= registerAnimationCallback(now => this.#tick(now), 80);
214
+ this.#ui.requestRender(true);
215
+ }
216
+
217
+ /** (Re)build the encoded frames for the current terminal cell metrics. */
218
+ #buildPixel(protocol: "sixel" | "kitty"): void {
219
+ const cell = getCellDimensions();
220
+ this.#builtCellW = cell.widthPx;
221
+ this.#builtCellH = cell.heightPx;
222
+ const skin: PetSkinId = this.#mode === "off" ? "red" : this.#mode;
223
+ this.#pixel = buildGajaePixelFrames({
224
+ protocol,
225
+ skin,
226
+ cellWidthPx: cell.widthPx,
227
+ cellHeightPx: cell.heightPx,
228
+ targetRows: 2,
229
+ sixelTopPaddingPx: protocol === "sixel" ? PET_SIXEL_DROP_PX : 0,
230
+ kittyCellYOffsetPx: protocol === "kitty" ? petKittyDropPx(cell.heightPx) : 0,
231
+ });
232
+ this.#framedEditor.setReserve(this.#pixel.columns + PET_SIDE_MARGIN);
233
+ }
234
+
235
+ dispose(): void {
236
+ this.#animation?.unregister();
237
+ this.#animation = undefined;
238
+ this.#ui.setPostRenderEmitter(undefined);
239
+ this.#floorContainer.clear();
240
+ this.#framedEditor.setReserve(0);
241
+ this.#mountEditor(false);
242
+ }
243
+
244
+ #mountEditor(framed: boolean): void {
245
+ this.#editorContainer.clear();
246
+ this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor);
247
+ }
248
+
249
+ /** Re-mount the composer editor (framed when a skin is active) after an overlay. */
250
+ remountComposer(): void {
251
+ this.#mountEditor(this.#mode !== "off");
252
+ }
253
+
254
+ #pickFrame(now: number): GajaePixelFrameName {
255
+ const mode = this.#mode;
256
+ if (mode === "off") return "base";
257
+ // Random idle burst → the skin's own animation, driven by its burst descriptor
258
+ // (RedGajae holds a flex; BlueGajae dances the para-para then sobs).
259
+ if (now < this.#flexUntil) {
260
+ const burst = PET_SKINS[mode].burst;
261
+ const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst));
262
+ return petBurstFrame(burst, elapsed, now);
263
+ }
264
+ // Working → loop the shared para-para beats.
265
+ if (this.#isWorking()) {
266
+ let d = now % WORK_LOOP_TOTAL;
267
+ for (const [frame, ms] of PARA_PARA_STEPS) {
268
+ if (d < ms) return frame;
269
+ d -= ms;
270
+ }
271
+ return "base";
272
+ }
273
+ let t = now % IDLE_LOOP_TOTAL;
274
+ for (const [frame, ms] of IDLE_LOOP) {
275
+ if (t < ms) return frame;
276
+ t -= ms;
277
+ }
278
+ return "base";
279
+ }
280
+
281
+ #scheduleAutoFlex(now: number): void {
282
+ if (!this.#autoFlexGapMs) return;
283
+ const [min, max] = this.#autoFlexGapMs;
284
+ this.#nextAutoFlexAt = now + min + Math.random() * Math.max(0, max - min);
285
+ }
286
+
287
+ #tick(now: number): void {
288
+ if (this.#mode === "off" || !this.#pixel) return;
289
+ // A font/zoom change resizes the terminal cells; rebuild the frames so the
290
+ // kitty image and its sub-cell drop match the new cell metrics.
291
+ const cell = getCellDimensions();
292
+ if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) {
293
+ const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol();
294
+ if (protocol) {
295
+ this.#buildPixel(protocol);
296
+ this.#mountEditor(true);
297
+ this.#ui.requestRender(true);
298
+ }
299
+ }
300
+ // Random show-off, both while idle and while working. Each skin's burst runs for
301
+ // its own length (RedGajae a brief flex; BlueGajae a para-para cycle plus sob).
302
+ if (this.#autoFlexGapMs && now >= this.#flexUntil) {
303
+ if (this.#nextAutoFlexAt === 0) {
304
+ this.#scheduleAutoFlex(now);
305
+ } else if (now >= this.#nextAutoFlexAt) {
306
+ const burstMs = petBurstDurationMs(PET_SKINS[this.#mode].burst);
307
+ this.#flexUntil = now + burstMs;
308
+ this.#scheduleAutoFlex(now + burstMs);
309
+ }
310
+ }
311
+ const frame = this.#pickFrame(now);
312
+ if (frame === this.#frame) return;
313
+ this.#frame = frame;
314
+ // Write directly: a frame swap changes no component line, so the TUI
315
+ // would skip the render write (and with it the post-render emitter).
316
+ const payload = this.#overlayPayload(true) ?? "";
317
+ if (payload && this.#ui.terminalAvailable) {
318
+ this.#ui.terminal.write(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`);
319
+ }
320
+ }
321
+
322
+ #petPosition(): { x: number; y: number } | null {
323
+ const pixel = this.#pixel;
324
+ if (!pixel) return null;
325
+ const columns = this.#ui.terminal.columns;
326
+ if (!this.#framedEditor.canFit(columns)) return null;
327
+ const rows = this.#ui.terminal.rows;
328
+ // The sprite is lifted one safety row above the scrolling edge, then dropped
329
+ // back onto the composer's bottom border per protocol (sixel via transparent
330
+ // top padding, kitty via a sub-cell Y offset baked into the frames).
331
+ const composerBottom = rows - this.#getComposerBottomOffset();
332
+ const y = composerBottom - pixel.rows - PET_RAISE_ROWS;
333
+ const x = columns - pixel.columns - PET_SIDE_MARGIN;
334
+ if (y < 0 || x < 0) return null;
335
+ return { x, y };
336
+ }
337
+
338
+ #clearPetPayload(x: number, y: number): string {
339
+ const pixel = this.#pixel;
340
+ if (pixel?.protocol !== "sixel") return "";
341
+ let out = "\x1b[0m";
342
+ for (let row = 0; row < pixel.rasterRows; row++) {
343
+ out += `\x1b[${y + row + 1};${x + 1}H\x1b[${pixel.columns}X`;
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /** Draw escape payload at the pet's absolute position. */
349
+ #overlayPayload(clearPet = false): string | null {
350
+ const pixel = this.#pixel;
351
+ if (!pixel) return null;
352
+ const pos = this.#petPosition();
353
+ if (!pos) return null;
354
+ const { x, y } = pos;
355
+
356
+ let out = clearPet ? this.#clearPetPayload(x, y) : "";
357
+ out += `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`;
358
+ return out;
359
+ }
360
+ }
@@ -69,6 +69,11 @@ export interface HookSelectorOptions {
69
69
  * completion behavior as the main prompt editor.
70
70
  */
71
71
  autocompleteProvider?: AutocompleteProvider;
72
+ /**
73
+ * Maps raw single-key accelerators to option labels. Unset maps preserve
74
+ * the legacy selector key handling exactly.
75
+ */
76
+ acceleratorMap?: Readonly<Record<string, string>>;
72
77
  }
73
78
 
74
79
  class OutlinedList extends Container {
@@ -351,6 +356,7 @@ export class HookSelectorComponent extends Container {
351
356
  #baseHelpText: string;
352
357
  #tui: TUI | undefined;
353
358
  #autocompleteProvider: AutocompleteProvider | undefined;
359
+ #acceleratorMap: Readonly<Record<string, string>> | undefined;
354
360
  constructor(
355
361
  title: string,
356
362
  options: string[],
@@ -375,6 +381,7 @@ export class HookSelectorComponent extends Container {
375
381
  this.#clarificationInput = opts?.clarificationInput;
376
382
  this.#tui = opts?.tui;
377
383
  this.#autocompleteProvider = opts?.autocompleteProvider;
384
+ this.#acceleratorMap = opts?.acceleratorMap;
378
385
 
379
386
  this.addChild(new DynamicBorder());
380
387
  this.addChild(new Spacer(1));
@@ -502,6 +509,11 @@ export class HookSelectorComponent extends Container {
502
509
  this.#handleInputModeKey(keyData, this.#inlineEditor);
503
510
  return;
504
511
  }
512
+ const acceleratedOption = this.#acceleratorMap?.[keyData.toLowerCase()];
513
+ if (acceleratedOption && this.#options.includes(acceleratedOption)) {
514
+ this.#onSelectCallback(acceleratedOption);
515
+ return;
516
+ }
505
517
  if (matchesKey(keyData, "up") || keyData === "k") {
506
518
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
507
519
  this.#updateList();