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