@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
@@ -17,7 +17,7 @@ function element(type: string) {
17
17
  }
18
18
  }
19
19
 
20
- export const elements = {
20
+ export const elements: ElementTable = {
21
21
  Box: element('Box'),
22
22
  Text: element('Text'),
23
23
  Button: element('Button'),
@@ -27,7 +27,10 @@ export const elements = {
27
27
  Input: element('Input'),
28
28
  Select: element('Select'),
29
29
  Image: element('Image'),
30
- } as unknown as ElementTable
30
+ Svg: element('Svg'),
31
+ Raster: element('Raster'),
32
+ Client: element('Client'),
33
+ }
31
34
 
32
35
  export function findElement(node: RenderNode, type: string, key: string): Drawn['props'] | undefined {
33
36
  if (typeof node === 'string') return undefined
@@ -41,11 +44,12 @@ export function findElement(node: RenderNode, type: string, key: string): Drawn[
41
44
  }
42
45
 
43
46
  export function rowsOf(node: RenderNode, width: number): string[] {
44
- if (typeof node !== 'string' && insideOf(node).type === 'Box') return boxRows(insideOf(node), width)
45
- const wrap = typeof node === 'string' ? undefined : insideOf(node).props['wrap']
47
+ const drawn = typeof node === 'string' ? undefined : insideOf(node)
48
+ if (drawn?.type === 'Box') return boxRows(drawn, width)
49
+ if (drawn?.type === 'Svg') return []
46
50
  return textOf(node)
47
51
  .split('\n')
48
- .flatMap((line) => fitted(line, width, wrap))
52
+ .flatMap((line) => fitted(line, width, drawn?.props['wrap']))
49
53
  }
50
54
 
51
55
  export function textOf(node: RenderNode): string {
@@ -89,11 +93,50 @@ function boxRows({ props, children = [] }: Drawn, width: number): string[] {
89
93
  const size = (key: string) => (typeof props[key] === 'number' ? props[key] : undefined)
90
94
  const space = (side: string, axis: string) => (size(`padding${side}`) ?? size(`padding${axis}`) ?? size('padding') ?? 0) + (size(`margin${side}`) ?? size(`margin${axis}`) ?? size('margin') ?? 0)
91
95
  const left = space('Left', 'X')
96
+ const right = space('Right', 'X')
97
+ const border = typeof props['borderStyle'] === 'string' ? 1 : 0
92
98
  const direction = String(props['flexDirection'] ?? 'row')
93
- const ordered = direction.endsWith('-reverse') ? [...children].reverse() : children
94
- const inner = Math.min(width, size('width') ?? width) - left - space('Right', 'X')
99
+ const flowing = children.filter((child) => !isPlaced(child))
100
+ const ordered = direction.endsWith('-reverse') ? [...flowing].reverse() : flowing
101
+ const outer = Math.min(width, size('width') ?? width)
102
+ const inner = outer - left - right - border * 2
95
103
  const rows = direction.startsWith('column') ? stacked(ordered, inner, size('rowGap') ?? size('gap') ?? 0) : sideBySide(ordered, inner, size('columnGap') ?? size('gap') ?? 0, props['justifyContent'] === 'space-between')
96
- return [...blankRows(space('Top', 'Y')), ...rows.map((row) => paddedTo(`${' '.repeat(left)}${row}`, size('minWidth') ?? 0)), ...blankRows(space('Bottom', 'Y'))]
104
+ const content = border === 1 && Number.isFinite(inner) ? inner : right === 0 && border === 0 ? 0 : Math.max(widthOf(rows), (size('minWidth') ?? 0) - left - right - border * 2)
105
+ const padded = [...blankRows(space('Top', 'Y')), ...rows.map((row) => `${' '.repeat(left)}${paddedTo(row, content)}${' '.repeat(right)}`), ...blankRows(space('Bottom', 'Y'))]
106
+ const framed = border === 0 ? padded : framedRows(padded, left + content + right, props['borderStyle'])
107
+ return placedOver(
108
+ framed.map((row) => paddedTo(row, size('minWidth') ?? 0)),
109
+ children.filter(isPlaced),
110
+ outer,
111
+ )
112
+ }
113
+
114
+ function isPlaced(node: RenderNode): node is RenderElement {
115
+ if (typeof node === 'string') return false
116
+ const { props } = insideOf(node)
117
+ return props['position'] === 'absolute' && typeof props['top'] === 'number'
118
+ }
119
+
120
+ function framedRows(rows: readonly string[], columns: number, style: unknown): string[] {
121
+ const [topLeft, topRight, bottomLeft, bottomRight] = style === 'round' ? ['╭', '╮', '╰', '╯'] : ['┌', '┐', '└', '┘']
122
+ return [`${topLeft}${'─'.repeat(columns)}${topRight}`, ...rows.map((row) => `│${paddedTo(row, columns)}│`), `${bottomLeft}${'─'.repeat(columns)}${bottomRight}`]
123
+ }
124
+
125
+ function placedOver(rows: readonly string[], placed: readonly RenderElement[], width: number): string[] {
126
+ const drawn = [...rows]
127
+ for (const node of placed) {
128
+ const { props } = insideOf(node)
129
+ const top = Number(props['top'])
130
+ const left = typeof props['left'] === 'number' ? props['left'] : 0
131
+ rowsOf(node, width - left).forEach((line, index) => {
132
+ const row = top + index
133
+ if (row < 0 || row >= drawn.length) return
134
+ const cells = [...paddedTo(drawn[row] ?? '', left)]
135
+ cells.splice(left, [...line].length, ...line)
136
+ drawn[row] = cells.join('')
137
+ })
138
+ }
139
+ return drawn
97
140
  }
98
141
 
99
142
  function stacked(children: readonly RenderNode[], width: number, gap: number): string[] {
@@ -1,4 +1,5 @@
1
1
  import { isObject } from '../records.js'
2
+ import { AttachedFile, attachmentNameOf, type AttachmentType } from '../session/context/turn/message/attached-file.js'
2
3
  import { InstructionFile } from '../session/context/turn/message/instruction-file.js'
3
4
  import { Message, messagesOf, noFiles, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
4
5
  import { Note } from '../session/context/turn/message/note.js'
@@ -7,12 +8,12 @@ import { Prompt } from '../session/context/turn/message/prompt.js'
7
8
  import { Reply } from '../session/context/turn/message/reply.js'
8
9
  import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
9
10
  import { GoalChange } from '../session/goal/goal.js'
10
- import { Task } from '../session/goal/task.js'
11
+ import { Task, TaskStart } from '../session/goal/task.js'
11
12
  import { Skill } from '../session/context/turn/message/skill.js'
12
13
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
14
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
- import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
15
- import type { Place } from '../runtime/before.js'
15
+ import { createConversation, emptyConversation, searchLimit, tasksWithAgents, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
16
+ import type { Decide, Place } from '../runtime/before.js'
16
17
  import type { Claude } from '../runtime/claude.js'
17
18
  import { conversationMethods, everySession, isHeardBy, resultOf, type Listened } from '../runtime/sessions.js'
18
19
  import { receivedMatcher } from '../utils/matcher.js'
@@ -26,6 +27,7 @@ export type MessageDraft =
26
27
  | { readonly type: typeof ToolResult; readonly callId: string; readonly output: string; readonly isError?: boolean }
27
28
  | { readonly type: typeof Skill; readonly name: string; readonly text: string }
28
29
  | { readonly type: typeof InstructionFile; readonly path: string; readonly text: string }
30
+ | { readonly type: typeof AttachedFile; readonly path: string; readonly attachmentType: AttachmentType; readonly text: string }
29
31
  | { readonly type: typeof Notice; readonly text: string }
30
32
 
31
33
  export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
@@ -45,6 +47,8 @@ type Kept = {
45
47
 
46
48
  type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
47
49
 
50
+ export type StartedTask = { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string; readonly isBackground?: true; readonly forkedFrom?: string }
51
+
48
52
 
49
53
  export type FakeSessions = {
50
54
  answer(caller: string, method: string, input: unknown): unknown
@@ -52,15 +56,15 @@ export type FakeSessions = {
52
56
  draft(draft: MessageDraft, task?: string): Message
53
57
  place(): Place
54
58
  wants(mod: string, event: string): boolean
55
- endTurn(ending?: TurnEnding): void
59
+ endTurn(ending?: TurnEnding, task?: string): void
56
60
  compact(): void
57
- startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): string
61
+ startTask(task: StartedTask): string
58
62
  start(id: string): void
59
63
  lastTurn(): TurnData | undefined
60
64
  readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
61
65
  }
62
66
 
63
- export function fakeSessions({ project, cwd, given, push, callTool }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call'] }): FakeSessions {
67
+ export function fakeSessions({ project, cwd, given, push, callTool, listAgents, decide }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call']; readonly listAgents: Claude['agent']['list']; readonly decide: Decide }): FakeSessions {
64
68
  const kept = new Map<string, Kept>()
65
69
  const heard = new Map<string, Map<string, readonly Heard[]>>()
66
70
  const watches = new Map<string, Map<string, readonly string[]>>()
@@ -96,6 +100,14 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
96
100
  return session.messages.slice(before)
97
101
  }
98
102
 
103
+ const openTask = (session: Kept, { title, agentType, model, openedBy = '', parent, isBackground, forkedFrom }: StartedTask): TaskData => {
104
+ const owner = forkedFrom !== undefined ? { forkedFrom } : isBackground === true ? (agentType === undefined ? {} : { agentType }) : { agentType: agentType ?? 'general-purpose' }
105
+ const data: TaskData = { id: `task-${(ids += 1)}`, title, ...owner, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }), ...(isBackground === true ? { isBackground } : {}) }
106
+ if (isBackground === true) keep({ id: data.id, title, project: session.data.project, cwd: session.data.cwd, model: model ?? session.data.model, startedAt: data.openedAt, lastAt: data.openedAt, isRunning: true, ...owner })
107
+ record(session, session.conversation.startTask(data))
108
+ return data
109
+ }
110
+
99
111
  for (const each of given) {
100
112
  const session = keep({ title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: '', isRunning: false, ...each })
101
113
  for (const draft of each.messages ?? []) add(session, draft)
@@ -115,6 +127,8 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
115
127
  return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
116
128
  }
117
129
 
130
+ const wantsBefore = (mod: string, event: string) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event))
131
+
118
132
  const taskOf = (session: Kept, id: string) => {
119
133
  const task = session.tasks.get(id)
120
134
  if (task === undefined) throw new Error(`Session ${session.data.id} has no task ${id}. Start it with tested.startTask.`)
@@ -172,7 +186,11 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
172
186
  const task = taskOf(session, request['task'])
173
187
  return [{ number: session.conversation.state.tasks[task.id]?.context ?? 1, openedAt: task.openedAt, ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }) }]
174
188
  },
175
- [conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task']),
189
+ async [conversationMethods.tasks](request) {
190
+ const session = String(request['session'])
191
+ const tasks = [...sessionOf(session).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task'])
192
+ return session === current ? tasksWithAgents(tasks, await listAgents()) : tasks
193
+ },
176
194
  [conversationMethods.goal](request) {
177
195
  const session = sessionOf(String(request['session']))
178
196
  const task = request['task']
@@ -196,7 +214,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
196
214
  watches.set(caller, targets)
197
215
  return null
198
216
  },
199
- async [conversationMethods.act](request) {
217
+ async [conversationMethods.act](request, caller) {
200
218
  const session = sessionOf(String(request['session']))
201
219
  const action = String(request['action'])
202
220
  const input = isObject(request['input']) ? request['input'] : {}
@@ -214,11 +232,24 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
214
232
  sent.push({ session: session.data.id, ...(task === undefined ? {} : { task }), type: String(input['type']), text: String(input['text']), ...(typeof input['as'] === 'string' ? { as: input['as'] } : {}) })
215
233
  return null
216
234
  }
217
- if (action === 'startTask') {
218
- const id = `task-${(ids += 1)}`
219
- const data: TaskData = { id, title: typeof input['title'] === 'string' ? input['title'] : '', agentType: typeof input['agentType'] === 'string' ? input['agentType'] : 'general-purpose', openedBy: '', openedAt: now(), ...(typeof input['model'] === 'string' ? { model: input['model'] } : {}) }
220
- record(session, session.conversation.startTask(data))
221
- return data
235
+ const text = (key: string) => (typeof input[key] === 'string' ? input[key] : undefined)
236
+ const title = text('title') ?? ''
237
+ if (action === 'startTask' || action === 'fork' || action === 'startBackgroundTask' || action === 'clone') {
238
+ if (title.trim() === '') throw new Error('a task needs a title.')
239
+ const isClone = action === 'clone'
240
+ const isBackground = isClone || action === 'startBackgroundTask'
241
+ const forkedFrom = isClone || action === 'fork' ? session.data.id : undefined
242
+ const agentType = forkedFrom === undefined ? text('agentType') : undefined
243
+ const model = forkedFrom === undefined ? text('model') : undefined
244
+ const fresh = { ...(agentType === undefined ? {} : { agentType }), ...(model === undefined ? {} : { model }), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
245
+ if (!isBackground) return openTask(session, { title, ...fresh })
246
+ if (input['name'] !== undefined) throw new Error('a background task takes no name.')
247
+ const asked: TaskData = { id: '', title, ...fresh, openedBy: '', openedAt: now(), isBackground: true }
248
+ const answer = wantsBefore(caller, TaskStart) ? await decide(TaskStart, { type: TaskStart, task: { ...asked, context: session.conversation.state.context } }) : undefined
249
+ if (answer?.refuse !== undefined) throw new Error(`a beforeTaskStart listener refused the task: ${answer.refuse}`)
250
+ const owner = forkedFrom === undefined ? (answer?.owner ?? {}) : {}
251
+ const chosen = { ...(owner.agentType === undefined ? {} : { agentType: owner.agentType }), ...(owner.model === undefined ? {} : { model: owner.model }) }
252
+ return openTask(session, { ...fresh, ...chosen, title: answer?.title ?? title, isBackground: true })
222
253
  }
223
254
  if (session.data.id === current) return resultOf(place(), { name: String(input['name']), input: input['input'] as Readonly<Record<string, unknown>>, cwd: project() }, await callTool({ ...input['input'] as object, tool: String(input['name']) } as never))
224
255
  return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', name: '', input: {}, cwd: project(), files: noFiles, output: '', isError: false }
@@ -242,7 +273,10 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
242
273
  return { deny: messageOf(error) }
243
274
  }
244
275
  },
245
- add: (draft, task) => add(currentSession(), draft, task),
276
+ add(draft, task) {
277
+ const session = currentSession()
278
+ return task !== undefined && session.tasks.get(task)?.isBackground === true ? add(sessionOf(task), draft) : add(session, draft, task)
279
+ },
246
280
  draft(draft, task) {
247
281
  const where = place()
248
282
  const [unplaced] = messagesOf(rowOf(draft, `message-${(ids += 1)}`, now(), cwd()), task !== undefined)
@@ -252,21 +286,23 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
252
286
  return { ...unplaced, ...call, session: where.session, context: task === undefined ? where.context : (where.tasks[task]?.context ?? where.context), ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) }
253
287
  },
254
288
  place,
255
- wants: (mod, event) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event)),
256
- endTurn(ending = {}) {
289
+ wants: wantsBefore,
290
+ endTurn(ending = {}, task) {
257
291
  const session = currentSession()
258
- record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }))
292
+ const background = task === undefined ? undefined : session.tasks.get(task)
293
+ if (background?.isBackground !== true) {
294
+ record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }, task))
295
+ return
296
+ }
297
+ const owner = sessionOf(background.id)
298
+ record(owner, owner.conversation.endTurn(now(), { reason: 'answer', ...ending }))
299
+ record(session, session.conversation.endTask(background, now()))
259
300
  },
260
301
  compact() {
261
302
  const session = currentSession()
262
303
  record(session, session.conversation.add({ id: `compact-${(ids += 1)}`, at: now(), type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }))
263
304
  },
264
- startTask({ title, agentType = 'general-purpose', model, openedBy = '', parent }) {
265
- const session = currentSession()
266
- const id = `task-${(ids += 1)}`
267
- record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }) }))
268
- return id
269
- },
305
+ startTask: (task) => openTask(currentSession(), task).id,
270
306
  start(id) {
271
307
  current = id
272
308
  const session = kept.get(id) ?? keep({ id, title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: now(), isRunning: true })
@@ -289,5 +325,6 @@ function rowOf(draft: MessageDraft, id: string, at: string, cwd: string): Row {
289
325
  if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.output, ...(draft.isError === true ? { is_error: true } : {}) }] }
290
326
  if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
291
327
  if (draft.type === InstructionFile) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
328
+ if (draft.type === AttachedFile) return { ...base, type: 'attachment', name: attachmentNameOf(draft.attachmentType), content: text(draft.text), path: draft.path }
292
329
  return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
293
330
  }
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'
@@ -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'>>
@@ -191,33 +195,61 @@ export function testMod<State extends object, Declared extends Options = Options
191
195
  await tellShown()
192
196
  }
193
197
  let widgets: Promise<Widgets> | undefined
198
+ let stylesMarkdown = false
199
+ const ownMarkdown = new Map<string, DrawnPart | null>()
200
+ const missedMarkdown = new Map<string, { readonly markdown: string } & StyledAt>()
194
201
  const updated = async ({ parts, redraw, requests }: PartsUpdate) => {
195
- if (parts !== undefined) declaredPages = pagesOf(parts)
202
+ if (parts !== undefined) {
203
+ declaredPages = pagesOf(parts)
204
+ stylesMarkdown = parts.stylesMarkdown === true
205
+ ownMarkdown.clear()
206
+ }
196
207
  if (parts !== undefined || redraw !== undefined) (await widgetsOf()).update(name, { ...(parts === undefined ? {} : { parts }), ...(redraw === undefined ? {} : { redraw }) })
197
208
  if (requests !== undefined && requests.length > 0) await applyRequests(requests)
198
209
  }
199
210
  const pressPart = (input: PartPress) => callMod(partMethods.press, input).then((update) => updated((update ?? {}) as PartsUpdate))
211
+ const markdownIn = (table: ElementTable, at: StyledAt) => (props: MarkdownProps): RenderElement => {
212
+ const drawnAt = `${at.surface}\u0000${at.columns}\u0000${props.text}`
213
+ const drawn = stylesMarkdown ? ownMarkdown.get(drawnAt) : null
214
+ if (drawn === undefined) missedMarkdown.set(drawnAt, { markdown: props.text, ...at })
215
+ if (drawn === undefined || drawn === null || drawn.tree === null) return table.Markdown(props)
216
+ const { draw, tree } = drawn
217
+ return drawWith(table, () => elementOf(tree, '', (handler, args) => void pressPart({ draw, handler, args }))) as RenderElement
218
+ }
219
+ const withOwnMarkdown = async <Drawn>(draw: () => Promise<Drawn>): Promise<Drawn> => {
220
+ const first = await draw()
221
+ if (missedMarkdown.size === 0) return first
222
+ for (const [drawnAt, input] of missedMarkdown) ownMarkdown.set(drawnAt, (await callMod(partMethods.draw, input)) as DrawnPart | null)
223
+ missedMarkdown.clear()
224
+ return draw()
225
+ }
226
+ const cmodClaude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
227
+ const reportPart = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
228
+ 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
229
  const servedWidgets = async (): Promise<Widgets> => {
201
230
  const own = name === cmodPluginName && lifecycle.mod !== undefined ? engineOf(lifecycle.mod).widgets : undefined
202
231
  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
232
  return serveWidgets({
207
- claude,
233
+ claude: cmodClaude,
208
234
  router: cmodPlugin,
209
235
  store: storeFolder({ HOME: await fake.claude.env.home(), XDG_DATA_HOME: await fake.claude.env.dataHome() }),
210
- report,
236
+ report: reportPart,
211
237
  turnClosedBy: () => conversations.lastTurn(),
212
- parts: drawnParts({ claude, report, press }),
238
+ parts,
239
+ markdownIn,
213
240
  })
214
241
  }
215
242
  const widgetsOf = () => (widgets ??= servedWidgets())
243
+ const cmodBeneath = createRouter()
244
+ const pages = servePages({ claude: cmodClaude, router: cmodBeneath, report: reportPart, widgets: { update: (mod, update) => void widgetsOf().then((served) => served.update(mod, update)) }, parts, tellStylers: () => undefined })
245
+ pages.apply((_mod, requests) => applyRequests(requests))
216
246
  const dependencies = fakeDependencies(name, options.dependencies ?? {}, conversations)
217
247
  fake.fakes.cmod.call = async (call) => {
218
248
  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 }
249
+ const { requests, isAsked, ...listing } = call.input as PartsUpdate
250
+ const isHeld = isAsked === true && requests !== undefined && pages.update(name, { requests, isAsked })
251
+ await updated(isAsked === true ? listing : (call.input as PartsUpdate))
252
+ return { isHeld }
221
253
  }
222
254
  const appendRow = fake.claude.session.append
223
255
  fake.claude.session.append = async (args) => {
@@ -274,39 +306,61 @@ export function testMod<State extends object, Declared extends Options = Options
274
306
  const asked = { to: name, method: conversationMethods.before, input: { session: sessionId, event, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
275
307
  return calledAnswer(cmodPluginName, await lifecycle.route('cmod.call', asked, async () => ({ deny: notInstalled(name) })))
276
308
  }
309
+ 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
310
  installBefore({
278
311
  claude: fake.claude,
279
312
  router: cmodPlugin,
280
313
  hooks: {
281
314
  place: async () => conversations.place(),
282
315
  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' })),
316
+ fold: () => decide,
284
317
  },
285
318
  })
286
319
 
287
320
  const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
288
321
  await start()
289
322
  if (event === 'ui.render') {
323
+ const { component, requestId } = input as Args<'ui.render'>
324
+ if (component === 'Pane') fake.placePane(requestId)
290
325
  await widgetsOf()
291
326
  await fake.settle()
292
327
  }
293
- return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>))
328
+ return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (await claudeCodeRuns(event, e, below)) as EventResult<N>))
329
+ }
330
+
331
+ const claudeCodeRuns = async (event: RoutedEvent, e: unknown, below: unknown): Promise<unknown> => {
332
+ if (event === 'session.compact' && (e as Args<'session.compact'>).trigger !== 'precompute') {
333
+ const started = await fire('SessionStart', { source: 'compact' })
334
+ fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
335
+ }
336
+ if (below !== undefined) return below
337
+ const answer = claudeCodeAnswer(event, e)
338
+ if (event === 'ui.close') {
339
+ const { id, origin } = e as Args<'ui.close'>
340
+ if (origin.kind === 'person' && fake.shown.openPanes.has(id)) await drawPane(id)
341
+ fake.shown.openPanes.delete(id)
342
+ }
343
+ if (event === 'agent.spawn') {
344
+ 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)
345
+ fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
346
+ }
347
+ return answer
294
348
  }
295
349
 
296
350
  const runCommand = async (command: string, args: string): Promise<CommandRunResult> => {
297
351
  await start()
298
352
  const reached: Set<string> = new Set()
299
353
  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))
354
+ 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
355
  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
356
  return answer
303
357
  }
304
358
 
305
- const drawPane = async (paneId: string) => {
359
+ const drawPane = async (paneId: string, asked?: number) => {
306
360
  await start()
307
361
  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
362
  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
363
+ const columns = asked ?? opened?.columns ?? defaultColumns
310
364
  const isFocused = (await fake.claude.ui.panes()).some((open) => open.id === paneId && open.isFocused)
311
365
  const props: RenderPropsOf['Pane'] = { title: opened?.title ?? paneId, isFocused, bodyColumns: columns, placement: 'dock', scroll: { offset: 0, bodyRows: defaultRows }, view: {} }
312
366
  const drawing = await route('ui.render', { surface: 'terminal', component: 'Pane', requestId: paneId, props }, elements.Box({}))
@@ -320,7 +374,7 @@ export function testMod<State extends object, Declared extends Options = Options
320
374
  const drawn = (await callMod(partMethods.draw, { page: pageId, params: page.params, columns, isFocused: page.isFocused })) as DrawnPart | null
321
375
  await fake.settle()
322
376
  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)
377
+ 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
378
  return { drawing, columns }
325
379
  }
326
380
 
@@ -339,7 +393,7 @@ export function testMod<State extends object, Declared extends Options = Options
339
393
  const draw = async (columns?: number) => {
340
394
  await start()
341
395
  const { id } = shownNow(isPane)
342
- return { id, ...(isPane ? await drawPane(id) : await drawPage(id, columns)) }
396
+ return { id, ...(isPane ? await drawPane(id, columns) : await drawPage(id, columns)) }
343
397
  }
344
398
  const drawnElement = async (type: 'Button' | 'Input' | 'Select' | 'Markdown', key: string) => {
345
399
  const { id, drawing } = await draw()
@@ -368,7 +422,7 @@ export function testMod<State extends object, Declared extends Options = Options
368
422
  else await (props['onLinkPress'] as (link: PressedLink, e: Frozen<UiPressArgument>) => unknown)(e.link, e)
369
423
  return { element: e.element }
370
424
  }
371
- if (isPane) await lifecycle.route('ui.press', pressed, run)
425
+ if (isPane) await lifecycle.route('ui.press', pressed, (e) => cmodBeneath.dispatch('ui.press', e, run))
372
426
  else await run(pressed)
373
427
  await fake.settle()
374
428
  },
@@ -397,7 +451,7 @@ export function testMod<State extends object, Declared extends Options = Options
397
451
  await fake.settle()
398
452
  const turn = area === TurnEndArea ? conversations.lastTurn() : undefined
399
453
  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 }
454
+ return { drawing: elements.Box({ flexDirection: 'column', children: [...(await withOwnMarkdown(() => served.draw(area, elements, { surface: 'terminal', columns }, turn)))] }), columns }
401
455
  }
402
456
 
403
457
  const drawSlot = async (component: RenderComponent, props: object, requestId = `${component}_${(slotDraws += 1)}`) => {
@@ -500,9 +554,9 @@ export function testMod<State extends object, Declared extends Options = Options
500
554
  await start()
501
555
  return settled(conversations.add(message, added?.task))
502
556
  },
503
- endTurn: async (ending) => {
557
+ endTurn: async (ending, options) => {
504
558
  await start()
505
- await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }))
559
+ await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }, options?.task))
506
560
  },
507
561
  compact: async () => {
508
562
  await start()
@@ -617,6 +671,10 @@ function claudeCodeAnswer(event: RoutedEvent, e: unknown): unknown {
617
671
  const { model, parentModel } = e as Args<'agent.spawn'>
618
672
  return { agentId: 'agent-1', model: model ?? parentModel }
619
673
  }
674
+ if (event === 'session.append') {
675
+ const { message, uuid } = e as Args<'session.append'>
676
+ return { message, uuid }
677
+ }
620
678
  if (event !== 'prompt.submit') return {}
621
679
  const { text, context, origin } = e as Args<'prompt.submit'>
622
680
  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 }