@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.
Files changed (122) hide show
  1. package/docs/commands.md +22 -11
  2. package/docs/dependencies.md +6 -4
  3. package/docs/hooks.md +26 -3
  4. package/docs/index.md +1 -1
  5. package/docs/install-steps.md +5 -3
  6. package/docs/mod.md +29 -3
  7. package/docs/permissions.md +4 -4
  8. package/docs/session.md +126 -63
  9. package/docs/testing.md +7 -5
  10. package/docs/ui.md +9 -1
  11. package/jobs/program.d.ts.map +1 -1
  12. package/jobs/program.js +7 -2
  13. package/mod.d.ts +30 -6
  14. package/mod.d.ts.map +1 -1
  15. package/mod.js +34 -1
  16. package/package.json +1 -1
  17. package/records.d.ts +4 -1
  18. package/records.d.ts.map +1 -1
  19. package/records.js +6 -1
  20. package/register.d.ts.map +1 -1
  21. package/register.js +17 -0
  22. package/runtime/announce.d.ts.map +1 -1
  23. package/runtime/announce.js +2 -0
  24. package/runtime/before.d.ts +32 -0
  25. package/runtime/before.d.ts.map +1 -0
  26. package/runtime/before.js +214 -0
  27. package/runtime/claude.d.ts +2 -1
  28. package/runtime/claude.d.ts.map +1 -1
  29. package/runtime/conversations.d.ts +1 -0
  30. package/runtime/conversations.d.ts.map +1 -1
  31. package/runtime/conversations.js +333 -61
  32. package/runtime/dependencies.js +1 -1
  33. package/runtime/engine.d.ts +2 -6
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts.map +1 -1
  36. package/runtime/grants.js +8 -8
  37. package/runtime/hooks.d.ts +1 -1
  38. package/runtime/hooks.d.ts.map +1 -1
  39. package/runtime/hooks.js +19 -4
  40. package/runtime/installer.d.ts +1 -1
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +6 -5
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +130 -59
  45. package/runtime/metadata.d.ts.map +1 -1
  46. package/runtime/metadata.js +5 -4
  47. package/runtime/sessions.d.ts +26 -4
  48. package/runtime/sessions.d.ts.map +1 -1
  49. package/runtime/sessions.js +315 -117
  50. package/runtime/toaster.d.ts +5 -12
  51. package/runtime/toaster.d.ts.map +1 -1
  52. package/runtime/toaster.js +158 -25
  53. package/runtime/ui.d.ts.map +1 -1
  54. package/runtime/ui.js +26 -6
  55. package/session/context/context.d.ts +3 -1
  56. package/session/context/context.d.ts.map +1 -1
  57. package/session/context/turn/message/message.d.ts +1 -0
  58. package/session/context/turn/message/message.d.ts.map +1 -1
  59. package/session/context/turn/message/message.js +42 -1
  60. package/session/context/turn/turn.d.ts +2 -1
  61. package/session/context/turn/turn.d.ts.map +1 -1
  62. package/session/conversation.d.ts +20 -11
  63. package/session/conversation.d.ts.map +1 -1
  64. package/session/conversation.js +50 -27
  65. package/session/goal/goal.d.ts +3 -2
  66. package/session/goal/goal.d.ts.map +1 -1
  67. package/session/goal/task.d.ts +4 -4
  68. package/session/goal/task.d.ts.map +1 -1
  69. package/session/session.d.ts +80 -21
  70. package/session/session.d.ts.map +1 -1
  71. package/session/session.js +1 -0
  72. package/src/jobs/program.ts +7 -2
  73. package/src/mod.ts +48 -5
  74. package/src/records.ts +6 -1
  75. package/src/register.ts +17 -0
  76. package/src/runtime/announce.ts +2 -0
  77. package/src/runtime/before.ts +200 -0
  78. package/src/runtime/claude.ts +3 -0
  79. package/src/runtime/conversations.ts +337 -60
  80. package/src/runtime/dependencies.ts +1 -1
  81. package/src/runtime/engine.ts +2 -1
  82. package/src/runtime/grants.ts +8 -10
  83. package/src/runtime/hooks.ts +20 -4
  84. package/src/runtime/installer.ts +6 -5
  85. package/src/runtime/lifecycle.ts +136 -56
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +183 -45
  89. package/src/runtime/ui.ts +23 -4
  90. package/src/session/context/context.ts +3 -1
  91. package/src/session/context/turn/message/message.ts +39 -1
  92. package/src/session/context/turn/turn.ts +2 -1
  93. package/src/session/conversation.ts +56 -31
  94. package/src/session/goal/goal.ts +3 -2
  95. package/src/session/goal/task.ts +4 -4
  96. package/src/session/session.ts +62 -15
  97. package/src/testing/fake-claude.ts +4 -2
  98. package/src/testing/fake-sessions.ts +94 -20
  99. package/src/testing.ts +27 -3
  100. package/src/utils/call-effects.ts +2 -2
  101. package/src/utils/disposable.ts +3 -0
  102. package/src/utils/matcher.ts +1 -1
  103. package/src/utils/paths.ts +6 -3
  104. package/testing/fake-claude.d.ts +1 -0
  105. package/testing/fake-claude.d.ts.map +1 -1
  106. package/testing/fake-claude.js +3 -1
  107. package/testing/fake-sessions.d.ts +8 -1
  108. package/testing/fake-sessions.d.ts.map +1 -1
  109. package/testing/fake-sessions.js +97 -18
  110. package/testing.d.ts +1 -0
  111. package/testing.d.ts.map +1 -1
  112. package/testing.js +23 -2
  113. package/utils/call-effects.d.ts +1 -0
  114. package/utils/call-effects.d.ts.map +1 -1
  115. package/utils/call-effects.js +1 -1
  116. package/utils/disposable.d.ts +4 -0
  117. package/utils/disposable.d.ts.map +1 -0
  118. package/utils/disposable.js +1 -0
  119. package/utils/matcher.js +1 -1
  120. package/utils/paths.d.ts +2 -1
  121. package/utils/paths.d.ts.map +1 -1
  122. package/utils/paths.js +6 -3
@@ -1,30 +1,47 @@
1
1
  import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
2
- import type { Mod, Toast, ToastId, ToastOptions, ToastType } from '../mod.js'
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 show =
89
- (type: ToastType) =>
90
- (message: string, options: ToastOptions = {}): ToastId => {
91
- const id = options.id ?? (lastId += 1)
92
- const previous = entries.get(id)
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) => showFailure({ name, claude, ui: { toast, copy } }, { id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
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, action } = entry.options
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: details === undefined ? [message] : [message, details] }),
156
- ...(action === undefined ? [] : [fixed(Button({ plain: true, key: `toast:${entry.id}:action`, label: action.label, onPress: press(entry, action.onClick) }))]),
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 === 0) return
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((block) => isObject(block) && block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string') as Readonly<Record<string, unknown>>[]
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