@sayknow-cli/coding-agent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/bin/skc.js +4 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -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/config/settings-schema.d.ts +39 -2
  12. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  13. package/dist/types/defaults/skc-defaults.d.ts +2 -13
  14. package/dist/types/extensibility/shared-events.d.ts +1 -0
  15. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  16. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  17. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  18. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  20. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  21. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  22. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  23. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  24. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  25. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  26. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  27. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  28. package/dist/types/lsp/types.d.ts +2 -0
  29. package/dist/types/main.d.ts +2 -0
  30. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  31. package/dist/types/modes/components/model-selector.d.ts +8 -0
  32. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  33. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  34. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  35. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  36. package/dist/types/notifications/config.d.ts +9 -1
  37. package/dist/types/notifications/engine.d.ts +59 -0
  38. package/dist/types/notifications/html-format.d.ts +11 -0
  39. package/dist/types/notifications/index.d.ts +149 -1
  40. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  41. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  42. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  43. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  44. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  45. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  46. package/dist/types/notifications/recent-activity.d.ts +35 -0
  47. package/dist/types/notifications/telegram-daemon.d.ts +133 -16
  48. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  49. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  50. package/dist/types/notifications/threaded-render.d.ts +6 -1
  51. package/dist/types/notifications/topic-registry.d.ts +12 -9
  52. package/dist/types/runtime-mcp/types.d.ts +7 -0
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +16 -4
  55. package/dist/types/session/blob-store.d.ts +25 -0
  56. package/dist/types/session/session-manager.d.ts +57 -0
  57. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  58. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  59. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  60. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  61. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  62. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  63. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  64. package/dist/types/system-prompt.d.ts +2 -0
  65. package/dist/types/task/executor.d.ts +9 -1
  66. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  67. package/dist/types/tools/fetch.d.ts +23 -0
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/telegram-send.d.ts +32 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/bridge.d.ts +103 -0
  72. package/dist/types/web/insane/url-guard.d.ts +25 -0
  73. package/dist/types/web/scrapers/types.d.ts +5 -0
  74. package/dist/types/web/scrapers/utils.d.ts +7 -1
  75. package/dist/types/web/search/provider.d.ts +18 -1
  76. package/dist/types/web/search/providers/insane.d.ts +53 -0
  77. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  78. package/dist/types/web/search/types.d.ts +12 -4
  79. package/package.json +14 -10
  80. package/scripts/g004-tmux-smoke.ts +100 -0
  81. package/scripts/g005-daemon-smoke.ts +180 -0
  82. package/scripts/g011-daemon-path-smoke.ts +153 -0
  83. package/scripts/verify-insane-vendor.ts +132 -0
  84. package/src/cli/args.ts +1 -1
  85. package/src/cli/fast-help.ts +30 -2
  86. package/src/cli/mcp-cli.ts +272 -0
  87. package/src/cli/notify-cli.ts +152 -5
  88. package/src/cli/plugin-cli.ts +66 -3
  89. package/src/cli.ts +30 -12
  90. package/src/commands/mcp.ts +117 -0
  91. package/src/commands/plugin.ts +4 -0
  92. package/src/commands/session.ts +18 -0
  93. package/src/commands/team.ts +1 -1
  94. package/src/config/keybindings.ts +2 -2
  95. package/src/config/model-profile-activation.ts +55 -7
  96. package/src/config/settings-schema.ts +30 -1
  97. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  98. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  99. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  100. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  101. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  103. package/src/defaults/skc-defaults.ts +2 -27
  104. package/src/export/html/index.ts +2 -2
  105. package/src/extensibility/extensions/runner.ts +1 -0
  106. package/src/extensibility/shared-events.ts +1 -0
  107. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  108. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  109. package/src/extensibility/skc-plugins/index.ts +9 -0
  110. package/src/extensibility/skc-plugins/injection.ts +109 -0
  111. package/src/extensibility/skc-plugins/installer.ts +434 -0
  112. package/src/extensibility/skc-plugins/loader.ts +3 -1
  113. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  114. package/src/extensibility/skc-plugins/observability.ts +84 -0
  115. package/src/extensibility/skc-plugins/paths.ts +1 -1
  116. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  117. package/src/extensibility/skc-plugins/registry.ts +180 -0
  118. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  119. package/src/extensibility/skc-plugins/schema.ts +250 -20
  120. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  121. package/src/extensibility/skc-plugins/types.ts +199 -3
  122. package/src/extensibility/skc-plugins/validation.ts +80 -0
  123. package/src/extensibility/skills.ts +15 -0
  124. package/src/hooks/skill-state.ts +57 -0
  125. package/src/internal-urls/docs-index.generated.ts +17 -13
  126. package/src/lsp/config.ts +16 -3
  127. package/src/lsp/defaults.json +7 -0
  128. package/src/lsp/types.ts +2 -0
  129. package/src/main.ts +14 -3
  130. package/src/modes/bridge/bridge-mode.ts +11 -0
  131. package/src/modes/components/assistant-message.ts +49 -1
  132. package/src/modes/components/custom-editor.ts +2 -0
  133. package/src/modes/components/footer.ts +2 -3
  134. package/src/modes/components/hook-editor.ts +1 -1
  135. package/src/modes/components/hook-selector.ts +67 -43
  136. package/src/modes/components/model-selector.ts +56 -11
  137. package/src/modes/components/status-line/git-utils.ts +25 -0
  138. package/src/modes/components/status-line.ts +10 -11
  139. package/src/modes/components/welcome.ts +2 -3
  140. package/src/modes/controllers/event-controller.ts +15 -0
  141. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  142. package/src/modes/controllers/selector-controller.ts +53 -11
  143. package/src/modes/interactive-mode.ts +50 -3
  144. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  145. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  146. package/src/modes/theme/defaults/index.ts +2 -0
  147. package/src/modes/utils/context-usage.ts +2 -2
  148. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  149. package/src/notifications/attachment-registry.ts +23 -0
  150. package/src/notifications/chat-adapters.ts +147 -0
  151. package/src/notifications/config.ts +23 -2
  152. package/src/notifications/engine.ts +100 -0
  153. package/src/notifications/html-format.ts +38 -0
  154. package/src/notifications/index.ts +417 -45
  155. package/src/notifications/lifecycle-commands.ts +238 -0
  156. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  157. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  158. package/src/notifications/managed-daemon.ts +163 -0
  159. package/src/notifications/operator-runtime.ts +171 -0
  160. package/src/notifications/rate-limit-pool.ts +19 -0
  161. package/src/notifications/recent-activity.ts +132 -0
  162. package/src/notifications/telegram-daemon.ts +984 -242
  163. package/src/notifications/telegram-reference.ts +25 -7
  164. package/src/notifications/threaded-inbound.ts +60 -4
  165. package/src/notifications/threaded-render.ts +20 -2
  166. package/src/notifications/topic-registry.ts +23 -9
  167. package/src/prompts/agents/executor.md +2 -2
  168. package/src/runtime-mcp/transports/stdio.ts +38 -4
  169. package/src/runtime-mcp/types.ts +7 -0
  170. package/src/sdk.ts +157 -10
  171. package/src/session/agent-session.ts +248 -125
  172. package/src/session/blob-store.ts +196 -8
  173. package/src/session/session-manager.ts +762 -12
  174. package/src/skc-runtime/launch-tmux.ts +85 -22
  175. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  176. package/src/skc-runtime/psmux-detect.ts +239 -0
  177. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  178. package/src/skc-runtime/team-runtime.ts +56 -23
  179. package/src/skc-runtime/tmux-common.ts +88 -4
  180. package/src/skc-runtime/tmux-sessions.ts +111 -9
  181. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  182. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  183. package/src/skc-runtime/workflow-manifest.generated.json +29 -0
  184. package/src/skc-runtime/workflow-manifest.ts +7 -2
  185. package/src/slash-commands/builtin-registry.ts +23 -3
  186. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  187. package/src/slash-commands/helpers/parse.ts +2 -1
  188. package/src/system-prompt.ts +9 -0
  189. package/src/task/executor.ts +31 -7
  190. package/src/task/index.ts +2 -0
  191. package/src/tools/ask.ts +5 -1
  192. package/src/tools/bash.ts +9 -0
  193. package/src/tools/composer-bash-policy.ts +96 -0
  194. package/src/tools/fetch.ts +94 -1
  195. package/src/tools/index.ts +6 -1
  196. package/src/tools/telegram-send.ts +137 -0
  197. package/src/utils/changelog.ts +8 -0
  198. package/src/web/insane/bridge.ts +350 -0
  199. package/src/web/insane/url-guard.ts +159 -0
  200. package/src/web/scrapers/types.ts +143 -45
  201. package/src/web/scrapers/utils.ts +70 -19
  202. package/src/web/search/provider.ts +77 -18
  203. package/src/web/search/providers/anthropic.ts +70 -3
  204. package/src/web/search/providers/codex.ts +1 -119
  205. package/src/web/search/providers/gemini.ts +99 -0
  206. package/src/web/search/providers/insane.ts +551 -0
  207. package/src/web/search/providers/openai-compatible.ts +66 -32
  208. package/src/web/search/providers/text-citations.ts +111 -0
  209. package/src/web/search/types.ts +13 -2
  210. package/vendor/insane-search/LICENSE +21 -0
  211. package/vendor/insane-search/MANIFEST.json +24 -0
  212. package/vendor/insane-search/engine/__init__.py +23 -0
  213. package/vendor/insane-search/engine/__main__.py +128 -0
  214. package/vendor/insane-search/engine/bias_check.py +183 -0
  215. package/vendor/insane-search/engine/executor.py +254 -0
  216. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  217. package/vendor/insane-search/engine/learning.py +175 -0
  218. package/vendor/insane-search/engine/phase0.py +214 -0
  219. package/vendor/insane-search/engine/safety.py +91 -0
  220. package/vendor/insane-search/engine/templates/package.json +11 -0
  221. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  222. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  223. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  224. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  225. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  226. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  227. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  228. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  229. package/vendor/insane-search/engine/transport.py +211 -0
  230. package/vendor/insane-search/engine/url_transforms.py +98 -0
  231. package/vendor/insane-search/engine/validators.py +331 -0
  232. package/vendor/insane-search/engine/waf_detector.py +214 -0
  233. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
package/src/lsp/config.ts CHANGED
@@ -88,6 +88,7 @@ function normalizeServerConfig(name: string, config: RawServerConfig): ServerCon
88
88
  : isRecord(config.initializationOptions)
89
89
  ? config.initializationOptions
90
90
  : undefined;
91
+ const supersedes = normalizeStringArray(config.supersedes);
91
92
 
92
93
  return {
93
94
  ...config,
@@ -96,6 +97,7 @@ function normalizeServerConfig(name: string, config: RawServerConfig): ServerCon
96
97
  fileTypes,
97
98
  rootMarkers,
98
99
  ...(initOptions ? { initOptions } : {}),
100
+ ...(supersedes ? { supersedes } : {}),
99
101
  };
100
102
  }
101
103
 
@@ -144,6 +146,18 @@ function mergeServers(
144
146
  return merged;
145
147
  }
146
148
 
149
+ function applyServerPrecedence(servers: Record<string, ServerConfig>): Record<string, ServerConfig> {
150
+ const suppressed = new Set<string>();
151
+ for (const config of Object.values(servers)) {
152
+ for (const serverName of config.supersedes ?? []) {
153
+ suppressed.add(serverName);
154
+ }
155
+ }
156
+ if (suppressed.size === 0) return servers;
157
+
158
+ return Object.fromEntries(Object.entries(servers).filter(([name]) => !suppressed.has(name)));
159
+ }
160
+
147
161
  function applyRuntimeDefaults(servers: Record<string, ServerConfig>): Record<string, ServerConfig> {
148
162
  const updated: Record<string, ServerConfig> = { ...servers };
149
163
 
@@ -422,9 +436,8 @@ export function loadConfig(cwd: string): LspConfig {
422
436
  detected[name] = { ...config, resolvedCommand: resolved };
423
437
  }
424
438
 
425
- return { servers: detected, idleTimeoutMs };
439
+ return { servers: applyServerPrecedence(detected), idleTimeoutMs };
426
440
  }
427
-
428
441
  // Merge overrides with defaults and filter to available servers
429
442
  const mergedWithRuntime = applyRuntimeDefaults(mergedServers);
430
443
  const available: Record<string, ServerConfig> = {};
@@ -437,7 +450,7 @@ export function loadConfig(cwd: string): LspConfig {
437
450
  available[name] = { ...config, resolvedCommand: resolved };
438
451
  }
439
452
 
440
- return { servers: available, idleTimeoutMs };
453
+ return { servers: applyServerPrecedence(available), idleTimeoutMs };
441
454
  }
442
455
 
443
456
  // =============================================================================
@@ -342,6 +342,13 @@
342
342
  "fileTypes": [".php"],
343
343
  "rootMarkers": ["composer.json", ".phpactor.json", ".phpactor.yml"]
344
344
  },
345
+ "csharp-ls": {
346
+ "command": "csharp-ls",
347
+ "args": [],
348
+ "fileTypes": [".cs", ".csx"],
349
+ "rootMarkers": ["*.sln", "*.slnx", "*.csproj", "global.json"],
350
+ "supersedes": ["omnisharp"]
351
+ },
345
352
  "omnisharp": {
346
353
  "command": "omnisharp",
347
354
  "args": ["-z", "--hostPID", "$PID", "--encoding", "utf-8", "--languageserver"],
package/src/lsp/types.ts CHANGED
@@ -358,6 +358,8 @@ export interface ServerConfig {
358
358
  /** Per-server warmup timeout in milliseconds. Overrides the global WARMUP_TIMEOUT_MS for this server during startup. */
359
359
  warmupTimeoutMs?: number;
360
360
  capabilities?: ServerCapabilities;
361
+ /** Names of lower-precedence servers to drop when this server is available. */
362
+ supersedes?: string[];
361
363
  /** If true, this is a linter/formatter server (e.g., Biome) - used only for diagnostics/actions, not type intelligence */
362
364
  isLinter?: boolean;
363
365
  /** Resolved absolute path to the command binary (set during config loading) */
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 (SKC_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 SKC_LIFECYCLE_REQUEST_ID.
490
+ if (
491
+ process.env.SKC_LIFECYCLE_REQUEST_ID &&
492
+ /^[A-Za-z0-9._-]{1,128}$/.test(process.env.SKC_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,
@@ -7,6 +7,54 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
7
  import { isSilentAbort } from "../../session/messages";
8
8
  import { resolveImageOptions } from "../../tools/render-utils";
9
9
 
10
+ const THINKING_REPETITION_ELIDE_MIN_RUN = 24;
11
+ const THINKING_REPETITION_VISIBLE_TOKENS = 3;
12
+ const THINKING_REPETITION_TOKEN_PATTERN = /[\p{L}\p{N}_'-]{1,32}/gu;
13
+
14
+ interface ThinkingRepetitionToken {
15
+ text: string;
16
+ normalized: string;
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ function elideRunawayThinkingRepetition(text: string): string {
22
+ const tokens: ThinkingRepetitionToken[] = [];
23
+ for (const match of text.matchAll(THINKING_REPETITION_TOKEN_PATTERN)) {
24
+ if (match.index === undefined) continue;
25
+ tokens.push({
26
+ text: match[0],
27
+ normalized: match[0].toLocaleLowerCase("en-US"),
28
+ start: match.index,
29
+ end: match.index + match[0].length,
30
+ });
31
+ }
32
+
33
+ let runStart = 0;
34
+ for (let i = 1; i <= tokens.length; i++) {
35
+ const current = tokens[i];
36
+ const previous = tokens[i - 1];
37
+ if (current && previous && current.normalized === previous.normalized) continue;
38
+
39
+ const runLength = i - runStart;
40
+ if (runLength >= THINKING_REPETITION_ELIDE_MIN_RUN) {
41
+ const first = tokens[runStart];
42
+ const last = tokens[i - 1];
43
+ if (!first || !last) return text;
44
+
45
+ const visibleCount = Math.min(THINKING_REPETITION_VISIBLE_TOKENS, runLength);
46
+ const visible = Array.from({ length: visibleCount }, () => first.text).join(" ");
47
+ const omitted = runLength - visibleCount;
48
+ const marker = `${visible} … [thinking loop elided: "${first.text}" repeated ${omitted} more times]`;
49
+ return `${text.slice(0, first.start)}${marker}${text.slice(last.end)}`.trim();
50
+ }
51
+
52
+ runStart = i;
53
+ }
54
+
55
+ return text;
56
+ }
57
+
10
58
  /**
11
59
  * Component that renders a complete assistant message
12
60
  */
@@ -128,7 +176,7 @@ export class AssistantMessageComponent extends Container {
128
176
  #renderThinkingBlock(content: { thinking: string }): Markdown {
129
177
  const cached = this.#contentBlocksCache.get(content);
130
178
  if (cached?.source === content.thinking) return cached.component as Markdown;
131
- const trimmed = content.thinking.trim();
179
+ const trimmed = elideRunawayThinkingRepetition(content.thinking.trim());
132
180
  const component = new Markdown(trimmed, 1, 0, getMarkdownTheme(), {
133
181
  color: (text: string) => theme.fg("thinkingText", text),
134
182
  italic: true,
@@ -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
 
@@ -29,22 +29,6 @@ import { getEditorCommand, openInEditor } from "../../utils/external-editor";
29
29
  import { CountdownTimer } from "./countdown-timer";
30
30
  import { DynamicBorder } from "./dynamic-border";
31
31
 
32
- const SGR_MOUSE_PRESS_PATTERN = /^\x1b\[<(\d+);\d+;\d+M$/;
33
- const MOUSE_WHEEL_TITLE_SCROLL_ROWS = 3;
34
-
35
- function getMouseWheelTitleScrollRows(keyData: string): number {
36
- const match = SGR_MOUSE_PRESS_PATTERN.exec(keyData);
37
- if (!match) return 0;
38
-
39
- const button = Number.parseInt(match[1] ?? "", 10);
40
- if (!Number.isFinite(button) || (button & 64) === 0) return 0;
41
-
42
- const wheelDirection = button & 3;
43
- if (wheelDirection === 0) return -MOUSE_WHEEL_TITLE_SCROLL_ROWS;
44
- if (wheelDirection === 1) return MOUSE_WHEEL_TITLE_SCROLL_ROWS;
45
- return 0;
46
- }
47
-
48
32
  export interface HookSelectorOptions {
49
33
  tui?: TUI;
50
34
  timeout?: number;
@@ -133,26 +117,58 @@ class ScrollableTitle extends Container {
133
117
 
134
118
  render(width: number): string[] {
135
119
  const lines = this.#markdown.render(width);
136
- const maxScrollOffset = Math.max(0, lines.length - this.#maxRows);
137
- this.#lastMaxScrollOffset = maxScrollOffset;
138
- this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
120
+ if (lines.length <= this.#maxRows) {
121
+ this.#lastMaxScrollOffset = 0;
122
+ this.#scrollOffset = 0;
123
+ return lines;
124
+ }
139
125
 
140
- const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
141
- if (maxScrollOffset === 0 || visibleLines.length === 0) {
126
+ if (this.#maxRows < 3) {
127
+ const maxScrollOffset = Math.max(0, lines.length - this.#maxRows);
128
+ this.#lastMaxScrollOffset = maxScrollOffset;
129
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
130
+
131
+ const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + this.#maxRows);
132
+ const indicator =
133
+ this.#scrollOffset === 0
134
+ ? theme.fg("dim", " PgDn↓")
135
+ : this.#scrollOffset >= maxScrollOffset
136
+ ? theme.fg("dim", " PgUp↑")
137
+ : theme.fg("dim", " PgUp/PgDn↕");
138
+ const lastIndex = visibleLines.length - 1;
139
+ const availableWidth = Math.max(1, width - visibleWidth(indicator));
140
+ const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth);
141
+ visibleLines[lastIndex] = `${fittedLine}${indicator}`;
142
142
  return visibleLines;
143
143
  }
144
144
 
145
- const indicator =
146
- this.#scrollOffset === 0
147
- ? theme.fg("dim", " PgDn↓")
148
- : this.#scrollOffset >= maxScrollOffset
149
- ? theme.fg("dim", " PgUp↑")
150
- : theme.fg("dim", " PgUp/PgDn↕");
151
- const lastIndex = visibleLines.length - 1;
152
- const availableWidth = Math.max(1, width - visibleWidth(indicator));
153
- const fittedLine = truncateToWidth(visibleLines[lastIndex] ?? "", availableWidth);
154
- visibleLines[lastIndex] = `${fittedLine}${indicator}`;
155
- return visibleLines;
145
+ let showTopIndicator = this.#scrollOffset > 0;
146
+ let showBottomIndicator = true;
147
+ let contentRows = 1;
148
+ let maxScrollOffset = 0;
149
+
150
+ for (let i = 0; i < 4; i++) {
151
+ contentRows = Math.max(1, this.#maxRows - (showTopIndicator ? 1 : 0) - (showBottomIndicator ? 1 : 0));
152
+ maxScrollOffset = Math.max(0, lines.length - contentRows);
153
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScrollOffset));
154
+
155
+ const nextShowTopIndicator = this.#scrollOffset > 0;
156
+ const nextShowBottomIndicator = this.#scrollOffset + contentRows < lines.length;
157
+ if (nextShowTopIndicator === showTopIndicator && nextShowBottomIndicator === showBottomIndicator) {
158
+ break;
159
+ }
160
+ showTopIndicator = nextShowTopIndicator;
161
+ showBottomIndicator = nextShowBottomIndicator;
162
+ }
163
+
164
+ this.#lastMaxScrollOffset = maxScrollOffset;
165
+
166
+ const visibleLines = lines.slice(this.#scrollOffset, this.#scrollOffset + contentRows);
167
+ const result: string[] = [];
168
+ if (showTopIndicator) result.push(theme.fg("dim", truncateToWidth("▲ more", width)));
169
+ result.push(...visibleLines);
170
+ if (showBottomIndicator) result.push(theme.fg("dim", truncateToWidth("▼ more", width)));
171
+ return result.slice(0, this.#maxRows);
156
172
  }
157
173
  }
158
174
 
@@ -455,13 +471,6 @@ export class HookSelectorComponent extends Container {
455
471
  // Reset countdown on any interaction
456
472
  this.#countdown?.reset();
457
473
 
458
- if (this.#scrollTitleRows !== undefined) {
459
- const wheelRows = getMouseWheelTitleScrollRows(keyData);
460
- if (wheelRows !== 0) {
461
- this.#scrollableTitle?.scrollBy(wheelRows);
462
- return;
463
- }
464
- }
465
474
  if (this.#scrollTitleRows !== undefined && matchesKey(keyData, "pageUp")) {
466
475
  this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
467
476
  return;
@@ -470,6 +479,14 @@ export class HookSelectorComponent extends Container {
470
479
  this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
471
480
  return;
472
481
  }
482
+ if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+u")) {
483
+ this.#scrollableTitle?.scrollBy(-this.#scrollTitleRows);
484
+ return;
485
+ }
486
+ if (!this.#inlineEditor && this.#scrollTitleRows !== undefined && matchesKey(keyData, "ctrl+d")) {
487
+ this.#scrollableTitle?.scrollBy(this.#scrollTitleRows);
488
+ return;
489
+ }
473
490
  if (this.#inlineEditor) {
474
491
  this.#handleInputModeKey(keyData, this.#inlineEditor);
475
492
  return;
@@ -538,16 +555,23 @@ export class HookSelectorComponent extends Container {
538
555
  editor.setBorderVisible(false);
539
556
  editor.setPromptGutter("> ");
540
557
  editor.disableSubmit = true;
558
+ // Mark the inline editor focused only when mirroring the app's hardware-cursor
559
+ // mode, so it emits CURSOR_MARKER at the input caret for IME preedit anchoring
560
+ // without changing legacy non-hardware-cursor layout.
561
+ const useTerminalCursor = this.#tui?.getShowHardwareCursor() ?? false;
562
+ editor.focused = useTerminalCursor;
563
+ editor.setUseTerminalCursor(useTerminalCursor);
541
564
  if (this.#autocompleteProvider) {
542
565
  editor.setAutocompleteProvider(this.#autocompleteProvider);
543
566
  }
544
567
  this.#inlineEditor = editor;
545
568
  this.#inputArea.addChild(new Spacer(1));
546
569
  this.#inputArea.addChild(editor);
547
- const scrollHint = this.#scrollTitleRows === undefined ? "" : " wheel/PgUp/PgDn scroll question";
548
- this.#helpTextComponent.setText(
549
- theme.fg("dim", `enter submit esc back to options ctrl+g external editor${scrollHint}`),
550
- );
570
+ const helpText =
571
+ this.#scrollTitleRows === undefined
572
+ ? "enter submit esc back to options ctrl+g external editor"
573
+ : "enter submit esc back to options PgUp/PgDn: question · Wheel: transcript";
574
+ this.#helpTextComponent.setText(theme.fg("dim", helpText));
551
575
  this.invalidate();
552
576
  }
553
577
 
@@ -228,8 +228,11 @@ export class ModelSelectorComponent extends Container {
228
228
  #scopedModels: ReadonlyArray<ScopedModelItem>;
229
229
  #temporaryOnly: boolean;
230
230
  #currentModel?: Model;
231
+ #currentThinkingLevel?: ThinkingLevel;
232
+ #activeModelProfile?: string;
231
233
  #isFastForProvider: (provider?: string) => boolean = () => false;
232
234
  #isFastForSubagentProvider: (provider?: string) => boolean = () => false;
235
+ #isCurrentModelFastModeActive: () => boolean = () => false;
233
236
  #pendingActionItem?: ModelItem | CanonicalModelItem;
234
237
  #selectedActionIndex: number = 0;
235
238
  #pendingThinkingChoice?: PendingThinkingChoice;
@@ -265,6 +268,9 @@ export class ModelSelectorComponent extends Container {
265
268
  sessionId?: string;
266
269
  isFastForProvider?: (provider?: string) => boolean;
267
270
  isFastForSubagentProvider?: (provider?: string) => boolean;
271
+ isCurrentModelFastModeActive?: () => boolean;
272
+ currentThinkingLevel?: ThinkingLevel;
273
+ activeModelProfile?: string;
268
274
  },
269
275
  ) {
270
276
  super();
@@ -278,8 +284,16 @@ export class ModelSelectorComponent extends Container {
278
284
  this.#temporaryOnly = options?.temporaryOnly ?? false;
279
285
  this.#authSessionId = options?.sessionId;
280
286
  this.#currentModel = _currentModel;
287
+ this.#currentThinkingLevel = options?.currentThinkingLevel;
288
+ this.#activeModelProfile = options?.activeModelProfile;
281
289
  this.#isFastForProvider = options?.isFastForProvider ?? (() => false);
282
290
  this.#isFastForSubagentProvider = options?.isFastForSubagentProvider ?? (() => false);
291
+ // Current-model EFFECTIVE fast state. Defaults to intent for the current
292
+ // model so existing callers/tests keep prior behavior; production wires the
293
+ // session's effective predicate so an auto-disabled provider shows no glyph.
294
+ this.#isCurrentModelFastModeActive =
295
+ options?.isCurrentModelFastModeActive ??
296
+ (() => (this.#currentModel ? this.#isFastForProvider(this.#currentModel.provider) : false));
283
297
  const initialSearchInput = options?.initialSearchInput;
284
298
  this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets";
285
299
 
@@ -373,6 +387,23 @@ export class ModelSelectorComponent extends Container {
373
387
  };
374
388
  }
375
389
  }
390
+ if (this.#activeModelProfile && this.#currentModel) {
391
+ this.#roles.default = {
392
+ model: this.#currentModel,
393
+ thinkingLevel: this.#currentThinkingLevel ?? ThinkingLevel.Inherit,
394
+ };
395
+ }
396
+ }
397
+
398
+ refreshRoleAssignments(
399
+ options: { currentModel?: Model; currentThinkingLevel?: ThinkingLevel; activeModelProfile?: string } = {},
400
+ ): void {
401
+ if ("currentModel" in options) this.#currentModel = options.currentModel;
402
+ if ("currentThinkingLevel" in options) this.#currentThinkingLevel = options.currentThinkingLevel;
403
+ if ("activeModelProfile" in options) this.#activeModelProfile = options.activeModelProfile;
404
+ this.#roles = {};
405
+ this.#loadRoleModels();
406
+ this.#applyTabFilter();
376
407
  }
377
408
 
378
409
  #sortModels(models: ModelItem[]): void {
@@ -980,32 +1011,46 @@ export class ModelSelectorComponent extends Container {
980
1011
 
981
1012
  // Build role badges (inverted: color as background, black text)
982
1013
  const roleBadgeTokens: string[] = [];
983
- let roleMatched = false;
1014
+ // Whether a non-subagent (modelRoles) badge on the CURRENT model row already
1015
+ // rendered the current-model EFFECTIVE glyph. Only that case should suppress
1016
+ // the standalone current glyph below — a subagent-only match must NOT, since
1017
+ // subagent badges reflect the subagent tier, not the current model.
1018
+ let currentModelEffectiveGlyphRendered = false;
984
1019
  for (const role of SKC_MODEL_ASSIGNMENT_TARGET_IDS) {
985
1020
  const roleInfo = SKC_MODEL_ASSIGNMENT_TARGETS[role];
986
1021
  const assigned = this.#roles[role];
987
1022
  if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
988
- roleMatched = true;
989
1023
  const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
990
1024
  const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
991
- // Subagent roles (task.agentModelOverrides) run under task.serviceTier, so
992
- // their ⚡ must reflect the effective subagent tier, not the main session tier.
993
- const roleFast =
994
- roleInfo.settingsPath === "task.agentModelOverrides"
995
- ? this.#isFastForSubagentProvider(assigned.model.provider)
1025
+
1026
+ // Subagent roles (task.agentModelOverrides) run under task.serviceTier,
1027
+ // so their ⚡ uses the effective subagent tier. A non-subagent
1028
+ // (modelRoles) badge on the CURRENT model row uses the current-model
1029
+ // effective predicate so a provider auto-disable hides the glyph;
1030
+ // other modelRoles rows show pure intent.
1031
+ const isSubagentRole = roleInfo.settingsPath === "task.agentModelOverrides";
1032
+ const isCurrentRow = this.#currentModel !== undefined && modelsAreEqual(this.#currentModel, item.model);
1033
+ const roleFast = isSubagentRole
1034
+ ? this.#isFastForSubagentProvider(assigned.model.provider)
1035
+ : isCurrentRow
1036
+ ? this.#isCurrentModelFastModeActive()
996
1037
  : this.#isFastForProvider(assigned.model.provider);
1038
+ if (roleFast && isCurrentRow && !isSubagentRole) {
1039
+ currentModelEffectiveGlyphRendered = true;
1040
+ }
997
1041
  const fastSuffix = roleFast ? ` ${theme.icon.fast}` : "";
998
1042
  roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}${fastSuffix}`);
999
1043
  }
1000
1044
  }
1001
1045
  // Active/current non-role row: show the fast glyph on the session's current
1002
- // model row even when it carries no role badge. Skip when a role token for
1003
- // this row already rendered the glyph (duplicate-glyph guard).
1046
+ // model row. Suppress only when a non-subagent current-row badge already
1047
+ // rendered the current-model effective glyph (duplicate-glyph guard) — a
1048
+ // subagent-only match must not hide the current model's own indicator.
1004
1049
  if (
1005
- !roleMatched &&
1050
+ !currentModelEffectiveGlyphRendered &&
1006
1051
  this.#currentModel !== undefined &&
1007
1052
  modelsAreEqual(this.#currentModel, item.model) &&
1008
- this.#isFastForProvider(item.model.provider)
1053
+ this.#isCurrentModelFastModeActive()
1009
1054
  ) {
1010
1055
  roleBadgeTokens.push(theme.icon.fast);
1011
1056
  }
@@ -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
 
@@ -76,9 +76,8 @@ export class WelcomeComponent implements Component {
76
76
  }
77
77
 
78
78
  render(termWidth: number): string[] {
79
- // Box dimensions - responsive with max width and small-terminal support
80
- const maxWidth = 100;
81
- const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
79
+ // Box dimensions track the live viewport so wide terminals feel intentionally full-screen.
80
+ const boxWidth = Math.max(0, termWidth - 2);
82
81
  if (boxWidth < 4) {
83
82
  return [];
84
83
  }