@cmodjs/core 0.3.0 → 1.0.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 (271) hide show
  1. package/docs/commands.md +50 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +39 -27
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +65 -22
  13. package/docs/ui.md +108 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +8 -3
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +17 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -2
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -26
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +316 -185
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +17 -5
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +4 -2
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -24
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +312 -180
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +22 -16
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +9 -8
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -1
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -11
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.js +9 -7
  262. package/utils/paths.d.ts +2 -0
  263. package/utils/paths.d.ts.map +1 -1
  264. package/utils/paths.js +13 -1
  265. package/utils/text.d.ts +1 -0
  266. package/utils/text.d.ts.map +1 -1
  267. package/utils/text.js +3 -0
  268. package/src/ui/slots.ts +0 -36
  269. package/ui/slots.d.ts +0 -208
  270. package/ui/slots.d.ts.map +0 -1
  271. package/ui/slots.js +0 -19
@@ -0,0 +1,203 @@
1
+ import { isObject } from '../records.js'
2
+ import { Document } from '../session/context/turn/message/document.js'
3
+ import { Message, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
4
+ import { Note } from '../session/context/turn/message/note.js'
5
+ import { Notice } from '../session/context/turn/message/notice.js'
6
+ import { Prompt } from '../session/context/turn/message/prompt.js'
7
+ import { Reply } from '../session/context/turn/message/reply.js'
8
+ import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
9
+ import { Task } from '../session/goal/task.js'
10
+ import { Skill } from '../session/context/turn/message/skill.js'
11
+ import { ToolCall } from '../session/context/turn/message/tool-call.js'
12
+ import { ToolResult } from '../session/context/turn/message/tool-result.js'
13
+ import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
14
+ import { conversationMethods, isHeardBy, type Subscription } from '../runtime/sessions.js'
15
+ import { receivedMatcher } from '../utils/matcher.js'
16
+ import { firstLine, messageOf } from '../utils/text.js'
17
+
18
+ export type MessageDraft =
19
+ | { readonly type: typeof Prompt; readonly text: string; readonly from?: Sender }
20
+ | { readonly type: typeof Note; readonly text: string; readonly from?: Sender }
21
+ | { readonly type: typeof Reply; readonly text: string }
22
+ | { readonly type: typeof ToolCall; readonly name: string; readonly input?: Readonly<Record<string, unknown>>; readonly callId?: string }
23
+ | { readonly type: typeof ToolResult; readonly callId: string; readonly text: string; readonly isError?: boolean }
24
+ | { readonly type: typeof Skill; readonly name: string; readonly text: string }
25
+ | { readonly type: typeof Document; readonly path: string; readonly text: string }
26
+ | { readonly type: typeof Notice; readonly text: string }
27
+
28
+ export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
29
+ readonly id: string
30
+ readonly messages?: readonly MessageDraft[]
31
+ readonly goal?: string
32
+ }
33
+
34
+ type Kept = {
35
+ data: SessionData
36
+ readonly conversation: Conversation
37
+ readonly messages: Message[]
38
+ readonly turns: Map<string, TurnData>
39
+ readonly contexts: Map<number, ContextData>
40
+ readonly tasks: Map<string, TaskData>
41
+ }
42
+
43
+ type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
44
+
45
+
46
+ export type FakeSessions = {
47
+ answer(caller: string, method: string, input: unknown): unknown
48
+ add(draft: MessageDraft, task?: string): Message[]
49
+ endTurn(): void
50
+ compact(): void
51
+ startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): string
52
+ start(id: string): void
53
+ readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
54
+ }
55
+
56
+ export function fakeSessions({ project, given, push }: { readonly project: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void }): FakeSessions {
57
+ const kept = new Map<string, Kept>()
58
+ const heard = new Map<string, Map<string, readonly Heard[]>>()
59
+ const sent: FakeSessions['sent'] = []
60
+ let current = ''
61
+ let clock = Date.parse('2026-01-01T00:00:00.000Z')
62
+ let ids = 0
63
+ const now = () => new Date((clock += 1000)).toISOString()
64
+
65
+ const keep = (data: SessionData): Kept => {
66
+ const session: Kept = { data, conversation: createConversation(emptyConversation(data.id)), messages: [], turns: new Map(), contexts: new Map(), tasks: new Map() }
67
+ kept.set(data.id, session)
68
+ return session
69
+ }
70
+
71
+ const record = (session: Kept, changes: readonly SessionChange[]) => {
72
+ for (const change of changes) {
73
+ if (change.type === Message) session.messages.push(change.message)
74
+ if ('turn' in change) session.turns.set(change.turn.prompt.id, change.turn)
75
+ if ('context' in change) session.contexts.set(change.context.number, change.context)
76
+ if ('task' in change) session.tasks.set(change.task.id, change.task)
77
+ session.data = { ...session.data, lastAt: change.type === Message ? change.message.at : session.data.lastAt, ...(session.data.title === '' && change.type === Message && change.message.type === Prompt && change.message.from.type === Person ? { title: firstLine(change.message.text) } : {}) }
78
+ for (const [mod, sessions] of heard) {
79
+ if ((sessions.get(session.data.id) ?? []).some(({ event, matcher }) => isHeardBy(event, matcher, change))) push(mod, session.data.id, change)
80
+ }
81
+ }
82
+ }
83
+
84
+ const add = (session: Kept, draft: MessageDraft, task?: string) => {
85
+ const before = session.messages.length
86
+ record(session, session.conversation.add(rowOf(draft, `message-${(ids += 1)}`, now()), task))
87
+ return session.messages.slice(before)
88
+ }
89
+
90
+ for (const each of given) {
91
+ const session = keep({ title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: '', isRunning: false, ...each })
92
+ for (const draft of each.messages ?? []) add(session, draft)
93
+ if (each.goal !== undefined) record(session, session.conversation.changeGoal(each.goal))
94
+ }
95
+
96
+ const sessionOf = (id: string) => {
97
+ const session = kept.get(id)
98
+ if (session === undefined) throw new Error(`No Claude Code transcript is named ${id}.jsonl. Give it to testMod as { sessions: [{ id: '${id}' }] }.`)
99
+ return session
100
+ }
101
+
102
+ const currentSession = () => sessionOf(current)
103
+
104
+ const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
105
+
106
+ const isInScope = (session: Kept, request: Readonly<Record<string, unknown>>) =>
107
+ (request['everyProject'] === true || session.data.project === project()) && session.data.lastAt >= (typeof request['since'] === 'string' ? request['since'] : '') && (request['running'] !== true || session.data.isRunning)
108
+
109
+ const methods: Readonly<Record<string, (request: Readonly<Record<string, unknown>>, caller: string) => unknown>> = {
110
+ [conversationMethods.sessions]: (request) =>
111
+ [...kept.values()]
112
+ .filter((session) => isInScope(session, request))
113
+ .map((session) => session.data)
114
+ .toSorted((first, second) => second.lastAt.localeCompare(first.lastAt)),
115
+ [conversationMethods.search](request) {
116
+ const text = String(request['text'])
117
+ if ([...text].length < 3) throw new Error('cmod history search takes at least 3 characters.')
118
+ const found = [...kept.values()]
119
+ .filter((session) => isInScope(session, request))
120
+ .flatMap((session) => session.messages)
121
+ .filter((message) => searchText(message).toLowerCase().includes(text.toLowerCase()))
122
+ .toSorted((first, second) => second.at.localeCompare(first.at))
123
+ const before = isObject(request['before']) ? request['before'] : undefined
124
+ const start = before === undefined ? 0 : found.findIndex((message) => message.session === before['session'] && message.id === before['id']) + 1
125
+ return found.slice(start, start + (typeof request['limit'] === 'number' ? request['limit'] : searchLimit))
126
+ },
127
+ [conversationMethods.messages](request) {
128
+ const session = sessionOf(String(request['session']))
129
+ const fits = session.messages.filter((message) => message.task === request['task'] && (request['turn'] === undefined || message.turn === request['turn']))
130
+ const after = typeof request['after'] === 'string' ? fits.findIndex((message) => message.id === request['after']) + 1 : 0
131
+ return lastOf(fits.slice(after), request['last'])
132
+ },
133
+ [conversationMethods.turns]: (request) => lastOf([...sessionOf(String(request['session'])).turns.values()].filter((turn) => request['context'] === undefined || turn.context === request['context']), request['last']),
134
+ [conversationMethods.contexts]: (request) => [...sessionOf(String(request['session'])).contexts.values()],
135
+ [conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => task.parent === request['task']),
136
+ [conversationMethods.goal]: (request) => ({ text: sessionOf(String(request['session'])).conversation.state.goal ?? '' }),
137
+ [conversationMethods.updateGoal](request) {
138
+ const session = sessionOf(String(request['session']))
139
+ record(session, session.conversation.changeGoal(String(request['text'])))
140
+ return null
141
+ },
142
+ [conversationMethods.subscribe](request, caller) {
143
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
144
+ const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
145
+ sessions.set(String(request['session']), list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
146
+ heard.set(caller, sessions)
147
+ return null
148
+ },
149
+ [conversationMethods.send](request) {
150
+ sent.push({ session: String(request['session']), type: String(request['type']), text: String(request['text']), ...(typeof request['as'] === 'string' ? { as: request['as'] } : {}) })
151
+ return null
152
+ },
153
+ }
154
+
155
+ return {
156
+ answer(caller, method, input) {
157
+ const run = methods[method]
158
+ if (run === undefined) return undefined
159
+ try {
160
+ return { value: run(isObject(input) ? input : {}, caller) }
161
+ } catch (error) {
162
+ return { deny: messageOf(error) }
163
+ }
164
+ },
165
+ add: (draft, task) => add(currentSession(), draft, task),
166
+ endTurn() {
167
+ const session = currentSession()
168
+ record(session, session.conversation.add({ id: `turn-end-${(ids += 1)}`, at: now(), type: 'system', name: 'turn_duration', isMeta: false, isSummary: false, content: [] }))
169
+ },
170
+ compact() {
171
+ const session = currentSession()
172
+ const at = now()
173
+ record(session, [...session.conversation.endContext(at), ...session.conversation.startContext(at)])
174
+ },
175
+ startTask({ title, agentType = 'general-purpose', model, openedBy = '' }) {
176
+ const session = currentSession()
177
+ const id = `task-${(ids += 1)}`
178
+ record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }) }))
179
+ return id
180
+ },
181
+ start(id) {
182
+ current = id
183
+ const session = kept.get(id) ?? keep({ id, title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: now(), isRunning: true })
184
+ session.data = { ...session.data, isRunning: true }
185
+ if (session.conversation.state.context === 0) record(session, session.conversation.startContext(now()))
186
+ },
187
+ sent,
188
+ }
189
+ }
190
+
191
+ function rowOf(draft: MessageDraft, id: string, at: string): Row {
192
+ const base = { id, at, isMeta: false, isSummary: false }
193
+ const text = (value: string) => [{ type: 'text', text: value }]
194
+ const origin = (from: Sender | undefined) => (from === undefined || from.type === Person ? { kind: 'human' } : from.type === Mod ? { kind: 'plugin', name: from.name, ...(from.as === Person ? { asUser: true } : {}) } : from.type === 'session' ? { kind: 'peer' } : from.type === Task ? { kind: 'task-notification' } : { kind: from.type === Schedule ? 'scheduled-trigger' : from.type, ...(from.name === undefined ? {} : { server: from.name }) })
195
+ if (draft.type === Prompt) return { ...base, type: 'user', content: text(draft.text), origin: origin(draft.from) }
196
+ if (draft.type === Note) return { ...base, type: 'user', isMeta: true, content: text(draft.text), origin: origin(draft.from ?? { type: Mod, name: 'test' }) }
197
+ if (draft.type === Reply) return { ...base, type: 'assistant', content: text(draft.text) }
198
+ if (draft.type === ToolCall) return { ...base, type: 'assistant', content: [{ type: 'tool_use', id: draft.callId ?? `toolu_${id}`, name: draft.name, input: draft.input ?? {} }], cwd: '/test/project' }
199
+ if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.text, ...(draft.isError === true ? { is_error: true } : {}) }] }
200
+ if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
201
+ if (draft.type === Document) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
202
+ return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
203
+ }
package/src/testing.ts CHANGED
@@ -17,23 +17,35 @@ import type {
17
17
  UiPressArgument,
18
18
  UiSelectArgument,
19
19
  } from 'claude-code'
20
- import type { Reply } from './jobs/slash-command.js'
21
- import type { ModDefinition, ModEvent } from './mod.js'
20
+ import type { CommandReply } from './jobs/slash-command.js'
21
+ import type { Hook, ModDefinition } from './mod.js'
22
22
  import type { Options, OptionValues } from './options.js'
23
23
  import { readSteps, storeFolder, type Steps } from './records.js'
24
24
  import type { Claude } from './runtime/claude.js'
25
- import { answerCall, notInstalled } from './runtime/dependencies.js'
25
+ import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
26
  import type { RoutedEvent } from './runtime/hooks.js'
27
27
  import { toolInputOf } from './runtime/tool-calls.js'
28
28
  import { createLifecycle } from './runtime/lifecycle.js'
29
29
  import { MissingOptions } from './runtime/options.js'
30
- import { fakeClaude, type Fakes, type Shown, type TestCall } from './testing/fake-claude.js'
30
+ import { conversationMethods } from './runtime/sessions.js'
31
+ import type { ShownToast } from './runtime/toaster.js'
32
+ import type { Message } from './session/context/turn/message/message.js'
33
+ import { Note } from './session/context/turn/message/note.js'
34
+ import { Prompt } from './session/context/turn/message/prompt.js'
35
+ import { Mod, Person } from './session/context/turn/message/sender.js'
36
+ import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
31
37
  import { elements, findElement, rowsOf } from './testing/fake-elements.js'
32
38
  import { fakeFiles } from './testing/fake-files.js'
39
+ import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
33
40
  import { listed } from './utils/text.js'
34
- import type { Slot, SlotProps } from './ui/slots.js'
41
+ import { componentOf, type ComponentPlace, type PlaceProps } from './ui/places.js'
35
42
 
36
- export type { Fakes, Shown, TestCall } from './testing/fake-claude.js'
43
+ export type { Fakes, TestCall } from './testing/fake-claude.js'
44
+ export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
45
+ export type { RoutedEvent } from './runtime/hooks.js'
46
+ export type { ShownToast } from './runtime/toaster.js'
47
+
48
+ export type Shown = ShownByClaude & { readonly toasts: readonly ShownToast[]; readonly sent: FakeSessions['sent'] }
37
49
 
38
50
  export type TestOptions<State extends object, Declared extends Options = Options> = {
39
51
  readonly state?: { readonly [Lifetime in keyof State]?: Partial<State[Lifetime]> }
@@ -45,23 +57,28 @@ export type TestOptions<State extends object, Declared extends Options = Options
45
57
  readonly dependencies?: { readonly [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
46
58
  readonly files?: Readonly<Record<string, string>>
47
59
  readonly links?: Readonly<Record<string, string>>
60
+ readonly sessions?: readonly FakeSession[]
48
61
  }
49
62
 
50
63
  type FilledField = 'session_id' | 'transcript_path' | 'cwd' | 'hook_event_name' | 'tool_use_id'
51
64
 
52
- export type TestInput<E extends ModEvent> = Omit<ClassicHookInputs[E], FilledField> & Partial<Pick<ClassicHookInputs[E], FilledField & keyof ClassicHookInputs[E]>>
65
+ export type TestInput<H extends Hook> = Omit<ClassicHookInputs[H], FilledField> & Partial<Pick<ClassicHookInputs[H], FilledField & keyof ClassicHookInputs[H]>>
53
66
 
54
67
  export type TestedMod<State extends object> = {
55
68
  start(): Promise<void>
56
- fire<E extends ModEvent>(event: E, input: TestInput<E>, below?: EventResult<`classic.${E}`>): Promise<EventResult<`classic.${E}`>>
69
+ fire<H extends Hook>(hook: H, input: TestInput<H>, below?: EventResult<`classic.${H}`>): Promise<EventResult<`classic.${H}`>>
57
70
  fire<N extends RoutedEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
58
71
  settle(): Promise<void>
72
+ add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
73
+ endTurn(): Promise<void>
74
+ compact(): Promise<void>
75
+ startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): Promise<string>
59
76
  lines(paneId: string): Promise<string[]>
60
- lines<S extends Slot<object, object, { readonly component: RenderComponent }>>(slot: S, props: Omit<SlotProps<S>, 'Default'>, requestId?: string): Promise<string[]>
61
- type(line: string): Promise<Reply>
77
+ lines<P extends ComponentPlace>(place: P, props: Omit<PlaceProps<P>, 'Default'>, requestId?: string): Promise<string[]>
78
+ type(line: string): Promise<CommandReply>
62
79
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
63
80
  press(paneId: string, key: string, link?: string): Promise<void>
64
- input(paneId: string, key: string, text: string, kind?: UiInputArgument['kind']): Promise<void>
81
+ input(paneId: string, key: string, text: string, type?: UiInputArgument['kind']): Promise<void>
65
82
  select(paneId: string, key: string, value: string): Promise<void>
66
83
  moveTo(projectRoot: string, cwd?: string): Promise<void>
67
84
  readonly state: Readonly<State>
@@ -76,7 +93,7 @@ const defaultRows = 24
76
93
 
77
94
  const presentation: CommandPresentation = { isFullscreen: false, columns: defaultColumns }
78
95
 
79
- const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly ModEvent[]
96
+ const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly Hook[]
80
97
 
81
98
  export function testMod<State extends object, Declared extends Options = Options>(definition: ModDefinition<State, string, Declared>, options: TestOptions<State, Declared> = {}): TestedMod<State> {
82
99
  installJsx()
@@ -93,14 +110,37 @@ export function testMod<State extends object, Declared extends Options = Options
93
110
  fake.claude.session.id = async () => sessionId
94
111
  fake.claude.session.root = async () => projectRoot
95
112
  fake.claude.session.cwd = async () => cwd
96
- fake.fakes.cmod.call = fakeDependencies(options.dependencies ?? {})
113
+ const conversations = fakeSessions({
114
+ project: () => projectRoot,
115
+ given: options.sessions ?? [],
116
+ push: (mod, session, change) => {
117
+ if (mod !== name) return
118
+ const pushed = { to: mod, method: conversationMethods.changed, input: { session, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
119
+ void lifecycle.route('cmod.call', pushed, async () => ({ deny: notInstalled(mod) }))
120
+ },
121
+ })
122
+ conversations.start(sessionId)
123
+ fake.fakes.cmod.call = fakeDependencies(name, options.dependencies ?? {}, conversations)
124
+ const appendRow = fake.claude.session.append
125
+ fake.claude.session.append = async (args) => {
126
+ const added = await appendRow(args)
127
+ if (added.deny === undefined) conversations.add({ type: Note, text: args.message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''), from: { type: Mod, name } })
128
+ return added
129
+ }
130
+ const submitRow = fake.claude.prompt.submit
131
+ fake.claude.prompt.submit = async (args) => {
132
+ const submitted = await submitRow(args)
133
+ conversations.add({ type: Prompt, text: args.text, from: { type: Mod, name, ...(args.asUser === true ? { as: Person } : {}) } })
134
+ return submitted
135
+ }
97
136
  const firedAgents = new Map<string, string>()
98
137
  const listAgents = fake.claude.agent.list
99
138
  fake.claude.agent.list = async () => {
100
139
  if (fake.fakes.agent.list !== undefined || firedAgents.size === 0) return listAgents()
101
140
  return [...firedAgents].map(([id, type]) => ({ id, type, description: '', status: 'running' as const }))
102
141
  }
103
- Object.assign(fake.fakes.fs, fakeFiles(options.files ?? {}, options.links))
142
+ const files = fakeFiles(options.files ?? {}, options.links)
143
+ Object.assign(fake.fakes.fs, files)
104
144
  const starting = options.state as Record<string, object> | undefined
105
145
  const declared = Object.entries(definition.state ?? {}) as [string, object][]
106
146
  const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
@@ -122,6 +162,7 @@ export function testMod<State extends object, Declared extends Options = Options
122
162
  }
123
163
  })
124
164
  await started
165
+ await fake.settle()
125
166
  const { failure } = lifecycle
126
167
  if (failure instanceof MissingOptions) {
127
168
  const given = failure.keys.map((key) => `${key}: …`).join(', ')
@@ -166,14 +207,19 @@ export function testMod<State extends object, Declared extends Options = Options
166
207
 
167
208
  const fire = ((event: string, input: object, below?: unknown) => {
168
209
  if (event.includes('.')) return route(event as RoutedEvent, input, below)
169
- if (event === 'SessionStart') sessionId = (input as { session_id?: string }).session_id ?? sessionId
210
+ if (event === 'SessionStart') {
211
+ sessionId = (input as { session_id?: string }).session_id ?? sessionId
212
+ conversations.start(sessionId)
213
+ }
170
214
  if (event === 'UserPromptSubmit') {
171
215
  cwd = cwdAfterPrompt ?? cwd
172
216
  cwdAfterPrompt = undefined
173
217
  }
174
218
  const toolUse = toolUseEvents.includes(event) ? { tool_use_id: `toolu_${(toolUses += 1)}` } : {}
175
- const filled = { session_id: sessionId, transcript_path: '/test/transcript.jsonl', cwd, hook_event_name: event, ...toolUse, ...input }
176
- if (event !== 'PreToolUse') return route(`classic.${event as Exclude<ModEvent, 'PreToolUse'>}`, filled, below)
219
+ const transcript = `/test/transcripts/${sessionId}.jsonl`
220
+ const filled = { session_id: sessionId, transcript_path: transcript, cwd, hook_event_name: event, ...toolUse, ...input }
221
+ if (event === 'SessionStart' && !('transcript_path' in input)) return files.write(transcript, '').then(() => route('classic.SessionStart', filled, below))
222
+ if (event !== 'PreToolUse') return route(`classic.${event as Exclude<Hook, 'PreToolUse'>}`, filled, below)
177
223
  return firePreToolUse(filled as ClassicHookInputs['PreToolUse'], below as { deny?: string } | undefined)
178
224
  }) as TestedMod<State>['fire']
179
225
 
@@ -187,7 +233,7 @@ export function testMod<State extends object, Declared extends Options = Options
187
233
  const answer = await lifecycle.route('tool.call', envelope, async (e) => {
188
234
  reached = e
189
235
  check = await lifecycle.route('tool.check', { tool: e.tool, input: toolInputOf(e), tool_use_id: e.tool_use_id, ...(e.agentId === undefined ? {} : { agentId: e.agentId }) }, async () => asks)
190
- return (check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }) as EventResult<'tool.call'>
236
+ return check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }
191
237
  })
192
238
  const updatedInput = toolInputOf(reached)
193
239
  const decided = check === asks ? {} : check.decision === 'allow' ? { allow: true } : check.decision === 'ask' ? { ask: check.reason ?? '' } : {}
@@ -198,14 +244,37 @@ export function testMod<State extends object, Declared extends Options = Options
198
244
  } as EventResult<'classic.PreToolUse'>
199
245
  }
200
246
 
247
+ const settled = async <Value>(value: Value) => {
248
+ await fake.settle()
249
+ return value
250
+ }
251
+
201
252
  return {
202
253
  start,
203
254
  fire,
204
255
  settle: fake.settle,
205
- lines: (async (target: string | { readonly component: RenderComponent }, props: object = {}, requestId?: string) => {
206
- const { drawing, columns } = typeof target === 'string' ? await drawPane(target) : await drawSlot(target.component, props, requestId)
256
+ add: async (message, added) => {
257
+ await start()
258
+ return settled(conversations.add(message, added?.task))
259
+ },
260
+ endTurn: async () => {
261
+ await start()
262
+ await settled(conversations.endTurn())
263
+ },
264
+ compact: async () => {
265
+ await start()
266
+ await settled(conversations.compact())
267
+ },
268
+ startTask: async (task) => {
269
+ await start()
270
+ return settled(conversations.startTask(task))
271
+ },
272
+ lines: async (target: string, props?: object, requestId?: string) => {
273
+ const component = componentOf(target as ComponentPlace)
274
+ if (props !== undefined && component === undefined) throw new Error(`lines takes a pane id, or a place render draws and its props, and ${target} is neither a place such as ToolCall nor called without props.`)
275
+ const { drawing, columns } = props === undefined || component === undefined ? await drawPane(target) : await drawSlot(component, props, requestId)
207
276
  return rowsOf(drawing, columns).map((row) => row.trimEnd())
208
- }) as TestedMod<State>['lines'],
277
+ },
209
278
  async type(line) {
210
279
  const typed = /^\/(\S+)\s*(.*)$/s.exec(line)
211
280
  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 /.`)
@@ -230,16 +299,16 @@ export function testMod<State extends object, Declared extends Options = Options
230
299
  const props = await drawnElement(paneId, link === undefined ? 'Button' : 'Markdown', key)
231
300
  if (link !== undefined && props['onLinkPress'] === undefined) throw new Error(`The Markdown "${key}" in the pane "${paneId}" of ${name} has no onLinkPress, so Claude Code opens its links itself.`)
232
301
  const pressed: UiPressArgument = { ...eventIn(paneId, String(props['key'])), ...(link === undefined ? {} : { link: { href: link } }) }
233
- await lifecycle.route('ui.press', pressed as Frozen<Args<'ui.press'>>, async (e) => {
302
+ await lifecycle.route('ui.press', pressed, async (e) => {
234
303
  if (e.link === undefined) await (props['onPress'] as (e: UiPressArgument) => unknown)(e)
235
304
  else await (props['onLinkPress'] as (link: PressedLink, e: UiPressArgument) => unknown)(e.link, e)
236
305
  return { element: e.element }
237
306
  })
238
307
  },
239
- async input(paneId, key, text, kind = 'submit') {
308
+ async input(paneId, key, text, type = 'submit') {
240
309
  const props = await drawnElement(paneId, 'Input', key)
241
- const typed: UiInputArgument = { ...eventIn(paneId, key), kind, value: text }
242
- await (props[kind === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
310
+ const typed: UiInputArgument = { ...eventIn(paneId, key), kind: type, value: text }
311
+ await (props[type === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
243
312
  },
244
313
  async select(paneId, key, value) {
245
314
  const props = await drawnElement(paneId, 'Select', key)
@@ -269,17 +338,24 @@ export function testMod<State extends object, Declared extends Options = Options
269
338
  },
270
339
  calls: fake.calls,
271
340
  fakes: fake.fakes,
272
- shown: fake.shown,
341
+ shown: {
342
+ ...fake.shown,
343
+ get toasts() {
344
+ return lifecycle.toasts
345
+ },
346
+ sent: conversations.sent,
347
+ },
273
348
  }
274
349
  }
275
350
 
276
- function fakeDependencies(dependencies: NonNullable<TestOptions<object>['dependencies']>): Claude['cmod']['call'] {
277
- const apis = dependencies as Readonly<Record<string, Parameters<typeof answerCall>[1] | undefined>>
351
+ function fakeDependencies(caller: string, dependencies: NonNullable<TestOptions<object>['dependencies']>, conversations: FakeSessions): Claude['cmod']['call'] {
352
+ const cmod = { forkParent: () => null, updateSettings: () => null }
353
+ const apis = { [cmodPluginName]: cmod, ...dependencies } as Readonly<Record<string, Parameters<typeof answerCall>[1] | undefined>>
278
354
  return async (call) => {
355
+ const served = call.to === cmodPluginName ? conversations.answer(caller, call.method, call.input) : undefined
356
+ if (served !== undefined) return calledAnswer(caller, served as EventResult<'cmod.call'>)
279
357
  const api = apis[call.to]
280
- const answer = api === undefined ? { deny: notInstalled(call.to) } : await answerCall(call.to, api, call)
281
- if (answer.deny !== undefined) throw new Error(answer.deny)
282
- return answer.value
358
+ return calledAnswer(caller, api === undefined ? { deny: notInstalled(call.to) } : await answerCall(call.to, api, call))
283
359
  }
284
360
  }
285
361
 
@@ -294,7 +370,7 @@ function plainOutput({ component, props }: Args<'ui.render'>): RenderElement {
294
370
  return elements.Box({ flexDirection: 'column', children: [elements.Text({ children: component }), elements.Box({ flexDirection: 'column', paddingLeft: 2, children: rows })] })
295
371
  }
296
372
 
297
- function replyOf({ text, context }: CommandRunResult): Reply {
373
+ function replyOf({ text, context }: CommandRunResult): CommandReply {
298
374
  return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : { context: context.join('\n') }) }
299
375
  }
300
376
 
@@ -302,7 +378,10 @@ async function modSteps(): Promise<Steps> {
302
378
  const manifest = '../../../package.json'
303
379
  const loaded: unknown = await import(manifest, { with: { type: 'json' } }).then(
304
380
  (module: { default: unknown }) => module.default,
305
- () => undefined,
381
+ (error: { readonly code?: unknown }) => {
382
+ if (error.code === 'ERR_MODULE_NOT_FOUND') return undefined
383
+ throw error
384
+ },
306
385
  )
307
386
  return readSteps(loaded) ?? {}
308
387
  }
@@ -49,9 +49,9 @@ export function Pagination({ page, pages, onPage }: { readonly page: number; rea
49
49
  })
50
50
  }
51
51
 
52
- export function ProgressBar({ done, total, width = 24 }: { readonly done: number; readonly total: number; readonly width?: number }): RenderElement {
53
- const filled = total > 0 ? Math.round(Math.min(done / total, 1) * width) : 0
54
- return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) }), ' ', Text({ dimColor: true, children: `${done}/${total}` })] })
52
+ export function Progress({ value, width = 24 }: { readonly value: number; readonly width?: number }): RenderElement {
53
+ const filled = Math.round((Math.min(Math.max(value, 0), 100) / 100) * width)
54
+ return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) })] })
55
55
  }
56
56
 
57
57
  const cardColumns = 36
@@ -6,25 +6,39 @@ type CompletedTable = Record<ElementName, (props: object) => RenderElement>
6
6
 
7
7
  type Drawing = { readonly table: CompletedTable; readonly markdown: ((props: MarkdownProps) => RenderElement) | undefined }
8
8
 
9
+ const unbuilt = Symbol('an element built outside a render')
10
+
11
+ type Unbuilt = { readonly [unbuilt]: { readonly name: ElementName; readonly props: object } }
12
+
9
13
  let drawing: Drawing | undefined
10
14
 
11
15
  export function drawWith<Drawn>(table: ElementTable, draw: () => Drawn, markdown?: (props: MarkdownProps) => RenderElement): Drawn {
12
16
  const outer = drawing
13
- drawing = { table: table as CompletedTable, markdown }
17
+ const current = { table: table as CompletedTable, markdown }
18
+ drawing = current
14
19
  try {
15
- return draw()
20
+ return built(current, draw()) as Drawn
16
21
  } finally {
17
22
  drawing = outer
18
23
  }
19
24
  }
20
25
 
21
26
  function element<Name extends keyof Terminal & ElementName>(name: Name): Terminal[Name] {
22
- const construct = (props: object) => {
23
- if (drawing === undefined) throw new Error(`${name} was called outside a render. Use it inside a pane's render or a slot's component.`)
24
- if (name === 'Markdown' && drawing.markdown !== undefined) return drawing.markdown(props as MarkdownProps)
25
- return drawing.table[name](props)
26
- }
27
- return construct as Terminal[Name]
27
+ const construct = (props: object) => (drawing === undefined ? ({ [unbuilt]: { name, props } } satisfies Unbuilt as unknown as RenderElement) : build(drawing, name, props))
28
+ return construct
29
+ }
30
+
31
+ function build(current: Drawing, name: ElementName, props: object): RenderElement {
32
+ const ready = 'children' in props ? { ...props, children: built(current, props.children) } : props
33
+ if (name === 'Markdown' && current.markdown !== undefined) return current.markdown(ready as MarkdownProps)
34
+ return current.table[name](ready)
35
+ }
36
+
37
+ function built(current: Drawing, value: unknown): unknown {
38
+ if (Array.isArray(value)) return value.map((child: unknown) => built(current, child))
39
+ if (typeof value !== 'object' || value === null || !(unbuilt in value)) return value
40
+ const { name, props } = (value as Unbuilt)[unbuilt]
41
+ return build(current, name, props)
28
42
  }
29
43
 
30
44
  export const Box = element('Box')
@@ -1,11 +1,9 @@
1
1
  import type { Nodes, RootContent } from 'mdast'
2
2
  import { fromMarkdown, gfm, gfmFromMarkdown, toString } from '../vendor-markdown.js'
3
- import type { Slot, SlotProps } from './slots.js'
3
+ import type { PlaceProps } from './places.js'
4
4
 
5
5
  type Block = { readonly text: string; readonly source: string }
6
6
 
7
- type MarkdownSlot<Props extends object = object> = Slot<Props, { readonly source?: string }, { readonly markdown: MarkdownKind }>
8
-
9
7
  type BlockFields = {
10
8
  heading: { readonly depth: 1 | 2 | 3 | 4 | 5 | 6 }
11
9
  code: { readonly lang: string | undefined; readonly meta: string | undefined; readonly value: string }
@@ -14,18 +12,31 @@ type BlockFields = {
14
12
  html: { readonly value: string }
15
13
  }
16
14
 
17
- type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
15
+ export type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
18
16
 
19
17
  export type MarkdownPiece<Render> = { readonly text: string } | { readonly render: Render; readonly props: Block }
20
18
 
21
19
  export type MarkdownReader = <Render>(text: string, renderOf: (type: string) => Render | undefined, replyId?: string) => readonly MarkdownPiece<Render>[]
22
20
 
23
- export type MarkdownKind = {
21
+ export type MarkdownPlace<Type extends string = string> = {
24
22
  readonly name: string
25
- readonly type: string
26
- readonly createReader: () => MarkdownReader
23
+ readonly markdown: Type
24
+ createReader(): MarkdownReader
27
25
  }
28
26
 
27
+ function markdownPlace<const Type extends string>(name: string, markdown: Type): MarkdownPlace<Type> {
28
+ return { name, markdown, createReader }
29
+ }
30
+
31
+ export const Heading = markdownPlace('Heading', 'heading')
32
+ export const Paragraph = markdownPlace('Paragraph', 'paragraph')
33
+ export const CodeBlock = markdownPlace('CodeBlock', 'code')
34
+ export const BlockQuote = markdownPlace('BlockQuote', 'blockquote')
35
+ export const List = markdownPlace('List', 'list')
36
+ export const Table = markdownPlace('Table', 'table')
37
+ export const ThematicBreak = markdownPlace('ThematicBreak', 'thematicBreak')
38
+ export const HtmlBlock = markdownPlace('HtmlBlock', 'html')
39
+
29
40
  type Parsed = {
30
41
  readonly type: string
31
42
  readonly start: number
@@ -52,7 +63,7 @@ const blankLine = /\n[^\S\n]*\n/
52
63
 
53
64
  const fence = /^ {0,3}(`{3}|~{3})/
54
65
 
55
- function createReader(): MarkdownReader {
66
+ export function createReader(): MarkdownReader {
56
67
  const replies: Reply[] = []
57
68
 
58
69
  const remember = (reply: Reply) => {
@@ -152,22 +163,7 @@ function plainText(node: Nodes): string {
152
163
  return node.children.map(plainText).join(node.type === 'tableRow' ? '\t' : '\n')
153
164
  }
154
165
 
155
- function markdownSlot<Type extends RootContent['type']>(name: string, type: Type): MarkdownSlot<BlockProps<Type>> {
156
- return { markdown: { name, type, createReader } } as MarkdownSlot<BlockProps<Type>>
157
- }
158
-
159
- export const markdownSlots = {
160
- Heading: markdownSlot('Heading', 'heading'),
161
- Paragraph: markdownSlot('Paragraph', 'paragraph'),
162
- CodeBlock: markdownSlot('CodeBlock', 'code'),
163
- BlockQuote: markdownSlot('BlockQuote', 'blockquote'),
164
- List: markdownSlot('List', 'list'),
165
- Table: markdownSlot('Table', 'table'),
166
- ThematicBreak: markdownSlot('ThematicBreak', 'thematicBreak'),
167
- HtmlBlock: markdownSlot('HtmlBlock', 'html'),
168
- }
169
-
170
- export function markdownBlocks<S extends MarkdownSlot>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[] {
171
- const blocks = parsed(text, 0).filter((block) => block.type === slot.markdown.type)
172
- return blocks.map((block) => block.props) as unknown as Omit<SlotProps<S>, 'Default'>[]
166
+ export function markdownBlocks<P extends MarkdownPlace>(text: string, place: P): Omit<PlaceProps<P>, 'Default'>[] {
167
+ const blocks = parsed(text, 0).filter((block) => block.type === place.markdown)
168
+ return blocks.map((block) => block.props) as unknown as Omit<PlaceProps<P>, 'Default'>[]
173
169
  }