Skip to content

Commit a1c9fec

Browse files
committed
feat: merge upstream PR anomalyco#5958 askquestion tool (replaces anomalyco#5563)
This merges the askquestion tool from upstream which: - Allows AI to pause and solicit structured feedback via TUI wizard - Supports single-select, multi-select, and confirm dialog types - Fixes race conditions from the previous Ask Tool implementation (PR anomalyco#5563) - Uses exponential backoff polling for state synchronization Merged fork features preserved: - Layout density system for small terminals - Spinner style customization - Bash output full-screen viewer - Search in messages (Ctrl+/) - Sidebar resize - All other fork-specific enhancements Also updated fork-features.json and README.md to reference PR anomalyco#5958 instead of anomalyco#5563.
2 parents 6a67059 + 19ad1ee commit a1c9fec

14 files changed

Lines changed: 972 additions & 54 deletions

File tree

README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,21 @@ This fork serves as an integration testing ground for upstream PRs before they a
3030

3131
The following PRs have been merged into this fork and are awaiting merge into upstream:
3232

33-
| PR | Title | Author | Status | Description |
34-
| ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------- |
35-
| [#4898](https://github.com/sst/opencode/pull/4898) | Search in messages | [@OpeOginni](https://github.com/OpeOginni) | Open | Ctrl+ / to search through session messages with highlighting |
36-
| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | [@remorses](https://github.com/remorses) | Open | Full terminal emulation for bash output with color support |
37-
| [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | [@AmineGuitouni](https://github.com/AmineGuitouni) | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits |
38-
| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | [@arsham](https://github.com/arsham) | Open | Real-time token tracking and display during model responses |
39-
| [#4865](https://github.com/sst/opencode/pull/4865) | Subagents sidebar with clickable navigation | [@franlol](https://github.com/franlol) | Open | Show subagents in sidebar with click-to-navigate and parent keybind |
40-
| [#4515](https://github.com/sst/opencode/pull/4515) | Show plugins in /status | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Merged | Display configured plugins in /status dialog alongside MCP/LSP servers |
41-
| [#4411](https://github.com/sst/opencode/pull/4411) | Plugin Commands | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Open | Register custom `/commands` from plugins with aliases and sessionOnly |
42-
| [#5563](https://github.com/sst/opencode/pull/5563) | Ask TUI Tool | [@dbpolito](https://github.com/dbpolito) | Open | Ask tool for agents to collect user input via select/confirm/text dialogs |
43-
| [#5508](https://github.com/sst/opencode/pull/5508) | Cache management command | [@JosXa](https://github.com/JosXa) | Open | `opencode cache info` and `opencode cache clean` for plugin cache mgmt |
44-
| [#5873](https://github.com/sst/opencode/pull/5873) | IDE integration UX improvements | [@tofunori](https://github.com/tofunori) | Open | Selection in footer, synthetic context, home screen IDE status |
45-
| [#5917](https://github.com/sst/opencode/pull/5917) | Draggable sidebar resize | [@agustif](https://github.com/agustif) | Open | Click and drag the sidebar border to resize, width persisted to KV store |
46-
| [#140](https://github.com/Latitudes-Dev/shuvcode/pull/140) | Toggle transparent background | [@JosXa](https://github.com/JosXa) | Open | Command palette toggle for transparent TUI background on any theme |
47-
| [Branch](https://github.com/ariane-emory/opencode/tree/feat/glob-permissions) | Granular File Permissions | [@ariane-emory](https://github.com/ariane-emory) | N/A | Glob pattern support for `permission.edit` to restrict agent file access |
33+
| PR | Title | Author | Status | Description |
34+
| ----------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------ |
35+
| [#4898](https://github.com/sst/opencode/pull/4898) | Search in messages | [@OpeOginni](https://github.com/OpeOginni) | Open | Ctrl+ / to search through session messages with highlighting |
36+
| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | [@remorses](https://github.com/remorses) | Open | Full terminal emulation for bash output with color support |
37+
| [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | [@AmineGuitouni](https://github.com/AmineGuitouni) | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits |
38+
| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | [@arsham](https://github.com/arsham) | Open | Real-time token tracking and display during model responses |
39+
| [#4865](https://github.com/sst/opencode/pull/4865) | Subagents sidebar with clickable navigation | [@franlol](https://github.com/franlol) | Open | Show subagents in sidebar with click-to-navigate and parent keybind |
40+
| [#4515](https://github.com/sst/opencode/pull/4515) | Show plugins in /status | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Merged | Display configured plugins in /status dialog alongside MCP/LSP servers |
41+
| [#4411](https://github.com/sst/opencode/pull/4411) | Plugin Commands | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Open | Register custom `/commands` from plugins with aliases and sessionOnly |
42+
| [#5958](https://github.com/sst/opencode/pull/5958) | AskQuestion Tool | [@iljod](https://github.com/iljod) | Open | Interactive tool for AI to collect user input via TUI wizard dialogs |
43+
| [#5508](https://github.com/sst/opencode/pull/5508) | Cache management command | [@JosXa](https://github.com/JosXa) | Open | `opencode cache info` and `opencode cache clean` for plugin cache mgmt |
44+
| [#5873](https://github.com/sst/opencode/pull/5873) | IDE integration UX improvements | [@tofunori](https://github.com/tofunori) | Open | Selection in footer, synthetic context, home screen IDE status |
45+
| [#5917](https://github.com/sst/opencode/pull/5917) | Draggable sidebar resize | [@agustif](https://github.com/agustif) | Open | Click and drag the sidebar border to resize, width persisted to KV store |
46+
| [#140](https://github.com/Latitudes-Dev/shuvcode/pull/140) | Toggle transparent background | [@JosXa](https://github.com/JosXa) | Open | Command palette toggle for transparent TUI background on any theme |
47+
| [Branch](https://github.com/ariane-emory/opencode/tree/feat/glob-permissions) | Granular File Permissions | [@ariane-emory](https://github.com/ariane-emory) | N/A | Glob pattern support for `permission.edit` to restrict agent file access |
4848

4949
_Last updated: 2025-12-22_
5050

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { BusEvent } from "@/bus/bus-event"
2+
import z from "zod"
3+
4+
export namespace AskQuestion {
5+
/**
6+
* Schema for a single question option
7+
*/
8+
export const OptionSchema = z.object({
9+
value: z.string().describe("Short identifier for the option"),
10+
label: z.string().describe("Display label for the option"),
11+
description: z.string().optional().describe("Additional context for the option"),
12+
})
13+
export type Option = z.infer<typeof OptionSchema>
14+
15+
/**
16+
* Schema for a single question in the wizard
17+
*/
18+
export const QuestionSchema = z.object({
19+
id: z.string().describe("Unique identifier for the question"),
20+
label: z.string().describe("Short tab label, e.g. 'UI Framework'"),
21+
question: z.string().describe("The full question to ask the user"),
22+
options: z.array(OptionSchema).min(2).max(8).describe("2-8 suggested answer options"),
23+
multiSelect: z.boolean().optional().describe("Allow selecting multiple options"),
24+
})
25+
export type Question = z.infer<typeof QuestionSchema>
26+
27+
/**
28+
* Schema for a single answer from the user
29+
*/
30+
export const AnswerSchema = z.object({
31+
questionId: z.string().describe("ID of the question being answered"),
32+
values: z.array(z.string()).describe("Selected option value(s)"),
33+
customText: z.string().optional().describe("Custom text if user typed their own response"),
34+
})
35+
export type Answer = z.infer<typeof AnswerSchema>
36+
37+
/**
38+
* Bus events for askquestion flow
39+
*/
40+
export const Event = {
41+
/**
42+
* Published by the askquestion tool when it needs user input
43+
*/
44+
Requested: BusEvent.define(
45+
"askquestion.requested",
46+
z.object({
47+
sessionID: z.string(),
48+
messageID: z.string(),
49+
callID: z.string(),
50+
questions: z.array(QuestionSchema),
51+
}),
52+
),
53+
54+
/**
55+
* Published by the TUI when user submits answers
56+
*/
57+
Answered: BusEvent.define(
58+
"askquestion.answered",
59+
z.object({
60+
sessionID: z.string(),
61+
callID: z.string(),
62+
answers: z.array(AnswerSchema),
63+
}),
64+
),
65+
66+
/**
67+
* Published when user cancels the question wizard
68+
*/
69+
Cancelled: BusEvent.define(
70+
"askquestion.cancelled",
71+
z.object({
72+
sessionID: z.string(),
73+
callID: z.string(),
74+
}),
75+
),
76+
}
77+
78+
/**
79+
* Pending askquestion requests waiting for user response
80+
*/
81+
interface PendingRequest {
82+
sessionID: string
83+
messageID: string
84+
callID: string
85+
questions: Question[]
86+
resolve: (answers: Answer[]) => void
87+
reject: (error: Error) => void
88+
}
89+
90+
// Global map of pending requests by callID
91+
const pendingRequests = new Map<string, PendingRequest>()
92+
93+
/**
94+
* Register a pending askquestion request
95+
*/
96+
export function register(
97+
callID: string,
98+
sessionID: string,
99+
messageID: string,
100+
questions: Question[],
101+
): Promise<Answer[]> {
102+
return new Promise((resolve, reject) => {
103+
pendingRequests.set(callID, {
104+
sessionID,
105+
messageID,
106+
callID,
107+
questions,
108+
resolve,
109+
reject,
110+
})
111+
})
112+
}
113+
114+
/**
115+
* Get a pending request
116+
*/
117+
export function get(callID: string): PendingRequest | undefined {
118+
return pendingRequests.get(callID)
119+
}
120+
121+
/**
122+
* Get all pending requests for a session
123+
*/
124+
export function getForSession(sessionID: string): PendingRequest[] {
125+
return Array.from(pendingRequests.values()).filter((r) => r.sessionID === sessionID)
126+
}
127+
128+
/**
129+
* Respond to a pending askquestion request
130+
*/
131+
export function respond(callID: string, answers: Answer[]): boolean {
132+
const pending = pendingRequests.get(callID)
133+
if (!pending) return false
134+
pending.resolve(answers)
135+
pendingRequests.delete(callID)
136+
return true
137+
}
138+
139+
/**
140+
* Cancel a pending askquestion request
141+
*/
142+
export function cancel(callID: string): boolean {
143+
const pending = pendingRequests.get(callID)
144+
if (!pending) return false
145+
pending.reject(new Error("User cancelled the question wizard"))
146+
pendingRequests.delete(callID)
147+
return true
148+
}
149+
150+
/**
151+
* Clean up pending requests for a session (e.g., on abort)
152+
*/
153+
export function cleanup(sessionID: string): void {
154+
for (const [callID, request] of pendingRequests) {
155+
if (request.sessionID === sessionID) {
156+
request.reject(new Error("Session aborted"))
157+
pendingRequests.delete(callID)
158+
}
159+
}
160+
}
161+
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ import {
8989
DEFAULT_SPINNER_KEY,
9090
DEFAULT_SPINNER_INTERVAL_MS,
9191
} from "../../util/spinners"
92+
import { DialogAskQuestion } from "../../ui/dialog-askquestion.tsx"
93+
import type { AskQuestion } from "@/askquestion"
9294

9395
// Re-export for backward compatibility
9496
export { getSpinnerFrame } from "../../util/spinners"
@@ -390,6 +392,37 @@ export function Session() {
390392
}
391393
})
392394

395+
// Detect pending askquestion tools from synced message parts
396+
// Access via session.messages -> parts for proper Solid.js reactivity
397+
const pendingAskQuestionFromSync = createMemo(() => {
398+
const sessionMessages = sync.data.message[route.sessionID] ?? []
399+
400+
// Search backwards for the most recent pending question
401+
for (const message of [...sessionMessages].reverse()) {
402+
const parts = sync.data.part[message.id] ?? []
403+
404+
for (const part of [...parts].reverse()) {
405+
if (part.type !== "tool") continue
406+
const toolPart = part as ToolPart
407+
408+
if (toolPart.tool !== "askquestion") continue
409+
if (toolPart.state.status !== "running") continue
410+
411+
const metadata = toolPart.state.metadata as { status?: string; questions?: AskQuestion.Question[] } | undefined
412+
413+
if (metadata?.status !== "waiting") continue
414+
415+
return {
416+
callID: toolPart.callID,
417+
messageId: toolPart.messageID,
418+
questions: (metadata.questions ?? []) as AskQuestion.Question[],
419+
}
420+
}
421+
}
422+
423+
return null
424+
})
425+
393426
let scroll: ScrollBoxRenderable
394427
let bashScroll: ScrollBoxRenderable
395428
let prompt: PromptRef
@@ -1404,9 +1437,66 @@ export function Session() {
14041437
</For>
14051438
</scrollbox>
14061439
<box flexShrink={0}>
1407-
<Show
1408-
when={searchMode()}
1409-
fallback={
1440+
<Switch>
1441+
<Match when={pendingAskQuestionFromSync()}>
1442+
{(pending) => (
1443+
<DialogAskQuestion
1444+
questions={pending().questions}
1445+
onSubmit={async (answers) => {
1446+
await fetch(`${sdk.url}/askquestion/respond`, {
1447+
method: "POST",
1448+
headers: { "Content-Type": "application/json" },
1449+
body: JSON.stringify({
1450+
callID: pending().callID,
1451+
sessionID: route.sessionID,
1452+
answers,
1453+
}),
1454+
}).catch(() => {
1455+
toast.show({
1456+
message: "Failed to submit answers",
1457+
variant: "error",
1458+
})
1459+
})
1460+
}}
1461+
onCancel={async () => {
1462+
await fetch(`${sdk.url}/askquestion/cancel`, {
1463+
method: "POST",
1464+
headers: { "Content-Type": "application/json" },
1465+
body: JSON.stringify({
1466+
callID: pending().callID,
1467+
sessionID: route.sessionID,
1468+
}),
1469+
}).catch(() => {
1470+
toast.show({
1471+
message: "Failed to cancel",
1472+
variant: "error",
1473+
})
1474+
})
1475+
}}
1476+
/>
1477+
)}
1478+
</Match>
1479+
<Match when={searchMode()}>
1480+
<SearchInput
1481+
ref={(r) => (searchRef = r)}
1482+
sessionID={route.sessionID}
1483+
onInput={(query) => {
1484+
setSearchQuery(query)
1485+
setCurrentMatchIndex(0)
1486+
if (query && searchMatches().length > 0) {
1487+
scrollToMatch(0)
1488+
}
1489+
}}
1490+
onNext={nextMatch}
1491+
onPrevious={previousMatch}
1492+
onExit={exitSearch}
1493+
matchInfo={{
1494+
current: currentMatchIndex(),
1495+
total: searchMatches().length,
1496+
}}
1497+
/>
1498+
</Match>
1499+
<Match when={!pendingAskQuestionFromSync() && !searchMode()}>
14101500
<Prompt
14111501
ref={(r) => {
14121502
prompt = r
@@ -1421,27 +1511,8 @@ export function Session() {
14211511
}}
14221512
sessionID={route.sessionID}
14231513
/>
1424-
}
1425-
>
1426-
<SearchInput
1427-
ref={(r) => (searchRef = r)}
1428-
sessionID={route.sessionID}
1429-
onInput={(query) => {
1430-
setSearchQuery(query)
1431-
setCurrentMatchIndex(0)
1432-
if (query && searchMatches().length > 0) {
1433-
scrollToMatch(0)
1434-
}
1435-
}}
1436-
onNext={nextMatch}
1437-
onPrevious={previousMatch}
1438-
onExit={exitSearch}
1439-
matchInfo={{
1440-
current: currentMatchIndex(),
1441-
total: searchMatches().length,
1442-
}}
1443-
/>
1444-
</Show>
1514+
</Match>
1515+
</Switch>
14451516
</box>
14461517
<Show when={density.tokens().showFooter}>
14471518
<Show when={!sidebarVisible()}>

0 commit comments

Comments
 (0)