From 470ea1e281dee08beede2db0f1bed7bd2f306b39 Mon Sep 17 00:00:00 2001 From: ndaemy Date: Wed, 9 Sep 2026 15:59:59 +0900 Subject: [PATCH 1/2] fix(coding-agent): restore the /thinking interactive dispatch (fixes #1437) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/changes.md | 20 ++ .../src/modes/interactive/interactive-mode.ts | 78 +++++++ .../1437-thinking-command-dispatch.test.ts | 218 ++++++++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 packages/coding-agent/test/suite/regressions/1437-thinking-command-dispatch.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1ed3ad191..4e540cee9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Extensions can inspect the effective shared-host capability during registration, allowing RPC-dependent tools to stay absent when shared-host support is disabled and available when it is enabled. - Oversized resumed sessions now open in a required-compaction state and compact before the first prompt instead of failing constructor-time model-budget admission ([#1511](https://github.com/code-yeongyu/senpi/issues/1511)). +- `/thinking` and `/thinking ` now open the thinking-level selector or set the session level instead of being sent to the model as a user message; the interactive dispatch dropped by the upstream sync merge is restored with argument completions ([#1437](https://github.com/code-yeongyu/senpi/issues/1437)). - Fresh `claude-sdk-oauth` sessions with injected context and multiple first-turn user messages now report continuity `bootstrap` instead of a false `registry_miss` loss; sessions that have a prior assistant message still flatten on a genuine registry miss. ### Removed diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 5d339b2db..0eb885e51 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,4 +1,24 @@ +## 2026-09-09 - Restore the /thinking interactive dispatch (#1437) + +### What changed + +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: `setupEditorSubmitHandler` dispatches `/thinking` and `/thinking ` to `handleThinkingCommand` (next to the `/model` branch); `createBaseAutocompleteProvider` gives the `thinking` builtin argument completions from `session.getAvailableThinkingLevels()`; `handleThinkingCommand`, `selectThinkingLevel`, and `showThinkingSelector` are restored from upstream 496185f6 with the `ThinkingSelectorComponent` import. +- Upstream's single `session.setThinkingLevel(level, { persist })` is mapped onto the fork's split setters: `/thinking ` and Enter in the selector call `setSessionThinkingLevel` (session scope), Ctrl+S in the selector calls `setThinkingLevel` (remembered per-model level), matching the `/settings` thinking row. +- `getAvailableThinkingLevels()` is awaited at every new call site because `InteractiveSession` widens it for the shared-host proxy; `getArgumentCompletions` is async for the same reason. + +### Why + +- The sync merge 463279038 (#1119) kept upstream's `thinking` entry in `BUILTIN_SLASH_COMMANDS` but resolved `interactive-mode.ts` without the handler, so autocomplete and `/help` advertised a command that fell through to `session.prompt()` and reached the model as a user message (#1437). + +### Why an extension could not handle it + +- Builtin slash commands are matched by literal text inside the interactive submit handler before extension commands are consulted; an extension cannot register `thinking` because the name is reserved by `BUILTIN_SLASH_COMMANDS` (`reasoning-commands.test.ts` pins that no alias is registered). + +### Expected merge conflict zones + +- MEDIUM: the `/model`..`/export` run of `if (text === ...)` branches in `setupEditorSubmitHandler`, the `loginCommand`/`thinkingCommand` completion blocks in `createBaseAutocompleteProvider`, and the three methods above `handleModelCommand`. Upstream carries the same methods with a `{ persist }` setter option; keep the fork's split-setter mapping on merge. + ## 2026-09-09 - Surface required compaction after oversized resume ### What changed diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 28723fa05..c2eb13bfc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -188,6 +188,7 @@ import { type StatusIndicator, WorkingStatusIndicator, } from "./components/status-indicator.ts"; +import { ThinkingSelectorComponent } from "./components/thinking-selector.ts"; import { ToolExecutionComponent } from "./components/tool-execution.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; @@ -1234,6 +1235,20 @@ export class InteractiveMode { }; } + const thinkingCommand = slashCommands.find((command) => command.name === "thinking"); + if (thinkingCommand) { + thinkingCommand.getArgumentCompletions = async (prefix: string): Promise => { + // Awaited at the boundary: the shared-host proxy answers this over RPC. + const levels = await this.session.getAvailableThinkingLevels(); + return createFuzzyAutocompleteItems( + levels, + prefix, + (level) => level, + (level) => ({ value: level, label: level }), + ); + }; + } + // Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, @@ -4212,6 +4227,12 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } + if (text === "/thinking" || text.startsWith("/thinking ")) { + const searchTerm = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined; + this.editor.setText(""); + await this.handleThinkingCommand(searchTerm); + return; + } if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); @@ -6881,6 +6902,63 @@ export class InteractiveMode { }); } + private async handleThinkingCommand(searchTerm?: string): Promise { + if (!searchTerm) { + await this.showThinkingSelector(); + return; + } + + // Awaited at the boundary: the shared-host proxy answers this over RPC. + const availableLevels = await this.session.getAvailableThinkingLevels(); + const normalized = searchTerm.trim().toLowerCase(); + const level = availableLevels.find((candidate) => candidate.toLowerCase() === normalized); + if (!level) { + this.showError(`Unknown thinking level "${searchTerm}". Available levels: ${availableLevels.join(", ")}.`); + return; + } + + this.selectThinkingLevel(level, false); + } + + /** + * `persist: false` scopes the level to this session; `persist: true` also + * records it as the model's remembered level (the Ctrl+S path of the selector). + */ + private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void { + try { + if (persist) this.session.setThinkingLevel(level); + else this.session.setSessionThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private async showThinkingSelector(): Promise { + // Awaited at the boundary: the shared-host proxy answers this over RPC. + const availableLevels = await this.session.getAvailableThinkingLevels(); + this.showSelector((done) => { + const selectLevel = (level: ThinkingLevel, persist: boolean) => { + this.selectThinkingLevel(level, persist); + done(); + }; + const selector = new ThinkingSelectorComponent( + this.session.thinkingLevel, + availableLevels, + (level) => selectLevel(level, false), + () => { + done(); + this.ui.requestRender(); + }, + (level) => selectLevel(level, true), + this.settingsManager.getDefaultThinkingLevel(), + ); + return { component: selector, focus: selector }; + }); + } + private async handleModelCommand(searchTerm?: string): Promise { if (!searchTerm) { this.showModelSelector(); diff --git a/packages/coding-agent/test/suite/regressions/1437-thinking-command-dispatch.test.ts b/packages/coding-agent/test/suite/regressions/1437-thinking-command-dispatch.test.ts new file mode 100644 index 000000000..9d5a81007 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1437-thinking-command-dispatch.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; + +vi.mock("../../../src/utils/version-check.ts", () => ({ + checkForNewPiVersion: vi.fn(async () => undefined), + getReleaseChangelogUrl: vi.fn((version: string) => `https://example.invalid/releases/${version}`), +})); + +// Issue #1437: `/thinking` sat in BUILTIN_SLASH_COMMANDS (autocomplete, /help) but +// the interactive submit handler had no branch for it, so the text fell through +// to session.prompt() and reached the model as an ordinary user message. + +function createEchoControllerStub() { + return { + begin: vi.fn(() => "pending-test"), + promptOptions: vi.fn(() => ({ preflightResult: vi.fn(), promptDisposition: vi.fn() })), + reject: vi.fn(), + }; +} + +type SubmitContext = { + defaultEditor: { onSubmit?: (text: string) => void | Promise }; + editor: { addToHistory?: (text: string) => void; setText: (text: string) => void }; + session: { + isCompacting: boolean; + isStreaming: boolean; + isBashRunning: boolean; + prompt: (text: string, options?: unknown) => Promise; + }; + flushPendingBashComponents: () => void; + hideShortcutOverlay: () => void; + isExtensionCommand: (text: string) => boolean; + lastEditorText: string; + onInputCallback?: (input: { text: string; images?: unknown[] }) => void; + pendingUserInputs: { text: string; images?: unknown[] }[]; + pendingImages: Map; + optimisticUserEchoes: ReturnType; + takeSubmissionImages: (submittedText: string) => unknown[]; + handleThinkingCommand: (searchTerm?: string) => Promise; +}; + +type ThinkingContext = { + session: { + thinkingLevel: string; + getAvailableThinkingLevels: () => string[] | Promise; + setThinkingLevel: (level: string) => void; + setSessionThinkingLevel: (level: string) => void; + }; + footer: { invalidate: () => void }; + updateEditorBorderColor: () => void; + showStatus: (message: string) => void; + showError: (message: string) => void; + showThinkingSelector: () => Promise; + selectThinkingLevel: (level: string, persist: boolean) => void; +}; + +type InteractiveModePrivate = { + setupEditorSubmitHandler(this: SubmitContext): void; + takeSubmissionImages(this: SubmitContext, submittedText: string): unknown[]; + handleThinkingCommand(this: ThinkingContext, searchTerm?: string): Promise; + selectThinkingLevel(this: ThinkingContext, level: string, persist: boolean): void; +}; + +const prototype = InteractiveMode.prototype as unknown as InteractiveModePrivate; + +function createSubmitContext(): SubmitContext { + const context: SubmitContext = { + defaultEditor: {}, + editor: { addToHistory: vi.fn(), setText: vi.fn() }, + session: { + isCompacting: false, + isStreaming: false, + isBashRunning: false, + prompt: vi.fn(async () => {}), + }, + flushPendingBashComponents: vi.fn(), + hideShortcutOverlay: vi.fn(), + isExtensionCommand: vi.fn(() => false), + lastEditorText: "", + pendingUserInputs: [], + pendingImages: new Map(), + optimisticUserEchoes: createEchoControllerStub(), + takeSubmissionImages: vi.fn(() => []), + handleThinkingCommand: vi.fn(async () => {}), + }; + context.takeSubmissionImages = prototype.takeSubmissionImages.bind(context); + return context; +} + +function createThinkingContext(levels: string[] | Promise): ThinkingContext { + const context: ThinkingContext = { + session: { + thinkingLevel: "medium", + getAvailableThinkingLevels: vi.fn(() => levels), + setThinkingLevel: vi.fn(), + setSessionThinkingLevel: vi.fn(), + }, + footer: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + showThinkingSelector: vi.fn(async () => {}), + selectThinkingLevel: vi.fn(), + }; + context.selectThinkingLevel = prototype.selectThinkingLevel.bind(context); + return context; +} + +describe("#1437 /thinking is dispatched by the interactive submit handler", () => { + it("routes /thinking to the handler instead of the model", async () => { + //#given + const context = createSubmitContext(); + prototype.setupEditorSubmitHandler.call(context); + + //#when + await context.defaultEditor.onSubmit?.("/thinking high"); + + //#then + expect(context.handleThinkingCommand).toHaveBeenCalledWith("high"); + expect(context.editor.setText).toHaveBeenCalledWith(""); + expect(context.session.prompt).not.toHaveBeenCalled(); + expect(context.pendingUserInputs).toEqual([]); + }); + + it("routes bare /thinking to the selector path", async () => { + //#given + const context = createSubmitContext(); + prototype.setupEditorSubmitHandler.call(context); + + //#when + await context.defaultEditor.onSubmit?.("/thinking"); + + //#then + expect(context.handleThinkingCommand).toHaveBeenCalledWith(undefined); + expect(context.session.prompt).not.toHaveBeenCalled(); + }); + + it("does not treat /thinking-prefixed text as the command", async () => { + //#given + const context = createSubmitContext(); + prototype.setupEditorSubmitHandler.call(context); + + //#when + await context.defaultEditor.onSubmit?.("/thinking-out-loud"); + + //#then + expect(context.handleThinkingCommand).not.toHaveBeenCalled(); + }); +}); + +describe("#1437 handleThinkingCommand", () => { + it("applies a known level to the session only, without touching the remembered default", async () => { + //#given + const context = createThinkingContext(["off", "low", "high"]); + + //#when + await prototype.handleThinkingCommand.call(context, "high"); + + //#then + expect(context.session.setSessionThinkingLevel).toHaveBeenCalledWith("high"); + expect(context.session.setThinkingLevel).not.toHaveBeenCalled(); + expect(context.showStatus).toHaveBeenCalledWith("Thinking level: high"); + expect(context.footer.invalidate).toHaveBeenCalledTimes(1); + expect(context.updateEditorBorderColor).toHaveBeenCalledTimes(1); + expect(context.showError).not.toHaveBeenCalled(); + }); + + it("matches the level case-insensitively and awaits a shared-host level list", async () => { + //#given - the shared-host proxy answers getAvailableThinkingLevels over RPC + const context = createThinkingContext(Promise.resolve(["off", "low", "high"])); + + //#when + await prototype.handleThinkingCommand.call(context, "HIGH"); + + //#then + expect(context.session.setSessionThinkingLevel).toHaveBeenCalledWith("high"); + }); + + it("rejects an unknown level and lists the available ones", async () => { + //#given + const context = createThinkingContext(["off", "low", "high"]); + + //#when + await prototype.handleThinkingCommand.call(context, "turbo"); + + //#then + expect(context.showError).toHaveBeenCalledWith( + 'Unknown thinking level "turbo". Available levels: off, low, high.', + ); + expect(context.session.setSessionThinkingLevel).not.toHaveBeenCalled(); + expect(context.session.setThinkingLevel).not.toHaveBeenCalled(); + }); + + it("opens the selector when no level is given", async () => { + //#given + const context = createThinkingContext(["off", "low", "high"]); + + //#when + await prototype.handleThinkingCommand.call(context, undefined); + + //#then + expect(context.showThinkingSelector).toHaveBeenCalledTimes(1); + expect(context.session.setSessionThinkingLevel).not.toHaveBeenCalled(); + }); + + it("persists through setThinkingLevel only on the explicit default path", () => { + //#given + const context = createThinkingContext(["off", "low", "high"]); + + //#when + context.selectThinkingLevel("low", true); + + //#then + expect(context.session.setThinkingLevel).toHaveBeenCalledWith("low"); + expect(context.session.setSessionThinkingLevel).not.toHaveBeenCalled(); + expect(context.showStatus).toHaveBeenCalledWith("Default thinking level: low"); + }); +}); From fbd44061f4bc8d09f4641d8be4ce75e56b8fbda5 Mon Sep 17 00:00:00 2001 From: ndaemy Date: Wed, 9 Sep 2026 17:49:57 +0900 Subject: [PATCH 2/2] docs(coding-agent): describe /thinking Ctrl+S as per-model memory --- packages/coding-agent/docs/settings.md | 4 ++-- packages/coding-agent/docs/usage.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index f537168b5..e01a15772 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -7,7 +7,7 @@ Senpi uses JSON settings files with project settings overriding global settings. | `~/.senpi/agent/settings.json` | Global (all projects) | | `.senpi/settings.json` | Project (current directory) | -Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To save the startup thinking level, use `/thinking` and press Ctrl+S. +Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To remember a thinking level for the current model across restarts, use `/thinking` and press Ctrl+S; that writes the per-model `modelThinkingLevels` entry, while `defaultThinkingLevel` stays the fallback for models without one. ## Project Trust @@ -84,7 +84,7 @@ Permission rules are a confirmation policy, not a sandbox. Senpi, extensions, pa | `defaultProvider` | string | - | Startup provider (e.g., `"anthropic"`, `"openai"`; saved with Ctrl+S in `/model`, or edited manually) | | `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) | | `recommendedModels` | string[] | `kimi-k3`, `gpt-6-astra`, `gpt-5.6-sol`, `claude-fable-5-1`, `claude-opus-5`, `glm-5.2` | Preferred default model ids in priority order. Built-in thinking levels are kimi-k3/`max`, GPT-6 Astra/`high`, GPT-5.6 Sol/`medium`, claude-fable-5-1/`high`, claude-opus-5/`xhigh`, glm-5.2/`max`. Override the list or disable auto-switch with `--no-recommended-models` / `warnings.offRecommendedModel`. | -| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | +| `defaultThinkingLevel` | string | - | Fallback startup thinking level for models without a `modelThinkingLevels` entry (edited manually; `/reasoning on` also refreshes it): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `modelThinkingLevels` | object | - | Per-model reasoning effort memory (`"provider/id": "level"`) | | `modelLastOnThinkingLevels` | object | - | Per-model last non-off reasoning level, used by `/reasoning on` to restore the previous effort | | `modelServiceTiers` | object | - | Per-model service tier memory (`"provider/id": "auto" \| "priority"`) | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 2575be03b..0ccf6a422 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -42,7 +42,7 @@ Type `/` in the editor to open command completion. Extensions can register custo | `/login`, `/logout` | Manage OAuth or API-key credentials | | [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models | | `/model` | Switch models; Ctrl+S in the picker saves the startup default | -| `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default | +| `/thinking` | Switch thinking level for this session; Ctrl+S in the picker remembers it for the current model | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/reasoning [on\|off]` | Show or toggle reasoning for the current model | | `/efforts [level]` | Show or set reasoning effort (graded models only) |