@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/testing.ts CHANGED
@@ -4,8 +4,10 @@ import type {
4
4
  CmodDependencies,
5
5
  CommandPresentation,
6
6
  CommandRunResult,
7
+ ElementTable,
7
8
  EventResult,
8
9
  Frozen,
10
+ MarkdownProps,
9
11
  PaneOpenArgs,
10
12
  PluginOptions,
11
13
  PressedLink,
@@ -23,7 +25,7 @@ import type { Options, OptionValues } from './options.js'
23
25
  import { hotkeyCommand, readSteps, storeFolder, type Steps } from './records.js'
24
26
  import type { Claude } from './runtime/claude.js'
25
27
  import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
- import { closingContext, foldBefore, installBefore, withAnswer, type Answers } from './runtime/before.js'
28
+ import { closingContext, foldBefore, installBefore, withAnswer, type Answers, type Decide } from './runtime/before.js'
27
29
  import { permissionRoutes } from './runtime/permission-check.js'
28
30
  import type { RoutedEvent } from './runtime/hooks.js'
29
31
  import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
@@ -31,7 +33,7 @@ import { createRouter } from './runtime/router.js'
31
33
  import { toolInputOf } from './runtime/tool-calls.js'
32
34
  import { createLifecycle } from './runtime/lifecycle.js'
33
35
  import { MissingOptions } from './runtime/options.js'
34
- import { conversationMethods } from './runtime/sessions.js'
36
+ import { conversationMethods, pickedColor, type SessionColor } from './runtime/sessions.js'
35
37
  import type { ShownToast } from './runtime/toaster.js'
36
38
  import { Message } from './session/context/turn/message/message.js'
37
39
  import { Note, untagged } from './session/context/turn/message/note.js'
@@ -45,19 +47,19 @@ import { TaskStart } from './session/goal/task.js'
45
47
  import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
46
48
  import { elements, findElement, rowsOf } from './testing/fake-elements.js'
47
49
  import { fakeFiles } from './testing/fake-files.js'
48
- import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
50
+ import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft, type StartedTask } from './testing/fake-sessions.js'
49
51
  import { listed, messageOf } from './utils/text.js'
50
- import { componentOf, type Row, type StyleProps } from './ui/styles.js'
52
+ import { componentOf, type KindProps, type Row, type StyledAt } from './ui/styles.js'
51
53
  import { areas, TurnEnd as TurnEndArea, type Area } from './ui/areas.js'
52
54
  import { drawWith, elementOf } from './ui/elements.js'
53
55
  import { drawnParts } from './runtime/parts.js'
54
56
  import { serveWidgets, type Widgets } from './runtime/widgets.js'
55
57
  import { engineOf } from './runtime/engine.js'
56
- import { pagesOf } from './runtime/pages.js'
58
+ import { pagesOf, servePages } from './runtime/pages.js'
57
59
  import { partMethods, type DrawnPart, type PageRequest, type PartPress, type PartsUpdate } from './runtime/ui.js'
58
60
 
59
61
  export type { Fakes, TestCall } from './testing/fake-claude.js'
60
- export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
62
+ export type { FakeSession, MessageDraft, StartedTask } from './testing/fake-sessions.js'
61
63
  export type { RoutedEvent } from './runtime/hooks.js'
62
64
  export type { ShownToast } from './runtime/toaster.js'
63
65
 
@@ -116,12 +118,12 @@ export type TestedMod<State extends object> = {
116
118
  fire<N extends FiredEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
117
119
  settle(): Promise<void>
118
120
  add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
119
- endTurn(ending?: Pick<Turn, 'answer' | 'failure'>): Promise<void>
121
+ endTurn(ending?: Pick<Turn, 'answer' | 'failure'>, options?: { readonly task?: string }): Promise<void>
120
122
  compact(): Promise<void>
121
123
  startSession(start: { readonly source: 'startup' | 'resume' | 'clear' | 'fork'; readonly id?: string }): Promise<void>
122
- startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): Promise<string>
124
+ startTask(task: StartedTask): Promise<string>
123
125
  lines(area: Area, props?: { readonly columns?: number }): Promise<string[]>
124
- lines<K extends Row>(kind: K, props: Omit<StyleProps<K>, 'Default'>, requestId?: string): Promise<string[]>
126
+ lines<K extends Row>(kind: K, props: KindProps<K>, requestId?: string): Promise<string[]>
125
127
  type(line: string): Promise<CommandReply>
126
128
  hotkey(name: string): Promise<void>
127
129
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
@@ -159,9 +161,11 @@ export function testMod<State extends object, Declared extends Options = Options
159
161
  fake.claude.session.cwd = async () => cwd
160
162
  const conversations = fakeSessions({
161
163
  callTool: (args: Parameters<Claude["tool"]["call"]>[0]) => fake.claude.tool.call(args),
164
+ listAgents: () => fake.fakes.agent.list?.() ?? Promise.resolve([]),
162
165
  project: () => projectRoot,
163
166
  cwd: () => cwd,
164
167
  given: options.sessions ?? [],
168
+ decide: (event, change) => decide(event, change),
165
169
  push: (mod, session, change) => {
166
170
  if (mod !== name) return
167
171
  const pushed = { to: mod, method: conversationMethods.changed, input: { session, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
@@ -170,6 +174,13 @@ export function testMod<State extends object, Declared extends Options = Options
170
174
  })
171
175
  conversations.start(sessionId)
172
176
  const callMod = async (method: string, input: unknown, caller = cmodPluginName) => calledAnswer(caller, await lifecycle.route('cmod.call', { to: name, method, input, caller } as unknown as Frozen<Args<'cmod.call'>>, async () => ({ deny: notInstalled(name) })))
177
+ const tellColor = async (told: SessionColor) => {
178
+ if (name !== cmodPluginName) await callMod(conversationMethods.sessionColor, told)
179
+ }
180
+ const tellHeldColor = async () => {
181
+ const held = conversations.color()
182
+ if (held.color !== undefined) await tellColor(held)
183
+ }
173
184
  let declaredPages: ReturnType<typeof pagesOf> = []
174
185
  let openPages: { readonly id: string; readonly params: unknown }[] = []
175
186
  let shownPage: { readonly id: string; readonly params: unknown; readonly isFocused: boolean } | undefined
@@ -191,33 +202,61 @@ export function testMod<State extends object, Declared extends Options = Options
191
202
  await tellShown()
192
203
  }
193
204
  let widgets: Promise<Widgets> | undefined
205
+ let stylesMarkdown = false
206
+ const ownMarkdown = new Map<string, DrawnPart | null>()
207
+ const missedMarkdown = new Map<string, { readonly markdown: string } & StyledAt>()
194
208
  const updated = async ({ parts, redraw, requests }: PartsUpdate) => {
195
- if (parts !== undefined) declaredPages = pagesOf(parts)
209
+ if (parts !== undefined) {
210
+ declaredPages = pagesOf(parts)
211
+ stylesMarkdown = parts.stylesMarkdown === true
212
+ ownMarkdown.clear()
213
+ }
196
214
  if (parts !== undefined || redraw !== undefined) (await widgetsOf()).update(name, { ...(parts === undefined ? {} : { parts }), ...(redraw === undefined ? {} : { redraw }) })
197
215
  if (requests !== undefined && requests.length > 0) await applyRequests(requests)
198
216
  }
199
217
  const pressPart = (input: PartPress) => callMod(partMethods.press, input).then((update) => updated((update ?? {}) as PartsUpdate))
218
+ const markdownIn = (table: ElementTable, at: StyledAt) => (props: MarkdownProps): RenderElement => {
219
+ const drawnAt = `${at.surface}\u0000${at.columns}\u0000${props.text}`
220
+ const drawn = stylesMarkdown ? ownMarkdown.get(drawnAt) : null
221
+ if (drawn === undefined) missedMarkdown.set(drawnAt, { markdown: props.text, ...at })
222
+ if (drawn === undefined || drawn === null || drawn.tree === null) return table.Markdown(props)
223
+ const { draw, tree } = drawn
224
+ return drawWith(table, () => elementOf(tree, '', (handler, args) => void pressPart({ draw, handler, args }))) as RenderElement
225
+ }
226
+ const withOwnMarkdown = async <Drawn>(draw: () => Promise<Drawn>): Promise<Drawn> => {
227
+ const first = await draw()
228
+ if (missedMarkdown.size === 0) return first
229
+ for (const [drawnAt, input] of missedMarkdown) ownMarkdown.set(drawnAt, (await callMod(partMethods.draw, input)) as DrawnPart | null)
230
+ missedMarkdown.clear()
231
+ return draw()
232
+ }
233
+ const cmodClaude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
234
+ const reportPart = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
235
+ const parts = drawnParts({ claude: cmodClaude, report: reportPart, press: (_mod: string, input: PartPress) => void pressPart(input).catch((error: unknown) => fake.claude.ui.log(`cmod: ${name} did not take the press: ${messageOf(error)}`, { to: 'debug' })) })
200
236
  const servedWidgets = async (): Promise<Widgets> => {
201
237
  const own = name === cmodPluginName && lifecycle.mod !== undefined ? engineOf(lifecycle.mod).widgets : undefined
202
238
  if (own !== undefined) return own
203
- const claude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
204
- const report = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
205
- const press = (_mod: string, input: PartPress) => void pressPart(input).catch((error: unknown) => fake.claude.ui.log(`cmod: ${name} did not take the press: ${messageOf(error)}`, { to: 'debug' }))
206
239
  return serveWidgets({
207
- claude,
240
+ claude: cmodClaude,
208
241
  router: cmodPlugin,
209
242
  store: storeFolder({ HOME: await fake.claude.env.home(), XDG_DATA_HOME: await fake.claude.env.dataHome() }),
210
- report,
243
+ report: reportPart,
211
244
  turnClosedBy: () => conversations.lastTurn(),
212
- parts: drawnParts({ claude, report, press }),
245
+ parts,
246
+ markdownIn,
213
247
  })
214
248
  }
215
249
  const widgetsOf = () => (widgets ??= servedWidgets())
250
+ const cmodBeneath = createRouter()
251
+ const pages = servePages({ claude: cmodClaude, router: cmodBeneath, report: reportPart, widgets: { update: (mod, update) => void widgetsOf().then((served) => served.update(mod, update)) }, parts, tellStylers: () => undefined })
252
+ pages.apply((_mod, requests) => applyRequests(requests))
216
253
  const dependencies = fakeDependencies(name, options.dependencies ?? {}, conversations)
217
254
  fake.fakes.cmod.call = async (call) => {
218
255
  if (call.to !== cmodPluginName || call.method !== partMethods.changed) return dependencies(call)
219
- await updated(call.input as PartsUpdate)
220
- return { isHeld: (call.input as PartsUpdate).isAsked === true }
256
+ const { requests, isAsked, ...listing } = call.input as PartsUpdate
257
+ const isHeld = isAsked === true && requests !== undefined && pages.update(name, { requests, isAsked })
258
+ await updated(isAsked === true ? listing : (call.input as PartsUpdate))
259
+ return { isHeld }
221
260
  }
222
261
  const appendRow = fake.claude.session.append
223
262
  fake.claude.session.append = async (args) => {
@@ -244,6 +283,7 @@ export function testMod<State extends object, Declared extends Options = Options
244
283
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
245
284
  const lifecycle = createLifecycle<State, Declared>({ ...definition, state }, () => permissionRoutes,(options.options ?? {}) as PluginOptions, false)
246
285
  let started: Promise<void> | undefined
286
+ let isColorTold = false
247
287
 
248
288
  const start = async () => {
249
289
  started ??= lifecycle.start(fake.claude, async (claude) => {
@@ -267,6 +307,9 @@ export function testMod<State extends object, Declared extends Options = Options
267
307
  throw new Error(`${name} needs ${listed(failure.titles)}, which a person sets in /config. Give ${failure.keys.length === 1 ? 'it' : 'them'} to the tested mod: testMod(mod, { options: { ${given} } }).`)
268
308
  }
269
309
  if (lifecycle.phase !== 'active') throw failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
310
+ if (isColorTold) return
311
+ isColorTold = true
312
+ await tellHeldColor()
270
313
  }
271
314
 
272
315
  const cmodPlugin = createRouter()
@@ -274,39 +317,69 @@ export function testMod<State extends object, Declared extends Options = Options
274
317
  const asked = { to: name, method: conversationMethods.before, input: { session: sessionId, event, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
275
318
  return calledAnswer(cmodPluginName, await lifecycle.route('cmod.call', asked, async () => ({ deny: notInstalled(name) })))
276
319
  }
320
+ const decide: Decide = (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' }))
277
321
  installBefore({
278
322
  claude: fake.claude,
279
323
  router: cmodPlugin,
280
324
  hooks: {
281
325
  place: async () => conversations.place(),
282
326
  wants: async (event) => conversations.wants(name, event),
283
- fold: () => (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
327
+ fold: () => decide,
284
328
  },
285
329
  })
286
330
 
287
331
  const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
288
332
  await start()
289
333
  if (event === 'ui.render') {
334
+ const { component, requestId } = input as Args<'ui.render'>
335
+ if (component === 'Pane') fake.placePane(requestId)
290
336
  await widgetsOf()
291
337
  await fake.settle()
292
338
  }
293
- return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>))
339
+ return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (await claudeCodeRuns(event, e, below)) as EventResult<N>))
294
340
  }
295
341
 
296
- const runCommand = async (command: string, args: string): Promise<CommandRunResult> => {
342
+ const claudeCodeRuns = async (event: RoutedEvent, e: unknown, below: unknown): Promise<unknown> => {
343
+ if (event === 'session.compact' && (e as Args<'session.compact'>).trigger !== 'precompute') {
344
+ const started = await fire('SessionStart', { source: 'compact' })
345
+ fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
346
+ }
347
+ if (below !== undefined) return below
348
+ const answer = claudeCodeAnswer(event, e)
349
+ if (event === 'ui.close') {
350
+ const { id, origin } = e as Args<'ui.close'>
351
+ if (origin.kind === 'person' && fake.shown.openPanes.has(id)) await drawPane(id)
352
+ fake.shown.openPanes.delete(id)
353
+ }
354
+ if (event === 'agent.spawn') {
355
+ const started = await route('classic.SubagentStart', { hook_event_name: 'SubagentStart', agent_id: (answer as { agentId: string }).agentId, agent_type: (e as Args<'agent.spawn'>).subagentType }, undefined)
356
+ fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
357
+ }
358
+ return answer
359
+ }
360
+
361
+ const runCommand = async (command: string, args: string, builtIn: CommandRunResult = {}): Promise<CommandRunResult> => {
297
362
  await start()
298
363
  const reached: Set<string> = new Set()
299
- const ownRun = async (): Promise<CommandRunResult> => (reached.add(command), {})
300
- const answer = await cmodPlugin.dispatch('command.run', { command, args, origin: { kind: 'composer' }, presentation }, (passed) => lifecycle.route('command.run', passed, ownRun))
364
+ const ownRun = async (): Promise<CommandRunResult> => (reached.add(command), builtIn)
365
+ const answer = await cmodPlugin.dispatch('command.run', { command, args, origin: { kind: 'composer' }, presentation }, (passed) => lifecycle.route('command.run', passed, (e) => cmodBeneath.dispatch('command.run', e, ownRun)))
301
366
  if (reached.has(command) && answer.text === undefined) throw new Error(!fake.shown.commands.includes(command) ? `${name} has no slash command /${command}. Add it in setup with mod.use(slashCommand({ name: '${command}', … })).` : `${name}: /${command} reached Claude Code's own run of the command, so Claude Code shows "${name} registered /${command} but no command.run hook answered it". Answer it without calling next(e).`)
302
367
  return answer
303
368
  }
304
369
 
305
- const drawPane = async (paneId: string) => {
370
+ const runColor = async (args: string): Promise<CommandRunResult> => {
371
+ if (args.trim() === '') throw new Error(`Claude Code's /color with no color picks one at random, so a test names the color, such as tested.type('/color cyan').`)
372
+ const color = pickedColor(args)
373
+ const answer = await runCommand('color', args, { text: color === undefined ? 'Session color reset to default' : `Session color set to: ${color}` })
374
+ await tellColor(conversations.recolor(color))
375
+ return answer
376
+ }
377
+
378
+ const drawPane = async (paneId: string, asked?: number) => {
306
379
  await start()
307
380
  if (!fake.shown.openPanes.has(paneId)) throw new Error(`The pane "${paneId}" of ${name} closed, and a user sees a pane only while it is open.`)
308
381
  const opened = fake.calls.findLast((call) => call.call === 'ui.open' && (call.args[0] as PaneOpenArgs).id === paneId)?.args[0] as PaneOpenArgs | undefined
309
- const columns = opened?.columns ?? defaultColumns
382
+ const columns = asked ?? opened?.columns ?? defaultColumns
310
383
  const isFocused = (await fake.claude.ui.panes()).some((open) => open.id === paneId && open.isFocused)
311
384
  const props: RenderPropsOf['Pane'] = { title: opened?.title ?? paneId, isFocused, bodyColumns: columns, placement: 'dock', scroll: { offset: 0, bodyRows: defaultRows }, view: {} }
312
385
  const drawing = await route('ui.render', { surface: 'terminal', component: 'Pane', requestId: paneId, props }, elements.Box({}))
@@ -320,7 +393,7 @@ export function testMod<State extends object, Declared extends Options = Options
320
393
  const drawn = (await callMod(partMethods.draw, { page: pageId, params: page.params, columns, isFocused: page.isFocused })) as DrawnPart | null
321
394
  await fake.settle()
322
395
  const tree = drawn?.tree ?? null
323
- const drawing = tree === null || drawn === null ? elements.Box({}) : (drawWith(elements, () => elementOf(tree, '', (handler, args) => void pressPart({ draw: drawn.draw, handler, args }))) as RenderElement)
396
+ const drawing = tree === null || drawn === null ? elements.Box({}) : await withOwnMarkdown(async () => drawWith(elements, () => elementOf(tree, '', (handler, args) => void pressPart({ draw: drawn.draw, handler, args })), markdownIn(elements, { surface: 'terminal', columns })) as RenderElement)
324
397
  return { drawing, columns }
325
398
  }
326
399
 
@@ -339,7 +412,7 @@ export function testMod<State extends object, Declared extends Options = Options
339
412
  const draw = async (columns?: number) => {
340
413
  await start()
341
414
  const { id } = shownNow(isPane)
342
- return { id, ...(isPane ? await drawPane(id) : await drawPage(id, columns)) }
415
+ return { id, ...(isPane ? await drawPane(id, columns) : await drawPage(id, columns)) }
343
416
  }
344
417
  const drawnElement = async (type: 'Button' | 'Input' | 'Select' | 'Markdown', key: string) => {
345
418
  const { id, drawing } = await draw()
@@ -368,7 +441,7 @@ export function testMod<State extends object, Declared extends Options = Options
368
441
  else await (props['onLinkPress'] as (link: PressedLink, e: Frozen<UiPressArgument>) => unknown)(e.link, e)
369
442
  return { element: e.element }
370
443
  }
371
- if (isPane) await lifecycle.route('ui.press', pressed, run)
444
+ if (isPane) await lifecycle.route('ui.press', pressed, (e) => cmodBeneath.dispatch('ui.press', e, run))
372
445
  else await run(pressed)
373
446
  await fake.settle()
374
447
  },
@@ -397,7 +470,7 @@ export function testMod<State extends object, Declared extends Options = Options
397
470
  await fake.settle()
398
471
  const turn = area === TurnEndArea ? conversations.lastTurn() : undefined
399
472
  if (area === TurnEndArea && turn?.closedAt === undefined) throw new Error(`${name} has no ended turn, so TurnEnd draws nothing. End one with tested.add({ type: Prompt, text: '…' }), then tested.endTurn().`)
400
- return { drawing: elements.Box({ flexDirection: 'column', children: [...(await served.draw(area, elements, columns, turn))] }), columns }
473
+ return { drawing: elements.Box({ flexDirection: 'column', children: [...(await withOwnMarkdown(() => served.draw(area, elements, { surface: 'terminal', columns }, turn)))] }), columns }
401
474
  }
402
475
 
403
476
  const drawSlot = async (component: RenderComponent, props: object, requestId = `${component}_${(slotDraws += 1)}`) => {
@@ -420,7 +493,11 @@ export function testMod<State extends object, Declared extends Options = Options
420
493
  const toolUse = toolUseEvents.includes(event) ? { tool_use_id: `toolu_${(toolUses += 1)}` } : {}
421
494
  const transcript = `/test/transcripts/${sessionId}.jsonl`
422
495
  const filled = { session_id: sessionId, transcript_path: transcript, cwd, hook_event_name: event, ...toolUse, ...input }
423
- if (event === 'SessionStart' && !('transcript_path' in input)) return files.write(transcript, '').then(() => route('classic.SessionStart', filled, below))
496
+ if (event === 'SessionStart') {
497
+ const source = (input as { readonly source?: string }).source
498
+ const started = 'transcript_path' in input ? route('classic.SessionStart', filled, below) : files.write(transcript, '').then(() => route('classic.SessionStart', filled, below))
499
+ return source === 'startup' || source === 'clear' ? started : started.then(async (answer) => (await tellHeldColor(), answer))
500
+ }
424
501
  if (event !== 'PreToolUse') return route(`classic.${event}` as RoutedEvent, filled, below)
425
502
  return firePreToolUse(filled as ClassicHookInputs['PreToolUse'], below as { deny?: string } | undefined)
426
503
  }) as ClaudeCode['fire']
@@ -500,9 +577,9 @@ export function testMod<State extends object, Declared extends Options = Options
500
577
  await start()
501
578
  return settled(conversations.add(message, added?.task))
502
579
  },
503
- endTurn: async (ending) => {
580
+ endTurn: async (ending, options) => {
504
581
  await start()
505
- await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }))
582
+ await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }, options?.task))
506
583
  },
507
584
  compact: async () => {
508
585
  await start()
@@ -531,7 +608,7 @@ export function testMod<State extends object, Declared extends Options = Options
531
608
  const typed = /^\/(\S+)\s*(.*)$/s.exec(line)
532
609
  if (typed === null) throw new Error(`type takes a slash command the way the user types it, such as tested.type('/help'), and "${line}" does not start with /.`)
533
610
  const [, command = '', args = ''] = typed
534
- return replyOf(await runCommand(command, args))
611
+ return replyOf(await (command === 'color' ? runColor(args) : runCommand(command, args)))
535
612
  },
536
613
  async callTool(toolName, input) {
537
614
  const tool = `mcp__${name}__${toolName}`
@@ -617,6 +694,10 @@ function claudeCodeAnswer(event: RoutedEvent, e: unknown): unknown {
617
694
  const { model, parentModel } = e as Args<'agent.spawn'>
618
695
  return { agentId: 'agent-1', model: model ?? parentModel }
619
696
  }
697
+ if (event === 'session.append') {
698
+ const { message, uuid } = e as Args<'session.append'>
699
+ return { message, uuid }
700
+ }
620
701
  if (event !== 'prompt.submit') return {}
621
702
  const { text, context, origin } = e as Args<'prompt.submit'>
622
703
  return { text, ...(context === undefined ? {} : { context }), origin }
package/src/ui/areas.ts CHANGED
@@ -17,6 +17,7 @@ export type WidgetInit<State extends object = Record<never, never>> = {
17
17
  readonly id: string
18
18
  readonly title: string
19
19
  readonly areas: readonly [Area, ...Area[]]
20
+ readonly place?: { readonly side?: 'left' | 'right'; readonly size?: 'fit' | 'fill' }
20
21
  render(mod: Mod<State>, context: WidgetContext): RenderElement | undefined
21
22
  }
22
23
 
@@ -1,15 +1,14 @@
1
1
  import type { RenderChildren, RenderElement } from 'claude-code'
2
2
  import { Box, Button, Text } from './elements.js'
3
3
 
4
- export type Tab = { readonly key: string; readonly label: string }
4
+ export type Tab = { readonly key: string; readonly label: string; readonly hotkey?: string }
5
5
 
6
6
  export function Tabs({ tabs, selected, onSelect, children }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void; readonly children?: RenderChildren }): RenderElement {
7
- const row = tabs.map((tab, index) => {
8
- const hotkey = index < 9 ? `${index + 1}` : undefined
9
- return tab.key === selected
10
- ? Text({ children: [...(hotkey === undefined ? [] : [Text({ color: 'claude', children: hotkey }), ': ']), Text({ bold: true, underline: true, children: tab.label })] })
11
- : Button({ plain: true, dimColor: true, key: `tab:${tab.key}`, ...(hotkey === undefined ? {} : { hotkey }), label: tab.label, onPress: () => onSelect(tab.key) })
12
- })
7
+ const row = tabs.map(({ key, label, hotkey }) =>
8
+ key === selected
9
+ ? Text({ children: [...(hotkey === undefined ? [] : [Text({ color: 'claude', children: hotkey }), ': ']), Text({ bold: true, underline: true, children: label })] })
10
+ : Button({ plain: true, dimColor: true, key: `tab:${key}`, ...(hotkey === undefined ? {} : { hotkey }), label, onPress: () => onSelect(key) }),
11
+ )
13
12
  return Box({ flexDirection: 'column', gap: 1, children: [Box({ gap: 3, children: row }), children] })
14
13
  }
15
14
 
@@ -19,7 +18,14 @@ export function Split({ children }: { readonly children: readonly RenderElement[
19
18
  }
20
19
 
21
20
  export function Panel({ title, children }: { readonly title: string; readonly children?: RenderChildren }): RenderElement {
22
- return Box({ flexDirection: 'column', flexGrow: 1, borderStyle: 'round', borderColor: 'subtle', paddingX: 1, children: [Text({ bold: true, children: title }), children] })
21
+ return Box({
22
+ flexDirection: 'column',
23
+ flexGrow: 1,
24
+ children: [
25
+ Box({ flexDirection: 'column', flexGrow: 1, minWidth: [...title].length + 6, borderStyle: 'round', borderColor: 'subtle', paddingX: 1, children }),
26
+ Box({ position: 'absolute', top: 0, left: 2, children: Text({ bold: true, wrap: 'truncate-end', children: ` ${title} ` }) }),
27
+ ],
28
+ })
23
29
  }
24
30
 
25
31
  export function Toggle({ label, checked, onChange }: { readonly label: string; readonly checked: boolean; readonly onChange: (checked: boolean) => void }): RenderElement {
@@ -1,6 +1,6 @@
1
1
  import type { ElementName, ElementTable, Elements, MarkdownProps, RenderElement } from 'claude-code'
2
2
 
3
- type Terminal = Elements['terminal']
3
+ type Terminal = Elements['terminal'] & Elements['desktop']
4
4
 
5
5
  type CompletedTable = Record<ElementName, (props: object) => RenderElement>
6
6
 
@@ -53,6 +53,7 @@ export function elementData(value: unknown, handlers: Handler[]): unknown {
53
53
  if (typeof value !== 'object' || value === null) return value
54
54
  if (unbuilt in value) {
55
55
  const { name, props } = (value as Unbuilt)[unbuilt]
56
+ if (name === 'Client') throw new Error('Client draws only in panes and styles.')
56
57
  return { [elementKey]: name, props: elementData(props, handlers) }
57
58
  }
58
59
  if (Object.getPrototypeOf(value) !== Object.prototype) throw new Error('a widget draws only the elements of ui/elements.js and ui/components.js, text, numbers, and plain objects.')
@@ -83,5 +84,8 @@ export const Markdown = element('Markdown')
83
84
  export const Input = element('Input')
84
85
  export const Select = element('Select')
85
86
  export const Image = element('Image')
87
+ export const Svg = element('Svg')
88
+ export const Raster = element('Raster')
89
+ export const Client = element('Client')
86
90
 
87
- const constructors = { Box, Text, Button, Link, Code, Markdown, Input, Select, Image }
91
+ const constructors = { Box, Text, Button, Link, Code, Markdown, Input, Select, Image, Svg, Raster }
@@ -1,6 +1,6 @@
1
1
  import type { Nodes, RootContent } from 'mdast'
2
2
  import { fromMarkdown, gfm, gfmFromMarkdown, toString } from '../vendor-markdown.js'
3
- import type { StyleProps } from './styles.js'
3
+ import type { KindProps } from './styles.js'
4
4
 
5
5
  type BlockText = { readonly text: string; readonly source: string }
6
6
 
@@ -163,7 +163,7 @@ function plainText(node: Nodes): string {
163
163
  return node.children.map(plainText).join(node.type === 'tableRow' ? '\t' : '\n')
164
164
  }
165
165
 
166
- export function markdownBlocks<K extends Block>(text: string, kind: K): Omit<StyleProps<K>, 'Default'>[] {
166
+ export function markdownBlocks<K extends Block>(text: string, kind: K): KindProps<K>[] {
167
167
  const blocks = parsed(text, 0).filter((block) => block.type === kind.markdown)
168
- return blocks.map((block) => block.props) as unknown as Omit<StyleProps<K>, 'Default'>[]
168
+ return blocks.map((block) => block.props) as unknown as KindProps<K>[]
169
169
  }
package/src/ui/panes.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Frozen, PaneCloseInput, RenderElement, RenderPropsOf, UiScrollInput } from 'claude-code'
2
- import type { Mod } from '../mod.js'
2
+ import type { Mod, Surface } from '../mod.js'
3
3
 
4
4
  export type ScrollTarget = 'start' | 'end' | { readonly key: string }
5
5
 
@@ -7,7 +7,7 @@ export type ScrollOptions = { readonly block?: 'start' | 'center' | 'end' | 'nea
7
7
 
8
8
  export type OpenOptions<Params> = { readonly focus?: true } & (undefined extends Params ? { readonly params?: Params } : { readonly params: Params })
9
9
 
10
- export type PaneProps<Params> = Frozen<RenderPropsOf['Pane']> & { readonly params: Params }
10
+ export type PaneProps<Params> = Frozen<RenderPropsOf['Pane']> & { readonly surface: Surface; readonly params: Params }
11
11
 
12
12
  export type PaneInit<State extends object = Record<never, never>, Params = undefined> = {
13
13
  readonly id: string
package/src/ui/styles.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Frozen, RenderComponent, RenderElement, RenderPropsOf } from 'claude-code'
2
- import type { Mod } from '../mod.js'
2
+ import type { Mod, Surface } from '../mod.js'
3
3
  import { TurnEnd, Working } from './areas.js'
4
4
  import type { Block, BlockProps } from './markdown.js'
5
5
 
@@ -32,7 +32,11 @@ type PropsOf<K extends Kind> = K extends Row ? RowProps[K] : K extends Block<inf
32
32
 
33
33
  type DefaultOf<K extends Kind> = K extends Row ? Partial<Omit<RowProps[K], 'onScreen'>> : { readonly source?: string }
34
34
 
35
- export type StyleProps<K extends Kind> = Frozen<PropsOf<K>> & { readonly Default: (props: Frozen<DefaultOf<K>>) => RenderElement }
35
+ export type StyleProps<K extends Kind> = Frozen<PropsOf<K>> & StyledAt & (K extends Row ? { readonly id: string } : { readonly id?: string }) & { readonly Default: (props: Frozen<DefaultOf<K>>) => RenderElement }
36
+
37
+ export type KindProps<K extends Kind> = Omit<StyleProps<K>, 'Default' | 'surface' | 'columns' | 'id'>
38
+
39
+ export type StyledAt = { readonly surface: Surface; readonly columns: number }
36
40
 
37
41
  export type StyleInit<K extends Kind, State extends object = Record<never, never>> = {
38
42
  readonly kind: K
@@ -40,6 +40,8 @@ export type Fakes = {
40
40
  messages?: Claude['session']['messages'];
41
41
  append?: Claude['session']['append'];
42
42
  send?: Claude['session']['send'];
43
+ surfaces?: Claude['session']['surfaces'];
44
+ usage?: Claude['session']['usage'];
43
45
  };
44
46
  prompt: {
45
47
  submit?: Claude['prompt']['submit'];
@@ -85,6 +87,7 @@ export type FakeClaude = {
85
87
  readonly shown: Shown;
86
88
  readonly store: Map<string, unknown>;
87
89
  settle(): Promise<void>;
90
+ placePane(id: string): void;
88
91
  };
89
92
  export declare function calledAnswer(caller: string, answer: EventResult<'cmod.call'>): unknown;
90
93
  export declare function fakeClaude(plugin: {
@@ -1 +1 @@
1
- {"version":3,"file":"fake-claude.d.ts","sourceRoot":"","sources":["../src/testing/fake-claude.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fake-claude.d.ts","sourceRoot":"","sources":["../src/testing/fake-claude.ts"],"names":[],"mappings":"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"}
@@ -31,6 +31,15 @@ export function fakeClaude(plugin) {
31
31
  const run = faked(call, answer);
32
32
  return async (...args) => run(...args);
33
33
  };
34
+ const cmodCall = rejected('cmod.call', () => fakes.cmod.call ?? cmodTakesParts);
35
+ const clonedAsClaudeCodeDoes = (args) => {
36
+ try {
37
+ return structuredClone(args);
38
+ }
39
+ catch (error) {
40
+ throw new Error(`interface.call: arguments are not plain data: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ };
34
43
  const cmodTakesParts = async (input) => {
35
44
  if (input.to === cmodPluginName && input.method === partMethods.changed)
36
45
  return null;
@@ -70,7 +79,7 @@ export function fakeClaude(plugin) {
70
79
  lines.push(text);
71
80
  },
72
81
  notice: () => undefined,
73
- invalidate: () => undefined,
82
+ invalidate: (event) => void calls.push({ call: 'ui.invalidate', args: [event] }),
74
83
  ask: rejected('ui.ask', () => fakes.ui.ask),
75
84
  open: async (pane) => {
76
85
  calls.push({ call: 'ui.open', args: [pane] });
@@ -143,8 +152,8 @@ export function fakeClaude(plugin) {
143
152
  root: async () => plugin.root,
144
153
  cwd: async () => plugin.root,
145
154
  model: async () => 'test-model',
146
- usage: async () => ({ startedAt: Date.parse('2026-01-01T00:00:00.000Z'), context: { window: 200000 }, rateLimits: [], cost: { usd: 0 } }),
147
- surfaces: async () => ['terminal'],
155
+ usage: () => fakes.session.usage?.() ?? Promise.resolve({ startedAt: Date.parse('2026-01-01T00:00:00.000Z'), context: { window: 200000 }, rateLimits: [], cost: { usd: 0 } }),
156
+ surfaces: () => fakes.session.surfaces?.() ?? Promise.resolve(['terminal']),
148
157
  messages: rejected('session.messages', () => fakes.session.messages),
149
158
  append: rejected('session.append', () => fakes.session.append ?? appendNote),
150
159
  send: rejected('session.send', () => fakes.session.send ?? (async () => ({ isDelivered: true }))),
@@ -172,7 +181,7 @@ export function fakeClaude(plugin) {
172
181
  dataHome: async () => undefined,
173
182
  configHome: async () => undefined,
174
183
  },
175
- cmod: { call: rejected('cmod.call', () => fakes.cmod.call ?? cmodTakesParts) },
184
+ cmod: { call: async (args) => cmodCall(clonedAsClaudeCodeDoes(args)) },
176
185
  };
177
186
  const settle = async () => {
178
187
  let seen;
@@ -181,7 +190,11 @@ export function fakeClaude(plugin) {
181
190
  await new Promise((resolve) => setTimeout(resolve, 0));
182
191
  } while (calls.length !== seen);
183
192
  };
184
- return { claude, calls, fakes, shown, store, settle };
193
+ const placePane = (id) => {
194
+ if (unplacedPanes.delete(id))
195
+ shown.openPanes.add(id);
196
+ };
197
+ return { claude, calls, fakes, shown, store, settle, placePane };
185
198
  }
186
199
  function realTimer(ms, fn) {
187
200
  const timeout = setTimeout(fn, ms);
@@ -1 +1 @@
1
- {"version":3,"file":"fake-elements.d.ts","sourceRoot":"","sources":["../src/testing/fake-elements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAiB,UAAU,EAAE,MAAM,aAAa,CAAA;AAE1E,KAAK,KAAK,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAA;AAiB1H,eAAO,MAAM,QAAQ,EAUL,YAAY,CAAA;AAE5B,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,SAAS,CASnG;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAMhE;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiB/C"}
1
+ {"version":3,"file":"fake-elements.d.ts","sourceRoot":"","sources":["../src/testing/fake-elements.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAiB,UAAU,EAAE,MAAM,aAAa,CAAA;AAE1E,KAAK,KAAK,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAAA;AAuB1H,eAAO,MAAM,QAAQ,EAAE,YAatB,CAAA;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,SAAS,CASnG;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAOhE;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiB/C"}