@gajae-code/coding-agent 0.9.1 → 0.9.2

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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  14. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  15. package/dist/types/modes/types.d.ts +1 -0
  16. package/dist/types/notifications/config-commands.d.ts +29 -0
  17. package/dist/types/notifications/index.d.ts +12 -1
  18. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  19. package/dist/types/notifications/threaded-render.d.ts +2 -0
  20. package/dist/types/notifications/topic-registry.d.ts +4 -5
  21. package/dist/types/runtime-credential-selector.d.ts +7 -0
  22. package/dist/types/sdk.d.ts +7 -1
  23. package/dist/types/session/agent-session.d.ts +6 -3
  24. package/dist/types/tools/index.d.ts +1 -0
  25. package/dist/types/tools/skill-discovery.d.ts +40 -0
  26. package/package.json +7 -7
  27. package/src/cli/args.ts +8 -0
  28. package/src/cli/notify-cli.ts +27 -1
  29. package/src/cli.ts +5 -0
  30. package/src/commands/launch.ts +5 -0
  31. package/src/commands/notify.ts +2 -2
  32. package/src/config/model-profile-activation.ts +23 -7
  33. package/src/config/model-registry.ts +21 -4
  34. package/src/config/model-resolver.ts +5 -16
  35. package/src/config/settings-schema.ts +3 -2
  36. package/src/config/settings.ts +24 -1
  37. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  38. package/src/gjc-runtime/launch-tmux.ts +72 -12
  39. package/src/gjc-runtime/psmux-detect.ts +17 -2
  40. package/src/gjc-runtime/team-runtime.ts +35 -3
  41. package/src/main.ts +26 -3
  42. package/src/modes/components/settings-selector.ts +3 -3
  43. package/src/modes/components/thinking-selector.ts +79 -18
  44. package/src/modes/controllers/input-controller.ts +50 -9
  45. package/src/modes/controllers/selector-controller.ts +7 -5
  46. package/src/modes/types.ts +1 -0
  47. package/src/modes/utils/ui-helpers.ts +48 -15
  48. package/src/notifications/config-commands.ts +70 -0
  49. package/src/notifications/index.ts +156 -9
  50. package/src/notifications/rich-render.ts +1 -0
  51. package/src/notifications/telegram-daemon-cli.ts +13 -5
  52. package/src/notifications/telegram-daemon.ts +87 -4
  53. package/src/notifications/threaded-render.ts +14 -0
  54. package/src/notifications/topic-registry.ts +9 -8
  55. package/src/prompts/tools/skill-discovery.md +13 -0
  56. package/src/runtime-credential-selector.ts +35 -0
  57. package/src/sdk.ts +68 -5
  58. package/src/session/agent-session.ts +42 -18
  59. package/src/system-prompt.ts +4 -6
  60. package/src/tools/index.ts +4 -0
  61. package/src/tools/skill-discovery.ts +73 -0
  62. package/src/tools/skill.ts +15 -2
@@ -158,11 +158,43 @@ export interface GjcTmuxProfileContext {
158
158
  version?: string | null;
159
159
  }
160
160
 
161
+ function tmuxExitMarkerPath(sessionStateFile: string): string {
162
+ return path.join(path.dirname(sessionStateFile), "tmux-exit.json");
163
+ }
164
+
165
+ function buildPosixTmuxExitMarkerPrefix(markerPath: string): string {
166
+ const markerDir = path.dirname(markerPath);
167
+ return [
168
+ `__gjc_tmux_exit_marker=${shellQuote(markerPath)}`,
169
+ "__gjc_tmux_write_exit_marker() { __gjc_tmux_status=$?",
170
+ "__gjc_tmux_ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)",
171
+ `mkdir -p ${shellQuote(markerDir)} 2>/dev/null || true`,
172
+ 'printf \'{"schema_version":1,"source":"tmux_inner_shell","ended_at":"%s","exit_code":%s}\\n\' "$__gjc_tmux_ended_at" "$__gjc_tmux_status" > "$__gjc_tmux_exit_marker" 2>/dev/null || true',
173
+ "}",
174
+ "trap __gjc_tmux_write_exit_marker EXIT",
175
+ ].join("; ");
176
+ }
177
+
178
+ function buildPowerShellTmuxExitMarkerFinally(markerPath: string): string {
179
+ const markerDir = path.win32.dirname(markerPath);
180
+ return [
181
+ "} finally {",
182
+ "\ttry {",
183
+ `\t\tNew-Item -ItemType Directory -Force -Path ${powershellQuote(markerDir)} -ErrorAction Stop | Out-Null`,
184
+ "\t\t$__gjcTmuxExitMarker = @{ schema_version = 1; source = 'tmux_inner_shell'; ended_at = (Get-Date).ToUniversalTime().ToString('o'); exit_code = $__gjcTmuxExitCode } | ConvertTo-Json -Compress",
185
+ `\t\tSet-Content -LiteralPath ${powershellQuote(markerPath)} -Value $__gjcTmuxExitMarker -Encoding UTF8 -ErrorAction Stop`,
186
+ "\t} catch {",
187
+ "\t}",
188
+ "}",
189
+ ].join("\n");
190
+ }
191
+
161
192
  interface CommandResolutionContext {
162
193
  cwd: string;
163
194
  argv: string[];
164
195
  execPath: string;
165
196
  extraEnv?: Record<string, string>;
197
+ tmuxExitMarkerPath?: string;
166
198
  platform?: NodeJS.Platform;
167
199
  }
168
200
 
@@ -286,7 +318,17 @@ function buildWindowsPowerShellInnerCommand(context: CommandResolutionContext, r
286
318
  const innerArgs = stripRootTmuxFlag(rawArgs).map(powershellQuote).join(" ");
287
319
  const invocation = `& ${resolvedCommand} ${innerArgs}`;
288
320
  const exitLine = "if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 }";
289
- const script = [...envLines, invocation, exitLine].join("\n");
321
+ const script = context.tmuxExitMarkerPath
322
+ ? [
323
+ ...envLines,
324
+ "$__gjcTmuxExitCode = 1",
325
+ "try {",
326
+ `\t${invocation}`,
327
+ "\tif ($null -ne $LASTEXITCODE) { $__gjcTmuxExitCode = $LASTEXITCODE } else { $__gjcTmuxExitCode = 1 }",
328
+ buildPowerShellTmuxExitMarkerFinally(context.tmuxExitMarkerPath),
329
+ "exit $__gjcTmuxExitCode",
330
+ ].join("\n")
331
+ : [...envLines, invocation, exitLine].join("\n");
290
332
  // Encode the script as UTF-16LE base64 for pwsh -EncodedCommand. Do NOT
291
333
  // prepend a UTF-16LE BOM (0xFF 0xFE): the BOM survives the decode and is
292
334
  // inserted as a literal U+FEFF character in front of the first script
@@ -366,7 +408,9 @@ function buildInnerCommand(context: CommandResolutionContext, rawArgs: string[])
366
408
  if (isWindowsPlatform(context.platform)) return buildWindowsPowerShellInnerCommand(context, rawArgs);
367
409
  const command = resolveCurrentGjcCommand(context);
368
410
  const quoted = [...command, ...stripRootTmuxFlag(rawArgs)].map(shellQuote).join(" ");
369
- return `exec env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
411
+ const invocation = `env ${GJC_TMUX_LAUNCHED_ENV}=1${buildEnvAssignments(context.extraEnv)} ${quoted}`;
412
+ if (!context.tmuxExitMarkerPath) return `exec ${invocation}`;
413
+ return `${buildPosixTmuxExitMarkerPrefix(context.tmuxExitMarkerPath)}; ${invocation}; exit $?`;
370
414
  }
371
415
 
372
416
  function visibleWidth(value: string): number {
@@ -404,6 +448,9 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
404
448
  const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
405
449
  const GJC_TMUX_WINDOW_TITLE_PREFIX = "GJC-";
406
450
  const GJC_TMUX_TERMINAL_TITLE_PREFIX = "GJC: ";
451
+ const GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION = "@gjc-root-terminal-title";
452
+ const GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION = "@gjc-root-terminal-title-session";
453
+ const GJC_TMUX_DYNAMIC_SESSION_TITLE = "GJC: #{session_name}";
407
454
 
408
455
  function sanitizeTmuxWindowTitleSegment(value: string): string {
409
456
  return value.replace(/:+/g, "-");
@@ -445,17 +492,31 @@ function sanitizeGjcTmuxRootTerminalTitle(title: string): string {
445
492
  return title.replace(TERMINAL_TITLE_CONTROL_CHARS, "").trim() || "GJC";
446
493
  }
447
494
 
448
- function escapeTmuxFormatLiteral(value: string): string {
449
- return value.replace(/#/g, "##");
495
+ function buildGjcTmuxRootTerminalTitleFormat(sessionName: string): string {
496
+ if (!sessionName.startsWith(GJC_TMUX_SESSION_PREFIX)) return GJC_TMUX_DYNAMIC_SESSION_TITLE;
497
+ return `#{?#{==:#{${GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION}},#{session_name}},#{${GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION}},${GJC_TMUX_DYNAMIC_SESSION_TITLE}}`;
450
498
  }
451
499
 
452
- function buildGjcTmuxRootTerminalTitleCommands(target: string, title: string): GjcTmuxProfileCommand[] {
453
- const sanitized = escapeTmuxFormatLiteral(sanitizeGjcTmuxRootTerminalTitle(title));
500
+ function buildGjcTmuxRootTerminalTitleCommands(
501
+ target: string,
502
+ sessionName: string,
503
+ title: string,
504
+ ): GjcTmuxProfileCommand[] {
505
+ const sanitized = sanitizeGjcTmuxRootTerminalTitle(title);
506
+ const format = buildGjcTmuxRootTerminalTitleFormat(sessionName);
454
507
  return [
508
+ {
509
+ description: "remember tmux client terminal title fallback",
510
+ args: ["set-option", "-t", target, GJC_TMUX_ROOT_TERMINAL_TITLE_OPTION, sanitized],
511
+ },
512
+ {
513
+ description: "remember tmux client terminal title session",
514
+ args: ["set-option", "-t", target, GJC_TMUX_ROOT_TERMINAL_TITLE_SESSION_OPTION, sessionName],
515
+ },
455
516
  { description: "enable tmux client terminal title", args: ["set-option", "-t", target, "set-titles", "on"] },
456
517
  {
457
- description: "set tmux client terminal title",
458
- args: ["set-option", "-t", target, "set-titles-string", sanitized],
518
+ description: "set dynamic tmux client terminal title",
519
+ args: ["set-option", "-t", target, "set-titles-string", format],
459
520
  },
460
521
  ];
461
522
  }
@@ -468,10 +529,8 @@ function applyGjcTmuxRootTerminalTitleProfile(context: {
468
529
  options: TmuxSpawnOptions;
469
530
  }): void {
470
531
  if (!context.title) return;
471
- for (const command of buildGjcTmuxRootTerminalTitleCommands(
472
- buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env }),
473
- context.title,
474
- )) {
532
+ const optionTarget = buildGjcTmuxExactOptionTarget(context.target, { env: context.options.env });
533
+ for (const command of buildGjcTmuxRootTerminalTitleCommands(optionTarget, context.target, context.title)) {
475
534
  context.spawnSync(context.tmuxCommand, command.args, context.options);
476
535
  }
477
536
  }
@@ -695,6 +754,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
695
754
  // session, which would split/send workers into the wrong session.
696
755
  [GJC_TMUX_ACTIVE_SESSION_ENV]: sessionName,
697
756
  },
757
+ tmuxExitMarkerPath: tmuxExitMarkerPath(sessionStateFile),
698
758
  platform,
699
759
  },
700
760
  context.rawArgs,
@@ -115,6 +115,20 @@ function outputMentionsPsmux(output: string): boolean {
115
115
  return PSMUX_VERSION_MARKERS.some(marker => output.includes(marker));
116
116
  }
117
117
 
118
+ function normalizedCommandBaseName(command: string): string {
119
+ const normalized = command
120
+ .trim()
121
+ .replace(/^['"]|['"]$/g, "")
122
+ .replace(/\\/g, "/");
123
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
124
+ return basename.endsWith(".exe") ? basename.slice(0, -4) : basename;
125
+ }
126
+
127
+ function isNamedPsmuxCommand(command: string): boolean {
128
+ const basename = normalizedCommandBaseName(command);
129
+ return basename === "psmux" || basename === "pmux";
130
+ }
131
+
118
132
  function resolveBinaryPath(candidate: string): string | null {
119
133
  return activeBinaryResolver(candidate);
120
134
  }
@@ -194,13 +208,14 @@ export function resolveGjcTmuxBinary(options: ResolveGjcTmuxBinaryOptions = {}):
194
208
  const runner = options.runner ?? readSpawnRunner();
195
209
  const explicit = env.GJC_TMUX_COMMAND?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim();
196
210
  if (explicit) {
197
- const isPsmux = detectPsmux(explicit, { env, runner });
211
+ const isPsmux =
212
+ platform === "win32" && isNamedPsmuxCommand(explicit) ? true : detectPsmux(explicit, { env, runner });
198
213
  return { command: explicit, isPsmux, viaExplicitOverride: true };
199
214
  }
200
215
  if (platform === "win32") {
201
216
  for (const candidate of PSMUX_BINARY_NAMES) {
202
217
  if (resolveBinaryPath(candidate)) {
203
- const isPsmux = detectPsmux(candidate, { env, runner });
218
+ const isPsmux = isNamedPsmuxCommand(candidate) ? true : detectPsmux(candidate, { env, runner });
204
219
  return { command: candidate, isPsmux, viaExplicitOverride: false };
205
220
  }
206
221
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { getWorktreesDir } from "@gajae-code/utils/dirs";
4
5
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
6
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
7
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
@@ -25,6 +26,7 @@ import {
25
26
  GJC_TMUX_ACTIVE_SESSION_ENV,
26
27
  GJC_TMUX_PROFILE_OPTION,
27
28
  GJC_TMUX_PROFILE_VALUE,
29
+ resolveGjcTmuxBinary,
28
30
  resolveGjcTmuxCommand,
29
31
  } from "./tmux-common";
30
32
 
@@ -288,6 +290,7 @@ export interface GjcTeamStartOptions {
288
290
  cwd?: string;
289
291
  env?: NodeJS.ProcessEnv;
290
292
  dryRun?: boolean;
293
+ platform?: NodeJS.Platform;
291
294
  mailboxDeliveryTransport?: GjcTeamMailboxDeliveryTransport;
292
295
  }
293
296
 
@@ -1844,18 +1847,41 @@ function findWorktreePath(repoRoot: string, worktreePath: string): string | null
1844
1847
  if (line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === resolved) return resolved;
1845
1848
  return null;
1846
1849
  }
1850
+ export function resolveWorkerWorktreePath(input: {
1851
+ repoRoot: string;
1852
+ stateDir: string;
1853
+ teamName: string;
1854
+ workerId: string;
1855
+ platform: NodeJS.Platform;
1856
+ isPsmux: boolean;
1857
+ }): string {
1858
+ if (input.platform === "win32" && input.isPsmux) {
1859
+ const slug = stableHash([input.repoRoot, input.stateDir, input.teamName].join("\0")).slice(0, 12);
1860
+ return path.join(getWorktreesDir(), `team-${slug}-${input.workerId}`);
1861
+ }
1862
+ return path.join(input.stateDir, "worktrees", input.workerId);
1863
+ }
1847
1864
  async function ensureWorkerWorktree(
1848
1865
  cwd: string,
1849
1866
  dir: string,
1850
1867
  teamName: string,
1851
1868
  worker: GjcTeamWorker,
1852
1869
  mode: GjcTeamWorktreeMode,
1870
+ platform: NodeJS.Platform,
1871
+ isPsmux: boolean,
1853
1872
  ): Promise<GjcTeamWorker> {
1854
1873
  if (!mode.enabled) return worker;
1855
1874
  if (!isGitRepository(cwd)) throw new Error(`team_worktree_requires_git_repo:${cwd}`);
1856
1875
  const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
1857
1876
  const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
1858
- const worktreePath = path.join(dir, "worktrees", worker.id);
1877
+ const worktreePath = resolveWorkerWorktreePath({
1878
+ repoRoot,
1879
+ stateDir: dir,
1880
+ teamName,
1881
+ workerId: worker.id,
1882
+ platform,
1883
+ isPsmux,
1884
+ });
1859
1885
  const existing = findWorktreePath(repoRoot, worktreePath);
1860
1886
  let created = false;
1861
1887
  const branchName = mode.detached
@@ -2994,7 +3020,9 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
2994
3020
  const dir = teamDir(stateRoot, teamName);
2995
3021
  const createdAt = now();
2996
3022
  const worktreeMode = resolveDefaultWorktreeMode(options.worktreeMode);
2997
- const tmuxCommand = resolveGjcTmuxCommand(env);
3023
+ const platform = options.platform ?? process.platform;
3024
+ const tmuxBinary = resolveGjcTmuxBinary({ env, platform });
3025
+ const tmuxCommand = tmuxBinary.command;
2998
3026
  const tmuxContext = options.dryRun
2999
3027
  ? { sessionName: "dry-run", windowIndex: "0", leaderPaneId: "%dry-run-leader", target: "dry-run:0" }
3000
3028
  : readCurrentTmuxLeaderContext(tmuxCommand, env);
@@ -3003,7 +3031,11 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
3003
3031
  const workers: GjcTeamWorker[] = [];
3004
3032
  try {
3005
3033
  for (const worker of initialWorkers)
3006
- workers.push(options.dryRun ? worker : await ensureWorkerWorktree(cwd, dir, teamName, worker, worktreeMode));
3034
+ workers.push(
3035
+ options.dryRun
3036
+ ? worker
3037
+ : await ensureWorkerWorktree(cwd, dir, teamName, worker, worktreeMode, platform, tmuxBinary.isPsmux),
3038
+ );
3007
3039
  } catch (error) {
3008
3040
  await rollbackCreatedWorktrees(workers);
3009
3041
  throw error;
package/src/main.ts CHANGED
@@ -38,6 +38,7 @@ import type { InteractiveMode } from "./modes/interactive-mode";
38
38
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
39
39
  import type { SubmittedUserInput } from "./modes/types";
40
40
  import { applyCliRuntimeApiKeyOverride } from "./runtime-api-key";
41
+ import { parseCliCredentialSelector } from "./runtime-credential-selector";
41
42
  import type { MCPManager } from "./runtime-mcp";
42
43
  import {
43
44
  type CreateAgentSessionOptions,
@@ -217,10 +218,14 @@ export async function applyStartupModelProfiles(args: {
217
218
  startupModel?: CreateAgentSessionOptions["model"];
218
219
  startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
219
220
  }): Promise<void> {
220
- const applyProfile = async (profileName: string, persistDefault: boolean): Promise<void> => {
221
+ const applyProfile = async (
222
+ profileName: string,
223
+ persistDefault: boolean,
224
+ options: { thinkingLevelOverride?: CreateAgentSessionOptions["thinkingLevel"] } = {},
225
+ ): Promise<void> => {
221
226
  await activateModelProfile(
222
227
  { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
223
- { persistDefault },
228
+ { persistDefault, thinkingLevelOverride: options.thinkingLevelOverride },
224
229
  );
225
230
  };
226
231
 
@@ -234,7 +239,11 @@ export async function applyStartupModelProfiles(args: {
234
239
  }
235
240
 
236
241
  if (defaultProfile) {
237
- await applyProfile(defaultProfile, false);
242
+ await applyProfile(defaultProfile, false, {
243
+ thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
244
+ ? args.settings.get("defaultThinkingLevel")
245
+ : undefined,
246
+ });
238
247
  }
239
248
  if (args.parsedArgs.mpreset) {
240
249
  await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true);
@@ -1002,6 +1011,20 @@ export async function runRootCommand(
1002
1011
  deps.rlmPreset?.applyOptions(sessionOptions, settingsInstance);
1003
1012
 
1004
1013
  // Handle CLI --api-key as runtime override (not persisted)
1014
+ if (parsedArgs.apiKey && parsedArgs.credential) {
1015
+ process.stderr.write(`${chalk.red("--api-key and --credential cannot be used together")}\n`);
1016
+ process.exit(1);
1017
+ }
1018
+
1019
+ if (parsedArgs.credential) {
1020
+ try {
1021
+ sessionOptions.credentialSelector = parseCliCredentialSelector(parsedArgs.credential);
1022
+ } catch (error) {
1023
+ const message = error instanceof Error ? error.message : String(error);
1024
+ process.stderr.write(`${chalk.red(message)}\n`);
1025
+ process.exit(1);
1026
+ }
1027
+ }
1005
1028
  if (parsedArgs.apiKey) {
1006
1029
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
1007
1030
  process.stderr.write(
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@gajae-code/agent-core";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
2
2
  import type { Effort } from "@gajae-code/ai";
3
3
  import {
4
4
  Container,
@@ -654,7 +654,7 @@ export interface SettingsRuntimeContext {
654
654
  /** Available thinking levels (from session) */
655
655
  availableThinkingLevels: Effort[];
656
656
  /** Current thinking level (from session) */
657
- thinkingLevel: ThinkingLevel | undefined;
657
+ thinkingLevel: ThinkingLevelValue | undefined;
658
658
  /** Available themes */
659
659
  availableThemes: string[];
660
660
  /** Available model profile names (from the model registry) */
@@ -843,7 +843,7 @@ export class SettingsSelectorComponent extends Container {
843
843
 
844
844
  // Special case: inject runtime options for thinking level
845
845
  if (def.path === "defaultThinkingLevel") {
846
- options = this.context.availableThinkingLevels.map(level => {
846
+ options = [ThinkingLevel.Off, ...this.context.availableThinkingLevels].map(level => {
847
847
  const baseOpt = options.find(o => o.value === level);
848
848
  return baseOpt || { value: level, label: level };
849
849
  });
@@ -1,53 +1,114 @@
1
1
  import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
2
- import { Container, type SelectItem, SelectList } from "@gajae-code/tui";
3
- import { getSelectListTheme } from "../../modes/theme/theme";
2
+ import { Container, type SelectItem, SelectList, Text } from "@gajae-code/tui";
3
+ import { getSelectListTheme, theme } from "../../modes/theme/theme";
4
4
  import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
+ const SCOPE_ITEMS = [
8
+ {
9
+ value: "session",
10
+ label: "Apply for this session",
11
+ description: "Use this effort until the session changes it again",
12
+ },
13
+ {
14
+ value: "default",
15
+ label: "Set as default",
16
+ description: "Save this effort for future sessions",
17
+ },
18
+ ] as const satisfies readonly SelectItem[];
19
+
20
+ export interface ThinkingSelectorSelection {
21
+ level: ThinkingLevelValue;
22
+ persistDefault: boolean;
23
+ }
24
+
7
25
  /**
8
26
  * Component that renders a thinking level selector with borders
9
27
  */
10
28
  export class ThinkingSelectorComponent extends Container {
11
29
  #selectList: SelectList;
30
+ #levelList: SelectList;
31
+ #selectedLevel: ThinkingLevelValue | undefined;
32
+ readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
33
+ readonly #onCancel: () => void;
12
34
 
13
35
  constructor(
14
36
  currentLevel: ThinkingLevelValue | undefined,
15
37
  availableLevels: ThinkingLevelValue[],
16
- onSelect: (level: ThinkingLevelValue) => void,
38
+ onSelect: (selection: ThinkingSelectorSelection) => void,
17
39
  onCancel: () => void,
18
40
  ) {
19
41
  super();
20
42
 
21
- const thinkingLevels: SelectItem[] = availableLevels.map(level =>
22
- getThinkingLevelMetadata(level as ThinkingMetadataValue),
23
- );
43
+ this.#onSelect = onSelect;
44
+ this.#onCancel = onCancel;
24
45
 
25
- // Add top border
26
- this.addChild(new DynamicBorder());
46
+ const currentValue = currentLevel ?? ThinkingLevel.Off;
47
+ const thinkingLevels: SelectItem[] = availableLevels.map(level => {
48
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
49
+ return level === currentValue
50
+ ? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
51
+ : metadata;
52
+ });
27
53
 
28
- // Create selector
29
- this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
54
+ this.#levelList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
55
+ this.#selectList = this.#levelList;
30
56
 
31
57
  // Preselect current level
32
- const currentIndex = thinkingLevels.findIndex(item => item.value === (currentLevel ?? ThinkingLevel.Off));
58
+ const currentIndex = thinkingLevels.findIndex(item => item.value === currentValue);
33
59
  if (currentIndex !== -1) {
34
- this.#selectList.setSelectedIndex(currentIndex);
60
+ this.#levelList.setSelectedIndex(currentIndex);
35
61
  }
36
62
 
37
- this.#selectList.onSelect = item => {
38
- onSelect(item.value as ThinkingLevelValue);
63
+ this.#levelList.onSelect = item => {
64
+ this.#selectedLevel = item.value as ThinkingLevelValue;
65
+ this.#renderScopeList();
39
66
  };
40
67
 
41
- this.#selectList.onCancel = () => {
42
- onCancel();
68
+ this.#levelList.onCancel = () => {
69
+ this.#onCancel();
43
70
  };
44
71
 
45
- this.addChild(this.#selectList);
72
+ this.#renderLevelList();
73
+ }
74
+
75
+ #renderLevelList(): void {
76
+ this.detachAll();
77
+ this.#selectedLevel = undefined;
78
+ this.#selectList = this.#levelList;
79
+ this.addChild(new DynamicBorder());
80
+ this.addChild(this.#levelList);
81
+ this.addChild(new DynamicBorder());
82
+ }
83
+
84
+ #renderScopeList(): void {
85
+ const level = this.#selectedLevel;
86
+ if (!level) return;
87
+
88
+ const scopeList = new SelectList(SCOPE_ITEMS, SCOPE_ITEMS.length, getSelectListTheme());
89
+ scopeList.onSelect = item => {
90
+ this.#onSelect({
91
+ level,
92
+ persistDefault: item.value === "default",
93
+ });
94
+ };
95
+ scopeList.onCancel = () => {
96
+ this.#renderLevelList();
97
+ };
46
98
 
47
- // Add bottom border
99
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
100
+ this.detachAll();
101
+ this.#selectList = scopeList;
102
+ this.addChild(new DynamicBorder());
103
+ this.addChild(new Text(theme.fg("muted", ` Reasoning effort: ${metadata.label}`), 0, 0));
104
+ this.addChild(scopeList);
48
105
  this.addChild(new DynamicBorder());
49
106
  }
50
107
 
108
+ handleInput(data: string): void {
109
+ this.#selectList.handleInput(data);
110
+ }
111
+
51
112
  getSelectList(): SelectList {
52
113
  return this.#selectList;
53
114
  }
@@ -27,6 +27,8 @@ interface Expandable {
27
27
  }
28
28
 
29
29
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
30
+ const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g;
31
+ const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/;
30
32
 
31
33
  function isExpandable(obj: unknown): obj is Expandable {
32
34
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
@@ -383,6 +385,7 @@ export class InputController {
383
385
  this.ctx.isBashMode = trimmed.startsWith("!");
384
386
  this.ctx.isBashNoContext = trimmed.startsWith("!!");
385
387
  this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
388
+ this.#clearPendingImagesIfPlaceholdersRemoved(text);
386
389
  if (
387
390
  wasBashMode !== this.ctx.isBashMode ||
388
391
  wasBashNoContext !== this.ctx.isBashNoContext ||
@@ -418,7 +421,7 @@ export class InputController {
418
421
  }
419
422
 
420
423
  const runner = this.ctx.session.extensionRunner;
421
- let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
424
+ let inputImages = this.#visiblePendingImagesForText(text);
422
425
 
423
426
  if (runner?.hasHandlers("input")) {
424
427
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -498,7 +501,7 @@ export class InputController {
498
501
 
499
502
  // Queue input during compaction
500
503
  if (this.ctx.session.isCompacting) {
501
- if (this.ctx.pendingImages.length > 0) {
504
+ if ((inputImages?.length ?? 0) > 0) {
502
505
  this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
503
506
  return;
504
507
  }
@@ -520,11 +523,13 @@ export class InputController {
520
523
  // typed since queuing intact. Same protection as #783, applied to
521
524
  // the streaming/queue path.
522
525
  const streamingBehavior = this.#busyStreamingBehavior();
523
- await this.ctx.withLocalSubmission(
524
- text,
525
- () => this.ctx.session.prompt(text, { streamingBehavior, images }),
526
- { imageCount: images?.length ?? 0 },
527
- );
526
+ const promptOptions =
527
+ streamingBehavior === "followUp"
528
+ ? { streamingBehavior, images, followUpQueuePolicy: "sequential" as const }
529
+ : { streamingBehavior, images };
530
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, promptOptions), {
531
+ imageCount: images?.length ?? 0,
532
+ });
528
533
  this.ctx.updatePendingMessagesDisplay();
529
534
  this.ctx.ui.requestRender();
530
535
  return;
@@ -869,7 +874,9 @@ export class InputController {
869
874
  details,
870
875
  attribution: "user",
871
876
  },
872
- { streamingBehavior },
877
+ streamingBehavior === "followUp"
878
+ ? { streamingBehavior, followUpQueuePolicy: "sequential" }
879
+ : { streamingBehavior },
873
880
  );
874
881
  }
875
882
  if (this.ctx.session.isStreaming) {
@@ -911,7 +918,10 @@ export class InputController {
911
918
  this.ctx.editor.addToHistory(text);
912
919
  this.ctx.editor.setText("");
913
920
  await this.ctx.withLocalSubmission(text, () =>
914
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
921
+ this.ctx.session.prompt(text, {
922
+ streamingBehavior: "followUp",
923
+ followUpQueuePolicy: "sequential",
924
+ }),
915
925
  );
916
926
  this.ctx.updatePendingMessagesDisplay();
917
927
  this.ctx.ui.requestRender();
@@ -1104,6 +1114,37 @@ export class InputController {
1104
1114
  return `[image ${this.ctx.pendingImages.length}]`;
1105
1115
  }
1106
1116
 
1117
+ #visiblePendingImagesForText(text: string): InteractiveModeContext["pendingImages"] | undefined {
1118
+ if (this.ctx.pendingImages.length === 0) {
1119
+ return undefined;
1120
+ }
1121
+
1122
+ const images: InteractiveModeContext["pendingImages"] = [];
1123
+ const seenImageIndexes = new Set<number>();
1124
+ for (const match of text.matchAll(IMAGE_PLACEHOLDER_PATTERN)) {
1125
+ const placeholderNumberText = match[1];
1126
+ if (!placeholderNumberText) continue;
1127
+ const placeholderNumber = Number.parseInt(placeholderNumberText, 10);
1128
+ const imageIndex = placeholderNumber - 1;
1129
+ if (imageIndex < 0 || imageIndex >= this.ctx.pendingImages.length || seenImageIndexes.has(imageIndex)) {
1130
+ continue;
1131
+ }
1132
+ const image = this.ctx.pendingImages[imageIndex];
1133
+ if (!image) continue;
1134
+ images.push(image);
1135
+ seenImageIndexes.add(imageIndex);
1136
+ }
1137
+
1138
+ return images.length > 0 ? images : undefined;
1139
+ }
1140
+
1141
+ #clearPendingImagesIfPlaceholdersRemoved(text: string): void {
1142
+ if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
1143
+ return;
1144
+ }
1145
+ this.ctx.pendingImages = [];
1146
+ }
1147
+
1107
1148
  async handleImagePaste(): Promise<boolean> {
1108
1149
  try {
1109
1150
  const image = await readImageFromClipboard();
@@ -443,12 +443,13 @@ export class SelectorController {
443
443
  const selector = new ThinkingSelectorComponent(
444
444
  this.ctx.session.thinkingLevel,
445
445
  availableLevels,
446
- level => {
446
+ selection => {
447
447
  done();
448
448
 
449
+ const { level, persistDefault } = selection;
449
450
  const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
450
451
  const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
451
- this.ctx.session.setThinkingLevel(levelToApply, false);
452
+ this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
452
453
  const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
453
454
  const requestedLabel =
454
455
  level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
@@ -458,9 +459,11 @@ export class SelectorController {
458
459
  this.ctx.statusLine.invalidate();
459
460
  this.ctx.updateEditorBorderColor();
460
461
  this.ctx.updateEditorTopBorder();
462
+ if (persistDefault) void this.ctx.notifyConfigChanged?.();
461
463
  this.ctx.ui.requestRender();
464
+ const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
462
465
  this.ctx.showStatus(
463
- `Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
466
+ `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
464
467
  );
465
468
  },
466
469
  () => {
@@ -468,10 +471,9 @@ export class SelectorController {
468
471
  this.ctx.ui.requestRender();
469
472
  },
470
473
  );
471
- return { component: selector, focus: selector.getSelectList() };
474
+ return { component: selector, focus: selector };
472
475
  });
473
476
  }
474
-
475
477
  showSettingsSelector(): void {
476
478
  getAvailableThemes().then(availableThemes => {
477
479
  this.showSelector(done => {
@@ -34,6 +34,7 @@ import type { Theme } from "./theme/theme";
34
34
  export type CompactionQueuedMessage = {
35
35
  text: string;
36
36
  mode: "steer" | "followUp";
37
+ followUpQueuePolicy?: "sequential";
37
38
  };
38
39
 
39
40
  export type SubmittedUserInput = {