@sayknow-cli/coding-agent 0.3.8 → 0.3.10

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 (93) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +11 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +15 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  13. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +2 -0
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  17. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  18. package/dist/types/modes/types.d.ts +3 -0
  19. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  20. package/dist/types/notifications/config-commands.d.ts +38 -0
  21. package/dist/types/notifications/config.d.ts +16 -0
  22. package/dist/types/notifications/index.d.ts +14 -1
  23. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  24. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  25. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  26. package/dist/types/notifications/rich-draft.d.ts +68 -0
  27. package/dist/types/notifications/rich-render.d.ts +90 -0
  28. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  29. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  30. package/dist/types/notifications/threaded-render.d.ts +10 -0
  31. package/dist/types/notifications/topic-registry.d.ts +4 -5
  32. package/dist/types/runtime-credential-selector.d.ts +7 -0
  33. package/dist/types/sdk.d.ts +7 -1
  34. package/dist/types/session/agent-session.d.ts +6 -3
  35. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/skill-discovery.d.ts +40 -0
  38. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  39. package/package.json +7 -7
  40. package/src/cli/args.ts +8 -0
  41. package/src/cli/notify-cli.ts +38 -10
  42. package/src/cli.ts +5 -0
  43. package/src/commands/launch.ts +5 -0
  44. package/src/commands/notify.ts +2 -2
  45. package/src/config/keybindings.ts +1 -1
  46. package/src/config/model-profile-activation.ts +23 -7
  47. package/src/config/model-registry.ts +107 -11
  48. package/src/config/model-resolver.ts +5 -16
  49. package/src/config/settings-schema.ts +11 -2
  50. package/src/config/settings.ts +24 -1
  51. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  52. package/src/internal-urls/docs-index.generated.ts +4 -4
  53. package/src/lsp/client.ts +1 -0
  54. package/src/main.ts +26 -3
  55. package/src/modes/components/footer.ts +7 -2
  56. package/src/modes/components/settings-selector.ts +3 -3
  57. package/src/modes/components/thinking-selector.ts +83 -20
  58. package/src/modes/controllers/event-controller.ts +11 -2
  59. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  60. package/src/modes/controllers/input-controller.ts +62 -26
  61. package/src/modes/controllers/selector-controller.ts +43 -0
  62. package/src/modes/interactive-mode.ts +5 -0
  63. package/src/modes/rpc/rpc-mode.ts +3 -9
  64. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/injected-user-submission.ts +94 -0
  67. package/src/modes/utils/ui-helpers.ts +48 -15
  68. package/src/notifications/config-commands.ts +90 -0
  69. package/src/notifications/config.ts +27 -0
  70. package/src/notifications/index.ts +184 -14
  71. package/src/notifications/lifecycle-commands.ts +37 -16
  72. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  73. package/src/notifications/reply-sent-store.ts +134 -0
  74. package/src/notifications/rich-draft.ts +107 -0
  75. package/src/notifications/rich-render.ts +143 -0
  76. package/src/notifications/telegram-daemon.ts +431 -91
  77. package/src/notifications/telegram-reference.ts +17 -0
  78. package/src/notifications/threaded-render.ts +42 -1
  79. package/src/notifications/topic-registry.ts +9 -8
  80. package/src/prompts/tools/search-tool-bm25.md +5 -0
  81. package/src/prompts/tools/skill-discovery.md +13 -0
  82. package/src/runtime-credential-selector.ts +35 -0
  83. package/src/sdk.ts +104 -13
  84. package/src/session/agent-session.ts +48 -22
  85. package/src/skc-runtime/psmux-detect.ts +17 -2
  86. package/src/skc-runtime/tmux-common.ts +1 -0
  87. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  88. package/src/slash-commands/builtin-registry.ts +78 -1
  89. package/src/system-prompt.ts +4 -6
  90. package/src/tools/index.ts +4 -0
  91. package/src/tools/skill-discovery.ts +73 -0
  92. package/src/tools/skill.ts +15 -2
  93. package/src/utils/pasted-image-path.ts +170 -0
package/src/lsp/client.ts CHANGED
@@ -835,6 +835,7 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
835
835
  await Promise.race([shutdown, Bun.sleep(5_000)]);
836
836
  await client.owner?.dispose();
837
837
  await client.owner?.awaitExit({ timeoutMs: 1_000 });
838
+ await Promise.race([client.proc.exited.catch(() => undefined), Bun.sleep(1_000)]);
838
839
  }
839
840
 
840
841
  export async function shutdownClient(key: string): Promise<void> {
package/src/main.ts CHANGED
@@ -39,6 +39,7 @@ import type { InteractiveMode } from "./modes/interactive-mode";
39
39
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
40
40
  import type { SubmittedUserInput } from "./modes/types";
41
41
  import { applyCliRuntimeApiKeyOverride } from "./runtime-api-key";
42
+ import { parseCliCredentialSelector } from "./runtime-credential-selector";
42
43
  import type { MCPManager } from "./runtime-mcp";
43
44
  import {
44
45
  type CreateAgentSessionOptions,
@@ -218,10 +219,14 @@ export async function applyStartupModelProfiles(args: {
218
219
  startupModel?: CreateAgentSessionOptions["model"];
219
220
  startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
220
221
  }): Promise<void> {
221
- const applyProfile = async (profileName: string, persistDefault: boolean): Promise<void> => {
222
+ const applyProfile = async (
223
+ profileName: string,
224
+ persistDefault: boolean,
225
+ options: { thinkingLevelOverride?: CreateAgentSessionOptions["thinkingLevel"] } = {},
226
+ ): Promise<void> => {
222
227
  await activateModelProfile(
223
228
  { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
224
- { persistDefault },
229
+ { persistDefault, thinkingLevelOverride: options.thinkingLevelOverride },
225
230
  );
226
231
  };
227
232
 
@@ -235,7 +240,11 @@ export async function applyStartupModelProfiles(args: {
235
240
  }
236
241
 
237
242
  if (defaultProfile) {
238
- await applyProfile(defaultProfile, false);
243
+ await applyProfile(defaultProfile, false, {
244
+ thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
245
+ ? args.settings.get("defaultThinkingLevel")
246
+ : undefined,
247
+ });
239
248
  }
240
249
  if (args.parsedArgs.mpreset) {
241
250
  await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true);
@@ -1005,6 +1014,20 @@ export async function runRootCommand(
1005
1014
  deps.rlmPreset?.applyOptions(sessionOptions, settingsInstance);
1006
1015
 
1007
1016
  // Handle CLI --api-key as runtime override (not persisted)
1017
+ if (parsedArgs.apiKey && parsedArgs.credential) {
1018
+ process.stderr.write(`${chalk.red("--api-key and --credential cannot be used together")}\n`);
1019
+ process.exit(1);
1020
+ }
1021
+
1022
+ if (parsedArgs.credential) {
1023
+ try {
1024
+ sessionOptions.credentialSelector = parseCliCredentialSelector(parsedArgs.credential);
1025
+ } catch (error) {
1026
+ const message = error instanceof Error ? error.message : String(error);
1027
+ process.stderr.write(`${chalk.red(message)}\n`);
1028
+ process.exit(1);
1029
+ }
1030
+ }
1008
1031
  if (parsedArgs.apiKey) {
1009
1032
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
1010
1033
  process.stderr.write(
@@ -16,6 +16,7 @@ import { resolveCurrentBranch } from "./status-line/git-utils";
16
16
  export class FooterComponent implements Component {
17
17
  #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
18
18
  #gitWatcher: fs.FSWatcher | null = null;
19
+ #gitWatcherGeneration = 0;
19
20
  #onBranchChange: (() => void) | null = null;
20
21
  #autoCompactEnabled: boolean = true;
21
22
  #extensionStatuses: Map<string, string> = new Map();
@@ -51,7 +52,10 @@ export class FooterComponent implements Component {
51
52
  }
52
53
 
53
54
  #setupGitWatcher(): void {
54
- // Clean up existing watcher
55
+ // Clean up existing watcher. The generation guard below keeps a stale
56
+ // in-flight resolve — one that dispose() or a newer setup already
57
+ // superseded — from installing a watcher nothing would ever close.
58
+ const generation = ++this.#gitWatcherGeneration;
55
59
  if (this.#gitWatcher) {
56
60
  this.#gitWatcher.close();
57
61
  this.#gitWatcher = null;
@@ -60,7 +64,7 @@ export class FooterComponent implements Component {
60
64
  void git.head
61
65
  .resolve(getProjectDir())
62
66
  .then(head => {
63
- if (!head) {
67
+ if (!head || generation !== this.#gitWatcherGeneration) {
64
68
  return;
65
69
  }
66
70
 
@@ -84,6 +88,7 @@ export class FooterComponent implements Component {
84
88
  * Clean up the file watcher
85
89
  */
86
90
  dispose(): void {
91
+ this.#gitWatcherGeneration++;
87
92
  if (this.#gitWatcher) {
88
93
  this.#gitWatcher.close();
89
94
  this.#gitWatcher = null;
@@ -1,4 +1,4 @@
1
- import type { ThinkingLevel } from "@sayknow-cli/agent-core";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@sayknow-cli/agent-core";
2
2
  import type { Effort } from "@sayknow-cli/ai";
3
3
  import {
4
4
  Container,
@@ -666,7 +666,7 @@ export interface SettingsRuntimeContext {
666
666
  /** Available thinking levels (from session) */
667
667
  availableThinkingLevels: Effort[];
668
668
  /** Current thinking level (from session) */
669
- thinkingLevel: ThinkingLevel | undefined;
669
+ thinkingLevel: ThinkingLevelValue | undefined;
670
670
  /** Available themes */
671
671
  availableThemes: string[];
672
672
  /** Available model profile names (from the model registry) */
@@ -857,7 +857,7 @@ export class SettingsSelectorComponent extends Container {
857
857
 
858
858
  // Special case: inject runtime options for thinking level
859
859
  if (def.path === "defaultThinkingLevel") {
860
- options = this.context.availableThinkingLevels.map(level => {
860
+ options = [ThinkingLevel.Off, ...this.context.availableThinkingLevels].map(level => {
861
861
  const baseOpt = options.find(o => o.value === level);
862
862
  return baseOpt || { value: level, label: level };
863
863
  });
@@ -1,51 +1,114 @@
1
- import type { Effort } from "@sayknow-cli/ai";
2
- import { Container, type SelectItem, SelectList } from "@sayknow-cli/tui";
3
- import { getSelectListTheme } from "../../modes/theme/theme";
4
- import { getThinkingLevelMetadata } from "../../thinking";
1
+ import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@sayknow-cli/agent-core";
2
+ import { Container, type SelectItem, SelectList, Text } from "@sayknow-cli/tui";
3
+ import { getSelectListTheme, theme } from "../../modes/theme/theme";
4
+ import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
6
 
7
+ const SCOPE_ITEMS = [
8
+ {
9
+ value: "session",
10
+ label: "Apply for this session",
11
+ description: "Use this effort until the session changes it again",
12
+ },
13
+ {
14
+ value: "default",
15
+ label: "Set as default",
16
+ description: "Save this effort for future sessions",
17
+ },
18
+ ] as const satisfies readonly SelectItem[];
19
+
20
+ export interface ThinkingSelectorSelection {
21
+ level: ThinkingLevelValue;
22
+ persistDefault: boolean;
23
+ }
24
+
7
25
  /**
8
26
  * Component that renders a thinking level selector with borders
9
27
  */
10
28
  export class ThinkingSelectorComponent extends Container {
11
29
  #selectList: SelectList;
30
+ #levelList: SelectList;
31
+ #selectedLevel: ThinkingLevelValue | undefined;
32
+ readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
33
+ readonly #onCancel: () => void;
12
34
 
13
35
  constructor(
14
- currentLevel: Effort,
15
- availableLevels: Effort[],
16
- onSelect: (level: Effort) => void,
36
+ currentLevel: ThinkingLevelValue | undefined,
37
+ availableLevels: ThinkingLevelValue[],
38
+ onSelect: (selection: ThinkingSelectorSelection) => void,
17
39
  onCancel: () => void,
18
40
  ) {
19
41
  super();
20
42
 
21
- const thinkingLevels: SelectItem[] = availableLevels.map(getThinkingLevelMetadata);
43
+ this.#onSelect = onSelect;
44
+ this.#onCancel = onCancel;
22
45
 
23
- // Add top border
24
- this.addChild(new DynamicBorder());
46
+ const currentValue = currentLevel ?? ThinkingLevel.Off;
47
+ const thinkingLevels: SelectItem[] = availableLevels.map(level => {
48
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
49
+ return level === currentValue
50
+ ? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
51
+ : metadata;
52
+ });
25
53
 
26
- // Create selector
27
- this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
54
+ this.#levelList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
55
+ this.#selectList = this.#levelList;
28
56
 
29
57
  // Preselect current level
30
- const currentIndex = thinkingLevels.findIndex(item => item.value === currentLevel);
58
+ const currentIndex = thinkingLevels.findIndex(item => item.value === currentValue);
31
59
  if (currentIndex !== -1) {
32
- this.#selectList.setSelectedIndex(currentIndex);
60
+ this.#levelList.setSelectedIndex(currentIndex);
33
61
  }
34
62
 
35
- this.#selectList.onSelect = item => {
36
- onSelect(item.value as Effort);
63
+ this.#levelList.onSelect = item => {
64
+ this.#selectedLevel = item.value as ThinkingLevelValue;
65
+ this.#renderScopeList();
37
66
  };
38
67
 
39
- this.#selectList.onCancel = () => {
40
- onCancel();
68
+ this.#levelList.onCancel = () => {
69
+ this.#onCancel();
41
70
  };
42
71
 
43
- this.addChild(this.#selectList);
72
+ this.#renderLevelList();
73
+ }
74
+
75
+ #renderLevelList(): void {
76
+ this.detachAll();
77
+ this.#selectedLevel = undefined;
78
+ this.#selectList = this.#levelList;
79
+ this.addChild(new DynamicBorder());
80
+ this.addChild(this.#levelList);
81
+ this.addChild(new DynamicBorder());
82
+ }
83
+
84
+ #renderScopeList(): void {
85
+ const level = this.#selectedLevel;
86
+ if (!level) return;
87
+
88
+ const scopeList = new SelectList(SCOPE_ITEMS, SCOPE_ITEMS.length, getSelectListTheme());
89
+ scopeList.onSelect = item => {
90
+ this.#onSelect({
91
+ level,
92
+ persistDefault: item.value === "default",
93
+ });
94
+ };
95
+ scopeList.onCancel = () => {
96
+ this.#renderLevelList();
97
+ };
44
98
 
45
- // Add bottom border
99
+ const metadata = getThinkingLevelMetadata(level as ThinkingMetadataValue);
100
+ this.detachAll();
101
+ this.#selectList = scopeList;
102
+ this.addChild(new DynamicBorder());
103
+ this.addChild(new Text(theme.fg("muted", ` Reasoning effort: ${metadata.label}`), 0, 0));
104
+ this.addChild(scopeList);
46
105
  this.addChild(new DynamicBorder());
47
106
  }
48
107
 
108
+ handleInput(data: string): void {
109
+ this.#selectList.handleInput(data);
110
+ }
111
+
49
112
  getSelectList(): SelectList {
50
113
  return this.#selectList;
51
114
  }
@@ -16,6 +16,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
16
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
17
17
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
18
18
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
19
+ import { completionNotifyDisabledByEnv } from "../../notifications/config";
19
20
  import { summaryFromMessage } from "../../notifications/helpers";
20
21
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
21
22
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -23,6 +24,7 @@ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
23
24
  import type { ResolveToolDetails } from "../../tools/resolve";
24
25
  import { interruptHint } from "../shared";
25
26
  import { buildAbortDisplayMessage } from "../utils/abort-message";
27
+ import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
26
28
  import { ringTerminalBell } from "../utils/terminal-bell";
27
29
  import { argsWithPartialJson } from "../utils/ui-helpers";
28
30
 
@@ -291,12 +293,17 @@ export class EventController {
291
293
  const signature = `${textContent}\u0000${imageCount}`;
292
294
 
293
295
  this.#resetReadGroup();
294
- const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
296
+ const wasSingleOptimistic = this.ctx.optimisticUserMessageSignature === signature;
297
+ const wasInjectedOptimistic = !wasSingleOptimistic && consumeInjectedOptimisticSignature(this.ctx, signature);
298
+ const wasOptimistic = wasSingleOptimistic || wasInjectedOptimistic;
295
299
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
296
300
  if (!wasOptimistic) {
297
301
  this.ctx.addMessageToChat(event.message);
298
302
  }
299
- if (wasOptimistic) {
303
+ // Only clear the single local slot when IT matched; an injected match is
304
+ // consumed from the counting Map above and must not clobber a coexisting
305
+ // pending local optimistic signature.
306
+ if (wasSingleOptimistic) {
300
307
  this.ctx.optimisticUserMessageSignature = undefined;
301
308
  }
302
309
 
@@ -960,6 +967,8 @@ export class EventController {
960
967
  }
961
968
 
962
969
  sendCompletionNotification(): void {
970
+ // Per-run hard opt-out (env, config-untouched, child-inheritable): SKC_NOTIFY=off.
971
+ if (completionNotifyDisabledByEnv(process.env)) return;
963
972
  const isBackgrounded = this.ctx.isBackgrounded !== false;
964
973
  const notify = settings.get("completion.notify");
965
974
  if (notify === "off") return;
@@ -24,6 +24,7 @@ import { HookSelectorComponent } from "../../modes/components/hook-selector";
24
24
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
25
25
  import type { InteractiveModeContext } from "../../modes/types";
26
26
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
27
+ import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
27
28
  import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
28
29
 
29
30
  const MAX_WIDGET_LINES = 10;
@@ -954,7 +955,13 @@ export class ExtensionUiController {
954
955
  }
955
956
 
956
957
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
957
- this.ctx.session.sendUserMessage(content, options).catch((err: unknown) => {
958
+ // Compute queued BEFORE send: prompt() may flip session.isStreaming synchronously.
959
+ const queued = Boolean(options?.deliverAs) || this.ctx.session.isStreaming;
960
+ // Call send first so the busy/queued path finds the session queue populated
961
+ // (queueSteer/queueFollowUp push synchronously) before refreshing pending display.
962
+ const send = this.ctx.session.sendUserMessage(content, options);
963
+ applyInjectedUserSubmission(this.ctx, { content, queued });
964
+ send.catch((err: unknown) => {
958
965
  this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
959
966
  });
960
967
  };
@@ -18,6 +18,7 @@ import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
18
18
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
19
19
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
20
20
  import { resizeImage } from "../../utils/image-resize";
21
+ import { resolvePastedImagePath } from "../../utils/pasted-image-path";
21
22
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
22
23
  import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
23
24
 
@@ -26,8 +27,8 @@ interface Expandable {
26
27
  }
27
28
 
28
29
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
29
- const CLIPBOARD_TEMP_IMAGE_FILE_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9]+\.(?:png|jpe?g|gif|webp)$/i;
30
- const MACOS_CLIPBOARD_TEMP_DIR_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T$/;
30
+ const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g;
31
+ const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/;
31
32
 
32
33
  function isExpandable(obj: unknown): obj is Expandable {
33
34
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
@@ -384,6 +385,7 @@ export class InputController {
384
385
  this.ctx.isBashMode = trimmed.startsWith("!");
385
386
  this.ctx.isBashNoContext = trimmed.startsWith("!!");
386
387
  this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
388
+ this.#clearPendingImagesIfPlaceholdersRemoved(text);
387
389
  if (
388
390
  wasBashMode !== this.ctx.isBashMode ||
389
391
  wasBashNoContext !== this.ctx.isBashNoContext ||
@@ -419,7 +421,7 @@ export class InputController {
419
421
  }
420
422
 
421
423
  const runner = this.ctx.session.extensionRunner;
422
- let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
424
+ let inputImages = this.#visiblePendingImagesForText(text);
423
425
 
424
426
  if (runner?.hasHandlers("input")) {
425
427
  const result = await runner.emitInput(text, inputImages, "interactive");
@@ -499,7 +501,7 @@ export class InputController {
499
501
 
500
502
  // Queue input during compaction
501
503
  if (this.ctx.session.isCompacting) {
502
- if (this.ctx.pendingImages.length > 0) {
504
+ if ((inputImages?.length ?? 0) > 0) {
503
505
  this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
504
506
  return;
505
507
  }
@@ -521,11 +523,13 @@ export class InputController {
521
523
  // typed since queuing intact. Same protection as #783, applied to
522
524
  // the streaming/queue path.
523
525
  const streamingBehavior = this.#busyStreamingBehavior();
524
- await this.ctx.withLocalSubmission(
525
- text,
526
- () => this.ctx.session.prompt(text, { streamingBehavior, images }),
527
- { imageCount: images?.length ?? 0 },
528
- );
526
+ const promptOptions =
527
+ streamingBehavior === "followUp"
528
+ ? { streamingBehavior, images, followUpQueuePolicy: "sequential" as const }
529
+ : { streamingBehavior, images };
530
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, promptOptions), {
531
+ imageCount: images?.length ?? 0,
532
+ });
529
533
  this.ctx.updatePendingMessagesDisplay();
530
534
  this.ctx.ui.requestRender();
531
535
  return;
@@ -870,7 +874,9 @@ export class InputController {
870
874
  details,
871
875
  attribution: "user",
872
876
  },
873
- { streamingBehavior },
877
+ streamingBehavior === "followUp"
878
+ ? { streamingBehavior, followUpQueuePolicy: "sequential" }
879
+ : { streamingBehavior },
874
880
  );
875
881
  }
876
882
  if (this.ctx.session.isStreaming) {
@@ -912,7 +918,10 @@ export class InputController {
912
918
  this.ctx.editor.addToHistory(text);
913
919
  this.ctx.editor.setText("");
914
920
  await this.ctx.withLocalSubmission(text, () =>
915
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
921
+ this.ctx.session.prompt(text, {
922
+ streamingBehavior: "followUp",
923
+ followUpQueuePolicy: "sequential",
924
+ }),
916
925
  );
917
926
  this.ctx.updatePendingMessagesDisplay();
918
927
  this.ctx.ui.requestRender();
@@ -1061,10 +1070,15 @@ export class InputController {
1061
1070
  }
1062
1071
 
1063
1072
  handleTextPaste(text: string): boolean | Promise<boolean> {
1064
- const imagePath = this.#getPastedImagePathCandidate(text);
1073
+ const imagePath = resolvePastedImagePath(text, { cwd: this.ctx.sessionManager.getCwd() });
1065
1074
  return imagePath ? this.#attachPastedImagePath(imagePath) : false;
1066
1075
  }
1067
1076
 
1077
+ /**
1078
+ * Returns `false` on every failure path so the editor replays the original
1079
+ * bracketed paste — the raw path text must never be lost when attachment
1080
+ * is impossible (unsupported content, oversized image, load error).
1081
+ */
1068
1082
  async #attachPastedImagePath(imagePath: string): Promise<boolean> {
1069
1083
  try {
1070
1084
  const image = await loadImageInput({
@@ -1073,8 +1087,8 @@ export class InputController {
1073
1087
  autoResize: this.ctx.settings.get("images.autoResize"),
1074
1088
  });
1075
1089
  if (!image) {
1076
- this.ctx.showStatus("Unsupported pasted clipboard image file");
1077
- return true;
1090
+ this.ctx.showStatus("Unsupported pasted image file");
1091
+ return false;
1078
1092
  }
1079
1093
 
1080
1094
  this.ctx.pendingImages.push({
@@ -1089,26 +1103,48 @@ export class InputController {
1089
1103
  } catch (error) {
1090
1104
  if (error instanceof ImageInputTooLargeError) {
1091
1105
  this.ctx.showStatus(error.message);
1092
- return true;
1106
+ return false;
1093
1107
  }
1094
- this.ctx.showStatus("Failed to attach pasted clipboard image");
1095
- return true;
1108
+ this.ctx.showStatus("Failed to attach pasted image");
1109
+ return false;
1096
1110
  }
1097
1111
  }
1098
1112
 
1099
- #getPastedImagePathCandidate(text: string): string | undefined {
1100
- const resolvedPath = path.resolve(text.trim());
1101
- const parentDir = path.dirname(resolvedPath);
1102
- const isClipboardTempPath =
1103
- (parentDir === "/tmp" || MACOS_CLIPBOARD_TEMP_DIR_PATTERN.test(parentDir)) &&
1104
- CLIPBOARD_TEMP_IMAGE_FILE_PATTERN.test(path.basename(resolvedPath));
1105
- return isClipboardTempPath ? resolvedPath : undefined;
1106
- }
1107
-
1108
1113
  #nextImagePlaceholder(): string {
1109
1114
  return `[image ${this.ctx.pendingImages.length}]`;
1110
1115
  }
1111
1116
 
1117
+ #visiblePendingImagesForText(text: string): InteractiveModeContext["pendingImages"] | undefined {
1118
+ if (this.ctx.pendingImages.length === 0) {
1119
+ return undefined;
1120
+ }
1121
+
1122
+ const images: InteractiveModeContext["pendingImages"] = [];
1123
+ const seenImageIndexes = new Set<number>();
1124
+ for (const match of text.matchAll(IMAGE_PLACEHOLDER_PATTERN)) {
1125
+ const placeholderNumberText = match[1];
1126
+ if (!placeholderNumberText) continue;
1127
+ const placeholderNumber = Number.parseInt(placeholderNumberText, 10);
1128
+ const imageIndex = placeholderNumber - 1;
1129
+ if (imageIndex < 0 || imageIndex >= this.ctx.pendingImages.length || seenImageIndexes.has(imageIndex)) {
1130
+ continue;
1131
+ }
1132
+ const image = this.ctx.pendingImages[imageIndex];
1133
+ if (!image) continue;
1134
+ images.push(image);
1135
+ seenImageIndexes.add(imageIndex);
1136
+ }
1137
+
1138
+ return images.length > 0 ? images : undefined;
1139
+ }
1140
+
1141
+ #clearPendingImagesIfPlaceholdersRemoved(text: string): void {
1142
+ if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
1143
+ return;
1144
+ }
1145
+ this.ctx.pendingImages = [];
1146
+ }
1147
+
1112
1148
  async handleImagePaste(): Promise<boolean> {
1113
1149
  try {
1114
1150
  const image = await readImageFromClipboard();
@@ -81,6 +81,7 @@ import { SessionSelectorComponent } from "../components/session-selector";
81
81
  import { SettingsSelectorComponent } from "../components/settings-selector";
82
82
  import type { StatusLineSettings } from "../components/status-line";
83
83
  import { ThemeSelectorComponent } from "../components/theme-selector";
84
+ import { ThinkingSelectorComponent } from "../components/thinking-selector";
84
85
  import { ToolExecutionComponent } from "../components/tool-execution";
85
86
  import { TreeSelectorComponent } from "../components/tree-selector";
86
87
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
@@ -328,6 +329,48 @@ export class SelectorController {
328
329
  });
329
330
  }
330
331
 
332
+ showEffortSelector(): void {
333
+ const availableLevels = [
334
+ ThinkingLevel.Inherit,
335
+ ThinkingLevel.Off,
336
+ ...this.ctx.session.getAvailableThinkingLevels(),
337
+ ];
338
+
339
+ this.showSelector(done => {
340
+ const selector = new ThinkingSelectorComponent(
341
+ this.ctx.session.thinkingLevel,
342
+ availableLevels,
343
+ selection => {
344
+ done();
345
+
346
+ const { level, persistDefault } = selection;
347
+ const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
348
+ const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
349
+ this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
350
+ const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
351
+ const requestedLabel =
352
+ level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
353
+ const clampedSuffix =
354
+ effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
355
+
356
+ this.ctx.statusLine.invalidate();
357
+ this.ctx.updateEditorBorderColor();
358
+ this.ctx.updateEditorTopBorder();
359
+ if (persistDefault) void this.ctx.notifyConfigChanged?.();
360
+ this.ctx.ui.requestRender();
361
+ const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
362
+ this.ctx.showStatus(
363
+ `${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
364
+ );
365
+ },
366
+ () => {
367
+ done();
368
+ this.ctx.ui.requestRender();
369
+ },
370
+ );
371
+ return { component: selector, focus: selector };
372
+ });
373
+ }
331
374
  showSettingsSelector(): void {
332
375
  getAvailableThemes().then(availableThemes => {
333
376
  this.showSelector(done => {
@@ -331,6 +331,7 @@ export class InteractiveMode implements InteractiveModeContext {
331
331
  onInputCallback?: (input: SubmittedUserInput) => void;
332
332
  optimisticUserMessageSignature: string | undefined = undefined;
333
333
  locallySubmittedUserSignatures: Set<string> = new Set();
334
+ optimisticInjectedSignatures: Map<string, number> = new Map();
334
335
  #pendingSubmittedInput: SubmittedUserInput | undefined;
335
336
  #pendingSubmissionDispose: (() => void) | undefined;
336
337
  lastSigintTime = 0;
@@ -2561,6 +2562,10 @@ export class InteractiveMode implements InteractiveModeContext {
2561
2562
  this.#selectorController.showModelSelector(options);
2562
2563
  }
2563
2564
 
2565
+ showEffortSelector(): void {
2566
+ this.#selectorController.showEffortSelector();
2567
+ }
2568
+
2564
2569
  showProviderOnboarding(): void {
2565
2570
  this.#selectorController.showProviderOnboarding();
2566
2571
  }
@@ -173,15 +173,9 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
173
173
  return "info";
174
174
  }
175
175
 
176
- export class RpcListenRefusedError extends Error {
177
- constructor(socketPath: string) {
178
- super(
179
- `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
180
- "Stop it first or choose a different --listen path.",
181
- );
182
- this.name = "RpcListenRefusedError";
183
- }
184
- }
176
+ // Thrown by prepareRpcSocketPath when the --listen path has a live owner;
177
+ // re-exported here because main.ts catches it at the RPC launch boundary.
178
+ export { RpcListenRefusedError } from "./rpc-socket-security";
185
179
 
186
180
  /**
187
181
  * Probe whether a unix-domain socket path has a live server accepting
@@ -9,6 +9,18 @@ export class RpcSocketSecurityError extends Error {
9
9
  }
10
10
  }
11
11
 
12
+ // The launch boundary in main.ts catches exactly this class to turn a
13
+ // duplicate --listen into a clean stderr diagnostic + non-zero exit (issue 19).
14
+ export class RpcListenRefusedError extends Error {
15
+ constructor(socketPath: string) {
16
+ super(
17
+ `RPC --listen refused: a live server is already listening on ${socketPath}. ` +
18
+ "Stop it first or choose a different --listen path.",
19
+ );
20
+ this.name = "RpcListenRefusedError";
21
+ }
22
+ }
23
+
12
24
  const unsafeBits = 0o077;
13
25
 
14
26
  function currentUid(): number | undefined {
@@ -53,7 +65,7 @@ export async function prepareRpcSocketPath(socketPath: string): Promise<void> {
53
65
  if (!existing.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
54
66
  assertPrivateMode(existing.mode, socketPath);
55
67
  if (await probeUnixSocketAlive(socketPath)) {
56
- throw new RpcSocketSecurityError(`RPC socket path is live: ${socketPath}`);
68
+ throw new RpcListenRefusedError(socketPath);
57
69
  }
58
70
  await fs.unlink(socketPath);
59
71
  }
@@ -34,6 +34,7 @@ import type { Theme } from "./theme/theme";
34
34
  export type CompactionQueuedMessage = {
35
35
  text: string;
36
36
  mode: "steer" | "followUp";
37
+ followUpQueuePolicy?: "sequential";
37
38
  };
38
39
 
39
40
  export type SubmittedUserInput = {
@@ -116,6 +117,7 @@ export interface InteractiveModeContext {
116
117
  onInputCallback?: (input: SubmittedUserInput) => void;
117
118
  optimisticUserMessageSignature: string | undefined;
118
119
  locallySubmittedUserSignatures: Set<string>;
120
+ optimisticInjectedSignatures: Map<string, number>;
119
121
  lastSigintTime: number;
120
122
  lastEscapeTime: number;
121
123
  lastComposerClearEscapeTime: number;
@@ -244,6 +246,7 @@ export interface InteractiveModeContext {
244
246
  showExtensionsDashboard(): void;
245
247
  showAgentsDashboard(): void;
246
248
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
249
+ showEffortSelector(): void;
247
250
  showProviderOnboarding(): void;
248
251
  showPluginSelector(mode?: "install" | "uninstall"): void;
249
252
  showUserMessageSelector(): void;