@cmodjs/core 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +63 -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 +372 -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 +72 -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 +405 -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/runtime/ui.ts
CHANGED
|
@@ -1,20 +1,60 @@
|
|
|
1
1
|
import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement } from 'claude-code'
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import type
|
|
6
|
-
import {
|
|
2
|
+
import type { Mod, Turn } from '../mod.js'
|
|
3
|
+
import { isObject } from '../records.js'
|
|
4
|
+
import { TurnEnd, type Area, type Widget, type WidgetContext, type WidgetInit } from '../ui/areas.js'
|
|
5
|
+
import { Box, drawWith, elementData, Text, type Handler } from '../ui/elements.js'
|
|
6
|
+
import type { Block, MarkdownReader } from '../ui/markdown.js'
|
|
7
|
+
import { checkPane, checkPart, sizeKeys, type OpenOptions, type Page, type PageInit, type Pane, type PaneInit, type ScrollOptions, type ScrollTarget } from '../ui/panes.js'
|
|
8
|
+
import { componentOf, nameOf, type Kind, type Style, type StyleInit } from '../ui/styles.js'
|
|
9
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
7
10
|
import { messageOf } from '../utils/text.js'
|
|
8
11
|
import type { Claude } from './claude.js'
|
|
12
|
+
import { cmodPluginName } from './dependencies.js'
|
|
9
13
|
import type { Router } from './router.js'
|
|
10
|
-
import type { Failure
|
|
14
|
+
import type { Failure } from './toaster.js'
|
|
15
|
+
|
|
16
|
+
export const partMethods = {
|
|
17
|
+
list: 'cmod:parts',
|
|
18
|
+
draw: 'cmod:draw',
|
|
19
|
+
press: 'cmod:press',
|
|
20
|
+
shown: 'cmod:shown',
|
|
21
|
+
changed: 'cmod:partsChanged',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type PagePlace = 'sidebar' | 'settings'
|
|
25
|
+
|
|
26
|
+
export type WidgetDeclaration = { readonly id: string; readonly title: string; readonly areas: readonly Area[] }
|
|
27
|
+
|
|
28
|
+
export type PageDeclaration = { readonly id: string; readonly title: string; readonly place: PagePlace }
|
|
29
|
+
|
|
30
|
+
export type PartList = { readonly widgets: readonly WidgetDeclaration[]; readonly pages: readonly PageDeclaration[] }
|
|
31
|
+
|
|
32
|
+
export type PartDraw =
|
|
33
|
+
| { readonly widget: string; readonly area: Area; readonly columns: number; readonly turn?: Turn }
|
|
34
|
+
| { readonly page: string; readonly params: unknown; readonly columns: number; readonly isFocused: boolean }
|
|
35
|
+
|
|
36
|
+
export type PartPress = { readonly draw: number; readonly handler: number; readonly args: readonly unknown[] }
|
|
37
|
+
|
|
38
|
+
export type DrawnPart = { readonly draw: number; readonly tree: unknown }
|
|
39
|
+
|
|
40
|
+
export type PageRequest =
|
|
41
|
+
| { readonly open: string; readonly params?: unknown; readonly focus?: true }
|
|
42
|
+
| { readonly close: string }
|
|
43
|
+
| { readonly scroll: string; readonly to: ScrollTarget; readonly block?: ScrollOptions['block'] }
|
|
44
|
+
| { readonly settings: true; readonly tab?: SettingsTab }
|
|
45
|
+
|
|
46
|
+
export type SettingsTab = 'changelog' | 'permissions'
|
|
47
|
+
|
|
48
|
+
export type PartsUpdate = { readonly parts?: PartList; readonly redraw?: true; readonly requests?: readonly PageRequest[]; readonly isAsked?: true }
|
|
49
|
+
|
|
50
|
+
export type ShownPages = { readonly open: readonly { readonly id: string; readonly params: unknown }[]; readonly shown: string | null; readonly isFocused: boolean }
|
|
51
|
+
|
|
11
52
|
|
|
12
53
|
type PaneSize = Pick<PaneOpenArgs, 'columns' | 'rows'>
|
|
13
54
|
|
|
14
55
|
const bullet = '⏺'
|
|
15
|
-
const sizeKeys = ['columns', 'rows'] as const
|
|
16
56
|
|
|
17
|
-
function paneSize<State extends object>(pane:
|
|
57
|
+
function paneSize<State extends object>(pane: PaneInit<State, never>, state: State): PaneSize {
|
|
18
58
|
const size: { columns?: number; rows?: number } = {}
|
|
19
59
|
for (const key of sizeKeys) {
|
|
20
60
|
const wanted = pane[key]
|
|
@@ -82,29 +122,49 @@ function drawPieces(pieces: readonly DrawnPiece[], place: 'pane' | 'opensReply'
|
|
|
82
122
|
return column([opening, Box({ paddingLeft: 2, children: column(rest) })])
|
|
83
123
|
}
|
|
84
124
|
|
|
125
|
+
function merged(updates: readonly PartsUpdate[]): PartsUpdate {
|
|
126
|
+
const parts = updates.findLast((update) => update.parts !== undefined)?.parts
|
|
127
|
+
const requests = updates.flatMap((update) => update.requests ?? [])
|
|
128
|
+
const isRedrawn = updates.some((update) => update.redraw === true)
|
|
129
|
+
return { ...(parts === undefined ? {} : { parts }), ...(isRedrawn ? { redraw: true } : {}), ...(requests.length === 0 ? {} : { requests }) }
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
type MarkdownRender = {
|
|
86
133
|
readonly Component: (props: object) => RenderElement
|
|
87
134
|
readonly log: (failure: string) => void
|
|
88
135
|
}
|
|
89
136
|
|
|
137
|
+
type AddedPane<State extends object> = { readonly init: PaneInit<State, unknown>; params: unknown }
|
|
138
|
+
|
|
139
|
+
type AddedPage<State extends object> = { readonly init: PageInit<State, unknown>; readonly place: PagePlace; params: unknown }
|
|
140
|
+
|
|
90
141
|
export type UiOptions<State extends object> = {
|
|
91
142
|
readonly name: string
|
|
92
143
|
readonly claude: Claude
|
|
93
144
|
readonly router: Router
|
|
94
|
-
readonly toaster: Toaster
|
|
95
145
|
readonly report: (failure: Failure) => void
|
|
96
146
|
readonly announce: (feature: string) => void
|
|
97
147
|
readonly mod: () => Mod<State>
|
|
98
148
|
}
|
|
99
149
|
|
|
100
150
|
export type UiArea<State extends object> = {
|
|
101
|
-
readonly
|
|
151
|
+
readonly panes: Mod<State>['panes']
|
|
152
|
+
readonly sidebar: Mod<State>['sidebar']
|
|
153
|
+
readonly settings: Mod<State>['settings']
|
|
154
|
+
readonly widgets: Mod<State>['widgets']
|
|
155
|
+
readonly styles: Mod<State>['styles']
|
|
156
|
+
asked<Value>(run: () => Value | Promise<Value>): Promise<Value>
|
|
157
|
+
answerInput<Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>): Promise<Answer>
|
|
158
|
+
answer(method: string, input: unknown): Promise<{ readonly value: unknown }> | undefined
|
|
159
|
+
started(): void
|
|
102
160
|
changed(): void
|
|
103
161
|
restorePanes(): Promise<void>
|
|
104
162
|
}
|
|
105
163
|
|
|
106
|
-
|
|
107
|
-
|
|
164
|
+
const keptDrawInputs = 200
|
|
165
|
+
|
|
166
|
+
export function createUi<State extends object>({ name, claude, router, report, announce, mod }: UiOptions<State>): UiArea<State> {
|
|
167
|
+
const panes = new Map<string, AddedPane<State>>()
|
|
108
168
|
const paneLogs = new Map<string, (failure: string) => void>()
|
|
109
169
|
const openPanes = new Set<string>()
|
|
110
170
|
const paneStates = new Map<string, { readonly isShown: boolean; readonly isFocused: boolean }>()
|
|
@@ -117,16 +177,75 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
117
177
|
}
|
|
118
178
|
const readPanes = () => void syncPanes().catch((error: unknown) => claude.ui.log(`${name} keeps its panes' shown and focused state from before: ${messageOf(error)}`, { to: 'debug' }))
|
|
119
179
|
const requestedSizes = new Map<string, PaneSize>()
|
|
120
|
-
const
|
|
180
|
+
const styles = new Set<string>()
|
|
121
181
|
const markdownRenders = new Map<string, MarkdownRender>()
|
|
122
182
|
let read: MarkdownReader | undefined
|
|
123
183
|
let isRouted = false
|
|
124
184
|
let isResizeQueued = false
|
|
185
|
+
const widgets = new Map<string, WidgetInit<State>>()
|
|
186
|
+
const widgetAreas = new Map<string, Area>()
|
|
187
|
+
const pages = new Map<string, AddedPage<State>>()
|
|
188
|
+
let shownPages: ShownPages = { open: [], shown: null, isFocused: false }
|
|
189
|
+
const draws = new Map<number, { readonly id: string; readonly handlers: readonly Handler[] }>()
|
|
190
|
+
const latestDraws = new Map<string, Map<string, readonly number[]>>()
|
|
191
|
+
const failedDraws = new Set<string>()
|
|
192
|
+
let drawCount = 0
|
|
193
|
+
let beforeStart: PartsUpdate[] | undefined = []
|
|
194
|
+
let askedDepth = 0
|
|
195
|
+
let unheldRequests = 0
|
|
196
|
+
let collected: PartsUpdate[] | undefined
|
|
197
|
+
let queued: PartsUpdate[] | undefined
|
|
198
|
+
|
|
199
|
+
const partList = (): PartList => ({
|
|
200
|
+
widgets: [...widgets.values()].map(({ id, title, areas: listed }) => ({ id, title, areas: listed })),
|
|
201
|
+
pages: [...pages.values()].map(({ init: { id, title }, place }) => ({ id, title, place })),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const send = (update: PartsUpdate) => {
|
|
205
|
+
if (collected !== undefined) return void collected.push(update)
|
|
206
|
+
if (beforeStart !== undefined) return void beforeStart.push(update)
|
|
207
|
+
const isAsked = askedDepth > 0
|
|
208
|
+
if (queued !== undefined && !isAsked) return void queued.push(update)
|
|
209
|
+
const batch: PartsUpdate[] = [update]
|
|
210
|
+
if (!isAsked) queued = batch
|
|
211
|
+
const tell = () => {
|
|
212
|
+
if (queued === batch) queued = undefined
|
|
213
|
+
const input = { ...merged(batch), ...(isAsked ? { isAsked: true as const } : {}) }
|
|
214
|
+
return claude.cmod.call({ to: cmodPluginName, method: partMethods.changed, input }).then(
|
|
215
|
+
(answer) => {
|
|
216
|
+
if (isAsked && input.requests !== undefined && !(isObject(answer) && answer['isHeld'] === true)) unheldRequests += 1
|
|
217
|
+
},
|
|
218
|
+
(error: unknown) => claude.ui.log(`${name}: the cmod plugin did not hear about its pages and widgets: ${messageOf(error)}`, { to: 'debug' }),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return isAsked ? tell() : Promise.resolve().then(tell)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const asked = async <Value>(run: () => Value | Promise<Value>): Promise<Value> => {
|
|
225
|
+
askedDepth += 1
|
|
226
|
+
try {
|
|
227
|
+
return await run()
|
|
228
|
+
} finally {
|
|
229
|
+
askedDepth -= 1
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const collect = async (run: () => unknown): Promise<PartsUpdate> => {
|
|
234
|
+
const outer = collected
|
|
235
|
+
const mine: PartsUpdate[] = []
|
|
236
|
+
collected = mine
|
|
237
|
+
try {
|
|
238
|
+
await run()
|
|
239
|
+
} finally {
|
|
240
|
+
collected = outer
|
|
241
|
+
}
|
|
242
|
+
return merged(mine)
|
|
243
|
+
}
|
|
125
244
|
|
|
126
245
|
const logOnce = (what: string) => {
|
|
127
246
|
let hasLogged = false
|
|
128
247
|
return (failure: string) => {
|
|
129
|
-
if (!hasLogged) report({ id: `
|
|
248
|
+
if (!hasLogged) report({ id: `draw:${what}`, message: `The ${what} failed`, description: failure })
|
|
130
249
|
hasLogged = true
|
|
131
250
|
}
|
|
132
251
|
}
|
|
@@ -182,13 +301,13 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
182
301
|
})
|
|
183
302
|
}
|
|
184
303
|
|
|
185
|
-
const openAt = (pane:
|
|
304
|
+
const openAt = (pane: PaneInit<State, never>, size: PaneSize, focus?: true) => {
|
|
186
305
|
requestedSizes.set(pane.id, size)
|
|
187
306
|
const { id, title, closeOnEscape, holdToasts } = pane
|
|
188
307
|
return claude.ui.open({ id, title, ...size, ...(closeOnEscape === true ? { closeOnEscape } : {}), ...(holdToasts === true ? { holdToasts } : {}), ...(focus === true ? { focus } : {}) })
|
|
189
308
|
}
|
|
190
309
|
|
|
191
|
-
const resize = async (pane:
|
|
310
|
+
const resize = async (pane: PaneInit<State, never>) => {
|
|
192
311
|
try {
|
|
193
312
|
const size = paneSize(pane, mod().state)
|
|
194
313
|
const requested = requestedSizes.get(pane.id)
|
|
@@ -199,28 +318,37 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
199
318
|
}
|
|
200
319
|
}
|
|
201
320
|
|
|
202
|
-
const
|
|
321
|
+
const handle = async (pane: PaneInit<State, never>, handler: string, run: () => unknown) => {
|
|
322
|
+
try {
|
|
323
|
+
await run()
|
|
324
|
+
} catch (error) {
|
|
325
|
+
report({ id: `pane:${pane.id}:${handler}`, message: `The ${pane.title} pane's ${handler} threw`, error })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const routePanes = () => {
|
|
203
330
|
if (isRouted) return
|
|
204
331
|
isRouted = true
|
|
205
332
|
router.add('ui.render', (e, next) => {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
333
|
+
const added = panes.get(e.requestId)
|
|
334
|
+
if (added === undefined || e.component !== 'Pane') return next(e)
|
|
335
|
+
const { init } = added
|
|
336
|
+
openPanes.add(init.id)
|
|
337
|
+
paneStates.set(init.id, { isShown: true, isFocused: e.props.isFocused })
|
|
210
338
|
readPanes()
|
|
211
339
|
const table = claude.ui.resolve(e)
|
|
212
340
|
try {
|
|
213
|
-
return drawWith(table, () =>
|
|
341
|
+
return drawWith(table, () => init.render(mod(), { ...e.props, params: added.params }), markdownIn(table))
|
|
214
342
|
} catch (error) {
|
|
215
|
-
paneLogs.get(
|
|
216
|
-
return drawWith(table, () => Text({ color: 'error', children: `The ${
|
|
343
|
+
paneLogs.get(init.id)?.(`threw: ${messageOf(error)}`)
|
|
344
|
+
return drawWith(table, () => Text({ color: 'error', children: `The ${init.title} pane could not draw: ${messageOf(error)}` }))
|
|
217
345
|
}
|
|
218
346
|
})
|
|
219
347
|
router.add('ui.scroll', async (e, next) => {
|
|
220
|
-
const
|
|
348
|
+
const init = panes.get(e.requestId)?.init
|
|
221
349
|
const moved = await next(e)
|
|
222
|
-
if (
|
|
223
|
-
await handle(
|
|
350
|
+
if (init?.onScroll === undefined || e.component !== 'Pane' || moved.deny !== undefined) return moved
|
|
351
|
+
await handle(init, 'onScroll', () => init.onScroll?.(mod(), e))
|
|
224
352
|
return moved
|
|
225
353
|
})
|
|
226
354
|
router.add('ui.close', async (e, next) => {
|
|
@@ -228,112 +356,274 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
228
356
|
paneStates.delete(e.id)
|
|
229
357
|
readPanes()
|
|
230
358
|
const closed = await next(e)
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
359
|
+
const init = panes.get(e.id)?.init
|
|
360
|
+
if (init?.onClose !== undefined) await handle(init, 'onClose', () => init.onClose?.(mod(), e))
|
|
233
361
|
return closed
|
|
234
362
|
})
|
|
235
363
|
}
|
|
236
364
|
|
|
237
|
-
const
|
|
365
|
+
const scrollIn = (id: string, target: ScrollTarget, options?: ScrollOptions) =>
|
|
366
|
+
claude.ui.scroll({ to: target, in: id, ...(options?.block === undefined ? {} : { block: options.block }) }).then(({ deny }) => {
|
|
367
|
+
if (deny !== undefined) throw new Error(`${name}: Claude Code did not scroll ${id}: ${deny}`)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const addPane = <Params>(init: PaneInit<State, Params>): Pane<Params> => {
|
|
371
|
+
checkPane(name, init)
|
|
372
|
+
if (panes.has(init.id)) throw new Error(`${name}: the pane "${init.id}" is already added. Give each pane its own id.`)
|
|
373
|
+
const added: AddedPane<State> = { init, params: undefined }
|
|
374
|
+
panes.set(init.id, added)
|
|
375
|
+
paneLogs.set(init.id, logOnce(`${init.title} pane`))
|
|
376
|
+
announce(`the ${init.title} pane`)
|
|
377
|
+
routePanes()
|
|
378
|
+
const sized = init as unknown as PaneInit<State, never>
|
|
379
|
+
const pane: Pane<Params> = {
|
|
380
|
+
id: init.id,
|
|
381
|
+
get isOpen() {
|
|
382
|
+
return openPanes.has(init.id)
|
|
383
|
+
},
|
|
384
|
+
get isShown() {
|
|
385
|
+
return paneStates.get(init.id)?.isShown ?? false
|
|
386
|
+
},
|
|
387
|
+
get isFocused() {
|
|
388
|
+
return paneStates.get(init.id)?.isFocused ?? false
|
|
389
|
+
},
|
|
390
|
+
get params() {
|
|
391
|
+
return added.params as Params | undefined
|
|
392
|
+
},
|
|
393
|
+
async open(...[options]: [OpenOptions<Params>?]) {
|
|
394
|
+
if (options !== undefined && 'params' in options) added.params = options.params
|
|
395
|
+
const { isPlaced } = await openAt(sized, paneSize(sized, mod().state), options?.focus)
|
|
396
|
+
if (isPlaced) openPanes.add(init.id)
|
|
397
|
+
await syncPanes()
|
|
398
|
+
claude.ui.invalidate('ui.render')
|
|
399
|
+
},
|
|
400
|
+
async close() {
|
|
401
|
+
await claude.ui.close({ id: init.id })
|
|
402
|
+
openPanes.delete(init.id)
|
|
403
|
+
paneStates.delete(init.id)
|
|
404
|
+
await syncPanes()
|
|
405
|
+
},
|
|
406
|
+
toggle: (options) => (openPanes.has(init.id) ? pane.close() : (pane.open as (options?: OpenOptions<Params>) => Promise<void>)(options as OpenOptions<Params>)),
|
|
407
|
+
scrollTo: (target, options) => scrollIn(init.id, target, options),
|
|
408
|
+
remove() {
|
|
409
|
+
if (panes.get(init.id) !== added) return
|
|
410
|
+
panes.delete(init.id)
|
|
411
|
+
paneLogs.delete(init.id)
|
|
412
|
+
if (openPanes.has(init.id)) void claude.ui.close({ id: init.id }).catch((error: unknown) => claude.ui.log(`${name}: the ${init.title} pane stayed open: ${messageOf(error)}`, { to: 'debug' }))
|
|
413
|
+
openPanes.delete(init.id)
|
|
414
|
+
paneStates.delete(init.id)
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
return pane
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const pageEntry = (id: string) => shownPages.open.find((each) => each.id === id)
|
|
421
|
+
|
|
422
|
+
const addPage = <Params>(place: PagePlace, init: PageInit<State, Params>): Page<Params> => {
|
|
423
|
+
checkPart(name, 'page', init.id, init.title)
|
|
424
|
+
if (pages.has(init.id)) throw new Error(`${name}: the page "${init.id}" is already added. Give each page its own id.`)
|
|
425
|
+
const added: AddedPage<State> = { init, place, params: undefined }
|
|
426
|
+
pages.set(init.id, added)
|
|
427
|
+
announce(place === 'settings' ? `the ${init.title} page in Settings` : `the ${init.title} page in the Sidebar`)
|
|
428
|
+
void send({ parts: partList() })
|
|
429
|
+
const page: Page<Params> = {
|
|
430
|
+
id: init.id,
|
|
431
|
+
get isOpen() {
|
|
432
|
+
return place === 'settings' ? shownPages.shown === init.id : pageEntry(init.id) !== undefined
|
|
433
|
+
},
|
|
434
|
+
get isShown() {
|
|
435
|
+
return shownPages.shown === init.id
|
|
436
|
+
},
|
|
437
|
+
get isFocused() {
|
|
438
|
+
return shownPages.shown === init.id && shownPages.isFocused
|
|
439
|
+
},
|
|
440
|
+
get params() {
|
|
441
|
+
return (pageEntry(init.id)?.params ?? added.params) as Params | undefined
|
|
442
|
+
},
|
|
443
|
+
async open(...[options]: [OpenOptions<Params>?]) {
|
|
444
|
+
if (options !== undefined && 'params' in options) added.params = options.params
|
|
445
|
+
await send({ requests: [{ open: init.id, ...(added.params === undefined ? {} : { params: added.params }), ...(options?.focus === true ? { focus: true } : {}) }] })
|
|
446
|
+
},
|
|
447
|
+
toggle: (options) => (page.isOpen ? page.close() : (page.open as (options?: OpenOptions<Params>) => Promise<void>)(options as OpenOptions<Params>)),
|
|
448
|
+
close: async () => {
|
|
449
|
+
await send({ requests: [{ close: init.id }] })
|
|
450
|
+
},
|
|
451
|
+
scrollTo: async (target, options) => {
|
|
452
|
+
await send({ requests: [{ scroll: init.id, to: target, ...(options?.block === undefined ? {} : { block: options.block }) }] })
|
|
453
|
+
},
|
|
454
|
+
remove() {
|
|
455
|
+
if (pages.get(init.id) !== added) return
|
|
456
|
+
pages.delete(init.id)
|
|
457
|
+
forgetDraws(`page:${init.id}`)
|
|
458
|
+
void send({ parts: partList(), requests: [{ close: init.id }] })
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
return page
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const addWidget = (init: WidgetInit<State>): Widget => {
|
|
465
|
+
checkPart(name, 'widget', init.id, init.title)
|
|
466
|
+
if (widgets.has(init.id)) throw new Error(`${name}: the widget "${init.id}" is already added. Give each widget its own id.`)
|
|
467
|
+
widgets.set(init.id, init)
|
|
468
|
+
announce(`the ${init.title} widget`)
|
|
469
|
+
void send({ parts: partList() })
|
|
470
|
+
return {
|
|
471
|
+
id: init.id,
|
|
472
|
+
get area() {
|
|
473
|
+
return widgetAreas.get(init.id)
|
|
474
|
+
},
|
|
475
|
+
remove() {
|
|
476
|
+
if (widgets.get(init.id) !== init) return
|
|
477
|
+
widgets.delete(init.id)
|
|
478
|
+
widgetAreas.delete(init.id)
|
|
479
|
+
forgetDraws(`widget:${init.id}`)
|
|
480
|
+
void send({ parts: partList() })
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const addStyle = <K extends Kind>({ kind, render }: StyleInit<K, State>): Style => {
|
|
486
|
+
const markdown: Block | undefined = typeof kind === 'string' ? undefined : kind
|
|
487
|
+
const component = componentOf(kind)
|
|
488
|
+
if (markdown === undefined && component === undefined) throw new Error(`${name}: ${String(kind)} is not a kind styles.add draws, such as ToolCall or CodeBlock.`)
|
|
489
|
+
const feature = `the style of ${nameOf(kind)}`
|
|
490
|
+
if (styles.has(feature)) throw new Error(`${name}: ${feature} is already added. Add one style per kind.`)
|
|
491
|
+
styles.add(feature)
|
|
492
|
+
announce(feature)
|
|
493
|
+
const Component = (props: object) => render(mod(), props as never)
|
|
494
|
+
const stop = (subscription?: Subscription) => () => {
|
|
495
|
+
subscription?.remove()
|
|
496
|
+
if (markdown !== undefined) markdownRenders.delete(markdown.markdown)
|
|
497
|
+
styles.delete(feature)
|
|
498
|
+
claude.ui.invalidate('ui.render')
|
|
499
|
+
}
|
|
500
|
+
if (markdown !== undefined) {
|
|
501
|
+
markdownRenders.set(markdown.markdown, { Component, log: logOnce(`${nameOf(kind)} style`) })
|
|
502
|
+
routeMarkdown(markdown.createReader)
|
|
503
|
+
return { kind, remove: stop() }
|
|
504
|
+
}
|
|
505
|
+
const log = logOnce(`${nameOf(kind)} style`)
|
|
506
|
+
const drawing = router.add('ui.render', async (e, next) => {
|
|
507
|
+
if (e.component !== component) return next(e)
|
|
508
|
+
const table = claude.ui.resolve(e)
|
|
509
|
+
const drawn = await drawComponent(
|
|
510
|
+
table,
|
|
511
|
+
(Default) => Component({ ...e.props, Default }),
|
|
512
|
+
(given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
|
|
513
|
+
)
|
|
514
|
+
if ('failure' in drawn) {
|
|
515
|
+
log(drawn.failure)
|
|
516
|
+
return next(e)
|
|
517
|
+
}
|
|
518
|
+
if (e.component !== 'AssistantMessage') return drawn.drawing
|
|
519
|
+
return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
|
|
520
|
+
})
|
|
521
|
+
return { kind, remove: stop(drawing) }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const forgetDraws = (key: string) => {
|
|
525
|
+
for (const kept of latestDraws.get(key)?.values() ?? []) for (const draw of kept) draws.delete(draw)
|
|
526
|
+
latestDraws.delete(key)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const drawPart = (key: string, title: string, input: PartDraw, render: () => RenderElement | undefined): DrawnPart | null => {
|
|
238
530
|
try {
|
|
239
|
-
|
|
531
|
+
const pressed: Handler[] = []
|
|
532
|
+
const drawn = render()
|
|
533
|
+
const tree = drawn === undefined ? null : elementData(drawn, pressed)
|
|
534
|
+
failedDraws.delete(key)
|
|
535
|
+
drawCount += 1
|
|
536
|
+
const inputs = latestDraws.get(key) ?? new Map<string, readonly number[]>()
|
|
537
|
+
latestDraws.set(key, inputs)
|
|
538
|
+
const drawnFor = JSON.stringify('turn' in input ? { ...input, turn: input.turn.prompt.id } : input)
|
|
539
|
+
const earlier = inputs.get(drawnFor) ?? []
|
|
540
|
+
const kept = [...earlier, drawCount].slice(-2)
|
|
541
|
+
for (const draw of earlier) if (!kept.includes(draw)) draws.delete(draw)
|
|
542
|
+
inputs.delete(drawnFor)
|
|
543
|
+
inputs.set(drawnFor, kept)
|
|
544
|
+
const [oldest] = inputs
|
|
545
|
+
if (inputs.size > keptDrawInputs && oldest !== undefined) {
|
|
546
|
+
for (const draw of oldest[1]) draws.delete(draw)
|
|
547
|
+
inputs.delete(oldest[0])
|
|
548
|
+
}
|
|
549
|
+
draws.set(drawCount, { id: key, handlers: pressed })
|
|
550
|
+
return { draw: drawCount, tree }
|
|
240
551
|
} catch (error) {
|
|
241
|
-
report({ id:
|
|
552
|
+
if (!failedDraws.has(key)) report({ id: key, message: `The ${title} could not draw`, error })
|
|
553
|
+
failedDraws.add(key)
|
|
554
|
+
return null
|
|
242
555
|
}
|
|
243
556
|
}
|
|
244
557
|
|
|
558
|
+
const draw = (input: PartDraw): DrawnPart | null => {
|
|
559
|
+
if ('page' in input) {
|
|
560
|
+
const added = pages.get(input.page)
|
|
561
|
+
if (added === undefined) return null
|
|
562
|
+
return drawPart(`page:${input.page}`, `${added.init.title} page`, input, () => added.init.render(mod(), { params: input.params, isFocused: input.isFocused, columns: input.columns }))
|
|
563
|
+
}
|
|
564
|
+
const widget = widgets.get(input.widget)
|
|
565
|
+
if (widget === undefined || !widget.areas.includes(input.area)) return null
|
|
566
|
+
widgetAreas.set(widget.id, input.area)
|
|
567
|
+
const context = (input.area === TurnEnd && input.turn !== undefined ? { area: input.area, columns: input.columns, turn: input.turn } : { area: input.area, columns: input.columns }) as WidgetContext
|
|
568
|
+
return drawPart(`widget:${widget.id}`, `${widget.title} widget`, input, () => widget.render(mod(), context))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const press = async ({ draw: drawn, handler, args }: PartPress): Promise<PartsUpdate> => {
|
|
572
|
+
const known = draws.get(drawn)
|
|
573
|
+
const run = known?.handlers[handler]
|
|
574
|
+
if (known === undefined || run === undefined) throw new Error(`${name}: that button drew before the last redraw. Press the one it shows now.`)
|
|
575
|
+
return collect(async () => {
|
|
576
|
+
try {
|
|
577
|
+
await run(...args)
|
|
578
|
+
} catch (error) {
|
|
579
|
+
report({ id: known.id, message: 'Its button failed', error })
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
245
584
|
return {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
route()
|
|
253
|
-
const handle: PaneHandle = {
|
|
254
|
-
get isOpen() {
|
|
255
|
-
return openPanes.has(pane.id)
|
|
256
|
-
},
|
|
257
|
-
get isShown() {
|
|
258
|
-
return paneStates.get(pane.id)?.isShown ?? false
|
|
259
|
-
},
|
|
260
|
-
get isFocused() {
|
|
261
|
-
return paneStates.get(pane.id)?.isFocused ?? false
|
|
262
|
-
},
|
|
263
|
-
async open(options) {
|
|
264
|
-
const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
|
|
265
|
-
if (isPlaced) openPanes.add(pane.id)
|
|
266
|
-
await syncPanes()
|
|
267
|
-
},
|
|
268
|
-
async close() {
|
|
269
|
-
await claude.ui.close({ id: pane.id })
|
|
270
|
-
openPanes.delete(pane.id)
|
|
271
|
-
paneStates.delete(pane.id)
|
|
272
|
-
await syncPanes()
|
|
273
|
-
},
|
|
274
|
-
toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
|
|
275
|
-
}
|
|
276
|
-
return handle
|
|
277
|
-
},
|
|
278
|
-
render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable {
|
|
279
|
-
const target: Place = place
|
|
280
|
-
const markdown = typeof target === 'string' ? undefined : target
|
|
281
|
-
const component = componentOf(place)
|
|
282
|
-
if (markdown === undefined && component === undefined) throw new Error(`${name}: ${String(place)} is not a place render draws, such as ToolCall or CodeBlock.`)
|
|
283
|
-
const feature = `a render of ${nameOf(place)}`
|
|
284
|
-
if (renders.has(feature)) throw new Error(`${name}: ${feature} is already added. Render each place once.`)
|
|
285
|
-
renders.add(feature)
|
|
286
|
-
announce(feature)
|
|
287
|
-
if (markdown !== undefined) {
|
|
288
|
-
const markdownType = markdown.markdown
|
|
289
|
-
markdownRenders.set(markdownType, { Component: Component as unknown as MarkdownRender['Component'], log: logOnce(nameOf(place)) })
|
|
290
|
-
routeMarkdown(markdown.createReader)
|
|
291
|
-
return {
|
|
292
|
-
dispose() {
|
|
293
|
-
markdownRenders.delete(markdownType)
|
|
294
|
-
renders.delete(feature)
|
|
295
|
-
claude.ui.invalidate('ui.render')
|
|
296
|
-
},
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
const log = logOnce(nameOf(place))
|
|
300
|
-
const drawing = router.add('ui.render', async (e, next) => {
|
|
301
|
-
if (e.component !== component) return next(e)
|
|
302
|
-
const table = claude.ui.resolve(e)
|
|
303
|
-
const drawn = await drawComponent(
|
|
304
|
-
table,
|
|
305
|
-
(Default) => Component({ ...e.props, Default } as unknown as PlaceProps<P>),
|
|
306
|
-
(given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
|
|
307
|
-
)
|
|
308
|
-
if ('failure' in drawn) {
|
|
309
|
-
log(drawn.failure)
|
|
310
|
-
return next(e)
|
|
311
|
-
}
|
|
312
|
-
if (e.component !== 'AssistantMessage') return drawn.drawing
|
|
313
|
-
return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
|
|
314
|
-
})
|
|
315
|
-
return {
|
|
316
|
-
dispose() {
|
|
317
|
-
drawing.dispose()
|
|
318
|
-
renders.delete(feature)
|
|
319
|
-
claude.ui.invalidate('ui.render')
|
|
320
|
-
},
|
|
321
|
-
}
|
|
585
|
+
panes: { add: addPane },
|
|
586
|
+
sidebar: { add: (init) => addPage('sidebar', init) },
|
|
587
|
+
settings: {
|
|
588
|
+
add: (init) => addPage('settings', init),
|
|
589
|
+
open: async () => {
|
|
590
|
+
await send({ requests: [{ settings: true }] })
|
|
322
591
|
},
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
592
|
+
},
|
|
593
|
+
widgets: { add: addWidget },
|
|
594
|
+
styles: { add: addStyle },
|
|
595
|
+
asked,
|
|
596
|
+
async answerInput(run, passOn) {
|
|
597
|
+
const before = unheldRequests
|
|
598
|
+
const answer = await asked(run)
|
|
599
|
+
if (unheldRequests > before) await passOn()
|
|
600
|
+
return answer
|
|
601
|
+
},
|
|
602
|
+
answer(method, input) {
|
|
603
|
+
if (method === partMethods.list) return Promise.resolve({ value: partList() })
|
|
604
|
+
if (method === partMethods.draw) return Promise.resolve({ value: draw(input as PartDraw) })
|
|
605
|
+
if (method === partMethods.press) return press(input as PartPress).then((value) => ({ value }))
|
|
606
|
+
if (method === partMethods.shown) {
|
|
607
|
+
shownPages = input as ShownPages
|
|
608
|
+
return Promise.resolve({ value: null })
|
|
609
|
+
}
|
|
610
|
+
return undefined
|
|
611
|
+
},
|
|
612
|
+
started() {
|
|
613
|
+
const held = merged(beforeStart ?? [])
|
|
614
|
+
beforeStart = undefined
|
|
615
|
+
void send({ ...held, parts: partList() })
|
|
327
616
|
},
|
|
328
617
|
changed() {
|
|
329
|
-
if (
|
|
618
|
+
if (widgets.size > 0 || pages.size > 0) void send({ redraw: true })
|
|
619
|
+
if (panes.size === 0 && styles.size === 0) return
|
|
330
620
|
claude.ui.invalidate('ui.render')
|
|
331
621
|
if (isResizeQueued) return
|
|
332
622
|
isResizeQueued = true
|
|
333
623
|
void Promise.resolve().then(() => {
|
|
334
624
|
isResizeQueued = false
|
|
335
|
-
for (const
|
|
336
|
-
if (openPanes.has(
|
|
625
|
+
for (const { init } of panes.values()) {
|
|
626
|
+
if (openPanes.has(init.id)) void resize(init)
|
|
337
627
|
}
|
|
338
628
|
})
|
|
339
629
|
},
|