Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Shared command layer (S3 first-party set: auth / models / session / config).
*
* Backs both subcommands and in-UI slash commands from one definition. The 30+
* pure-UI slash commands (/compact, /thinking, /copy, /export, ...) are not
* pure-UI slash commands (/compact, /effort, /copy, /export, ...) are not
* commands and do not belong here.
*/

Expand Down
35 changes: 12 additions & 23 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,14 +239,7 @@ export function applyStepKeybindingRemap(keybindings: KeybindingsManager): void
* model/effort/mode switches most often, so they lead the list instead of
* following builtin registration order (feedback issue-c6b8e3bb543482b7).
*/
const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = [
"model",
"permissions",
"ultracode",
"effort",
"thinking",
"plan",
];
const STEP_SLASH_COMMAND_PRIORITY: readonly string[] = ["model", "permissions", "ultracode", "effort", "plan"];

/** Exported for the acceptance test suite (tui-acceptance-interactions.test.ts). */
// 结构重构(代码结构方案步骤 4)时迁往 ui/runtime/input-dispatch.ts —— 斜杠命令分派属于交互编排。
Expand Down Expand Up @@ -891,11 +884,9 @@ export class InteractiveMode {
};
}

const thinkingCommands = slashCommands.filter(
(command) => command.name === "thinking" || command.name === "effort",
);
for (const thinkingCommand of thinkingCommands) {
thinkingCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
const effortCommand = slashCommands.find((command) => command.name === "effort");
if (effortCommand) {
effortCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => {
return createFuzzyAutocompleteItems(
this.session.getAvailableThinkingLevels(),
prefix,
Expand Down Expand Up @@ -4775,26 +4766,22 @@ export class InteractiveMode {
return;
}

this.selectThinkingLevel(level, false);
this.selectThinkingLevel(level);
}

private selectThinkingLevel(level: ThinkingLevel, persist: boolean): void {
private selectThinkingLevel(level: ThinkingLevel): void {
try {
this.session.setThinkingLevel(level, { persist });
this.session.setThinkingLevel(level, { persist: true });
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(persist ? `Default thinking level: ${level}` : `Thinking level: ${level}`);
this.showStatus(`Default thinking level: ${level}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}

private showThinkingSelector(): void {
this.showSelector((done) => {
const selectLevel = (level: ThinkingLevel, persist: boolean) => {
this.selectThinkingLevel(level, persist);
done();
};
const availableLevels = this.session.getAvailableThinkingLevels();
const globalDefault = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
const model = this.session.model;
Expand All @@ -4805,12 +4792,14 @@ export class InteractiveMode {
const selector = new ThinkingSelectorComponent(
this.session.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
availableLevels,
(level) => selectLevel(level, false),
(level) => {
this.selectThinkingLevel(level);
done();
},
() => {
done();
this.redraw.requestRender();
},
(level) => selectLevel(level, true),
defaultMarker,
);
return { component: selector, focus: selector };
Expand Down
10 changes: 3 additions & 7 deletions apps/cli/src/ui/runtime/input-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Everything here is relocated VERBATIM from InteractiveMode (only `this.` → `ctx.`). The
* slash if-ladder ORDER, the per-branch `setText("")` placement (before vs after the
* awaited command), the per-branch `addToHistory` position, the `stopLogoIntro()`
* first-in-onSubmit, the exact `/model` vs `/model ` (slice 7) and `/thinking`(10) vs
* first-in-onSubmit, the exact `/model` vs `/model ` (slice 7) and
* `/effort`(8) offsets, the bash `!`/`!!` branch AFTER the slash checks, and the
* compaction gate BEFORE the streaming gate are all load-bearing and preserved byte for
* byte.
Expand Down Expand Up @@ -291,12 +291,8 @@ export function wireSubmitHandler(ctx: RuntimeContext): void {
await ctx.handleModelCommand(searchTerm);
return;
}
if (text === "/thinking" || text.startsWith("/thinking ") || text === "/effort" || text.startsWith("/effort ")) {
const searchTerm = text.startsWith("/thinking ")
? text.slice(10).trim()
: text.startsWith("/effort ")
? text.slice(8).trim()
: undefined;
if (text === "/effort" || text.startsWith("/effort ")) {
const searchTerm = text.startsWith("/effort ") ? text.slice(8).trim() : undefined;
ctx.editor.setText("");
ctx.handleThinkingCommand(searchTerm);
return;
Expand Down
19 changes: 4 additions & 15 deletions apps/cli/src/ui/view/dialogs/thinking-selector.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { ThinkingLevel } from "@step-harness/agent-core";
import { DynamicBorder, getSelectListTheme, keyDisplayText, theme } from "@step-harness/coding-agent";
import { DynamicBorder, getSelectListTheme, theme } from "@step-harness/coding-agent";
import {
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
Expand All @@ -30,7 +29,8 @@ const LEVEL_DESCRIPTIONS: Record<ThinkingLevel, string> = {
};

/**
* Component that renders a thinking level selector with borders
* Component that renders a thinking level selector with borders.
* Enter applies the level and saves it as the startup default.
*/
export class ThinkingSelectorComponent extends Container implements Focusable {
private searchInput: Input;
Expand All @@ -39,7 +39,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable {
private allItems: SelectItem[];
private onSelect: (level: ThinkingLevel) => void;
private onCancel: () => void;
private onSelectAsDefault?: (level: ThinkingLevel) => void;
private _focused = false;

get focused(): boolean {
Expand All @@ -56,13 +55,11 @@ export class ThinkingSelectorComponent extends Container implements Focusable {
availableLevels: ThinkingLevel[],
onSelect: (level: ThinkingLevel) => void,
onCancel: () => void,
onSelectAsDefault?: (level: ThinkingLevel) => void,
defaultThinkingLevel?: ThinkingLevel,
) {
super();
this.onSelect = onSelect;
this.onCancel = onCancel;
this.onSelectAsDefault = onSelectAsDefault;

this.allItems = availableLevels.map((level) => ({
value: level,
Expand All @@ -76,8 +73,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
this.addChild(new Text("Thinking Level", 0, 0));
this.addChild(new Spacer(1));
this.addChild(new Text(`${keyDisplayText("app.thinking.cycle")} cycles thinking levels in-session`, 0, 0));
this.addChild(new Spacer(1));

this.searchInput = new Input();
this.searchInput.onSubmit = () => this.selectList.handleInput("\r");
Expand All @@ -89,7 +84,7 @@ export class ThinkingSelectorComponent extends Container implements Focusable {
this.selectListChildIndex = this.children.length;
this.addChild(this.selectList);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0));
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0));

// Add bottom border
this.addChild(new DynamicBorder());
Expand Down Expand Up @@ -117,12 +112,6 @@ export class ThinkingSelectorComponent extends Container implements Focusable {
}

handleInput(keyData: string): void {
if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) {
const item = this.selectList.getSelectedItem();
if (item) this.onSelectAsDefault(item.value as ThinkingLevel);
return;
}

const kb = getKeybindings();
const isNav =
kb.matches(keyData, "tui.select.up") ||
Expand Down
2 changes: 0 additions & 2 deletions apps/cli/test/interactive-mode-startup-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,7 @@ describe("InteractiveMode startup input", () => {
});

it.each([
["/thinking", undefined],
["/effort", undefined],
["/thinking high", "high"],
["/effort high", "high"],
])("routes %s through the native thinking command handler", async (input, expectedSearchTerm) => {
const context = createSubmitContext();
Expand Down
9 changes: 4 additions & 5 deletions apps/cli/test/interactive-mode-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
});

describe("InteractiveMode.createBaseAutocompleteProvider", () => {
test("offers the same thinking-level completions for /thinking and /effort", async () => {
test("offers thinking-level completions for /effort", async () => {
type FakeInteractiveMode = {
session: {
scopedModels: [];
Expand Down Expand Up @@ -456,12 +456,11 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => {
provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
const thinkingCompletions = await getCompletions("/thinking h");
const effortCompletions = await getCompletions("/effort h");

expect(thinkingCompletions?.items.map((item) => item.value)).toEqual(["high"]);
expect(effortCompletions).toEqual(thinkingCompletions);
expect(getAvailableThinkingLevels).toHaveBeenCalledTimes(2);
expect(effortCompletions?.items.map((item) => item.value)).toEqual(["high"]);
expect(await getCompletions("/thinking h")).toBeNull();
expect(getAvailableThinkingLevels).toHaveBeenCalledOnce();
});

test("matches model command arguments across provider/model order", async () => {
Expand Down
18 changes: 9 additions & 9 deletions apps/cli/test/tui-acceptance-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* TUI 验收交互套件(第 2 层)—— 对应《tui-acceptance-manual.md》F2/F6/F7/K6 项。
*
* 验证三件交互级行为(不经真实终端):
* - F2 斜杠命令优先级:model/permissions/ultracode/effort/thinking/plan 置顶,其余稳定排序;
* - F2 斜杠命令优先级:model/permissions/ultracode/effort/plan 置顶,其余稳定排序;
* - F7/K6 Ctrl+L 重映射:step 模式 ctrl+l → app.redraw,model.select 让位;
* native 模式不受影响;用户显式绑定永远优先。
*/
Expand All @@ -26,7 +26,7 @@ import { applyStepKeybindingRemap, InteractiveMode, orderStepSlashCommands } fro

describe("F2. 斜杠命令优先级", () => {
test("高频命令置顶,其余保持原有相对顺序", () => {
const builtins = ["settings", "model", "tree", "thinking", "effort", "export", "quit"].map((name) => ({
const builtins = ["settings", "model", "tree", "effort", "export", "quit"].map((name) => ({
name,
}));
const extensions = ["init", "permissions", "plugin", "plan", "status", "feedback"].map((name) => ({
Expand All @@ -35,9 +35,9 @@ describe("F2. 斜杠命令优先级", () => {

const ordered = orderStepSlashCommands([...builtins, ...extensions]).map((command) => command.name);

expect(ordered.slice(0, 5)).toEqual(["model", "permissions", "effort", "thinking", "plan"]);
expect(ordered.slice(0, 4)).toEqual(["model", "permissions", "effort", "plan"]);
// 未入优先级的命令保持传入顺序(稳定排序)
expect(ordered.slice(5)).toEqual(["settings", "tree", "export", "quit", "init", "plugin", "status", "feedback"]);
expect(ordered.slice(4)).toEqual(["settings", "tree", "export", "quit", "init", "plugin", "status", "feedback"]);
});

test("无优先级命中时原样返回", () => {
Expand All @@ -46,12 +46,12 @@ describe("F2. 斜杠命令优先级", () => {
});

test("pins registered Ultracode near the top while keeping Ultraloop in the remaining commands", () => {
const names = ["settings", "ultraloop", "model", "permissions", "effort", "thinking", "plan", "ultracode", "quit"];
const names = ["settings", "ultraloop", "model", "permissions", "effort", "plan", "ultracode", "quit"];
const commands = names.map((name) => ({ name }));
const ordered = orderStepSlashCommands(commands).map((command) => command.name);

expect(ordered.slice(0, 6)).toEqual(["model", "permissions", "ultracode", "effort", "thinking", "plan"]);
expect(ordered.slice(6)).toEqual(["settings", "ultraloop", "quit"]);
expect(ordered.slice(0, 5)).toEqual(["model", "permissions", "ultracode", "effort", "plan"]);
expect(ordered.slice(5)).toEqual(["settings", "ultraloop", "quit"]);
expect(commands.map((command) => command.name)).toEqual(names);
});

Expand Down Expand Up @@ -150,10 +150,10 @@ describe("Ultracode discovery from session registration", () => {
const suggestions = await provider.getSuggestions(["/"], 0, 1, { signal: new AbortController().signal });
const names = suggestions!.items.map((item) => item.value);

expect(names.slice(0, 6)).toEqual(["model", "permissions", "ultracode", "effort", "thinking", "plan"]);
expect(names.slice(0, 5)).toEqual(["model", "permissions", "ultracode", "effort", "plan"]);
expect(names.filter((name) => name === "ultracode")).toHaveLength(1);
expect(names.filter((name) => name === "ultraloop")).toHaveLength(1);
expect(names.indexOf("ultraloop")).toBeGreaterThan(5);
expect(names.indexOf("ultraloop")).toBeGreaterThan(4);
});

test("does not add unavailable workflow commands to bare slash completion", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist
| `/login`, `/logout` | Manage provider credentials |
| [`/llama`](docs/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 |
| `/effort` | Switch thinking level and save it as the startup default |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Theme, message delivery, transport, and other preferences |
| `/resume` | Pick from previous sessions |
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 @@ Step uses JSON settings files with project settings overriding global settings.
| `~/.stepcode/agent/settings.json` | Global (all projects) |
| `.stepcode/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 save the startup thinking level, use `/effort` and press Enter.

## Project Trust

Expand All @@ -29,7 +29,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|---------|------|---------|-------------|
| `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) |
| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `defaultThinkingLevel` | string | - | Startup thinking level (saved with Enter in `/effort`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `modelThinkingLevels` | object | - | Per-model startup thinking levels keyed by `"provider/modelId"`; configure from `/settings` → Default thinking level per model or edit manually |
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage |
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/step-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ service and are not silently faked by this fork.
| Model/provider selection, compaction, resume, fork, new session | Pi native commands and `SessionManager` | aligned |
| StepCode OAuth login/logout and legacy credential normalization | `features/step-provider`, `step/auth.ts`, `step/models-endpoint-repair.ts` | aligned; endpoint repair is tested |
| Global/project settings and session paths | Pi managers wrapped by `step/settings-manager.ts` and `step/session.ts` | aligned; writes use `.stepcode` |
| Step permissions, `/permissions`, `/init` | Step extension/facades over Pi selectors and hooks | aligned; `/effort` is Pi's native `/thinking` alias, while `/permission` and `/mode` remain absent |
| Step permissions, `/permissions`, `/init` | Step extension/facades over Pi selectors and hooks | aligned; `/effort` replaces Pi's native `/thinking`, while `/permission` and `/mode` remain absent |
| Built-in `search_web` | Step tool profile backed by `stepsearch.web_search` over Streamable HTTP MCP | aligned; uses search-specific environment overrides and the Step login credential fallback |
| Top-level `step feedback` and TUI `/feedback` | `step/feedback/`, `stepcode.ts`, and the Step extension over Pi's current session/UI | aligned; the body and optional session bundle use separate Step collector requests |
| `/status`, self-memory/skills governance, `/refresh`, `/rewind`, `/copy` full-transcript semantics | Previous gateway/TUI product layer | not yet ported; no misleading alias is registered |
Expand Down
7 changes: 1 addition & 6 deletions packages/coding-agent/src/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,9 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
argumentHint: "<provider/model>",
},
{ name: "tree", description: "Navigate session tree (switch branches)" },
{
name: "thinking",
description: "Set thinking level",
argumentHint: "<level>",
},
{
name: "effort",
description: "Set thinking level (alias for /thinking)",
description: "Set thinking level",
argumentHint: "<level>",
},
{
Expand Down
22 changes: 17 additions & 5 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ const StepAgentScopeSchema = StringEnum(["user", "project", "both"] as const, {

const StepSubscribeSchema = StringEnum(["final", "progress", "none"] as const, {
description:
'Notification level for background lanes. "final" (default) sends one completion notification, "progress" adds throttled progress updates, "none" is fire-and-forget.',
'Notification level for background lanes. "final" (default) sends one completion notification, "progress" adds throttled progress updates, "none" is fire-and-forget. Except with "none", completion, failure, and needs-input notifications wake you automatically, so never sleep or poll to wait for a lane.',
default: "final",
});

Expand Down Expand Up @@ -549,9 +549,9 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
const updateLaneWidget = (lane: BackgroundAgentLane): void => {
if (!lane.ctx.hasUI) return;
shownLanes.add(lane.id);
const batch = [...shownLanes]
.map((id) => lanes.get(id))
.filter((entry): entry is BackgroundAgentLane => entry !== undefined);
// `lanes` iterates in creation order; `shownLanes` in first-update order,
// which depends on which child streams first and would shuffle the rows.
const batch = [...lanes.values()].filter((entry) => shownLanes.has(entry.id));
try {
if (batch.every((entry) => entry.status !== "running")) {
shownLanes.clear();
Expand Down Expand Up @@ -635,7 +635,19 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
const monitorHint =
lane.subscribe === "none"
? "Fire-and-forget lane: no notifications will be sent."
: `Lane events arrive automatically as <agent-notification> messages (subscribe: ${lane.subscribe}).`;
: [
// Without this the parent, told to "wait for the lanes", has no
// wait primitive and falls back to `sleep` loops: the lane result
// then sits behind a sleep of up to minutes, and each progress
// notice landing mid-sleep keeps the loop going.
"Do not wait for it with sleep or polling: end your turn, or carry on with other work.",
"You are woken automatically with an <agent-notification> when the lane finishes, fails, or needs input.",
lane.subscribe === "progress"
? "Progress <agent-notification> messages are informational; they need no reply."
: "",
]
.filter(Boolean)
.join(" ");
return makeToolResult(
details,
`Started background agent ${lane.id}${lane.alias ? ` (${lane.alias})` : ""}. ${monitorHint}`,
Expand Down
Loading
Loading