@cmodjs/core 3.0.0 → 4.0.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 (201) hide show
  1. package/docs/commands.md +39 -22
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +94 -66
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +7 -5
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +14 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +44 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/permission-check.js +12 -4
  85. package/runtime/programs.js +1 -1
  86. package/runtime/router.d.ts +2 -2
  87. package/runtime/router.d.ts.map +1 -1
  88. package/runtime/router.js +1 -1
  89. package/runtime/sessions.d.ts +10 -9
  90. package/runtime/sessions.d.ts.map +1 -1
  91. package/runtime/sessions.js +14 -5
  92. package/runtime/state.d.ts +2 -1
  93. package/runtime/state.d.ts.map +1 -1
  94. package/runtime/state.js +1 -0
  95. package/runtime/toaster.d.ts +1 -1
  96. package/runtime/toaster.d.ts.map +1 -1
  97. package/runtime/toaster.js +10 -4
  98. package/runtime/ui.d.ts +87 -5
  99. package/runtime/ui.d.ts.map +1 -1
  100. package/runtime/ui.js +372 -109
  101. package/runtime/watches.d.ts +9 -0
  102. package/runtime/watches.d.ts.map +1 -0
  103. package/runtime/watches.js +73 -0
  104. package/runtime/widgets.d.ts +48 -0
  105. package/runtime/widgets.d.ts.map +1 -0
  106. package/runtime/widgets.js +219 -0
  107. package/session/context/turn/message/message.d.ts.map +1 -1
  108. package/session/context/turn/message/message.js +7 -2
  109. package/session/session.d.ts +11 -5
  110. package/session/session.d.ts.map +1 -1
  111. package/src/hotkeys.ts +51 -0
  112. package/src/installer.ts +8 -4
  113. package/src/jobs/check.ts +4 -3
  114. package/src/jobs/context.ts +3 -2
  115. package/src/jobs/permissions.ts +4 -3
  116. package/src/jobs/program.ts +1 -1
  117. package/src/jobs/prompt.ts +23 -14
  118. package/src/jobs/schedule.ts +16 -2
  119. package/src/jobs/slash-command.ts +10 -7
  120. package/src/jobs/tool.ts +7 -5
  121. package/src/mod.ts +59 -47
  122. package/src/records.ts +50 -31
  123. package/src/register.ts +2 -3
  124. package/src/runtime/announce.ts +6 -7
  125. package/src/runtime/before.ts +4 -4
  126. package/src/runtime/claude.ts +0 -1
  127. package/src/runtime/conversations.ts +66 -20
  128. package/src/runtime/dependencies.ts +2 -1
  129. package/src/runtime/engine.ts +5 -0
  130. package/src/runtime/grants.ts +2 -2
  131. package/src/runtime/hooks.ts +1 -0
  132. package/src/runtime/hotkeys.ts +80 -0
  133. package/src/runtime/installer.ts +24 -12
  134. package/src/runtime/lifecycle.ts +189 -136
  135. package/src/runtime/options.ts +2 -2
  136. package/src/runtime/pages.ts +127 -0
  137. package/src/runtime/parts.ts +72 -0
  138. package/src/runtime/permission-check.ts +13 -5
  139. package/src/runtime/programs.ts +1 -1
  140. package/src/runtime/router.ts +3 -3
  141. package/src/runtime/sessions.ts +24 -15
  142. package/src/runtime/state.ts +3 -1
  143. package/src/runtime/toaster.ts +10 -4
  144. package/src/runtime/ui.ts +405 -115
  145. package/src/runtime/watches.ts +75 -0
  146. package/src/runtime/widgets.ts +287 -0
  147. package/src/session/context/turn/message/message.ts +7 -2
  148. package/src/session/session.ts +10 -6
  149. package/src/testing/fake-claude.ts +9 -6
  150. package/src/testing/fake-elements.ts +7 -4
  151. package/src/testing/fake-sessions.ts +6 -4
  152. package/src/testing.ts +203 -46
  153. package/src/ui/areas.ts +27 -0
  154. package/src/ui/elements.ts +35 -0
  155. package/src/ui/markdown.ts +18 -18
  156. package/src/ui/panes.ts +73 -0
  157. package/src/ui/styles.ts +53 -0
  158. package/src/utils/subscription.ts +1 -0
  159. package/testing/fake-claude.d.ts +0 -1
  160. package/testing/fake-claude.d.ts.map +1 -1
  161. package/testing/fake-claude.js +9 -5
  162. package/testing/fake-elements.js +7 -4
  163. package/testing/fake-sessions.d.ts +2 -1
  164. package/testing/fake-sessions.d.ts.map +1 -1
  165. package/testing/fake-sessions.js +1 -0
  166. package/testing.d.ts +21 -6
  167. package/testing.d.ts.map +1 -1
  168. package/testing.js +206 -45
  169. package/ui/areas.d.ts +29 -0
  170. package/ui/areas.d.ts.map +1 -0
  171. package/ui/areas.js +6 -0
  172. package/ui/elements.d.ts +3 -0
  173. package/ui/elements.d.ts.map +1 -1
  174. package/ui/elements.js +36 -0
  175. package/ui/markdown.d.ts +14 -14
  176. package/ui/markdown.d.ts.map +1 -1
  177. package/ui/markdown.js +11 -11
  178. package/ui/panes.d.ts +71 -0
  179. package/ui/panes.d.ts.map +1 -0
  180. package/ui/panes.js +16 -0
  181. package/ui/styles.d.ts +44 -0
  182. package/ui/styles.d.ts.map +1 -0
  183. package/ui/styles.js +24 -0
  184. package/utils/subscription.d.ts +4 -0
  185. package/utils/subscription.d.ts.map +1 -0
  186. package/jobs/status-line.d.ts +0 -6
  187. package/jobs/status-line.d.ts.map +0 -1
  188. package/jobs/status-line.js +0 -39
  189. package/src/jobs/status-line.ts +0 -44
  190. package/src/ui/define-pane.ts +0 -27
  191. package/src/ui/places.ts +0 -49
  192. package/src/utils/disposable.ts +0 -3
  193. package/ui/define-pane.d.ts +0 -15
  194. package/ui/define-pane.d.ts.map +0 -1
  195. package/ui/define-pane.js +0 -12
  196. package/ui/places.d.ts +0 -43
  197. package/ui/places.d.ts.map +0 -1
  198. package/ui/places.js +0 -31
  199. package/utils/disposable.d.ts +0 -4
  200. package/utils/disposable.d.ts.map +0 -1
  201. /package/utils/{disposable.js → subscription.js} +0 -0
package/src/runtime/ui.ts CHANGED
@@ -1,20 +1,60 @@
1
1
  import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement } from 'claude-code'
2
- import type { Disposable, Mod, PaneHandle } from '../mod.js'
3
- import type { Pane } from '../ui/define-pane.js'
4
- import { Box, drawWith, Text } from '../ui/elements.js'
5
- import type { MarkdownReader } from '../ui/markdown.js'
6
- import { componentOf, nameOf, type Place, type PlaceProps } from '../ui/places.js'
2
+ import type { Mod, Turn } from '../mod.js'
3
+ import { isObject } from '../records.js'
4
+ import { TurnEnd, type Area, type Widget, type WidgetContext, type WidgetInit } from '../ui/areas.js'
5
+ import { Box, drawWith, elementData, Text, type Handler } from '../ui/elements.js'
6
+ import type { Block, MarkdownReader } from '../ui/markdown.js'
7
+ import { checkPane, checkPart, sizeKeys, type OpenOptions, type Page, type PageInit, type Pane, type PaneInit, type ScrollOptions, type ScrollTarget } from '../ui/panes.js'
8
+ import { componentOf, nameOf, type Kind, type Style, type StyleInit } from '../ui/styles.js'
9
+ import type { Subscription } from '../utils/subscription.js'
7
10
  import { messageOf } from '../utils/text.js'
8
11
  import type { Claude } from './claude.js'
12
+ import { cmodPluginName } from './dependencies.js'
9
13
  import type { Router } from './router.js'
10
- import type { Failure, Toaster } from './toaster.js'
14
+ import type { Failure } from './toaster.js'
15
+
16
+ export const partMethods = {
17
+ list: 'cmod:parts',
18
+ draw: 'cmod:draw',
19
+ press: 'cmod:press',
20
+ shown: 'cmod:shown',
21
+ changed: 'cmod:partsChanged',
22
+ } as const
23
+
24
+ export type PagePlace = 'sidebar' | 'settings'
25
+
26
+ export type WidgetDeclaration = { readonly id: string; readonly title: string; readonly areas: readonly Area[] }
27
+
28
+ export type PageDeclaration = { readonly id: string; readonly title: string; readonly place: PagePlace }
29
+
30
+ export type PartList = { readonly widgets: readonly WidgetDeclaration[]; readonly pages: readonly PageDeclaration[] }
31
+
32
+ export type PartDraw =
33
+ | { readonly widget: string; readonly area: Area; readonly columns: number; readonly turn?: Turn }
34
+ | { readonly page: string; readonly params: unknown; readonly columns: number; readonly isFocused: boolean }
35
+
36
+ export type PartPress = { readonly draw: number; readonly handler: number; readonly args: readonly unknown[] }
37
+
38
+ export type DrawnPart = { readonly draw: number; readonly tree: unknown }
39
+
40
+ export type PageRequest =
41
+ | { readonly open: string; readonly params?: unknown; readonly focus?: true }
42
+ | { readonly close: string }
43
+ | { readonly scroll: string; readonly to: ScrollTarget; readonly block?: ScrollOptions['block'] }
44
+ | { readonly settings: true; readonly tab?: SettingsTab }
45
+
46
+ export type SettingsTab = 'changelog' | 'permissions'
47
+
48
+ export type PartsUpdate = { readonly parts?: PartList; readonly redraw?: true; readonly requests?: readonly PageRequest[]; readonly isAsked?: true }
49
+
50
+ export type ShownPages = { readonly open: readonly { readonly id: string; readonly params: unknown }[]; readonly shown: string | null; readonly isFocused: boolean }
51
+
11
52
 
12
53
  type PaneSize = Pick<PaneOpenArgs, 'columns' | 'rows'>
13
54
 
14
55
  const bullet = '⏺'
15
- const sizeKeys = ['columns', 'rows'] as const
16
56
 
17
- function paneSize<State extends object>(pane: Pane<State>, state: State): PaneSize {
57
+ function paneSize<State extends object>(pane: PaneInit<State, never>, state: State): PaneSize {
18
58
  const size: { columns?: number; rows?: number } = {}
19
59
  for (const key of sizeKeys) {
20
60
  const wanted = pane[key]
@@ -82,29 +122,49 @@ function drawPieces(pieces: readonly DrawnPiece[], place: 'pane' | 'opensReply'
82
122
  return column([opening, Box({ paddingLeft: 2, children: column(rest) })])
83
123
  }
84
124
 
125
+ function merged(updates: readonly PartsUpdate[]): PartsUpdate {
126
+ const parts = updates.findLast((update) => update.parts !== undefined)?.parts
127
+ const requests = updates.flatMap((update) => update.requests ?? [])
128
+ const isRedrawn = updates.some((update) => update.redraw === true)
129
+ return { ...(parts === undefined ? {} : { parts }), ...(isRedrawn ? { redraw: true } : {}), ...(requests.length === 0 ? {} : { requests }) }
130
+ }
131
+
85
132
  type MarkdownRender = {
86
133
  readonly Component: (props: object) => RenderElement
87
134
  readonly log: (failure: string) => void
88
135
  }
89
136
 
137
+ type AddedPane<State extends object> = { readonly init: PaneInit<State, unknown>; params: unknown }
138
+
139
+ type AddedPage<State extends object> = { readonly init: PageInit<State, unknown>; readonly place: PagePlace; params: unknown }
140
+
90
141
  export type UiOptions<State extends object> = {
91
142
  readonly name: string
92
143
  readonly claude: Claude
93
144
  readonly router: Router
94
- readonly toaster: Toaster
95
145
  readonly report: (failure: Failure) => void
96
146
  readonly announce: (feature: string) => void
97
147
  readonly mod: () => Mod<State>
98
148
  }
99
149
 
100
150
  export type UiArea<State extends object> = {
101
- readonly ui: Omit<Mod<State>['ui'], 'notice'>
151
+ readonly panes: Mod<State>['panes']
152
+ readonly sidebar: Mod<State>['sidebar']
153
+ readonly settings: Mod<State>['settings']
154
+ readonly widgets: Mod<State>['widgets']
155
+ readonly styles: Mod<State>['styles']
156
+ asked<Value>(run: () => Value | Promise<Value>): Promise<Value>
157
+ answerInput<Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>): Promise<Answer>
158
+ answer(method: string, input: unknown): Promise<{ readonly value: unknown }> | undefined
159
+ started(): void
102
160
  changed(): void
103
161
  restorePanes(): Promise<void>
104
162
  }
105
163
 
106
- export function createUi<State extends object>({ name, claude, router, toaster, report, announce, mod }: UiOptions<State>): UiArea<State> {
107
- const panes = new Map<string, Pane<State>>()
164
+ const keptDrawInputs = 200
165
+
166
+ export function createUi<State extends object>({ name, claude, router, report, announce, mod }: UiOptions<State>): UiArea<State> {
167
+ const panes = new Map<string, AddedPane<State>>()
108
168
  const paneLogs = new Map<string, (failure: string) => void>()
109
169
  const openPanes = new Set<string>()
110
170
  const paneStates = new Map<string, { readonly isShown: boolean; readonly isFocused: boolean }>()
@@ -117,16 +177,75 @@ export function createUi<State extends object>({ name, claude, router, toaster,
117
177
  }
118
178
  const readPanes = () => void syncPanes().catch((error: unknown) => claude.ui.log(`${name} keeps its panes' shown and focused state from before: ${messageOf(error)}`, { to: 'debug' }))
119
179
  const requestedSizes = new Map<string, PaneSize>()
120
- const renders = new Set<string>()
180
+ const styles = new Set<string>()
121
181
  const markdownRenders = new Map<string, MarkdownRender>()
122
182
  let read: MarkdownReader | undefined
123
183
  let isRouted = false
124
184
  let isResizeQueued = false
185
+ const widgets = new Map<string, WidgetInit<State>>()
186
+ const widgetAreas = new Map<string, Area>()
187
+ const pages = new Map<string, AddedPage<State>>()
188
+ let shownPages: ShownPages = { open: [], shown: null, isFocused: false }
189
+ const draws = new Map<number, { readonly id: string; readonly handlers: readonly Handler[] }>()
190
+ const latestDraws = new Map<string, Map<string, readonly number[]>>()
191
+ const failedDraws = new Set<string>()
192
+ let drawCount = 0
193
+ let beforeStart: PartsUpdate[] | undefined = []
194
+ let askedDepth = 0
195
+ let unheldRequests = 0
196
+ let collected: PartsUpdate[] | undefined
197
+ let queued: PartsUpdate[] | undefined
198
+
199
+ const partList = (): PartList => ({
200
+ widgets: [...widgets.values()].map(({ id, title, areas: listed }) => ({ id, title, areas: listed })),
201
+ pages: [...pages.values()].map(({ init: { id, title }, place }) => ({ id, title, place })),
202
+ })
203
+
204
+ const send = (update: PartsUpdate) => {
205
+ if (collected !== undefined) return void collected.push(update)
206
+ if (beforeStart !== undefined) return void beforeStart.push(update)
207
+ const isAsked = askedDepth > 0
208
+ if (queued !== undefined && !isAsked) return void queued.push(update)
209
+ const batch: PartsUpdate[] = [update]
210
+ if (!isAsked) queued = batch
211
+ const tell = () => {
212
+ if (queued === batch) queued = undefined
213
+ const input = { ...merged(batch), ...(isAsked ? { isAsked: true as const } : {}) }
214
+ return claude.cmod.call({ to: cmodPluginName, method: partMethods.changed, input }).then(
215
+ (answer) => {
216
+ if (isAsked && input.requests !== undefined && !(isObject(answer) && answer['isHeld'] === true)) unheldRequests += 1
217
+ },
218
+ (error: unknown) => claude.ui.log(`${name}: the cmod plugin did not hear about its pages and widgets: ${messageOf(error)}`, { to: 'debug' }),
219
+ )
220
+ }
221
+ return isAsked ? tell() : Promise.resolve().then(tell)
222
+ }
223
+
224
+ const asked = async <Value>(run: () => Value | Promise<Value>): Promise<Value> => {
225
+ askedDepth += 1
226
+ try {
227
+ return await run()
228
+ } finally {
229
+ askedDepth -= 1
230
+ }
231
+ }
232
+
233
+ const collect = async (run: () => unknown): Promise<PartsUpdate> => {
234
+ const outer = collected
235
+ const mine: PartsUpdate[] = []
236
+ collected = mine
237
+ try {
238
+ await run()
239
+ } finally {
240
+ collected = outer
241
+ }
242
+ return merged(mine)
243
+ }
125
244
 
126
245
  const logOnce = (what: string) => {
127
246
  let hasLogged = false
128
247
  return (failure: string) => {
129
- if (!hasLogged) report({ id: `render:${what}`, message: `The ${what} render failed`, description: failure })
248
+ if (!hasLogged) report({ id: `draw:${what}`, message: `The ${what} failed`, description: failure })
130
249
  hasLogged = true
131
250
  }
132
251
  }
@@ -182,13 +301,13 @@ export function createUi<State extends object>({ name, claude, router, toaster,
182
301
  })
183
302
  }
184
303
 
185
- const openAt = (pane: Pane<State>, size: PaneSize, focus?: true) => {
304
+ const openAt = (pane: PaneInit<State, never>, size: PaneSize, focus?: true) => {
186
305
  requestedSizes.set(pane.id, size)
187
306
  const { id, title, closeOnEscape, holdToasts } = pane
188
307
  return claude.ui.open({ id, title, ...size, ...(closeOnEscape === true ? { closeOnEscape } : {}), ...(holdToasts === true ? { holdToasts } : {}), ...(focus === true ? { focus } : {}) })
189
308
  }
190
309
 
191
- const resize = async (pane: Pane<State>) => {
310
+ const resize = async (pane: PaneInit<State, never>) => {
192
311
  try {
193
312
  const size = paneSize(pane, mod().state)
194
313
  const requested = requestedSizes.get(pane.id)
@@ -199,28 +318,37 @@ export function createUi<State extends object>({ name, claude, router, toaster,
199
318
  }
200
319
  }
201
320
 
202
- const route = () => {
321
+ const handle = async (pane: PaneInit<State, never>, handler: string, run: () => unknown) => {
322
+ try {
323
+ await run()
324
+ } catch (error) {
325
+ report({ id: `pane:${pane.id}:${handler}`, message: `The ${pane.title} pane's ${handler} threw`, error })
326
+ }
327
+ }
328
+
329
+ const routePanes = () => {
203
330
  if (isRouted) return
204
331
  isRouted = true
205
332
  router.add('ui.render', (e, next) => {
206
- const pane = panes.get(e.requestId)
207
- if (pane === undefined || e.component !== 'Pane') return next(e)
208
- openPanes.add(pane.id)
209
- paneStates.set(pane.id, { isShown: true, isFocused: e.props.isFocused })
333
+ const added = panes.get(e.requestId)
334
+ if (added === undefined || e.component !== 'Pane') return next(e)
335
+ const { init } = added
336
+ openPanes.add(init.id)
337
+ paneStates.set(init.id, { isShown: true, isFocused: e.props.isFocused })
210
338
  readPanes()
211
339
  const table = claude.ui.resolve(e)
212
340
  try {
213
- return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
341
+ return drawWith(table, () => init.render(mod(), { ...e.props, params: added.params }), markdownIn(table))
214
342
  } catch (error) {
215
- paneLogs.get(pane.id)?.(`threw: ${messageOf(error)}`)
216
- return drawWith(table, () => Text({ color: 'error', children: `The ${pane.title} pane could not draw: ${messageOf(error)}` }))
343
+ paneLogs.get(init.id)?.(`threw: ${messageOf(error)}`)
344
+ return drawWith(table, () => Text({ color: 'error', children: `The ${init.title} pane could not draw: ${messageOf(error)}` }))
217
345
  }
218
346
  })
219
347
  router.add('ui.scroll', async (e, next) => {
220
- const pane = panes.get(e.requestId)
348
+ const init = panes.get(e.requestId)?.init
221
349
  const moved = await next(e)
222
- if (pane?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
223
- await handle(pane, 'onScroll', () => pane.onScroll?.(mod(), e))
350
+ if (init?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
351
+ await handle(init, 'onScroll', () => init.onScroll?.(mod(), e))
224
352
  return moved
225
353
  })
226
354
  router.add('ui.close', async (e, next) => {
@@ -228,112 +356,274 @@ export function createUi<State extends object>({ name, claude, router, toaster,
228
356
  paneStates.delete(e.id)
229
357
  readPanes()
230
358
  const closed = await next(e)
231
- const pane = panes.get(e.id)
232
- if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
359
+ const init = panes.get(e.id)?.init
360
+ if (init?.onClose !== undefined) await handle(init, 'onClose', () => init.onClose?.(mod(), e))
233
361
  return closed
234
362
  })
235
363
  }
236
364
 
237
- const handle = async (pane: Pane<State>, handler: string, run: () => unknown) => {
365
+ const scrollIn = (id: string, target: ScrollTarget, options?: ScrollOptions) =>
366
+ claude.ui.scroll({ to: target, in: id, ...(options?.block === undefined ? {} : { block: options.block }) }).then(({ deny }) => {
367
+ if (deny !== undefined) throw new Error(`${name}: Claude Code did not scroll ${id}: ${deny}`)
368
+ })
369
+
370
+ const addPane = <Params>(init: PaneInit<State, Params>): Pane<Params> => {
371
+ checkPane(name, init)
372
+ if (panes.has(init.id)) throw new Error(`${name}: the pane "${init.id}" is already added. Give each pane its own id.`)
373
+ const added: AddedPane<State> = { init, params: undefined }
374
+ panes.set(init.id, added)
375
+ paneLogs.set(init.id, logOnce(`${init.title} pane`))
376
+ announce(`the ${init.title} pane`)
377
+ routePanes()
378
+ const sized = init as unknown as PaneInit<State, never>
379
+ const pane: Pane<Params> = {
380
+ id: init.id,
381
+ get isOpen() {
382
+ return openPanes.has(init.id)
383
+ },
384
+ get isShown() {
385
+ return paneStates.get(init.id)?.isShown ?? false
386
+ },
387
+ get isFocused() {
388
+ return paneStates.get(init.id)?.isFocused ?? false
389
+ },
390
+ get params() {
391
+ return added.params as Params | undefined
392
+ },
393
+ async open(...[options]: [OpenOptions<Params>?]) {
394
+ if (options !== undefined && 'params' in options) added.params = options.params
395
+ const { isPlaced } = await openAt(sized, paneSize(sized, mod().state), options?.focus)
396
+ if (isPlaced) openPanes.add(init.id)
397
+ await syncPanes()
398
+ claude.ui.invalidate('ui.render')
399
+ },
400
+ async close() {
401
+ await claude.ui.close({ id: init.id })
402
+ openPanes.delete(init.id)
403
+ paneStates.delete(init.id)
404
+ await syncPanes()
405
+ },
406
+ toggle: (options) => (openPanes.has(init.id) ? pane.close() : (pane.open as (options?: OpenOptions<Params>) => Promise<void>)(options as OpenOptions<Params>)),
407
+ scrollTo: (target, options) => scrollIn(init.id, target, options),
408
+ remove() {
409
+ if (panes.get(init.id) !== added) return
410
+ panes.delete(init.id)
411
+ paneLogs.delete(init.id)
412
+ if (openPanes.has(init.id)) void claude.ui.close({ id: init.id }).catch((error: unknown) => claude.ui.log(`${name}: the ${init.title} pane stayed open: ${messageOf(error)}`, { to: 'debug' }))
413
+ openPanes.delete(init.id)
414
+ paneStates.delete(init.id)
415
+ },
416
+ }
417
+ return pane
418
+ }
419
+
420
+ const pageEntry = (id: string) => shownPages.open.find((each) => each.id === id)
421
+
422
+ const addPage = <Params>(place: PagePlace, init: PageInit<State, Params>): Page<Params> => {
423
+ checkPart(name, 'page', init.id, init.title)
424
+ if (pages.has(init.id)) throw new Error(`${name}: the page "${init.id}" is already added. Give each page its own id.`)
425
+ const added: AddedPage<State> = { init, place, params: undefined }
426
+ pages.set(init.id, added)
427
+ announce(place === 'settings' ? `the ${init.title} page in Settings` : `the ${init.title} page in the Sidebar`)
428
+ void send({ parts: partList() })
429
+ const page: Page<Params> = {
430
+ id: init.id,
431
+ get isOpen() {
432
+ return place === 'settings' ? shownPages.shown === init.id : pageEntry(init.id) !== undefined
433
+ },
434
+ get isShown() {
435
+ return shownPages.shown === init.id
436
+ },
437
+ get isFocused() {
438
+ return shownPages.shown === init.id && shownPages.isFocused
439
+ },
440
+ get params() {
441
+ return (pageEntry(init.id)?.params ?? added.params) as Params | undefined
442
+ },
443
+ async open(...[options]: [OpenOptions<Params>?]) {
444
+ if (options !== undefined && 'params' in options) added.params = options.params
445
+ await send({ requests: [{ open: init.id, ...(added.params === undefined ? {} : { params: added.params }), ...(options?.focus === true ? { focus: true } : {}) }] })
446
+ },
447
+ toggle: (options) => (page.isOpen ? page.close() : (page.open as (options?: OpenOptions<Params>) => Promise<void>)(options as OpenOptions<Params>)),
448
+ close: async () => {
449
+ await send({ requests: [{ close: init.id }] })
450
+ },
451
+ scrollTo: async (target, options) => {
452
+ await send({ requests: [{ scroll: init.id, to: target, ...(options?.block === undefined ? {} : { block: options.block }) }] })
453
+ },
454
+ remove() {
455
+ if (pages.get(init.id) !== added) return
456
+ pages.delete(init.id)
457
+ forgetDraws(`page:${init.id}`)
458
+ void send({ parts: partList(), requests: [{ close: init.id }] })
459
+ },
460
+ }
461
+ return page
462
+ }
463
+
464
+ const addWidget = (init: WidgetInit<State>): Widget => {
465
+ checkPart(name, 'widget', init.id, init.title)
466
+ if (widgets.has(init.id)) throw new Error(`${name}: the widget "${init.id}" is already added. Give each widget its own id.`)
467
+ widgets.set(init.id, init)
468
+ announce(`the ${init.title} widget`)
469
+ void send({ parts: partList() })
470
+ return {
471
+ id: init.id,
472
+ get area() {
473
+ return widgetAreas.get(init.id)
474
+ },
475
+ remove() {
476
+ if (widgets.get(init.id) !== init) return
477
+ widgets.delete(init.id)
478
+ widgetAreas.delete(init.id)
479
+ forgetDraws(`widget:${init.id}`)
480
+ void send({ parts: partList() })
481
+ },
482
+ }
483
+ }
484
+
485
+ const addStyle = <K extends Kind>({ kind, render }: StyleInit<K, State>): Style => {
486
+ const markdown: Block | undefined = typeof kind === 'string' ? undefined : kind
487
+ const component = componentOf(kind)
488
+ if (markdown === undefined && component === undefined) throw new Error(`${name}: ${String(kind)} is not a kind styles.add draws, such as ToolCall or CodeBlock.`)
489
+ const feature = `the style of ${nameOf(kind)}`
490
+ if (styles.has(feature)) throw new Error(`${name}: ${feature} is already added. Add one style per kind.`)
491
+ styles.add(feature)
492
+ announce(feature)
493
+ const Component = (props: object) => render(mod(), props as never)
494
+ const stop = (subscription?: Subscription) => () => {
495
+ subscription?.remove()
496
+ if (markdown !== undefined) markdownRenders.delete(markdown.markdown)
497
+ styles.delete(feature)
498
+ claude.ui.invalidate('ui.render')
499
+ }
500
+ if (markdown !== undefined) {
501
+ markdownRenders.set(markdown.markdown, { Component, log: logOnce(`${nameOf(kind)} style`) })
502
+ routeMarkdown(markdown.createReader)
503
+ return { kind, remove: stop() }
504
+ }
505
+ const log = logOnce(`${nameOf(kind)} style`)
506
+ const drawing = router.add('ui.render', async (e, next) => {
507
+ if (e.component !== component) return next(e)
508
+ const table = claude.ui.resolve(e)
509
+ const drawn = await drawComponent(
510
+ table,
511
+ (Default) => Component({ ...e.props, Default }),
512
+ (given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
513
+ )
514
+ if ('failure' in drawn) {
515
+ log(drawn.failure)
516
+ return next(e)
517
+ }
518
+ if (e.component !== 'AssistantMessage') return drawn.drawing
519
+ return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
520
+ })
521
+ return { kind, remove: stop(drawing) }
522
+ }
523
+
524
+ const forgetDraws = (key: string) => {
525
+ for (const kept of latestDraws.get(key)?.values() ?? []) for (const draw of kept) draws.delete(draw)
526
+ latestDraws.delete(key)
527
+ }
528
+
529
+ const drawPart = (key: string, title: string, input: PartDraw, render: () => RenderElement | undefined): DrawnPart | null => {
238
530
  try {
239
- await run()
531
+ const pressed: Handler[] = []
532
+ const drawn = render()
533
+ const tree = drawn === undefined ? null : elementData(drawn, pressed)
534
+ failedDraws.delete(key)
535
+ drawCount += 1
536
+ const inputs = latestDraws.get(key) ?? new Map<string, readonly number[]>()
537
+ latestDraws.set(key, inputs)
538
+ const drawnFor = JSON.stringify('turn' in input ? { ...input, turn: input.turn.prompt.id } : input)
539
+ const earlier = inputs.get(drawnFor) ?? []
540
+ const kept = [...earlier, drawCount].slice(-2)
541
+ for (const draw of earlier) if (!kept.includes(draw)) draws.delete(draw)
542
+ inputs.delete(drawnFor)
543
+ inputs.set(drawnFor, kept)
544
+ const [oldest] = inputs
545
+ if (inputs.size > keptDrawInputs && oldest !== undefined) {
546
+ for (const draw of oldest[1]) draws.delete(draw)
547
+ inputs.delete(oldest[0])
548
+ }
549
+ draws.set(drawCount, { id: key, handlers: pressed })
550
+ return { draw: drawCount, tree }
240
551
  } catch (error) {
241
- report({ id: `pane:${pane.id}:${handler}`, message: `The ${pane.title} pane's ${handler} threw`, error })
552
+ if (!failedDraws.has(key)) report({ id: key, message: `The ${title} could not draw`, error })
553
+ failedDraws.add(key)
554
+ return null
242
555
  }
243
556
  }
244
557
 
558
+ const draw = (input: PartDraw): DrawnPart | null => {
559
+ if ('page' in input) {
560
+ const added = pages.get(input.page)
561
+ if (added === undefined) return null
562
+ return drawPart(`page:${input.page}`, `${added.init.title} page`, input, () => added.init.render(mod(), { params: input.params, isFocused: input.isFocused, columns: input.columns }))
563
+ }
564
+ const widget = widgets.get(input.widget)
565
+ if (widget === undefined || !widget.areas.includes(input.area)) return null
566
+ widgetAreas.set(widget.id, input.area)
567
+ const context = (input.area === TurnEnd && input.turn !== undefined ? { area: input.area, columns: input.columns, turn: input.turn } : { area: input.area, columns: input.columns }) as WidgetContext
568
+ return drawPart(`widget:${widget.id}`, `${widget.title} widget`, input, () => widget.render(mod(), context))
569
+ }
570
+
571
+ const press = async ({ draw: drawn, handler, args }: PartPress): Promise<PartsUpdate> => {
572
+ const known = draws.get(drawn)
573
+ const run = known?.handlers[handler]
574
+ if (known === undefined || run === undefined) throw new Error(`${name}: that button drew before the last redraw. Press the one it shows now.`)
575
+ return collect(async () => {
576
+ try {
577
+ await run(...args)
578
+ } catch (error) {
579
+ report({ id: known.id, message: 'Its button failed', error })
580
+ }
581
+ })
582
+ }
583
+
245
584
  return {
246
- ui: {
247
- pane(pane) {
248
- if (panes.has(pane.id)) throw new Error(`${name}: the pane "${pane.id}" is already added. Give each pane its own id.`)
249
- panes.set(pane.id, pane)
250
- paneLogs.set(pane.id, logOnce(`${pane.title} pane`))
251
- announce(`the ${pane.title} pane`)
252
- route()
253
- const handle: PaneHandle = {
254
- get isOpen() {
255
- return openPanes.has(pane.id)
256
- },
257
- get isShown() {
258
- return paneStates.get(pane.id)?.isShown ?? false
259
- },
260
- get isFocused() {
261
- return paneStates.get(pane.id)?.isFocused ?? false
262
- },
263
- async open(options) {
264
- const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
265
- if (isPlaced) openPanes.add(pane.id)
266
- await syncPanes()
267
- },
268
- async close() {
269
- await claude.ui.close({ id: pane.id })
270
- openPanes.delete(pane.id)
271
- paneStates.delete(pane.id)
272
- await syncPanes()
273
- },
274
- toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
275
- }
276
- return handle
277
- },
278
- render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable {
279
- const target: Place = place
280
- const markdown = typeof target === 'string' ? undefined : target
281
- const component = componentOf(place)
282
- if (markdown === undefined && component === undefined) throw new Error(`${name}: ${String(place)} is not a place render draws, such as ToolCall or CodeBlock.`)
283
- const feature = `a render of ${nameOf(place)}`
284
- if (renders.has(feature)) throw new Error(`${name}: ${feature} is already added. Render each place once.`)
285
- renders.add(feature)
286
- announce(feature)
287
- if (markdown !== undefined) {
288
- const markdownType = markdown.markdown
289
- markdownRenders.set(markdownType, { Component: Component as unknown as MarkdownRender['Component'], log: logOnce(nameOf(place)) })
290
- routeMarkdown(markdown.createReader)
291
- return {
292
- dispose() {
293
- markdownRenders.delete(markdownType)
294
- renders.delete(feature)
295
- claude.ui.invalidate('ui.render')
296
- },
297
- }
298
- }
299
- const log = logOnce(nameOf(place))
300
- const drawing = router.add('ui.render', async (e, next) => {
301
- if (e.component !== component) return next(e)
302
- const table = claude.ui.resolve(e)
303
- const drawn = await drawComponent(
304
- table,
305
- (Default) => Component({ ...e.props, Default } as unknown as PlaceProps<P>),
306
- (given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
307
- )
308
- if ('failure' in drawn) {
309
- log(drawn.failure)
310
- return next(e)
311
- }
312
- if (e.component !== 'AssistantMessage') return drawn.drawing
313
- return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
314
- })
315
- return {
316
- dispose() {
317
- drawing.dispose()
318
- renders.delete(feature)
319
- claude.ui.invalidate('ui.render')
320
- },
321
- }
585
+ panes: { add: addPane },
586
+ sidebar: { add: (init) => addPage('sidebar', init) },
587
+ settings: {
588
+ add: (init) => addPage('settings', init),
589
+ open: async () => {
590
+ await send({ requests: [{ settings: true }] })
322
591
  },
323
- toast: toaster.toast,
324
- copy: toaster.copy,
325
- ask: (question, options) => claude.ui.ask(question, options),
326
- scroll: (args) => claude.ui.scroll(args),
592
+ },
593
+ widgets: { add: addWidget },
594
+ styles: { add: addStyle },
595
+ asked,
596
+ async answerInput(run, passOn) {
597
+ const before = unheldRequests
598
+ const answer = await asked(run)
599
+ if (unheldRequests > before) await passOn()
600
+ return answer
601
+ },
602
+ answer(method, input) {
603
+ if (method === partMethods.list) return Promise.resolve({ value: partList() })
604
+ if (method === partMethods.draw) return Promise.resolve({ value: draw(input as PartDraw) })
605
+ if (method === partMethods.press) return press(input as PartPress).then((value) => ({ value }))
606
+ if (method === partMethods.shown) {
607
+ shownPages = input as ShownPages
608
+ return Promise.resolve({ value: null })
609
+ }
610
+ return undefined
611
+ },
612
+ started() {
613
+ const held = merged(beforeStart ?? [])
614
+ beforeStart = undefined
615
+ void send({ ...held, parts: partList() })
327
616
  },
328
617
  changed() {
329
- if (panes.size === 0 && renders.size === 0) return
618
+ if (widgets.size > 0 || pages.size > 0) void send({ redraw: true })
619
+ if (panes.size === 0 && styles.size === 0) return
330
620
  claude.ui.invalidate('ui.render')
331
621
  if (isResizeQueued) return
332
622
  isResizeQueued = true
333
623
  void Promise.resolve().then(() => {
334
624
  isResizeQueued = false
335
- for (const pane of panes.values()) {
336
- if (openPanes.has(pane.id)) void resize(pane)
625
+ for (const { init } of panes.values()) {
626
+ if (openPanes.has(init.id)) void resize(init)
337
627
  }
338
628
  })
339
629
  },