@gajae-code/coding-agent 0.12.7 → 0.12.10

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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +54 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
package/src/main.ts CHANGED
@@ -57,10 +57,11 @@ import {
57
57
  discoverAuthStorage,
58
58
  } from "./sdk";
59
59
  import type { AgentSession } from "./session/agent-session";
60
-
60
+ import { SessionMigrationBusyError } from "./session/internal/session-open-errors";
61
61
  import {
62
62
  type ResumeSessionIdentity,
63
63
  resolveResumableSession,
64
+ SessionArtifactCapacityError,
64
65
  type SessionDestination,
65
66
  type SessionDirectoryMigrationPolicy,
66
67
  type SessionInfo,
@@ -562,6 +563,22 @@ export function resolveManagedAgentDirForScope(_cwd: string): string {
562
563
  return getAgentDir();
563
564
  }
564
565
  export const BARE_RESUME_OPEN_ERROR = "Could not open the selected session. Use --resume <id>.";
566
+ const SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE =
567
+ "The selected legacy session's artifacts exceed the supported migration capacity. Archive or remove only that legacy session's artifacts after confirming they are no longer needed, then retry.";
568
+
569
+ function operatorFacingSessionOpenMessage(value: unknown): string | undefined {
570
+ const code =
571
+ value instanceof SessionArtifactCapacityError
572
+ ? value.code
573
+ : value instanceof SessionMigrationBusyError
574
+ ? value.code
575
+ : typeof value === "string"
576
+ ? value
577
+ : undefined;
578
+ if (code === "artifact_capacity_exceeded") return SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE;
579
+ if (code === "migration_busy") return new SessionMigrationBusyError().message;
580
+ return undefined;
581
+ }
565
582
 
566
583
  function isBareResume(parsed: Args): boolean {
567
584
  return (
@@ -1072,6 +1089,7 @@ type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promis
1072
1089
 
1073
1090
  export interface RunRootCommandDependencies {
1074
1091
  createAgentSession?: typeof createAgentSession;
1092
+ createSessionManager?: typeof createSessionManager;
1075
1093
  discoverAuthStorage?: typeof discoverAuthStorage;
1076
1094
  runAcpMode?: (options?: { agentDir?: string }) => Promise<void>;
1077
1095
  settings?: Settings;
@@ -1208,13 +1226,13 @@ export async function runRootCommand(
1208
1226
  undefined,
1209
1227
  resumeMigrationPolicy,
1210
1228
  );
1211
- } catch {
1212
- process.stderr.write(`${BARE_RESUME_OPEN_ERROR}\n`);
1229
+ } catch (error) {
1230
+ process.stderr.write(`${operatorFacingSessionOpenMessage(error) ?? BARE_RESUME_OPEN_ERROR}\n`);
1213
1231
  if (!deps.suppressProcessExit) process.exitCode = 1;
1214
1232
  return;
1215
1233
  }
1216
1234
  if (opened.kind === "error") {
1217
- process.stderr.write(`${BARE_RESUME_OPEN_ERROR}\n`);
1235
+ process.stderr.write(`${operatorFacingSessionOpenMessage(opened.reason) ?? BARE_RESUME_OPEN_ERROR}\n`);
1218
1236
  if (!deps.suppressProcessExit) process.exitCode = 1;
1219
1237
  return;
1220
1238
  }
@@ -1381,9 +1399,27 @@ export async function runRootCommand(
1381
1399
 
1382
1400
  // Create session manager based on CLI flags. A bare resume was strictly opened
1383
1401
  // before startup discovery, so it never reaches create-or-open behavior here.
1384
- const sessionManager =
1385
- bareResumeSessionManager ??
1386
- (await logger.time("createSessionManager", createSessionManager, parsedArgs, cwd, settingsInstance));
1402
+ let sessionManager: SessionManager | undefined = bareResumeSessionManager;
1403
+ if (!sessionManager) {
1404
+ try {
1405
+ sessionManager = await logger.time(
1406
+ "createSessionManager",
1407
+ deps.createSessionManager ?? createSessionManager,
1408
+ parsedArgs,
1409
+ cwd,
1410
+ settingsInstance,
1411
+ );
1412
+ } catch (error) {
1413
+ const message = operatorFacingSessionOpenMessage(error);
1414
+ if (!message) throw error;
1415
+ process.stderr.write(`${message}\n`);
1416
+ if (!deps.suppressProcessExit) process.exitCode = 1;
1417
+ authStorage.close();
1418
+ stopThemeWatcher();
1419
+ await postmortem.cleanup();
1420
+ return;
1421
+ }
1422
+ }
1387
1423
 
1388
1424
  // Restore the resumed session's working directory so the HUD branch, the
1389
1425
  // project path, and the agent's tools all match where the session was
@@ -187,21 +187,21 @@ combine, or substitute them.
187
187
 
188
188
  | State ID | Required condition represented |
189
189
  | --- | --- |
190
- | `home-unconfigured` | No configured notification destination. |
191
- | `home-configured-inactive` | Credentials/configuration exist; current session is inactive. |
190
+ | `home-unconfigured` | No complete notification provider. |
191
+ | `home-configured-inactive` | Provider configuration and desired intent exist; current session is inactive. |
192
192
  | `home-runtime-active` | Current session endpoint is active. |
193
193
  | `home-local-off` | Current session is explicitly locally disabled. |
194
- | `home-env-off` | Environment hard-off suppresses the surface/runtime. |
195
- | `home-env-on` | Explicit environment opt-in enables the current session. |
196
- | `home-discord-only` | Global Discord configuration without Telegram setup. |
197
- | `home-slack-only` | Global Slack configuration without Telegram setup. |
198
- | `setup-provider` | Provider choice is focused. |
194
+ | `home-env-off` | Process-level environment hard-off suppresses the surface/runtime. |
195
+ | `home-env-on` | Explicit environment opt-in enables automatic current-session admission. |
196
+ | `home-discord-only` | Discord is complete, desired on, and effective without Telegram. |
197
+ | `home-slack-only` | Slack is complete, desired on, and effective without Telegram. |
198
+ | `setup-provider` | First-class Telegram, Discord, and Slack provider choice is focused. |
199
199
  | `setup-chat-entry` | Telegram chat ID field is focused. |
200
200
  | `setup-token-entry` | Masked Telegram token field is focused. |
201
201
  | `setup-validating` | Token/destination validation is pending. |
202
202
  | `setup-threaded-warning` | Threaded mode compatibility warning is visible. |
203
203
  | `setup-pairing` | Cancellable private-chat pairing/discovery is pending. |
204
- | `setup-review` | Sanitized setup choices await explicit save. |
204
+ | `setup-review` | Sanitized provider, secret action, desired intent, and destination await explicit save. |
205
205
  | `saving` | Durable atomic save is in progress and guarded. |
206
206
  | `health-probing` | Non-cancellable health probe is in progress and guarded. |
207
207
  | `health-ok` | Health report is successful. |
@@ -211,12 +211,12 @@ combine, or substitute them.
211
211
  | `recovering` | Recovery action is in progress and guarded. |
212
212
  | `reconnecting` | Reconnect action is in progress and guarded. |
213
213
  | `navigation-locked` | A guarded operation explains why Tab/Escape cannot leave. |
214
- | `confirmation-remove` | Adapter-local Telegram removal awaits confirmation. |
215
- | `confirmation-disable` | Global disable awaits confirmation. |
214
+ | `confirmation-remove` | Adapter-local Telegram removal awaits confirmation; sibling/global state is preserved. |
215
+ | `confirmation-disable` | Global disable awaits confirmation and preserves provider credentials/intent. |
216
216
  | `success` | A completed operation has concise success copy. |
217
217
  | `preferences` | Notification preferences are visible and editable. |
218
218
  | `error` | A sanitized operation failure has recovery guidance. |
219
- | `foreign-blocked` | A foreign/unknown daemon identity blocks activation safely. |
219
+ | `foreign-blocked` | Telegram is excluded from the shared endpoint while effective chat siblings use isolated discovery. |
220
220
  | `blocked-restore-retain` | A blocked post-save identity race requires Restore or Retain before navigation. |
221
221
  | `cancellation` | A cancellable setup/pairing action was cancelled and restored. |
222
222
  | `narrow-cjk` | Narrow localized CJK content exercises semantic line wrapping. |
@@ -9,6 +9,10 @@ import { theme } from "../../../modes/theme/theme";
9
9
  import { shortenPath } from "../../../tools/render-utils";
10
10
  import type { Extension, ExtensionState } from "./types";
11
11
 
12
+ function renderSeparator(width: number): string {
13
+ return theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.max(0, Math.min(width - 2, 40))));
14
+ }
15
+
12
16
  export class InspectorPanel implements Component {
13
17
  #extension: Extension | null = null;
14
18
 
@@ -106,7 +110,7 @@ export class InspectorPanel implements Component {
106
110
  #renderFilePreview(raw: unknown, width: number): string[] {
107
111
  const lines: string[] = [];
108
112
  lines.push(theme.fg("muted", "Preview:"));
109
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
113
+ lines.push(renderSeparator(width));
110
114
 
111
115
  const content = this.#getContextFileContent(raw);
112
116
  if (!content) {
@@ -164,7 +168,7 @@ export class InspectorPanel implements Component {
164
168
  #renderToolArgs(raw: unknown, width: number): string[] {
165
169
  const lines: string[] = [];
166
170
  lines.push(theme.fg("muted", "Arguments:"));
167
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
171
+ lines.push(renderSeparator(width));
168
172
 
169
173
  try {
170
174
  const tool = raw as any;
@@ -203,7 +207,7 @@ export class InspectorPanel implements Component {
203
207
  #renderSkillContent(raw: unknown, width: number): string[] {
204
208
  const lines: string[] = [];
205
209
  lines.push(theme.fg("muted", "Instruction:"));
206
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
210
+ lines.push(renderSeparator(width));
207
211
 
208
212
  try {
209
213
  const skill = raw as any;
@@ -232,7 +236,7 @@ export class InspectorPanel implements Component {
232
236
  #renderMcpDetails(raw: unknown, width: number): string[] {
233
237
  const lines: string[] = [];
234
238
  lines.push(theme.fg("muted", "Connection:"));
235
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
239
+ lines.push(renderSeparator(width));
236
240
 
237
241
  try {
238
242
  const mcp = raw as any;
@@ -271,7 +275,7 @@ export class InspectorPanel implements Component {
271
275
  // Show trigger pattern if present
272
276
  if (ext.trigger) {
273
277
  lines.push(theme.fg("muted", "Trigger:"));
274
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
278
+ lines.push(renderSeparator(width));
275
279
  lines.push(` ${theme.fg("accent", ext.trigger)}`);
276
280
  lines.push("");
277
281
  }
@@ -47,6 +47,21 @@ export interface HookSelectorOptions {
47
47
  */
48
48
  wrapFocused?: boolean;
49
49
  scrollTitleRows?: number;
50
+ /**
51
+ * Maximum visible rows for the inline editor in bounded scroll-title selectors.
52
+ * The editor keeps the full draft and scrolls within this height.
53
+ */
54
+ inlineEditorMaxHeight?: number;
55
+ /**
56
+ * Maximum visible autocomplete rows for the inline editor in a bounded selector.
57
+ * The Editor retains its normal minimum and scroll indicator behavior.
58
+ */
59
+ inlineAutocompleteMaxVisible?: number;
60
+ /**
61
+ * Compact bounded-selector chrome while the inline editor is active.
62
+ * Used only when the terminal cannot fit the normal editor and dropdown rows.
63
+ */
64
+ compactInlineInput?: boolean;
50
65
  /**
51
66
  * Inline free-text entry for the option with this label (e.g. the ask
52
67
  * tool's "Other (type your own)"). Selecting it keeps the title and option
@@ -103,6 +118,7 @@ class ScrollableTitle extends Container {
103
118
  #maxRows: number;
104
119
  #scrollOffset = 0;
105
120
  #lastMaxScrollOffset = 0;
121
+ #lastRenderWidth?: number;
106
122
 
107
123
  constructor(title: string, maxRows: number) {
108
124
  super();
@@ -110,9 +126,15 @@ class ScrollableTitle extends Container {
110
126
  this.#markdown = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
111
127
  }
112
128
 
113
- setText(text: string): void {
129
+ setText(text: string, resetScroll = true): void {
114
130
  this.#markdown.setText(text);
115
- this.#scrollOffset = 0;
131
+ if (resetScroll) this.#scrollOffset = 0;
132
+ this.invalidate();
133
+ }
134
+ setMaxRows(maxRows: number): void {
135
+ const wasAtBottom = this.#lastMaxScrollOffset > 0 && this.#scrollOffset >= this.#lastMaxScrollOffset;
136
+ this.#maxRows = Math.max(1, Math.floor(maxRows));
137
+ if (wasAtBottom) this.#scrollOffset = Number.MAX_SAFE_INTEGER;
116
138
  this.invalidate();
117
139
  }
118
140
 
@@ -125,7 +147,30 @@ class ScrollableTitle extends Container {
125
147
  }
126
148
 
127
149
  render(width: number): string[] {
128
- const lines = this.#markdown.render(width);
150
+ const wasAtBottom = this.#lastMaxScrollOffset > 0 && this.#scrollOffset >= this.#lastMaxScrollOffset;
151
+ let lines: string[];
152
+ if (this.#lastRenderWidth !== undefined && this.#lastRenderWidth !== width) {
153
+ const previous = this.#markdown.renderWithViewportAnchorSource(this.#lastRenderWidth, { id: "title" });
154
+ const previousOffset = Math.min(this.#scrollOffset, previous.anchors.length - 1);
155
+ const previousAnchor =
156
+ previous.anchors[previousOffset] ??
157
+ previous.anchors.slice(previousOffset + 1).find(anchor => anchor !== null) ??
158
+ previous.anchors.slice(0, previousOffset).findLast(anchor => anchor !== null);
159
+ const reflowed = this.#markdown.renderWithViewportAnchorSource(width, { id: "title" });
160
+ lines = reflowed.lines;
161
+ if (previousAnchor) {
162
+ const anchoredLine = reflowed.anchors.findIndex(
163
+ anchor =>
164
+ anchor !== null &&
165
+ anchor.graphemeStart <= previousAnchor.graphemeStart &&
166
+ previousAnchor.graphemeStart < anchor.graphemeEnd,
167
+ );
168
+ if (anchoredLine >= 0) this.#scrollOffset = anchoredLine;
169
+ }
170
+ } else {
171
+ lines = this.#markdown.render(width);
172
+ }
173
+ this.#lastRenderWidth = width;
129
174
  if (lines.length <= this.#maxRows) {
130
175
  this.#lastMaxScrollOffset = 0;
131
176
  this.#scrollOffset = 0;
@@ -137,22 +182,17 @@ class ScrollableTitle extends Container {
137
182
  this.#lastMaxScrollOffset = maxScrollOffset;
138
183
  this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
139
184
 
140
- const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
141
- const indicator =
142
- this.#scrollOffset === 0
143
- ? theme.fg("dim", " PgDn↓")
144
- : this.#scrollOffset >= maxScrollOffset
145
- ? theme.fg("dim", " PgUp↑")
146
- : theme.fg("dim", " PgUp/PgDn↕");
147
- const lastIndex = visibleLines.length - 1;
148
- const availableWidth = Math.max(1, width - visibleWidth(indicator));
149
- const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth);
150
- visibleLines[lastIndex] = `${fittedLine}${indicator}`;
151
- return visibleLines;
185
+ return lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
152
186
  }
153
187
 
188
+ if (wasAtBottom) {
189
+ // The previous render may have reached the bottom after converging
190
+ // away the bottom indicator. Re-anchor before recomputing indicators
191
+ // so a timer repaint cannot resurrect a stale `▼ more` row.
192
+ this.#scrollOffset = Math.max(0, lines.length - (this.#maxRows - 1));
193
+ }
154
194
  let showTopIndicator = this.#scrollOffset > 0;
155
- let showBottomIndicator = true;
195
+ let showBottomIndicator = !wasAtBottom;
156
196
  let contentRows = 1;
157
197
  let maxScrollOffset = 0;
158
198
 
@@ -234,12 +274,14 @@ class FocusAwareList extends Container {
234
274
  );
235
275
  const focusedWrappedSegments = wrapTextWithAnsi(focusedLabel, availableLabelWidth);
236
276
 
237
- // Reserve one row for the option position marker only when the focused
238
- // block itself must be compacted. Moderate focused labels keep the legacy
239
- // wrap-focused behavior and spend the full viewport on label context.
277
+ // Reserve one row when a non-full focused block and its sibling window
278
+ // cannot both fit. A full focused block keeps its existing priority.
240
279
  const totalOptions = this.#options.length;
241
280
  const mustCompactFocused = focusedWrappedSegments.length > this.#maxVisibleRows;
242
- const positionMarkerSlot = mustCompactFocused && totalOptions > 1 ? 1 : 0;
281
+ const mustMarkClippedSiblings =
282
+ focusedWrappedSegments.length < this.#maxVisibleRows &&
283
+ focusedWrappedSegments.length + totalOptions - 1 > this.#maxVisibleRows;
284
+ const positionMarkerSlot = (mustCompactFocused || mustMarkClippedSiblings) && totalOptions > 1 ? 1 : 0;
243
285
  const focusedBudget = Math.max(1, this.#maxVisibleRows - positionMarkerSlot);
244
286
  const focusedSegments = this.#capFocusedSegments(focusedWrappedSegments, focusedBudget, availableLabelWidth);
245
287
  const focusedRows = Math.max(1, focusedSegments.length);
@@ -296,7 +338,7 @@ class FocusAwareList extends Container {
296
338
  if (rows.length <= budget) return rows;
297
339
 
298
340
  if (budget === 1) {
299
- return [truncateToWidth(`… ${rows.length - 1} wrapped rows omitted …`, availableLabelWidth)];
341
+ return [truncateToWidth(rows[0] ?? "", availableLabelWidth)];
300
342
  }
301
343
 
302
344
  if (budget === 2) {
@@ -347,6 +389,7 @@ export class HookSelectorComponent extends Container {
347
389
  #wrapFocused: boolean;
348
390
  #outline: boolean;
349
391
  #scrollTitleRows: number | undefined;
392
+ #inlineEditorMaxHeight: number | undefined;
350
393
  #customInput: { optionLabel: string; onSubmit: (text: string) => void } | undefined;
351
394
  #clarificationInput: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
352
395
  #activeInput: { onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
@@ -357,6 +400,10 @@ export class HookSelectorComponent extends Container {
357
400
  #tui: TUI | undefined;
358
401
  #autocompleteProvider: AutocompleteProvider | undefined;
359
402
  #acceleratorMap: Readonly<Record<string, string>> | undefined;
403
+ #inlineAutocompleteMaxVisible: number | undefined;
404
+ #compactInlineInput: boolean;
405
+ #titleSpacer: Spacer;
406
+ #inputSpacer: Spacer;
360
407
  constructor(
361
408
  title: string,
362
409
  options: string[],
@@ -368,7 +415,6 @@ export class HookSelectorComponent extends Container {
368
415
 
369
416
  this.#options = options;
370
417
  this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
371
- this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
372
418
  this.#onSelectCallback = onSelect;
373
419
  this.#onCancelCallback = onCancel;
374
420
  this.#baseTitle = title;
@@ -382,13 +428,21 @@ export class HookSelectorComponent extends Container {
382
428
  this.#tui = opts?.tui;
383
429
  this.#autocompleteProvider = opts?.autocompleteProvider;
384
430
  this.#acceleratorMap = opts?.acceleratorMap;
431
+ this.#inlineAutocompleteMaxVisible =
432
+ opts?.inlineAutocompleteMaxVisible === undefined
433
+ ? undefined
434
+ : Math.max(3, Math.floor(opts.inlineAutocompleteMaxVisible));
435
+ this.#compactInlineInput = opts?.compactInlineInput === true;
385
436
 
386
437
  this.addChild(new DynamicBorder());
387
438
  this.addChild(new Spacer(1));
388
439
 
389
440
  const scrollTitleRows =
390
441
  opts?.scrollTitleRows === undefined ? undefined : Math.max(1, Math.floor(opts.scrollTitleRows));
442
+ this.#maxVisible = Math.max(scrollTitleRows === undefined ? 3 : 1, opts?.maxVisible ?? 12);
391
443
  this.#scrollTitleRows = scrollTitleRows;
444
+ this.#inlineEditorMaxHeight =
445
+ opts?.inlineEditorMaxHeight === undefined ? undefined : Math.max(1, Math.floor(opts.inlineEditorMaxHeight));
392
446
  if (scrollTitleRows === undefined) {
393
447
  this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
394
448
  } else {
@@ -396,13 +450,20 @@ export class HookSelectorComponent extends Container {
396
450
  this.#titleComponent = this.#scrollableTitle;
397
451
  }
398
452
  this.addChild(this.#titleComponent);
399
- this.addChild(new Spacer(1));
453
+ this.#titleSpacer = new Spacer(1);
454
+ this.addChild(this.#titleSpacer);
400
455
 
401
456
  if (opts?.timeout && opts.timeout > 0 && opts.tui) {
402
457
  this.#countdown = new CountdownTimer(
403
458
  opts.timeout,
404
459
  opts.tui,
405
- s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
460
+ s => {
461
+ if (this.#scrollableTitle) {
462
+ this.#scrollableTitle.setText(`${this.#baseTitle} (${s}s)`, false);
463
+ } else {
464
+ this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`);
465
+ }
466
+ },
406
467
  () => {
407
468
  opts?.onTimeout?.();
408
469
  // Auto-select current option on timeout (typically the first/recommended option)
@@ -431,7 +492,8 @@ export class HookSelectorComponent extends Container {
431
492
  }
432
493
  this.#inputArea = new Container();
433
494
  this.addChild(this.#inputArea);
434
- this.addChild(new Spacer(1));
495
+ this.#inputSpacer = new Spacer(1);
496
+ this.addChild(this.#inputSpacer);
435
497
  this.#baseHelpText = opts?.helpText ?? "up/down navigate enter select esc cancel";
436
498
  this.#helpTextComponent = new Text(theme.fg("dim", this.#baseHelpText), 1, 0);
437
499
  this.addChild(this.#helpTextComponent);
@@ -444,6 +506,38 @@ export class HookSelectorComponent extends Container {
444
506
  hasActiveInlineInput(): boolean {
445
507
  return this.#inlineEditor !== undefined;
446
508
  }
509
+ /**
510
+ * Update the bounded selector's title/list row budgets after a terminal resize.
511
+ * The existing title scroll offset, option focus, and inline editor remain intact.
512
+ */
513
+ setLayoutBudget(
514
+ maxVisible: number,
515
+ scrollTitleRows: number,
516
+ inlineEditorMaxHeight = this.#inlineEditorMaxHeight,
517
+ inlineAutocompleteMaxVisible = this.#inlineAutocompleteMaxVisible,
518
+ compactInlineInput = this.#compactInlineInput,
519
+ ): void {
520
+ if (!this.#scrollableTitle) return;
521
+ const titleRows = Math.max(1, Math.floor(scrollTitleRows));
522
+ this.#maxVisible = Math.max(1, Math.floor(maxVisible));
523
+ this.#scrollTitleRows = titleRows;
524
+ this.#inlineEditorMaxHeight =
525
+ inlineEditorMaxHeight === undefined ? undefined : Math.max(1, Math.floor(inlineEditorMaxHeight));
526
+ this.#inlineAutocompleteMaxVisible =
527
+ inlineAutocompleteMaxVisible === undefined ? undefined : Math.max(3, Math.floor(inlineAutocompleteMaxVisible));
528
+ this.#compactInlineInput = compactInlineInput;
529
+ if (this.#inlineEditor) {
530
+ this.#inlineEditor.setMaxHeight(this.#inlineEditorMaxHeight);
531
+ if (this.#inlineAutocompleteMaxVisible !== undefined) {
532
+ this.#inlineEditor.setAutocompleteMaxVisible(this.#inlineAutocompleteMaxVisible);
533
+ }
534
+ this.#inlineEditor.setAutocompleteProvider(this.#compactInlineInput ? undefined : this.#autocompleteProvider);
535
+ }
536
+ this.#setCompactInputSpacing(this.#inlineEditor !== undefined && this.#compactInlineInput);
537
+ this.#scrollableTitle.setMaxRows(titleRows);
538
+ this.#updateList();
539
+ this.invalidate();
540
+ }
447
541
 
448
542
  #updateList(): void {
449
543
  if (this.#wrapFocused && this.#focusAwareList) {
@@ -489,21 +583,24 @@ export class HookSelectorComponent extends Container {
489
583
  // Reset countdown on any interaction
490
584
  this.#countdown?.reset();
491
585
 
492
- if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageUp")) {
493
- this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
494
- return;
495
- }
496
- if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageDown")) {
497
- this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
498
- return;
499
- }
500
- if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+u")) {
501
- this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
502
- return;
503
- }
504
- if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+d")) {
505
- this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
506
- return;
586
+ if (this.#scrollTitleRows !== undefined && !this.#inlineEditor?.isAutocompleteOpen()) {
587
+ const titlePageRows = Math.max(1, this.#scrollTitleRows - 2);
588
+ if (matchesKey(keyData, "pageUp")) {
589
+ this.#scrollableTitle?.scrollBy(-titlePageRows);
590
+ return;
591
+ }
592
+ if (matchesKey(keyData, "pageDown")) {
593
+ this.#scrollableTitle?.scrollBy(titlePageRows);
594
+ return;
595
+ }
596
+ if (!this.#inlineEditor && matchesKey(keyData, "ctrl+u")) {
597
+ this.#scrollableTitle?.scrollBy(-titlePageRows);
598
+ return;
599
+ }
600
+ if (!this.#inlineEditor && matchesKey(keyData, "ctrl+d")) {
601
+ this.#scrollableTitle?.scrollBy(titlePageRows);
602
+ return;
603
+ }
507
604
  }
508
605
  if (this.#inlineEditor) {
509
606
  this.#handleInputModeKey(keyData, this.#inlineEditor);
@@ -573,6 +670,10 @@ export class HookSelectorComponent extends Container {
573
670
  editor.handleInput(keyData);
574
671
  }
575
672
 
673
+ #setCompactInputSpacing(compact: boolean): void {
674
+ this.#titleSpacer.setLines(compact ? 0 : 1);
675
+ this.#inputSpacer.setLines(compact ? 0 : 1);
676
+ }
576
677
  #enterInputMode(input: { onSubmit: (text: string) => void; allowEmpty?: boolean }): void {
577
678
  if (this.#inlineEditor) return;
578
679
  this.#activeInput = input;
@@ -581,28 +682,37 @@ export class HookSelectorComponent extends Container {
581
682
  if (this.#countdown) {
582
683
  this.#countdown.dispose();
583
684
  this.#countdown = undefined;
584
- this.#titleComponent.setText(this.#baseTitle);
685
+ if (this.#scrollableTitle) {
686
+ this.#scrollableTitle.setText(this.#baseTitle, false);
687
+ } else {
688
+ this.#titleComponent.setText(this.#baseTitle);
689
+ }
585
690
  }
586
691
  const editor = new Editor(getEditorTheme());
587
692
  editor.setBorderVisible(false);
588
693
  editor.setPromptGutter("> ");
589
694
  editor.disableSubmit = true;
695
+ editor.setMaxHeight(this.#inlineEditorMaxHeight);
696
+ if (this.#inlineAutocompleteMaxVisible !== undefined) {
697
+ editor.setAutocompleteMaxVisible(this.#inlineAutocompleteMaxVisible);
698
+ }
590
699
  // Mark the inline editor focused only when mirroring the app's hardware-cursor
591
700
  // mode, so it emits CURSOR_MARKER at the input caret for IME preedit anchoring
592
701
  // without changing legacy non-hardware-cursor layout.
593
702
  const useTerminalCursor = this.#tui?.getShowHardwareCursor() ?? false;
594
703
  editor.focused = useTerminalCursor;
595
704
  editor.setUseTerminalCursor(useTerminalCursor);
596
- if (this.#autocompleteProvider) {
705
+ if (this.#autocompleteProvider && !this.#compactInlineInput) {
597
706
  editor.setAutocompleteProvider(this.#autocompleteProvider);
598
707
  }
599
708
  this.#inlineEditor = editor;
600
- this.#inputArea.addChild(new Spacer(1));
709
+ this.#setCompactInputSpacing(this.#compactInlineInput);
710
+ if (!this.#compactInlineInput) this.#inputArea.addChild(new Spacer(1));
601
711
  this.#inputArea.addChild(editor);
602
712
  const helpText =
603
713
  this.#scrollTitleRows === undefined
604
714
  ? "enter submit esc back to options ctrl+g external editor"
605
- : "enter submit esc back to options PgUp/PgDn: question · Wheel: transcript";
715
+ : "enter submit esc back to options ctrl+g external editor PgUp/PgDn: question · Wheel: transcript";
606
716
  this.#helpTextComponent.setText(theme.fg("dim", helpText));
607
717
  this.invalidate();
608
718
  }
@@ -612,6 +722,7 @@ export class HookSelectorComponent extends Container {
612
722
  this.#inlineEditor = undefined;
613
723
  this.#activeInput = undefined;
614
724
  this.#inputArea.clear();
725
+ this.#setCompactInputSpacing(false);
615
726
  this.#helpTextComponent.setText(theme.fg("dim", this.#baseHelpText));
616
727
  this.invalidate();
617
728
  }