@gajae-code/coding-agent 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
package/src/main.ts CHANGED
@@ -52,7 +52,7 @@ import { formatModelOnboardingGuidance } from "./setup/model-onboarding-guidance
52
52
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
53
53
  import { resolvePromptInput } from "./system-prompt";
54
54
  import type { LspStartupServerInfo } from "./tools";
55
- import { getDisplayChangelogEntries, getNewEntries } from "./utils/changelog";
55
+ import { getDisplayChangelogEntries, getInstalledVersionChangelogEntry, getNewEntries } from "./utils/changelog";
56
56
  import type { EventBus } from "./utils/event-bus";
57
57
 
58
58
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
@@ -407,7 +407,7 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
407
407
  if (entries.length > 0) {
408
408
  settings.set("lastChangelogVersion", VERSION);
409
409
  await flushChangelogVersion();
410
- return entries.map(e => e.content).join("\n\n");
410
+ return getInstalledVersionChangelogEntry(entries, VERSION)?.content;
411
411
  }
412
412
  } else {
413
413
  const newEntries = getNewEntries(entries, lastVersion);
@@ -429,7 +429,7 @@ async function flushChangelogVersion(): Promise<void> {
429
429
  }
430
430
  }
431
431
 
432
- async function createSessionManager(
432
+ export async function createSessionManager(
433
433
  parsed: Args,
434
434
  cwd: string,
435
435
  activeSettings: Settings = settings,
@@ -482,6 +482,17 @@ async function createSessionManager(
482
482
  if (parsed.sessionDir) {
483
483
  return SessionManager.create(cwd, parsed.sessionDir);
484
484
  }
485
+ // A lifecycle `/session_create` child must start a FRESH session that adopts
486
+ // the pre-allocated id (GJC_SESSION_ID), never auto-resume existing history in
487
+ // the target cwd — otherwise the daemon/tmux id and the session header id
488
+ // diverge and close/resume-by-create-id break. Resume children are launched
489
+ // with `--resume <id>` (handled above) and carry no GJC_LIFECYCLE_REQUEST_ID.
490
+ if (
491
+ process.env.GJC_LIFECYCLE_REQUEST_ID &&
492
+ /^[A-Za-z0-9._-]{1,128}$/.test(process.env.GJC_SESSION_ID?.trim() ?? "")
493
+ ) {
494
+ return undefined;
495
+ }
485
496
  // Auto-resume: behave like --continue if the setting is enabled and a prior
486
497
  // session exists. When a prior session is resumed, mark parsed.continue so
487
498
  // buildSessionOptions restores the session's model/thinking instead of
@@ -153,6 +153,14 @@ function jsonResponse(status: number, body: unknown): Response {
153
153
  });
154
154
  }
155
155
 
156
+ function isBridgeControllerOwner(options: BridgeFetchHandlerOptions, ownerToken: string): boolean {
157
+ if (!ownerToken) return false;
158
+ const ownerTokens = [options.permissionBroker?.ownerToken, options.uiBroker?.ownerToken].filter(
159
+ (token): token is string => typeof token === "string" && token.length > 0,
160
+ );
161
+ return ownerTokens.length > 0 && ownerTokens.every(token => token === ownerToken);
162
+ }
163
+
156
164
  function parseBridgeScopes(value: string | undefined): readonly BridgeCommandScope[] {
157
165
  if (!value?.trim()) return DEFAULT_BRIDGE_SCOPES;
158
166
  const allowed = new Set(BRIDGE_COMMAND_SCOPES);
@@ -425,6 +433,9 @@ export function createBridgeFetchHandler(options: BridgeFetchHandlerOptions): (r
425
433
  "answer" in payload &&
426
434
  (correlationId === (payload as RpcWorkflowGateResponse).gate_id || correlationId.startsWith("wg_"))
427
435
  ) {
436
+ if (!isBridgeControllerOwner(options, ownerToken)) {
437
+ return jsonResponse(403, { status: "rejected", code: "not_controller" });
438
+ }
428
439
  try {
429
440
  const resolution = await options.unattendedControlPlane?.resolveGate({
430
441
  gate_id: (payload as RpcWorkflowGateResponse).gate_id,
@@ -18,6 +18,7 @@ type ConfigurableEditorAction = Extract<
18
18
  | "app.editor.external"
19
19
  | "app.history.search"
20
20
  | "app.message.dequeue"
21
+ | "app.message.followUp"
21
22
  | "app.message.queue"
22
23
  | "app.clipboard.pasteImage"
23
24
  | "app.clipboard.copyPrompt"
@@ -40,6 +41,7 @@ const CONFIGURABLE_EDITOR_ACTIONS = [
40
41
  "app.thinking.toggle",
41
42
  "app.editor.external",
42
43
  "app.history.search",
44
+ "app.message.followUp",
43
45
  "app.message.queue",
44
46
  "app.message.dequeue",
45
47
  "app.clipboard.pasteImage",
@@ -8,6 +8,7 @@ import { shortenPath } from "../../tools/render-utils";
8
8
  import * as git from "../../utils/git";
9
9
  import { sanitizeStatusText } from "../shared";
10
10
  import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
11
+ import { resolveCurrentBranch } from "./status-line/git-utils";
11
12
 
12
13
  /**
13
14
  * Footer component that shows pwd, token stats, and context usage
@@ -103,9 +104,7 @@ export class FooterComponent implements Component {
103
104
  return this.#cachedBranch;
104
105
  }
105
106
 
106
- const headState = git.head.resolveSync(getProjectDir());
107
- this.#cachedBranch =
108
- headState === null ? null : headState.kind === "ref" ? (headState.branchName ?? headState.ref) : "detached";
107
+ this.#cachedBranch = resolveCurrentBranch(getProjectDir()).branch;
109
108
  return this.#cachedBranch;
110
109
  }
111
110
 
@@ -66,7 +66,7 @@ export class HookEditorComponent extends Container {
66
66
 
67
67
  // Hint
68
68
  const hint = this.#promptStyle
69
- ? "enter submit esc cancel ctrl+g external editor"
69
+ ? "enter submit shift+enter/ctrl+j newline esc cancel ctrl+g external editor"
70
70
  : "ctrl+enter submit esc cancel ctrl+g external editor";
71
71
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
72
72
 
@@ -28,22 +28,6 @@ import { getEditorCommand, openInEditor } from "../../utils/external-editor";
28
28
  import { CountdownTimer } from "./countdown-timer";
29
29
  import { DynamicBorder } from "./dynamic-border";
30
30
 
31
- const SGR_MOUSE_PRESS_PATTERN = /^\x1b\[<(\d+);\d+;\d+M$/;
32
- const MOUSE_WHEEL_TITLE_SCROLL_ROWS = 3;
33
-
34
- function getMouseWheelTitleScrollRows(keyData: string): number {
35
- const match = SGR_MOUSE_PRESS_PATTERN.exec(keyData);
36
- if (!match) return 0;
37
-
38
- const button = Number.parseInt(match[1] ?? "", 10);
39
- if (!Number.isFinite(button) || (button & 64) === 0) return 0;
40
-
41
- const wheelDirection = button & 3;
42
- if (wheelDirection === 0) return -MOUSE_WHEEL_TITLE_SCROLL_ROWS;
43
- if (wheelDirection === 1) return MOUSE_WHEEL_TITLE_SCROLL_ROWS;
44
- return 0;
45
- }
46
-
47
31
  export interface HookSelectorOptions {
48
32
  tui?: TUI;
49
33
  timeout?: number;
@@ -132,26 +116,58 @@ class ScrollableTitle extends Container {
132
116
 
133
117
  render(width: number): string[] {
134
118
  const lines = this.#markdown.render(width);
135
- const maxScrollOffset = Math.max(0, lines.length - this.#maxRows);
136
- this.#lastMaxScrollOffset = maxScrollOffset;
137
- this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
119
+ if (lines.length <= this.#maxRows) {
120
+ this.#lastMaxScrollOffset = 0;
121
+ this.#scrollOffset = 0;
122
+ return lines;
123
+ }
138
124
 
139
- const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
140
- if (maxScrollOffset === 0 || visibleLines.length === 0) {
125
+ if (this.#maxRows < 3) {
126
+ const maxScrollOffset = Math.max(0, lines.length - this.#maxRows);
127
+ this.#lastMaxScrollOffset = maxScrollOffset;
128
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
129
+
130
+ const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
131
+ const indicator =
132
+ this.#scrollOffset === 0
133
+ ? theme.fg("dim", " PgDn↓")
134
+ : this.#scrollOffset >= maxScrollOffset
135
+ ? theme.fg("dim", " PgUp↑")
136
+ : theme.fg("dim", " PgUp/PgDn↕");
137
+ const lastIndex = visibleLines.length - 1;
138
+ const availableWidth = Math.max(1, width - visibleWidth(indicator));
139
+ const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth);
140
+ visibleLines[lastIndex] = `${fittedLine}${indicator}`;
141
141
  return visibleLines;
142
142
  }
143
143
 
144
- const indicator =
145
- this.#scrollOffset === 0
146
- ? theme.fg("dim", " PgDn↓")
147
- : this.#scrollOffset >= maxScrollOffset
148
- ? theme.fg("dim", " PgUp↑")
149
- : theme.fg("dim", " PgUp/PgDn↕");
150
- const lastIndex = visibleLines.length - 1;
151
- const availableWidth = Math.max(1, width - visibleWidth(indicator));
152
- const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth);
153
- visibleLines[lastIndex] = `${fittedLine}${indicator}`;
154
- return visibleLines;
144
+ let showTopIndicator = this.#scrollOffset > 0;
145
+ let showBottomIndicator = true;
146
+ let contentRows = 1;
147
+ let maxScrollOffset = 0;
148
+
149
+ for (let i = 0; i < 4; i++) {
150
+ contentRows = Math.max(1, this.#maxRows - (showTopIndicator ? 1 : 0) - (showBottomIndicator ? 1 : 0));
151
+ maxScrollOffset = Math.max(0, lines.length - contentRows);
152
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
153
+
154
+ const nextShowTopIndicator = this.#scrollOffset > 0;
155
+ const nextShowBottomIndicator = this.#scrollOffset + contentRows < lines.length;
156
+ if (nextShowTopIndicator === showTopIndicator && nextShowBottomIndicator === showBottomIndicator) {
157
+ break;
158
+ }
159
+ showTopIndicator = nextShowTopIndicator;
160
+ showBottomIndicator = nextShowBottomIndicator;
161
+ }
162
+
163
+ this.#lastMaxScrollOffset = maxScrollOffset;
164
+
165
+ const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + contentRows);
166
+ const result: string[] = [];
167
+ if (showTopIndicator) result.push(theme.fg("dim", truncateToWidth("▲ more", width)));
168
+ result.push(...visibleLines);
169
+ if (showBottomIndicator) result.push(theme.fg("dim", truncateToWidth("▼ more", width)));
170
+ return result.slice(0, this.#maxRows);
155
171
  }
156
172
  }
157
173
 
@@ -454,13 +470,6 @@ export class HookSelectorComponent extends Container {
454
470
  // Reset countdown on any interaction
455
471
  this.#countdown?.reset();
456
472
 
457
- if (this.#scrollTitleRows !== undefined) {
458
- const wheelRows = getMouseWheelTitleScrollRows(keyData);
459
- if (wheelRows !== 0) {
460
- this.#scrollableTitle?.scrollBy(wheelRows);
461
- return;
462
- }
463
- }
464
473
  if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageUp")) {
465
474
  this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
466
475
  return;
@@ -469,6 +478,14 @@ export class HookSelectorComponent extends Container {
469
478
  this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
470
479
  return;
471
480
  }
481
+ if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+u")) {
482
+ this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
483
+ return;
484
+ }
485
+ if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+d")) {
486
+ this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
487
+ return;
488
+ }
472
489
  if (this.#inlineEditor) {
473
490
  this.#handleInputModeKey(keyData, this.#inlineEditor);
474
491
  return;
@@ -537,16 +554,23 @@ export class HookSelectorComponent extends Container {
537
554
  editor.setBorderVisible(false);
538
555
  editor.setPromptGutter("> ");
539
556
  editor.disableSubmit = true;
557
+ // Mark the inline editor focused only when mirroring the app's hardware-cursor
558
+ // mode, so it emits CURSOR_MARKER at the input caret for IME preedit anchoring
559
+ // without changing legacy non-hardware-cursor layout.
560
+ const useTerminalCursor = this.#tui?.getShowHardwareCursor() ?? false;
561
+ editor.focused = useTerminalCursor;
562
+ editor.setUseTerminalCursor(useTerminalCursor);
540
563
  if (this.#autocompleteProvider) {
541
564
  editor.setAutocompleteProvider(this.#autocompleteProvider);
542
565
  }
543
566
  this.#inlineEditor = editor;
544
567
  this.#inputArea.addChild(new Spacer(1));
545
568
  this.#inputArea.addChild(editor);
546
- const scrollHint = this.#scrollTitleRows === undefined ? "" : " wheel/PgUp/PgDn scroll question";
547
- this.#helpTextComponent.setText(
548
- theme.fg("dim", `enter submit esc back to options ctrl+g external editor${scrollHint}`),
549
- );
569
+ const helpText =
570
+ this.#scrollTitleRows === undefined
571
+ ? "enter submit esc back to options ctrl+g external editor"
572
+ : "enter submit esc back to options PgUp/PgDn: question · Wheel: transcript";
573
+ this.#helpTextComponent.setText(theme.fg("dim", helpText));
550
574
  this.invalidate();
551
575
  }
552
576
 
@@ -221,8 +221,11 @@ export class ModelSelectorComponent extends Container {
221
221
  #scopedModels: ReadonlyArray<ScopedModelItem>;
222
222
  #temporaryOnly: boolean;
223
223
  #currentModel?: Model;
224
+ #currentThinkingLevel?: ThinkingLevel;
225
+ #activeModelProfile?: string;
224
226
  #isFastForProvider: (provider?: string) => boolean = () => false;
225
227
  #isFastForSubagentProvider: (provider?: string) => boolean = () => false;
228
+ #isCurrentModelFastModeActive: () => boolean = () => false;
226
229
  #pendingActionItem?: ModelItem | CanonicalModelItem;
227
230
  #selectedActionIndex: number = 0;
228
231
  #pendingThinkingChoice?: PendingThinkingChoice;
@@ -258,6 +261,9 @@ export class ModelSelectorComponent extends Container {
258
261
  sessionId?: string;
259
262
  isFastForProvider?: (provider?: string) => boolean;
260
263
  isFastForSubagentProvider?: (provider?: string) => boolean;
264
+ isCurrentModelFastModeActive?: () => boolean;
265
+ currentThinkingLevel?: ThinkingLevel;
266
+ activeModelProfile?: string;
261
267
  },
262
268
  ) {
263
269
  super();
@@ -271,8 +277,16 @@ export class ModelSelectorComponent extends Container {
271
277
  this.#temporaryOnly = options?.temporaryOnly ?? false;
272
278
  this.#authSessionId = options?.sessionId;
273
279
  this.#currentModel = _currentModel;
280
+ this.#currentThinkingLevel = options?.currentThinkingLevel;
281
+ this.#activeModelProfile = options?.activeModelProfile;
274
282
  this.#isFastForProvider = options?.isFastForProvider ?? (() => false);
275
283
  this.#isFastForSubagentProvider = options?.isFastForSubagentProvider ?? (() => false);
284
+ // Current-model EFFECTIVE fast state. Defaults to intent for the current
285
+ // model so existing callers/tests keep prior behavior; production wires the
286
+ // session's effective predicate so an auto-disabled provider shows no glyph.
287
+ this.#isCurrentModelFastModeActive =
288
+ options?.isCurrentModelFastModeActive ??
289
+ (() => (this.#currentModel ? this.#isFastForProvider(this.#currentModel.provider) : false));
276
290
  const initialSearchInput = options?.initialSearchInput;
277
291
  this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets";
278
292
 
@@ -367,6 +381,23 @@ export class ModelSelectorComponent extends Container {
367
381
  };
368
382
  }
369
383
  }
384
+ if (this.#activeModelProfile && this.#currentModel) {
385
+ this.#roles.default = {
386
+ model: this.#currentModel,
387
+ thinkingLevel: this.#currentThinkingLevel ?? ThinkingLevel.Inherit,
388
+ };
389
+ }
390
+ }
391
+
392
+ refreshRoleAssignments(
393
+ options: { currentModel?: Model; currentThinkingLevel?: ThinkingLevel; activeModelProfile?: string } = {},
394
+ ): void {
395
+ if ("currentModel" in options) this.#currentModel = options.currentModel;
396
+ if ("currentThinkingLevel" in options) this.#currentThinkingLevel = options.currentThinkingLevel;
397
+ if ("activeModelProfile" in options) this.#activeModelProfile = options.activeModelProfile;
398
+ this.#roles = {};
399
+ this.#loadRoleModels();
400
+ this.#applyTabFilter();
370
401
  }
371
402
 
372
403
  #sortModels(models: ModelItem[]): void {
@@ -961,32 +992,46 @@ export class ModelSelectorComponent extends Container {
961
992
 
962
993
  // Build role badges (inverted: color as background, black text)
963
994
  const roleBadgeTokens: string[] = [];
964
- let roleMatched = false;
995
+ // Whether a non-subagent (modelRoles) badge on the CURRENT model row already
996
+ // rendered the current-model EFFECTIVE glyph. Only that case should suppress
997
+ // the standalone current glyph below — a subagent-only match must NOT, since
998
+ // subagent badges reflect the subagent tier, not the current model.
999
+ let currentModelEffectiveGlyphRendered = false;
965
1000
  for (const role of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
966
1001
  const roleInfo = GJC_MODEL_ASSIGNMENT_TARGETS[role];
967
1002
  const assigned = this.#roles[role];
968
1003
  if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
969
- roleMatched = true;
970
1004
  const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
971
1005
  const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
972
- // Subagent roles (task.agentModelOverrides) run under task.serviceTier, so
973
- // their must reflect the effective subagent tier, not the main session tier.
974
- const roleFast =
975
- roleInfo.settingsPath === "task.agentModelOverrides"
976
- ? this.#isFastForSubagentProvider(assigned.model.provider)
1006
+
1007
+ // Subagent roles (task.agentModelOverrides) run under task.serviceTier,
1008
+ // so their ⚡ uses the effective subagent tier. A non-subagent
1009
+ // (modelRoles) badge on the CURRENT model row uses the current-model
1010
+ // effective predicate so a provider auto-disable hides the glyph;
1011
+ // other modelRoles rows show pure intent.
1012
+ const isSubagentRole = roleInfo.settingsPath === "task.agentModelOverrides";
1013
+ const isCurrentRow = this.#currentModel !== undefined && modelsAreEqual(this.#currentModel, item.model);
1014
+ const roleFast = isSubagentRole
1015
+ ? this.#isFastForSubagentProvider(assigned.model.provider)
1016
+ : isCurrentRow
1017
+ ? this.#isCurrentModelFastModeActive()
977
1018
  : this.#isFastForProvider(assigned.model.provider);
1019
+ if (roleFast && isCurrentRow && !isSubagentRole) {
1020
+ currentModelEffectiveGlyphRendered = true;
1021
+ }
978
1022
  const fastSuffix = roleFast ? ` ${theme.icon.fast}` : "";
979
1023
  roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}${fastSuffix}`);
980
1024
  }
981
1025
  }
982
1026
  // Active/current non-role row: show the fast glyph on the session's current
983
- // model row even when it carries no role badge. Skip when a role token for
984
- // this row already rendered the glyph (duplicate-glyph guard).
1027
+ // model row. Suppress only when a non-subagent current-row badge already
1028
+ // rendered the current-model effective glyph (duplicate-glyph guard) — a
1029
+ // subagent-only match must not hide the current model's own indicator.
985
1030
  if (
986
- !roleMatched &&
1031
+ !currentModelEffectiveGlyphRendered &&
987
1032
  this.#currentModel !== undefined &&
988
1033
  modelsAreEqual(this.#currentModel, item.model) &&
989
- this.#isFastForProvider(item.model.provider)
1034
+ this.#isCurrentModelFastModeActive()
990
1035
  ) {
991
1036
  roleBadgeTokens.push(theme.icon.fast);
992
1037
  }
@@ -1,3 +1,6 @@
1
+ import type { GitHeadState } from "../../../utils/git";
2
+ import * as git from "../../../utils/git";
3
+
1
4
  /**
2
5
  * Extract "owner/repo" from a GitHub remote URL.
3
6
  * Handles HTTPS, SSH (scp-style), and git:// protocols.
@@ -40,3 +43,25 @@ export function canReuseCachedPr(
40
43
  ): boolean {
41
44
  return cachedPr !== undefined && currentContext !== null && isSamePrCacheContext(cachedContext, currentContext);
42
45
  }
46
+
47
+ export interface CurrentBranchState {
48
+ readonly branch: string | null;
49
+ readonly repoId: string | null;
50
+ }
51
+
52
+ export function resolveCurrentBranch(
53
+ cwd: string,
54
+ resolveHead: (cwd: string) => GitHeadState | null = git.head.resolveSync,
55
+ ): CurrentBranchState {
56
+ try {
57
+ const head = resolveHead(cwd);
58
+ if (!head) return { branch: null, repoId: null };
59
+ return {
60
+ branch: head.kind === "ref" ? (head.branchName ?? head.ref) : "detached",
61
+ repoId: head.headPath,
62
+ };
63
+ } catch (error) {
64
+ if (error instanceof Error) return { branch: null, repoId: null };
65
+ throw error;
66
+ }
67
+ }
@@ -20,6 +20,7 @@ import {
20
20
  createPrCacheContext,
21
21
  isSamePrCacheContext,
22
22
  type PrCacheContext,
23
+ resolveCurrentBranch,
23
24
  } from "./status-line/git-utils";
24
25
  import { getPreset } from "./status-line/presets";
25
26
  import { renderSegment, type SegmentContext } from "./status-line/segments";
@@ -303,19 +304,13 @@ export class StatusLineComponent implements Component {
303
304
  this.#cachedPrContext = undefined;
304
305
  }
305
306
  #getCurrentBranch(): string | null {
306
- const head = git.head.resolveSync(getProjectDir());
307
- const gitHeadPath = head?.headPath ?? null;
308
- if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
307
+ const current = resolveCurrentBranch(getProjectDir());
308
+ if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === current.repoId) {
309
309
  return this.#cachedBranch;
310
310
  }
311
311
 
312
- this.#cachedBranchRepoId = gitHeadPath;
313
- if (!head) {
314
- this.#cachedBranch = null;
315
- return null;
316
- }
317
-
318
- this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached";
312
+ this.#cachedBranchRepoId = current.repoId;
313
+ this.#cachedBranch = current.branch;
319
314
 
320
315
  return this.#cachedBranch ?? null;
321
316
  }
@@ -680,7 +675,11 @@ export class StatusLineComponent implements Component {
680
675
  const effectiveSettings = this.#resolveSettings();
681
676
  const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
682
677
 
683
- const bgAnsi = theme.getBgAnsi("statusLineBg");
678
+ // Use the subtle surface tone (the same elevated background as user-message
679
+ // bubbles) instead of the heavy `statusLineBg` block, so the rail layers
680
+ // just above the base background as a quiet zone rather than a solid bar.
681
+ // Resolving through a semantic slot keeps it correct across every theme.
682
+ const bgAnsi = theme.getBgAnsi("userMessageBg");
684
683
  const fgAnsi = theme.getFgAnsi("text");
685
684
  const sepAnsi = theme.getFgAnsi("statusLineSep");
686
685
 
@@ -74,9 +74,8 @@ export class WelcomeComponent implements Component {
74
74
  }
75
75
 
76
76
  render(termWidth: number): string[] {
77
- // Box dimensions - responsive with max width and small-terminal support
78
- const maxWidth = 100;
79
- const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
77
+ // Box dimensions track the live viewport so wide terminals feel intentionally full-screen.
78
+ const boxWidth = Math.max(0, termWidth - 2);
80
79
  if (boxWidth < 4) {
81
80
  return [];
82
81
  }
@@ -25,8 +25,6 @@ import type { InteractiveModeContext } from "../../modes/types";
25
25
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
26
26
 
27
27
  const MAX_WIDGET_LINES = 10;
28
- const HOOK_SELECTOR_MOUSE_REPORTING_ENABLE = "\x1b[?1006h\x1b[?1000h";
29
- const HOOK_SELECTOR_MOUSE_REPORTING_DISABLE = "\x1b[?1000l\x1b[?1006l";
30
28
  const HOOK_SELECTOR_CHROME_ROWS = 7;
31
29
  const HOOK_SELECTOR_OUTLINE_ROWS = 2;
32
30
  const HOOK_SELECTOR_INLINE_INPUT_ROWS = 2;
@@ -35,7 +33,6 @@ export class ExtensionUiController {
35
33
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
36
34
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
37
35
  #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
38
- #hookSelectorMouseReportingEnabled = false;
39
36
  #activeHookCustomComponent?: Component & { dispose?(): void };
40
37
  #activeHookCustomOverlay?: OverlayHandle;
41
38
 
@@ -624,9 +621,6 @@ export class ExtensionUiController {
624
621
  this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - inlineInputRows - HOOK_SELECTOR_CHROME_ROWS;
625
622
  const scrollTitleRows =
626
623
  requestedTitleRows === undefined ? undefined : Math.max(1, Math.min(requestedTitleRows, availableTitleRows));
627
- if (scrollTitleRows !== undefined) {
628
- this.#enableHookSelectorMouseReporting();
629
- }
630
624
 
631
625
  this.ctx.hookSelector = new HookSelectorComponent(
632
626
  title,
@@ -691,31 +685,10 @@ export class ExtensionUiController {
691
685
  return promise;
692
686
  }
693
687
 
694
- #enableHookSelectorMouseReporting(): void {
695
- if (this.#hookSelectorMouseReportingEnabled) return;
696
- this.#hookSelectorMouseReportingEnabled = true;
697
- this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_ENABLE);
698
- }
699
-
700
- #disableHookSelectorMouseReporting(): void {
701
- if (!this.#hookSelectorMouseReportingEnabled) return;
702
- this.#hookSelectorMouseReportingEnabled = false;
703
- this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_DISABLE);
704
- }
705
-
706
- #writeTerminalControl(sequence: string): void {
707
- try {
708
- this.ctx.ui.terminal.write(sequence);
709
- } catch {
710
- // Terminal teardown can race selector cleanup; normal shutdown restores modes.
711
- }
712
- }
713
-
714
688
  /**
715
689
  * Hide the hook selector.
716
690
  */
717
691
  hideHookSelector(): void {
718
- this.#disableHookSelectorMouseReporting();
719
692
  this.ctx.hookSelector?.dispose();
720
693
  this.ctx.editorContainer.clear();
721
694
  this.ctx.editorContainer.addChild(this.ctx.editor);
@@ -4,8 +4,10 @@ import type { OAuthProvider } from "@gajae-code/ai/utils/oauth/types";
4
4
  import type { Component, OverlayHandle } from "@gajae-code/tui";
5
5
  import { Input, Loader, Spacer, Text } from "@gajae-code/tui";
6
6
  import { getAgentDbPath, getProjectDir } from "@gajae-code/utils";
7
- import { activateModelProfile } from "../../config/model-profile-activation";
7
+ import { activateModelProfile, materializeActiveModelProfileAssignment } from "../../config/model-profile-activation";
8
8
  import { recommendModelProfileForProvider } from "../../config/model-profiles";
9
+ import { GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
10
+ import { formatModelSelectorValue } from "../../config/model-resolver";
9
11
  import { settings } from "../../config/settings";
10
12
  import { DebugSelectorComponent } from "../../debug";
11
13
  import { disableProvider, enableProvider } from "../../discovery";
@@ -663,7 +665,8 @@ export class SelectorController {
663
665
 
664
666
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
665
667
  this.showSelector(done => {
666
- const selector = new ModelSelectorComponent(
668
+ let modelSelector: ModelSelectorComponent;
669
+ modelSelector = new ModelSelectorComponent(
667
670
  this.ctx.ui,
668
671
  this.ctx.session.model,
669
672
  this.ctx.settings,
@@ -697,38 +700,73 @@ export class SelectorController {
697
700
  this.ctx.ui.requestRender();
698
701
  return;
699
702
  }
700
- const { model, role, thinkingLevel, selector } = selection;
703
+ const { model, role, thinkingLevel, selector: selectedSelector } = selection;
701
704
  if (role === null) {
702
705
  // Temporary: update agent state but don't persist to settings
703
706
  await this.ctx.session.setModelTemporary(model, thinkingLevel);
704
707
  this.ctx.statusLine.invalidate();
705
708
  this.ctx.updateEditorBorderColor();
706
- this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`);
709
+ this.ctx.showStatus(`Temporary model: ${selectedSelector ?? model.id}`);
707
710
  done();
708
711
  this.ctx.ui.requestRender();
709
712
  } else if (role === "default") {
710
713
  // Default: update agent state and persist as the active default model.
711
714
  await this.ctx.session.setModel(model, role, {
712
- selector,
715
+ selector: selectedSelector,
713
716
  thinkingLevel,
714
717
  });
718
+ const value = formatModelSelectorValue(
719
+ selectedSelector ?? `${model.provider}/${model.id}`,
720
+ thinkingLevel,
721
+ );
722
+ materializeActiveModelProfileAssignment({
723
+ session: this.ctx.session,
724
+ settings: this.ctx.settings,
725
+ role,
726
+ selector: value,
727
+ });
715
728
  if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
716
729
  this.ctx.session.setThinkingLevel(thinkingLevel);
717
730
  }
731
+ modelSelector.refreshRoleAssignments({
732
+ currentModel: this.ctx.session.model,
733
+ currentThinkingLevel: this.ctx.session.thinkingLevel,
734
+ activeModelProfile:
735
+ this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
736
+ });
718
737
  this.ctx.statusLine.invalidate();
719
738
  this.ctx.updateEditorBorderColor();
720
- this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
739
+ this.ctx.showStatus(`Default model: ${selectedSelector ?? model.id}`);
721
740
  done();
722
741
  this.ctx.ui.requestRender();
723
742
  } else {
724
- // Role-agent assignments configure Task dispatch and must not switch the active chat model.
725
743
  const apiKey = await this.ctx.session.modelRegistry.getApiKey(model, this.ctx.session.sessionId);
726
744
  if (!apiKey) {
727
745
  throw new Error(`No API key for ${model.provider}/${model.id}`);
728
746
  }
729
- const overrides = this.ctx.settings.get("task.agentModelOverrides");
730
- const value = selector ?? `${model.provider}/${model.id}`;
731
- this.ctx.settings.set("task.agentModelOverrides", { ...overrides, [role]: value });
747
+ const value =
748
+ selectedSelector ?? formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel);
749
+ const materializedProfile = materializeActiveModelProfileAssignment({
750
+ session: this.ctx.session,
751
+ settings: this.ctx.settings,
752
+ role,
753
+ selector: value,
754
+ });
755
+ if (!materializedProfile) {
756
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
757
+ if (target.settingsPath === "modelRoles") {
758
+ this.ctx.settings.setModelRole(role, value);
759
+ } else {
760
+ const overrides = this.ctx.settings.get("task.agentModelOverrides");
761
+ this.ctx.settings.set("task.agentModelOverrides", { ...overrides, [role]: value });
762
+ }
763
+ }
764
+ modelSelector.refreshRoleAssignments({
765
+ currentModel: this.ctx.session.model,
766
+ currentThinkingLevel: this.ctx.session.thinkingLevel,
767
+ activeModelProfile:
768
+ this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
769
+ });
732
770
  this.ctx.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
733
771
  this.ctx.showStatus(`${role} agent model: ${value}`);
734
772
  done();
@@ -745,11 +783,15 @@ export class SelectorController {
745
783
  {
746
784
  ...options,
747
785
  sessionId: this.ctx.session.sessionId,
786
+ currentThinkingLevel: this.ctx.session.thinkingLevel,
787
+ activeModelProfile:
788
+ this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
748
789
  isFastForProvider: provider => this.ctx.session.isFastForProvider(provider),
749
790
  isFastForSubagentProvider: provider => this.ctx.session.isFastForSubagentProvider(provider),
791
+ isCurrentModelFastModeActive: () => this.ctx.session.isFastModeActive(),
750
792
  },
751
793
  );
752
- return { component: selector, focus: selector };
794
+ return { component: modelSelector, focus: modelSelector };
753
795
  });
754
796
  }
755
797