@xp266/dshtui 0.1.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 (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. package/src/vendor.ts +12 -0
@@ -0,0 +1,53 @@
1
+ import { registerToolView } from './tool-views.ts'
2
+ import { ASK_USER_TOOL_NAME, formatAskAnswered, formatAskQuestions, parseAskAnswers, parseAskQuestions } from './question-view.ts'
3
+ import { TODO_TOOL_NAME, formatTodoBubble, parseTodoArgs } from './todo-view.ts'
4
+
5
+ /**
6
+ * The interactive builtins ride the same contribution registry third-party
7
+ * plugins use: keyed by tool name, `takeover` keeps the protocol presenter
8
+ * out. The call renders the pending interaction; the ask result rewrites the
9
+ * card into question-plus-choice so the settled record shows what was picked,
10
+ * not the offer again. The todo checklist already IS the settled state (the
11
+ * call args carry the new list), so its result stays a wipe.
12
+ */
13
+ let registered: (() => void) | undefined
14
+
15
+ export function registerBuiltinToolViews(): () => void {
16
+ if (registered !== undefined) return registered
17
+ const offTodo = registerToolView({
18
+ tool: TODO_TOOL_NAME,
19
+ takeover: true,
20
+ call: ({ tool, args }) => {
21
+ const items = parseTodoArgs(args)
22
+ return { label: tool, body: items.length === 0 ? '' : formatTodoBubble(items) }
23
+ },
24
+ })
25
+ const offAsk = registerToolView({
26
+ tool: ASK_USER_TOOL_NAME,
27
+ takeover: true,
28
+ call: ({ tool, args }) => ({ label: tool, body: formatAskQuestions(args) }),
29
+ result: ({ result, args }) => {
30
+ const answers = parseAskAnswers(resultTextOf(result))
31
+ if (answers.length === 0) return { kind: 'replace', text: '' }
32
+ return {
33
+ kind: 'replace',
34
+ text: formatAskAnswered(parseAskQuestions(args), answers),
35
+ argsBody: '',
36
+ }
37
+ },
38
+ })
39
+ registered = () => {
40
+ offTodo()
41
+ offAsk()
42
+ registered = undefined
43
+ }
44
+ return registered
45
+ }
46
+
47
+ function resultTextOf(result: { content: readonly { type: string; text?: string }[] }): string {
48
+ let text = ''
49
+ for (const block of result.content) {
50
+ if (block.type === 'text') text += block.text ?? ''
51
+ }
52
+ return text
53
+ }
@@ -0,0 +1,16 @@
1
+ import type { PendingImage } from '../core/paste.ts'
2
+ import type { TuiChatFace } from '../contract/index.ts'
3
+ import type { ChatBridge } from './bridge.ts'
4
+
5
+ export function createChatFace(bridge: ChatBridge): TuiChatFace {
6
+ return {
7
+ cwd: () => bridge.cwd(),
8
+ activeSessionId: () => bridge.activeSessionId(),
9
+ onEvent: listener => bridge.subscribe(listener),
10
+ send: (text, images) => bridge.send(text, images),
11
+ interrupt: () => bridge.interrupt(),
12
+ newSession: () => bridge.newSession(),
13
+ openSession: id => bridge.openSession(id),
14
+ listSessions: () => bridge.listSessions(),
15
+ }
16
+ }
@@ -0,0 +1,53 @@
1
+ import { keyedRegistry } from '../kernel/registry.ts'
2
+ import type { ChatNodeDefinition, CustomMessage } from '../contract/index.ts'
3
+ import type { Message } from '../model/message.ts'
4
+ import type { TurnState } from './store.ts'
5
+
6
+ const definitions = keyedRegistry<ChatNodeDefinition>()
7
+
8
+ export function registerChatNode(definition: ChatNodeDefinition): () => void {
9
+ return definitions.register(definition.id, definition, { order: definition.order })
10
+ }
11
+
12
+ export function subscribeChatNodes(listener: () => void): () => void {
13
+ return definitions.subscribe(listener)
14
+ }
15
+
16
+ let sequence = 0
17
+
18
+ function nextNodeId(nodeId: string): string {
19
+ sequence += 1
20
+ return `node-${nodeId}-${sequence}`
21
+ }
22
+
23
+ function claimChatNode(messages: Message[], event: Parameters<ChatNodeDefinition['match']>[0], turn: TurnState, definition: ChatNodeDefinition): boolean {
24
+ const claimedId = turn.chatNodes.get(definition.id)
25
+ if (claimedId !== undefined) {
26
+ const at = messages.findIndex(message => message.id === claimedId)
27
+ const current = at >= 0 && messages[at]!.kind === 'custom' ? (messages[at] as CustomMessage) : undefined
28
+ if (current !== undefined) {
29
+ if (definition.update === undefined) return true
30
+ const next = definition.update(event, current)
31
+ if (next === null) {
32
+ messages.splice(at, 1)
33
+ turn.chatNodes.delete(definition.id)
34
+ return true
35
+ }
36
+ messages[at] = { ...next, kind: 'custom', id: current.id }
37
+ return true
38
+ }
39
+ turn.chatNodes.delete(definition.id)
40
+ }
41
+ const message: CustomMessage = { ...definition.start(event), kind: 'custom', id: nextNodeId(definition.id) }
42
+ messages.push(message)
43
+ turn.chatNodes.set(definition.id, message.id)
44
+ return true
45
+ }
46
+
47
+ export function applyChatNodes(messages: Message[], event: Parameters<ChatNodeDefinition['match']>[0], turn: TurnState): boolean {
48
+ for (const definition of definitions.values()) {
49
+ if (!definition.match(event)) continue
50
+ return claimChatNode(messages, event, turn, definition)
51
+ }
52
+ return false
53
+ }
@@ -0,0 +1,4 @@
1
+ export interface EffortSummary {
2
+ id: string
3
+ name: string
4
+ }
@@ -0,0 +1,329 @@
1
+ import { keyedRegistry } from '../kernel/registry.ts'
2
+ import type { InteractionPanelComponentProps, InteractionPanelContribution } from '../contract/index.ts'
3
+
4
+ export type { InteractionPanelComponentProps, InteractionPanelContribution } from '../contract/index.ts'
5
+
6
+ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
7
+
8
+ export interface ApprovalPanelRequest {
9
+ id: string
10
+ toolName: string
11
+ callId?: string
12
+ reason?: string
13
+ }
14
+
15
+ export interface AskOptionLike {
16
+ label: string
17
+ description?: string
18
+ }
19
+
20
+ export interface AskQuestionItemLike {
21
+ id: string
22
+ question: string
23
+ header?: string
24
+ detail?: string
25
+ options?: AskOptionLike[]
26
+ multiSelect?: boolean
27
+ multi_select?: boolean
28
+ intent?: { kind?: string; approve?: string }
29
+ }
30
+
31
+ export interface AskUserQuestionRequestLike {
32
+ questions: AskQuestionItemLike[]
33
+ signal?: AbortSignal
34
+ }
35
+
36
+ export interface AskUserQuestionAnswerItemLike {
37
+ id: string
38
+ selected: string[]
39
+ custom?: string
40
+ }
41
+
42
+ export interface AskUserQuestionAnswerLike {
43
+ answers: AskUserQuestionAnswerItemLike[]
44
+ }
45
+
46
+ export interface QuestionPanelRequest {
47
+ id: string
48
+ request: AskUserQuestionRequestLike
49
+ }
50
+
51
+ /**
52
+ * One pending interaction surfaced to the shell. Built-in kinds carry their
53
+ * typed payload in the documented fields; plugin kinds carry anything in
54
+ * `request` and are rendered by the matching panel contribution.
55
+ */
56
+ export interface ActivePanel {
57
+ kind: string
58
+ /** Built-in approval payload (kind === 'approval'). */
59
+ readonly approval?: ApprovalPanelRequest
60
+ /** Built-in question payload (kind === 'question'). */
61
+ readonly question?: QuestionPanelRequest
62
+ /** Plugin-defined payload for contributions registered under `kind`. */
63
+ readonly request?: unknown
64
+ /** Settle a built-in approval. */
65
+ settle?(outcome: ApprovalOutcome | unknown): void
66
+ /** Resolve the interaction with a value (question or plugin kinds). */
67
+ resolve?(value: unknown): void
68
+ /** Reject the interaction; approvals settle as 'cancelled'. */
69
+ reject?(cause: unknown): void
70
+ }
71
+
72
+ interface ApprovalEntry {
73
+ panel: ApprovalPanelRequest
74
+ settle: (value: ApprovalOutcome) => void
75
+ detach: () => void
76
+ }
77
+
78
+ interface QuestionEntry {
79
+ panel: QuestionPanelRequest
80
+ resolve: (answer: AskUserQuestionAnswerLike) => void
81
+ reject: (cause: unknown) => void
82
+ detach: () => void
83
+ }
84
+
85
+ export interface InteractionPanelRequest {
86
+ kind: string
87
+ request: unknown
88
+ resolve(value: unknown): void
89
+ reject(cause: unknown): void
90
+ }
91
+
92
+ export class InteractionPanelRegistry {
93
+ private contributions = keyedRegistry<InteractionPanelContribution>()
94
+
95
+ register(contribution: InteractionPanelContribution): () => void {
96
+ return this.contributions.register(contribution.kind, contribution, { order: contribution.order })
97
+ }
98
+
99
+ of(kind: string): InteractionPanelContribution | undefined {
100
+ return this.contributions.get(kind)
101
+ }
102
+ }
103
+
104
+ let sequence = 0
105
+
106
+ function nextId(prefix: string): string {
107
+ sequence += 1
108
+ return `${prefix}-${Date.now()}-${sequence}`
109
+ }
110
+
111
+ function attachAbort(signal: AbortSignal | undefined, onAbort: () => void): () => void {
112
+ if (signal === undefined) return () => {}
113
+ signal.addEventListener('abort', onAbort, { once: true })
114
+ return () => {
115
+ signal.removeEventListener('abort', onAbort)
116
+ }
117
+ }
118
+
119
+ export class InteractionStore {
120
+ private listeners = new Set<() => void>()
121
+ private approvals: ApprovalEntry[] = []
122
+ private questions: QuestionEntry[] = []
123
+ private generic: Array<{ entry: InteractionPanelRequest; detach: () => void }> = []
124
+ private snapshot: ActivePanel | null = null
125
+ readonly panels = new InteractionPanelRegistry()
126
+
127
+ subscribe = (listener: () => void): (() => void) => {
128
+ this.listeners.add(listener)
129
+ return () => {
130
+ this.listeners.delete(listener)
131
+ }
132
+ }
133
+
134
+ getSnapshot = (): ActivePanel | null => this.snapshot
135
+
136
+ private notify(): void {
137
+ for (const listener of [...this.listeners]) listener()
138
+ }
139
+
140
+ private refresh(): void {
141
+ const approval = this.approvals[0]
142
+ if (approval !== undefined) {
143
+ this.snapshot = {
144
+ kind: 'approval',
145
+ approval: approval.panel,
146
+ settle: outcome => {
147
+ this.settleApproval(approval.panel.id, outcome as ApprovalOutcome)
148
+ },
149
+ }
150
+ } else {
151
+ const question = this.questions[0]
152
+ if (question !== undefined) {
153
+ this.snapshot = {
154
+ kind: 'question',
155
+ question: question.panel,
156
+ resolve: answer => {
157
+ this.answerQuestion(question.panel.id, answer as AskUserQuestionAnswerLike)
158
+ },
159
+ reject: cause => {
160
+ this.cancelQuestion(question.panel.id, cause instanceof Error ? cause.message : String(cause))
161
+ },
162
+ }
163
+ } else {
164
+ const generic = this.generic[0]
165
+ this.snapshot = generic === undefined
166
+ ? null
167
+ : { kind: generic.entry.kind, request: generic.entry.request, resolve: generic.entry.resolve, reject: generic.entry.reject }
168
+ }
169
+ }
170
+ this.notify()
171
+ }
172
+
173
+ /**
174
+ * Queue a plugin-defined interaction request. A panel contribution for the
175
+ * given kind must exist for the request to render; the returned promise
176
+ * settles when the panel resolves it, or rejects on abort.
177
+ */
178
+ pushRequest(kind: string, request: unknown, signal?: AbortSignal): Promise<unknown> {
179
+ if (signal?.aborted === true) return Promise.reject(new Error(`"${kind}" interaction was aborted before the user answered`))
180
+ return new Promise((resolve, reject) => {
181
+ const entry: InteractionPanelRequest = { kind, request, resolve, reject }
182
+ const detach = attachAbort(signal, () => {
183
+ this.cancelRequest(kind, request, `"${kind}" interaction was aborted before the user answered`)
184
+ })
185
+ this.generic.push({ entry, detach })
186
+ this.refresh()
187
+ })
188
+ }
189
+
190
+ resolveRequest(kind: string, request: unknown, value: unknown): boolean {
191
+ const index = this.generic.findIndex(slot => slot.entry.kind === kind && slot.entry.request === request)
192
+ if (index < 0) return false
193
+ const [slot] = this.generic.splice(index, 1)
194
+ slot.detach()
195
+ slot.entry.resolve(value)
196
+ this.refresh()
197
+ return true
198
+ }
199
+
200
+ cancelRequest(kind: string, request: unknown, message: string): boolean {
201
+ const index = this.generic.findIndex(slot => slot.entry.kind === kind && slot.entry.request === request)
202
+ if (index < 0) return false
203
+ const [slot] = this.generic.splice(index, 1)
204
+ slot.detach()
205
+ slot.entry.reject(new Error(message))
206
+ this.refresh()
207
+ return true
208
+ }
209
+
210
+ pushApproval(toolName: string, callId?: string, reason?: string, signal?: AbortSignal): Promise<ApprovalOutcome> {
211
+ if (signal?.aborted === true) return Promise.resolve('cancelled')
212
+ return new Promise(resolve => {
213
+ const panel: ApprovalPanelRequest = {
214
+ id: nextId('approval'),
215
+ toolName,
216
+ ...(callId === undefined ? {} : { callId }),
217
+ ...(reason === undefined ? {} : { reason }),
218
+ }
219
+ const detach = attachAbort(signal, () => {
220
+ this.settleApproval(panel.id, 'cancelled')
221
+ })
222
+ this.approvals.push({ panel, settle: resolve, detach })
223
+ this.refresh()
224
+ })
225
+ }
226
+
227
+ settleApproval(id: string, outcome: ApprovalOutcome): boolean {
228
+ const index = this.approvals.findIndex(entry => entry.panel.id === id)
229
+ if (index < 0) return false
230
+ const [entry] = this.approvals.splice(index, 1)
231
+ entry.detach()
232
+ entry.settle(outcome)
233
+ this.refresh()
234
+ return true
235
+ }
236
+
237
+ pushQuestion(rawRequest: AskUserQuestionRequestLike, signal?: AbortSignal): Promise<AskUserQuestionAnswerLike> {
238
+ const request: AskUserQuestionRequestLike = {
239
+ ...rawRequest,
240
+ questions: rawRequest.questions.map(question => ({
241
+ ...question,
242
+ multiSelect: question.multiSelect ?? question.multi_select,
243
+ })),
244
+ }
245
+ if (request.questions.length === 0) {
246
+ return Promise.reject(new Error('no questions were provided'))
247
+ }
248
+ if (signal?.aborted === true) {
249
+ return Promise.reject(new Error('ask_user_question was aborted before the user answered'))
250
+ }
251
+ return new Promise((resolve, reject) => {
252
+ const panel: QuestionPanelRequest = { id: nextId('question'), request }
253
+ const detach = attachAbort(signal, () => {
254
+ this.cancelQuestion(panel.id, 'ask_user_question was aborted before the user answered')
255
+ })
256
+ this.questions.push({ panel, resolve, reject, detach })
257
+ this.refresh()
258
+ })
259
+ }
260
+
261
+ answerQuestion(id: string, answer: AskUserQuestionAnswerLike): boolean {
262
+ const index = this.questions.findIndex(entry => entry.panel.id === id)
263
+ if (index < 0) return false
264
+ const [entry] = this.questions.splice(index, 1)
265
+ entry.detach()
266
+ entry.resolve(answer)
267
+ this.refresh()
268
+ return true
269
+ }
270
+
271
+ cancelQuestion(id: string, message: string): boolean {
272
+ const index = this.questions.findIndex(entry => entry.panel.id === id)
273
+ if (index < 0) return false
274
+ const [entry] = this.questions.splice(index, 1)
275
+ entry.detach()
276
+ entry.reject(new Error(message))
277
+ this.refresh()
278
+ return true
279
+ }
280
+
281
+ dispose(): void {
282
+ const approvals = this.approvals.splice(0)
283
+ for (const entry of approvals) {
284
+ entry.detach()
285
+ entry.settle('cancelled')
286
+ }
287
+ const questions = this.questions.splice(0)
288
+ for (const entry of questions) {
289
+ entry.detach()
290
+ entry.reject(new Error('the interaction store was disposed'))
291
+ }
292
+ const generic = this.generic.splice(0)
293
+ for (const slot of generic) {
294
+ slot.detach()
295
+ slot.entry.reject(new Error('the interaction store was disposed'))
296
+ }
297
+ this.refresh()
298
+ }
299
+ }
300
+
301
+ type ApprovalRequestLike = {
302
+ agent: unknown
303
+ toolName: string
304
+ callId?: string
305
+ reason?: string
306
+ signal?: AbortSignal
307
+ }
308
+
309
+ type CtxLike = {
310
+ on(event: 'approval/request', handler: (req: ApprovalRequestLike) => Promise<ApprovalOutcome>): () => void
311
+ get(service: 'userQuestions'): {
312
+ registerProvider(provider: { ask(request: AskUserQuestionRequestLike): Promise<AskUserQuestionAnswerLike> }): () => void
313
+ } | undefined
314
+ }
315
+
316
+ export function registerInteractionChannels(ctx: CtxLike, store: InteractionStore): () => void {
317
+ const offApproval = ctx.on('approval/request', async req => {
318
+ return store.pushApproval(req.toolName, req.callId, req.reason, req.signal)
319
+ })
320
+ const unregisterProvider = ctx.get('userQuestions')?.registerProvider({
321
+ ask(request) {
322
+ return store.pushQuestion(request, request.signal)
323
+ },
324
+ })
325
+ return () => {
326
+ offApproval()
327
+ unregisterProvider?.()
328
+ }
329
+ }