@cmodjs/core 0.1.1 → 0.1.3

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/ui.md ADDED
@@ -0,0 +1,295 @@
1
+ # UI
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`.
4
+
5
+ ```ts
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): void
9
+ mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
10
+ mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
11
+ ```
12
+
13
+ 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.
14
+
15
+ ## Elements
16
+
17
+ `ui/elements.js` exports the elements a render draws with. Each takes the props Claude Code types in the `claude-code` module:
18
+
19
+ | Element | Props type | What it draws |
20
+ | --- | --- | --- |
21
+ | `Box` | `BoxProps` | A flex container: `flexDirection`, `gap`, `padding`, `margin`, `minWidth`, and the rest |
22
+ | `Text` | `TextProps` | Text: `bold`, `dimColor`, `color`, `wrap`, and the rest |
23
+ | `Button` | `ButtonProps` | A button: `label` or one string child, `onPress`, `hotkey`, `key`, `variant` |
24
+ | `Link` | `LinkProps` | A link |
25
+ | `Code` | `CodeProps` | Highlighted code |
26
+ | `Markdown` | `MarkdownProps` | Markdown text, as Claude Code draws a reply |
27
+ | `Input` | `InputProps` | A text input |
28
+ | `Select` | `SelectProps` | A choice of options |
29
+ | `Image` | `ImageProps` | An image, with `alt` text |
30
+
31
+ Write them as JSX in a `.tsx` file. The template's `tsconfig.json` sets `jsx: react` with `jsxFactory: h` and `jsxFragmentFactory: Fragment`. Claude Code declares `h` and `Fragment` as globals, and `testMod` supplies them in tests. Never declare, import, or name anything `h` or `Fragment` in a file that writes JSX. A component is a function that returns a `RenderElement`:
32
+
33
+ ```tsx
34
+ import type { RenderElement } from 'claude-code'
35
+ import { Box, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
36
+
37
+ export function Count({ label, count }: { readonly label: string; readonly count: number }): RenderElement {
38
+ return (
39
+ <Box gap={1}>
40
+ <Text bold>{label}</Text>
41
+ <Text dimColor>{`${count}`}</Text>
42
+ </Box>
43
+ )
44
+ }
45
+ ```
46
+
47
+ 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.
48
+
49
+ `drawWith(table, draw, markdown?)` is the function CMod draws a render with. A mod never calls it.
50
+
51
+ ## Panes
52
+
53
+ A pane is a framed region the mod opens. Claude Code docks it beside the transcript in fullscreen, or shows it above the prompt.
54
+
55
+ ### definePane
56
+
57
+ ```ts
58
+ definePane<State>(pane: Pane<State>): Pane<State>
59
+
60
+ type Pane<State> = {
61
+ readonly id: string
62
+ readonly title: string
63
+ readonly columns?: number | ((state: State) => number | undefined)
64
+ readonly rows?: number | ((state: State) => number | undefined)
65
+ render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement
66
+ }
67
+ ```
68
+
69
+ - `id` is 1 to 64 letters, digits, `_`, or `-`. Another id throws `definePane: "<id>" is not a pane id.`
70
+ - `title` labels the pane's tab while more than one pane is open.
71
+ - `columns` and `rows` ask for a size. A number must be a whole number above 0, or `definePane` throws. A function reads the state and returns the size, or `undefined` for Claude Code's default. When the state changes the size, CMod resizes an open pane. A function that returns a bad size keeps an open pane at its size and logs the pane's title to the debug log. `open`, and `toggle` on a closed pane, reject with that size instead.
72
+ - `render` draws the pane's body. `props` holds `title`, `isFocused`, `bodyColumns`, `placement` (`'dock'` or `'inline'`), `scroll`, the body's window over a taller drawing, and `view`, which transcript is on screen beside the pane.
73
+
74
+ Put each pane in its own file in `src/panes/`.
75
+
76
+ ```tsx
77
+ import { definePane } from '../../node_modules/@cmodjs/core/ui/define-pane.js'
78
+ import { Box, Button, Text } from '../../node_modules/@cmodjs/core/ui/elements.js'
79
+
80
+ export type NotesState = { global: { notes: readonly string[] } }
81
+
82
+ export const notesPane = definePane<NotesState>({
83
+ id: 'notes',
84
+ title: 'Notes',
85
+ columns: (state) => (state.global.notes.length > 10 ? 40 : 24),
86
+ render: (mod) => (
87
+ <Box flexDirection="column" gap={1}>
88
+ {mod.state.global.notes.map((note) => <Text>{note}</Text>)}
89
+ <Button label="Clear" onPress={() => { mod.state.global.notes = [] }} />
90
+ </Box>
91
+ ),
92
+ })
93
+ ```
94
+
95
+ ### mod.ui.pane
96
+
97
+ `mod.ui.pane(pane)` adds the pane and returns its handle:
98
+
99
+ ```ts
100
+ type PaneHandle = {
101
+ open(): Promise<void>
102
+ close(): Promise<void>
103
+ toggle(): Promise<void>
104
+ readonly isOpen: boolean
105
+ }
106
+ ```
107
+
108
+ - `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.
109
+ - `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()`.
110
+ - A pane opened from what the person did, such as a slash command they typed or a `Button` they pressed, shows at any terminal width. A pane opened from anything else, such as `setup`, a timer, or `SessionStart`, shows only on a terminal 144 columns wide, or 110 for a pane the person opened before. Below that it waits undrawn until the person opens it or the terminal widens.
111
+ - `close` closes it. The person closes a pane too, and `isOpen` follows.
112
+ - `toggle` closes an open pane and opens a closed one.
113
+ - When the mod starts, `isOpen` is true for each of its panes Claude Code already shows.
114
+ - Adding two panes with one id throws `<mod>: the pane "<id>" is already added. Give each pane its own id.`
115
+ - The line CMod logs when the mod first starts names the pane by its `title`, as in `the Notes pane`.
116
+
117
+ ```tsx
118
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
119
+ import { slashCommand } from '../node_modules/@cmodjs/core/jobs/slash-command.js'
120
+ import { notesPane, type NotesState } from './panes/notes.js'
121
+
122
+ const initialState: NotesState = { global: { notes: [] } }
123
+
124
+ export const notes = defineMod({
125
+ name: 'notes',
126
+ state: initialState,
127
+ setup(mod) {
128
+ const pane = mod.ui.pane(notesPane)
129
+ mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle() }))
130
+ },
131
+ })
132
+ ```
133
+
134
+ ## Slots: change a row Claude Code draws
135
+
136
+ `mod.ui.render(slot, Component)` draws in place of one of Claude Code's own rows. `ui/slots.js` exports `slots`:
137
+
138
+ | Slot | What Claude Code draws there |
139
+ | --- | --- |
140
+ | `slots.AssistantMessage` | One text block of Claude's reply |
141
+ | `slots.UserMessage` | A user row: the prompt, a task notification, or another agent's message |
142
+ | `slots.ToolUse` | A tool call's row |
143
+ | `slots.ToolResult` | A tool call's result |
144
+ | `slots.ToolGroup` | The condensed row of a group of tool calls |
145
+ | `slots.ToolProgress` | A tool's progress line |
146
+ | `slots.CommandOutput` | A slash command's output |
147
+ | `slots.AskUserQuestion` | The dialog the AskUserQuestion tool opens |
148
+ | `slots.InfoNotice` | A dim status line under the logo |
149
+ | `slots.Spinner` | The spinner line while Claude works |
150
+ | `slots.TurnDuration` | The line that closes a turn |
151
+ | `slots.SessionMode` | The session's modes |
152
+ | `slots.PromptHint` | The prompt's hint line, such as `? for shortcuts` |
153
+ | `slots.AbovePrompt` | The band above the prompt, where surveys draw |
154
+
155
+ `Component` gets `SlotProps<S>`: the props Claude Code types as `RenderPropsOf['<Slot>']`, plus `Default`.
156
+
157
+ - `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.
158
+ - 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.
159
+ - 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.
160
+ - 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.
161
+ - 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`.
162
+ - `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.
163
+ - No slot covers the permission dialog.
164
+
165
+ ```tsx
166
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
167
+ import { Box, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
168
+ import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
169
+
170
+ export const hints = defineMod({
171
+ name: 'hints',
172
+ state: { session: { prompts: 0 } },
173
+ setup(mod) {
174
+ mod.ui.render(slots.PromptHint, ({ hint, Default }) => <Default hint={hint.toUpperCase()} />)
175
+ mod.ui.render(slots.AbovePrompt, ({ hasSurvey, Default }) =>
176
+ hasSurvey ? <Default /> : (
177
+ <Box flexDirection="column">
178
+ <Default />
179
+ <Text dimColor>{`Prompts: ${mod.state.session.prompts}`}</Text>
180
+ </Box>
181
+ ))
182
+ mod.on('UserPromptSubmit', () => {
183
+ mod.state.session.prompts += 1
184
+ })
185
+ },
186
+ })
187
+ ```
188
+
189
+ `Slot` and `SlotProps` are the types behind `slots`. Type a component's props as `SlotProps<typeof slots.PromptHint>`.
190
+
191
+ ## Markdown slots: change the blocks of a reply
192
+
193
+ `ui/markdown.js` exports `markdownSlots`, one per markdown block:
194
+
195
+ | Slot | Block | Props beyond `text` and `source` |
196
+ | --- | --- | --- |
197
+ | `markdownSlots.Heading` | A heading | `depth`, 1 to 6 |
198
+ | `markdownSlots.Paragraph` | A paragraph | none |
199
+ | `markdownSlots.CodeBlock` | A code block | `lang`, `meta`, `value` |
200
+ | `markdownSlots.BlockQuote` | A block quote | none |
201
+ | `markdownSlots.List` | A list | `ordered`, `start`, `spread` |
202
+ | `markdownSlots.Table` | A table | `align` |
203
+ | `markdownSlots.ThematicBreak` | A `---` line | none |
204
+ | `markdownSlots.HtmlBlock` | An HTML block | `value` |
205
+
206
+ - `text` is the block's plain text, one line per list item, table row, or quoted line. `source` is the block's markdown.
207
+ - `Default` draws the block as Claude Code would. `<Default source="…" />` draws other markdown in its place.
208
+ - A markdown slot render draws in Claude's replies and in every `Markdown` element of the mod's panes.
209
+ - While a reply streams in, Claude Code draws a block that is not finished yet. The mod draws it once it is.
210
+ - A render that throws falls back to Claude Code's drawing of the block and logs once.
211
+
212
+ ```tsx
213
+ import type { RenderElement } from 'claude-code'
214
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
215
+ import { Text } from '../node_modules/@cmodjs/core/ui/elements.js'
216
+ import { markdownSlots } from '../node_modules/@cmodjs/core/ui/markdown.js'
217
+ import type { SlotProps } from '../node_modules/@cmodjs/core/ui/slots.js'
218
+
219
+ function MermaidBlock({ lang, value, Default }: SlotProps<typeof markdownSlots.CodeBlock>): RenderElement {
220
+ return lang === 'mermaid' ? <Text>{`diagram: ${value}`}</Text> : <Default />
221
+ }
222
+
223
+ export const diagrams = defineMod({
224
+ name: 'diagrams',
225
+ setup(mod) {
226
+ mod.ui.render(markdownSlots.CodeBlock, MermaidBlock)
227
+ },
228
+ })
229
+ ```
230
+
231
+ ```ts
232
+ markdownBlocks<S>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[]
233
+ ```
234
+
235
+ `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.
236
+
237
+ `MarkdownKind`, `MarkdownReader`, and `MarkdownPiece` are the types behind `markdownSlots`. A mod never builds them.
238
+
239
+ ## Toasts
240
+
241
+ `mod.ui.toast(text)` shows the person a short message.
242
+
243
+ ## Progress lines
244
+
245
+ ```ts
246
+ mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
247
+
248
+ type ProgressStep = { readonly done: number; readonly total: number; readonly label?: string }
249
+ ```
250
+
251
+ `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.
252
+
253
+ ```ts
254
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
255
+
256
+ export const indexer = defineMod({
257
+ name: 'indexer',
258
+ setup(mod) {
259
+ mod.on('SessionStart', async () => {
260
+ const entries = await mod.fs.list(mod.projectRoot)
261
+ await mod.ui.progress('Indexing', async (report) => {
262
+ for (const [index, entry] of entries.entries()) report({ done: index + 1, total: entries.length, label: entry.name })
263
+ })
264
+ })
265
+ },
266
+ })
267
+ ```
268
+
269
+ ## Questions
270
+
271
+ ```ts
272
+ mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
273
+
274
+ type AskOptions = { options?: readonly string[]; header?: string; multiSelect?: true }
275
+ ```
276
+
277
+ `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.
278
+
279
+ ```ts
280
+ import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
281
+
282
+ export const committer = defineMod({
283
+ name: 'committer',
284
+ setup(mod) {
285
+ mod.on('Stop', async () => {
286
+ const answer = await mod.ui.ask('Commit the changes?', { options: ['Commit', 'Wait'], header: 'Commit' })
287
+ if (answer === 'Commit') await mod.process.run(['git', 'commit', '-am', 'wip'], { cwd: mod.projectRoot })
288
+ })
289
+ },
290
+ })
291
+ ```
292
+
293
+ ## Test the UI
294
+
295
+ `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmodjs/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Build Claude Code mods: hooks, panes, permissions, checks, and an install that just works.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,8 @@
21
21
  "testing/",
22
22
  "ui/",
23
23
  "utils/",
24
- "src/"
24
+ "src/",
25
+ "docs/"
25
26
  ],
26
27
  "scripts": {
27
28
  "build": "tsc -p tsconfig.build.json && bun build src/vendor.ts --outfile vendor.js --format esm --target browser && NODE_ENV=production bun build src/vendor-markdown.ts --outfile vendor-markdown.js --format esm --target browser --conditions worker",
package/ui/slots.d.ts CHANGED
@@ -18,10 +18,12 @@ export declare const slots: {
18
18
  AssistantMessage: Slot<{
19
19
  text: string;
20
20
  isFirstOfReply: boolean;
21
+ isSummary?: true;
21
22
  onScreen?: import("claude-code").OnScreen | null;
22
23
  }, Partial<Omit<{
23
24
  text: string;
24
25
  isFirstOfReply: boolean;
26
+ isSummary?: true;
25
27
  onScreen?: import("claude-code").OnScreen | null;
26
28
  }, "onScreen">>, {
27
29
  readonly component: "AssistantMessage";
package/ui/slots.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../src/ui/slots.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,aAAa,CAAA;AACxF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAEjD,OAAO,CAAC,MAAM,KAAK,EAAE,OAAO,MAAM,CAAA;AAElC,OAAO,CAAC,MAAM,YAAY,EAAE,OAAO,MAAM,CAAA;AAEzC,KAAK,KAAK,GAAG;IAAE,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAA;AAE1F,MAAM,MAAM,IAAI,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,EAAE,YAAY,SAAS,MAAM,GAAG,MAAM,EAAE,KAAK,SAAS,KAAK,GAAG,KAAK,IAAI,KAAK,GAAG;IAC3H,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;IACvB,QAAQ,CAAC,CAAC,YAAY,CAAC,EAAE,YAAY,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,YAAY,CAAC,CAAC,KAAK,aAAa,CAAA;CAAE,CAAA;AAMhJ,eAAO,MAAM,KAAK;IAChB,gBAAgB;;;;;;;;;;;IAChB,WAAW;;;;;;;;;;;;;;;;;IACX,OAAO;;;;;;;;;;;;;;;;;;;;;IACP,UAAU;;;;;;;;;;;;;;;IACV,SAAS;;;;;;;;;;;;;IACT,YAAY;;;;;;;;;;;IACZ,aAAa;;;;;;;;;;;;;;;IACb,eAAe;;;;;;;;;;;IACf,UAAU;;;;;;;;;;;IACV,OAAO;;;;;;;;;;;;;IACP,YAAY;;;;;;;;;;;IACZ,WAAW;;;;;;;IACX,UAAU;;;;;;;;;;;;;IACV,WAAW;;;;;;;;;;;;;;;;;CACZ,CAAA"}
1
+ {"version":3,"file":"slots.d.ts","sourceRoot":"","sources":["../src/ui/slots.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,aAAa,CAAA;AACxF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAEjD,OAAO,CAAC,MAAM,KAAK,EAAE,OAAO,MAAM,CAAA;AAElC,OAAO,CAAC,MAAM,YAAY,EAAE,OAAO,MAAM,CAAA;AAEzC,KAAK,KAAK,GAAG;IAAE,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAA;AAE1F,MAAM,MAAM,IAAI,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM,EAAE,YAAY,SAAS,MAAM,GAAG,MAAM,EAAE,KAAK,SAAS,KAAK,GAAG,KAAK,IAAI,KAAK,GAAG;IAC3H,QAAQ,CAAC,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;IACvB,QAAQ,CAAC,CAAC,YAAY,CAAC,EAAE,YAAY,CAAA;CACtC,CAAA;AAED,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,YAAY,CAAC,CAAC,KAAK,aAAa,CAAA;CAAE,CAAA;AAMhJ,eAAO,MAAM,KAAK;IAChB,gBAAgB;;;;;;;;;;;;;IAChB,WAAW;;;;;;;;;;;;;;;;;IACX,OAAO;;;;;;;;;;;;;;;;;;;;;IACP,UAAU;;;;;;;;;;;;;;;IACV,SAAS;;;;;;;;;;;;;IACT,YAAY;;;;;;;;;;;IACZ,aAAa;;;;;;;;;;;;;;;IACb,eAAe;;;;;;;;;;;IACf,UAAU;;;;;;;;;;;IACV,OAAO;;;;;;;;;;;;;IACP,YAAY;;;;;;;;;;;IACZ,WAAW;;;;;;;IACX,UAAU;;;;;;;;;;;;;IACV,WAAW;;;;;;;;;;;;;;;;;CACZ,CAAA"}