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
22 changes: 21 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ export function Prompt(props: PromptProps) {
return messages.findLast((m) => m.role === "user")
})

const hasQueuedMessage = createMemo(() => {
if (!props.sessionID) return false
if (status().type === "idle") return false
const messages = sync.data.message[props.sessionID]
if (!messages) return false
const pending = messages.findLast((m) => m.role === "assistant" && !m.time.completed)?.id
if (!pending) return false
return messages.some((m) => m.role === "user" && m.id > pending)
})

const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
Expand Down Expand Up @@ -515,7 +525,12 @@ export function Prompt(props: PromptProps) {
async function submit() {
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (!store.prompt.input) {
if (hasQueuedMessage() && props.sessionID) {
sdk.client.session.force({ sessionID: props.sessionID }).catch(() => {})
}
return
}
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
Expand Down Expand Up @@ -1099,6 +1114,11 @@ export function Prompt(props: PromptProps) {
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
<Show when={hasQueuedMessage()}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>force</span>
</text>
</Show>
</box>
</Show>
<Show when={status().type !== "retry"}>
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,36 @@ export const SessionRoutes = lazy(() =>
return c.json(true)
},
)
.post(
"/:sessionID/force",
describeRoute({
summary: "Force next queued message",
description:
"Abort current session and immediately process the next queued message. Returns false if no queued message exists.",
operationId: "session.force",
responses: {
200: {
description: "Force result",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const result = SessionPrompt.force(c.req.valid("param").sessionID)
return c.json(result)
},
)
.post(
"/:sessionID/share",
describeRoute({
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,23 @@ export namespace SessionPrompt {
return
}

export function force(sessionID: string) {
const s = state()
const match = s[sessionID]
if (!match) return false
if (match.callbacks.length === 0) return false
log.info("force", { sessionID })
for (const item of match.callbacks) {
item.reject()
}
match.callbacks = []
// delete state before aborting so old loop's defer() finds nothing
delete s[sessionID]
match.abort.abort()
loop(sessionID)
return true
}

export const loop = fn(Identifier.schema("session"), async (sessionID) => {
const abort = start(sessionID)
if (!abort) {
Expand Down
32 changes: 32 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ import type {
SessionDeleteErrors,
SessionDeleteResponses,
SessionDiffResponses,
SessionForceErrors,
SessionForceResponses,
SessionForkResponses,
SessionGetErrors,
SessionGetResponses,
Expand Down Expand Up @@ -1289,6 +1291,36 @@ export class Session extends HeyApiClient {
})
}

/**
* Force next queued message
*
* Abort current session and immediately process the next queued message. Returns false if no queued message exists.
*/
public force<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
],
},
],
)
return (options?.client ?? this.client).post<SessionForceResponses, SessionForceErrors, ThrowOnError>({
url: "/session/{sessionID}/force",
...options,
...params,
})
}

/**
* Unshare session
*
Expand Down
33 changes: 33 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3178,6 +3178,39 @@ export type SessionAbortResponses = {

export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses]

export type SessionForceData = {
body?: never
path: {
sessionID: string
}
query?: {
directory?: string
}
url: "/session/{sessionID}/force"
}

export type SessionForceErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}

export type SessionForceError = SessionForceErrors[keyof SessionForceErrors]

export type SessionForceResponses = {
/**
* Force result
*/
200: boolean
}

export type SessionForceResponse = SessionForceResponses[keyof SessionForceResponses]

export type SessionUnshareData = {
body?: never
path: {
Expand Down
Loading