@cmodjs/core 1.0.0 → 1.5.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 +22 -11
- package/docs/dependencies.md +6 -4
- package/docs/hooks.md +26 -3
- package/docs/index.md +1 -1
- package/docs/install-steps.md +5 -3
- package/docs/mod.md +29 -3
- package/docs/permissions.md +4 -4
- package/docs/session.md +126 -63
- package/docs/testing.md +7 -5
- package/docs/ui.md +9 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +7 -2
- package/mod.d.ts +30 -6
- package/mod.d.ts.map +1 -1
- package/mod.js +34 -1
- package/package.json +1 -1
- package/records.d.ts +4 -1
- package/records.d.ts.map +1 -1
- package/records.js +6 -1
- package/register.d.ts.map +1 -1
- package/register.js +17 -0
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +2 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/claude.d.ts +2 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +1 -0
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +333 -61
- package/runtime/dependencies.js +1 -1
- package/runtime/engine.d.ts +2 -6
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +8 -8
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +19 -4
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +6 -5
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +130 -59
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +5 -4
- package/runtime/sessions.d.ts +26 -4
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +315 -117
- package/runtime/toaster.d.ts +5 -12
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +158 -25
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +26 -6
- package/session/context/context.d.ts +3 -1
- package/session/context/context.d.ts.map +1 -1
- package/session/context/turn/message/message.d.ts +1 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +42 -1
- package/session/context/turn/turn.d.ts +2 -1
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +20 -11
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +50 -27
- package/session/goal/goal.d.ts +3 -2
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +4 -4
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +80 -21
- package/session/session.d.ts.map +1 -1
- package/session/session.js +1 -0
- package/src/jobs/program.ts +7 -2
- package/src/mod.ts +48 -5
- package/src/records.ts +6 -1
- package/src/register.ts +17 -0
- package/src/runtime/announce.ts +2 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/claude.ts +3 -0
- package/src/runtime/conversations.ts +337 -60
- package/src/runtime/dependencies.ts +1 -1
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/grants.ts +8 -10
- package/src/runtime/hooks.ts +20 -4
- package/src/runtime/installer.ts +6 -5
- package/src/runtime/lifecycle.ts +136 -56
- package/src/runtime/metadata.ts +5 -4
- package/src/runtime/sessions.ts +316 -111
- package/src/runtime/toaster.ts +183 -45
- package/src/runtime/ui.ts +23 -4
- package/src/session/context/context.ts +3 -1
- package/src/session/context/turn/message/message.ts +39 -1
- package/src/session/context/turn/turn.ts +2 -1
- package/src/session/conversation.ts +56 -31
- package/src/session/goal/goal.ts +3 -2
- package/src/session/goal/task.ts +4 -4
- package/src/session/session.ts +62 -15
- package/src/testing/fake-claude.ts +4 -2
- package/src/testing/fake-sessions.ts +94 -20
- package/src/testing.ts +27 -3
- package/src/utils/call-effects.ts +2 -2
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +1 -1
- package/src/utils/paths.ts +6 -3
- package/testing/fake-claude.d.ts +1 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +3 -1
- package/testing/fake-sessions.d.ts +8 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +97 -18
- package/testing.d.ts +1 -0
- package/testing.d.ts.map +1 -1
- package/testing.js +23 -2
- package/utils/call-effects.d.ts +1 -0
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +1 -1
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.js +1 -1
- package/utils/paths.d.ts +2 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +6 -3
package/src/runtime/toaster.ts
CHANGED
|
@@ -1,30 +1,47 @@
|
|
|
1
1
|
import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
|
|
2
|
-
import type {
|
|
3
|
-
import { Box, Button, drawWith, Text } from '../ui/elements.js'
|
|
4
|
-
import { coreVersion } from '../records.js'
|
|
2
|
+
import type { Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
|
|
3
|
+
import { Box, Button, drawWith, Link, Text } from '../ui/elements.js'
|
|
4
|
+
import { coreVersion, isObject } from '../records.js'
|
|
5
5
|
import { firstLine, messageOf } from '../utils/text.js'
|
|
6
6
|
import type { Claude } from './claude.js'
|
|
7
7
|
|
|
8
8
|
export type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
|
|
9
9
|
|
|
10
|
-
export type Failure = { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string }
|
|
10
|
+
export type Failure = { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string; readonly retry?: () => unknown }
|
|
11
11
|
|
|
12
12
|
export type Toaster = {
|
|
13
13
|
readonly toast: Toast
|
|
14
14
|
copy(text: string): Promise<void>
|
|
15
|
+
fail(failure: Failure): ToastId
|
|
15
16
|
readonly shown: readonly ShownToast[]
|
|
16
|
-
draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt']): RenderElement
|
|
17
|
+
draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt'], bandId: string): RenderElement
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
type Action = { readonly label: string; readonly onClick: () => unknown }
|
|
21
|
+
|
|
22
|
+
type Contact = { readonly kind: 'github'; readonly repository: string } | { readonly kind: 'email'; readonly address: string }
|
|
23
|
+
|
|
24
|
+
type Reporting = { readonly contact: Contact | undefined; readonly details: string; readonly status?: string }
|
|
25
|
+
|
|
19
26
|
type Entry = {
|
|
20
27
|
readonly id: ToastId
|
|
21
28
|
readonly type: ToastType
|
|
22
29
|
readonly message: string
|
|
23
30
|
readonly options: ToastOptions
|
|
31
|
+
readonly actions: readonly Action[]
|
|
32
|
+
readonly hides?: string
|
|
24
33
|
isOpen: boolean
|
|
34
|
+
reporting?: Reporting | undefined
|
|
35
|
+
trying?: string | RenderElement | undefined
|
|
25
36
|
timer: Timer | undefined
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
const githubRepository = /^https:\/\/github\.com\/([^/]+\/[^/#?]+?)(?:\.git)?(?:\/issues)?\/?$/
|
|
40
|
+
|
|
41
|
+
const linkLimit = 2048
|
|
42
|
+
|
|
43
|
+
const hiddenKey = 'cmod:hidden-failures'
|
|
44
|
+
|
|
28
45
|
const defaultDuration = 4000
|
|
29
46
|
const visibleToasts = 3
|
|
30
47
|
const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
|
|
@@ -37,16 +54,25 @@ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; rea
|
|
|
37
54
|
error: { glyph: '✗', color: 'error' },
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
export function createToaster(claude: Claude, name: string): Toaster {
|
|
57
|
+
export function createToaster(claude: Claude, name: string, version: () => string | undefined): Toaster {
|
|
41
58
|
const entries = new Map<ToastId, Entry>()
|
|
59
|
+
const hidden = new Map<string, string>()
|
|
60
|
+
void claude.store.get(hiddenKey).then(
|
|
61
|
+
(saved) => {
|
|
62
|
+
if (typeof saved !== 'object' || saved === null) return
|
|
63
|
+
for (const [id, hides] of Object.entries(saved)) if (typeof hides === 'string' && !hidden.has(id)) hidden.set(id, hides)
|
|
64
|
+
},
|
|
65
|
+
(error: unknown) => claude.ui.log(`${name}: could not read the failures you hid: ${messageOf(error)}`, { to: 'debug' }),
|
|
66
|
+
)
|
|
42
67
|
let lastId = 0
|
|
43
68
|
let frame = 0
|
|
44
69
|
let spinning: Timer | undefined
|
|
45
70
|
let isHeld = false
|
|
46
71
|
let showsAll = false
|
|
72
|
+
let bandId: string | undefined
|
|
47
73
|
|
|
48
74
|
const changed = () => {
|
|
49
|
-
const isLoading = [...entries.values()].some((entry) => entry.type === 'loading')
|
|
75
|
+
const isLoading = [...entries.values()].some((entry) => entry.type === 'loading' || entry.trying !== undefined)
|
|
50
76
|
if (isLoading && spinning === undefined) {
|
|
51
77
|
spinning = claude.clock.every(spinnerMs, () => {
|
|
52
78
|
frame += 1
|
|
@@ -85,18 +111,34 @@ export function createToaster(claude: Claude, name: string): Toaster {
|
|
|
85
111
|
changed()
|
|
86
112
|
}
|
|
87
113
|
|
|
88
|
-
const
|
|
89
|
-
(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (previous !== undefined) disarm(previous)
|
|
94
|
-
const entry: Entry = { id, type, message, options, isOpen: previous?.isOpen ?? false, timer: undefined }
|
|
95
|
-
if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
|
|
96
|
-
entries.set(id, entry)
|
|
114
|
+
const put = (type: ToastType, message: string, options: ToastOptions, actions: readonly Action[], hides?: string): ToastId => {
|
|
115
|
+
const id = options.id ?? (lastId += 1)
|
|
116
|
+
const previous = entries.get(id)
|
|
117
|
+
if (previous?.trying !== undefined && type === 'loading') {
|
|
118
|
+
previous.trying = options.description ?? message
|
|
97
119
|
changed()
|
|
98
120
|
return id
|
|
99
121
|
}
|
|
122
|
+
if (previous !== undefined) disarm(previous)
|
|
123
|
+
const reporting = hides !== undefined && previous?.hides === hides ? previous.reporting : undefined
|
|
124
|
+
const entry: Entry = { id, type, message, options, actions, ...(hides === undefined ? {} : { hides }), isOpen: previous?.isOpen ?? false, reporting, timer: undefined }
|
|
125
|
+
if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
|
|
126
|
+
entries.set(id, entry)
|
|
127
|
+
changed()
|
|
128
|
+
return id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const show =
|
|
132
|
+
(type: ToastType) =>
|
|
133
|
+
(message: string, options: ToastOptions = {}): ToastId =>
|
|
134
|
+
put(type, message, options, options.action === undefined ? [] : [options.action])
|
|
135
|
+
|
|
136
|
+
const hide = (entry: Entry) => {
|
|
137
|
+
dismiss(entry.id)
|
|
138
|
+
if (entry.hides === undefined) return
|
|
139
|
+
hidden.set(String(entry.id), entry.hides)
|
|
140
|
+
void claude.store.set(hiddenKey, Object.fromEntries(hidden)).catch((error: unknown) => claude.ui.log(`${name}: could not remember the failure you hid: ${messageOf(error)}`, { to: 'debug' }))
|
|
141
|
+
}
|
|
100
142
|
|
|
101
143
|
const toast: Toast = Object.assign(show('default'), {
|
|
102
144
|
success: show('success'),
|
|
@@ -132,7 +174,47 @@ export function createToaster(claude: Claude, name: string): Toaster {
|
|
|
132
174
|
const press = (entry: Entry, run: () => unknown) => () =>
|
|
133
175
|
void Promise.resolve()
|
|
134
176
|
.then(run)
|
|
135
|
-
.catch((error: unknown) =>
|
|
177
|
+
.catch((error: unknown) => fail({ id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
|
|
178
|
+
|
|
179
|
+
const fail = ({ id, message, error, description = error === undefined ? undefined : describe(error), retry }: Failure): ToastId => {
|
|
180
|
+
const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
|
|
181
|
+
if (hidden.get(id) === hides) return id
|
|
182
|
+
hidden.delete(id)
|
|
183
|
+
const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
|
|
184
|
+
const details = async () => {
|
|
185
|
+
const modVersion = await claude.fs
|
|
186
|
+
.read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
|
|
187
|
+
.then((text) => String((JSON.parse(text) as { version?: unknown }).version))
|
|
188
|
+
.catch((failure: unknown) => `unknown (${messageOf(failure)})`)
|
|
189
|
+
return `${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}${description === undefined ? '' : `\n${description}`}${stack}`
|
|
190
|
+
}
|
|
191
|
+
const startReporting = async () => {
|
|
192
|
+
const shown = entries.get(id)
|
|
193
|
+
if (shown === undefined) return
|
|
194
|
+
shown.reporting = { contact: await contactOf(claude), details: await details() }
|
|
195
|
+
disarm(shown)
|
|
196
|
+
changed()
|
|
197
|
+
if (bandId === undefined) return
|
|
198
|
+
const focused = await claude.ui.focus({ requestId: bandId, key: `toast:${id}:ask-claude` })
|
|
199
|
+
if ('deny' in focused) claude.ui.log(`${name}: the Report choices did not take the keys: ${String(focused.deny)}`, { to: 'debug' })
|
|
200
|
+
}
|
|
201
|
+
const tryAgain = async () => {
|
|
202
|
+
const shown = entries.get(id)
|
|
203
|
+
if (shown === undefined || retry === undefined || shown.trying !== undefined) return
|
|
204
|
+
shown.trying = 'Starting'
|
|
205
|
+
changed()
|
|
206
|
+
try {
|
|
207
|
+
await retry()
|
|
208
|
+
} finally {
|
|
209
|
+
if (entries.get(id) === shown) {
|
|
210
|
+
shown.trying = undefined
|
|
211
|
+
changed()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const actions = [...(retry === undefined ? [] : [{ label: 'Retry', onClick: tryAgain }]), { label: 'Report', onClick: startReporting }]
|
|
216
|
+
return put('error', message, { id, ...(description === undefined ? {} : { description }), duration: Infinity }, actions, hides)
|
|
217
|
+
}
|
|
136
218
|
|
|
137
219
|
const toggle = (entry: Entry) => () => {
|
|
138
220
|
entry.isOpen = !entry.isOpen
|
|
@@ -141,31 +223,72 @@ export function createToaster(claude: Claude, name: string): Toaster {
|
|
|
141
223
|
}
|
|
142
224
|
|
|
143
225
|
const row = (entry: Entry): RenderElement => {
|
|
144
|
-
const { description
|
|
226
|
+
const { description } = entry.options
|
|
145
227
|
const isText = typeof description === 'string'
|
|
146
|
-
const icon = entry.type === 'loading' ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
|
|
228
|
+
const icon = entry.type === 'loading' || entry.trying !== undefined ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
|
|
147
229
|
const message = isText ? Button({ plain: true, key: `toast:${entry.id}:open`, label: entry.message, onPress: toggle(entry) }) : Text({ children: entry.message })
|
|
148
230
|
const details = !isText ? description : Text({ dimColor: true, wrap: entry.isOpen ? 'wrap' : 'truncate-end', children: entry.isOpen ? description : firstLine(description) })
|
|
149
231
|
const fixed = (child: RenderElement) => Box({ flexShrink: 0, children: child })
|
|
232
|
+
const close = Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: entry.hides === undefined ? '✕' : 'Hide', onPress: () => hide(entry) })
|
|
233
|
+
const actions = entry.actions.map((action, index) => Button({ plain: true, key: `toast:${entry.id}:${index === 0 ? 'action' : `action-${index}`}`, label: action.label, onPress: press(entry, action.onClick) }))
|
|
234
|
+
const buttons = Box({ gap: 2, flexWrap: 'wrap', children: [...actions, close] })
|
|
235
|
+
const trying = entry.trying === undefined ? undefined : Box({ gap: 1, children: [Text({ dimColor: true, children: 'Trying again:' }), typeof entry.trying === 'string' ? Text({ dimColor: true, wrap: 'truncate-end', children: entry.trying }) : entry.trying] })
|
|
236
|
+
const text = details === undefined ? [message] : [message, trying ?? details]
|
|
237
|
+
const under = trying !== undefined && details === undefined ? [trying] : entry.reporting === undefined ? [buttons] : reportChoices(entry, entry.reporting)
|
|
150
238
|
return Box({
|
|
151
239
|
gap: 2,
|
|
152
240
|
children: [
|
|
153
241
|
fixed(Text({ ...(icon.color === undefined ? {} : { color: icon.color }), children: icon.glyph })),
|
|
154
242
|
fixed(Text({ dimColor: true, children: name })),
|
|
155
|
-
Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children:
|
|
156
|
-
...(
|
|
157
|
-
fixed(Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: '✕', onPress: () => dismiss(entry.id) })),
|
|
243
|
+
Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: entry.hides === undefined ? text : [...text, ...under] }),
|
|
244
|
+
...(entry.hides === undefined ? [...actions.map(fixed), fixed(close)] : []),
|
|
158
245
|
],
|
|
159
246
|
})
|
|
160
247
|
}
|
|
161
248
|
|
|
249
|
+
const reportChoices = (entry: Entry, { contact, details, status }: Reporting): RenderElement[] => {
|
|
250
|
+
const say = (said: string) => {
|
|
251
|
+
entry.reporting = { contact, details, status: said }
|
|
252
|
+
changed()
|
|
253
|
+
}
|
|
254
|
+
const back = () => {
|
|
255
|
+
entry.reporting = undefined
|
|
256
|
+
changed()
|
|
257
|
+
}
|
|
258
|
+
const askClaude = async () => {
|
|
259
|
+
await claude.prompt.submit({ text: reportPrompt(name, contact, details), asUser: true })
|
|
260
|
+
back()
|
|
261
|
+
}
|
|
262
|
+
const copyError = async () => {
|
|
263
|
+
const copied = await claude.ui.copy({ text: details })
|
|
264
|
+
say(copied.isCopied ? 'Copied the error.' : `Could not copy it: ${copied.reason}`)
|
|
265
|
+
}
|
|
266
|
+
const report = contact === undefined ? [] : [Link({ href: reportLink(contact, name, entry.message, details), label: contact.kind === 'github' ? 'Report it on GitHub (opens your browser)' : 'Email the author (opens your mail app)' })]
|
|
267
|
+
return [
|
|
268
|
+
Text({ dimColor: true, children: 'Claude writes up the problem and files it with your tools. You approve each step before anything is posted.' }),
|
|
269
|
+
Box({
|
|
270
|
+
gap: 2,
|
|
271
|
+
flexWrap: 'wrap',
|
|
272
|
+
children: [
|
|
273
|
+
Button({ plain: true, key: `toast:${entry.id}:ask-claude`, label: 'Ask Claude to report it', onPress: press(entry, askClaude) }),
|
|
274
|
+
...report,
|
|
275
|
+
Button({ plain: true, key: `toast:${entry.id}:copy`, label: 'Copy the error', onPress: press(entry, copyError) }),
|
|
276
|
+
Button({ plain: true, dimColor: true, key: `toast:${entry.id}:back`, label: 'Back', onPress: back }),
|
|
277
|
+
],
|
|
278
|
+
}),
|
|
279
|
+
...(status === undefined ? [] : [Text({ dimColor: true, children: status })]),
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
|
|
162
283
|
return {
|
|
163
284
|
toast,
|
|
164
285
|
copy,
|
|
286
|
+
fail,
|
|
165
287
|
get shown() {
|
|
166
288
|
return [...entries.values()].map(({ type, message, options }) => ({ type, message, ...(typeof options.description === 'string' ? { description: options.description } : {}) }))
|
|
167
289
|
},
|
|
168
|
-
draw(drawn, table, band) {
|
|
290
|
+
draw(drawn, table, band, drawnIn) {
|
|
291
|
+
bandId = drawnIn
|
|
169
292
|
isHeld = band.hasSurvey === true
|
|
170
293
|
if (isHeld || entries.size === 0) return drawn
|
|
171
294
|
const all = [...entries.values()]
|
|
@@ -186,28 +309,6 @@ export function createToaster(claude: Claude, name: string): Toaster {
|
|
|
186
309
|
}
|
|
187
310
|
}
|
|
188
311
|
|
|
189
|
-
export type FailureTarget = {
|
|
190
|
-
readonly name: string
|
|
191
|
-
readonly ui: Pick<Mod['ui'], 'toast' | 'copy'>
|
|
192
|
-
readonly claude: { readonly plugin: Claude['plugin']; readonly fs: Pick<Claude['fs'], 'read'> }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function showFailure({ name, ui, claude }: FailureTarget, { id, message, error, description = describe(error) }: Failure): ToastId {
|
|
196
|
-
const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
|
|
197
|
-
const show = (label: string): ToastId => ui.toast.error(message, { id, description, duration: Infinity, action: { label, onClick: copy } })
|
|
198
|
-
const copy = async (): Promise<ToastId> => {
|
|
199
|
-
const modVersion = await claude.fs
|
|
200
|
-
.read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
|
|
201
|
-
.then((text) => String((JSON.parse(text) as { version?: unknown }).version))
|
|
202
|
-
.catch((failure: unknown) => `unknown (${messageOf(failure)})`)
|
|
203
|
-
return ui.copy(`${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}\n${description}${stack}`).then(
|
|
204
|
-
() => show('Copied'),
|
|
205
|
-
(failure: unknown) => show(`Copy failed: ${messageOf(failure)}`),
|
|
206
|
-
)
|
|
207
|
-
}
|
|
208
|
-
return show('Copy')
|
|
209
|
-
}
|
|
210
|
-
|
|
211
312
|
function describe(error: unknown): string {
|
|
212
313
|
const messages: string[] = []
|
|
213
314
|
for (let cause = error; cause !== undefined; cause = cause instanceof Error ? cause.cause : undefined) {
|
|
@@ -216,3 +317,40 @@ function describe(error: unknown): string {
|
|
|
216
317
|
}
|
|
217
318
|
return messages.join('\n')
|
|
218
319
|
}
|
|
320
|
+
|
|
321
|
+
async function contactOf(claude: Claude): Promise<Contact | undefined> {
|
|
322
|
+
const manifest: Record<string, unknown> = await claude.fs
|
|
323
|
+
.read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
|
|
324
|
+
.then((text) => JSON.parse(text) as Record<string, unknown>)
|
|
325
|
+
.catch((error: unknown) => {
|
|
326
|
+
claude.ui.log(`${claude.plugin.name}: could not read whom to report to: ${messageOf(error)}`, { to: 'debug' })
|
|
327
|
+
return {}
|
|
328
|
+
})
|
|
329
|
+
const repositoryUrl = isObject(manifest['repository']) ? manifest['repository']['url'] : manifest['repository']
|
|
330
|
+
const repository = [manifest['supportUrl'], repositoryUrl].map((url) => (typeof url === 'string' ? githubRepository.exec(url)?.[1] : undefined)).find((found) => found !== undefined)
|
|
331
|
+
if (repository !== undefined) return { kind: 'github', repository }
|
|
332
|
+
const author = manifest['author']
|
|
333
|
+
const address = isObject(author) ? author['email'] : undefined
|
|
334
|
+
return typeof address === 'string' && address !== '' ? { kind: 'email', address } : undefined
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function reportLink(contact: Contact, name: string, message: string, details: string): string {
|
|
338
|
+
const title = `${name}: ${message}`.slice(0, 120)
|
|
339
|
+
const linkOf = (body: string) =>
|
|
340
|
+
contact.kind === 'github'
|
|
341
|
+
? `https://github.com/${contact.repository}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
|
|
342
|
+
: `mailto:${contact.address}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
|
|
343
|
+
let body = details
|
|
344
|
+
while (linkOf(body).length > linkLimit && body.length > 0) body = `${body.slice(0, Math.floor(body.length * 0.8))}…`
|
|
345
|
+
return linkOf(body)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function reportPrompt(name: string, contact: Contact | undefined, details: string): string {
|
|
349
|
+
const where =
|
|
350
|
+
contact === undefined
|
|
351
|
+
? `${name} names no place to report it, so find its author from its plugin.json or its marketplace.`
|
|
352
|
+
: contact.kind === 'github'
|
|
353
|
+
? `Report it as an issue on https://github.com/${contact.repository}.`
|
|
354
|
+
: `Email it to ${contact.address}.`
|
|
355
|
+
return `The ${name} mod failed. Report it to its author. ${where} First check whether it is reported already, and add to that report instead of filing a new one. Show me the report before you post it.\n\n${details}`
|
|
356
|
+
}
|
package/src/runtime/ui.ts
CHANGED
|
@@ -107,6 +107,15 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
107
107
|
const panes = new Map<string, Pane<State>>()
|
|
108
108
|
const paneLogs = new Map<string, (failure: string) => void>()
|
|
109
109
|
const openPanes = new Set<string>()
|
|
110
|
+
const paneStates = new Map<string, { readonly isShown: boolean; readonly isFocused: boolean }>()
|
|
111
|
+
const syncPanes = async () => {
|
|
112
|
+
for (const open of await claude.ui.panes()) {
|
|
113
|
+
if (!panes.has(open.id)) continue
|
|
114
|
+
if (open.isPlaced) openPanes.add(open.id)
|
|
115
|
+
paneStates.set(open.id, { isShown: open.isShown, isFocused: open.isFocused })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const readPanes = () => void syncPanes().catch((error: unknown) => claude.ui.log(`${name} keeps its panes' shown and focused state from before: ${messageOf(error)}`, { to: 'debug' }))
|
|
110
119
|
const requestedSizes = new Map<string, PaneSize>()
|
|
111
120
|
const renders = new Set<string>()
|
|
112
121
|
const markdownRenders = new Map<string, MarkdownRender>()
|
|
@@ -197,6 +206,8 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
197
206
|
const pane = panes.get(e.requestId)
|
|
198
207
|
if (pane === undefined || e.component !== 'Pane') return next(e)
|
|
199
208
|
openPanes.add(pane.id)
|
|
209
|
+
paneStates.set(pane.id, { isShown: true, isFocused: e.props.isFocused })
|
|
210
|
+
readPanes()
|
|
200
211
|
const table = claude.ui.resolve(e)
|
|
201
212
|
try {
|
|
202
213
|
return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
|
|
@@ -214,6 +225,8 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
214
225
|
})
|
|
215
226
|
router.add('ui.close', async (e, next) => {
|
|
216
227
|
openPanes.delete(e.id)
|
|
228
|
+
paneStates.delete(e.id)
|
|
229
|
+
readPanes()
|
|
217
230
|
const closed = await next(e)
|
|
218
231
|
const pane = panes.get(e.id)
|
|
219
232
|
if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
|
|
@@ -241,13 +254,22 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
241
254
|
get isOpen() {
|
|
242
255
|
return openPanes.has(pane.id)
|
|
243
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
|
+
},
|
|
244
263
|
async open(options) {
|
|
245
264
|
const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
|
|
246
265
|
if (isPlaced) openPanes.add(pane.id)
|
|
266
|
+
await syncPanes()
|
|
247
267
|
},
|
|
248
268
|
async close() {
|
|
249
269
|
await claude.ui.close({ id: pane.id })
|
|
250
270
|
openPanes.delete(pane.id)
|
|
271
|
+
paneStates.delete(pane.id)
|
|
272
|
+
await syncPanes()
|
|
251
273
|
},
|
|
252
274
|
toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
|
|
253
275
|
}
|
|
@@ -316,10 +338,7 @@ export function createUi<State extends object>({ name, claude, router, toaster,
|
|
|
316
338
|
})
|
|
317
339
|
},
|
|
318
340
|
async restorePanes() {
|
|
319
|
-
if (panes.size
|
|
320
|
-
for (const open of await claude.ui.panes()) {
|
|
321
|
-
if (open.isPlaced && panes.has(open.id)) openPanes.add(open.id)
|
|
322
|
-
}
|
|
341
|
+
if (panes.size > 0) await syncPanes()
|
|
323
342
|
},
|
|
324
343
|
}
|
|
325
344
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import type { RecordPart } from '../session.js'
|
|
1
2
|
import type { Turn } from './turn/turn.js'
|
|
2
3
|
|
|
3
4
|
export const ContextStart = 'contextStart'
|
|
4
5
|
|
|
5
6
|
export const ContextEnd = 'contextEnd'
|
|
6
7
|
|
|
7
|
-
export type Context = {
|
|
8
|
+
export type Context = RecordPart & {
|
|
8
9
|
readonly number: number
|
|
9
10
|
readonly openedAt: string
|
|
10
11
|
readonly closedAt?: string
|
|
12
|
+
readonly task?: string
|
|
11
13
|
turns(): Promise<Turn[]>
|
|
12
14
|
}
|
|
@@ -112,7 +112,7 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
|
|
|
112
112
|
})
|
|
113
113
|
}
|
|
114
114
|
if (row.isSummary) return []
|
|
115
|
-
const results = row.content.filter(
|
|
115
|
+
const results = row.content.filter(isToolResultBlock)
|
|
116
116
|
if (results.length > 0) return results.map((block) => ({ ...base, type: ToolResult, callId: block['tool_use_id'] as string, text: textOf(block['content']), isError: block['is_error'] === true }))
|
|
117
117
|
if (text === '') return []
|
|
118
118
|
if (text.startsWith(skillPrefix)) return [{ ...base, type: Skill, name: basename(text.slice(skillPrefix.length).split('\n')[0]?.trim() ?? ''), text }]
|
|
@@ -122,6 +122,44 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
|
|
|
122
122
|
return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export function withTexts(row: Row, texts: ReadonlyMap<number, string>): readonly unknown[] {
|
|
126
|
+
if (row.type === 'assistant') {
|
|
127
|
+
let index = -1
|
|
128
|
+
return row.content.map((block) => {
|
|
129
|
+
if (!isObject(block)) return block
|
|
130
|
+
const isText = block['type'] === 'text' && typeof block['text'] === 'string' && block['text'].trim() !== ''
|
|
131
|
+
const isCall = block['type'] === 'tool_use' && typeof block['id'] === 'string' && typeof block['name'] === 'string'
|
|
132
|
+
if (!isText && !isCall) return block
|
|
133
|
+
index += 1
|
|
134
|
+
const text = texts.get(index)
|
|
135
|
+
return isText && text !== undefined ? { ...block, text } : block
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
if (row.type === 'user' && row.content.some(isToolResultBlock)) {
|
|
139
|
+
let index = -1
|
|
140
|
+
return row.content.map((block) => {
|
|
141
|
+
if (!isToolResultBlock(block)) return block
|
|
142
|
+
index += 1
|
|
143
|
+
const text = texts.get(index)
|
|
144
|
+
if (text === undefined) return block
|
|
145
|
+
return { ...block, content: Array.isArray(block['content']) ? keepingMedia(block['content'], text) : text }
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const text = texts.get(0)
|
|
149
|
+
if (text === undefined) return row.content
|
|
150
|
+
const whole = textOf(row.content)
|
|
151
|
+
if (whole.trim().startsWith('<task-notification') && tagOf(whole, 'summary') !== undefined) return keepingMedia(row.content, whole.replace(/<summary>[\s\S]*?<\/summary>/, () => `<summary>${text}</summary>`))
|
|
152
|
+
return keepingMedia(row.content, text)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function keepingMedia(content: readonly unknown[], text: string): unknown[] {
|
|
156
|
+
return [{ type: 'text', text }, ...content.filter((block) => !isObject(block) || block['type'] !== 'text')]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isToolResultBlock(block: unknown): block is Readonly<Record<string, unknown>> {
|
|
160
|
+
return isObject(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string'
|
|
161
|
+
}
|
|
162
|
+
|
|
125
163
|
export function textOf(content: unknown): string {
|
|
126
164
|
if (typeof content === 'string') return content
|
|
127
165
|
if (!Array.isArray(content)) return ''
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RecordPart } from '../../session.js'
|
|
1
2
|
import type { Message } from './message/message.js'
|
|
2
3
|
import type { Prompt } from './message/prompt.js'
|
|
3
4
|
|
|
@@ -5,7 +6,7 @@ export const TurnStart = 'turnStart'
|
|
|
5
6
|
|
|
6
7
|
export const TurnEnd = 'turnEnd'
|
|
7
8
|
|
|
8
|
-
export type Turn = {
|
|
9
|
+
export type Turn = RecordPart & {
|
|
9
10
|
readonly prompt: Prompt
|
|
10
11
|
readonly context: number
|
|
11
12
|
readonly openedAt: string
|