@cmodjs/core 3.1.0 → 4.0.1

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