@cmodjs/core 3.1.0 → 4.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 (200) hide show
  1. package/docs/commands.md +38 -21
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +93 -65
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +4 -4
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +13 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +41 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/programs.js +1 -1
  85. package/runtime/router.d.ts +2 -2
  86. package/runtime/router.d.ts.map +1 -1
  87. package/runtime/router.js +1 -1
  88. package/runtime/sessions.d.ts +10 -9
  89. package/runtime/sessions.d.ts.map +1 -1
  90. package/runtime/sessions.js +14 -5
  91. package/runtime/state.d.ts +2 -1
  92. package/runtime/state.d.ts.map +1 -1
  93. package/runtime/state.js +1 -0
  94. package/runtime/toaster.d.ts +1 -1
  95. package/runtime/toaster.d.ts.map +1 -1
  96. package/runtime/toaster.js +10 -4
  97. package/runtime/ui.d.ts +87 -5
  98. package/runtime/ui.d.ts.map +1 -1
  99. package/runtime/ui.js +372 -109
  100. package/runtime/watches.d.ts +9 -0
  101. package/runtime/watches.d.ts.map +1 -0
  102. package/runtime/watches.js +73 -0
  103. package/runtime/widgets.d.ts +48 -0
  104. package/runtime/widgets.d.ts.map +1 -0
  105. package/runtime/widgets.js +219 -0
  106. package/session/context/turn/message/message.d.ts.map +1 -1
  107. package/session/context/turn/message/message.js +7 -2
  108. package/session/session.d.ts +11 -5
  109. package/session/session.d.ts.map +1 -1
  110. package/src/hotkeys.ts +51 -0
  111. package/src/installer.ts +8 -4
  112. package/src/jobs/check.ts +4 -3
  113. package/src/jobs/context.ts +3 -2
  114. package/src/jobs/permissions.ts +4 -3
  115. package/src/jobs/program.ts +1 -1
  116. package/src/jobs/prompt.ts +23 -14
  117. package/src/jobs/schedule.ts +16 -2
  118. package/src/jobs/slash-command.ts +10 -7
  119. package/src/jobs/tool.ts +7 -5
  120. package/src/mod.ts +59 -47
  121. package/src/records.ts +46 -31
  122. package/src/register.ts +2 -3
  123. package/src/runtime/announce.ts +6 -7
  124. package/src/runtime/before.ts +4 -4
  125. package/src/runtime/claude.ts +0 -1
  126. package/src/runtime/conversations.ts +66 -20
  127. package/src/runtime/dependencies.ts +2 -1
  128. package/src/runtime/engine.ts +5 -0
  129. package/src/runtime/grants.ts +2 -2
  130. package/src/runtime/hooks.ts +1 -0
  131. package/src/runtime/hotkeys.ts +80 -0
  132. package/src/runtime/installer.ts +24 -12
  133. package/src/runtime/lifecycle.ts +189 -136
  134. package/src/runtime/options.ts +2 -2
  135. package/src/runtime/pages.ts +127 -0
  136. package/src/runtime/parts.ts +72 -0
  137. package/src/runtime/permission-check.ts +2 -2
  138. package/src/runtime/programs.ts +1 -1
  139. package/src/runtime/router.ts +3 -3
  140. package/src/runtime/sessions.ts +24 -15
  141. package/src/runtime/state.ts +3 -1
  142. package/src/runtime/toaster.ts +10 -4
  143. package/src/runtime/ui.ts +405 -115
  144. package/src/runtime/watches.ts +75 -0
  145. package/src/runtime/widgets.ts +287 -0
  146. package/src/session/context/turn/message/message.ts +7 -2
  147. package/src/session/session.ts +10 -6
  148. package/src/testing/fake-claude.ts +9 -6
  149. package/src/testing/fake-elements.ts +7 -4
  150. package/src/testing/fake-sessions.ts +6 -4
  151. package/src/testing.ts +203 -46
  152. package/src/ui/areas.ts +27 -0
  153. package/src/ui/elements.ts +35 -0
  154. package/src/ui/markdown.ts +18 -18
  155. package/src/ui/panes.ts +73 -0
  156. package/src/ui/styles.ts +53 -0
  157. package/src/utils/subscription.ts +1 -0
  158. package/testing/fake-claude.d.ts +0 -1
  159. package/testing/fake-claude.d.ts.map +1 -1
  160. package/testing/fake-claude.js +9 -5
  161. package/testing/fake-elements.js +7 -4
  162. package/testing/fake-sessions.d.ts +2 -1
  163. package/testing/fake-sessions.d.ts.map +1 -1
  164. package/testing/fake-sessions.js +1 -0
  165. package/testing.d.ts +21 -6
  166. package/testing.d.ts.map +1 -1
  167. package/testing.js +206 -45
  168. package/ui/areas.d.ts +29 -0
  169. package/ui/areas.d.ts.map +1 -0
  170. package/ui/areas.js +6 -0
  171. package/ui/elements.d.ts +3 -0
  172. package/ui/elements.d.ts.map +1 -1
  173. package/ui/elements.js +36 -0
  174. package/ui/markdown.d.ts +14 -14
  175. package/ui/markdown.d.ts.map +1 -1
  176. package/ui/markdown.js +11 -11
  177. package/ui/panes.d.ts +71 -0
  178. package/ui/panes.d.ts.map +1 -0
  179. package/ui/panes.js +16 -0
  180. package/ui/styles.d.ts +44 -0
  181. package/ui/styles.d.ts.map +1 -0
  182. package/ui/styles.js +24 -0
  183. package/utils/subscription.d.ts +4 -0
  184. package/utils/subscription.d.ts.map +1 -0
  185. package/jobs/status-line.d.ts +0 -6
  186. package/jobs/status-line.d.ts.map +0 -1
  187. package/jobs/status-line.js +0 -39
  188. package/src/jobs/status-line.ts +0 -44
  189. package/src/ui/define-pane.ts +0 -27
  190. package/src/ui/places.ts +0 -49
  191. package/src/utils/disposable.ts +0 -3
  192. package/ui/define-pane.d.ts +0 -15
  193. package/ui/define-pane.d.ts.map +0 -1
  194. package/ui/define-pane.js +0 -12
  195. package/ui/places.d.ts +0 -43
  196. package/ui/places.d.ts.map +0 -1
  197. package/ui/places.js +0 -31
  198. package/utils/disposable.d.ts +0 -4
  199. package/utils/disposable.d.ts.map +0 -1
  200. /package/utils/{disposable.js → subscription.js} +0 -0
package/docs/session.md CHANGED
@@ -34,7 +34,7 @@ type Sessions = {
34
34
  readonly current: CurrentSession
35
35
  list(options?: { running?: boolean; since?: string; everyProject?: boolean }): Promise<Session[]>
36
36
  search(text: string, matcher?: Matcher<Message>, options?: { running?: boolean; since?: string; everyProject?: boolean; before?: Message }): AsyncIterable<Message>
37
- onMessage(matcher?, listener, options?): Disposable // and every on method below
37
+ onMessage(matcher?, listener, options?): Subscription // and every on method below
38
38
  }
39
39
  ```
40
40
 
@@ -76,8 +76,8 @@ type Session = {
76
76
  turns(options?: { last?: number }): Promise<Turn[]>
77
77
  messages(options?: { after?: string; last?: number }): Promise<Message[]>
78
78
  search(text: string, matcher?: Matcher<Message>): AsyncIterable<Message>
79
- onMessage(matcher?, listener, options?): Disposable // and every on method below
80
- beforePrompt(matcher?, listener, options?): Disposable // and every before method below
79
+ onMessage(matcher?, listener, options?): Subscription // and every on method below
80
+ beforePrompt(matcher?, listener, options?): Subscription // and every before method below
81
81
  send(message: { type: Prompt | Note; text: string; as?: Person | Session }): Promise<void>
82
82
  startTask(task: { prompt: string; title: string; agentType?: string; model?: string; name?: string; cwd?: string }): Promise<Task>
83
83
  callTool(call: { name: string; input: Record<string, unknown> }): Promise<ToolResult>
@@ -109,11 +109,13 @@ const explorers = (await sessions.current.goal.tasks()).filter((task) => task.ow
109
109
 
110
110
  A session that has ended keeps its record, so every read works on it.
111
111
 
112
+ `sessions.current` adds two members for the rows Claude Code draws, `scrollTo(message, options?)` and `caption(toolCall, text)` ([ui.md](ui.md#the-conversations-rows)).
113
+
112
114
  ### Listen to a record
113
115
 
114
116
  ```ts
115
- part.on<Event>(listener: (payload, time: { deadline: number; signal: AbortSignal }) => unknown, options?: { timeoutMs?: number }): Disposable
116
- part.on<Event>(matcher: Matcher<payload>, listener, options?): Disposable
117
+ part.on<Event>(listener: (payload, time: { deadline: number; signal: AbortSignal }) => unknown, options?: { timeoutMs?: number }): Subscription
118
+ part.on<Event>(matcher: Matcher<payload>, listener, options?): Subscription
117
119
  ```
118
120
 
119
121
  Each event has its own method, such as `onReply` or `onTaskStart`, which runs `listener` for each new message, turn, context, task, goal change, or usage change, as it happens. `sessions`, a session, its goal, a task, a context, and a turn each have every `on` method, and each hears what happens inside it: an event reaches the part it happens in and every part above it.
@@ -153,7 +155,7 @@ sessions.current.onToolCall({ name: /^(Edit|Write)$/ }, (call) => void (mod.stat
153
155
  sessions.current.onTaskStart({ owner: { agentType: 'Explore' } }, (task) => mod.log(`Exploring: ${task.title}`))
154
156
  ```
155
157
 
156
- A listener that throws shows the error toast `The <method> listener failed`, such as `The onReply listener failed`. `dispose()` on the `Disposable` stops the listener.
158
+ A listener that throws shows the error toast `The <method> listener failed`, such as `The onReply listener failed`. `remove()` on the `Subscription` stops the listener.
157
159
 
158
160
  ### Give a listener time
159
161
 
@@ -170,8 +172,8 @@ An `on` listener that passes its time stops quietly. A `before` listener that pa
170
172
  ### Change what happens
171
173
 
172
174
  ```ts
173
- part.before<Event>(listener: (payload, time) => Answer | void, options?: { timeoutMs?: number; onFailure?: 'pass' | 'refuse' }): Disposable
174
- part.before<Event>(matcher: Matcher<payload>, listener, options?): Disposable
175
+ part.before<Event>(listener: (payload, time) => Answer | void, options?: { timeoutMs?: number; onFailure?: 'pass' | 'refuse' }): Subscription
176
+ part.before<Event>(matcher: Matcher<payload>, listener, options?): Subscription
175
177
  ```
176
178
 
177
179
  Each `before` method, such as `beforeToolCall`, runs `listener` just before something happens, and its answer changes or refuses it. Returning nothing lets it happen as it was. Each part except `sessions` has every `before` method, and hears what is about to happen inside it, the way `on` methods do.
@@ -277,7 +279,7 @@ mod.use(slashCommand({
277
279
  const task = await sessions.current.startTask({ prompt: 'Summarize README.md in one line.', title: 'Summarize the README', agentType: 'Explore' })
278
280
  task.onTaskEnd(async (ended) => {
279
281
  const [answer] = await ended.owner.messages({ last: 1 })
280
- if (answer?.type === Reply) mod.ui.toast(answer.text)
282
+ if (answer?.type === Reply) mod.toast(answer.text)
281
283
  })
282
284
  return 'Summarizing the README.'
283
285
  },
@@ -300,14 +302,14 @@ type MessageOf<Type> = {
300
302
  }
301
303
  ```
302
304
 
303
- - `id` names the transcript row the message is in. When one row holds several messages, such as a reply with its tool calls, each `id` ends in `:<n>`. `mod.ui.scroll({ to: { requestId: message.id.split(':')[0] } })` scrolls to the message's row ([ui.md](ui.md#scrolling)).
305
+ - `id` names the transcript row the message is in. When one row holds several messages, such as a reply with its tool calls, each `id` ends in `:<n>`. `sessions.current.scrollTo(message)` scrolls to the message's row ([ui.md](ui.md#the-conversations-rows)).
304
306
  - `at` is its ISO time. `session` is the session's id, and `context` the number of the context it is in, from 1.
305
307
  - `turn` is the `id` of the prompt that opened its turn. A task's message names its owner's turn. A message before the first prompt has none.
306
308
  - `task` is the id of the task whose message it is. A message of the session itself has none.
307
309
 
308
310
  | Type | Constant | Its own fields |
309
311
  | --- | --- | --- |
310
- | `'prompt'` | `Prompt` | `text`, `from`: the prompt that opens a turn. A command Claude Code sends as a prompt reads as typed, such as `/review 12`. |
312
+ | `'prompt'` | `Prompt` | `text`, `from`: the prompt that opens a turn. A command Claude Code sends as a prompt reads as typed, such as `/review 12`. A command a mod runs, such as cmod's `/reload-plugins`, is no prompt, and `commands.onRun` hears it. |
311
313
  | `'note'` | `Note` | `text`, `from`: text Claude reads that is not a prompt, such as a mod's note or a task's notification. |
312
314
  | `'reply'` | `Reply` | `text`: what Claude said. |
313
315
  | `'toolCall'` | `ToolCall` | `callId`, `name`, `input`, `cwd`, `files`: a tool Claude called, the folder it called it in, and the files it reads and changes. |
@@ -396,7 +398,7 @@ type Task = {
396
398
 
397
399
  ```ts
398
400
  sessions.current.onTurnEnd((turn) => {
399
- if (turn.failure !== undefined) mod.ui.toast.error(`Claude stopped: ${turn.failure.text}`)
401
+ if (turn.failure !== undefined) mod.toast.error(`Claude stopped: ${turn.failure.text}`)
400
402
  })
401
403
  ```
402
404
  - `abort()` stops a turn while it runs, a task's turn included, and needs the `prompt` permission. It throws on a turn that has ended. Claude Code shows no marker, so a mod that aborts tells the person with a toast.
package/docs/testing.md CHANGED
@@ -19,7 +19,7 @@ test('/todo adds the item to the project list', async () => {
19
19
  })
20
20
  ```
21
21
 
22
- Assert what a person or Claude sees: `tested.lines(...)`, `tested.shown`, `tested.state`, and what `tested.raise...`, `tested.type(...)`, and `tested.callTool(...)` return.
22
+ Assert what a person or Claude sees: `tested.shown.page.lines()`, `tested.lines(...)`, `tested.shown`, `tested.state`, and what `tested.raise...`, `tested.type(...)`, and `tested.callTool(...)` return.
23
23
 
24
24
  ## TestOptions
25
25
 
@@ -46,7 +46,7 @@ type TestOptions<State, Options> = {
46
46
  | `scope` | `'project'` makes the mod a project plugin, in `<projectRoot>/.claude/skills/<name>` | `'user'` |
47
47
  | `projectRoot` | `sessions.current.project` | `/test/plugins/<name>`, or `/test/project` for a project plugin |
48
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
+ | `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed. Install it in Plugins.` |
50
50
  | `files` | The fake file system, absolute path to text | no files |
51
51
  | `links` | Symbolic links in the fake file system, absolute link path to its target | no links |
52
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` |
@@ -68,14 +68,14 @@ type TestedMod<State> = {
68
68
  compact(): Promise<void>
69
69
  startSession(start: { source: 'startup' | 'resume' | 'clear' | 'fork'; id?: string }): Promise<void>
70
70
  startTask(task: { title: string; agentType?: string; model?: string; openedBy?: string; parent?: string }): Promise<string>
71
- lines(paneId: string): Promise<string[]>
72
- lines(place, props, requestId?): Promise<string[]>
71
+ lines(area: Area, props?: { columns?: number }): Promise<string[]>
72
+ lines(kind: Row, props, requestId?): Promise<string[]>
73
73
  type(line: string): Promise<CommandReply>
74
+ hotkey(name: string): Promise<void>
74
75
  callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
75
- press(paneId: string, key: string, link?: string): Promise<void>
76
- input(paneId: string, key: string, text: string, type?: 'submit' | 'change'): Promise<void>
77
- select(paneId: string, key: string, value: string): Promise<void>
76
+ press(area: Area, key: string): Promise<void>
78
77
  moveTo(projectRoot: string, cwd?: string): Promise<void>
78
+ readonly api: { [method: string]: (input?: unknown) => Promise<unknown> }
79
79
  readonly state: Readonly<State>
80
80
  readonly calls: readonly TestCall[]
81
81
  readonly fakes: Fakes
@@ -85,7 +85,7 @@ type TestedMod<State> = {
85
85
 
86
86
  ### start
87
87
 
88
- Starts the mod: loads its state, runs `setup`, and adds its listeners, panes, and jobs. `raise`, `fire`, `lines`, `type`, `callTool`, `press`, `input`, `select`, and `moveTo` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
88
+ Starts the mod: loads its state, runs `setup`, and adds its listeners, pages, panes, widgets, and jobs. `raise`, `fire`, `lines`, `type`, `hotkey`, `callTool`, `press`, `moveTo`, and `api` start the mod first, so a test calls `start` only before it reads `state` or `shown` with nothing else to run. It rejects with the reason when the mod does not start.
89
89
 
90
90
  ### raise
91
91
 
@@ -121,8 +121,8 @@ fire(event: 'tool.check' | 'prompt.context' | 'cmod.call' | 'ui.render' | 'ui.sc
121
121
 
122
122
  - `fire('tool.check', { tool: 'Edit', input: { … }, tool_use_id: 'toolu_1' }, { decision: 'allow' })` asks the mod's `permissions` job and `beforePermissionCheck` listeners about any call. `below` is Claude Code's own verdict.
123
123
  - `fire('prompt.context', { blocks: [] }, { blocks: [] })` starts the conversation's context, which gives each `prompt` job its first block.
124
- - `fire('cmod.call', { to, method, input })` calls a method of the mod's `api`, as another mod's `mod.dependencies` does, and returns `{ value }` with what the method returned, or `{ deny }` with the reason it failed.
125
- - `ui.render`, `ui.scroll`, and `ui.close` are what `lines` and a pane's own events raise. `lines` is the way to draw.
124
+ - `fire('cmod.call', { to, method, input })` calls a method `setup` returned, as another mod's `mod.dependencies` does, and returns `{ value }` with what the method returned, or `{ deny }` with the reason it failed. `tested.api.<method>(input)` does the same and resolves the value, or rejects with the reason.
125
+ - `ui.render`, `ui.scroll`, and `ui.close` are what `lines` and a pane's own events raise. `shown.pane.lines()` and `lines` are the way to draw.
126
126
 
127
127
  ```ts
128
128
  expect(await tested.fire('cmod.call', { to: 'safe-delete', method: 'restore', input: { path: 'a.ts' } })).toEqual({ value: { restored: true } })
@@ -210,30 +210,41 @@ A `before` listener on the current session answers what `raise` asks, such as `t
210
210
 
211
211
  ### lines
212
212
 
213
- - `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.
213
+ - `shown.page.lines()` draws the page the mod shows in the Sidebar now, and `shown.pane.lines()` the pane it shows now, and each returns its rows of text. Each rejects when the mod shows none, such as `No pane of notes shows. Open one with pane.open(), from a slash command, a hotkey, or setup.` A page gets `params`, `isFocused`, and `columns`, 80 unless `lines({ columns })` gives another. A pane gets `params`, `title`, `isFocused`, true once an `open` or `toggle` with `{ focus: true }` opened it, `bodyColumns`, `placement: 'dock'`, `scroll: { offset: 0, bodyRows: 24 }`, and `view: {}`. `shown.page.id` and `shown.pane.id` name what shows, and `params` holds its params.
214
214
  - 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.
215
- - `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.
215
+ - `lines(area)` draws the mod's widgets in that area, in the rows the person would see, and returns them. A new widget is in the first row of its first area. A layout given in `files` as `/test/home/.local/share/cmod/widgets.json` places the widgets another way. `{ columns }` sets the row's width, 80 by default. `lines(TurnEnd)` draws under the last turn that ended, and rejects when none has: end one with `tested.endTurn()` first.
216
+ - `lines(kind, props, requestId?)` draws the mod's style of that kind with `props` and returns its rows. `Default` draws a plain listing of the props it gets, so a test sees what the style changed. Draw `Reply` with a reply's `text` to test a markdown style. Draws that pass one `requestId` grow one streamed reply.
216
217
 
217
218
  ```ts
218
219
  import { expect, test } from 'bun:test'
219
- import type { RenderPropsOf } from 'claude-code'
220
- import { AbovePrompt, Prompt } from '../node_modules/@cmodjs/core/mod.js'
220
+ import { BelowPrompt, Prompt } from '../node_modules/@cmodjs/core/mod.js'
221
221
  import { testMod } from '../node_modules/@cmodjs/core/testing.js'
222
- import { hints } from '../src/mod.js'
222
+ import { prompts } from '../src/mod.js'
223
223
 
224
- const band: RenderPropsOf['AbovePrompt'] = { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 75, scroll: { offset: 0, bodyRows: 12 }, view: {} }
225
-
226
- test('the band shows the prompt count', async () => {
227
- const tested = testMod(hints)
224
+ test('the prompt count shows below the prompt', async () => {
225
+ const tested = testMod(prompts)
228
226
  await tested.add({ type: Prompt, text: 'hello' })
229
227
 
230
- expect(await tested.lines(AbovePrompt, band)).toContain('Prompts: 1')
228
+ expect(await tested.lines(BelowPrompt)).toEqual(['Prompts: 1'])
231
229
  })
232
230
  ```
233
231
 
234
232
  ### type
235
233
 
236
- `type('/<command> <arguments>')` runs a slash command as the person types it and returns its reply, `{ text?, note? }`. The mod's `commands.beforeRun` listeners hear it first, so `type('/compact')` tests a listener that refuses a built-in. It rejects a line that does not start with `/`, and a command nothing in the mod answers.
234
+ `type('/<command> <arguments>')` runs a slash command as the person types it and returns its reply, `{ text?, note? }`. The mod's `commands.beforeRun` listeners hear it first, so `type('/compact')` tests a listener that refuses a built-in. It rejects a line that does not start with `/`, a command nothing in the mod answers, and a command of the mod that calls `next(e)` on to Claude Code's own run of it, which shows the person `<mod> registered /<command> but no command.run hook answered it`. `hotkey` rejects the same way.
235
+
236
+ ### hotkey
237
+
238
+ `hotkey(name)` presses the hotkey `defineMod` declared under `name`, as the person pressing its combo does, and awaits its listeners. A page it opens shows as `shown.page`. It rejects a name the mod does not declare.
239
+
240
+ ```ts
241
+ test('the hotkey shows the prompt count in the Sidebar', async () => {
242
+ const tested = testMod(myMod)
243
+ await tested.add({ type: Prompt, text: 'hello' })
244
+ await tested.hotkey('toggle')
245
+ expect(await tested.shown.page.lines()).toEqual(['Prompts this session: 1'])
246
+ })
247
+ ```
237
248
 
238
249
  ### callTool
239
250
 
@@ -241,17 +252,18 @@ test('the band shows the prompt count', async () => {
241
252
 
242
253
  ### press, input, and select
243
254
 
244
- `press`, `input`, and `select` act on an element of an open pane as the person does, and await its handler. Each passes the handler the event Claude Code passes, with `component: 'Pane'`, `requestId` the pane's id, and `surface: 'terminal'`. Each rejects when the pane does not draw the element: `The pane "<pane>" of <mod> draws no <Element> with the key "<key>".`
255
+ `shown.page` and `shown.pane` act on an element of what shows as the person does, and await its handler. Each rejects when it does not draw the element: `The pane "<pane>" of <mod> draws no <Element> with the key "<key>".`, or the same for a page.
245
256
 
246
- - `press(paneId, key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
247
- - `press(paneId, key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
248
- - `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.
249
- - `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.
257
+ - `press(key)` presses the `Button` whose `key` or `label` is `key`, and runs its `onPress`.
258
+ - `press(key, link)` presses the link `link` in the `Markdown` whose `key` is `key`, and runs its `onLinkPress`.
259
+ - `input(key, text)` types `text` into the `Input` whose `key` is `key` and presses Enter, which runs its `onSubmit`. With a third argument `'change'` it runs `onInput` instead, as one edit does.
260
+ - `select(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.
261
+ - `tested.press(area, key)` presses a `Button` one of the mod's widgets draws in that area, the way the person presses it in a row.
250
262
 
251
263
  ```ts
252
- await tested.input('history', 'search', 'deploy')
253
- await tested.select('history', 'agent', 'explorer')
254
- expect(await tested.lines('history')).toContain('deploy by explorer')
264
+ await tested.shown.page.input('search', 'deploy')
265
+ await tested.shown.page.select('agent', 'explorer')
266
+ expect(await tested.shown.page.lines()).toContain('deploy by explorer')
255
267
  ```
256
268
 
257
269
  ### moveTo
@@ -276,7 +288,7 @@ type Fakes = {
276
288
  settings: { read? }
277
289
  config: { list? }
278
290
  command: { list?, run? }
279
- ui: { ask?, open?, scroll? }
291
+ ui: { ask?, open?, scroll?, copy?, focus? }
280
292
  session: { messages?, append? }
281
293
  prompt: { submit? }
282
294
  model: { complete? }
@@ -294,6 +306,7 @@ Set a fake to answer a call:
294
306
  - `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.
295
307
  - `ui.open` places every pane by default. A fake that answers `{ isPlaced: false }` holds a pane back.
296
308
  - `ui.scroll` answers `{}` by default, as when the window moved. A fake that answers `{ deny }` refuses the scroll.
309
+ - `clipboard.write(text)` reaches `ui.copy({ text })`, so a test keeps each copied text with a fake that answers `{ isCopied: true }`.
297
310
  - `clock.now()` answers the real time unless a fake gives its own, so a test of a deadline can move time. `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.
298
311
  - `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.
299
312
  - `model.complete` has no default: set it to the answer the model gives, such as `async () => ({ isAnswered: true, text: 'no', usage })`.
@@ -333,18 +346,28 @@ type Shown = {
333
346
  readonly prompts: string[]
334
347
  readonly logs: string[]
335
348
  readonly debug: string[]
336
- readonly statuses: (string | undefined)[]
337
349
  readonly openPanes: Set<string>
338
350
  readonly commands: string[]
339
351
  readonly tools: string[]
340
352
  readonly schedules: { readonly id: string; readonly cron: string; readonly prompt: string }[]
341
353
  readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
354
+ readonly page: ShownPart
355
+ readonly pane: ShownPart
356
+ }
357
+
358
+ type ShownPart = {
359
+ readonly id: string
360
+ readonly params: unknown
361
+ lines(options?: { columns?: number }): Promise<string[]>
362
+ press(key: string, link?: string): Promise<void>
363
+ input(key: string, text: string, type?: 'submit' | 'change'): Promise<void>
364
+ select(key: string, value: string): Promise<void>
342
365
  }
343
366
 
344
367
  type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
345
368
  ```
346
369
 
347
- 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.
370
+ 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, the open panes, the slash commands and tools the mod added, and the crons its `schedule` keeps now.
348
371
 
349
372
  ```ts
350
373
  test('the Mode prompt follows the mode', async () => {