@sayknow-cli/coding-agent 0.3.5 → 0.3.7

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 (238) hide show
  1. package/README.md +76 -0
  2. package/dist/types/capability/mcp.d.ts +2 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/telegram-cli.d.ts +13 -0
  6. package/dist/types/cli/update-cli.d.ts +24 -1
  7. package/dist/types/commands/local-provider.d.ts +34 -0
  8. package/dist/types/commands/telegram.d.ts +22 -0
  9. package/dist/types/config/keybindings.d.ts +12 -6
  10. package/dist/types/config/model-registry.d.ts +5 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +284 -4
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/config/telegram-autostart.d.ts +14 -0
  15. package/dist/types/config/telegram-env-bridge.d.ts +18 -0
  16. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  17. package/dist/types/defaults/skc-defaults.d.ts +7 -0
  18. package/dist/types/discovery/cursor.d.ts +4 -1
  19. package/dist/types/discovery/index.d.ts +0 -1
  20. package/dist/types/discovery/windsurf.d.ts +3 -1
  21. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  22. package/dist/types/i18n/messages/en.d.ts +1 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +6 -1
  24. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  25. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  26. package/dist/types/modes/components/model-selector.d.ts +2 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +5 -1
  28. package/dist/types/modes/components/skill-message.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  30. package/dist/types/modes/components/status-line.d.ts +15 -3
  31. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  32. package/dist/types/modes/components/welcome.d.ts +9 -1
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/input-controller.d.ts +6 -1
  35. package/dist/types/modes/controllers/selector-controller.d.ts +10 -1
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/prompt-action-autocomplete.d.ts +3 -0
  38. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  40. package/dist/types/modes/slash-command-visibility.d.ts +1 -0
  41. package/dist/types/modes/theme/theme.d.ts +1 -1
  42. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  43. package/dist/types/modes/types.d.ts +5 -1
  44. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  45. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  46. package/dist/types/notifications/html-format.d.ts +2 -0
  47. package/dist/types/notifications/recent-activity.d.ts +5 -1
  48. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  49. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  50. package/dist/types/notifications/telegram-daemon.d.ts +4 -10
  51. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  52. package/dist/types/notifications/threaded-render.d.ts +1 -0
  53. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  54. package/dist/types/runtime-mcp/config.d.ts +2 -0
  55. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  56. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  57. package/dist/types/runtime-mcp/types.d.ts +6 -0
  58. package/dist/types/sdk.d.ts +1 -1
  59. package/dist/types/skc-runtime/launch-tmux.d.ts +2 -2
  60. package/dist/types/skc-runtime/ralplan-runtime.d.ts +3 -2
  61. package/dist/types/skc-runtime/restricted-role-agent-bash.d.ts +1 -0
  62. package/dist/types/skc-runtime/session-state-sidecar.d.ts +5 -0
  63. package/dist/types/skc-runtime/state-writer.d.ts +1 -0
  64. package/dist/types/skc-runtime/team-runtime.d.ts +1 -1
  65. package/dist/types/skc-runtime/tmux-common.d.ts +12 -0
  66. package/dist/types/skc-runtime/ultragoal-guard.d.ts +7 -1
  67. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +0 -4
  68. package/dist/types/slash-commands/types.d.ts +2 -0
  69. package/dist/types/system-prompt.d.ts +7 -0
  70. package/dist/types/tools/skill.d.ts +1 -1
  71. package/dist/types/utils/cmux-workspace.d.ts +52 -0
  72. package/dist/types/web/search/index.d.ts +4 -2
  73. package/dist/types/web/search/provider.d.ts +14 -0
  74. package/dist/types/web/search/providers/utils.d.ts +42 -13
  75. package/dist/types/web/search/render.d.ts +1 -0
  76. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  77. package/package.json +8 -8
  78. package/scripts/build-binary.ts +3 -0
  79. package/scripts/verify-insane-vendor.ts +0 -5
  80. package/src/capability/mcp.ts +2 -0
  81. package/src/cli/local-provider-smoke.ts +632 -0
  82. package/src/cli/notify-cli.ts +4 -2
  83. package/src/cli/telegram-cli.ts +156 -0
  84. package/src/cli/update-cli.ts +179 -13
  85. package/src/cli/web-search-cli.ts +5 -7
  86. package/src/cli.ts +20 -1
  87. package/src/commands/launch.ts +47 -2
  88. package/src/commands/local-provider.ts +62 -0
  89. package/src/commands/notify.ts +1 -1
  90. package/src/commands/ralplan.ts +1 -0
  91. package/src/commands/telegram.ts +47 -0
  92. package/src/config/keybindings.ts +15 -6
  93. package/src/config/mcp-schema.json +4 -0
  94. package/src/config/model-registry.ts +55 -1
  95. package/src/config/models-config-schema.ts +9 -0
  96. package/src/config/settings-schema.ts +256 -4
  97. package/src/config/settings.ts +11 -0
  98. package/src/config/telegram-autostart.ts +107 -0
  99. package/src/config/telegram-env-bridge.ts +92 -0
  100. package/src/coordinator-mcp/server.ts +276 -34
  101. package/src/defaults/skc/skills/ralplan/SKILL.md +29 -20
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +37 -112
  103. package/src/defaults/skc-defaults.ts +16 -0
  104. package/src/discovery/builtin.ts +12 -0
  105. package/src/discovery/cursor.ts +5 -84
  106. package/src/discovery/gemini.ts +3 -89
  107. package/src/discovery/index.ts +4 -1
  108. package/src/discovery/mcp-json.ts +11 -0
  109. package/src/discovery/opencode.ts +5 -122
  110. package/src/discovery/windsurf.ts +4 -82
  111. package/src/edit/read-file.ts +9 -1
  112. package/src/extensibility/extensions/runner.ts +2 -1
  113. package/src/extensibility/extensions/types.ts +11 -0
  114. package/src/extensibility/skills.ts +30 -2
  115. package/src/hooks/skill-state.ts +33 -73
  116. package/src/i18n/messages/de.ts +1 -0
  117. package/src/i18n/messages/en.ts +1 -0
  118. package/src/i18n/messages/es.ts +1 -0
  119. package/src/i18n/messages/fr.ts +1 -0
  120. package/src/i18n/messages/ja.settings.ts +28 -0
  121. package/src/i18n/messages/ja.ts +1 -0
  122. package/src/i18n/messages/ko.settings.ts +28 -0
  123. package/src/i18n/messages/ko.ts +1 -0
  124. package/src/i18n/messages/zh.settings.ts +28 -0
  125. package/src/i18n/messages/zh.ts +1 -0
  126. package/src/internal-urls/docs-index.generated.ts +12 -7
  127. package/src/main.ts +5 -2
  128. package/src/migrate/executor.ts +23 -6
  129. package/src/modes/acp/acp-agent.ts +7 -6
  130. package/src/modes/components/agent-dashboard.ts +3 -35
  131. package/src/modes/components/custom-editor.ts +26 -0
  132. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  133. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  134. package/src/modes/components/hook-selector.ts +21 -3
  135. package/src/modes/components/model-selector.ts +246 -11
  136. package/src/modes/components/session-observer-overlay.ts +3 -3
  137. package/src/modes/components/session-selector.ts +19 -9
  138. package/src/modes/components/settings-selector.ts +551 -11
  139. package/src/modes/components/skill-message.ts +18 -10
  140. package/src/modes/components/status-line/presets.ts +11 -0
  141. package/src/modes/components/status-line/segments.ts +16 -19
  142. package/src/modes/components/status-line/types.ts +6 -2
  143. package/src/modes/components/status-line.ts +281 -117
  144. package/src/modes/components/tree-selector.ts +28 -8
  145. package/src/modes/components/two-column-body.ts +36 -0
  146. package/src/modes/components/welcome.ts +165 -30
  147. package/src/modes/controllers/command-controller.ts +26 -2
  148. package/src/modes/controllers/event-controller.ts +102 -4
  149. package/src/modes/controllers/extension-ui-controller.ts +21 -2
  150. package/src/modes/controllers/input-controller.ts +214 -47
  151. package/src/modes/controllers/selector-controller.ts +35 -33
  152. package/src/modes/interactive-mode.ts +36 -30
  153. package/src/modes/prompt-action-autocomplete.ts +48 -8
  154. package/src/modes/rpc/rpc-types.ts +1 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +72 -15
  156. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  157. package/src/modes/slash-command-visibility.ts +9 -0
  158. package/src/modes/theme/theme.ts +5 -1
  159. package/src/modes/tmux-scroll.ts +92 -0
  160. package/src/modes/types.ts +8 -1
  161. package/src/modes/utils/hotkeys-markdown.ts +5 -2
  162. package/src/modes/utils/terminal-bell.ts +48 -0
  163. package/src/modes/utils/ui-helpers.ts +76 -3
  164. package/src/notifications/daemon-paths.ts +22 -0
  165. package/src/notifications/html-format.ts +75 -1
  166. package/src/notifications/index.ts +32 -2
  167. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  168. package/src/notifications/recent-activity.ts +32 -8
  169. package/src/notifications/telegram-daemon-cli.ts +125 -13
  170. package/src/notifications/telegram-daemon-control.ts +9 -0
  171. package/src/notifications/telegram-daemon.ts +88 -46
  172. package/src/notifications/telegram-reference.ts +36 -14
  173. package/src/notifications/threaded-render.ts +14 -6
  174. package/src/prompts/agents/architect.md +7 -4
  175. package/src/prompts/agents/critic.md +15 -6
  176. package/src/prompts/agents/planner.md +8 -3
  177. package/src/prompts/system/system-prompt.md +20 -2
  178. package/src/prompts/tools/bash.md +1 -1
  179. package/src/runtime-mcp/config-writer.ts +28 -0
  180. package/src/runtime-mcp/config.ts +7 -0
  181. package/src/runtime-mcp/loader.ts +3 -0
  182. package/src/runtime-mcp/manager.ts +3 -0
  183. package/src/runtime-mcp/types.ts +6 -0
  184. package/src/sdk.ts +10 -11
  185. package/src/session/agent-session.ts +103 -13
  186. package/src/session/session-manager.ts +58 -18
  187. package/src/skc-runtime/launch-tmux.ts +34 -8
  188. package/src/skc-runtime/launch-worktree.ts +16 -2
  189. package/src/skc-runtime/ralplan-runtime.ts +22 -6
  190. package/src/skc-runtime/restricted-role-agent-bash.ts +1 -0
  191. package/src/skc-runtime/session-state-sidecar.ts +167 -24
  192. package/src/skc-runtime/state-runtime.ts +8 -5
  193. package/src/skc-runtime/state-writer.ts +5 -5
  194. package/src/skc-runtime/team-runtime.ts +21 -6
  195. package/src/skc-runtime/tmux-common.ts +16 -0
  196. package/src/skc-runtime/tmux-sessions.ts +13 -9
  197. package/src/skc-runtime/ultragoal-guard.ts +150 -24
  198. package/src/skc-runtime/ultragoal-runtime.ts +16 -124
  199. package/src/skc-runtime/workflow-manifest.generated.json +0 -8
  200. package/src/skc-runtime/workflow-manifest.ts +0 -1
  201. package/src/slash-commands/builtin-registry.ts +136 -23
  202. package/src/slash-commands/types.ts +2 -0
  203. package/src/system-prompt.ts +8 -0
  204. package/src/task/executor.ts +5 -0
  205. package/src/tools/ast-grep.ts +10 -7
  206. package/src/tools/bash.ts +39 -14
  207. package/src/tools/index.ts +8 -1
  208. package/src/tools/skill.ts +1 -1
  209. package/src/utils/cmux-workspace.ts +233 -0
  210. package/src/utils/title-generator.ts +2 -0
  211. package/src/web/kagi.ts +1 -1
  212. package/src/web/parallel.ts +2 -2
  213. package/src/web/search/index.ts +137 -52
  214. package/src/web/search/provider.ts +80 -0
  215. package/src/web/search/providers/anthropic.ts +1 -1
  216. package/src/web/search/providers/brave.ts +1 -1
  217. package/src/web/search/providers/codex.ts +1 -1
  218. package/src/web/search/providers/duckduckgo.ts +1 -1
  219. package/src/web/search/providers/exa.ts +1 -1
  220. package/src/web/search/providers/gemini.ts +7 -3
  221. package/src/web/search/providers/jina.ts +1 -1
  222. package/src/web/search/providers/kimi.ts +6 -1
  223. package/src/web/search/providers/openai-compatible.ts +1 -1
  224. package/src/web/search/providers/parallel.ts +1 -1
  225. package/src/web/search/providers/perplexity.ts +2 -2
  226. package/src/web/search/providers/searxng.ts +1 -1
  227. package/src/web/search/providers/synthetic.ts +1 -1
  228. package/src/web/search/providers/tavily.ts +1 -1
  229. package/src/web/search/providers/utils.ts +67 -17
  230. package/src/web/search/providers/xai.ts +1 -1
  231. package/src/web/search/providers/zai.ts +1 -1
  232. package/src/web/search/render.ts +3 -0
  233. package/src/workflow/workflow-intent-diff.ts +199 -0
  234. package/vendor/insane-search/MANIFEST.json +1 -3
  235. package/vendor/insane-search/engine/tests/test_u1.py +0 -28
  236. package/vendor/insane-search/engine/transport.py +2 -3
  237. package/vendor/insane-search/engine/validators.py +0 -14
  238. package/src/defaults/skc/rules/ponytail.md +0 -68
@@ -29,6 +29,7 @@ import {
29
29
  resolveModelRoleValue,
30
30
  type ScopedModelSelection,
31
31
  } from "../../config/model-resolver";
32
+ import type { ModelProfileConfig } from "../../config/models-config-schema";
32
33
  import type { Settings } from "../../config/settings";
33
34
  import { t } from "../../i18n";
34
35
  import { type ThemeColor, theme } from "../../modes/theme/theme";
@@ -107,10 +108,9 @@ export type ModelSelectorSelection =
107
108
  }
108
109
  | {
109
110
  kind: "createProfile";
111
+ profile: ModelProfileConfig;
110
112
  }
111
113
  | {
112
- // User picked an unauthenticated preset/model — launch OAuth login for
113
- // the first missing provider instead of only printing a hint.
114
114
  kind: "login";
115
115
  providerId: string;
116
116
  };
@@ -163,11 +163,27 @@ interface PresetCreateRow {
163
163
  kind: "create";
164
164
  }
165
165
 
166
+ interface PresetCreateUnavailableRow {
167
+ kind: "createUnavailable";
168
+ label: string;
169
+ }
170
+
171
+ interface PresetAlreadySavedRow {
172
+ kind: "alreadySaved";
173
+ profileName: string;
174
+ }
175
+
166
176
  interface PresetBrowseRow {
167
177
  kind: "browse";
168
178
  }
169
179
 
170
- type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetCreateRow | PresetBrowseRow;
180
+ type PresetLandingRow =
181
+ | PresetGroupRow
182
+ | PresetProfileRow
183
+ | PresetCreateRow
184
+ | PresetCreateUnavailableRow
185
+ | PresetAlreadySavedRow
186
+ | PresetBrowseRow;
171
187
 
172
188
  // Stable logical identity for a preset landing row, independent of its current
173
189
  // list position. Used to relocate the cursor after the expanded group changes so
@@ -182,6 +198,10 @@ function presetRowIdentity(row: PresetLandingRow): string {
182
198
  return "browse";
183
199
  case "create":
184
200
  return "create";
201
+ case "createUnavailable":
202
+ return "createUnavailable";
203
+ case "alreadySaved":
204
+ return `alreadySaved:${row.profileName}`;
185
205
  }
186
206
  }
187
207
 
@@ -201,6 +221,64 @@ function isPrintableCharacter(keyData: string): boolean {
201
221
  function profileRequiredProviders(profile: ModelProfileDefinition): string[] {
202
222
  return [...new Set(profile.requiredProviders)].sort((a, b) => a.localeCompare(b));
203
223
  }
224
+
225
+ function isInheritedRoleSelector(value: string | undefined): boolean {
226
+ const normalized = value?.trim();
227
+ return !normalized || normalized === "default" || normalized === "pi/default";
228
+ }
229
+
230
+ function getDefaultAliasThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
231
+ const normalized = value?.trim();
232
+ if (!normalized?.startsWith("pi/default:")) return undefined;
233
+ return parseThinkingLevel(normalized.slice("pi/default:".length));
234
+ }
235
+
236
+ function getSelectorProvider(selector: string): string | undefined {
237
+ const slashIndex = selector.indexOf("/");
238
+ return slashIndex > 0 ? selector.slice(0, slashIndex) : undefined;
239
+ }
240
+
241
+ function deriveRequiredProviders(modelMapping: ModelProfileConfig["model_mapping"]): string[] {
242
+ const providers = new Set<string>();
243
+ for (const selector of Object.values(modelMapping)) {
244
+ const provider = getSelectorProvider(selector);
245
+ if (provider) providers.add(provider);
246
+ }
247
+ return [...providers].sort((a, b) => a.localeCompare(b));
248
+ }
249
+
250
+ function sameStringRecord(
251
+ left: Readonly<Record<string, string | undefined>>,
252
+ right: Readonly<Record<string, string | undefined>>,
253
+ ): boolean {
254
+ const leftEntries = Object.entries(left)
255
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
256
+ .sort(([a], [b]) => a.localeCompare(b));
257
+ const rightEntries = Object.entries(right)
258
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
259
+ .sort(([a], [b]) => a.localeCompare(b));
260
+ if (leftEntries.length !== rightEntries.length) return false;
261
+ for (let i = 0; i < leftEntries.length; i++) {
262
+ const leftEntry = leftEntries[i];
263
+ const rightEntry = rightEntries[i];
264
+ if (!leftEntry || !rightEntry || leftEntry[0] !== rightEntry[0] || leftEntry[1] !== rightEntry[1]) {
265
+ return false;
266
+ }
267
+ }
268
+ return true;
269
+ }
270
+
271
+ function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
272
+ if (left.length !== right.length) return false;
273
+ for (let i = 0; i < left.length; i++) {
274
+ if (left[i] !== right[i]) return false;
275
+ }
276
+ return true;
277
+ }
278
+
279
+ function hasPersistableProfileSnapshot(snapshot: ModelProfileConfig): boolean {
280
+ return Object.keys(snapshot.model_mapping).length > 0 && snapshot.required_providers.length > 0;
281
+ }
204
282
  /**
205
283
  * Component that renders a canonical model selector with provider tabs.
206
284
  * - Preset landing Left/Right: Collapse/expand selected provider
@@ -319,7 +397,7 @@ export class ModelSelectorComponent extends Container {
319
397
  // Create search input
320
398
  this.#searchInput = new Input();
321
399
  if (initialSearchInput) {
322
- this.#searchInput.setValue(initialSearchInput);
400
+ this.#setSearchInputValue(initialSearchInput);
323
401
  }
324
402
  this.#searchInput.onSubmit = () => {
325
403
  const selectedItem = this.#getSelectedItem();
@@ -740,6 +818,67 @@ export class ModelSelectorComponent extends Container {
740
818
  return groupModelProfilesForPresetLanding(this.#modelRegistry.getModelProfiles?.() ?? new Map());
741
819
  }
742
820
 
821
+ #formatCurrentModelSelector(
822
+ thinkingLevel: ThinkingLevel | undefined = this.#currentThinkingLevel,
823
+ ): string | undefined {
824
+ if (!this.#currentModel) return undefined;
825
+ return formatModelSelectorValue(`${this.#currentModel.provider}/${this.#currentModel.id}`, thinkingLevel);
826
+ }
827
+
828
+ #resolveProfileModelSelector(value: string | undefined): string | undefined {
829
+ const normalized = value?.trim();
830
+ if (isInheritedRoleSelector(normalized)) return undefined;
831
+
832
+ const resolved = resolveModelRoleValue(normalized, this.#modelRegistry.getAll(), {
833
+ settings: this.#settings,
834
+ matchPreferences: { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() },
835
+ modelRegistry: this.#modelRegistry,
836
+ });
837
+ if (resolved.model) {
838
+ return formatModelSelectorValue(`${resolved.model.provider}/${resolved.model.id}`, resolved.thinkingLevel);
839
+ }
840
+
841
+ const inheritedDefaultThinkingLevel = getDefaultAliasThinkingLevel(normalized);
842
+ if (inheritedDefaultThinkingLevel) return this.#formatCurrentModelSelector(inheritedDefaultThinkingLevel);
843
+ return undefined;
844
+ }
845
+
846
+ #buildCustomModelProfileSnapshot(): ModelProfileConfig {
847
+ const modelMapping: ModelProfileConfig["model_mapping"] = {};
848
+ const currentModelSelector = this.#formatCurrentModelSelector();
849
+ if (currentModelSelector) {
850
+ modelMapping.default = currentModelSelector;
851
+ } else {
852
+ const defaultRole = this.#settings.getModelRole("default");
853
+ const defaultSelector = this.#resolveProfileModelSelector(defaultRole);
854
+ if (defaultSelector) modelMapping.default = defaultSelector;
855
+ }
856
+
857
+ const agentOverrides = this.#settings.get("task.agentModelOverrides");
858
+ for (const role of SKC_MODEL_ASSIGNMENT_TARGET_IDS) {
859
+ if (role === "default") continue;
860
+ const selector = this.#resolveProfileModelSelector(agentOverrides[role]);
861
+ if (selector) modelMapping[role] = selector;
862
+ }
863
+
864
+ return {
865
+ required_providers: deriveRequiredProviders(modelMapping),
866
+ model_mapping: modelMapping,
867
+ };
868
+ }
869
+
870
+ #findDuplicateGeneratedProfile(snapshot: ModelProfileConfig): ModelProfileDefinition | undefined {
871
+ for (const profile of this.#modelRegistry.getModelProfiles?.().values() ?? []) {
872
+ if (
873
+ sameStringRecord(profile.modelMapping, snapshot.model_mapping) &&
874
+ sameStringArray(profile.requiredProviders, snapshot.required_providers)
875
+ ) {
876
+ return profile;
877
+ }
878
+ }
879
+ return undefined;
880
+ }
881
+
743
882
  #getPresetRows(): PresetLandingRow[] {
744
883
  const rows: PresetLandingRow[] = [];
745
884
  for (const [groupId, profiles] of this.#getPresetGroups()) {
@@ -748,7 +887,15 @@ export class ModelSelectorComponent extends Container {
748
887
  for (const profile of profiles) rows.push({ kind: "profile", groupId, profile });
749
888
  }
750
889
  }
751
- rows.push({ kind: "create" });
890
+ const snapshot = this.#buildCustomModelProfileSnapshot();
891
+ if (hasPersistableProfileSnapshot(snapshot)) {
892
+ const duplicateProfile = this.#findDuplicateGeneratedProfile(snapshot);
893
+ rows.push(
894
+ duplicateProfile ? { kind: "alreadySaved", profileName: duplicateProfile.name } : { kind: "create" },
895
+ );
896
+ } else {
897
+ rows.push({ kind: "createUnavailable", label: "Select a model before creating a custom preset" });
898
+ }
752
899
  rows.push({ kind: "browse" });
753
900
  return rows;
754
901
  }
@@ -835,7 +982,14 @@ export class ModelSelectorComponent extends Container {
835
982
 
836
983
  #expandSelectedPresetProvider(): void {
837
984
  const selected = this.#getSelectedPresetRow();
838
- if (!selected || selected.kind === "browse" || selected.kind === "create") return;
985
+ if (
986
+ !selected ||
987
+ selected.kind === "browse" ||
988
+ selected.kind === "create" ||
989
+ selected.kind === "createUnavailable" ||
990
+ selected.kind === "alreadySaved"
991
+ )
992
+ return;
839
993
  if (this.#expandedPresetProviderId === selected.groupId) return;
840
994
  const targetIdentity = presetRowIdentity(selected);
841
995
  this.#expandedPresetProviderId = selected.groupId;
@@ -844,13 +998,24 @@ export class ModelSelectorComponent extends Container {
844
998
 
845
999
  #collapseSelectedPresetProvider(): void {
846
1000
  const selected = this.#getSelectedPresetRow();
847
- if (!selected || selected.kind === "browse" || selected.kind === "create") return;
1001
+ if (
1002
+ !selected ||
1003
+ selected.kind === "browse" ||
1004
+ selected.kind === "create" ||
1005
+ selected.kind === "createUnavailable" ||
1006
+ selected.kind === "alreadySaved"
1007
+ )
1008
+ return;
848
1009
  if (this.#expandedPresetProviderId !== selected.groupId) return;
849
1010
  const targetIdentity = selected.kind === "profile" ? `group:${selected.groupId}` : presetRowIdentity(selected);
850
1011
  this.#expandedPresetProviderId = undefined;
851
1012
  if (!this.#relocatePresetCursor(targetIdentity)) this.#clampPresetCursor();
852
1013
  }
853
1014
 
1015
+ #setSearchInputValue(value: string): void {
1016
+ this.#searchInput.setValue(value);
1017
+ }
1018
+
854
1019
  #switchToModelMode(seed?: string): void {
855
1020
  this.#viewMode = "models";
856
1021
  this.#expandedPresetProviderId = undefined;
@@ -860,16 +1025,56 @@ export class ModelSelectorComponent extends Container {
860
1025
  this.#presetLoginHint = undefined;
861
1026
  this.#activeTabIndex = 0;
862
1027
  this.#selectedIndex = 0;
863
- this.#searchInput.setValue(seed ?? this.#searchInput.getValue());
1028
+ this.#setSearchInputValue(seed ?? this.#searchInput.getValue());
864
1029
  this.#updateTabBar();
865
1030
  this.#filterModels(this.#searchInput.getValue());
866
1031
  }
867
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 = SKC_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
+
868
1070
  #renderPresetLanding(): void {
869
1071
  this.#headerContainer.clear();
870
1072
  this.#tabBar = null;
871
1073
  this.#listContainer.clear();
872
1074
  this.#headerContainer.addChild(new Text(theme.fg("accent", t("modelSelector.presets")), 0, 0));
1075
+ for (const line of this.#formatCurrentSessionLines()) {
1076
+ this.#headerContainer.addChild(new Text(line, 0, 0));
1077
+ }
873
1078
  const rows = this.#getPresetRows();
874
1079
  for (let i = 0; i < rows.length; i++) {
875
1080
  const row = rows[i];
@@ -880,6 +1085,17 @@ export class ModelSelectorComponent extends Container {
880
1085
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
881
1086
  continue;
882
1087
  }
1088
+ if (row.kind === "createUnavailable") {
1089
+ const renderedLabel = selected ? theme.fg("accent", row.label) : theme.fg("dim", row.label);
1090
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1091
+ continue;
1092
+ }
1093
+ if (row.kind === "alreadySaved") {
1094
+ const label = `Already saved as ${row.profileName}`;
1095
+ const renderedLabel = selected ? theme.fg("accent", label) : theme.fg("dim", label);
1096
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1097
+ continue;
1098
+ }
883
1099
  if (row.kind === "browse") {
884
1100
  const label = t("modelSelector.browseAll");
885
1101
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
@@ -888,17 +1104,23 @@ export class ModelSelectorComponent extends Container {
888
1104
  if (row.kind === "group") {
889
1105
  const authenticated = this.#isPresetGroupUsable(row.profiles);
890
1106
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
1107
+ const containsActive =
1108
+ this.#activeModelProfile !== undefined &&
1109
+ row.profiles.some(profile => profile.name === this.#activeModelProfile);
891
1110
  const label = `${mark} ${row.groupId}`;
892
1111
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
893
- this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1112
+ const activeSuffix = containsActive ? theme.fg("muted", " (current)") : "";
1113
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}${activeSuffix}`, 0, 0));
894
1114
  continue;
895
1115
  }
896
1116
  const presentation = getModelProfilePresentation(row.profile);
897
1117
  const authenticated = this.#isPresetAuthenticated(row.profile);
898
1118
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
1119
+ const isActive = row.profile.name === this.#activeModelProfile;
899
1120
  const label = ` ${mark} ${presentation.displayName}`;
900
1121
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
901
- this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1122
+ const activeSuffix = isActive ? theme.fg("muted", " (current)") : "";
1123
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}${activeSuffix}`, 0, 0));
902
1124
  }
903
1125
  if (this.#presetLoginHint) {
904
1126
  this.#listContainer.addChild(new Spacer(1));
@@ -1350,7 +1572,10 @@ export class ModelSelectorComponent extends Container {
1350
1572
  const row = this.#getSelectedPresetRow();
1351
1573
  if (!row) return;
1352
1574
  if (row.kind === "create") {
1353
- this.#onSelectCallback({ kind: "createProfile" });
1575
+ this.#onSelectCallback({ kind: "createProfile", profile: this.#buildCustomModelProfileSnapshot() });
1576
+ return;
1577
+ }
1578
+ if (row.kind === "alreadySaved" || row.kind === "createUnavailable") {
1354
1579
  return;
1355
1580
  }
1356
1581
  if (row.kind === "browse") {
@@ -1366,7 +1591,17 @@ export class ModelSelectorComponent extends Container {
1366
1591
  if (this.#startLoginForMissing(missing)) return;
1367
1592
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
1368
1593
  this.#renderPresetLanding();
1594
+ return;
1369
1595
  }
1596
+ // Enter toggles a usable group's expansion so drilling into presets
1597
+ // works without reaching for the arrow keys (right/left still work).
1598
+ if (this.#expandedPresetProviderId === row.groupId) {
1599
+ this.#collapseSelectedPresetProvider();
1600
+ } else {
1601
+ this.#expandSelectedPresetProvider();
1602
+ }
1603
+ this.#presetLoginHint = undefined;
1604
+ this.#renderPresetLanding();
1370
1605
  return;
1371
1606
  }
1372
1607
  const missing = this.#getMissingProviders(row.profile);
@@ -15,7 +15,7 @@
15
15
  * - Enter on main session -> close overlay (jump back)
16
16
  */
17
17
  import type { ToolResultMessage } from "@sayknow-cli/ai";
18
- import { Container, Markdown, type MarkdownTheme, matchesKey } from "@sayknow-cli/tui";
18
+ import { Container, Markdown, type MarkdownTheme, matchesKey, resolveTerminalColumns } from "@sayknow-cli/tui";
19
19
  import { formatDuration, formatNumber, logger } from "@sayknow-cli/utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
21
  import { isSilentAbort } from "../../session/messages";
@@ -39,7 +39,7 @@ const INDENT = " ";
39
39
 
40
40
  /** Compute the max content width for the current terminal, accounting for indent and chrome. */
41
41
  function contentWidth(indent = INDENT): number {
42
- return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
42
+ return Math.max(TRUNCATE_LENGTHS.SHORT, resolveTerminalColumns() - indent.length - 2);
43
43
  }
44
44
 
45
45
  /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
@@ -390,7 +390,7 @@ export class SessionObserverOverlayComponent extends Container {
390
390
 
391
391
  /** Render markdown text into indented lines using the theme's markdown renderer */
392
392
  #renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
393
- const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
393
+ const width = Math.max(40, resolveTerminalColumns() - indent.length - 4);
394
394
  const md = new Markdown(text, 0, 0, this.#mdTheme);
395
395
  const rendered = md.render(width);
396
396
  return rendered.map(line => `${indent}${line.trimEnd()}`);
@@ -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