@cmodjs/core 4.0.2 → 4.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 (141) hide show
  1. package/docs/commands.md +43 -8
  2. package/docs/index.md +3 -3
  3. package/docs/install-steps.md +6 -6
  4. package/docs/jobs.md +2 -1
  5. package/docs/mod.md +1 -1
  6. package/docs/options.md +1 -1
  7. package/docs/permissions.md +1 -1
  8. package/docs/session.md +44 -13
  9. package/docs/testing.md +14 -10
  10. package/docs/ui.md +43 -11
  11. package/installer.d.ts +3 -2
  12. package/installer.d.ts.map +1 -1
  13. package/jobs/program.d.ts +1 -0
  14. package/jobs/program.d.ts.map +1 -1
  15. package/jobs/program.js +39 -23
  16. package/mod.d.ts +3 -2
  17. package/mod.d.ts.map +1 -1
  18. package/mod.js +1 -0
  19. package/package.json +1 -1
  20. package/records.d.ts +15 -1
  21. package/records.d.ts.map +1 -1
  22. package/records.js +39 -1
  23. package/register.d.ts.map +1 -1
  24. package/register.js +5 -2
  25. package/runtime/before.d.ts +6 -2
  26. package/runtime/before.d.ts.map +1 -1
  27. package/runtime/before.js +56 -18
  28. package/runtime/buttons.d.ts +10 -0
  29. package/runtime/buttons.d.ts.map +1 -0
  30. package/runtime/buttons.js +12 -0
  31. package/runtime/conversations.d.ts.map +1 -1
  32. package/runtime/conversations.js +134 -25
  33. package/runtime/engine.d.ts +2 -0
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts +1 -0
  36. package/runtime/grants.d.ts.map +1 -1
  37. package/runtime/grants.js +5 -2
  38. package/runtime/hotkeys.d.ts.map +1 -1
  39. package/runtime/hotkeys.js +7 -4
  40. package/runtime/installer.d.ts +9 -6
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +104 -47
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +121 -40
  45. package/runtime/pages.d.ts +3 -1
  46. package/runtime/pages.d.ts.map +1 -1
  47. package/runtime/pages.js +34 -10
  48. package/runtime/parts.d.ts +1 -0
  49. package/runtime/parts.d.ts.map +1 -1
  50. package/runtime/parts.js +6 -3
  51. package/runtime/programs.d.ts +1 -0
  52. package/runtime/programs.d.ts.map +1 -1
  53. package/runtime/programs.js +4 -1
  54. package/runtime/sessions.d.ts +2 -2
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +88 -58
  57. package/runtime/toaster.d.ts.map +1 -1
  58. package/runtime/toaster.js +23 -9
  59. package/runtime/ui.d.ts +21 -4
  60. package/runtime/ui.d.ts.map +1 -1
  61. package/runtime/ui.js +158 -58
  62. package/runtime/watches.d.ts.map +1 -1
  63. package/runtime/watches.js +18 -4
  64. package/runtime/widgets.d.ts +7 -5
  65. package/runtime/widgets.d.ts.map +1 -1
  66. package/runtime/widgets.js +29 -20
  67. package/session/context/turn/message/attached-file.d.ts +13 -0
  68. package/session/context/turn/message/attached-file.d.ts.map +1 -0
  69. package/session/context/turn/message/attached-file.js +21 -0
  70. package/session/context/turn/message/message.d.ts +3 -2
  71. package/session/context/turn/message/message.d.ts.map +1 -1
  72. package/session/context/turn/message/message.js +20 -6
  73. package/session/conversation.d.ts +14 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +23 -4
  76. package/session/goal/task.d.ts +5 -0
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +35 -7
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +2 -1
  81. package/src/installer.ts +3 -2
  82. package/src/jobs/program.ts +41 -21
  83. package/src/mod.ts +3 -2
  84. package/src/records.ts +49 -1
  85. package/src/register.ts +9 -6
  86. package/src/runtime/before.ts +61 -22
  87. package/src/runtime/buttons.ts +22 -0
  88. package/src/runtime/conversations.ts +138 -32
  89. package/src/runtime/engine.ts +2 -0
  90. package/src/runtime/grants.ts +6 -2
  91. package/src/runtime/hotkeys.ts +7 -4
  92. package/src/runtime/installer.ts +144 -82
  93. package/src/runtime/lifecycle.ts +128 -43
  94. package/src/runtime/pages.ts +45 -8
  95. package/src/runtime/parts.ts +8 -4
  96. package/src/runtime/programs.ts +5 -1
  97. package/src/runtime/sessions.ts +83 -57
  98. package/src/runtime/toaster.ts +27 -9
  99. package/src/runtime/ui.ts +163 -61
  100. package/src/runtime/watches.ts +20 -5
  101. package/src/runtime/widgets.ts +34 -22
  102. package/src/session/context/turn/message/attached-file.ts +36 -0
  103. package/src/session/context/turn/message/message.ts +20 -7
  104. package/src/session/conversation.ts +30 -6
  105. package/src/session/goal/task.ts +6 -0
  106. package/src/session/session.ts +17 -7
  107. package/src/testing/fake-claude.ts +21 -6
  108. package/src/testing/fake-elements.ts +51 -8
  109. package/src/testing/fake-sessions.ts +60 -23
  110. package/src/testing.ts +86 -28
  111. package/src/ui/areas.ts +1 -0
  112. package/src/ui/components.ts +14 -8
  113. package/src/ui/elements.ts +6 -2
  114. package/src/ui/markdown.ts +3 -3
  115. package/src/ui/panes.ts +2 -2
  116. package/src/ui/styles.ts +6 -2
  117. package/testing/fake-claude.d.ts +3 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +18 -5
  120. package/testing/fake-elements.d.ts.map +1 -1
  121. package/testing/fake-elements.js +46 -7
  122. package/testing/fake-sessions.d.ts +21 -10
  123. package/testing/fake-sessions.d.ts.map +1 -1
  124. package/testing/fake-sessions.js +58 -20
  125. package/testing.d.ts +8 -12
  126. package/testing.d.ts.map +1 -1
  127. package/testing.js +82 -21
  128. package/ui/areas.d.ts +4 -0
  129. package/ui/areas.d.ts.map +1 -1
  130. package/ui/components.d.ts +1 -0
  131. package/ui/components.d.ts.map +1 -1
  132. package/ui/components.js +11 -7
  133. package/ui/elements.d.ts +3 -0
  134. package/ui/elements.d.ts.map +1 -1
  135. package/ui/elements.js +6 -1
  136. package/ui/markdown.d.ts +2 -2
  137. package/ui/markdown.d.ts.map +1 -1
  138. package/ui/panes.d.ts +2 -1
  139. package/ui/panes.d.ts.map +1 -1
  140. package/ui/styles.d.ts +11 -2
  141. package/ui/styles.d.ts.map +1 -1
package/src/runtime/ui.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement } from 'claude-code'
1
+ import type { Args, ElementTable, Frozen, MarkdownProps, PaneOpenArgs, RenderElement } from 'claude-code'
2
2
  import type { Mod, Turn } from '../mod.js'
3
3
  import { isObject } from '../records.js'
4
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'
5
+ import { Box, drawWith, elementData, Markdown, Text, type Handler } from '../ui/elements.js'
6
+ import type { Block, MarkdownPiece, MarkdownReader } from '../ui/markdown.js'
7
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'
8
+ import { componentOf, nameOf, type Kind, type Style, type StyledAt, type StyleInit } from '../ui/styles.js'
9
9
  import type { Subscription } from '../utils/subscription.js'
10
10
  import { messageOf } from '../utils/text.js'
11
11
  import type { Claude } from './claude.js'
@@ -19,19 +19,33 @@ export const partMethods = {
19
19
  press: 'cmod:press',
20
20
  shown: 'cmod:shown',
21
21
  changed: 'cmod:partsChanged',
22
+ stylers: 'cmod:markdownStylers',
22
23
  } as const
23
24
 
24
25
  export type PagePlace = 'sidebar' | 'settings'
25
26
 
26
- export type WidgetDeclaration = { readonly id: string; readonly title: string; readonly areas: readonly Area[] }
27
+ export type WidgetDeclaration = { readonly id: string; readonly title: string; readonly areas: readonly Area[]; readonly place?: NonNullable<WidgetInit['place']> }
27
28
 
28
29
  export type PageDeclaration = { readonly id: string; readonly title: string; readonly place: PagePlace }
29
30
 
30
- export type PartList = { readonly widgets: readonly WidgetDeclaration[]; readonly pages: readonly PageDeclaration[] }
31
+ export type PartList = { readonly widgets: readonly WidgetDeclaration[]; readonly pages: readonly PageDeclaration[]; readonly stylesMarkdown?: true }
31
32
 
32
33
  export type PartDraw =
33
34
  | { readonly widget: string; readonly area: Area; readonly columns: number; readonly turn?: Turn }
34
35
  | { readonly page: string; readonly params: unknown; readonly columns: number; readonly isFocused: boolean }
36
+ | ({ readonly markdown: string } & StyledAt)
37
+
38
+ export type BorrowedMarkdown = (mod: string, cached: string, input: { readonly markdown: string } & StyledAt) => RenderElement | undefined
39
+
40
+ const unmeasuredColumns = 80
41
+
42
+ export function viewportColumns(e: Frozen<Args<'ui.render'>>): number {
43
+ return e.viewport?.columns ?? unmeasuredColumns
44
+ }
45
+
46
+ export function styledAt(e: Frozen<Args<'ui.render'>>, columns = viewportColumns(e)): StyledAt {
47
+ return { surface: e.surface, columns }
48
+ }
35
49
 
36
50
  export type PartPress = { readonly draw: number; readonly handler: number; readonly args: readonly unknown[] }
37
51
 
@@ -81,6 +95,8 @@ function threw(error: unknown): string {
81
95
 
82
96
  type DrawnPiece = { readonly drawing: RenderElement; readonly isClaudesRow: boolean }
83
97
 
98
+ type SentDrawing = { readonly draws: readonly number[]; readonly tree: string; readonly render: () => RenderElement | undefined }
99
+
84
100
  async function drawComponent<Given extends object>(
85
101
  table: ElementTable,
86
102
  draw: (Default: (props: Given & { readonly children?: unknown }) => RenderElement) => RenderElement,
@@ -122,7 +138,7 @@ function drawPieces(pieces: readonly DrawnPiece[], place: 'pane' | 'opensReply'
122
138
  return column([opening, Box({ paddingLeft: 2, children: column(rest) })])
123
139
  }
124
140
 
125
- function merged(updates: readonly PartsUpdate[]): PartsUpdate {
141
+ export function merged(updates: readonly PartsUpdate[]): PartsUpdate {
126
142
  const parts = updates.findLast((update) => update.parts !== undefined)?.parts
127
143
  const requests = updates.flatMap((update) => update.requests ?? [])
128
144
  const isRedrawn = updates.some((update) => update.redraw === true)
@@ -145,6 +161,8 @@ export type UiOptions<State extends object> = {
145
161
  readonly report: (failure: Failure) => void
146
162
  readonly announce: (feature: string) => void
147
163
  readonly mod: () => Mod<State>
164
+ readonly borrowed: BorrowedMarkdown
165
+ readonly nextDraw: () => number
148
166
  }
149
167
 
150
168
  export type UiArea<State extends object> = {
@@ -155,7 +173,9 @@ export type UiArea<State extends object> = {
155
173
  readonly styles: Mod<State>['styles']
156
174
  asked<Value>(run: () => Value | Promise<Value>): Promise<Value>
157
175
  answerInput<Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>): Promise<Answer>
158
- answer(method: string, input: unknown): Promise<{ readonly value: unknown }> | undefined
176
+ answer(method: string, input: unknown, caller: string): Promise<{ readonly value: unknown }> | undefined
177
+ markdownIn(table: ElementTable, at: StyledAt): ((props: MarkdownProps) => RenderElement) | undefined
178
+ restyled(stylers: readonly string[]): void
159
179
  started(): void
160
180
  changed(): void
161
181
  restorePanes(): Promise<void>
@@ -163,23 +183,28 @@ export type UiArea<State extends object> = {
163
183
 
164
184
  const keptDrawInputs = 200
165
185
 
166
- export function createUi<State extends object>({ name, claude, router, report, announce, mod }: UiOptions<State>): UiArea<State> {
186
+ export function createUi<State extends object>({ name, claude, router, report, announce, mod, borrowed, nextDraw }: UiOptions<State>): UiArea<State> {
167
187
  const panes = new Map<string, AddedPane<State>>()
168
188
  const paneLogs = new Map<string, (failure: string) => void>()
169
189
  const openPanes = new Set<string>()
170
190
  const paneStates = new Map<string, { readonly isShown: boolean; readonly isFocused: boolean }>()
171
191
  const syncPanes = async () => {
172
- for (const open of await claude.ui.panes()) {
173
- if (!panes.has(open.id)) continue
174
- if (open.isPlaced) openPanes.add(open.id)
175
- paneStates.set(open.id, { isShown: open.isShown, isFocused: open.isFocused })
192
+ const listed = new Map((await claude.ui.panes()).map((open) => [open.id, open]))
193
+ for (const id of panes.keys()) {
194
+ const open = listed.get(id)
195
+ if (open?.isPlaced === true) openPanes.add(id)
196
+ else openPanes.delete(id)
197
+ if (open === undefined) paneStates.delete(id)
198
+ else paneStates.set(id, { isShown: open.isShown, isFocused: open.isFocused })
176
199
  }
177
200
  }
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' }))
201
+ const readPanes = () => syncPanes().catch((error: unknown) => claude.ui.log(`${name} keeps its panes' open, shown, and focused state from before: ${messageOf(error)}`, { to: 'debug' }))
179
202
  const requestedSizes = new Map<string, PaneSize>()
180
203
  const styles = new Set<string>()
181
204
  const markdownRenders = new Map<string, MarkdownRender>()
182
205
  let read: MarkdownReader | undefined
206
+ let stylers: readonly string[] = []
207
+ let stylersSeen = 0
183
208
  let isRouted = false
184
209
  let isResizeQueued = false
185
210
  const widgets = new Map<string, WidgetInit<State>>()
@@ -187,21 +212,56 @@ export function createUi<State extends object>({ name, claude, router, report, a
187
212
  const pages = new Map<string, AddedPage<State>>()
188
213
  let shownPages: ShownPages = { open: [], shown: null, isFocused: false }
189
214
  const draws = new Map<number, { readonly id: string; readonly handlers: readonly Handler[] }>()
190
- const latestDraws = new Map<string, Map<string, readonly number[]>>()
215
+ const latestDraws = new Map<string, Map<string, SentDrawing>>()
191
216
  const failedDraws = new Set<string>()
192
- let drawCount = 0
193
217
  let beforeStart: PartsUpdate[] | undefined = []
194
218
  let askedDepth = 0
219
+ const askedCalls = new Set<Promise<void>>()
195
220
  let unheldRequests = 0
196
221
  let collected: PartsUpdate[] | undefined
197
222
  let collecting = 0
198
223
  let queued: PartsUpdate[] | undefined
199
224
 
200
225
  const partList = (): PartList => ({
201
- widgets: [...widgets.values()].map(({ id, title, areas: listed }) => ({ id, title, areas: listed })),
226
+ widgets: [...widgets.values()].map(({ id, title, areas: listed, place }) => ({ id, title, areas: listed, ...(place === undefined ? {} : { place }) })),
202
227
  pages: [...pages.values()].map(({ init: { id, title }, place }) => ({ id, title, place })),
228
+ ...(markdownRenders.size > 0 ? { stylesMarkdown: true } : {}),
203
229
  })
204
230
 
231
+ const hear = (listed: unknown, isRestyled: boolean) => {
232
+ if (!Array.isArray(listed)) return
233
+ const heard = listed.filter((styler): styler is string => typeof styler === 'string' && styler !== name)
234
+ if (!isRestyled && heard.join('\0') === stylers.join('\0')) return
235
+ stylers = heard
236
+ stylersSeen += 1
237
+ claude.ui.invalidate('ui.render')
238
+ }
239
+
240
+ const treeOf = (render: () => RenderElement | undefined, handlers: Handler[]) => {
241
+ const drawn = render()
242
+ return drawn === undefined ? null : elementData(drawn, handlers)
243
+ }
244
+
245
+ const isAnyDrawingChanged = () => {
246
+ for (const [key, inputs] of latestDraws) {
247
+ if (key === 'markdown') continue
248
+ for (const sent of inputs.values()) {
249
+ const handlers: Handler[] = []
250
+ try {
251
+ if (JSON.stringify(treeOf(sent.render, handlers)) !== sent.tree) return true
252
+ } catch (error) {
253
+ claude.ui.log(`${name}: the ${key} drawing threw, so the cmod plugin asks for it again: ${messageOf(error)}`, { to: 'debug' })
254
+ return true
255
+ }
256
+ const latest = sent.draws.at(-1)
257
+ if (latest !== undefined && draws.has(latest)) draws.set(latest, { id: key, handlers })
258
+ }
259
+ }
260
+ return false
261
+ }
262
+
263
+ const withNeededRedraw = ({ redraw, ...update }: PartsUpdate): PartsUpdate => (redraw === true && isAnyDrawingChanged() ? { ...update, redraw } : update)
264
+
205
265
  const send = (update: PartsUpdate) => {
206
266
  if (collected !== undefined) return void collected.push(update)
207
267
  if (beforeStart !== undefined) return void beforeStart.push(update)
@@ -209,23 +269,31 @@ export function createUi<State extends object>({ name, claude, router, report, a
209
269
  if (queued !== undefined && !isAsked) return void queued.push(update)
210
270
  const batch: PartsUpdate[] = [update]
211
271
  if (!isAsked) queued = batch
212
- const tell = () => {
272
+ const tell = async () => {
213
273
  if (queued === batch) queued = undefined
214
- const input = { ...merged(batch), ...(isAsked ? { isAsked: true as const } : {}) }
274
+ const needed = withNeededRedraw(merged(batch))
275
+ if (Object.keys(needed).length === 0) return
276
+ const input = { ...needed, ...(isAsked ? { isAsked: true as const } : {}) }
215
277
  return claude.cmod.call({ to: cmodPluginName, method: partMethods.changed, input }).then(
216
278
  (answer) => {
217
279
  if (isAsked && input.requests !== undefined && !(isObject(answer) && answer['isHeld'] === true)) unheldRequests += 1
280
+ if (isObject(answer) && name !== cmodPluginName) hear(answer['stylers'], false)
218
281
  },
219
282
  (error: unknown) => claude.ui.log(`${name}: the cmod plugin did not hear about its pages and widgets: ${messageOf(error)}`, { to: 'debug' }),
220
283
  )
221
284
  }
222
- return isAsked ? tell() : Promise.resolve().then(tell)
285
+ if (!isAsked) return Promise.resolve().then(tell)
286
+ const told = tell().finally(() => askedCalls.delete(told))
287
+ askedCalls.add(told)
288
+ return told
223
289
  }
224
290
 
225
291
  const asked = async <Value>(run: () => Value | Promise<Value>): Promise<Value> => {
226
292
  askedDepth += 1
227
293
  try {
228
- return await run()
294
+ const value = await run()
295
+ while (askedCalls.size > 0) await Promise.allSettled(askedCalls)
296
+ return value
229
297
  } finally {
230
298
  askedDepth -= 1
231
299
  }
@@ -241,7 +309,7 @@ export function createUi<State extends object>({ name, claude, router, report, a
241
309
  }
242
310
  const mine = collected
243
311
  collected = collecting === 0 ? undefined : []
244
- return merged(mine)
312
+ return withNeededRedraw(merged(mine))
245
313
  }
246
314
 
247
315
  const logOnce = (what: string) => {
@@ -254,27 +322,48 @@ export function createUi<State extends object>({ name, claude, router, report, a
254
322
 
255
323
  const renderOf = (type: string) => markdownRenders.get(type)
256
324
 
257
- const markdownIn = (table: ElementTable) => {
325
+ const drawnBlock = (piece: MarkdownPiece<MarkdownRender>, at: StyledAt, drawClaudes: (text: string) => RenderElement, table?: ElementTable): RenderElement => {
326
+ if ('text' in piece) return drawClaudes(piece.text)
327
+ const { render, props: block } = piece
328
+ const draw = () => render.Component({ ...block, ...at, Default: ({ source = block.source }: { readonly source?: string }) => drawClaudes(source) })
329
+ try {
330
+ return table === undefined ? draw() : drawWith(table, draw)
331
+ } catch (error) {
332
+ render.log(threw(error))
333
+ return drawClaudes(block.source)
334
+ }
335
+ }
336
+
337
+ const othersFrom =
338
+ (table: ElementTable, at: StyledAt, from: number) =>
339
+ (props: MarkdownProps): RenderElement => {
340
+ for (const [index, styler] of stylers.entries()) {
341
+ if (index < from) continue
342
+ const cached = `${stylersSeen}\u0000${at.surface}\u0000${at.columns}\u0000${props.text}`
343
+ const drawn = drawWith(table, () => borrowed(styler, cached, { markdown: props.text, ...at }), othersFrom(table, at, index + 1))
344
+ if (drawn !== undefined) return drawn
345
+ }
346
+ return table.Markdown(props)
347
+ }
348
+
349
+ const markdownIn = (table: ElementTable, at: StyledAt) => {
258
350
  const reader = read
259
- if (reader === undefined) return undefined
351
+ const others = othersFrom(table, at, 0)
352
+ if (reader === undefined) return stylers.length === 0 ? undefined : others
260
353
  return (props: MarkdownProps) => {
261
354
  const pieces = reader(props.text, renderOf)
262
- if (pieces.every((piece) => 'text' in piece)) return table.Markdown(props)
263
- const drawClaudes = (text: string) => table.Markdown({ ...props, text })
264
- const drawings = pieces.map((piece) => {
265
- if ('text' in piece) return drawClaudes(piece.text)
266
- const { render, props: block } = piece
267
- try {
268
- return drawWith(table, () => render.Component({ ...block, Default: ({ source = block.source }: { readonly source?: string }) => drawClaudes(source) }))
269
- } catch (error) {
270
- render.log(threw(error))
271
- return drawClaudes(block.source)
272
- }
273
- })
355
+ if (pieces.every((piece) => 'text' in piece)) return others(props)
356
+ const drawings = pieces.map((piece) => drawnBlock(piece, at, (text) => others({ ...props, text }), table))
274
357
  return drawPieces(drawings.map((drawing) => ({ drawing, isClaudesRow: false })), 'pane')
275
358
  }
276
359
  }
277
360
 
361
+ const markdownDrawing = (text: string, at: StyledAt): RenderElement => {
362
+ const pieces = read === undefined ? [] : read(text, renderOf)
363
+ if (pieces.every((piece) => 'text' in piece)) return Markdown({ text })
364
+ return drawPieces(pieces.map((piece) => ({ drawing: drawnBlock(piece, at, (source) => Markdown({ text: source })), isClaudesRow: false })), 'pane')
365
+ }
366
+
278
367
  const routeMarkdown = (createReader: () => MarkdownReader) => {
279
368
  if (read !== undefined) return
280
369
  const reader = createReader()
@@ -291,7 +380,7 @@ export function createUi<State extends object>({ name, claude, router, report, a
291
380
  const { render, props: block } = piece
292
381
  const component = await drawComponent<{ readonly source?: string }>(
293
382
  table,
294
- (Default) => render.Component({ ...block, Default }),
383
+ (Default) => render.Component({ ...block, ...styledAt(e), id: e.requestId, Default }),
295
384
  ({ source = block.source }, isClaudesRow) => drawClaudes(source, index === 0 && isClaudesRow),
296
385
  )
297
386
  if (!('failure' in component)) return component
@@ -337,10 +426,10 @@ export function createUi<State extends object>({ name, claude, router, report, a
337
426
  const { init } = added
338
427
  openPanes.add(init.id)
339
428
  paneStates.set(init.id, { isShown: true, isFocused: e.props.isFocused })
340
- readPanes()
429
+ void readPanes()
341
430
  const table = claude.ui.resolve(e)
342
431
  try {
343
- return drawWith(table, () => init.render(mod(), { ...e.props, params: added.params }), markdownIn(table))
432
+ return drawWith(table, () => init.render(mod(), { ...e.props, surface: e.surface, params: added.params }), markdownIn(table, styledAt(e, e.props.bodyColumns)))
344
433
  } catch (error) {
345
434
  paneLogs.get(init.id)?.(`threw: ${messageOf(error)}`)
346
435
  return drawWith(table, () => Text({ color: 'error', children: `The ${init.title} pane could not draw: ${messageOf(error)}` }))
@@ -356,8 +445,8 @@ export function createUi<State extends object>({ name, claude, router, report, a
356
445
  router.add('ui.close', async (e, next) => {
357
446
  openPanes.delete(e.id)
358
447
  paneStates.delete(e.id)
359
- readPanes()
360
448
  const closed = await next(e)
449
+ await readPanes()
361
450
  const init = panes.get(e.id)?.init
362
451
  if (init?.onClose !== undefined) await handle(init, 'onClose', () => init.onClose?.(mod(), e))
363
452
  return closed
@@ -495,13 +584,14 @@ export function createUi<State extends object>({ name, claude, router, report, a
495
584
  const Component = (props: object) => render(mod(), props as never)
496
585
  const stop = (subscription?: Subscription) => () => {
497
586
  subscription?.remove()
498
- if (markdown !== undefined) markdownRenders.delete(markdown.markdown)
587
+ if (markdown !== undefined && markdownRenders.delete(markdown.markdown)) void send({ parts: partList() })
499
588
  styles.delete(feature)
500
589
  claude.ui.invalidate('ui.render')
501
590
  }
502
591
  if (markdown !== undefined) {
503
592
  markdownRenders.set(markdown.markdown, { Component, log: logOnce(`${nameOf(kind)} style`) })
504
593
  routeMarkdown(markdown.createReader)
594
+ void send({ parts: partList() })
505
595
  return { kind, remove: stop() }
506
596
  }
507
597
  const log = logOnce(`${nameOf(kind)} style`)
@@ -510,7 +600,7 @@ export function createUi<State extends object>({ name, claude, router, report, a
510
600
  const table = claude.ui.resolve(e)
511
601
  const drawn = await drawComponent(
512
602
  table,
513
- (Default) => Component({ ...e.props, Default }),
603
+ (Default) => Component({ ...e.props, ...styledAt(e), id: e.requestId, Default }),
514
604
  (given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
515
605
  )
516
606
  if ('failure' in drawn) {
@@ -524,43 +614,43 @@ export function createUi<State extends object>({ name, claude, router, report, a
524
614
  }
525
615
 
526
616
  const forgetDraws = (key: string) => {
527
- for (const kept of latestDraws.get(key)?.values() ?? []) for (const draw of kept) draws.delete(draw)
617
+ for (const kept of latestDraws.get(key)?.values() ?? []) for (const draw of kept.draws) draws.delete(draw)
528
618
  latestDraws.delete(key)
529
619
  }
530
620
 
531
621
  const drawPart = (key: string, title: string, input: PartDraw, render: () => RenderElement | undefined): DrawnPart | null => {
532
622
  try {
533
623
  const pressed: Handler[] = []
534
- const drawn = render()
535
- const tree = drawn === undefined ? null : elementData(drawn, pressed)
624
+ const tree = treeOf(render, pressed)
536
625
  failedDraws.delete(key)
537
- drawCount += 1
538
- const inputs = latestDraws.get(key) ?? new Map<string, readonly number[]>()
626
+ const number = nextDraw()
627
+ const inputs = latestDraws.get(key) ?? new Map<string, SentDrawing>()
539
628
  latestDraws.set(key, inputs)
540
629
  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)
630
+ const earlier = inputs.get(drawnFor)?.draws ?? []
631
+ const kept = [...earlier, number].slice(-2)
543
632
  for (const draw of earlier) if (!kept.includes(draw)) draws.delete(draw)
544
633
  inputs.delete(drawnFor)
545
- inputs.set(drawnFor, kept)
634
+ inputs.set(drawnFor, { draws: kept, tree: JSON.stringify(tree), render })
546
635
  const [oldest] = inputs
547
636
  if (inputs.size > keptDrawInputs && oldest !== undefined) {
548
- for (const draw of oldest[1]) draws.delete(draw)
637
+ for (const draw of oldest[1].draws) draws.delete(draw)
549
638
  inputs.delete(oldest[0])
550
639
  }
551
- draws.set(drawCount, { id: key, handlers: pressed })
552
- return { draw: drawCount, tree }
640
+ draws.set(number, { id: key, handlers: pressed })
641
+ return { draw: number, tree }
553
642
  } catch (error) {
554
643
  if (!failedDraws.has(key)) report({ id: key, message: `The ${title} could not draw`, error })
555
644
  failedDraws.add(key)
556
- return null
645
+ throw error
557
646
  }
558
647
  }
559
648
 
560
649
  const draw = (input: PartDraw): DrawnPart | null => {
650
+ if ('markdown' in input) return drawPart('markdown', 'markdown style', input, () => markdownDrawing(input.markdown, { surface: input.surface, columns: input.columns }))
561
651
  if ('page' in input) {
562
652
  const added = pages.get(input.page)
563
- if (added === undefined) return null
653
+ if (added === undefined) throw new Error(`${name} has no ${input.page} page.`)
564
654
  return drawPart(`page:${input.page}`, `${added.init.title} page`, input, () => added.init.render(mod(), { params: input.params, isFocused: input.isFocused, columns: input.columns }))
565
655
  }
566
656
  const widget = widgets.get(input.widget)
@@ -570,17 +660,21 @@ export function createUi<State extends object>({ name, claude, router, report, a
570
660
  return drawPart(`widget:${widget.id}`, `${widget.title} widget`, input, () => widget.render(mod(), context))
571
661
  }
572
662
 
573
- const press = async ({ draw: drawn, handler, args }: PartPress): Promise<PartsUpdate> => {
663
+ const press = async ({ draw: drawn, handler, args }: PartPress, caller: string): Promise<PartsUpdate | null> => {
574
664
  const known = draws.get(drawn)
575
665
  const run = known?.handlers[handler]
576
666
  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 () => {
667
+ if (caller !== cmodPluginName && known.id !== 'markdown') throw new Error(`${name}: ${caller} pressed a button only the cmod plugin draws.`)
668
+ const pressed = async () => {
578
669
  try {
579
670
  await run(...args)
580
671
  } catch (error) {
581
672
  report({ id: known.id, message: 'Its button failed', error })
582
673
  }
583
- })
674
+ }
675
+ if (caller === cmodPluginName) return collect(pressed)
676
+ await pressed()
677
+ return null
584
678
  }
585
679
 
586
680
  return {
@@ -601,16 +695,24 @@ export function createUi<State extends object>({ name, claude, router, report, a
601
695
  if (unheldRequests > before) await passOn()
602
696
  return answer
603
697
  },
604
- answer(method, input) {
698
+ answer(method, input, caller) {
699
+ const isMarkdown = method === partMethods.draw && isObject(input) && typeof input['markdown'] === 'string'
700
+ if (caller !== cmodPluginName && !isMarkdown && method !== partMethods.press) return undefined
605
701
  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 }))
702
+ if (method === partMethods.draw) return Promise.resolve().then(() => ({ value: draw(input as PartDraw) }))
703
+ if (method === partMethods.press) return press(input as PartPress, caller).then((value) => ({ value }))
608
704
  if (method === partMethods.shown) {
609
705
  shownPages = input as ShownPages
610
706
  return Promise.resolve({ value: null })
611
707
  }
708
+ if (method === partMethods.stylers) {
709
+ hear(input, true)
710
+ return Promise.resolve({ value: null })
711
+ }
612
712
  return undefined
613
713
  },
714
+ markdownIn,
715
+ restyled: (stylers) => hear(stylers, true),
614
716
  started() {
615
717
  const held = merged(beforeStart ?? [])
616
718
  beforeStart = undefined
@@ -6,18 +6,24 @@ import type { Failure } from './toaster.js'
6
6
 
7
7
  export type Watches = { add(paths: readonly string[], listener: (change: FileChange) => unknown): Subscription }
8
8
 
9
- type Watch = { readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown; seen: ReadonlyMap<string, number> | undefined }
9
+ type Watch = { readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown; seen: ReadonlyMap<string, number> | undefined; missing: ReadonlySet<string> }
10
10
 
11
11
  const watchMs = 500
12
12
 
13
+ const missingEvery = 4
14
+
13
15
  const watchedFiles = 2000
14
16
 
15
17
  export function createWatches(claude: Claude, report: (failure: Failure) => void): Watches {
16
18
  const watches = new Set<Watch>()
17
19
  let polling: { cancel(): void } | undefined
18
20
  let isPolling = false
19
- const stampsOf = async (paths: readonly string[]): Promise<Map<string, number>> => {
21
+ let ticks = 0
22
+ const stampsOf = async (watch: Watch): Promise<Map<string, number>> => {
23
+ const { paths } = watch
20
24
  const stamps = new Map<string, number>()
25
+ const missing = new Set<string>()
26
+ const skipped = ticks % missingEvery === 0 ? new Set<string>() : watch.missing
21
27
  const visit = async (path: string): Promise<void> => {
22
28
  const stat = await claude.fs.stat(path).catch(ifMissing(undefined))
23
29
  if (stat === undefined) return
@@ -27,7 +33,15 @@ export function createWatches(claude: Claude, report: (failure: Failure) => void
27
33
  }
28
34
  for (const entry of await claude.fs.list(path).catch(ifMissing([]))) await visit(`${path}/${entry.name}`)
29
35
  }
30
- for (const path of paths) await visit(path)
36
+ for (const path of paths) {
37
+ if (skipped.has(path)) {
38
+ missing.add(path)
39
+ continue
40
+ }
41
+ await visit(path)
42
+ if (![...stamps.keys()].some((seen) => seen === path || seen.startsWith(`${path}/`))) missing.add(path)
43
+ }
44
+ watch.missing = missing
31
45
  return stamps
32
46
  }
33
47
  const stop = (watch: Watch) => {
@@ -37,7 +51,7 @@ export function createWatches(claude: Claude, report: (failure: Failure) => void
37
51
  polling = undefined
38
52
  }
39
53
  const stampsOrStop = (watch: Watch) =>
40
- stampsOf(watch.paths).catch((error: unknown) => {
54
+ stampsOf(watch).catch((error: unknown) => {
41
55
  stop(watch)
42
56
  report({ id: 'fs:onChange', message: 'Could not read the files fs.onChange watches', error })
43
57
  return undefined
@@ -45,6 +59,7 @@ export function createWatches(claude: Claude, report: (failure: Failure) => void
45
59
  const poll = async () => {
46
60
  if (isPolling) return
47
61
  isPolling = true
62
+ ticks += 1
48
63
  try {
49
64
  for (const watch of watches) {
50
65
  const now = await stampsOrStop(watch)
@@ -65,7 +80,7 @@ export function createWatches(claude: Claude, report: (failure: Failure) => void
65
80
  }
66
81
  return {
67
82
  add(paths, listener) {
68
- const watch: Watch = { paths, listener, seen: undefined }
83
+ const watch: Watch = { paths, listener, seen: undefined, missing: new Set() }
69
84
  watches.add(watch)
70
85
  void stampsOrStop(watch).then((stamps) => void (watch.seen ??= stamps))
71
86
  polling ??= claude.clock.every(watchMs, () => void poll())