@cmodjs/core 0.3.0 → 1.5.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 (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -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 +14 -4
  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 +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -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 +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
package/docs/testing.md CHANGED
@@ -34,6 +34,7 @@ type TestOptions<State, Options> = {
34
34
  readonly dependencies?: { [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
35
35
  readonly files?: Readonly<Record<string, string>>
36
36
  readonly links?: Readonly<Record<string, string>>
37
+ readonly sessions?: readonly FakeSession[]
37
38
  }
38
39
  ```
39
40
 
@@ -43,15 +44,16 @@ type TestOptions<State, Options> = {
43
44
  | `options` | The option values Claude Code passes, as a person set them ([options.md](options.md)) | none: each option gets its default |
44
45
  | `permissions` | Exactly the permissions the person granted, such as `['network:api.github.com', 'model']` ([permissions.md](permissions.md)) | every permission the mod's `package.json` declares |
45
46
  | `scope` | `'project'` makes the mod a project plugin, in `<projectRoot>/.claude/skills/<name>` | `'user'` |
46
- | `projectRoot` | `mod.projectRoot` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
47
- | `cwd` | `mod.cwd` | `projectRoot` |
48
- | `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Run cmod install <name>.` |
47
+ | `projectRoot` | `sessions.current.project` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
48
+ | `cwd` | `sessions.current.cwd` | `projectRoot` |
49
+ | `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Add it with /plugin.` |
49
50
  | `files` | The fake file system, absolute path to text | no files |
50
51
  | `links` | Symbolic links in the fake file system, absolute link path to its target | no links |
52
+ | `sessions` | The earlier conversations `sessions.list` and `sessions.search` read, each `{ id, messages?, goal?, … }` ([Build a conversation](#build-a-conversation)) | none: only the current session, `test-session` |
51
53
 
52
54
  A project plugin finds its repository through a `.git` entry at `projectRoot`, so a test of one puts it in `files`, such as `files: { '/test/project/.git': '' }`. Without it the project plugin has no repository, and its rules match calls in every folder, as a user mod's do.
53
55
 
54
- The fake session's id is `test-session`, its model is `test-model`, and its home is `/test/home`. So `mod.dataFolder` is `/test/home/.local/share/cmod/data/<name>`. The mod is installed and draws on a terminal 80 columns wide.
56
+ The fake session's id is `test-session`, its model is `test-model`, and its home is `/test/home`. So `mod.dataFolder` is `/test/home/.local/share/cmod/data/<name>`, and `mod.folder` is `/test/plugins/<name>`. The mod is installed and draws on a terminal 80 columns wide.
55
57
 
56
58
  ## TestedMod
57
59
 
@@ -60,12 +62,16 @@ type TestedMod<State> = {
60
62
  start(): Promise<void>
61
63
  fire(event, input, below?): Promise<EventResult>
62
64
  settle(): Promise<void>
65
+ add(message: MessageDraft, options?: { task?: string }): Promise<Message[]>
66
+ endTurn(): Promise<void>
67
+ compact(): Promise<void>
68
+ startTask(task: { title: string; agentType?: string; model?: string; openedBy?: string; parent?: string }): Promise<string>
63
69
  lines(paneId: string): Promise<string[]>
64
- lines(slot, props, requestId?): Promise<string[]>
65
- type(line: string): Promise<Reply>
70
+ lines(place, props, requestId?): Promise<string[]>
71
+ type(line: string): Promise<CommandReply>
66
72
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
67
73
  press(paneId: string, key: string, link?: string): Promise<void>
68
- input(paneId: string, key: string, text: string, kind?: 'submit' | 'change'): Promise<void>
74
+ input(paneId: string, key: string, text: string, type?: 'submit' | 'change'): Promise<void>
69
75
  select(paneId: string, key: string, value: string): Promise<void>
70
76
  moveTo(projectRoot: string, cwd?: string): Promise<void>
71
77
  readonly state: Readonly<State>
@@ -82,13 +88,14 @@ Starts the mod: loads its state, runs `setup`, and adds its hooks, panes, and jo
82
88
  ### fire
83
89
 
84
90
  ```ts
85
- fire<E extends ModEvent>(event: E, input: TestInput<E>, below?): Promise<EventResult<`classic.${E}`>>
91
+ fire<H extends Hook>(hook: H, input: TestInput<H>, below?): Promise<EventResult<`classic.${H}`>>
86
92
  fire<N extends RoutedEvent>(event: N, input: Args<N>, below?): Promise<EventResult<N>>
87
93
  ```
88
94
 
89
- - `fire('UserPromptSubmit', { prompt: 'hello' })` runs the mod's hooks on a `ModEvent`. `TestInput<E>` is the event's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
95
+ - `fire(UserPromptSubmit, { prompt: 'hello' })` runs the mod's listeners on a hook. `TestInput<H>` is the hook's input without the fields `fire` fills: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, and a new `tool_use_id` for each tool event.
96
+ - A `SessionStart` that names no `transcript_path` also writes its transcript as an empty file in the test's files, so the session's `session` values last as they do in Claude Code ([state.md](state.md)).
90
97
  - For a tool event, `files` is worked out from the call and the test's `files`, as in Claude Code.
91
- - `fire('PreToolUse', …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire('PreToolUse', { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
98
+ - `fire(PreToolUse, …)` runs the call's `tool.call`, and its `tool.check` inside it, as Claude Code does. `testMod` runs a mod as if `register.ts` calls `registerPermissionCheck`, so it returns `allow: true` or `ask` with the reason when the mod's hooks or `permissions` job decided the permission. A `PreToolUse` with `agent_id` and `agent_type`, such as `fire(PreToolUse, { tool_name: 'Read', tool_input: { file_path: 'a.ts' }, agent_id: 'agent-7', agent_type: 'explorer' })`, runs as that subagent's call. It returns `updatedInput` when the hooks rewrote the input, `additionalContext` as a list, and `deny` when a hook or Claude Code refused the call. Its `below` is `{ deny }` to test a call Claude Code refuses, and Claude Code runs the call otherwise.
92
99
  - On every other event, `below` is what the hooks beneath the mod answered. Pass it to test how the mod's answer combines with another. When nothing in the mod answers, `fire` returns `below` as it is.
93
100
  - Left out, `below` is `{}`, except on `prompt.submit`, where it is what Claude Code answers: the prompt that entered, `{ text, context, origin }`, with the `context` the mod's hooks added.
94
101
  - It returns the combined answer in Claude Code's own field names: `additionalContext` is a list, and `decision: 'block'` is `block`.
@@ -115,7 +122,7 @@ test('a force push is denied with the reason', async () => {
115
122
 
116
123
  ### Test a prompt with when
117
124
 
118
- A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire('UserPromptSubmit', …)` never reaches it. `fire('prompt.submit', …)` returns the prompt that entered, with the text the prompt added in its `context`:
125
+ A `prompt` with `when` runs on the hook-module event `prompt.submit`, not on `UserPromptSubmit`, so `fire(UserPromptSubmit, …)` never reaches it. `fire('prompt.submit', …)` returns the prompt that entered, with the text the prompt added in its `context`:
119
126
 
120
127
  - The `prompt.submit` input is `{ text, wait, origin }`. A prompt the person typed has `wait: false` and `origin: { kind: 'composer' }`.
121
128
  - `context` is a list of strings, each `# <name>\n<text>`. The answer has no `context` when no prompt added anything.
@@ -153,26 +160,59 @@ test('a prompt about a release gets the release steps, and another prompt gets n
153
160
 
154
161
  Waits until the work a hook started without awaiting it has run, such as a `void` call in a `Stop` hook. It returns while a fake the test holds open has not answered.
155
162
 
163
+ ### Build a conversation
164
+
165
+ The current session, `test-session`, starts empty. A test builds the conversation the mod reads through `sessions` ([session.md](session.md)), the way Claude Code would write it:
166
+
167
+ - `add(message, { task? })` adds a message and returns it, placed in its context and turn. A `Prompt` opens a new turn. With `task`, the message is that task's.
168
+ - `endTurn()` ends the open turn, as Claude stopping does.
169
+ - `compact()` records a compaction boundary, which ends the context and starts the next, as a compaction does.
170
+ - `startTask({ title, agentType?, model?, openedBy?, parent? })` starts a task and returns its id, under the task `parent` names when it has one. Its owner runs `agentType` on `model`, and a `Prompt` added with its `task` opens the owner's own turn.
171
+
172
+ Each one runs the mod's session listeners, as a new message does in Claude Code. A `MessageDraft` is a message without its place: `{ type: Prompt, text, from? }`, `{ type: Note, text, from? }`, `{ type: Reply, text }`, `{ type: ToolCall, name, input?, callId? }`, `{ type: ToolResult, callId, text, isError? }`, `{ type: Skill, name, text }`, `{ type: Document, path, text }`, or `{ type: Notice, text }`.
173
+
174
+ The `sessions` option gives the earlier conversations, each a `FakeSession`: an `id`, its `messages` as drafts, its `goal`, and any `Session` field, such as `title`, `project`, or `isRunning`. A session that names no `project` is in the test's project.
175
+
176
+ ```ts
177
+ import { expect, test } from 'bun:test'
178
+ import { Prompt, Reply } from '../node_modules/@cmodjs/core/mod.js'
179
+ import { testMod } from '../node_modules/@cmodjs/core/testing.js'
180
+ import { recall } from '../src/mod.js'
181
+
182
+ test('/recall finds the earlier session about the deploy', async () => {
183
+ const tested = testMod(recall, { sessions: [{ id: 'older', title: 'Fix the deploy', messages: [{ type: Prompt, text: 'Why did the deploy fail?' }] }] })
184
+ await tested.add({ type: Prompt, text: 'Recall the deploy' })
185
+ await tested.add({ type: Reply, text: 'Looking.' })
186
+ await tested.endTurn()
187
+
188
+ expect(await tested.type('/recall deploy')).toEqual({ text: 'Fix the deploy: Why did the deploy fail?' })
189
+ })
190
+ ```
191
+
192
+ `shown.sent` lists what the mod sent to other sessions and to tasks' owners, each with its `session`, and its `task` for an owner. `fakes.agent.spawn` answers the current session's `startTask`, such as `async () => ({ agentId: 'agent-7', model: 'test-model' })`, and `fakes.tool.call` its `callTool`. Another session's `startTask` starts a task in that fake session.
193
+
194
+ A `before` listener on the current session answers the event `fire` raises, such as `tested.fire('prompt.submit', { text: 'Deploy to prod', origin: { kind: 'composer' }, wait: false })` or `tested.fire('tool.call', { tool: 'Bash', tool_use_id: 'toolu_1', command: 'rm -rf build' })`.
195
+
156
196
  ### lines
157
197
 
158
198
  - `lines(paneId)` draws an open pane and returns its rows of text. It rejects a pane that is not open: open it first with `tested.type('/<command>')` or `pane.open()` in the mod. The render gets the props `title`, `isFocused`, true once an `open` or `toggle` with `{ focus: true }` opened it, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`, the main conversation's view.
159
199
  - A `Button` draws as Claude Code draws it: `[ Save ]`, or `1: Save` when it is `plain` with a `hotkey`, or `Save` when it is `plain` alone. Borders and colors are not drawn.
160
- - `lines(slot, props, requestId?)` draws a slot render with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the render changed. Draw `slots.AssistantMessage` with a reply's `text` to test a markdown slot render. Draws that pass one `requestId` grow one streamed reply.
200
+ - `lines(place, props, requestId?)` draws a place render with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the render changed. Draw `Reply` with a reply's `text` to test a markdown place render. Draws that pass one `requestId` grow one streamed reply. `AbovePrompt` ends with the toasts shown now, under the mod's own rows.
161
201
 
162
202
  ```ts
163
203
  import { expect, test } from 'bun:test'
164
204
  import type { RenderPropsOf } from 'claude-code'
205
+ import { AbovePrompt, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
165
206
  import { testMod } from '../node_modules/@cmodjs/core/testing.js'
166
- import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
167
207
  import { hints } from '../src/mod.js'
168
208
 
169
209
  const band: RenderPropsOf['AbovePrompt'] = { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 75, scroll: { offset: 0, bodyRows: 12 }, view: {} }
170
210
 
171
- test('the band shows the prompt count on its last line', async () => {
211
+ test('the band shows the prompt count', async () => {
172
212
  const tested = testMod(hints)
173
- await tested.fire('UserPromptSubmit', { prompt: 'hello' })
213
+ await tested.fire(UserPromptSubmit, { prompt: 'hello' })
174
214
 
175
- expect((await tested.lines(slots.AbovePrompt, band)).at(-1)).toBe('Prompts: 1')
215
+ expect(await tested.lines(AbovePrompt, band)).toContain('Prompts: 1')
176
216
  })
177
217
  ```
178
218
 
@@ -190,7 +230,7 @@ test('the band shows the prompt count on its last line', async () => {
190
230
 
191
231
  - `press(paneId, key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
192
232
  - `press(paneId, key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
193
- - `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `kind` `'change'` it runs `onInput` instead, as one edit does.
233
+ - `input(paneId, key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With `type` `'change'` it runs `onInput` instead, as one edit does.
194
234
  - `select(paneId, key, value)` picks the option `value` of the `Select` whose `key` is `key`, and runs its `onSelect`. It rejects an option the `Select` does not have.
195
235
 
196
236
  ```ts
@@ -201,7 +241,7 @@ expect(await tested.lines('history')).toContain('deploy by explorer')
201
241
 
202
242
  ### moveTo
203
243
 
204
- `moveTo(projectRoot, cwd?)` moves the session. A new `projectRoot` runs a `/cd` there, so the mod loads that project's state. A `cwd` then runs a Bash `cd` there. The mod's `CwdChanged` hooks run for each move.
244
+ `moveTo(projectRoot, cwd?)` moves the session. A new `projectRoot` runs a `/cd` there, so the mod loads that project's state. A `cwd` then runs a Bash `cd` there. The mod's `CwdChanged` listeners run for each move, and `sessions.current` follows it.
205
245
 
206
246
  ### state
207
247
 
@@ -235,20 +275,22 @@ Set a fake to answer a call:
235
275
 
236
276
  - A call with no fake throws `<call>(<args>) has no fake answer. Set fakes.<call> on the tested mod.` So set `fakes.process.run` before a test runs a program, and `fakes.ui.ask` before the mod asks a question.
237
277
  - A `program` job is the exception: it catches the `process.spawn` error and retries after 1, 2, 4, 8 and 16 seconds on real timers, so its `ready()` waits past `bun test`'s 5-second timeout. Set `fakes.process.spawn` before a test starts a `program` job.
238
- - `files` already fills the `fs` fakes: a write is read back, and `list` lists the given files. The fake file system resolves a relative path against `/`, not against `mod.cwd`, so a mod under test reads `${mod.projectRoot}/<file>` rather than a relative path. `fs.list()` with no path rejects.
278
+ - `files` already fills the `fs` fakes: a write is read back, and `list` lists the given files. The fake file system resolves a relative path against `/`, not against the session's working folder, so a mod under test reads `${sessions.current.project}/<file>` rather than a relative path. `fs.list()` with no path rejects.
239
279
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
240
280
  - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
241
281
  - `clock.after(ms, fn)` and `clock.every(ms, fn)` each return `{ cancel() }`, and so must a fake of either. `clock.after` runs a real timer by default. `clock.every` never fires unless a fake keeps `fn` for the test to call, so a test of `mod.every` keeps it and calls it once per tick.
242
282
  - `session.append` and `prompt.submit` take every note and prompt by default, into `shown.notes` and `shown.prompts`. A fake that answers `{ deny }` or `{ drop }` refuses one.
243
283
  - `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
284
+ - `settings.read` answers `{}` by default, as Claude Code does with no `settings.json` files.
244
285
  - `config.list` answers no `/config` rows by default. A `config.list` fake that answers a row with `isLocked: true` locks that option as managed settings do.
245
286
  - `tool.call` answers `CronCreate`, `CronDelete`, and `CronList` by default, keeping the crons a `schedule` makes in `shown.schedules`. A call to any other tool needs a fake.
246
- - `files` takes symbolic links too: `testMod(mod, { files, links: { '/work/skills': '/shared/skills' } })` makes `/work/skills` a link to `/shared/skills`, so a test of `mod.fs.find` or `mod.metadata.update` follows it as a real file system does.
287
+ - `files` takes symbolic links too: `testMod(mod, { files, links: { '/work/skills': '/shared/skills' } })` makes `/work/skills` a link to `/shared/skills`, so a test of `fs.find` or `metadata.update` follows it as a real file system does.
247
288
  - `testMod` runs no installer pane: an option with no default and no value fails `start()` with its fix, and the `installer` steps are not shown. Test a step's `render` and `isDone` by calling them with the started `mod`.
248
289
  - The `committer` below runs `git`, so its `package.json` declares `"permissions": { "run": ["git"] }`. A gated call the mod does not declare fails before it reaches a fake ([permissions.md](permissions.md)).
249
290
 
250
291
  ```ts
251
292
  import { expect, test } from 'bun:test'
293
+ import { Stop } from '../node_modules/@cmodjs/core/mod.js'
252
294
  import { testMod } from '../node_modules/@cmodjs/core/testing.js'
253
295
  import { committer } from '../src/mod.js'
254
296
 
@@ -257,7 +299,7 @@ test('a Commit answer commits', async () => {
257
299
  tested.fakes.ui.ask = async () => 'Commit'
258
300
  tested.fakes.process.run = async () => ({ exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false })
259
301
 
260
- await tested.fire('Stop', { stop_hook_active: false })
302
+ await tested.fire(Stop, { stop_hook_active: false })
261
303
 
262
304
  expect(tested.calls.filter(({ call }) => call === 'process.run').map(({ args }) => args[0])).toEqual([['git', 'commit', '-am', 'wip']])
263
305
  })
@@ -267,7 +309,7 @@ test('a Commit answer commits', async () => {
267
309
 
268
310
  ```ts
269
311
  type Shown = {
270
- readonly toasts: string[]
312
+ readonly toasts: ShownToast[]
271
313
  readonly notes: string[]
272
314
  readonly prompts: string[]
273
315
  readonly logs: string[]
@@ -277,10 +319,13 @@ type Shown = {
277
319
  readonly commands: string[]
278
320
  readonly tools: string[]
279
321
  readonly schedules: { readonly id: string; readonly cron: string; readonly prompt: string }[]
322
+ readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
280
323
  }
324
+
325
+ type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
281
326
  ```
282
327
 
283
- What the person and Claude would see: the toasts, the notes the mod added for Claude with `mod.session.append` or a `prompt` that follows the state, the prompts it sent with `mod.session.submit`, the log lines, the debug log lines, each status line text, the open panes, the slash commands and tools the mod added, and the crons its `schedule` keeps now.
328
+ What the person and Claude would see: the toasts shown now, with a text description, the notes the mod added for Claude with `sessions.current.send` or a `prompt` that follows the state, the prompts it sent to the current session, the messages it sent to other sessions in `sent`, the log lines, the debug log lines, each status line text, the open panes, the slash commands and tools the mod added, and the crons its `schedule` keeps now.
284
329
 
285
330
  ```ts
286
331
  test('the Mode prompt follows the mode', async () => {
package/docs/ui.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # UI
2
2
 
3
- A mod draws in four places: its own panes, the rows Claude Code draws (slots), the blocks of Claude's replies (markdown slots), and short messages: toasts, progress lines, and questions. All of it lives on `mod.ui`.
3
+ A mod draws in four ways: its own panes, the rows Claude Code draws (places), the blocks of Claude's replies (markdown places), and short messages: toasts, questions, and notices. All of it lives on `mod.ui`.
4
4
 
5
5
  ```ts
6
6
  mod.ui.pane(pane: Pane<State>): PaneHandle
7
- mod.ui.render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement): void
8
- mod.ui.toast(text: string, options?: { timeoutMs?: number }): void
9
- mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
7
+ mod.ui.render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable
8
+ mod.ui.toast: Toast
9
+ mod.ui.copy(text: string): Promise<void>
10
10
  mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
11
11
  mod.ui.scroll(args: UiScrollArgs): Promise<UiScrollResult>
12
+ mod.ui.notice(callId: string, text: string | undefined): void
12
13
  ```
13
14
 
14
15
  Claude Mod Manager (cmod) draws the mod again whenever a value in `mod.state` changes. A render reads `mod.state` and returns what to show. It never draws by hand.
@@ -45,13 +46,13 @@ export function Count({ label, count }: { readonly label: string; readonly count
45
46
  }
46
47
  ```
47
48
 
48
- An element called outside a render throws `<Element> was called outside a render. Use it inside a pane's render or a slot's component.` So build elements inside a pane's `render` or a slot's component, never at module load or in a hook.
49
+ An element built outside a render, at module load or in a hook, holds its props until a render draws it. So a hook can build a toast's `description` and the band draws it later.
49
50
 
50
51
  `drawWith(table, draw, markdown?)` is the function cmod draws a render with. A mod never calls it.
51
52
 
52
53
  ## Components
53
54
 
54
- `ui/components.js` exports controls built from the elements and Claude Code's theme colors, so they follow the person's theme and look the same in every mod. Each is a component like the ones a mod writes: a function of props, used in a pane's `render` or a slot's component.
55
+ `ui/components.js` exports controls built from the elements and Claude Code's theme colors, so they follow the person's theme and look the same in every mod. Each is a component like the ones a mod writes: a function of props, used in a pane's `render` or a place's component.
55
56
 
56
57
  ```ts
57
58
  Tabs(props: { tabs: readonly { key: string; label: string }[]; selected: string; onSelect(key: string): void; children? })
@@ -62,7 +63,7 @@ Tooltip(props: { text: string; children? })
62
63
  Help(props: { text: string; isOpen: boolean; onOpenChange(isOpen: boolean): void })
63
64
  Dialog(props: { title: string; children?; actions })
64
65
  Pagination(props: { page: number; pages: number; onPage(page: number): void })
65
- ProgressBar(props: { done: number; total: number; width?: number })
66
+ Progress(props: { value: number; width?: number })
66
67
  ```
67
68
 
68
69
  - `Tabs` draws a row of tabs over `children`. The selected tab's number is in the accent color and its label bold and underlined. Each other tab is a `Button` whose hotkey is its number, so a pane that holds the keyboard switches tabs by number, Tab, or a click. A hotkey is one digit, so only the first nine tabs have a number.
@@ -72,7 +73,7 @@ ProgressBar(props: { done: number; total: number; width?: number })
72
73
  - `Help` draws a `?` that shows `text` the same way. A press of `?` keeps the card open until the next press, so the keyboard reaches it too. Keep `isOpen` in `mod.state`.
73
74
  - `Dialog` lays out a dialog's title, body, and `actions`, the buttons in a row. Put the safe action first, as the `primary` Button with `autoFocus`.
74
75
  - `Pagination` draws `p: Previous 2 of 5 n: Next`. `p` and `n` are its hotkeys, and an end it cannot pass is dim text.
75
- - `ProgressBar` fills `width` columns, 24 unless given, in proportion to `done` of `total`, and shows the count. The progress lines `mod.ui.progress` draws use it too.
76
+ - `Progress` fills `value` percent of `width` columns, 24 unless given. Put the count or a label beside it in a `Text`, or pass it as a loading toast's `description`.
76
77
  - `Tabs`, `Toggle`, `Help`, and `Pagination` hold no state of their own. Keep their value in `mod.state` and set it in the callback, as in React's controlled components.
77
78
 
78
79
  A pane opened with `focus`, `closeOnEscape`, and `holdToasts` is a dialog. Claude Code shows it as a tab of the dock, beside the mod's other panes, and Escape closes it:
@@ -114,7 +115,7 @@ Open it with `mod.ui.pane(confirmPane).open({ focus: true })`. It draws as:
114
115
  [ Keep it ] [ Delete ]
115
116
  ```
116
117
 
117
- A pane with `Tabs`, a `Tooltip`, a `ProgressBar`, `Pagination`, and a `Toggle` with `Help` draws as:
118
+ A pane with `Tabs`, a `Tooltip`, a `Progress` beside its count, `Pagination`, and a `Toggle` with `Help` draws as:
118
119
 
119
120
  ```text
120
121
  1: Controls 2: Split 3: Files
@@ -217,9 +218,13 @@ type PaneHandle = {
217
218
  close(): Promise<void>
218
219
  toggle(options?: { readonly focus?: true }): Promise<void>
219
220
  readonly isOpen: boolean
221
+ readonly isShown: boolean
222
+ readonly isFocused: boolean
220
223
  }
221
224
  ```
222
225
 
226
+ - `isShown` is true while the pane is the one Claude Code shows. Claude Code shows one pane at a time and the rest wait as tabs behind it.
227
+ - `isFocused` is true while the person has given the pane the keyboard.
223
228
  - `open` asks Claude Code to show the pane. `isOpen` turns true once Claude Code places it. Claude Code may hold a pane back, and then `isOpen` stays false.
224
229
  - `{ focus: true }` asks Claude Code to give the pane the keyboard as it opens. Claude Code gives it only while the prompt is empty and holds the keyboard, so typed text, a dialog, or another pane the person is using keeps it. cmod sends `focus` only on that one open, never when it resizes the pane.
225
230
  - `mod.ui.pane` only adds the pane. cmod opens it only when the mod calls `open` or `toggle`, and Claude Code shows a pane only once the plugin has opened it. So give the person a way in, such as a slash command whose `reply` calls `pane.toggle()`.
@@ -248,141 +253,170 @@ export const notes = defineMod({
248
253
  })
249
254
  ```
250
255
 
251
- ## Slots: change a row Claude Code draws
256
+ ## Places: change a row Claude Code draws
252
257
 
253
- `mod.ui.render(slot, Component)` draws in place of one of Claude Code's own rows. `ui/slots.js` exports `slots`:
258
+ `mod.ui.render(place, Component)` draws in place of one of Claude Code's own rows. `mod.js` exports each place as a constant. A message type is a place too, so `Reply` is where Claude Code draws a reply:
254
259
 
255
- | Slot | What Claude Code draws there |
260
+ | Place | What Claude Code draws there |
256
261
  | --- | --- |
257
- | `slots.AssistantMessage` | One text block of Claude's reply |
258
- | `slots.UserMessage` | A user row: the prompt, a task notification, or another agent's message |
259
- | `slots.ToolUse` | A tool call's row |
260
- | `slots.ToolResult` | A tool call's result |
261
- | `slots.ToolGroup` | The condensed row of a group of tool calls |
262
- | `slots.ToolProgress` | A tool's progress line |
263
- | `slots.CommandOutput` | A slash command's output |
264
- | `slots.AskUserQuestion` | The dialog the AskUserQuestion tool opens |
265
- | `slots.InfoNotice` | A dim status line under the logo |
266
- | `slots.Spinner` | The spinner line while Claude works |
267
- | `slots.TurnDuration` | The line that closes a turn |
268
- | `slots.SessionMode` | The session's modes |
269
- | `slots.PromptHint` | The prompt's hint line, such as `? for shortcuts` |
270
- | `slots.AbovePrompt` | The band above the prompt, where surveys draw |
271
-
272
- `Component` gets `SlotProps<S>`: the props Claude Code types as `RenderPropsOf['<Slot>']`, plus `Default`.
262
+ | `Reply` | One text block of Claude's reply |
263
+ | `Prompt` | A prompt row: the person's prompt, a task notification, or another session's message |
264
+ | `ToolCall` | A tool call's row |
265
+ | `ToolResult` | A tool call's result |
266
+ | `Notice` | A dim status line under the logo |
267
+ | `ToolGroup` | The condensed row of a group of tool calls |
268
+ | `ToolProgress` | A tool's progress line |
269
+ | `CommandOutput` | A slash command's output |
270
+ | `AskUserQuestion` | The dialog the AskUserQuestion tool opens |
271
+ | `Spinner` | The spinner line while Claude works |
272
+ | `TurnDuration` | The line that closes a turn |
273
+ | `SessionMode` | The session's modes |
274
+ | `PromptHint` | The prompt's hint line, such as `? for shortcuts` |
275
+ | `AbovePrompt` | The band above the prompt, where surveys draw |
276
+
277
+ `Component` gets `PlaceProps<P>`: the props Claude Code draws that row with, plus `Default`.
273
278
 
274
279
  - `Default` draws what Claude Code, and the renders of other mods, would draw. Pass it props to change what it draws, such as `<Default hint="…" />`. Return `<Default />` to change nothing.
275
280
  - A render adds to what it wraps. In `AbovePrompt`, drawing `<Default />` and a line under it keeps the lines of Claude Code and other mods.
276
281
  - A render must draw the same for the same props. One that calls `Default` a different number of times on a second draw falls back to Claude Code's drawing.
277
- - A render that throws falls back to Claude Code's drawing. cmod logs `<mod>: the <Slot> render threw, so Claude Code draws its own: <error>` once.
278
- - A mod renders each slot once. A second `mod.ui.render` of one slot throws `<mod>: a render of <Slot> is already added. Render each slot once.` For a markdown slot the error names it as `a render of markdown <Kind>`, such as `a render of markdown CodeBlock`.
279
- - `slots.ToolUse` changes a call's own row. A group of calls shows the `slots.ToolGroup` row, which Claude Code builds from the stored message, so a mod that hides a tool's input also sets `isExpanded` on `slots.ToolGroup` to unfold the group into `ToolUse` rows.
280
- - No slot covers the permission dialog.
282
+ - A render that throws, or draws differently on a second pass, falls back to Claude Code's drawing, and cmod shows the error toast `The <Place> render failed` once, with the reason under it.
283
+ - A mod renders each place once. A second `mod.ui.render` of one place throws `<mod>: a render of <Place> is already added. Render each place once.`
284
+ - `render` returns a `Disposable`. `dispose()` takes the render back, and Claude Code draws the row as before ([mod.md](mod.md#disposable)).
285
+ - `ToolCall` changes a call's own row. A group of calls shows the `ToolGroup` row, which Claude Code builds from the stored message, so a mod that hides a tool's input also sets `isExpanded` on `ToolGroup` to unfold the group into `ToolCall` rows.
286
+ - No place covers the permission dialog. `mod.ui.notice` adds a line under it instead ([Notices](#notices)).
281
287
 
282
288
  ```tsx
283
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
289
+ import { AbovePrompt, defineMod, PromptHint, UserPromptSubmit } from '../node_modules/@cmodjs/core/mod.js'
284
290
  import { Box, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
285
- import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
286
291
 
287
292
  export const hints = defineMod({
288
293
  name: 'hints',
289
294
  state: { session: { prompts: 0 } },
290
295
  setup(mod) {
291
- mod.ui.render(slots.PromptHint, ({ hint, Default }) => <Default hint={hint.toUpperCase()} />)
292
- mod.ui.render(slots.AbovePrompt, ({ hasSurvey, Default }) =>
296
+ mod.ui.render(PromptHint, ({ hint, Default }) => <Default hint={hint.toUpperCase()} />)
297
+ mod.ui.render(AbovePrompt, ({ hasSurvey, Default }) =>
293
298
  hasSurvey ? <Default /> : (
294
299
  <Box flexDirection="column">
295
300
  <Default />
296
301
  <Text dimColor>{`Prompts: ${mod.state.session.prompts}`}</Text>
297
302
  </Box>
298
303
  ))
299
- mod.on('UserPromptSubmit', () => {
304
+ mod.on(UserPromptSubmit, () => {
300
305
  mod.state.session.prompts += 1
301
306
  })
302
307
  },
303
308
  })
304
309
  ```
305
310
 
306
- `Slot` and `SlotProps` are the types behind `slots`. Type a component's props as `SlotProps<typeof slots.PromptHint>`.
311
+ `Place` is the type of every place, and `PlaceProps` the props of one. Type a component's props as `PlaceProps<typeof PromptHint>`.
307
312
 
308
- ## Markdown slots: change the blocks of a reply
313
+ ## Markdown places: change the blocks of a reply
309
314
 
310
- `ui/markdown.js` exports `markdownSlots`, one per markdown block:
315
+ Each markdown block of a reply is a place of its own. `ui/markdown.js` exports these places, apart from the rest, because it brings the markdown reader, about 230 KB that takes some 18 ms to load. A mod that imports none of them starts without it.
311
316
 
312
- | Slot | Block | Props beyond `text` and `source` |
317
+ | Place | Block | Props beyond `text` and `source` |
313
318
  | --- | --- | --- |
314
- | `markdownSlots.Heading` | A heading | `depth`, 1 to 6 |
315
- | `markdownSlots.Paragraph` | A paragraph | none |
316
- | `markdownSlots.CodeBlock` | A code block | `lang`, `meta`, `value` |
317
- | `markdownSlots.BlockQuote` | A block quote | none |
318
- | `markdownSlots.List` | A list | `ordered`, `start`, `spread` |
319
- | `markdownSlots.Table` | A table | `align` |
320
- | `markdownSlots.ThematicBreak` | A `---` line | none |
321
- | `markdownSlots.HtmlBlock` | An HTML block | `value` |
319
+ | `Heading` | A heading | `depth`, 1 to 6 |
320
+ | `Paragraph` | A paragraph | none |
321
+ | `CodeBlock` | A code block | `lang`, `meta`, `value` |
322
+ | `BlockQuote` | A block quote | none |
323
+ | `List` | A list | `ordered`, `start`, `spread` |
324
+ | `Table` | A table | `align` |
325
+ | `ThematicBreak` | A `---` line | none |
326
+ | `HtmlBlock` | An HTML block | `value` |
322
327
 
323
328
  - `text` is the block's plain text, one line per list item, table row, or quoted line. `source` is the block's markdown.
324
329
  - `Default` draws the block as Claude Code would. `<Default source="…" />` draws other markdown in its place.
325
- - A markdown slot render draws in Claude's replies and in every `Markdown` element of the mod's panes.
330
+ - A markdown render draws in Claude's replies and in every `Markdown` element of the mod's panes.
326
331
  - While a reply streams in, Claude Code draws a block that is not finished yet. The mod draws it once it is.
327
- - A render that throws falls back to Claude Code's drawing of the block and logs once.
332
+ - A render that throws falls back to Claude Code's drawing of the block, and shows the error toast once.
328
333
 
329
334
  ```tsx
330
335
  import type { RenderElement } from 'claude-code'
331
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
336
+ import { defineMod, type PlaceProps } from '../node_modules/@cmodjs/core/mod.js'
337
+ import { CodeBlock } from '../node_modules/@cmodjs/core/ui/markdown.js'
332
338
  import { Text } from '../node_modules/@cmodjs/core/ui/elements.js'
333
- import { markdownSlots } from '../node_modules/@cmodjs/core/ui/markdown.js'
334
- import type { SlotProps } from '../node_modules/@cmodjs/core/ui/slots.js'
335
339
 
336
- function MermaidBlock({ lang, value, Default }: SlotProps<typeof markdownSlots.CodeBlock>): RenderElement {
340
+ function MermaidBlock({ lang, value, Default }: PlaceProps<typeof CodeBlock>): RenderElement {
337
341
  return lang === 'mermaid' ? <Text>{`diagram: ${value}`}</Text> : <Default />
338
342
  }
339
343
 
340
344
  export const diagrams = defineMod({
341
345
  name: 'diagrams',
342
346
  setup(mod) {
343
- mod.ui.render(markdownSlots.CodeBlock, MermaidBlock)
347
+ mod.ui.render(CodeBlock, MermaidBlock)
344
348
  },
345
349
  })
346
350
  ```
347
351
 
348
352
  ```ts
349
- markdownBlocks<S>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[]
353
+ markdownBlocks<P extends MarkdownPlace>(text: string, place: P): Omit<PlaceProps<P>, 'Default'>[]
350
354
  ```
351
355
 
352
- `markdownBlocks` returns the props of every block of one kind in a markdown text. A test uses it to call a component with real props.
353
-
354
- `MarkdownKind`, `MarkdownReader`, and `MarkdownPiece` are the types behind `markdownSlots`. A mod never builds them.
356
+ `markdownBlocks`, exported from `ui/markdown.js`, returns the props of every block at one markdown place in a markdown text, such as every code block of a reply. A mod reads the blocks of a reply it got in a hook with it, and a test calls a component with real props.
355
357
 
356
- ## Toasts
358
+ ```ts
359
+ import { CodeBlock, markdownBlocks } from '../node_modules/@cmodjs/core/ui/markdown.js'
357
360
 
358
- `mod.ui.toast(text, options?)` shows the person a short message on Claude Code's stack of plugin toasts, over the transcript's top right corner and under the mod's name. It stays 4 seconds, or `timeoutMs`. A click takes it off, and the pointer resting on it holds it. While a pane opened with `holdToasts` is shown, it waits.
361
+ const diagrams = markdownBlocks(reply, CodeBlock).filter((block) => block.lang === 'mermaid')
362
+ ```
359
363
 
360
- ## Progress lines
364
+ ## Notices
361
365
 
362
366
  ```ts
363
- mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
367
+ mod.ui.notice(callId: string, text: string | undefined): void
368
+ ```
369
+
370
+ `notice` shows `text` as one line under the permission dialog that is open for the tool call `callId`, the call's `tool_use_id`, such as `checked by safe-delete`. `undefined` removes the line, and Claude Code removes it on its own once the call is decided. A call with no open dialog takes no line.
371
+
372
+ ## Toasts
373
+
374
+ `mod.ui.toast` shows the person a short message in the band above the prompt, one row per toast, under the mod's name. Its shape follows [Sonner](https://sonner.emilkowal.ski/toast):
364
375
 
365
- type ProgressStep = { readonly done: number; readonly total: number; readonly label?: string }
376
+ ```ts
377
+ mod.ui.toast(message: string, options?: ToastOptions): ToastId
378
+ mod.ui.toast.success(message, options?) / .info / .warning / .error / .loading
379
+ mod.ui.toast.promise<T>(task: Promise<T> | (() => Promise<T>), messages: { loading: string; success: string | ((value: T) => string); error: string | ((error: unknown) => string) }): { unwrap(): Promise<T> }
380
+ mod.ui.toast.dismiss(id?: ToastId): void
381
+
382
+ type ToastOptions = {
383
+ id?: ToastId
384
+ description?: string | RenderElement
385
+ action?: { label: string; onClick(): unknown }
386
+ duration?: number
387
+ }
366
388
  ```
367
389
 
368
- `progress` draws a line in the band above the prompt while `task` runs, and removes it when `task` settles. The line shows a spinner and the title. After the first `report` it also shows a bar, `done/total`, and the label. `progress` resolves or rejects as `task` does.
390
+ - Each type draws its icon: `✓` success, `ℹ` info, `⚠` warning, `✗` error, and a spinner for loading.
391
+ - A toast stays 4 seconds once the band draws it, or `duration` ms. `Infinity` keeps it until the person closes it, and a loading toast stays until it changes. The time waits while Claude Code shows a survey over the band.
392
+ - A toast with the `id` of a shown toast replaces it in place, so a loading toast turns into its success or error.
393
+ - A text `description` shows its first line dim under the message. A press on the message unfolds the whole text and stops the time, and a second press folds it. An element `description`, such as a `Progress`, draws as it is.
394
+ - `action` draws a button after the message. A press that throws shows an error toast with the cause.
395
+ - `✕` closes the toast. The band shows the newest 3, and `+N more` shows every toast.
396
+ - `promise` shows `loading` while `task` runs, then `success` or `error` in the same row. `unwrap()` resolves or rejects as `task` does.
369
397
 
370
398
  ```ts
371
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
399
+ import { defineMod, fs, SessionStart, sessions } from '../node_modules/@cmodjs/core/mod.js'
372
400
 
373
401
  export const indexer = defineMod({
374
402
  name: 'indexer',
375
403
  setup(mod) {
376
- mod.on('SessionStart', async () => {
377
- const entries = await mod.fs.list(mod.projectRoot)
378
- await mod.ui.progress('Indexing', async (report) => {
379
- for (const [index, entry] of entries.entries()) report({ done: index + 1, total: entries.length, label: entry.name })
380
- })
404
+ mod.on(SessionStart, async () => {
405
+ const indexed = fs.list(sessions.current.project).then((entries) => entries.length)
406
+ mod.ui.toast.promise(indexed, { loading: 'Indexing files', success: (count) => `Indexed ${count} files`, error: 'Indexing failed' })
381
407
  })
382
408
  },
383
409
  })
384
410
  ```
385
411
 
412
+ An error cmod catches for a mod, such as a hook that throws, shows as an error toast that stays, with its buttons on a line under the message:
413
+
414
+ - `Retry` runs the failed step again, where running it again can work: an install, the mod's setup, or a program that stopped.
415
+ - `Report` replaces the toast's buttons with the ways to tell the mod's author, with the keys on the first, and `Back` returns to the buttons. The same failure happening again keeps them open. `Ask Claude to report it` sends this session a prompt to file the report with the person's own tools, and Claude Code asks before each step. `Report it on GitHub (opens your browser)` opens a filled-in issue on the mod's `supportUrl` or `repository` in its `plugin.json`, or `Email the author (opens your mail app)` writes to its `author.email`. `Copy the error` copies the mod's name and version, the `@cmodjs/core` version, the message, the whole cause, and the stack.
416
+ - `Hide` closes the toast and keeps the same failure hidden in later sessions. A new version of the mod, or a different failure, shows again.
417
+
418
+ `mod.ui.copy(text)` puts `text` on the person's clipboard. It rejects with Claude Code's reason when the terminal cannot take it.
419
+
386
420
  ## Questions
387
421
 
388
422
  ```ts
@@ -394,14 +428,14 @@ type AskOptions = { options?: readonly string[]; header?: string; multiSelect?:
394
428
  `ask` shows a question with 2 to 4 options and resolves the answer. Fewer than two options are padded with Yes and No, and the person may type an answer of their own. `header` is a short chip beside the question, 12 characters at most. `multiSelect` lets the person pick several, and the answer comes back joined by commas.
395
429
 
396
430
  ```ts
397
- import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
431
+ import { defineMod, process, sessions, Stop } from '../node_modules/@cmodjs/core/mod.js'
398
432
 
399
433
  export const committer = defineMod({
400
434
  name: 'committer',
401
435
  setup(mod) {
402
- mod.on('Stop', async () => {
436
+ mod.on(Stop, async () => {
403
437
  const answer = await mod.ui.ask('Commit the changes?', { options: ['Commit', 'Wait'], header: 'Commit' })
404
- if (answer === 'Commit') await mod.process.run(['git', 'commit', '-am', 'wip'], { cwd: mod.projectRoot })
438
+ if (answer === 'Commit') await process.run(['git', 'commit', '-am', 'wip'], { cwd: sessions.current.project })
405
439
  })
406
440
  },
407
441
  })
@@ -419,6 +453,12 @@ mod.ui.scroll(args: { to: 'start' | 'end' | { key: string } | { requestId: strin
419
453
  <Button label="Latest" onPress={() => mod.ui.scroll({ to: 'end', in: 'log' })} />
420
454
  ```
421
455
 
456
+ A [message](session.md#message)'s `id` names the row Claude Code draws it under, so a history pane brings the conversation to the message a person presses. When one row holds several messages, such as a reply with its tool calls, each `id` ends in `:<n>`, and the row is the `id` before it:
457
+
458
+ ```tsx
459
+ <Button label={message.text} onPress={() => mod.ui.scroll({ to: { requestId: message.id.split(':')[0] } })} />
460
+ ```
461
+
422
462
  ## Test the UI
423
463
 
424
- `tested.lines(paneId)` reads an open pane as rows of text. `tested.lines(slot, props)` draws a slot render. `tested.press(paneId, key)` presses a button. `tested.shown.toasts` lists the toasts. [testing.md](testing.md) covers each.
464
+ `tested.lines(paneId)` reads an open pane as rows of text. `tested.lines(place, props)` draws a place render. `tested.press(paneId, key)` presses a button. `tested.shown.toasts` lists the toasts shown now, each as `{ type, message, description? }`. [testing.md](testing.md) covers each.
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/jobs/check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAOpC,OAAO,EAAyB,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAIlF,wBAAgB,KAAK,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CA4BjM"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../src/jobs/check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAQpC,OAAO,EAAyB,KAAK,MAAM,EAAE,MAAM,+BAA+B,CAAA;AAIlF,wBAAgB,KAAK,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CA8BjM"}
package/jobs/check.js CHANGED
@@ -2,7 +2,8 @@ import { longestMs } from '../runtime/deadline.js';
2
2
  import { callEffects } from '../utils/call-effects.js';
3
3
  import { dirname } from '../vendor.js';
4
4
  import { listed, messageOf } from '../utils/text.js';
5
- import { afterCall, modOf, targetWords } from './context.js';
5
+ import { engineOf } from '../runtime/engine.js';
6
+ import { afterCall, targetWords, withinDeadline } from './context.js';
6
7
  import { matchTarget, targetOf } from './permissions/match-target.js';
7
8
  const defaultMs = 60_000;
8
9
  export function check(options) {
@@ -25,7 +26,9 @@ export function check(options) {
25
26
  return [];
26
27
  try {
27
28
  const { command, folder } = triggered;
28
- const { exitCode, stdout, stderr } = await modOf(job, { folder }, workspace, deadline).process.run(command);
29
+ const workTree = await workspace.scope?.workTreeOf(folder);
30
+ const { claude } = engineOf(job.mod);
31
+ const { exitCode, stdout, stderr } = await withinDeadline(job.mod, deadline, command.join(' '), () => claude.process.run(command, { ...(workTree === undefined ? {} : { cwd: workTree }), timeoutMs }));
29
32
  return exitCode === 0 ? [] : [`${command.join(' ')} exited with ${exitCode}:\n${`${stdout}${stderr}`.trim()}`];
30
33
  }
31
34
  catch (error) {