Skip to content

Commit 104a8a4

Browse files
committed
feat(session): implement codex-style steering and busy submit behavior
1 parent 0a0513b commit 104a8a4

23 files changed

Lines changed: 642 additions & 208 deletions

packages/web/src/content/docs/config.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ Available options:
175175
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
176176
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
177177

178-
Note: If you submit a prompt while the session is already running, Zee will steer (interrupt) the active run.
178+
Note: While a session is running, `Enter` steers the active turn and `Tab` queues a message for the next turn.
179179

180180
[Learn more about using the TUI here](/docs/tui).
181181

packages/web/src/content/docs/keybinds.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ You can disable a keybind by adding the key to your config with a value of "none
130130

131131
---
132132

133+
## Busy submit behavior
134+
135+
When a session is running:
136+
137+
- `Enter` steers the active turn immediately.
138+
- `Tab` queues your message to run after the current turn.
139+
140+
---
141+
133142
## Desktop prompt shortcuts
134143

135144
The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
export type BusySubmitDecision =
22
| { submit: "prompt" }
33
| { submit: "steer" }
4+
| { submit: "queue" }
45

56
export function decideBusySubmit(input: {
67
sessionIsBusy: boolean
78
hasSessionID: boolean
9+
hasActiveTurn: boolean
10+
trigger: "enter" | "tab"
811
}): BusySubmitDecision {
912
if (!input.sessionIsBusy) return { submit: "prompt" }
1013

1114
// No active session to steer against (new session flow).
1215
if (!input.hasSessionID) return { submit: "prompt" }
1316

17+
// Session is busy, but no active assistant turn is steerable yet.
18+
if (!input.hasActiveTurn) return { submit: "queue" }
19+
20+
if (input.trigger === "tab") return { submit: "queue" }
1421
return { submit: "steer" }
1522
}

packages/zee/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export type PromptRef = {
6969
submit(): void
7070
}
7171

72+
type SubmitTrigger = "enter" | "tab"
73+
7274

7375
function resolveHoldKeyNames(bindings: Keybind.Info[] | undefined): Set<string> {
7476
const names = new Set<string>()
@@ -99,26 +101,9 @@ export function Prompt(props: PromptProps) {
99101
const safeLayoutWidth = createMemo(() => Math.max(0, layoutWidth()))
100102
const borderFill = createMemo(() => "─".repeat(safeLayoutWidth()))
101103
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
102-
// Extended type to include new fields until SDK is regenerated
103-
type StreamHealthExtended = {
104-
isStalled: boolean
105-
isThinking?: boolean
106-
timeSinceLastEventMs: number
107-
timeSinceContentMs?: number
108-
eventsReceived: number
109-
stallWarnings: number
110-
phase?: "starting" | "thinking" | "tool_calling" | "generating"
111-
charsReceived?: number
112-
estimatedTokens?: number
113-
requestCount?: number
114-
embeddingConfig?: {
115-
model: string
116-
maxContext: number
117-
}
118-
}
119-
const streamHealth = createMemo((): StreamHealthExtended | undefined => {
104+
const streamHealth = createMemo(() => {
120105
const s = status()
121-
return s.type === "busy" ? (s.streamHealth as StreamHealthExtended | undefined) : undefined
106+
return s.type === "busy" ? s.streamHealth : undefined
122107
})
123108
// Session for token counter
124109
const session = createMemo(() => props.sessionID ? sync.session.get(props.sessionID) : undefined)
@@ -674,7 +659,7 @@ export function Prompt(props: PromptProps) {
674659
insertDictationText(transcript)
675660
setDictationState("idle")
676661
if (config.autoSubmit) {
677-
setTimeout(() => submit(), 0)
662+
setTimeout(() => submit("enter"), 0)
678663
}
679664
} catch (error) {
680665
toast.show({
@@ -1227,7 +1212,7 @@ export function Prompt(props: PromptProps) {
12271212
setStore("extmarkToPartIndex", new Map())
12281213
},
12291214
submit() {
1230-
submit()
1215+
submit("enter")
12311216
},
12321217
}
12331218

@@ -1392,7 +1377,7 @@ export function Prompt(props: PromptProps) {
13921377
},
13931378
])
13941379

1395-
async function submit() {
1380+
async function submit(trigger: SubmitTrigger = "enter") {
13961381
if (props.disabled) return
13971382
if (autocomplete?.visible) return
13981383
if (!store.prompt.input) return
@@ -1477,9 +1462,13 @@ export function Prompt(props: PromptProps) {
14771462
// Capture mode before it gets reset
14781463
const currentMode = store.mode
14791464
const variant = local.model.variant.current()
1465+
const sessionStatus = status()
1466+
const activeTurnID = sessionStatus.type === "busy" ? sessionStatus.activeTurnID : undefined
14801467
const busyDecision = decideBusySubmit({
1481-
sessionIsBusy: status().type !== "idle",
1468+
sessionIsBusy: sessionStatus.type !== "idle",
14821469
hasSessionID: Boolean(props.sessionID),
1470+
hasActiveTurn: Boolean(activeTurnID),
1471+
trigger,
14831472
})
14841473

14851474
// Tool permissions based on mode
@@ -1617,9 +1606,41 @@ export function Prompt(props: PromptProps) {
16171606
],
16181607
} satisfies Parameters<typeof sdk.client.session.prompt>[0]
16191608

1609+
const queuePrompt = async () => {
1610+
try {
1611+
await sdk.client.session.prompt(
1612+
{
1613+
...promptPayload,
1614+
noReply: true,
1615+
},
1616+
{ throwOnError: true },
1617+
)
1618+
toast.show({
1619+
message: "Message queued.",
1620+
variant: "info",
1621+
duration: 2000,
1622+
})
1623+
} catch (error) {
1624+
restoreInput()
1625+
toast.show({
1626+
message: `Failed to queue message: ${formatSubmitError(error)}`,
1627+
variant: "error",
1628+
duration: 7000,
1629+
})
1630+
}
1631+
}
1632+
16201633
if (busyDecision.submit === "steer") {
1634+
if (!activeTurnID) {
1635+
await queuePrompt()
1636+
return
1637+
}
16211638
try {
1622-
await sdk.client.session.steer(promptPayload, { throwOnError: true })
1639+
const steerPayload = {
1640+
...promptPayload,
1641+
expectedTurnID: activeTurnID,
1642+
} satisfies Parameters<typeof sdk.client.session.steer>[0]
1643+
await sdk.client.session.steer(steerPayload, { throwOnError: true })
16231644
toast.show({
16241645
message: "Steering message sent.",
16251646
variant: "info",
@@ -1636,6 +1657,11 @@ export function Prompt(props: PromptProps) {
16361657
return
16371658
}
16381659

1660+
if (busyDecision.submit === "queue") {
1661+
await queuePrompt()
1662+
return
1663+
}
1664+
16391665
try {
16401666
await sdk.client.session.prompt(promptPayload, { throwOnError: true })
16411667
} catch (error) {
@@ -2054,6 +2080,20 @@ export function Prompt(props: PromptProps) {
20542080
}
20552081
if (store.mode === "normal") autocomplete.onKeyDown(e)
20562082
if (!autocomplete.visible) {
2083+
if (
2084+
e.name === "tab" &&
2085+
!e.ctrl &&
2086+
!e.meta &&
2087+
!e.shift &&
2088+
!keybind.leader &&
2089+
store.mode !== "shell" &&
2090+
!input.plainText.trimStart().startsWith("!")
2091+
) {
2092+
e.preventDefault()
2093+
await submit("tab")
2094+
return
2095+
}
2096+
20572097
if (
20582098
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
20592099
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
@@ -2078,7 +2118,7 @@ export function Prompt(props: PromptProps) {
20782118
input.cursorOffset = input.plainText.length
20792119
}
20802120
}}
2081-
onSubmit={submit}
2121+
onSubmit={() => submit("enter")}
20822122
onPaste={async (event: PasteEvent) => {
20832123
if (props.disabled) {
20842124
event.preventDefault()

packages/zee/src/gateway/embedded-gateway.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ async function resolvePersonaGateway() {
2828
_resolveAttempted = true
2929
try {
3030
const [configMod, authMod, serverMod, lockMod] = await Promise.all([
31-
importUnchecked("../../Swabble/src/config/config"),
32-
importUnchecked("../../Swabble/src/gateway/auth"),
33-
importUnchecked("../../Swabble/src/gateway/server"),
34-
importUnchecked("../../Swabble/src/infra/gateway-lock"),
31+
importUnchecked("../../../Swabble/src/config/config"),
32+
importUnchecked("../../../Swabble/src/gateway/auth"),
33+
importUnchecked("../../../Swabble/src/gateway/server"),
34+
importUnchecked("../../../Swabble/src/infra/gateway-lock"),
3535
])
3636
_resolved = {
3737
loadConfig: configMod.loadConfig,

packages/zee/src/mcp/index.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ export namespace MCP {
460460
})
461461
}
462462

463+
function resolveRuntimeMcpConfig(name: string, config: McpConfigMap): Config.Mcp | undefined {
464+
const fromConfig = resolveMcpConfigEntry(name, config[name])
465+
if (fromConfig) return fromConfig
466+
467+
const persona = (personaServers as Record<string, PersonaServerConfig>)[name]
468+
if (!persona) return undefined
469+
return forceMcpEnabled(name, {
470+
type: persona.type,
471+
command: Array.from(persona.command),
472+
})
473+
}
474+
463475
function isLocalServer(name: string, config: McpConfigMap): boolean {
464476
const configured = resolveMcpConfigEntry(name, config[name])
465477
if (configured) return configured.type === "local"
@@ -1014,16 +1026,10 @@ export namespace MCP {
10141026
// Use mutex to prevent concurrent state mutations for the same server
10151027
return withServerMutex(name, async () => {
10161028
const cfg = await Config.get()
1017-
const config = cfg.mcp ?? {}
1018-
const mcp = config[name]
1019-
if (!mcp) {
1020-
log.error("MCP config not found", { name })
1021-
return
1022-
}
1023-
1024-
const resolved = resolveMcpConfigEntry(name, mcp)
1029+
const config: McpConfigMap = (cfg.mcp ?? {}) as McpConfigMap
1030+
const resolved = resolveRuntimeMcpConfig(name, config)
10251031
if (!resolved) {
1026-
log.error("Ignoring MCP connect request for config without type", { name })
1032+
log.error("MCP config not found", { name })
10271033
return
10281034
}
10291035

@@ -1103,19 +1109,13 @@ export namespace MCP {
11031109
// Use mutex to prevent concurrent state mutations for the same server
11041110
return withServerMutex(name, async () => {
11051111
const cfg = await Config.get()
1106-
const mcpConfig = cfg.mcp?.[name]
1107-
1108-
if (!mcpConfig) {
1112+
const config: McpConfigMap = (cfg.mcp ?? {}) as McpConfigMap
1113+
const resolved = resolveRuntimeMcpConfig(name, config)
1114+
if (!resolved) {
11091115
log.error("MCP config not found for reconnect", { name })
11101116
return { status: "failed", error: "MCP config not found" }
11111117
}
11121118

1113-
const resolved = resolveMcpConfigEntry(name, mcpConfig)
1114-
if (!resolved) {
1115-
log.error("MCP config invalid for reconnect", { name })
1116-
return { status: "failed", error: "Invalid MCP configuration" }
1117-
}
1118-
11191119
// Close existing client if any
11201120
const s = await state()
11211121
const existingClient = s.clients[name]

packages/zee/src/pkg/sdk/gen/types.gen.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ export type SessionStatus =
472472
}
473473
| {
474474
type: "busy"
475+
activeTurnID?: string
475476
streamHealth?: {
476477
isStalled: boolean
477478
isThinking?: boolean
@@ -481,6 +482,12 @@ export type SessionStatus =
481482
stallWarnings: number
482483
phase?: "starting" | "thinking" | "tool_calling" | "generating"
483484
charsReceived?: number
485+
estimatedTokens?: number
486+
requestCount?: number
487+
embeddingConfig?: {
488+
model: string
489+
maxContext: number
490+
}
484491
}
485492
}
486493

packages/zee/src/pkg/sdk/v2/client.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -441,30 +441,14 @@ class ExtendedSession extends GeneratedSession {
441441
})
442442
}
443443

444-
// Steer a running session by injecting a user message at the next tool-call boundary
445-
steer<ThrowOnError extends boolean = false>(
446-
parameters: {
447-
sessionID: string
448-
parts: Array<{ id?: string; type: "text"; text: string } | { id?: string; type: string; [key: string]: any }>
449-
model?: { providerID: string; modelID: string }
450-
agent?: string
451-
variant?: string
452-
tools?: { [key: string]: boolean }
453-
options?: { [key: string]: unknown }
454-
directory?: string
455-
},
444+
// Steer a running session by injecting a user message into the active turn.
445+
// expectedTurnID must match the current active turn.
446+
override steer<ThrowOnError extends boolean = false>(
447+
parameters: Parameters<GeneratedSession["steer"]>[0] & { directory?: string },
456448
options?: Options<never, ThrowOnError>
457449
) {
458-
const { directory: _, sessionID, ...body } = parameters
459-
return (options?.client ?? this.client).post<void, unknown, ThrowOnError>({
460-
url: `/session/${sessionID}/steer`,
461-
body,
462-
...options,
463-
headers: {
464-
"Content-Type": "application/json",
465-
...options?.headers,
466-
},
467-
})
450+
const { directory: _, ...rest } = parameters
451+
return super.steer<ThrowOnError>(rest as Parameters<GeneratedSession["steer"]>[0], options)
468452
}
469453
}
470454

0 commit comments

Comments
 (0)