@cmodjs/core 3.1.0 → 4.0.1
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 +38 -21
- package/docs/dependencies.md +27 -20
- package/docs/index.md +21 -17
- package/docs/install-steps.md +37 -29
- package/docs/jobs.md +25 -52
- package/docs/mod.md +93 -65
- package/docs/options.md +2 -2
- package/docs/permissions.md +4 -4
- package/docs/session.md +14 -12
- package/docs/testing.md +55 -32
- package/docs/ui.md +255 -263
- package/hotkeys.d.ts +22 -0
- package/hotkeys.d.ts.map +1 -0
- package/hotkeys.js +26 -0
- package/installer.d.ts +1 -1
- package/installer.d.ts.map +1 -1
- package/installer.js +11 -6
- package/jobs/check.d.ts +2 -1
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts +2 -1
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +1 -1
- package/jobs/permissions.d.ts +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +2 -2
- package/jobs/program.js +1 -1
- package/jobs/prompt.d.ts +2 -1
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +20 -13
- package/jobs/schedule.d.ts +2 -1
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +15 -1
- package/jobs/slash-command.d.ts +6 -2
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +6 -5
- package/jobs/tool.d.ts +6 -4
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -3
- package/mod.d.ts +63 -42
- package/mod.d.ts.map +1 -1
- package/mod.js +11 -1
- package/package.json +1 -1
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +41 -31
- package/register.d.ts.map +1 -1
- package/register.js +1 -2
- package/runtime/announce.d.ts +1 -1
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +6 -7
- package/runtime/before.d.ts +2 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +1 -1
- package/runtime/claude.d.ts +0 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +4 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +42 -14
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +2 -1
- package/runtime/engine.d.ts +5 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.js +2 -2
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hotkeys.d.ts +15 -0
- package/runtime/hotkeys.d.ts.map +1 -0
- package/runtime/hotkeys.js +58 -0
- package/runtime/installer.d.ts +5 -2
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +23 -12
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +190 -141
- package/runtime/options.js +2 -2
- package/runtime/pages.d.ts +28 -0
- package/runtime/pages.d.ts.map +1 -0
- package/runtime/pages.js +98 -0
- package/runtime/parts.d.ts +14 -0
- package/runtime/parts.d.ts.map +1 -0
- package/runtime/parts.js +59 -0
- package/runtime/permission-check.d.ts +2 -2
- package/runtime/permission-check.d.ts.map +1 -1
- package/runtime/programs.js +1 -1
- package/runtime/router.d.ts +2 -2
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +1 -1
- package/runtime/sessions.d.ts +10 -9
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +14 -5
- package/runtime/state.d.ts +2 -1
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +1 -0
- package/runtime/toaster.d.ts +1 -1
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +10 -4
- package/runtime/ui.d.ts +87 -5
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +374 -109
- package/runtime/watches.d.ts +9 -0
- package/runtime/watches.d.ts.map +1 -0
- package/runtime/watches.js +73 -0
- package/runtime/widgets.d.ts +48 -0
- package/runtime/widgets.d.ts.map +1 -0
- package/runtime/widgets.js +219 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +7 -2
- package/session/session.d.ts +11 -5
- package/session/session.d.ts.map +1 -1
- package/src/hotkeys.ts +51 -0
- package/src/installer.ts +8 -4
- package/src/jobs/check.ts +4 -3
- package/src/jobs/context.ts +3 -2
- package/src/jobs/permissions.ts +4 -3
- package/src/jobs/program.ts +1 -1
- package/src/jobs/prompt.ts +23 -14
- package/src/jobs/schedule.ts +16 -2
- package/src/jobs/slash-command.ts +10 -7
- package/src/jobs/tool.ts +7 -5
- package/src/mod.ts +59 -47
- package/src/records.ts +46 -31
- package/src/register.ts +2 -3
- package/src/runtime/announce.ts +6 -7
- package/src/runtime/before.ts +4 -4
- package/src/runtime/claude.ts +0 -1
- package/src/runtime/conversations.ts +66 -20
- package/src/runtime/dependencies.ts +2 -1
- package/src/runtime/engine.ts +5 -0
- package/src/runtime/grants.ts +2 -2
- package/src/runtime/hooks.ts +1 -0
- package/src/runtime/hotkeys.ts +80 -0
- package/src/runtime/installer.ts +24 -12
- package/src/runtime/lifecycle.ts +189 -136
- package/src/runtime/options.ts +2 -2
- package/src/runtime/pages.ts +127 -0
- package/src/runtime/parts.ts +70 -0
- package/src/runtime/permission-check.ts +2 -2
- package/src/runtime/programs.ts +1 -1
- package/src/runtime/router.ts +3 -3
- package/src/runtime/sessions.ts +24 -15
- package/src/runtime/state.ts +3 -1
- package/src/runtime/toaster.ts +10 -4
- package/src/runtime/ui.ts +407 -115
- package/src/runtime/watches.ts +75 -0
- package/src/runtime/widgets.ts +287 -0
- package/src/session/context/turn/message/message.ts +7 -2
- package/src/session/session.ts +10 -6
- package/src/testing/fake-claude.ts +9 -6
- package/src/testing/fake-elements.ts +7 -4
- package/src/testing/fake-sessions.ts +6 -4
- package/src/testing.ts +203 -46
- package/src/ui/areas.ts +27 -0
- package/src/ui/elements.ts +35 -0
- package/src/ui/markdown.ts +18 -18
- package/src/ui/panes.ts +73 -0
- package/src/ui/styles.ts +53 -0
- package/src/utils/subscription.ts +1 -0
- package/testing/fake-claude.d.ts +0 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +9 -5
- package/testing/fake-elements.js +7 -4
- package/testing/fake-sessions.d.ts +2 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +1 -0
- package/testing.d.ts +21 -6
- package/testing.d.ts.map +1 -1
- package/testing.js +206 -45
- package/ui/areas.d.ts +29 -0
- package/ui/areas.d.ts.map +1 -0
- package/ui/areas.js +6 -0
- package/ui/elements.d.ts +3 -0
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +36 -0
- package/ui/markdown.d.ts +14 -14
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +11 -11
- package/ui/panes.d.ts +71 -0
- package/ui/panes.d.ts.map +1 -0
- package/ui/panes.js +16 -0
- package/ui/styles.d.ts +44 -0
- package/ui/styles.d.ts.map +1 -0
- package/ui/styles.js +24 -0
- package/utils/subscription.d.ts +4 -0
- package/utils/subscription.d.ts.map +1 -0
- package/jobs/status-line.d.ts +0 -6
- package/jobs/status-line.d.ts.map +0 -1
- package/jobs/status-line.js +0 -39
- package/src/jobs/status-line.ts +0 -44
- package/src/ui/define-pane.ts +0 -27
- package/src/ui/places.ts +0 -49
- package/src/utils/disposable.ts +0 -3
- package/ui/define-pane.d.ts +0 -15
- package/ui/define-pane.d.ts.map +0 -1
- package/ui/define-pane.js +0 -12
- package/ui/places.d.ts +0 -43
- package/ui/places.d.ts.map +0 -1
- package/ui/places.js +0 -31
- package/utils/disposable.d.ts +0 -4
- package/utils/disposable.d.ts.map +0 -1
- /package/utils/{disposable.js → subscription.js} +0 -0
package/src/testing.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
import type { CommandReply } from './jobs/slash-command.js'
|
|
21
21
|
import type { ModDefinition } from './mod.js'
|
|
22
22
|
import type { Options, OptionValues } from './options.js'
|
|
23
|
-
import { readSteps, storeFolder, type Steps } from './records.js'
|
|
23
|
+
import { hotkeyCommand, readSteps, storeFolder, type Steps } from './records.js'
|
|
24
24
|
import type { Claude } from './runtime/claude.js'
|
|
25
25
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
|
|
26
26
|
import { closingContext, foldBefore, installBefore, withAnswer, type Answers } from './runtime/before.js'
|
|
@@ -47,14 +47,30 @@ import { elements, findElement, rowsOf } from './testing/fake-elements.js'
|
|
|
47
47
|
import { fakeFiles } from './testing/fake-files.js'
|
|
48
48
|
import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
|
|
49
49
|
import { listed, messageOf } from './utils/text.js'
|
|
50
|
-
import { componentOf, type
|
|
50
|
+
import { componentOf, type Row, type StyleProps } from './ui/styles.js'
|
|
51
|
+
import { areas, TurnEnd as TurnEndArea, type Area } from './ui/areas.js'
|
|
52
|
+
import { drawWith, elementOf } from './ui/elements.js'
|
|
53
|
+
import { drawnParts } from './runtime/parts.js'
|
|
54
|
+
import { serveWidgets, type Widgets } from './runtime/widgets.js'
|
|
55
|
+
import { engineOf } from './runtime/engine.js'
|
|
56
|
+
import { pagesOf } from './runtime/pages.js'
|
|
57
|
+
import { partMethods, type DrawnPart, type PageRequest, type PartPress, type PartsUpdate } from './runtime/ui.js'
|
|
51
58
|
|
|
52
59
|
export type { Fakes, TestCall } from './testing/fake-claude.js'
|
|
53
60
|
export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
|
|
54
61
|
export type { RoutedEvent } from './runtime/hooks.js'
|
|
55
62
|
export type { ShownToast } from './runtime/toaster.js'
|
|
56
63
|
|
|
57
|
-
export type
|
|
64
|
+
export type ShownPart = {
|
|
65
|
+
readonly id: string
|
|
66
|
+
readonly params: unknown
|
|
67
|
+
lines(options?: { readonly columns?: number }): Promise<string[]>
|
|
68
|
+
press(key: string, link?: string): Promise<void>
|
|
69
|
+
input(key: string, text: string, type?: UiInputArgument['kind']): Promise<void>
|
|
70
|
+
select(key: string, value: string): Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type Shown = ShownByClaude & { readonly toasts: readonly ShownToast[]; readonly sent: FakeSessions['sent']; readonly pane: ShownPart; readonly page: ShownPart }
|
|
58
74
|
|
|
59
75
|
export type TestOptions<State extends object, Declared extends Options = Options> = {
|
|
60
76
|
readonly state?: { readonly [Lifetime in keyof State]?: Partial<State[Lifetime]> }
|
|
@@ -104,14 +120,14 @@ export type TestedMod<State extends object> = {
|
|
|
104
120
|
compact(): Promise<void>
|
|
105
121
|
startSession(start: { readonly source: 'startup' | 'resume' | 'clear' | 'fork'; readonly id?: string }): Promise<void>
|
|
106
122
|
startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): Promise<string>
|
|
107
|
-
lines(
|
|
108
|
-
lines<
|
|
123
|
+
lines(area: Area, props?: { readonly columns?: number }): Promise<string[]>
|
|
124
|
+
lines<K extends Row>(kind: K, props: Omit<StyleProps<K>, 'Default'>, requestId?: string): Promise<string[]>
|
|
109
125
|
type(line: string): Promise<CommandReply>
|
|
126
|
+
hotkey(name: string): Promise<void>
|
|
110
127
|
callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
|
|
111
|
-
press(
|
|
112
|
-
input(paneId: string, key: string, text: string, type?: UiInputArgument['kind']): Promise<void>
|
|
113
|
-
select(paneId: string, key: string, value: string): Promise<void>
|
|
128
|
+
press(area: Area, key: string): Promise<void>
|
|
114
129
|
moveTo(projectRoot: string, cwd?: string): Promise<void>
|
|
130
|
+
readonly api: Readonly<Record<string, (input?: unknown) => Promise<unknown>>>
|
|
115
131
|
readonly state: Readonly<State>
|
|
116
132
|
readonly calls: readonly TestCall[]
|
|
117
133
|
readonly fakes: Fakes
|
|
@@ -153,7 +169,56 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
153
169
|
},
|
|
154
170
|
})
|
|
155
171
|
conversations.start(sessionId)
|
|
156
|
-
|
|
172
|
+
const callMod = async (method: string, input: unknown, caller = cmodPluginName) => calledAnswer(caller, await lifecycle.route('cmod.call', { to: name, method, input, caller } as unknown as Frozen<Args<'cmod.call'>>, async () => ({ deny: notInstalled(name) })))
|
|
173
|
+
let declaredPages: ReturnType<typeof pagesOf> = []
|
|
174
|
+
let openPages: { readonly id: string; readonly params: unknown }[] = []
|
|
175
|
+
let shownPage: { readonly id: string; readonly params: unknown; readonly isFocused: boolean } | undefined
|
|
176
|
+
const tellShown = () => callMod(partMethods.shown, { open: openPages, shown: shownPage?.id ?? null, isFocused: shownPage?.isFocused ?? false })
|
|
177
|
+
const applyRequests = async (requests: readonly PageRequest[]) => {
|
|
178
|
+
for (const request of requests) {
|
|
179
|
+
if ('open' in request) {
|
|
180
|
+
const isSettings = declaredPages.some((page) => page.id === request.open && page.place === 'settings')
|
|
181
|
+
if (!isSettings) openPages = [...openPages.filter((page) => page.id !== request.open), { id: request.open, params: request.params ?? null }]
|
|
182
|
+
shownPage = { id: request.open, params: request.params ?? null, isFocused: request.focus === true }
|
|
183
|
+
} else if ('close' in request) {
|
|
184
|
+
openPages = openPages.filter((page) => page.id !== request.close)
|
|
185
|
+
if (shownPage?.id === request.close) shownPage = undefined
|
|
186
|
+
} else if ('settings' in request) {
|
|
187
|
+
const first = declaredPages.find((page) => page.place === 'settings')
|
|
188
|
+
shownPage = first === undefined ? undefined : { id: first.id, params: null, isFocused: true }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
await tellShown()
|
|
192
|
+
}
|
|
193
|
+
let widgets: Promise<Widgets> | undefined
|
|
194
|
+
const updated = async ({ parts, redraw, requests }: PartsUpdate) => {
|
|
195
|
+
if (parts !== undefined) declaredPages = pagesOf(parts)
|
|
196
|
+
if (parts !== undefined || redraw !== undefined) (await widgetsOf()).update(name, { ...(parts === undefined ? {} : { parts }), ...(redraw === undefined ? {} : { redraw }) })
|
|
197
|
+
if (requests !== undefined && requests.length > 0) await applyRequests(requests)
|
|
198
|
+
}
|
|
199
|
+
const pressPart = (input: PartPress) => callMod(partMethods.press, input).then((update) => updated((update ?? {}) as PartsUpdate))
|
|
200
|
+
const servedWidgets = async (): Promise<Widgets> => {
|
|
201
|
+
const own = name === cmodPluginName && lifecycle.mod !== undefined ? engineOf(lifecycle.mod).widgets : undefined
|
|
202
|
+
if (own !== undefined) return own
|
|
203
|
+
const claude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
|
|
204
|
+
const report = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
|
|
205
|
+
const press = (_mod: string, input: PartPress) => void pressPart(input).catch((error: unknown) => fake.claude.ui.log(`cmod: ${name} did not take the press: ${messageOf(error)}`, { to: 'debug' }))
|
|
206
|
+
return serveWidgets({
|
|
207
|
+
claude,
|
|
208
|
+
router: cmodPlugin,
|
|
209
|
+
store: storeFolder({ HOME: await fake.claude.env.home(), XDG_DATA_HOME: await fake.claude.env.dataHome() }),
|
|
210
|
+
report,
|
|
211
|
+
turnClosedBy: () => conversations.lastTurn(),
|
|
212
|
+
parts: drawnParts({ claude, report, press }),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
const widgetsOf = () => (widgets ??= servedWidgets())
|
|
216
|
+
const dependencies = fakeDependencies(name, options.dependencies ?? {}, conversations)
|
|
217
|
+
fake.fakes.cmod.call = async (call) => {
|
|
218
|
+
if (call.to !== cmodPluginName || call.method !== partMethods.changed) return dependencies(call)
|
|
219
|
+
await updated(call.input as PartsUpdate)
|
|
220
|
+
return { isHeld: (call.input as PartsUpdate).isAsked === true }
|
|
221
|
+
}
|
|
157
222
|
const appendRow = fake.claude.session.append
|
|
158
223
|
fake.claude.session.append = async (args) => {
|
|
159
224
|
const added = await appendRow(args)
|
|
@@ -221,15 +286,25 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
221
286
|
|
|
222
287
|
const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
|
|
223
288
|
await start()
|
|
289
|
+
if (event === 'ui.render') {
|
|
290
|
+
await widgetsOf()
|
|
291
|
+
await fake.settle()
|
|
292
|
+
}
|
|
224
293
|
return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>))
|
|
225
294
|
}
|
|
226
295
|
|
|
296
|
+
const runCommand = async (command: string, args: string): Promise<CommandRunResult> => {
|
|
297
|
+
await start()
|
|
298
|
+
const reached: Set<string> = new Set()
|
|
299
|
+
const ownRun = async (): Promise<CommandRunResult> => (reached.add(command), {})
|
|
300
|
+
const answer = await cmodPlugin.dispatch('command.run', { command, args, origin: { kind: 'composer' }, presentation }, (passed) => lifecycle.route('command.run', passed, ownRun))
|
|
301
|
+
if (reached.has(command) && answer.text === undefined) throw new Error(!fake.shown.commands.includes(command) ? `${name} has no slash command /${command}. Add it in setup with mod.use(slashCommand({ name: '${command}', … })).` : `${name}: /${command} reached Claude Code's own run of the command, so Claude Code shows "${name} registered /${command} but no command.run hook answered it". Answer it without calling next(e).`)
|
|
302
|
+
return answer
|
|
303
|
+
}
|
|
304
|
+
|
|
227
305
|
const drawPane = async (paneId: string) => {
|
|
228
306
|
await start()
|
|
229
|
-
if (!fake.shown.openPanes.has(paneId)) {
|
|
230
|
-
const opening = [...fake.shown.commands.map((command) => `tested.type('/${command}')`), 'pane.open() in the mod'].join(', or with ')
|
|
231
|
-
throw new Error(`The pane "${paneId}" of ${name} is not open, and a user sees a pane only while it is open. Open it first with ${opening}.`)
|
|
232
|
-
}
|
|
307
|
+
if (!fake.shown.openPanes.has(paneId)) throw new Error(`The pane "${paneId}" of ${name} closed, and a user sees a pane only while it is open.`)
|
|
233
308
|
const opened = fake.calls.findLast((call) => call.call === 'ui.open' && (call.args[0] as PaneOpenArgs).id === paneId)?.args[0] as PaneOpenArgs | undefined
|
|
234
309
|
const columns = opened?.columns ?? defaultColumns
|
|
235
310
|
const isFocused = (await fake.claude.ui.panes()).some((open) => open.id === paneId && open.isFocused)
|
|
@@ -238,13 +313,92 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
238
313
|
return { drawing, columns }
|
|
239
314
|
}
|
|
240
315
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
316
|
+
const drawPage = async (pageId: string, columns = defaultColumns) => {
|
|
317
|
+
await start()
|
|
318
|
+
const page = shownPage
|
|
319
|
+
if (page?.id !== pageId) throw new Error(`The page "${pageId}" of ${name} no longer shows, and a user sees a page only while it shows.`)
|
|
320
|
+
const drawn = (await callMod(partMethods.draw, { page: pageId, params: page.params, columns, isFocused: page.isFocused })) as DrawnPart | null
|
|
321
|
+
await fake.settle()
|
|
322
|
+
const tree = drawn?.tree ?? null
|
|
323
|
+
const drawing = tree === null || drawn === null ? elements.Box({}) : (drawWith(elements, () => elementOf(tree, '', (handler, args) => void pressPart({ draw: drawn.draw, handler, args }))) as RenderElement)
|
|
324
|
+
return { drawing, columns }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const shownNow = (isPane: boolean): { readonly id: string; readonly params: unknown } => {
|
|
328
|
+
if (isPane) {
|
|
329
|
+
const id = [...fake.shown.openPanes].at(-1)
|
|
330
|
+
if (id === undefined) throw new Error(`No pane of ${name} shows. Open one with pane.open(), from a slash command, a hotkey, or setup.`)
|
|
331
|
+
return { id, params: undefined }
|
|
332
|
+
}
|
|
333
|
+
if (shownPage === undefined) throw new Error(`No page of ${name} shows in the Sidebar. Open one with page.open(), or Settings with mod.settings.open().`)
|
|
334
|
+
return shownPage
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const shownPart = (isPane: boolean): ShownPart => {
|
|
338
|
+
const kind = isPane ? 'pane' : 'page'
|
|
339
|
+
const draw = async (columns?: number) => {
|
|
340
|
+
await start()
|
|
341
|
+
const { id } = shownNow(isPane)
|
|
342
|
+
return { id, ...(isPane ? await drawPane(id) : await drawPage(id, columns)) }
|
|
343
|
+
}
|
|
344
|
+
const drawnElement = async (type: 'Button' | 'Input' | 'Select' | 'Markdown', key: string) => {
|
|
345
|
+
const { id, drawing } = await draw()
|
|
346
|
+
const props = findElement(drawing, type, key)
|
|
347
|
+
if (props === undefined) throw new Error(`The ${kind} "${id}" of ${name} draws no ${type} with the ${type === 'Button' ? 'key or label' : 'key'} "${key}".`)
|
|
348
|
+
return { id, props }
|
|
349
|
+
}
|
|
350
|
+
const eventIn = (id: string, key: string) => ({ plugin: name, element: key, component: 'Pane' as const, requestId: id, surface: 'terminal' as const })
|
|
351
|
+
return {
|
|
352
|
+
get id() {
|
|
353
|
+
return shownNow(isPane).id
|
|
354
|
+
},
|
|
355
|
+
get params() {
|
|
356
|
+
return shownNow(isPane).params
|
|
357
|
+
},
|
|
358
|
+
lines: async (lineOptions) => {
|
|
359
|
+
const { drawing, columns } = await draw(lineOptions?.columns)
|
|
360
|
+
return rowsOf(drawing, columns).map((row) => row.trimEnd())
|
|
361
|
+
},
|
|
362
|
+
async press(key, link) {
|
|
363
|
+
const { id, props } = await drawnElement(link === undefined ? 'Button' : 'Markdown', key)
|
|
364
|
+
if (link !== undefined && props['onLinkPress'] === undefined) throw new Error(`The Markdown "${key}" in the ${kind} "${id}" of ${name} has no onLinkPress, so Claude Code opens its links itself.`)
|
|
365
|
+
const pressed: UiPressArgument = { ...eventIn(id, String(props['key'])), ...(link === undefined ? {} : { link: { href: link } }) }
|
|
366
|
+
const run = async (e: Frozen<UiPressArgument>) => {
|
|
367
|
+
if (e.link === undefined) await (props['onPress'] as (e: Frozen<UiPressArgument>) => unknown)(e)
|
|
368
|
+
else await (props['onLinkPress'] as (link: PressedLink, e: Frozen<UiPressArgument>) => unknown)(e.link, e)
|
|
369
|
+
return { element: e.element }
|
|
370
|
+
}
|
|
371
|
+
if (isPane) await lifecycle.route('ui.press', pressed, run)
|
|
372
|
+
else await run(pressed)
|
|
373
|
+
await fake.settle()
|
|
374
|
+
},
|
|
375
|
+
async input(key, text, type = 'submit') {
|
|
376
|
+
const { id, props } = await drawnElement('Input', key)
|
|
377
|
+
const typed: UiInputArgument = { ...eventIn(id, key), kind: type, value: text }
|
|
378
|
+
await (props[type === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
|
|
379
|
+
await fake.settle()
|
|
380
|
+
},
|
|
381
|
+
async select(key, value) {
|
|
382
|
+
const { id, props } = await drawnElement('Select', key)
|
|
383
|
+
const choices = (props['options'] as readonly { readonly value: string }[]).map((option) => option.value)
|
|
384
|
+
if (!choices.includes(value)) throw new Error(`The Select "${key}" in the ${kind} "${id}" of ${name} has no option "${value}". Its options are ${choices.map((option) => `"${option}"`).join(', ')}.`)
|
|
385
|
+
const picked: UiSelectArgument = { ...eventIn(id, key), value }
|
|
386
|
+
await (props['onSelect'] as (value: string, e: UiSelectArgument) => unknown)(value, picked)
|
|
387
|
+
await fake.settle()
|
|
388
|
+
},
|
|
389
|
+
}
|
|
245
390
|
}
|
|
246
391
|
|
|
247
|
-
const
|
|
392
|
+
const isArea = (target: string): target is Area => (areas as readonly string[]).includes(target)
|
|
393
|
+
|
|
394
|
+
const drawWidgets = async (area: Area, columns = defaultColumns) => {
|
|
395
|
+
await start()
|
|
396
|
+
const served = await widgetsOf()
|
|
397
|
+
await fake.settle()
|
|
398
|
+
const turn = area === TurnEndArea ? conversations.lastTurn() : undefined
|
|
399
|
+
if (area === TurnEndArea && turn?.closedAt === undefined) throw new Error(`${name} has no ended turn, so TurnEnd draws nothing. End one with tested.add({ type: Prompt, text: '…' }), then tested.endTurn().`)
|
|
400
|
+
return { drawing: elements.Box({ flexDirection: 'column', children: [...(await served.draw(area, elements, columns, turn))] }), columns }
|
|
401
|
+
}
|
|
248
402
|
|
|
249
403
|
const drawSlot = async (component: RenderComponent, props: object, requestId = `${component}_${(slotDraws += 1)}`) => {
|
|
250
404
|
await start()
|
|
@@ -363,20 +517,21 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
363
517
|
await start()
|
|
364
518
|
return settled(conversations.startTask(task))
|
|
365
519
|
},
|
|
366
|
-
lines: async (target: string, props?:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
520
|
+
lines: async (target: string, props?: { readonly columns?: number }, requestId?: string) => {
|
|
521
|
+
if (isArea(target) && (props === undefined || Object.keys(props).every((key) => key === 'columns'))) {
|
|
522
|
+
const { drawing, columns } = await drawWidgets(target, props?.columns)
|
|
523
|
+
return rowsOf(drawing, columns).map((row) => row.trimEnd())
|
|
524
|
+
}
|
|
525
|
+
const component = componentOf(target as Row)
|
|
526
|
+
if (props === undefined || component === undefined) throw new Error(`lines takes an area, such as AbovePrompt, or a row kind, such as ToolCall, with its props, and ${target} is neither. Read a pane with tested.shown.pane.lines() and a page with tested.shown.page.lines().`)
|
|
527
|
+
const { drawing, columns } = await drawSlot(component, props, requestId)
|
|
370
528
|
return rowsOf(drawing, columns).map((row) => row.trimEnd())
|
|
371
529
|
},
|
|
372
530
|
async type(line) {
|
|
373
531
|
const typed = /^\/(\S+)\s*(.*)$/s.exec(line)
|
|
374
532
|
if (typed === null) throw new Error(`type takes a slash command the way the user types it, such as tested.type('/help'), and "${line}" does not start with /.`)
|
|
375
533
|
const [, command = '', args = ''] = typed
|
|
376
|
-
|
|
377
|
-
const answer = await route('command.run', { command, args, origin: { kind: 'composer' }, presentation }, unanswered)
|
|
378
|
-
if (answer === unanswered) throw new Error(`${name} has no slash command /${command}. Add it in setup with mod.use(slashCommand({ name: '${command}', … })).`)
|
|
379
|
-
return replyOf(answer)
|
|
534
|
+
return replyOf(await runCommand(command, args))
|
|
380
535
|
},
|
|
381
536
|
async callTool(toolName, input) {
|
|
382
537
|
const tool = `mcp__${name}__${toolName}`
|
|
@@ -389,28 +544,28 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
389
544
|
if (answer === unanswered) throw new Error(`${name} has no tool ${toolName}. Add it in setup with mod.use(tool({ name: '${toolName}', … })).`)
|
|
390
545
|
return answer
|
|
391
546
|
},
|
|
392
|
-
async
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
await lifecycle.route('ui.press', pressed, async (e) => {
|
|
397
|
-
if (e.link === undefined) await (props['onPress'] as (e: UiPressArgument) => unknown)(e)
|
|
398
|
-
else await (props['onLinkPress'] as (link: PressedLink, e: UiPressArgument) => unknown)(e.link, e)
|
|
399
|
-
return { element: e.element }
|
|
400
|
-
})
|
|
401
|
-
},
|
|
402
|
-
async input(paneId, key, text, type = 'submit') {
|
|
403
|
-
const props = await drawnElement(paneId, 'Input', key)
|
|
404
|
-
const typed: UiInputArgument = { ...eventIn(paneId, key), kind: type, value: text }
|
|
405
|
-
await (props[type === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
|
|
547
|
+
async hotkey(hotkeyName) {
|
|
548
|
+
if ((definition.hotkeys as Readonly<Record<string, unknown>> | undefined)?.[hotkeyName] === undefined) throw new Error(`${name} has no hotkey ${hotkeyName}. Declare it in defineMod: hotkeys: { ${hotkeyName}: { title: '…', default: 'ctrl+…' } }.`)
|
|
549
|
+
await runCommand(hotkeyCommand(name, hotkeyName), '')
|
|
550
|
+
await fake.settle()
|
|
406
551
|
},
|
|
407
|
-
async
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
await (
|
|
552
|
+
async press(area: Area, key: string) {
|
|
553
|
+
const { drawing } = await drawWidgets(area)
|
|
554
|
+
const keys = [key, ...(await widgetsOf()).widgets().filter((widget) => widget.mod === name).map((widget) => `${widget.key}:${key}`)]
|
|
555
|
+
const button = keys.map((each) => findElement(drawing, 'Button', each)).find((found) => found !== undefined)
|
|
556
|
+
if (button === undefined) throw new Error(`The widgets of ${name} in ${area} draw no Button with the key or label "${key}".`)
|
|
557
|
+
await (button['onPress'] as () => unknown)()
|
|
558
|
+
await fake.settle()
|
|
413
559
|
},
|
|
560
|
+
api: new Proxy({}, {
|
|
561
|
+
get: (_target, method) =>
|
|
562
|
+
method === 'then'
|
|
563
|
+
? undefined
|
|
564
|
+
: async (input?: unknown) => {
|
|
565
|
+
await start()
|
|
566
|
+
return callMod(String(method), input ?? null, 'tester')
|
|
567
|
+
},
|
|
568
|
+
}),
|
|
414
569
|
async moveTo(nextRoot, nextCwd = nextRoot) {
|
|
415
570
|
await start()
|
|
416
571
|
if (nextRoot !== projectRoot) {
|
|
@@ -438,6 +593,8 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
438
593
|
return lifecycle.toasts
|
|
439
594
|
},
|
|
440
595
|
sent: conversations.sent,
|
|
596
|
+
pane: shownPart(true),
|
|
597
|
+
page: shownPart(false),
|
|
441
598
|
},
|
|
442
599
|
}
|
|
443
600
|
keepClaudeCode(tested, { fire })
|
package/src/ui/areas.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RenderElement } from 'claude-code'
|
|
2
|
+
import type { Mod, Turn } from '../mod.js'
|
|
3
|
+
|
|
4
|
+
export const AbovePrompt = 'abovePrompt'
|
|
5
|
+
export const BelowPrompt = 'belowPrompt'
|
|
6
|
+
export const Working = 'working'
|
|
7
|
+
export const TurnEnd = 'turnEnd'
|
|
8
|
+
export const Dashboard = 'dashboard'
|
|
9
|
+
|
|
10
|
+
export const areas = [AbovePrompt, BelowPrompt, Working, TurnEnd, Dashboard] as const
|
|
11
|
+
|
|
12
|
+
export type Area = (typeof areas)[number]
|
|
13
|
+
|
|
14
|
+
export type WidgetContext = { readonly area: typeof TurnEnd; readonly columns: number; readonly turn: Turn } | { readonly area: Exclude<Area, typeof TurnEnd>; readonly columns: number }
|
|
15
|
+
|
|
16
|
+
export type WidgetInit<State extends object = Record<never, never>> = {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly title: string
|
|
19
|
+
readonly areas: readonly [Area, ...Area[]]
|
|
20
|
+
render(mod: Mod<State>, context: WidgetContext): RenderElement | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Widget = {
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly area: Area | undefined
|
|
26
|
+
remove(): void
|
|
27
|
+
}
|
package/src/ui/elements.ts
CHANGED
|
@@ -41,6 +41,39 @@ function built(current: Drawing, value: unknown): unknown {
|
|
|
41
41
|
return build(current, name, props)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const elementKey = '$cmodElement'
|
|
45
|
+
|
|
46
|
+
const handlerKey = '$cmodHandler'
|
|
47
|
+
|
|
48
|
+
export type Handler = (...args: unknown[]) => unknown
|
|
49
|
+
|
|
50
|
+
export function elementData(value: unknown, handlers: Handler[]): unknown {
|
|
51
|
+
if (typeof value === 'function') return { [handlerKey]: handlers.push(value as Handler) - 1 }
|
|
52
|
+
if (Array.isArray(value)) return value.map((each: unknown) => elementData(each, handlers))
|
|
53
|
+
if (typeof value !== 'object' || value === null) return value
|
|
54
|
+
if (unbuilt in value) {
|
|
55
|
+
const { name, props } = (value as Unbuilt)[unbuilt]
|
|
56
|
+
return { [elementKey]: name, props: elementData(props, handlers) }
|
|
57
|
+
}
|
|
58
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) throw new Error('a widget draws only the elements of ui/elements.js and ui/components.js, text, numbers, and plain objects.')
|
|
59
|
+
return Object.fromEntries(Object.entries(value).map(([key, each]) => [key, elementData(each, handlers)]))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function elementOf(data: unknown, keyPrefix: string, press: (handler: number, args: readonly unknown[]) => void): unknown {
|
|
63
|
+
if (Array.isArray(data)) return data.map((each: unknown) => elementOf(each, keyPrefix, press))
|
|
64
|
+
if (typeof data !== 'object' || data === null) return data
|
|
65
|
+
const fields = data as Readonly<Record<string, unknown>>
|
|
66
|
+
if (typeof fields[handlerKey] === 'number') {
|
|
67
|
+
const handler = fields[handlerKey]
|
|
68
|
+
return (...args: unknown[]) => press(handler, JSON.parse(JSON.stringify(args)) as unknown[])
|
|
69
|
+
}
|
|
70
|
+
const name = fields[elementKey]
|
|
71
|
+
if (name === undefined) return Object.fromEntries(Object.entries(fields).map(([key, each]) => [key, elementOf(each, keyPrefix, press)]))
|
|
72
|
+
if (typeof name !== 'string' || !Object.hasOwn(constructors, name)) throw new Error(`a widget drew ${String(name)}, which is not an element of ui/elements.js.`)
|
|
73
|
+
const props = elementOf(fields['props'], keyPrefix, press) as Record<string, unknown>
|
|
74
|
+
return constructors[name as keyof typeof constructors]((typeof props['key'] === 'string' ? { ...props, key: `${keyPrefix}${props['key']}` } : props) as never)
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
export const Box = element('Box')
|
|
45
78
|
export const Text = element('Text')
|
|
46
79
|
export const Button = element('Button')
|
|
@@ -50,3 +83,5 @@ export const Markdown = element('Markdown')
|
|
|
50
83
|
export const Input = element('Input')
|
|
51
84
|
export const Select = element('Select')
|
|
52
85
|
export const Image = element('Image')
|
|
86
|
+
|
|
87
|
+
const constructors = { Box, Text, Button, Link, Code, Markdown, Input, Select, Image }
|
package/src/ui/markdown.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Nodes, RootContent } from 'mdast'
|
|
2
2
|
import { fromMarkdown, gfm, gfmFromMarkdown, toString } from '../vendor-markdown.js'
|
|
3
|
-
import type {
|
|
3
|
+
import type { StyleProps } from './styles.js'
|
|
4
4
|
|
|
5
|
-
type
|
|
5
|
+
type BlockText = { readonly text: string; readonly source: string }
|
|
6
6
|
|
|
7
7
|
type BlockFields = {
|
|
8
8
|
heading: { readonly depth: 1 | 2 | 3 | 4 | 5 | 6 }
|
|
@@ -12,30 +12,30 @@ type BlockFields = {
|
|
|
12
12
|
html: { readonly value: string }
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export type BlockProps<Type extends string> = Type extends keyof BlockFields ?
|
|
15
|
+
export type BlockProps<Type extends string> = Type extends keyof BlockFields ? BlockText & BlockFields[Type] : BlockText
|
|
16
16
|
|
|
17
|
-
export type MarkdownPiece<Render> = { readonly text: string } | { readonly render: Render; readonly props:
|
|
17
|
+
export type MarkdownPiece<Render> = { readonly text: string } | { readonly render: Render; readonly props: BlockText }
|
|
18
18
|
|
|
19
19
|
export type MarkdownReader = <Render>(text: string, renderOf: (type: string) => Render | undefined, replyId?: string) => readonly MarkdownPiece<Render>[]
|
|
20
20
|
|
|
21
|
-
export type
|
|
21
|
+
export type Block<Type extends string = string> = {
|
|
22
22
|
readonly name: string
|
|
23
23
|
readonly markdown: Type
|
|
24
24
|
createReader(): MarkdownReader
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function
|
|
27
|
+
function blockKind<const Type extends string>(name: string, markdown: Type): Block<Type> {
|
|
28
28
|
return { name, markdown, createReader }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export const Heading =
|
|
32
|
-
export const Paragraph =
|
|
33
|
-
export const CodeBlock =
|
|
34
|
-
export const BlockQuote =
|
|
35
|
-
export const List =
|
|
36
|
-
export const Table =
|
|
37
|
-
export const ThematicBreak =
|
|
38
|
-
export const HtmlBlock =
|
|
31
|
+
export const Heading = blockKind('Heading', 'heading')
|
|
32
|
+
export const Paragraph = blockKind('Paragraph', 'paragraph')
|
|
33
|
+
export const CodeBlock = blockKind('CodeBlock', 'code')
|
|
34
|
+
export const BlockQuote = blockKind('BlockQuote', 'blockquote')
|
|
35
|
+
export const List = blockKind('List', 'list')
|
|
36
|
+
export const Table = blockKind('Table', 'table')
|
|
37
|
+
export const ThematicBreak = blockKind('ThematicBreak', 'thematicBreak')
|
|
38
|
+
export const HtmlBlock = blockKind('HtmlBlock', 'html')
|
|
39
39
|
|
|
40
40
|
type Parsed = {
|
|
41
41
|
readonly type: string
|
|
@@ -44,7 +44,7 @@ type Parsed = {
|
|
|
44
44
|
readonly isClosed: boolean
|
|
45
45
|
readonly canSpanBlankLines: boolean
|
|
46
46
|
readonly hasDefinition: boolean
|
|
47
|
-
readonly props:
|
|
47
|
+
readonly props: BlockText
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
type Reply = { readonly id: string; readonly text: string; readonly blocks: readonly Parsed[] }
|
|
@@ -163,7 +163,7 @@ function plainText(node: Nodes): string {
|
|
|
163
163
|
return node.children.map(plainText).join(node.type === 'tableRow' ? '\t' : '\n')
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
export function markdownBlocks<
|
|
167
|
-
const blocks = parsed(text, 0).filter((block) => block.type ===
|
|
168
|
-
return blocks.map((block) => block.props) as unknown as Omit<
|
|
166
|
+
export function markdownBlocks<K extends Block>(text: string, kind: K): Omit<StyleProps<K>, 'Default'>[] {
|
|
167
|
+
const blocks = parsed(text, 0).filter((block) => block.type === kind.markdown)
|
|
168
|
+
return blocks.map((block) => block.props) as unknown as Omit<StyleProps<K>, 'Default'>[]
|
|
169
169
|
}
|
package/src/ui/panes.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Frozen, PaneCloseInput, RenderElement, RenderPropsOf, UiScrollInput } from 'claude-code'
|
|
2
|
+
import type { Mod } from '../mod.js'
|
|
3
|
+
|
|
4
|
+
export type ScrollTarget = 'start' | 'end' | { readonly key: string }
|
|
5
|
+
|
|
6
|
+
export type ScrollOptions = { readonly block?: 'start' | 'center' | 'end' | 'nearest' }
|
|
7
|
+
|
|
8
|
+
export type OpenOptions<Params> = { readonly focus?: true } & (undefined extends Params ? { readonly params?: Params } : { readonly params: Params })
|
|
9
|
+
|
|
10
|
+
export type PaneProps<Params> = Frozen<RenderPropsOf['Pane']> & { readonly params: Params }
|
|
11
|
+
|
|
12
|
+
export type PaneInit<State extends object = Record<never, never>, Params = undefined> = {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly title: string
|
|
15
|
+
readonly columns?: number | ((state: State) => number | undefined)
|
|
16
|
+
readonly rows?: number | ((state: State) => number | undefined)
|
|
17
|
+
readonly closeOnEscape?: true
|
|
18
|
+
readonly holdToasts?: true
|
|
19
|
+
render(mod: Mod<State>, props: PaneProps<Params>): RenderElement
|
|
20
|
+
onScroll?(mod: Mod<State>, e: Frozen<UiScrollInput>): void | Promise<void>
|
|
21
|
+
onClose?(mod: Mod<State>, e: Frozen<PaneCloseInput>): void | Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Pane<Params = undefined> = {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly isOpen: boolean
|
|
27
|
+
readonly isShown: boolean
|
|
28
|
+
readonly isFocused: boolean
|
|
29
|
+
readonly params: Params | undefined
|
|
30
|
+
open(...options: undefined extends Params ? [options?: OpenOptions<Params>] : [options: OpenOptions<Params>]): Promise<void>
|
|
31
|
+
toggle(options?: { readonly focus?: true }): Promise<void>
|
|
32
|
+
close(): Promise<void>
|
|
33
|
+
scrollTo(target: ScrollTarget, options?: ScrollOptions): Promise<void>
|
|
34
|
+
remove(): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type PageProps<Params> = { readonly params: Params; readonly isFocused: boolean; readonly columns: number }
|
|
38
|
+
|
|
39
|
+
export type PageInit<State extends object = Record<never, never>, Params = undefined> = {
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly title: string
|
|
42
|
+
render(mod: Mod<State>, props: PageProps<Params>): RenderElement
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Page<Params = undefined> = {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly isOpen: boolean
|
|
48
|
+
readonly isShown: boolean
|
|
49
|
+
readonly isFocused: boolean
|
|
50
|
+
readonly params: Params | undefined
|
|
51
|
+
open(...options: undefined extends Params ? [options?: OpenOptions<Params>] : [options: OpenOptions<Params>]): Promise<void>
|
|
52
|
+
toggle(options?: { readonly focus?: true }): Promise<void>
|
|
53
|
+
close(): Promise<void>
|
|
54
|
+
scrollTo(target: ScrollTarget, options?: ScrollOptions): Promise<void>
|
|
55
|
+
remove(): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const partId = /^[A-Za-z0-9_-]{1,64}$/
|
|
59
|
+
|
|
60
|
+
export const sizeKeys = ['columns', 'rows'] as const
|
|
61
|
+
|
|
62
|
+
export function checkPart(name: string, kind: string, id: string, title: string): void {
|
|
63
|
+
if (!partId.test(id)) throw new Error(`${name}: "${id}" is not a ${kind} id. Use 1 to 64 letters, digits, "_", or "-".`)
|
|
64
|
+
if (title.trim() === '') throw new Error(`${name}: the ${kind} "${id}" needs a title, the label its tab shows.`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function checkPane<State extends object, Params>(name: string, pane: PaneInit<State, Params>): void {
|
|
68
|
+
checkPart(name, 'pane', pane.id, pane.title)
|
|
69
|
+
for (const key of sizeKeys) {
|
|
70
|
+
const size = pane[key]
|
|
71
|
+
if (typeof size !== 'function' && size !== undefined && (!Number.isInteger(size) || size <= 0)) throw new Error(`${name}: the pane "${pane.id}" has ${key} ${size}. Use a whole number above 0, or leave it out.`)
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/ui/styles.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Frozen, RenderComponent, RenderElement, RenderPropsOf } from 'claude-code'
|
|
2
|
+
import type { Mod } from '../mod.js'
|
|
3
|
+
import { TurnEnd, Working } from './areas.js'
|
|
4
|
+
import type { Block, BlockProps } from './markdown.js'
|
|
5
|
+
|
|
6
|
+
export const ToolGroup = 'toolGroup'
|
|
7
|
+
export const ToolProgress = 'toolProgress'
|
|
8
|
+
export const CommandOutput = 'commandOutput'
|
|
9
|
+
export const AskUserQuestion = 'askUserQuestion'
|
|
10
|
+
|
|
11
|
+
const components = {
|
|
12
|
+
prompt: 'UserMessage',
|
|
13
|
+
reply: 'AssistantMessage',
|
|
14
|
+
toolCall: 'ToolUse',
|
|
15
|
+
toolResult: 'ToolResult',
|
|
16
|
+
notice: 'InfoNotice',
|
|
17
|
+
[ToolGroup]: 'ToolGroup',
|
|
18
|
+
[ToolProgress]: 'ToolProgress',
|
|
19
|
+
[CommandOutput]: 'CommandOutput',
|
|
20
|
+
[AskUserQuestion]: 'AskUserQuestion',
|
|
21
|
+
[Working]: 'Spinner',
|
|
22
|
+
[TurnEnd]: 'TurnDuration',
|
|
23
|
+
} as const satisfies Readonly<Record<string, Exclude<RenderComponent, 'Pane'>>>
|
|
24
|
+
|
|
25
|
+
export type Row = keyof typeof components
|
|
26
|
+
|
|
27
|
+
export type Kind = Row | Block
|
|
28
|
+
|
|
29
|
+
type RowProps = { readonly [R in Row]: RenderPropsOf[(typeof components)[R]] }
|
|
30
|
+
|
|
31
|
+
type PropsOf<K extends Kind> = K extends Row ? RowProps[K] : K extends Block<infer Type> ? BlockProps<Type> : never
|
|
32
|
+
|
|
33
|
+
type DefaultOf<K extends Kind> = K extends Row ? Partial<Omit<RowProps[K], 'onScreen'>> : { readonly source?: string }
|
|
34
|
+
|
|
35
|
+
export type StyleProps<K extends Kind> = Frozen<PropsOf<K>> & { readonly Default: (props: Frozen<DefaultOf<K>>) => RenderElement }
|
|
36
|
+
|
|
37
|
+
export type StyleInit<K extends Kind, State extends object = Record<never, never>> = {
|
|
38
|
+
readonly kind: K
|
|
39
|
+
render(mod: Mod<State>, props: StyleProps<K>): RenderElement
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Style = {
|
|
43
|
+
readonly kind: Kind
|
|
44
|
+
remove(): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function componentOf(kind: Kind): Exclude<RenderComponent, 'Pane'> | undefined {
|
|
48
|
+
return typeof kind === 'string' && Object.hasOwn(components, kind) ? components[kind] : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function nameOf(kind: Kind): string {
|
|
52
|
+
return typeof kind === 'string' ? `${kind.charAt(0).toUpperCase()}${kind.slice(1)}` : kind.name
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Subscription = { remove(): void }
|
package/testing/fake-claude.d.ts
CHANGED