@sayknow-cli/coding-agent 0.3.1 → 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 (170) 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/plugin-cli.d.ts +2 -0
  4. package/dist/types/cli.d.ts +6 -0
  5. package/dist/types/commands/mcp.d.ts +70 -0
  6. package/dist/types/commands/plugin.d.ts +6 -0
  7. package/dist/types/commands/session.d.ts +6 -0
  8. package/dist/types/config/keybindings.d.ts +2 -2
  9. package/dist/types/config/model-profile-activation.d.ts +8 -1
  10. package/dist/types/config/settings-schema.d.ts +1 -59
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  25. package/dist/types/i18n/messages/en.d.ts +0 -1
  26. package/dist/types/main.d.ts +2 -0
  27. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  28. package/dist/types/modes/components/model-selector.d.ts +8 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +0 -1
  30. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  31. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  32. package/dist/types/modes/theme/theme.d.ts +1 -1
  33. package/dist/types/notifications/html-format.d.ts +11 -0
  34. package/dist/types/notifications/index.d.ts +149 -1
  35. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  36. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  37. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  38. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  39. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  40. package/dist/types/notifications/recent-activity.d.ts +35 -0
  41. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  42. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  43. package/dist/types/notifications/topic-registry.d.ts +12 -9
  44. package/dist/types/runtime-mcp/types.d.ts +7 -0
  45. package/dist/types/sdk.d.ts +2 -0
  46. package/dist/types/session/agent-session.d.ts +14 -4
  47. package/dist/types/session/blob-store.d.ts +25 -0
  48. package/dist/types/session/session-manager.d.ts +57 -0
  49. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  50. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  51. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  52. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  53. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  54. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  55. package/dist/types/system-prompt.d.ts +2 -0
  56. package/dist/types/task/executor.d.ts +9 -1
  57. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  58. package/dist/types/tools/index.d.ts +3 -1
  59. package/dist/types/utils/changelog.d.ts +1 -0
  60. package/dist/types/web/insane/url-guard.d.ts +6 -3
  61. package/dist/types/web/scrapers/types.d.ts +5 -0
  62. package/dist/types/web/scrapers/utils.d.ts +7 -1
  63. package/package.json +11 -9
  64. package/scripts/g004-tmux-smoke.ts +100 -0
  65. package/scripts/g005-daemon-smoke.ts +180 -0
  66. package/scripts/g011-daemon-path-smoke.ts +153 -0
  67. package/src/cli/fast-help.ts +29 -1
  68. package/src/cli/mcp-cli.ts +272 -0
  69. package/src/cli/plugin-cli.ts +66 -3
  70. package/src/cli.ts +30 -12
  71. package/src/commands/mcp.ts +117 -0
  72. package/src/commands/plugin.ts +4 -0
  73. package/src/commands/session.ts +18 -0
  74. package/src/config/keybindings.ts +2 -2
  75. package/src/config/model-profile-activation.ts +55 -7
  76. package/src/config/settings-schema.ts +8 -93
  77. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  78. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  79. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  80. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  81. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  82. package/src/export/html/index.ts +2 -2
  83. package/src/extensibility/extensions/runner.ts +1 -0
  84. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  85. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  86. package/src/extensibility/skc-plugins/index.ts +9 -0
  87. package/src/extensibility/skc-plugins/injection.ts +109 -0
  88. package/src/extensibility/skc-plugins/installer.ts +434 -0
  89. package/src/extensibility/skc-plugins/loader.ts +3 -1
  90. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  91. package/src/extensibility/skc-plugins/observability.ts +84 -0
  92. package/src/extensibility/skc-plugins/paths.ts +1 -1
  93. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  94. package/src/extensibility/skc-plugins/registry.ts +180 -0
  95. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  96. package/src/extensibility/skc-plugins/schema.ts +250 -20
  97. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  98. package/src/extensibility/skc-plugins/types.ts +199 -3
  99. package/src/extensibility/skc-plugins/validation.ts +80 -0
  100. package/src/extensibility/skills.ts +15 -0
  101. package/src/hooks/skill-state.ts +57 -0
  102. package/src/i18n/messages/de.ts +0 -1
  103. package/src/i18n/messages/en.ts +0 -1
  104. package/src/i18n/messages/es.ts +0 -1
  105. package/src/i18n/messages/fr.ts +0 -1
  106. package/src/i18n/messages/ja.ts +0 -1
  107. package/src/i18n/messages/ko.ts +0 -1
  108. package/src/i18n/messages/zh.ts +0 -1
  109. package/src/internal-urls/docs-index.generated.ts +12 -8
  110. package/src/main.ts +14 -3
  111. package/src/modes/bridge/bridge-mode.ts +11 -0
  112. package/src/modes/components/assistant-message.ts +49 -1
  113. package/src/modes/components/custom-editor.ts +2 -0
  114. package/src/modes/components/footer.ts +2 -3
  115. package/src/modes/components/hook-editor.ts +1 -1
  116. package/src/modes/components/hook-selector.ts +67 -43
  117. package/src/modes/components/model-selector.ts +56 -11
  118. package/src/modes/components/settings-defs.ts +1 -2
  119. package/src/modes/components/settings-selector.ts +2 -7
  120. package/src/modes/components/status-line/git-utils.ts +25 -0
  121. package/src/modes/components/status-line.ts +10 -11
  122. package/src/modes/components/welcome.ts +2 -3
  123. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  124. package/src/modes/controllers/selector-controller.ts +53 -11
  125. package/src/modes/interactive-mode.ts +4 -1
  126. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  127. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  128. package/src/modes/theme/defaults/index.ts +2 -0
  129. package/src/modes/theme/theme.ts +0 -4
  130. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  131. package/src/notifications/html-format.ts +38 -0
  132. package/src/notifications/index.ts +242 -12
  133. package/src/notifications/lifecycle-commands.ts +238 -0
  134. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  135. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  136. package/src/notifications/operator-runtime.ts +171 -0
  137. package/src/notifications/rate-limit-pool.ts +19 -0
  138. package/src/notifications/recent-activity.ts +132 -0
  139. package/src/notifications/telegram-daemon.ts +778 -257
  140. package/src/notifications/telegram-reference.ts +25 -7
  141. package/src/notifications/topic-registry.ts +23 -9
  142. package/src/prompts/agents/executor.md +2 -2
  143. package/src/runtime-mcp/transports/stdio.ts +38 -4
  144. package/src/runtime-mcp/types.ts +7 -0
  145. package/src/sdk.ts +157 -10
  146. package/src/session/agent-session.ts +166 -74
  147. package/src/session/blob-store.ts +196 -8
  148. package/src/session/session-manager.ts +762 -12
  149. package/src/skc-runtime/launch-tmux.ts +68 -19
  150. package/src/skc-runtime/psmux-detect.ts +239 -0
  151. package/src/skc-runtime/team-runtime.ts +56 -23
  152. package/src/skc-runtime/tmux-common.ts +88 -4
  153. package/src/skc-runtime/tmux-sessions.ts +111 -9
  154. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  155. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  156. package/src/slash-commands/builtin-registry.ts +23 -3
  157. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  158. package/src/slash-commands/helpers/parse.ts +2 -1
  159. package/src/system-prompt.ts +9 -0
  160. package/src/task/executor.ts +31 -7
  161. package/src/task/index.ts +2 -0
  162. package/src/tools/ask.ts +5 -1
  163. package/src/tools/bash.ts +9 -0
  164. package/src/tools/composer-bash-policy.ts +96 -0
  165. package/src/tools/fetch.ts +18 -2
  166. package/src/tools/index.ts +3 -1
  167. package/src/utils/changelog.ts +8 -0
  168. package/src/web/insane/url-guard.ts +18 -14
  169. package/src/web/scrapers/types.ts +143 -45
  170. package/src/web/scrapers/utils.ts +70 -19
package/src/main.ts CHANGED
@@ -52,7 +52,7 @@ import { formatModelOnboardingGuidance } from "./setup/model-onboarding-guidance
52
52
  import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
53
53
  import { resolvePromptInput } from "./system-prompt";
54
54
  import type { LspStartupServerInfo } from "./tools";
55
- import { getDisplayChangelogEntries, getNewEntries } from "./utils/changelog";
55
+ import { getDisplayChangelogEntries, getInstalledVersionChangelogEntry, getNewEntries } from "./utils/changelog";
56
56
  import type { EventBus } from "./utils/event-bus";
57
57
 
58
58
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
@@ -407,7 +407,7 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
407
407
  if (entries.length > 0) {
408
408
  settings.set("lastChangelogVersion", VERSION);
409
409
  await flushChangelogVersion();
410
- return entries.map(e => e.content).join("\n\n");
410
+ return getInstalledVersionChangelogEntry(entries, VERSION)?.content;
411
411
  }
412
412
  } else {
413
413
  const newEntries = getNewEntries(entries, lastVersion);
@@ -429,7 +429,7 @@ async function flushChangelogVersion(): Promise<void> {
429
429
  }
430
430
  }
431
431
 
432
- async function createSessionManager(
432
+ export async function createSessionManager(
433
433
  parsed: Args,
434
434
  cwd: string,
435
435
  activeSettings: Settings = settings,
@@ -482,6 +482,17 @@ async function createSessionManager(
482
482
  if (parsed.sessionDir) {
483
483
  return SessionManager.create(cwd, parsed.sessionDir);
484
484
  }
485
+ // A lifecycle `/session_create` child must start a FRESH session that adopts
486
+ // the pre-allocated id (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
  }
@@ -62,7 +62,6 @@ export interface SubmenuSettingDef extends BaseSettingDef {
62
62
 
63
63
  export interface TextInputSettingDef extends BaseSettingDef {
64
64
  type: "text";
65
- secret?: boolean;
66
65
  }
67
66
 
68
67
  export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef | TextInputSettingDef;
@@ -129,7 +128,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
129
128
  if (options) {
130
129
  return { ...base, type: "submenu", options };
131
130
  }
132
- return { ...base, type: "text", secret: (ui as { secret?: boolean }).secret };
131
+ return { ...base, type: "text" };
133
132
  }
134
133
 
135
134
  return null;
@@ -14,7 +14,6 @@ import {
14
14
  Text,
15
15
  } from "@sayknow-cli/tui";
16
16
  import { type SettingPath, settings } from "../../config/settings";
17
- import { maskToken } from "../../notifications/config";
18
17
  import type {
19
18
  SettingTab,
20
19
  StatusLinePreset,
@@ -353,9 +352,7 @@ export class SettingsSelectorComponent extends Container {
353
352
  id: def.path,
354
353
  label,
355
354
  description,
356
- currentValue: def.secret
357
- ? maskToken(currentValue as string | undefined)
358
- : ((currentValue as string) ?? ""),
355
+ currentValue: (currentValue as string) ?? "",
359
356
  submenu: (cv, done) => this.#createTextInput(def, cv, done),
360
357
  };
361
358
  }
@@ -483,12 +480,10 @@ export class SettingsSelectorComponent extends Container {
483
480
  this.#textInputActive = false;
484
481
  done(value);
485
482
  };
486
- // For secret fields, pre-fill with the real stored value rather than the masked display value.
487
- const prefillValue = def.secret ? ((settings.get(def.path) as string) ?? "") : currentValue;
488
483
  return new TextInputSubmenu(
489
484
  tSettingLabel(def),
490
485
  tSettingDesc(def),
491
- prefillValue,
486
+ currentValue,
492
487
  value => {
493
488
  // Empty string clears the setting; undefined-typed string settings
494
489
  // store "" which the browser.ts expandPath ignores (no-op fallback).
@@ -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
  }
@@ -26,8 +26,6 @@ import type { InteractiveModeContext } from "../../modes/types";
26
26
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
27
27
 
28
28
  const MAX_WIDGET_LINES = 10;
29
- const HOOK_SELECTOR_MOUSE_REPORTING_ENABLE = "\x1b[?1006h\x1b[?1000h";
30
- const HOOK_SELECTOR_MOUSE_REPORTING_DISABLE = "\x1b[?1000l\x1b[?1006l";
31
29
  const HOOK_SELECTOR_CHROME_ROWS = 7;
32
30
  const HOOK_SELECTOR_OUTLINE_ROWS = 2;
33
31
  const HOOK_SELECTOR_INLINE_INPUT_ROWS = 2;
@@ -36,7 +34,6 @@ export class ExtensionUiController {
36
34
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
37
35
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
38
36
  #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
39
- #hookSelectorMouseReportingEnabled = false;
40
37
  #activeHookCustomComponent?: Component & { dispose?(): void };
41
38
  #activeHookCustomOverlay?: OverlayHandle;
42
39
 
@@ -625,9 +622,6 @@ export class ExtensionUiController {
625
622
  this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - inlineInputRows - HOOK_SELECTOR_CHROME_ROWS;
626
623
  const scrollTitleRows =
627
624
  requestedTitleRows === undefined ? undefined : Math.max(1, Math.min(requestedTitleRows, availableTitleRows));
628
- if (scrollTitleRows !== undefined) {
629
- this.#enableHookSelectorMouseReporting();
630
- }
631
625
 
632
626
  this.ctx.hookSelector = new HookSelectorComponent(
633
627
  title,
@@ -692,31 +686,10 @@ export class ExtensionUiController {
692
686
  return promise;
693
687
  }
694
688
 
695
- #enableHookSelectorMouseReporting(): void {
696
- if (this.#hookSelectorMouseReportingEnabled) return;
697
- this.#hookSelectorMouseReportingEnabled = true;
698
- this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_ENABLE);
699
- }
700
-
701
- #disableHookSelectorMouseReporting(): void {
702
- if (!this.#hookSelectorMouseReportingEnabled) return;
703
- this.#hookSelectorMouseReportingEnabled = false;
704
- this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_DISABLE);
705
- }
706
-
707
- #writeTerminalControl(sequence: string): void {
708
- try {
709
- this.ctx.ui.terminal.write(sequence);
710
- } catch {
711
- // Terminal teardown can race selector cleanup; normal shutdown restores modes.
712
- }
713
- }
714
-
715
689
  /**
716
690
  * Hide the hook selector.
717
691
  */
718
692
  hideHookSelector(): void {
719
- this.#disableHookSelectorMouseReporting();
720
693
  this.ctx.hookSelector?.dispose();
721
694
  this.ctx.editorContainer.clear();
722
695
  this.ctx.editorContainer.addChild(this.ctx.editor);