@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.
- package/docs/commands.md +61 -10
- package/docs/dependencies.md +16 -14
- package/docs/hooks.md +64 -29
- package/docs/index.md +29 -25
- package/docs/install-steps.md +13 -9
- package/docs/jobs.md +59 -111
- package/docs/mod.md +227 -175
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +311 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +68 -23
- package/docs/ui.md +116 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +14 -4
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +178 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +127 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +33 -17
- package/register.d.ts.map +1 -1
- package/register.js +34 -5
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +68 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +4 -2
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +17 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +563 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +10 -1
- package/runtime/engine.d.ts +13 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +68 -31
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +29 -9
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +17 -14
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +414 -212
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +25 -14
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +58 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +433 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +24 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +286 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +62 -118
- package/session/context/context.d.ts +12 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +55 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +178 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +13 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +73 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +146 -0
- package/session/goal/goal.d.ts +11 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +158 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +2 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +13 -4
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +252 -97
- package/src/options.ts +22 -22
- package/src/records.ts +40 -23
- package/src/register.ts +34 -5
- package/src/runtime/announce.ts +76 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +7 -2
- package/src/runtime/conversations.ts +579 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +13 -1
- package/src/runtime/engine.ts +23 -0
- package/src/runtime/grants.ts +69 -29
- package/src/runtime/hooks.ts +39 -14
- package/src/runtime/installer.ts +16 -14
- package/src/runtime/lifecycle.ts +413 -201
- package/src/runtime/metadata.ts +37 -14
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +447 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +334 -0
- package/src/runtime/ui.ts +65 -143
- package/src/session/context/context.ts +14 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +203 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +15 -0
- package/src/session/conversation.ts +194 -0
- package/src/session/goal/goal.ts +10 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +141 -0
- package/src/testing/fake-claude.ts +25 -17
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +277 -0
- package/src/testing.ts +138 -35
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +5 -5
- package/src/utils/changelog.ts +32 -0
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +9 -8
- package/src/utils/paths.ts +21 -4
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +5 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +21 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +81 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +249 -0
- package/testing.d.ts +34 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +115 -23
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +2 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +3 -3
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.js +9 -7
- package/utils/paths.d.ts +4 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +19 -4
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- 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` | `
|
|
47
|
-
| `cwd` | `
|
|
48
|
-
| `dependencies` | The methods other mods answer with | none: every call fails with `<name> is not installed.
|
|
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(
|
|
65
|
-
type(line: string): Promise<
|
|
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,
|
|
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<
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
211
|
+
test('the band shows the prompt count', async () => {
|
|
172
212
|
const tested = testMod(hints)
|
|
173
|
-
await tested.fire(
|
|
213
|
+
await tested.fire(UserPromptSubmit, { prompt: 'hello' })
|
|
174
214
|
|
|
175
|
-
expect(
|
|
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 `
|
|
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`
|
|
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
|
|
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 `
|
|
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(
|
|
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:
|
|
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 `
|
|
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
|
|
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<
|
|
8
|
-
mod.ui.toast
|
|
9
|
-
mod.ui.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
- `
|
|
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 `
|
|
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
|
-
##
|
|
256
|
+
## Places: change a row Claude Code draws
|
|
252
257
|
|
|
253
|
-
`mod.ui.render(
|
|
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
|
-
|
|
|
260
|
+
| Place | What Claude Code draws there |
|
|
256
261
|
| --- | --- |
|
|
257
|
-
| `
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
261
|
-
| `
|
|
262
|
-
| `
|
|
263
|
-
| `
|
|
264
|
-
| `
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
|
|
272
|
-
`Component` gets `
|
|
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
|
|
278
|
-
- A mod renders each
|
|
279
|
-
- `
|
|
280
|
-
-
|
|
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(
|
|
292
|
-
mod.ui.render(
|
|
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(
|
|
304
|
+
mod.on(UserPromptSubmit, () => {
|
|
300
305
|
mod.state.session.prompts += 1
|
|
301
306
|
})
|
|
302
307
|
},
|
|
303
308
|
})
|
|
304
309
|
```
|
|
305
310
|
|
|
306
|
-
`
|
|
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
|
|
313
|
+
## Markdown places: change the blocks of a reply
|
|
309
314
|
|
|
310
|
-
`ui/markdown.js` exports
|
|
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
|
-
|
|
|
317
|
+
| Place | Block | Props beyond `text` and `source` |
|
|
313
318
|
| --- | --- | --- |
|
|
314
|
-
| `
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
317
|
-
| `
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
| `
|
|
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
|
|
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
|
|
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 }:
|
|
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(
|
|
347
|
+
mod.ui.render(CodeBlock, MermaidBlock)
|
|
344
348
|
},
|
|
345
349
|
})
|
|
346
350
|
```
|
|
347
351
|
|
|
348
352
|
```ts
|
|
349
|
-
markdownBlocks<
|
|
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
|
|
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
|
-
|
|
358
|
+
```ts
|
|
359
|
+
import { CodeBlock, markdownBlocks } from '../node_modules/@cmodjs/core/ui/markdown.js'
|
|
357
360
|
|
|
358
|
-
|
|
361
|
+
const diagrams = markdownBlocks(reply, CodeBlock).filter((block) => block.lang === 'mermaid')
|
|
362
|
+
```
|
|
359
363
|
|
|
360
|
-
##
|
|
364
|
+
## Notices
|
|
361
365
|
|
|
362
366
|
```ts
|
|
363
|
-
mod.ui.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
377
|
-
const
|
|
378
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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.
|
package/jobs/check.d.ts.map
CHANGED
|
@@ -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;
|
|
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 {
|
|
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
|
|
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) {
|