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
5 changes: 3 additions & 2 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ ade chat attach-linear-issue <session> --issue-id ENG-431
ade chat create --from-linear-issue ENG-431 --no-parent
ade chat list --personal --text
ade chat create --personal --provider codex --model openai/gpt-5.5 --prompt "Plan a trip"
ade chat steer personal-session-id --personal --text "focus on the tradeoffs"
ade chat steer personal-session-id --personal --text "focus on the tradeoffs" # add --dispatch inline|interrupt for atomic active-turn delivery
ade chat interrupt personal-session-id --personal --keep-queue
ade chat restore-queue personal-session-id recovery-id --personal
ade chat actions --personal --text
Expand Down Expand Up @@ -550,6 +550,7 @@ ade chat read session-id --limit 20 --max-chars 8000 --text
ade chat read session-id --page --cursor 4096 --limit 20 --max-chars 8000 --text
ade chat message session-id --kind auto --text "status/context"
ade chat steer session-id --text "active-turn context"
ade chat steer session-id --text "active-turn context" --dispatch interrupt # atomic active-turn delivery: inline | interrupt; omit to stage for the next turn (Claude takes both, Cursor takes interrupt)
ade chat note "testing desktop auth fallback" # update Work status (aim for 6 words or fewer; truncated past 72 characters); add --session <id> to target explicitly
ade chat ask "Which account should I use?" # escalate a blocking question; add --session <id> to target explicitly
ade session show session-id --text # status + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back
Expand All @@ -572,7 +573,7 @@ ade chat demote [session-id] # take over a s
ade chat promote [session-id] # restore a peer as a subagent so it reports to its parent again
ade chat keep-reporting [session-id] # dismiss the takeover prompt without changing the report channel
ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane <lane-id> to hand off into another lane
ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); stays in source lane
ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); cursor has no fork surface so ADE replays the transcript into a fresh agent; stays in source lane
ade chat models --provider codex --json # model order + supported reasoning tiers
ade code
ade code --embedded
Expand Down
51 changes: 51 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4164,6 +4164,57 @@ describe("ADE CLI", () => {
});
});

it("passes chat steer --dispatch through without restating provider rules", () => {
// The host owns which providers honor which active-turn dispatch mode
// (`ACTIVE_TURN_DISPATCH_MODES`), so the CLI forwards the mode verbatim for
// every provider — Cursor's "interrupt" must not be filtered out here.
const interrupt = expectExecutePlan(buildCliPlan([
"chat",
"steer",
"chat-1",
"--text",
"switch to the other repro",
"--dispatch",
"interrupt",
]));
expect(interrupt.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "steer",
args: { sessionId: "chat-1", text: "switch to the other repro", dispatchMode: "interrupt" },
},
});

const inline = expectExecutePlan(buildCliPlan([
"chat", "steer", "chat-1", "--text", "context", "--dispatch-mode", "inline",
]));
expect(inline.steps[0]?.params).toMatchObject({
arguments: { args: { dispatchMode: "inline" } },
});

const personal = expectExecutePlan(buildCliPlan([
"chat", "steer", "personal-1", "--personal", "--text", "context", "--dispatch", "interrupt",
]));
expect(personal.steps[0]).toMatchObject({
params: { action: "steer", args: { sessionId: "personal-1", text: "context", dispatchMode: "interrupt" } },
});

// Omitting the flag stages the message, so no mode reaches the host.
const staged = expectExecutePlan(buildCliPlan([
"chat", "steer", "chat-1", "--text", "context",
]));
expect(
(staged.steps[0]?.params as { arguments?: { args?: Record<string, unknown> } })?.arguments?.args,
).not.toHaveProperty("dispatchMode");

expect(() => buildCliPlan([
"chat", "steer", "chat-1", "--text", "context", "--dispatch", "queue",
])).toThrow(/stages the message for the next turn/);
expect(() => buildCliPlan([
"chat", "steer", "chat-1", "--text", "context", "--dispatch", "later",
])).toThrow(/must be inline or interrupt/);
});

it("routes queue-aware interruption and recovery for project and personal chats", () => {
const stopOnly = expectExecutePlan(buildCliPlan([
"chat",
Expand Down
45 changes: 43 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
machineStatusLine,
} from "../../desktop/src/shared/machinePresence";
import { SEARCH_DOC_KINDS } from "../../desktop/src/shared/types/search";
import type { AgentChatDispatchSteerMode } from "../../desktop/src/shared/types/chat";
import type { TerminalSessionSummary } from "../../desktop/src/shared/types/sessions";
import {
formatWorkingDuration,
Expand Down Expand Up @@ -1927,6 +1928,13 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat message <session> --kind auto --text "status"
Deliver via auto | queue | wake | interrupt-replace
$ ade chat steer <session> --text "context" Steer/queue context into an active turn
$ ade chat steer <session> --text "context" --dispatch interrupt
Deliver into the running turn: inline | interrupt.
Omit --dispatch to stage for the next turn.
Claude takes inline and interrupt; Cursor takes
interrupt (cancel + resend on the same thread).
Other providers reject the flag outright and nothing
is sent; omit --dispatch to stage the message.
$ ade chat wait <session> --for idle --timeout-ms 600000
Wait for idle, active, awaiting-input, or terminal
$ ade chat recover <session> --turn <turn-id> --action nudge
Expand Down Expand Up @@ -2012,8 +2020,9 @@ const HELP_BY_COMMAND: Record<string, string> = {
fork stays on the source provider and in the source lane; brief summarizes
the chat, can switch provider, and accepts --target-lane.
Claude, Codex, OpenCode, and Droid fork through the provider's own fork.
Cursor has no fork surface, so ADE forks it by seeding a fresh Cursor agent
with this conversation's context instead of copying a provider thread.
Cursor has no fork surface, so ADE forks it by replaying this conversation
into a fresh Cursor agent instead of copying a provider thread; the oldest
turns drop if the transcript exceeds the target model's context window.

Personal chats attach to the machine-owned ADE brain and never register a
project. They work with a desktopless brain and through the same
Expand Down Expand Up @@ -3473,6 +3482,28 @@ function normalizeChatMessageKind(value: string | null): "auto" | "queue" | "wak
);
}

/**
* `chat steer --dispatch` asks for atomic delivery into the turn that is
* already running instead of staging the message for the next one. Which
* providers honor which mode is the host's call — the canonical table lives in
* desktop `shared/types/chat.ts` (`ACTIVE_TURN_DISPATCH_MODES`) and the chat
* service rejects an unsupported mode with a templated message — so the CLI
* only validates the shape and never restates the per-provider rules.
*/
function normalizeChatSteerDispatchMode(value: string | null): AgentChatDispatchSteerMode | null {
if (value == null) return null;
const normalized = value.trim().toLowerCase();
if (normalized.length === 0) return null;
if (normalized === "inline" || normalized === "now" || normalized === "send") return "inline";
if (normalized === "interrupt" || normalized === "replace") return "interrupt";
if (normalized === "queue" || normalized === "stage" || normalized === "next") {
throw new CliUsageError(
"chat steer stages the message for the next turn by default; omit --dispatch instead of passing 'queue'.",
);
}
throw new CliUsageError("chat steer --dispatch must be inline or interrupt.");
}

function normalizeChatWaitTarget(value: string | null): ChatWaitTarget {
const normalized = (value ?? "idle").trim().toLowerCase();
if (normalized === "idle" || normalized === "done" || normalized === "complete") return "idle";
Expand Down Expand Up @@ -7652,6 +7683,9 @@ function buildChatPlan(args: string[]): CliPlan {
}
if (sub === "steer") {
const imageUrl = readValue(args, ["--image-url"]);
const dispatchMode = normalizeChatSteerDispatchMode(
readValue(args, ["--dispatch", "--dispatch-mode"]),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const steerText = requireValue(
readValue(args, ["--text", "--message"]) ?? args.join(" "),
"message text",
Expand All @@ -7667,6 +7701,7 @@ function buildChatPlan(args: string[]): CliPlan {
withSession({
sessionId: requireValue(sessionId, "sessionId"),
text: steerText,
...(dispatchMode ? { dispatchMode } : {}),
...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}),
}),
),
Expand Down Expand Up @@ -8346,6 +8381,9 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan {
};
}
if (sub === "steer") {
const dispatchMode = normalizeChatSteerDispatchMode(
readValue(args, ["--dispatch", "--dispatch-mode"]),
);
const text = requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text");
const imageUrl = readValue(args, ["--image-url"]);
return {
Expand All @@ -8354,6 +8392,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan {
steps: [personalChatStep("steer", collectGenericObjectArgs(args, {
sessionId,
text,
...(dispatchMode ? { dispatchMode } : {}),
...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}),
}))],
};
Expand Down Expand Up @@ -12224,6 +12263,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
"--depth",
"--desc",
"--device",
"--dispatch",
"--dispatch-mode",
"--disk",
"--disk-size",
"--display",
Expand Down
16 changes: 16 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,22 @@ describe("commands", () => {
]);
});

it("offers each /steer dispatch command exactly where the provider accepts that mode", () => {
// Gating is derived from ACTIVE_TURN_DISPATCH_MODES, not restated: Claude
// takes inline + interrupt, Cursor only interrupt, everything else stages.
const steerRows = (provider: string) => paletteCommands("/steer", [], { provider })
.map((row) => row.name);
expect(steerRows("claude")).toEqual(expect.arrayContaining(["/steer send", "/steer interrupt"]));
expect(steerRows("cursor")).toContain("/steer interrupt");
expect(steerRows("cursor")).not.toContain("/steer send");
for (const provider of ["codex", "droid", "opencode"]) {
expect(steerRows(provider)).not.toContain("/steer send");
expect(steerRows(provider)).not.toContain("/steer interrupt");
// The provider-agnostic staging commands stay available everywhere.
expect(steerRows(provider)).toEqual(expect.arrayContaining(["/steer edit", "/steer cancel"]));
}
});

it("filters provider-specific ADE commands outside supported chats", () => {
expect(paletteCommands("/context", [], { provider: "codex" })).toContainEqual(
expect.objectContaining({ name: "/context" }),
Expand Down
59 changes: 51 additions & 8 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
import { resolveStableLaneBaseBranch } from "../../../desktop/src/shared/laneBaseResolution";
import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch";
import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots";
import {
activeTurnInterruptContinues,
supportsActiveTurnDispatchMode,
unsupportedActiveTurnDispatchModeMessage,
} from "../../../desktop/src/shared/types/chat";
import { providerDisplayLabel } from "../../../desktop/src/shared/pendingInputLabels";
import {
composerFileSearchQuery,
composerTriggerForSelection,
Expand Down Expand Up @@ -10336,8 +10342,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// A full steer queue drops the message server-side. Surface it the same way
// the primary messageSession path does — throw so submitPrompt restores the
// typed text and shows an error — instead of falsely implying it was sent.
// Every queue-bearing runtime can hit this (Claude, Cursor, Droid,
// OpenCode), so the message names the session's own agent.
if (result.reason === "queue_full") {
throw new Error("The Claude steer queue is full; the message was not queued.");
const agentLabel = providerDisplayLabel(
sessions.find((session) => session.sessionId === sessionId)?.provider,
"agent",
);
throw new Error(`The ${agentLabel} steer queue is full; the message was not queued.`);
}
if (result.queued) {
addNotice("Staged message — sends after the current turn.", "info");
Expand Down Expand Up @@ -10862,10 +10874,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
if (name === "/steer") {
// Which dispatch commands this pane advertises comes off the canonical
// per-provider table (desktop shared/types/chat.ts), the same source the
// /steer commands and the desktop staged strip read — Claude offers both,
// Cursor only the interrupt, everything else stages until the turn ends.
const steerProvider = activeSession?.provider;
const dispatchHint = [
supportsActiveTurnDispatchMode(steerProvider, "inline") ? "/steer send" : null,
supportsActiveTurnDispatchMode(steerProvider, "interrupt") ? "/steer interrupt" : null,
].filter((entry): entry is string => entry != null);
const hintLine = pendingSteers.length
? dispatchHint.length
? `${dispatchHint.join(" · ")} · /steer edit · /steer cancel`
: "Sends when the current turn finishes · /steer edit · /steer cancel"
: null;
const body = pendingSteers.length
? pendingSteers
.map((steer, index) => `${index + 1}. ${steer.text}`)
.join("\n")
? [
pendingSteers.map((steer, index) => `${index + 1}. ${steer.text}`).join("\n"),
...(hintLine ? ["", hintLine] : []),
].join("\n")
: "No staged steer messages are waiting.";
setRightPane({ kind: "details", title: "Staged messages", body });
return;
Expand Down Expand Up @@ -12183,12 +12210,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
if (name === "/steer send" || name === "/steer interrupt") {
if (activeSession?.provider !== "claude") {
addNotice("Only Claude staged messages support send-now and interrupt dispatch.", "error");
// Which modes each provider honors lives in one table (desktop
// shared/types/chat.ts); this branch only maps commands onto it.
const provider = activeSession?.provider;
const mode = name === "/steer send" ? "inline" : "interrupt";
if (!supportsActiveTurnDispatchMode(provider, mode)) {
addNotice(unsupportedActiveTurnDispatchModeMessage(provider, mode), "error");
return;
}
await dispatchSteerMessage(conn, sessionId, latestSteer.steerId, name === "/steer send" ? "inline" : "interrupt");
addNotice(name === "/steer send" ? "Sent staged message into the active Claude turn." : "Interrupting Claude to run the staged message.", "info");
const agentLabel = providerDisplayLabel(provider, "the agent");
// Cursor's interrupt cancels the run and resends on the same thread, so
// it continues rather than starting something new — same wording the
// desktop composer and iOS use, off the same shared fact.
const interruptContinues = activeTurnInterruptContinues(provider);
await dispatchSteerMessage(conn, sessionId, latestSteer.steerId, mode);
addNotice(
mode === "inline"
? `Sent staged message into the active ${agentLabel} turn.`
: interruptContinues
? `Interrupting ${agentLabel} and continuing with the staged message.`
: `Interrupting ${agentLabel} to run the staged message.`,
"info",
);
await refreshState();
return;
}
Expand Down
25 changes: 22 additions & 3 deletions apps/ade-cli/src/tuiClient/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
import type { AgentChatProvider, AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat";
import {
ACTIVE_TURN_DISPATCH_MODES,
type ActiveTurnSendMode,
type AgentChatProvider,
type AgentChatSlashCommand,
} from "../../../desktop/src/shared/types/chat";

/**
* Providers whose backend accepts this atomic active-turn dispatch mode, read
* off the canonical table rather than restated here — adding a provider there
* offers its /steer command in the TUI automatically.
*/
function providersSupporting(mode: ActiveTurnSendMode): AgentChatProvider[] {
return (Object.entries(ACTIVE_TURN_DISPATCH_MODES) as [AgentChatProvider, readonly ActiveTurnSendMode[]][])
.filter(([, modes]) => modes.includes(mode))
.map(([provider]) => provider);
}

const INLINE_STEER_PROVIDERS = providersSupporting("inline");
const INTERRUPT_STEER_PROVIDERS = providersSupporting("interrupt");

export type CommandPlacement = "inline" | "right" | "overlay" | "chat";

Expand Down Expand Up @@ -52,8 +71,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [
{ name: "/quit", description: "Exit ade code", placement: "inline", category: "System" },
{ name: "/steer cancel", description: "Remove the latest staged steer message", placement: "inline", category: "Steer" },
{ name: "/steer edit", description: "Edit the latest staged steer message", placement: "inline", argumentHint: "<text>", category: "Steer" },
{ name: "/steer send", description: "Send the latest staged steer into a Claude turn", placement: "inline", providers: ["claude"], category: "Steer" },
{ name: "/steer interrupt", description: "Interrupt Claude and run the latest staged steer", placement: "inline", providers: ["claude"], category: "Steer" },
{ name: "/steer send", description: "Send the latest staged steer into the active agent's turn", placement: "inline", providers: INLINE_STEER_PROVIDERS, category: "Steer" },
{ name: "/steer interrupt", description: "Interrupt the agent and run the latest staged steer", placement: "inline", providers: INTERRUPT_STEER_PROVIDERS, category: "Steer" },
{ name: "/steer", description: "Show staged steer messages", placement: "right", category: "Steer" },
{ name: "/new lane", description: "Create a new lane", placement: "right", category: "Lanes" },
{ name: "/new chat", description: "Create a new chat in the current lane", placement: "right", argumentHint: "[title]", category: "Chats" },
Expand Down
Loading
Loading