Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <level>` 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
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"`) |
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/modes/interactive/changes.md
Original file line number Diff line number Diff line change
@@ -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 <level>` 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 <level>` 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
Expand Down
78 changes: 78 additions & 0 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1234,6 +1235,20 @@ export class InteractiveMode {
};
}

const thinkingCommand = slashCommands.find((command) => command.name === "thinking");
if (thinkingCommand) {
thinkingCommand.getArgumentCompletions = async (prefix: string): Promise<AutocompleteItem[] | null> => {
// 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,
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -6881,6 +6902,63 @@ export class InteractiveMode {
});
}

private async handleThinkingCommand(searchTerm?: string): Promise<void> {
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<void> {
// 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<void> {
if (!searchTerm) {
this.showModelSelector();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> };
editor: { addToHistory?: (text: string) => void; setText: (text: string) => void };
session: {
isCompacting: boolean;
isStreaming: boolean;
isBashRunning: boolean;
prompt: (text: string, options?: unknown) => Promise<void>;
};
flushPendingBashComponents: () => void;
hideShortcutOverlay: () => void;
isExtensionCommand: (text: string) => boolean;
lastEditorText: string;
onInputCallback?: (input: { text: string; images?: unknown[] }) => void;
pendingUserInputs: { text: string; images?: unknown[] }[];
pendingImages: Map<number, unknown>;
optimisticUserEchoes: ReturnType<typeof createEchoControllerStub>;
takeSubmissionImages: (submittedText: string) => unknown[];
handleThinkingCommand: (searchTerm?: string) => Promise<void>;
};

type ThinkingContext = {
session: {
thinkingLevel: string;
getAvailableThinkingLevels: () => string[] | Promise<string[]>;
setThinkingLevel: (level: string) => void;
setSessionThinkingLevel: (level: string) => void;
};
footer: { invalidate: () => void };
updateEditorBorderColor: () => void;
showStatus: (message: string) => void;
showError: (message: string) => void;
showThinkingSelector: () => Promise<void>;
selectThinkingLevel: (level: string, persist: boolean) => void;
};

type InteractiveModePrivate = {
setupEditorSubmitHandler(this: SubmitContext): void;
takeSubmissionImages(this: SubmitContext, submittedText: string): unknown[];
handleThinkingCommand(this: ThinkingContext, searchTerm?: string): Promise<void>;
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<string[]>): 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 <level> 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");
});
});