@dreb/coding-agent 2.51.0 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +8 -6
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +11 -6
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/core/extensions/types.d.ts +31 -11
  6. package/dist/core/extensions/types.d.ts.map +1 -1
  7. package/dist/core/extensions/types.js.map +1 -1
  8. package/dist/core/slash-commands.d.ts +14 -0
  9. package/dist/core/slash-commands.d.ts.map +1 -1
  10. package/dist/core/slash-commands.js +28 -3
  11. package/dist/core/slash-commands.js.map +1 -1
  12. package/dist/core/tools/ask-user.d.ts +28 -20
  13. package/dist/core/tools/ask-user.d.ts.map +1 -1
  14. package/dist/core/tools/ask-user.js +130 -106
  15. package/dist/core/tools/ask-user.js.map +1 -1
  16. package/dist/core/tools/index.d.ts +9 -7
  17. package/dist/core/tools/index.d.ts.map +1 -1
  18. package/dist/core/tools/index.js.map +1 -1
  19. package/dist/modes/interactive/components/ask-wizard.d.ts +83 -0
  20. package/dist/modes/interactive/components/ask-wizard.d.ts.map +1 -0
  21. package/dist/modes/interactive/components/ask-wizard.js +464 -0
  22. package/dist/modes/interactive/components/ask-wizard.js.map +1 -0
  23. package/dist/modes/interactive/interactive-mode.d.ts +4 -2
  24. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  25. package/dist/modes/interactive/interactive-mode.js +7 -5
  26. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  27. package/dist/modes/rpc/rpc-client.d.ts +11 -1
  28. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  29. package/dist/modes/rpc/rpc-client.js +15 -1
  30. package/dist/modes/rpc/rpc-client.js.map +1 -1
  31. package/dist/modes/rpc/rpc-mode.d.ts +3 -1
  32. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  33. package/dist/modes/rpc/rpc-mode.js +230 -47
  34. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  35. package/dist/modes/rpc/rpc-types.d.ts +66 -11
  36. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  37. package/dist/modes/rpc/rpc-types.js.map +1 -1
  38. package/docs/dashboard.md +1 -3
  39. package/docs/extensions.md +39 -22
  40. package/docs/rpc.md +84 -25
  41. package/examples/extensions/rpc-demo.ts +29 -0
  42. package/examples/rpc-extension-ui.ts +162 -6
  43. package/package.json +1 -1
  44. package/dist/modes/interactive/components/ask-user.d.ts +0 -59
  45. package/dist/modes/interactive/components/ask-user.d.ts.map +0 -1
  46. package/dist/modes/interactive/components/ask-user.js +0 -240
  47. package/dist/modes/interactive/components/ask-user.js.map +0 -1
@@ -162,24 +162,33 @@ export default function (dreb: ExtensionAPI) {
162
162
  ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
163
163
  ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
164
164
 
165
- // ctx.ui.ask — a rich clarifying question with options + free text,
166
- // rendered natively in the TUI and Dashboard (and over RPC). Resolves to
167
- // { selected: string[], customText?: string }. Dismissing or timing out an
168
- // ask stops the current agent turn and resolves undefined. This is the same
169
- // primitive that powers the built-in `ask_user` tool.
170
- const answer = await ctx.ui.ask(
165
+ // ctx.ui.ask — one or more rich clarifying questions asked together as a
166
+ // single wizard, rendered natively in the TUI and Dashboard (and over RPC).
167
+ // Resolves to { answers: Array<{ selected: string[], customText?: string,
168
+ // skipped?: boolean }> } with one answer per question, in order. Dismissing
169
+ // or timing out an ask stops the current agent turn and resolves undefined.
170
+ // This is the same primitive that powers the built-in `ask_user` tool.
171
+ const result = await ctx.ui.ask(
171
172
  {
172
173
  title: "Choose a database",
173
- question: "Which persistence strategy should I use?",
174
- options: ["SQLite", "PostgreSQL", "Keep the JSON file"],
175
- allowFreeText: true, // default; offers a "type your own answer" field
176
- multiSelect: false, // true → checkboxes, combined with any free text
177
- multiline: false, // true multi-line free-text area
174
+ questions: [
175
+ {
176
+ question: "Which persistence strategy should I use?",
177
+ options: ["SQLite", "PostgreSQL", "Keep the JSON file"],
178
+ allowFreeText: true, // default; offers a "type your own answer" field
179
+ multiSelect: false, // true → checkboxes, combined with any free text
180
+ multiline: false, // true → multi-line free-text area
181
+ },
182
+ { question: "Any migration constraints I should know about?", multiline: true },
183
+ ],
178
184
  },
179
185
  { signal, timeout: 60000 }, // both optional; absent user never deadlocks
180
186
  );
181
- if (!answer) {
187
+ if (!result) {
182
188
  // The ask was dismissed or timed out; the current agent turn is stopping.
189
+ } else {
190
+ const [dbAnswer] = result.answers; // one entry per question, in order
191
+ // dbAnswer.selected / dbAnswer.customText / dbAnswer.skipped
183
192
  }
184
193
  });
185
194
 
@@ -1167,8 +1176,9 @@ dreb.registerCommand("deploy", {
1167
1176
 
1168
1177
  ### dreb.getCommands()
1169
1178
 
1170
- Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands.
1171
- The list matches the RPC `get_commands` ordering: extensions first, then templates, then skills.
1179
+ Get the slash commands available for invocation via `prompt` in the current session. Includes extension commands, prompt templates, and skill commands, in that order.
1180
+
1181
+ This extension-SDK contract is intentionally narrower than RPC `get_commands`, which also exposes client-handled built-ins for alternate frontends.
1172
1182
 
1173
1183
  ```typescript
1174
1184
  const commands = dreb.getCommands();
@@ -1195,8 +1205,7 @@ Each entry has this shape:
1195
1205
 
1196
1206
  Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.
1197
1207
 
1198
- Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive
1199
- mode and would not execute if sent via `prompt`.
1208
+ Built-in commands (like `/model` and `/settings`) are not included here because they are not prompt-invokable and have no resource provenance. RPC clients can discover them separately through `get_commands`; sending one through an RPC prompt command is rejected rather than passed to the model.
1200
1209
 
1201
1210
  ### dreb.registerMessageRenderer(customType, renderer)
1202
1211
 
@@ -1804,14 +1813,21 @@ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
1804
1813
  // Text input
1805
1814
  const name = await ctx.ui.input("Name:", "placeholder");
1806
1815
 
1807
- // Rich question: question text supports Markdown and options can be combined
1808
- // with free text. Dismissal stops the current agent turn and returns undefined.
1809
- const answer = await ctx.ui.ask({
1816
+ // Rich question wizard: one or more questions asked together, question text
1817
+ // supports Markdown and options can be combined with free text. Resolves to
1818
+ // { answers: [...] } (one per question, in order); dismissal stops the current
1819
+ // agent turn and returns undefined.
1820
+ const result = await ctx.ui.ask({
1810
1821
  title: "Choose a database",
1811
- question: "Which persistence strategy should I use?",
1812
- options: ["SQLite", "PostgreSQL"],
1813
- allowFreeText: true,
1822
+ questions: [
1823
+ {
1824
+ question: "Which persistence strategy should I use?",
1825
+ options: ["SQLite", "PostgreSQL"],
1826
+ allowFreeText: true,
1827
+ },
1828
+ ],
1814
1829
  });
1830
+ const answer = result?.answers[0];
1815
1831
 
1816
1832
  // Multi-line editor
1817
1833
  const text = await ctx.ui.editor("Edit:", "prefilled text");
@@ -2156,6 +2172,7 @@ All examples in [examples/extensions/](../examples/extensions/).
2156
2172
  | `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options |
2157
2173
  | `notify.ts` | Simple notifications | `ui.notify` |
2158
2174
  | `timed-confirm.ts` | Dialogs with timeout | `ui.confirm` with timeout/signal |
2175
+ | `rpc-demo.ts` | Complete RPC extension-UI demo, including the batch question wizard | `ui.select`, `ui.confirm`, `ui.input`, `ui.editor`, `ui.ask`, status/widget/title methods |
2159
2176
  | `mac-system-theme.ts` | Auto-switch theme | `setTheme`, `exec` |
2160
2177
  | **Complex Extensions** |||
2161
2178
  | `plan-mode/` | Full plan mode implementation | All event types, `registerCommand`, `registerShortcut`, `registerFlag`, `setStatus`, `setWidget`, `sendMessage`, `setActiveTools` |
package/docs/rpc.md CHANGED
@@ -82,6 +82,8 @@ If the agent is streaming and no `streamingBehavior` is specified, the command r
82
82
 
83
83
  **Input expansion**: Skill commands (`/skill:name`) and prompt templates (`/template`) are expanded before sending/queueing.
84
84
 
85
+ **Built-in commands are client actions, not model prompts.** Registered built-ins such as `/fork` are rejected by `prompt`, `steer`, and `follow_up` with `success: false`; the message is not queued or sent to the model. Discover them through [`get_commands`](#get_commands), then invoke the corresponding RPC operation or client UI. Matching uses the complete first slash token: `/fork` and `/fork anything` are recognized, while `/forklift` and unknown slash commands remain ordinary prompt text.
86
+
85
87
  Response:
86
88
  ```json
87
89
  {"id": "req-1", "type": "response", "command": "prompt", "success": true}
@@ -169,6 +171,34 @@ If an extension cancelled:
169
171
  {"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}
170
172
  ```
171
173
 
174
+ #### reload
175
+
176
+ Reload session resources (extensions, skills, prompt templates, context, and themes) using the same core reload operation as the interactive command.
177
+
178
+ ```json
179
+ {"type": "reload"}
180
+ ```
181
+
182
+ Response:
183
+ ```json
184
+ {"type": "response", "command": "reload", "success": true}
185
+ ```
186
+
187
+ #### dream
188
+
189
+ Run memory consolidation or manage its archive path. `args` is the text after `/dream`: omit it to run consolidation, use `"backup"` to read the archive path, or `"backup <path>"` to set it.
190
+
191
+ ```json
192
+ {"type": "dream", "args": "backup"}
193
+ ```
194
+
195
+ Response:
196
+ ```json
197
+ {"type": "response", "command": "dream", "success": true, "data": {"message": "Dream backup path: /home/user/.dreb/memory-archive"}}
198
+ ```
199
+
200
+ A consolidation run performs and verifies the backup before prompting the agent. Backup, lock, path-validation, settings-write, or consolidation failures are explicit RPC errors.
201
+
172
202
  ### State
173
203
 
174
204
  #### get_state
@@ -800,6 +830,19 @@ Response:
800
830
  }
801
831
  ```
802
832
 
833
+ #### import_jsonl
834
+
835
+ Import a JSONL session into the current runtime. This can be cancelled by a session-switch extension hook.
836
+
837
+ ```json
838
+ {"type": "import_jsonl", "inputPath": "/tmp/session.jsonl"}
839
+ ```
840
+
841
+ Response:
842
+ ```json
843
+ {"type": "response", "command": "import_jsonl", "success": true, "data": {"cancelled": false}}
844
+ ```
845
+
803
846
  #### switch_session
804
847
 
805
848
  Load a different session file. Can be cancelled by a `session_before_switch` extension event handler.
@@ -945,7 +988,7 @@ The current session name is available via `get_state` in the `sessionName` field
945
988
 
946
989
  #### get_commands
947
990
 
948
- Get available commands (extension commands, prompt templates, and skills). These can be invoked via the `prompt` command by prefixing with `/`.
991
+ Discover extension commands, prompt templates, skills, and registered built-ins. Resource commands can be invoked through `prompt`; built-ins have `source: "builtin"` and require client-side handling through the corresponding RPC operation or UI. Sending a recognized built-in through `prompt`, `steer`, or `follow_up` is rejected fail-closed.
949
992
 
950
993
  ```json
951
994
  {"type": "get_commands"}
@@ -959,28 +1002,26 @@ Response:
959
1002
  "success": true,
960
1003
  "data": {
961
1004
  "commands": [
962
- {"name": "session-name", "description": "Set or clear session name", "source": "extension", "path": "/home/user/.dreb/agent/extensions/session.ts"},
963
- {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.dreb/agent/prompts/fix-tests.md"},
964
- {"name": "skill:brave-search", "description": "Web search via Brave API", "source": "skill", "location": "user", "path": "/home/user/.dreb/agent/skills/brave-search/SKILL.md"}
1005
+ {"name": "session-name", "description": "Set or clear session name", "source": "extension", "sourceInfo": {"path": "/home/user/.dreb/agent/extensions/session.ts"}},
1006
+ {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "sourceInfo": {"path": "/home/user/myproject/.dreb/agent/prompts/fix-tests.md"}},
1007
+ {"name": "skill:brave-search", "description": "Web search via Brave API", "source": "skill", "sourceInfo": {"path": "/home/user/.dreb/agent/skills/brave-search/SKILL.md"}},
1008
+ {"name": "fork", "description": "Create a new fork from a previous message", "source": "builtin", "dashboard": true},
1009
+ {"name": "copy", "description": "Copy last agent message to clipboard", "source": "builtin", "dashboard": false}
965
1010
  ]
966
1011
  }
967
1012
  }
968
1013
  ```
969
1014
 
970
- Each command has:
971
- - `name`: Command name (invoke with `/name`)
972
- - `description`: Human-readable description (optional for extension commands)
973
- - `source`: What kind of command:
974
- - `"extension"`: Registered via `dreb.registerCommand()` in an extension
975
- - `"prompt"`: Loaded from a prompt template `.md` file
976
- - `"skill"`: Loaded from a skill directory (name is prefixed with `skill:`)
977
- - `location`: Where it was loaded from (optional, not present for extensions):
978
- - `"user"`: User-level (`~/.dreb/agent/`)
979
- - `"project"`: Project-level (`./.dreb/agent/`)
980
- - `"path"`: Explicit path via CLI or settings
981
- - `path`: Absolute file path to the command source (optional)
1015
+ Every command has `name`, optional `description`, and a `source`:
1016
+
1017
+ - `"extension"`: registered via `dreb.registerCommand()`; prompt-invokable and includes `sourceInfo`.
1018
+ - `"prompt"`: loaded prompt template; prompt-invokable and includes `sourceInfo`.
1019
+ - `"skill"`: loaded skill (name prefixed with `skill:`); prompt-invokable and includes `sourceInfo`.
1020
+ - `"builtin"`: core slash command; not prompt-invokable, has no file `sourceInfo`, and includes `dashboard`. A false value means dashboard clients should omit it from autocomplete while still intercepting typed use with terminal-only guidance.
982
1021
 
983
- **Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are not included. They are handled only in interactive mode and would not execute if sent via `prompt`.
1022
+ Names are deduplicated, with a built-in taking precedence over a colliding resource command. Future registry entries appear automatically. Hidden development commands are intentionally not registered and do not appear.
1023
+
1024
+ The extension SDK's `dreb.getCommands()` contract is unchanged: it continues to return only commands invokable via `prompt`, with required resource provenance. Built-ins are added only to this RPC discovery surface.
984
1025
 
985
1026
  ### Session Listing
986
1027
 
@@ -2015,7 +2056,7 @@ Expected response: `extension_ui_response` with `value` (the edited text) or `ca
2015
2056
 
2016
2057
  #### ask
2017
2058
 
2018
- Ask the user a rich clarifying question with Markdown-formatted question text, optional single- or multi-select options, and an optional free-text field. This powers the built-in `ask_user` tool. `options` (2-4 nonblank strings) is optional; `allowFreeText` (default `true`), `multiSelect`, and `multiline` are optional booleans.
2059
+ Ask the user one or more rich clarifying questions together as a single wizard. Each question has Markdown-formatted question text, optional single- or multi-select options, and an optional free-text field. This powers the built-in `ask_user` tool. The request carries a `questions` array (1-10 entries); per question, `options` (2-4 nonblank strings) is optional and `allowFreeText` (default `true`), `multiSelect`, and `multiline` are optional booleans. An overall `title` defaults to `"Question"`.
2019
2060
 
2020
2061
  ```json
2021
2062
  {
@@ -2023,11 +2064,20 @@ Ask the user a rich clarifying question with Markdown-formatted question text, o
2023
2064
  "id": "uuid-5",
2024
2065
  "method": "ask",
2025
2066
  "title": "Choose a database",
2026
- "question": "Which persistence strategy should I use?",
2027
- "options": ["SQLite", "PostgreSQL", "Keep the JSON file"],
2028
- "allowFreeText": true,
2029
- "multiSelect": false,
2030
- "multiline": false,
2067
+ "questions": [
2068
+ {
2069
+ "question": "Which persistence strategy should I use?",
2070
+ "title": "Storage",
2071
+ "options": ["SQLite", "PostgreSQL", "Keep the JSON file"],
2072
+ "allowFreeText": true,
2073
+ "multiSelect": false,
2074
+ "multiline": false
2075
+ },
2076
+ {
2077
+ "question": "Any migration constraints I should know about?",
2078
+ "multiline": true
2079
+ }
2080
+ ],
2031
2081
  "timeout": 60000,
2032
2082
  "expiresAt": 1785434460000
2033
2083
  }
@@ -2035,12 +2085,21 @@ Ask the user a rich clarifying question with Markdown-formatted question text, o
2035
2085
 
2036
2086
  `timeout` is the original duration in milliseconds. `expiresAt` is the corresponding absolute Unix timestamp in milliseconds; Dashboard clients should use it for the visible countdown so reload, resync, or drill-in recovery does not restart the full duration.
2037
2087
 
2038
- Expected response: `extension_ui_response` with `selected` (an array of strings) and optional string `customText` (the typed answer). Sending `cancelled: true`, or an empty `selected` with no nonblank `customText`, stops the current agent turn rather than continuing without an answer. A timeout has the same stop semantics. Other malformed ask responses are rejected as protocol failures.
2088
+ Expected response: `extension_ui_response` with `answers` — an array with one entry per question, in the same order. Each answer has `selected` (an array of strings) and optional string `customText` (the typed answer); an answer with an empty `selected` and no nonblank `customText` is treated as skipped (an explicit `skipped: true` is also honored). Answering submits the batch even when some questions are skipped. Sending `cancelled: true` stops the current agent turn rather than continuing; a timeout has the same stop semantics. A missing/non-array `answers`, or a malformed `selected`/`customText`, is rejected as a protocol failure.
2039
2089
 
2040
2090
  ```json
2041
- { "type": "extension_ui_response", "id": "uuid-5", "selected": ["SQLite"], "customText": "with WAL enabled" }
2091
+ {
2092
+ "type": "extension_ui_response",
2093
+ "id": "uuid-5",
2094
+ "answers": [
2095
+ { "selected": ["SQLite"], "customText": "with WAL enabled" },
2096
+ { "selected": [], "skipped": true }
2097
+ ]
2098
+ }
2042
2099
  ```
2043
2100
 
2101
+ `ask` requests are single-flight per RPC runtime. If parallel tool execution starts several calls concurrently, RPC emits only the first request and queues the rest in FIFO order. The next request is emitted only after the active request settles; a queued call that is aborted before it starts settles without emitting. Hosts therefore need to render at most one pending `ask` wizard at a time.
2102
+
2044
2103
  #### notify
2045
2104
 
2046
2105
  Display a notification. Fire-and-forget, no response expected.
@@ -10,6 +10,7 @@
10
10
  * - confirm() - on session_before_switch
11
11
  * - input() - via /rpc-input command
12
12
  * - editor() - via /rpc-editor command
13
+ * - ask() - via /rpc-ask command
13
14
  * - notify() - after each dialog completes
14
15
  * - setStatus() - on turn_start/turn_end
15
16
  * - setWidget() - on session_start
@@ -112,6 +113,34 @@ export default function (dreb: ExtensionAPI) {
112
113
  },
113
114
  });
114
115
 
116
+ // -- batch ask wizard via command --
117
+
118
+ dreb.registerCommand("rpc-ask", {
119
+ description: "Open a batch question wizard (demonstrates ctx.ui.ask in RPC)",
120
+ handler: async (_args, ctx) => {
121
+ const result = await ctx.ui.ask({
122
+ title: "Configure the RPC demo",
123
+ questions: [
124
+ {
125
+ question: "Which **transport checks** should run?",
126
+ title: "Checks",
127
+ options: ["Protocol", "Reconnect", "Timeout"],
128
+ multiSelect: true,
129
+ allowFreeText: true,
130
+ },
131
+ {
132
+ question: "Anything else the RPC host should display?",
133
+ title: "Notes",
134
+ multiline: true,
135
+ },
136
+ ],
137
+ });
138
+ if (!result) return;
139
+ const answered = result.answers.filter((answer) => !answer.skipped).length;
140
+ ctx.ui.notify(`Submitted ${answered} of ${result.answers.length} answers`, "info");
141
+ },
142
+ });
143
+
115
144
  // -- setEditorText via command --
116
145
 
117
146
  dreb.registerCommand("rpc-prefill", {
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * A lightweight TUI chat client that spawns the agent in RPC mode.
5
5
  * Demonstrates how to build a custom UI on top of the RPC protocol,
6
- * including handling extension UI requests (select, confirm, input, editor).
6
+ * including handling extension UI requests (select, confirm, input, editor, ask).
7
7
  *
8
8
  * Usage: npx tsx examples/rpc-extension-ui.ts
9
9
  *
10
10
  * Slash commands:
11
- * /select - demo select dialog
12
- * /confirm - demo confirm dialog
13
- * /input - demo input dialog
14
- * /editor - demo editor dialog
11
+ * /select - demo select dialog
12
+ * /confirm - demo confirm dialog
13
+ * /input - demo input dialog
14
+ * /editor - demo editor dialog
15
+ * /rpc-ask - demo batch ask wizard from the companion extension
15
16
  */
16
17
 
17
18
  import { spawn } from "node:child_process";
@@ -39,12 +40,28 @@ const RESET = "\x1b[0m";
39
40
  // Extension UI request type (subset of rpc-types.ts)
40
41
  // ============================================================================
41
42
 
43
+ interface AskQuestion {
44
+ question: string;
45
+ title?: string;
46
+ options?: string[];
47
+ allowFreeText?: boolean;
48
+ multiSelect?: boolean;
49
+ multiline?: boolean;
50
+ }
51
+
52
+ interface AskAnswer {
53
+ selected: string[];
54
+ customText?: string;
55
+ skipped?: boolean;
56
+ }
57
+
42
58
  interface ExtensionUIRequest {
43
59
  type: "extension_ui_request";
44
60
  id: string;
45
61
  method: string;
46
62
  title?: string;
47
63
  options?: string[];
64
+ questions?: AskQuestion[];
48
65
  message?: string;
49
66
  placeholder?: string;
50
67
  prefill?: string;
@@ -239,6 +256,98 @@ class InputDialog implements Component {
239
256
  }
240
257
  }
241
258
 
259
+ /**
260
+ * Compact batch-question wizard for the RPC example. Each page accepts option
261
+ * numbers and optional free text separated by commas, then sends one ordered
262
+ * answers[] response after the final question.
263
+ */
264
+ class AskDialog implements Component {
265
+ private readonly input = new Input();
266
+ private readonly answers: AskAnswer[] = [];
267
+ private questionIndex = 0;
268
+ onSubmit?: (answers: AskAnswer[]) => void;
269
+ onCancel?: () => void;
270
+ onCtrlD?: () => void;
271
+
272
+ constructor(
273
+ private readonly title: string,
274
+ private readonly questions: AskQuestion[],
275
+ ) {
276
+ this.input.onSubmit = (value) => this.acceptAnswer(value);
277
+ this.input.onEscape = () => this.onCancel?.();
278
+ }
279
+
280
+ private acceptAnswer(value: string): void {
281
+ const question = this.questions[this.questionIndex];
282
+ if (!question) return;
283
+ const raw = value.trim();
284
+ const options = question.options ?? [];
285
+ const selected: string[] = [];
286
+ const customParts: string[] = [];
287
+
288
+ if (options.length === 0) {
289
+ if (raw) customParts.push(raw);
290
+ } else if (raw) {
291
+ for (const token of raw
292
+ .split(",")
293
+ .map((part) => part.trim())
294
+ .filter(Boolean)) {
295
+ const numericIndex = /^\d+$/.test(token) ? Number(token) - 1 : -1;
296
+ const matched =
297
+ options[numericIndex] ?? options.find((option) => option.toLowerCase() === token.toLowerCase());
298
+ if (matched && (question.multiSelect || selected.length === 0)) {
299
+ if (!selected.includes(matched)) selected.push(matched);
300
+ } else if (!matched && question.allowFreeText !== false) {
301
+ customParts.push(token);
302
+ }
303
+ }
304
+ }
305
+
306
+ const customText = customParts.join(", ").trim() || undefined;
307
+ this.answers.push(selected.length > 0 || customText ? { selected, customText } : { selected: [], skipped: true });
308
+ this.questionIndex++;
309
+ if (this.questionIndex >= this.questions.length) {
310
+ this.onSubmit?.(this.answers);
311
+ return;
312
+ }
313
+ this.input.setValue("");
314
+ }
315
+
316
+ handleInput(data: string): void {
317
+ if (matchesKey(data, "ctrl+d")) {
318
+ this.onCtrlD?.();
319
+ return;
320
+ }
321
+ this.input.handleInput(data);
322
+ }
323
+
324
+ invalidate(): void {
325
+ this.input.invalidate();
326
+ }
327
+
328
+ render(width: number): string[] {
329
+ const question = this.questions[this.questionIndex];
330
+ if (!question) return [`${RED}Invalid empty ask request${RESET}`];
331
+ const options = question.options ?? [];
332
+ const optionLines = options.map((option, index) => ` ${index + 1}. ${option}`);
333
+ const answerHint =
334
+ options.length === 0
335
+ ? "Type an answer"
336
+ : question.multiSelect
337
+ ? "Type option numbers separated by commas"
338
+ : "Type one option number";
339
+ const customHint = options.length > 0 && question.allowFreeText !== false ? ", plus optional text" : "";
340
+ return [
341
+ `${MAGENTA}${BOLD}${this.title} — ${this.questionIndex + 1}/${this.questions.length}${RESET}`,
342
+ `${BOLD}${question.title ?? question.question}${RESET}`,
343
+ ...(question.title ? [question.question] : []),
344
+ ...optionLines,
345
+ ...this.input.render(width),
346
+ `${DIM}${answerHint}${customHint}. Enter to continue, Esc to cancel.${RESET}`,
347
+ ];
348
+ }
349
+ }
350
+
242
351
  // ============================================================================
243
352
  // Main
244
353
  // ============================================================================
@@ -300,6 +409,7 @@ async function main() {
300
409
  // These helpers swap between them.
301
410
 
302
411
  let activeDialog: Component | null = null;
412
+ let activeDialogId: string | null = null;
303
413
 
304
414
  function setBottomComponent(component: Component): void {
305
415
  root.clear();
@@ -312,6 +422,7 @@ async function main() {
312
422
 
313
423
  function showPrompt(): void {
314
424
  activeDialog = null;
425
+ activeDialogId = null;
315
426
  setBottomComponent(promptInput);
316
427
  tui.setFocus(promptInput.input);
317
428
  }
@@ -374,8 +485,27 @@ async function main() {
374
485
  tui.setFocus(dialog.inputComponent);
375
486
  }
376
487
 
488
+ function showAskDialog(
489
+ title: string,
490
+ questions: AskQuestion[],
491
+ onDone: (answers: AskAnswer[] | undefined) => void,
492
+ ): void {
493
+ const dialog = new AskDialog(title, questions);
494
+ dialog.onSubmit = (answers) => {
495
+ showPrompt();
496
+ onDone(answers);
497
+ };
498
+ dialog.onCancel = () => {
499
+ showPrompt();
500
+ onDone(undefined);
501
+ };
502
+ dialog.onCtrlD = exit;
503
+ showDialog(dialog);
504
+ }
505
+
377
506
  function handleExtensionUI(req: ExtensionUIRequest): void {
378
507
  const { id, method } = req;
508
+ if (["select", "confirm", "input", "editor", "ask"].includes(method)) activeDialogId = id;
379
509
 
380
510
  switch (method) {
381
511
  // Dialog methods: replace prompt with interactive component
@@ -422,6 +552,23 @@ async function main() {
422
552
  break;
423
553
  }
424
554
 
555
+ case "ask": {
556
+ const questions = req.questions ?? [];
557
+ if (questions.length === 0) {
558
+ showPrompt();
559
+ send({ type: "extension_ui_response", id, cancelled: true });
560
+ break;
561
+ }
562
+ showAskDialog(req.title ?? "Question", questions, (answers) => {
563
+ if (answers) {
564
+ send({ type: "extension_ui_response", id, answers });
565
+ } else {
566
+ send({ type: "extension_ui_response", id, cancelled: true });
567
+ }
568
+ });
569
+ break;
570
+ }
571
+
425
572
  // Fire-and-forget methods: display as notification
426
573
  case "notify": {
427
574
  const notifyType = (req.notifyType as string) ?? "info";
@@ -450,6 +597,10 @@ async function main() {
450
597
  break;
451
598
  }
452
599
 
600
+ case "setTitle":
601
+ terminal.setTitle(req.title ?? "");
602
+ break;
603
+
453
604
  case "set_editor_text":
454
605
  promptInput.input.setValue((req.text as string) ?? "");
455
606
  tui.requestRender();
@@ -535,6 +686,11 @@ async function main() {
535
686
  return;
536
687
  }
537
688
 
689
+ if (data.type === "extension_ui_response_handled") {
690
+ if (data.id === activeDialogId) showPrompt();
691
+ return;
692
+ }
693
+
538
694
  if (data.type === "message_update") {
539
695
  const evt = data.assistantMessageEvent as Record<string, unknown> | undefined;
540
696
  if (evt?.type === "text_delta") {
@@ -620,7 +776,7 @@ async function main() {
620
776
 
621
777
  outputLog.append(`${BOLD}RPC Chat${RESET}`);
622
778
  outputLog.append(`${DIM}Type a message and press Enter. Esc to abort or exit. Ctrl+D to quit.${RESET}`);
623
- outputLog.append(`${DIM}Slash commands: /select /confirm /input /editor${RESET}`);
779
+ outputLog.append(`${DIM}Slash commands: /select /confirm /input /editor /rpc-ask${RESET}`);
624
780
  outputLog.append("");
625
781
 
626
782
  tui.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreb/coding-agent",
3
- "version": "2.51.0",
3
+ "version": "2.53.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "drebConfig": {
@@ -1,59 +0,0 @@
1
- /**
2
- * ask_user question component.
3
- *
4
- * Renders a clarifying question with optional single- or multi-select options
5
- * and an optional free-text field, matching the approved cross-surface design.
6
- *
7
- * Keyboard model (settled in issue 396 discussion):
8
- * ↑/↓ move a single cursor through the options and, last, the free-text field
9
- * Space toggle the highlighted checkbox (multi-select only)
10
- * Enter single-select: pick the highlighted option and submit;
11
- * free-text field: submit the typed answer;
12
- * multi-select: submit all checked options plus any free text
13
- * Shift+Enter insert a newline in the multiline free-text field
14
- * Esc stop the current agent turn
15
- */
16
- import { Container, type Focusable, type TUI } from "@dreb/tui";
17
- import type { AskRequest, AskResult } from "../../../core/extensions/types.js";
18
- export interface AskUserComponentOptions {
19
- tui?: TUI;
20
- timeout?: number;
21
- }
22
- export declare class AskUserComponent extends Container implements Focusable {
23
- private options;
24
- private allowFreeText;
25
- private multiSelect;
26
- private multiline;
27
- /** Cursor over [options..., freeTextRow?]. */
28
- private cursorIndex;
29
- /** Checkbox state for multi-select, aligned to options. */
30
- private checked;
31
- private onSubmitCallback;
32
- private onStopCallback;
33
- private titleText;
34
- private baseTitle;
35
- private optionsContainer;
36
- private fieldLabel;
37
- private input;
38
- private editor;
39
- private countdown;
40
- private submitted;
41
- private _focused;
42
- get focused(): boolean;
43
- set focused(value: boolean);
44
- private get freeTextRow();
45
- private get lastRow();
46
- private cursorOnField;
47
- constructor(request: AskRequest, onSubmit: (result: AskResult) => void, onStop: () => void, opts?: AskUserComponentOptions);
48
- private buildHint;
49
- private renderRows;
50
- private syncFieldFocus;
51
- private moveCursor;
52
- private fieldText;
53
- private currentAnswer;
54
- private submit;
55
- private stop;
56
- handleInput(keyData: string): void;
57
- dispose(): void;
58
- }
59
- //# sourceMappingURL=ask-user.d.ts.map