@gajae-code/coding-agent 0.7.10 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +2 -2
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/config/keybindings.d.ts +11 -5
  5. package/dist/types/config/settings-schema.d.ts +25 -2
  6. package/dist/types/defaults/gjc-defaults.d.ts +7 -0
  7. package/dist/types/discovery/cursor.d.ts +4 -1
  8. package/dist/types/discovery/index.d.ts +0 -1
  9. package/dist/types/discovery/windsurf.d.ts +3 -1
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +3 -2
  12. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +1 -0
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +6 -1
  16. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  17. package/dist/types/modes/components/skill-message.d.ts +1 -0
  18. package/dist/types/modes/components/status-line.d.ts +8 -0
  19. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  20. package/dist/types/modes/components/welcome.d.ts +9 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  22. package/dist/types/modes/controllers/input-controller.d.ts +5 -1
  23. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  24. package/dist/types/modes/interactive-mode.d.ts +2 -0
  25. package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -0
  26. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  27. package/dist/types/modes/slash-command-visibility.d.ts +1 -0
  28. package/dist/types/modes/types.d.ts +2 -0
  29. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  30. package/dist/types/runtime-mcp/config.d.ts +2 -0
  31. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  32. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  33. package/dist/types/runtime-mcp/types.d.ts +6 -0
  34. package/dist/types/sdk.d.ts +1 -1
  35. package/dist/types/session/agent-session.d.ts +8 -5
  36. package/dist/types/slash-commands/types.d.ts +2 -0
  37. package/dist/types/system-prompt.d.ts +7 -0
  38. package/dist/types/utils/cmux-workspace.d.ts +30 -1
  39. package/dist/types/web/search/index.d.ts +3 -1
  40. package/dist/types/web/search/provider.d.ts +9 -0
  41. package/dist/types/web/search/providers/utils.d.ts +42 -13
  42. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  43. package/package.json +10 -10
  44. package/src/capability/mcp.ts +2 -0
  45. package/src/cli/update-cli.ts +24 -0
  46. package/src/cli/web-search-cli.ts +2 -5
  47. package/src/cli.ts +1 -1
  48. package/src/commands/launch.ts +1 -1
  49. package/src/commands/ralplan.ts +1 -0
  50. package/src/config/keybindings.ts +14 -5
  51. package/src/config/mcp-schema.json +5 -1
  52. package/src/config/model-profiles.ts +16 -0
  53. package/src/config/settings-schema.ts +18 -2
  54. package/src/coordinator-mcp/server.ts +15 -3
  55. package/src/defaults/gjc/skills/ralplan/SKILL.md +16 -14
  56. package/src/defaults/gjc-defaults.ts +16 -0
  57. package/src/discovery/builtin.ts +12 -0
  58. package/src/discovery/cursor.ts +5 -84
  59. package/src/discovery/gemini.ts +3 -89
  60. package/src/discovery/index.ts +4 -1
  61. package/src/discovery/mcp-json.ts +11 -0
  62. package/src/discovery/opencode.ts +5 -122
  63. package/src/discovery/windsurf.ts +4 -82
  64. package/src/edit/read-file.ts +9 -1
  65. package/src/extensibility/extensions/runner.ts +2 -1
  66. package/src/gjc-runtime/launch-tmux.ts +164 -42
  67. package/src/gjc-runtime/ralplan-runtime.ts +22 -6
  68. package/src/gjc-runtime/restricted-role-agent-bash.ts +1 -0
  69. package/src/gjc-runtime/session-state-sidecar.ts +167 -24
  70. package/src/gjc-runtime/state-runtime.ts +8 -5
  71. package/src/gjc-runtime/state-writer.ts +5 -5
  72. package/src/gjc-runtime/team-runtime.ts +56 -51
  73. package/src/internal-urls/docs-index.generated.ts +8 -7
  74. package/src/main.ts +2 -2
  75. package/src/migrate/executor.ts +23 -6
  76. package/src/modes/components/agent-dashboard.ts +1 -34
  77. package/src/modes/components/custom-editor.ts +26 -0
  78. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  79. package/src/modes/components/model-selector.ts +64 -4
  80. package/src/modes/components/session-selector.ts +19 -9
  81. package/src/modes/components/settings-selector.ts +13 -2
  82. package/src/modes/components/skill-message.ts +18 -10
  83. package/src/modes/components/status-line.ts +215 -86
  84. package/src/modes/components/tree-selector.ts +28 -8
  85. package/src/modes/components/two-column-body.ts +36 -0
  86. package/src/modes/components/welcome.ts +171 -35
  87. package/src/modes/controllers/command-controller.ts +23 -0
  88. package/src/modes/controllers/event-controller.ts +4 -0
  89. package/src/modes/controllers/input-controller.ts +192 -39
  90. package/src/modes/controllers/selector-controller.ts +25 -28
  91. package/src/modes/interactive-mode.ts +36 -30
  92. package/src/modes/prompt-action-autocomplete.ts +23 -1
  93. package/src/modes/rpc/rpc-types.ts +1 -0
  94. package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
  95. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  96. package/src/modes/slash-command-visibility.ts +9 -0
  97. package/src/modes/types.ts +2 -0
  98. package/src/modes/utils/hotkeys-markdown.ts +5 -2
  99. package/src/modes/utils/ui-helpers.ts +76 -3
  100. package/src/notifications/index.ts +20 -1
  101. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  102. package/src/notifications/telegram-daemon.ts +4 -4
  103. package/src/prompts/agents/architect.md +7 -4
  104. package/src/prompts/agents/critic.md +15 -6
  105. package/src/prompts/agents/planner.md +8 -3
  106. package/src/prompts/system/system-prompt.md +15 -1
  107. package/src/prompts/tools/bash.md +1 -1
  108. package/src/runtime-mcp/config-writer.ts +28 -0
  109. package/src/runtime-mcp/config.ts +7 -0
  110. package/src/runtime-mcp/loader.ts +3 -0
  111. package/src/runtime-mcp/manager.ts +3 -0
  112. package/src/runtime-mcp/types.ts +6 -0
  113. package/src/sdk.ts +7 -6
  114. package/src/session/agent-session.ts +81 -18
  115. package/src/session/session-manager.ts +58 -18
  116. package/src/slash-commands/builtin-registry.ts +30 -10
  117. package/src/slash-commands/types.ts +2 -0
  118. package/src/system-prompt.ts +8 -0
  119. package/src/task/executor.ts +5 -0
  120. package/src/tools/ast-grep.ts +10 -7
  121. package/src/tools/bash.ts +39 -14
  122. package/src/tools/index.ts +8 -1
  123. package/src/utils/cmux-workspace.ts +134 -7
  124. package/src/utils/title-generator.ts +1 -1
  125. package/src/web/kagi.ts +1 -1
  126. package/src/web/parallel.ts +2 -2
  127. package/src/web/search/index.ts +118 -45
  128. package/src/web/search/provider.ts +65 -0
  129. package/src/web/search/providers/anthropic.ts +1 -1
  130. package/src/web/search/providers/brave.ts +1 -1
  131. package/src/web/search/providers/codex.ts +1 -1
  132. package/src/web/search/providers/duckduckgo.ts +1 -1
  133. package/src/web/search/providers/exa.ts +1 -1
  134. package/src/web/search/providers/gemini.ts +7 -3
  135. package/src/web/search/providers/jina.ts +1 -1
  136. package/src/web/search/providers/kimi.ts +6 -1
  137. package/src/web/search/providers/openai-compatible.ts +1 -1
  138. package/src/web/search/providers/parallel.ts +1 -1
  139. package/src/web/search/providers/perplexity.ts +2 -2
  140. package/src/web/search/providers/searxng.ts +1 -1
  141. package/src/web/search/providers/synthetic.ts +1 -1
  142. package/src/web/search/providers/tavily.ts +1 -1
  143. package/src/web/search/providers/utils.ts +67 -17
  144. package/src/web/search/providers/xai.ts +1 -1
  145. package/src/web/search/providers/zai.ts +1 -1
  146. package/src/workflow/workflow-intent-diff.ts +199 -0
  147. package/dist/types/discovery/vscode.d.ts +0 -1
  148. package/src/discovery/vscode.ts +0 -105
package/src/main.ts CHANGED
@@ -671,7 +671,7 @@ async function buildSessionOptions(
671
671
  options.thinkingLevel = scopedModels[0].thinkingLevel;
672
672
  }
673
673
 
674
- // Scoped models for Ctrl+P cycling - fill in default thinking levels when not explicit
674
+ // Scoped models for Alt+N cycling - fill in default thinking levels when not explicit
675
675
  if (scopedModels.length > 0) {
676
676
  const defaultThinkingLevel = activeSettings.get("defaultThinkingLevel");
677
677
  options.scopedModels = scopedModels.map(scopedModel => ({
@@ -1067,7 +1067,7 @@ export async function runRootCommand(
1067
1067
  return `${scopedModel.model.id}${thinkingStr}`;
1068
1068
  })
1069
1069
  .join(", ");
1070
- process.stdout.write(`${chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)}\n`);
1070
+ process.stdout.write(`${chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Alt+N to cycle)")}`)}\n`);
1071
1071
  }
1072
1072
 
1073
1073
  if ($env.PI_TIMING) {
@@ -12,11 +12,28 @@ import * as path from "node:path";
12
12
  import { upsertMCPServer } from "../runtime-mcp/config-writer";
13
13
  import type { MigrateAction } from "./types";
14
14
 
15
- async function ensureRealDirectoryPathNoFollow(directory: string): Promise<void> {
16
- const resolved = path.resolve(directory);
17
- const parsed = path.parse(resolved);
18
- let current = parsed.root;
19
- for (const part of resolved.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
15
+ function trustedSkillDestinationBase(destination: string): string {
16
+ const skillDir = path.dirname(path.resolve(destination));
17
+ const skillsRoot = path.dirname(skillDir);
18
+ const scopeRoot = path.dirname(skillsRoot);
19
+ return path.basename(scopeRoot) === ".gjc" ? path.dirname(scopeRoot) : scopeRoot;
20
+ }
21
+
22
+ async function ensureRealDirectoryPathNoFollow(directory: string, trustedBase: string): Promise<void> {
23
+ const resolvedBase = path.resolve(trustedBase);
24
+ const resolvedDirectory = path.resolve(directory);
25
+ const relative = path.relative(resolvedBase, resolvedDirectory);
26
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
27
+ throw new Error(`skill destination escapes trusted base: ${resolvedDirectory}`);
28
+ }
29
+
30
+ const baseStat = await fs.stat(resolvedBase);
31
+ if (!baseStat.isDirectory()) {
32
+ throw new Error(`skill destination base is not a directory: ${resolvedBase}`);
33
+ }
34
+
35
+ let current = resolvedBase;
36
+ for (const part of relative.split(path.sep).filter(part => part && part !== ".")) {
20
37
  current = path.join(current, part);
21
38
  try {
22
39
  const stat = await fs.lstat(current);
@@ -36,7 +53,7 @@ async function ensureRealDirectoryPathNoFollow(directory: string): Promise<void>
36
53
 
37
54
  async function writeSkillFileNoFollow(destination: string, content: string): Promise<void> {
38
55
  const skillDir = path.dirname(destination);
39
- await ensureRealDirectoryPathNoFollow(skillDir);
56
+ await ensureRealDirectoryPathNoFollow(skillDir, trustedSkillDestinationBase(destination));
40
57
  const handle = await fs.open(
41
58
  destination,
42
59
  fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_TRUNC | fsSync.constants.O_NOFOLLOW,
@@ -23,13 +23,11 @@ import {
23
23
  extractPrintableText,
24
24
  Input,
25
25
  matchesKey,
26
- padding,
27
26
  replaceTabs,
28
27
  resolveTerminalColumns,
29
28
  Spacer,
30
29
  Text,
31
30
  truncateToWidth,
32
- visibleWidth,
33
31
  wrapTextWithAnsi,
34
32
  } from "@gajae-code/tui";
35
33
  import { isEnoent, prompt } from "@gajae-code/utils";
@@ -52,6 +50,7 @@ import { shortenPath } from "../../tools/render-utils";
52
50
  import { theme } from "../theme/theme";
53
51
  import { matchesAppInterrupt } from "../utils/keybinding-matchers";
54
52
  import { DynamicBorder } from "./dynamic-border";
53
+ import { TwoColumnBody } from "./two-column-body";
55
54
 
56
55
  type SourceTabId = "all" | AgentSource;
57
56
  type AgentScope = "project" | "user";
@@ -296,38 +295,6 @@ class AgentInspectorPane implements Component {
296
295
  invalidate(): void {}
297
296
  }
298
297
 
299
- class TwoColumnBody implements Component {
300
- constructor(
301
- private readonly leftPane: AgentListPane,
302
- private readonly rightPane: AgentInspectorPane,
303
- private readonly maxHeight: number,
304
- ) {}
305
-
306
- render(width: number): string[] {
307
- const leftWidth = Math.floor(width * 0.5);
308
- const rightWidth = width - leftWidth - 3;
309
- const leftLines = this.leftPane.render(leftWidth);
310
- const rightLines = this.rightPane.render(rightWidth);
311
- const lineCount = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
312
- const out: string[] = [];
313
- const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
314
-
315
- for (let i = 0; i < lineCount; i++) {
316
- const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
317
- const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
318
- const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
319
- out.push(leftPadded + separator + right);
320
- }
321
-
322
- return out;
323
- }
324
-
325
- invalidate(): void {
326
- this.leftPane.invalidate?.();
327
- this.rightPane.invalidate?.();
328
- }
329
- }
330
-
331
298
  export class AgentDashboard extends Container {
332
299
  #settingsManager: Settings | null = null;
333
300
  #allAgents: DashboardAgent[] = [];
@@ -9,6 +9,7 @@ type ConfigurableEditorAction = Extract<
9
9
  | "app.exit"
10
10
  | "app.suspend"
11
11
  | "app.thinking.cycle"
12
+ | "app.commandPalette.open"
12
13
  | "app.model.cycleForward"
13
14
  | "app.model.cycleBackward"
14
15
  | "app.model.select"
@@ -33,6 +34,7 @@ const CONFIGURABLE_EDITOR_ACTIONS = [
33
34
  "app.exit",
34
35
  "app.suspend",
35
36
  "app.thinking.cycle",
37
+ "app.commandPalette.open",
36
38
  "app.model.cycleForward",
37
39
  "app.model.cycleBackward",
38
40
  "app.model.select",
@@ -76,6 +78,7 @@ export class CustomEditor extends Editor {
76
78
  onClear?: () => void;
77
79
  onExit?: () => void;
78
80
  onCycleThinkingLevel?: () => void;
81
+ onOpenCommandPalette?: () => void;
79
82
  onCycleModelForward?: () => void;
80
83
  onCycleModelBackward?: () => void;
81
84
  onSelectModel?: () => void;
@@ -100,6 +103,10 @@ export class CustomEditor extends Editor {
100
103
  onQueue?: () => void;
101
104
  /** Called when Caps Lock is pressed. */
102
105
  onCapsLock?: () => void;
106
+ /** Called when PageUp/PageDown should scroll the transcript viewport instead of prompt history. */
107
+ onViewportPageScroll?: (direction: -1 | 1) => void;
108
+ /** Called before regular composer input should return the transcript viewport to the live bottom. */
109
+ onViewportFollowLive?: () => void;
103
110
 
104
111
  /** Custom key handlers from extensions and non-built-in app actions. */
105
112
  #customKeyHandlers = new Map<KeyId, () => boolean | undefined>();
@@ -240,6 +247,20 @@ export class CustomEditor extends Editor {
240
247
  return;
241
248
  }
242
249
  }
250
+ if (!this.isShowingAutocomplete()) {
251
+ if (matchesKey(data, "pageUp") && this.onViewportPageScroll) {
252
+ this.onViewportPageScroll(-1);
253
+ return;
254
+ }
255
+ if (matchesKey(data, "pageDown") && this.onViewportPageScroll) {
256
+ this.onViewportPageScroll(1);
257
+ return;
258
+ }
259
+ }
260
+
261
+ if (!matchesKey(data, "pageUp") && !matchesKey(data, "pageDown")) {
262
+ this.onViewportFollowLive?.();
263
+ }
243
264
  // Intercept configured image paste (async - fires and handles result)
244
265
  if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
245
266
  void this.onPasteImage();
@@ -288,6 +309,11 @@ export class CustomEditor extends Editor {
288
309
  return;
289
310
  }
290
311
 
312
+ // Intercept configured command palette shortcut before model cycling (Ctrl+P default).
313
+ if (this.#matchesAction(data, "app.commandPalette.open") && this.onOpenCommandPalette) {
314
+ this.onOpenCommandPalette();
315
+ return;
316
+ }
291
317
  // Intercept configured backward model cycling (check before forward cycling)
292
318
  if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
293
319
  this.onCycleModelBackward();
@@ -11,20 +11,12 @@
11
11
  * - Space: Toggle selected item (or master switch)
12
12
  * - Esc: Close dashboard (clears search first if active)
13
13
  */
14
- import {
15
- type Component,
16
- Container,
17
- matchesKey,
18
- padding,
19
- Spacer,
20
- Text,
21
- truncateToWidth,
22
- visibleWidth,
23
- } from "@gajae-code/tui";
14
+ import { Container, matchesKey, Spacer, Text } from "@gajae-code/tui";
24
15
  import { Settings } from "../../../config/settings";
25
16
  import { DynamicBorder } from "../../../modes/components/dynamic-border";
26
17
  import { theme } from "../../../modes/theme/theme";
27
18
  import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
19
+ import { TwoColumnBody } from "../two-column-body";
28
20
  import { ExtensionList } from "./extension-list";
29
21
  import { InspectorPanel } from "./inspector-panel";
30
22
  import {
@@ -310,41 +302,3 @@ export class ExtensionDashboard extends Container {
310
302
  }
311
303
  }
312
304
  }
313
-
314
- /**
315
- * Two-column body component for side-by-side rendering.
316
- */
317
- class TwoColumnBody implements Component {
318
- constructor(
319
- private readonly leftPane: ExtensionList,
320
- private readonly rightPane: InspectorPanel,
321
- private readonly maxHeight: number,
322
- ) {}
323
-
324
- render(width: number): string[] {
325
- const leftWidth = Math.floor(width * 0.5);
326
- const rightWidth = Math.max(0, width - leftWidth - 3);
327
-
328
- const leftLines = this.leftPane.render(leftWidth);
329
- const rightLines = this.rightPane.render(rightWidth);
330
-
331
- // Limit to maxHeight lines
332
- const numLines = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
333
- const combined: string[] = [];
334
- const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
335
-
336
- for (let i = 0; i < numLines; i++) {
337
- const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
338
- const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left)));
339
- const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
340
- combined.push(leftPadded + separator + right);
341
- }
342
-
343
- return combined;
344
- }
345
-
346
- invalidate(): void {
347
- this.leftPane.invalidate?.();
348
- this.rightPane.invalidate?.();
349
- }
350
- }
@@ -405,7 +405,7 @@ export class ModelSelectorComponent extends Container {
405
405
  // Create search input
406
406
  this.#searchInput = new Input();
407
407
  if (initialSearchInput) {
408
- this.#searchInput.setValue(initialSearchInput);
408
+ this.#setSearchInputValue(initialSearchInput);
409
409
  }
410
410
  this.#searchInput.onSubmit = () => {
411
411
  const selectedItem = this.#getSelectedItem();
@@ -1012,6 +1012,10 @@ export class ModelSelectorComponent extends Container {
1012
1012
  if (!this.#relocatePresetCursor(targetIdentity)) this.#clampPresetCursor();
1013
1013
  }
1014
1014
 
1015
+ #setSearchInputValue(value: string): void {
1016
+ this.#searchInput.setValue(value);
1017
+ }
1018
+
1015
1019
  #switchToModelMode(seed?: string): void {
1016
1020
  this.#viewMode = "models";
1017
1021
  this.#expandedPresetProviderId = undefined;
@@ -1021,16 +1025,56 @@ export class ModelSelectorComponent extends Container {
1021
1025
  this.#presetLoginHint = undefined;
1022
1026
  this.#activeTabIndex = 0;
1023
1027
  this.#selectedIndex = 0;
1024
- this.#searchInput.setValue(seed ?? this.#searchInput.getValue());
1028
+ this.#setSearchInputValue(seed ?? this.#searchInput.getValue());
1025
1029
  this.#updateTabBar();
1026
1030
  this.#filterModels(this.#searchInput.getValue());
1027
1031
  }
1028
1032
 
1033
+ /**
1034
+ * "What is this session running right now" summary for the preset landing
1035
+ * header: active preset (when one is applied), the effective current model
1036
+ * and thinking level, and one line per assigned role (default, executor,
1037
+ * planner, critic, architect).
1038
+ */
1039
+ #formatCurrentSessionLines(): string[] {
1040
+ const lines: string[] = [];
1041
+ const parts: string[] = [];
1042
+ if (this.#activeModelProfile) {
1043
+ const profile = this.#getProfileByName(this.#activeModelProfile);
1044
+ const displayName = profile ? getModelProfilePresentation(profile).displayName : this.#activeModelProfile;
1045
+ parts.push(`preset ${theme.fg("accent", displayName)}`);
1046
+ }
1047
+ if (this.#currentModel) {
1048
+ parts.push(this.#formatAssignedModelLabel(this.#currentModel, this.#currentThinkingLevel));
1049
+ }
1050
+ if (parts.length > 0) lines.push(theme.fg("muted", `Current: ${parts.join(" · ")}`));
1051
+ for (const role of PROFILE_ROLE_PREVIEW_ORDER) {
1052
+ const assigned = this.#roles[role];
1053
+ if (!assigned) continue;
1054
+ const label = GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase();
1055
+ lines.push(
1056
+ theme.fg("dim", ` ${label}: ${this.#formatAssignedModelLabel(assigned.model, assigned.thinkingLevel)}`),
1057
+ );
1058
+ }
1059
+ return lines;
1060
+ }
1061
+
1062
+ #formatAssignedModelLabel(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1063
+ let label = `${model.provider}/${model.id}`;
1064
+ if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
1065
+ label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`;
1066
+ }
1067
+ return label;
1068
+ }
1069
+
1029
1070
  #renderPresetLanding(): void {
1030
1071
  this.#headerContainer.clear();
1031
1072
  this.#tabBar = null;
1032
1073
  this.#listContainer.clear();
1033
1074
  this.#headerContainer.addChild(new Text(theme.fg("accent", "Model presets"), 0, 0));
1075
+ for (const line of this.#formatCurrentSessionLines()) {
1076
+ this.#headerContainer.addChild(new Text(line, 0, 0));
1077
+ }
1034
1078
  const rows = this.#getPresetRows();
1035
1079
  for (let i = 0; i < rows.length; i++) {
1036
1080
  const row = rows[i];
@@ -1061,17 +1105,23 @@ export class ModelSelectorComponent extends Container {
1061
1105
  if (row.kind === "group") {
1062
1106
  const authenticated = this.#isPresetGroupUsable(row.profiles);
1063
1107
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
1108
+ const containsActive =
1109
+ this.#activeModelProfile !== undefined &&
1110
+ row.profiles.some(profile => profile.name === this.#activeModelProfile);
1064
1111
  const label = `${mark} ${row.groupId}`;
1065
1112
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
1066
- this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1113
+ const activeSuffix = containsActive ? theme.fg("muted", " (current)") : "";
1114
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}${activeSuffix}`, 0, 0));
1067
1115
  continue;
1068
1116
  }
1069
1117
  const presentation = getModelProfilePresentation(row.profile);
1070
1118
  const authenticated = this.#isPresetAuthenticated(row.profile);
1071
1119
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
1120
+ const isActive = row.profile.name === this.#activeModelProfile;
1072
1121
  const label = ` ${mark} ${presentation.displayName}`;
1073
1122
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
1074
- this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1123
+ const activeSuffix = isActive ? theme.fg("muted", " (current)") : "";
1124
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}${activeSuffix}`, 0, 0));
1075
1125
  }
1076
1126
  if (this.#presetLoginHint) {
1077
1127
  this.#listContainer.addChild(new Spacer(1));
@@ -1534,7 +1584,17 @@ export class ModelSelectorComponent extends Container {
1534
1584
  const missing = this.#getMissingProviders(row.profiles);
1535
1585
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1536
1586
  this.#renderPresetLanding();
1587
+ return;
1537
1588
  }
1589
+ // Enter toggles a usable group's expansion so drilling into presets
1590
+ // works without reaching for the arrow keys (right/left still work).
1591
+ if (this.#expandedPresetProviderId === row.groupId) {
1592
+ this.#collapseSelectedPresetProvider();
1593
+ } else {
1594
+ this.#expandSelectedPresetProvider();
1595
+ }
1596
+ this.#presetLoginHint = undefined;
1597
+ this.#renderPresetLanding();
1538
1598
  return;
1539
1599
  }
1540
1600
  const missing = this.#getMissingProviders(row.profile);
@@ -50,6 +50,14 @@ class SessionList implements Component {
50
50
  };
51
51
  }
52
52
 
53
+ #clampSelectedIndex(): void {
54
+ if (this.#filteredSessions.length === 0) {
55
+ this.#selectedIndex = 0;
56
+ return;
57
+ }
58
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#filteredSessions.length - 1));
59
+ }
60
+
53
61
  #filterSessions(query: string): void {
54
62
  this.#filteredSessions = fuzzyFilter(this.allSessions, query, session => {
55
63
  const parts = [
@@ -62,7 +70,7 @@ class SessionList implements Component {
62
70
  ];
63
71
  return parts.filter(Boolean).join(" ");
64
72
  });
65
- this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
73
+ this.#clampSelectedIndex();
66
74
  }
67
75
 
68
76
  removeSession(sessionPath: string): void {
@@ -71,10 +79,6 @@ class SessionList implements Component {
71
79
  this.allSessions.splice(index, 1);
72
80
  // Re-filter to update filteredSessions
73
81
  this.#filterSessions(this.#searchInput.getValue());
74
- // Adjust selectedIndex if we deleted the last item or beyond
75
- if (this.#selectedIndex >= this.#filteredSessions.length) {
76
- this.#selectedIndex = Math.max(0, this.#filteredSessions.length - 1);
77
- }
78
82
  }
79
83
 
80
84
  invalidate(): void {
@@ -182,6 +186,12 @@ class SessionList implements Component {
182
186
  }
183
187
 
184
188
  handleInput(keyData: string): void {
189
+ const moveSelection = (delta: number): void => {
190
+ if (this.#filteredSessions.length === 0) return;
191
+ this.#selectedIndex += delta;
192
+ this.#clampSelectedIndex();
193
+ };
194
+
185
195
  // Delete key - request delete confirmation from parent
186
196
  if (matchesKey(keyData, "delete")) {
187
197
  const selected = this.#filteredSessions[this.#selectedIndex];
@@ -193,22 +203,22 @@ class SessionList implements Component {
193
203
 
194
204
  // Up arrow
195
205
  if (matchesKey(keyData, "up")) {
196
- this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
206
+ moveSelection(-1);
197
207
  return;
198
208
  }
199
209
  // Down arrow
200
210
  if (matchesKey(keyData, "down")) {
201
- this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1);
211
+ moveSelection(1);
202
212
  return;
203
213
  }
204
214
  // Page up - jump up by maxVisible items
205
215
  if (matchesKey(keyData, "pageUp")) {
206
- this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
216
+ moveSelection(-this.#maxVisible);
207
217
  return;
208
218
  }
209
219
  // Page down - jump down by maxVisible items
210
220
  if (matchesKey(keyData, "pageDown")) {
211
- this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#maxVisible);
221
+ moveSelection(this.#maxVisible);
212
222
  return;
213
223
  }
214
224
  // Enter
@@ -60,8 +60,6 @@ class TextInputSubmenu extends Container {
60
60
  this.#input = new Input();
61
61
  if (currentValue) {
62
62
  this.#input.setValue(currentValue);
63
- // Move cursor to end of pre-filled value (ctrl+e = cursorLineEnd).
64
- this.#input.handleInput("\x05");
65
63
  }
66
64
  this.#input.onSubmit = value => {
67
65
  this.onSubmit(value); // empty string clears the setting
@@ -672,6 +670,7 @@ export interface StatusLinePreviewSettings {
672
670
  segmentOptions?: StatusLineSegmentOptions;
673
671
  previewHighlightSegment?: StatusLineSegmentId;
674
672
  sessionAccent?: boolean;
673
+ maxRows?: number;
675
674
  }
676
675
 
677
676
  export interface SettingsCallbacks {
@@ -910,6 +909,18 @@ export class SettingsSelectorComponent extends Container {
910
909
  this.callbacks.onStatusLinePreview?.({ separator, previewHighlightSegment: undefined });
911
910
  this.#updateStatusPreview();
912
911
  };
912
+ } else if (def.path === "statusLine.maxRows") {
913
+ onPreview = value => {
914
+ this.callbacks.onStatusLinePreview?.({ maxRows: Number(value) });
915
+ this.#updateStatusPreview();
916
+ };
917
+ onPreviewCancel = () => {
918
+ this.callbacks.onStatusLinePreview?.({
919
+ maxRows: settings.get("statusLine.maxRows"),
920
+ previewHighlightSegment: undefined,
921
+ });
922
+ this.#updateStatusPreview();
923
+ };
913
924
  }
914
925
 
915
926
  // Provide status line preview for theme selection
@@ -13,18 +13,20 @@ import {
13
13
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
14
14
  import type { CustomMessage, SkillPromptDetails } from "../../session/messages";
15
15
 
16
- const COLLAPSED_ARGS_PREVIEW_WIDTH = 96;
16
+ const DEFAULT_COLLAPSED_ARGS_PREVIEW_WIDTH = 96;
17
17
  const COLLAPSED_ARGS_PREVIEW_LINES = 6;
18
+ const SKILL_BOX_HORIZONTAL_PADDING = 1;
18
19
  export class SkillMessageComponent extends Container {
19
20
  #box: Box;
20
21
  #contentComponent?: Component;
21
22
  #expanded = false;
23
+ #argsPreviewWidth = DEFAULT_COLLAPSED_ARGS_PREVIEW_WIDTH;
22
24
 
23
25
  constructor(private readonly message: CustomMessage<SkillPromptDetails>) {
24
26
  super();
25
27
  this.addChild(new Spacer(1));
26
28
 
27
- this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t));
29
+ this.#box = new Box(SKILL_BOX_HORIZONTAL_PADDING, 1, t => theme.bg("customMessageBg", t));
28
30
  this.#rebuild();
29
31
  }
30
32
 
@@ -40,6 +42,15 @@ export class SkillMessageComponent extends Container {
40
42
  this.#rebuild();
41
43
  }
42
44
 
45
+ override render(width: number): string[] {
46
+ const contentWidth = Math.max(1, width - SKILL_BOX_HORIZONTAL_PADDING * 2);
47
+ if (this.#argsPreviewWidth !== contentWidth) {
48
+ this.#argsPreviewWidth = contentWidth;
49
+ this.#rebuild();
50
+ }
51
+ return super.render(width);
52
+ }
53
+
43
54
  #rebuild(): void {
44
55
  if (this.#contentComponent) {
45
56
  this.removeChild(this.#contentComponent);
@@ -57,7 +68,7 @@ export class SkillMessageComponent extends Container {
57
68
  // Collapsed view keeps args readable without exposing the full prompt body.
58
69
  // Each visual line is bounded, but multi-line arguments remain multi-line so
59
70
  // the invocation context is not mistaken for a one-line truncated payload.
60
- const argsPreview = args ? this.#formatArgsPreview(args) : [];
71
+ const argsPreview = args ? this.#formatArgsPreview(args, this.#argsPreviewWidth) : [];
61
72
  const header = `${theme.fg("customMessageLabel", theme.bold("[skill]"))} ${theme.fg("customMessageText", name)}`;
62
73
  this.#box.addChild(new Text(header, 0, 0));
63
74
  if (argsPreview.length > 0) {
@@ -111,19 +122,19 @@ export class SkillMessageComponent extends Container {
111
122
  this.#box.addChild(this.#contentComponent);
112
123
  }
113
124
 
114
- #formatArgsPreview(args: string): string[] {
125
+ #formatArgsPreview(args: string, width: number): string[] {
115
126
  const preview: string[] = [];
116
127
  let omitted = false;
117
128
  const sourceLines = replaceTabs(args).split(/\r?\n/);
118
129
  for (let index = 0; index < sourceLines.length; index += 1) {
119
130
  const sourceLine = sourceLines[index]?.trimEnd() ?? "";
120
- const wrapped = wrapTextWithAnsi(sourceLine.length > 0 ? sourceLine : " ", COLLAPSED_ARGS_PREVIEW_WIDTH);
131
+ const wrapped = wrapTextWithAnsi(sourceLine.length > 0 ? sourceLine : " ", width);
121
132
  for (const line of wrapped.length > 0 ? wrapped : [""]) {
122
133
  if (preview.length >= COLLAPSED_ARGS_PREVIEW_LINES) {
123
134
  omitted = true;
124
135
  break;
125
136
  }
126
- preview.push(truncateToWidth(line, COLLAPSED_ARGS_PREVIEW_WIDTH));
137
+ preview.push(truncateToWidth(line, width));
127
138
  }
128
139
  if (omitted) break;
129
140
  }
@@ -132,10 +143,7 @@ export class SkillMessageComponent extends Container {
132
143
  omitted ||= replaceTabs(args).trimEnd().length > visibleSource.trimEnd().length;
133
144
  }
134
145
  if (omitted && preview.length > 0) {
135
- preview[preview.length - 1] = truncateToWidth(
136
- `${preview[preview.length - 1]} …`,
137
- COLLAPSED_ARGS_PREVIEW_WIDTH,
138
- );
146
+ preview[preview.length - 1] = truncateToWidth(`${preview[preview.length - 1]} …`, width);
139
147
  }
140
148
  return preview;
141
149
  }