@cmodjs/core 0.3.0 → 1.5.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.
Files changed (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +14 -4
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
@@ -0,0 +1,334 @@
1
+ import type { ElementTable, RenderElement, RenderPropsOf, Timer } from 'claude-code'
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
+ import { firstLine, messageOf } from '../utils/text.js'
6
+ import type { Claude } from './claude.js'
7
+
8
+ export type ShownToast = { readonly type: ToastType; readonly message: string; readonly description?: string }
9
+
10
+ export type Failure = { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string; readonly retry?: () => unknown }
11
+
12
+ export type Toaster = {
13
+ readonly toast: Toast
14
+ copy(text: string): Promise<void>
15
+ fail(failure: Failure): ToastId
16
+ readonly shown: readonly ShownToast[]
17
+ draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt'], bandId: string): RenderElement
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
+
26
+ type Entry = {
27
+ readonly id: ToastId
28
+ readonly type: ToastType
29
+ readonly message: string
30
+ readonly options: ToastOptions
31
+ readonly actions: readonly Action[]
32
+ readonly hides?: string
33
+ isOpen: boolean
34
+ reporting?: Reporting | undefined
35
+ timer: Timer | undefined
36
+ }
37
+
38
+ const githubRepository = /^https:\/\/github\.com\/([^/]+\/[^/#?]+?)(?:\.git)?(?:\/issues)?\/?$/
39
+
40
+ const linkLimit = 2048
41
+
42
+ const hiddenKey = 'cmod:hidden-failures'
43
+
44
+ const defaultDuration = 4000
45
+ const visibleToasts = 3
46
+ const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
47
+ const spinnerMs = 100
48
+ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; readonly color?: string }> = {
49
+ default: { glyph: ' ' },
50
+ success: { glyph: '✓', color: 'success' },
51
+ info: { glyph: 'ℹ', color: 'suggestion' },
52
+ warning: { glyph: '⚠', color: 'warning' },
53
+ error: { glyph: '✗', color: 'error' },
54
+ }
55
+
56
+ export function createToaster(claude: Claude, name: string, version: () => string | undefined): Toaster {
57
+ const entries = new Map<ToastId, Entry>()
58
+ const hidden = new Map<string, string>()
59
+ void claude.store.get(hiddenKey).then(
60
+ (saved) => {
61
+ if (typeof saved !== 'object' || saved === null) return
62
+ for (const [id, hides] of Object.entries(saved)) if (typeof hides === 'string' && !hidden.has(id)) hidden.set(id, hides)
63
+ },
64
+ (error: unknown) => claude.ui.log(`${name}: could not read the failures you hid: ${messageOf(error)}`, { to: 'debug' }),
65
+ )
66
+ let lastId = 0
67
+ let frame = 0
68
+ let spinning: Timer | undefined
69
+ let isHeld = false
70
+ let showsAll = false
71
+ let bandId: string | undefined
72
+
73
+ const changed = () => {
74
+ const isLoading = [...entries.values()].some((entry) => entry.type === 'loading')
75
+ if (isLoading && spinning === undefined) {
76
+ spinning = claude.clock.every(spinnerMs, () => {
77
+ frame += 1
78
+ claude.ui.invalidate('ui.render')
79
+ })
80
+ }
81
+ if (!isLoading && spinning !== undefined) {
82
+ spinning.cancel()
83
+ spinning = undefined
84
+ }
85
+ if (entries.size <= visibleToasts) showsAll = false
86
+ claude.ui.invalidate('ui.render')
87
+ }
88
+
89
+ const disarm = (entry: Entry) => {
90
+ entry.timer?.cancel()
91
+ entry.timer = undefined
92
+ }
93
+
94
+ const arm = (entry: Entry) => {
95
+ const duration = entry.options.duration ?? defaultDuration
96
+ if (entry.timer !== undefined || entry.type === 'loading' || entry.isOpen || !Number.isFinite(duration)) return
97
+ entry.timer = claude.clock.after(duration, () => {
98
+ entry.timer = undefined
99
+ if (isHeld) arm(entry)
100
+ else dismiss(entry.id)
101
+ })
102
+ }
103
+
104
+ const dismiss = (id?: ToastId) => {
105
+ for (const entry of entries.values()) {
106
+ if (id !== undefined && entry.id !== id) continue
107
+ disarm(entry)
108
+ entries.delete(entry.id)
109
+ }
110
+ changed()
111
+ }
112
+
113
+ const put = (type: ToastType, message: string, options: ToastOptions, actions: readonly Action[], hides?: string): ToastId => {
114
+ const id = options.id ?? (lastId += 1)
115
+ const previous = entries.get(id)
116
+ if (previous !== undefined) disarm(previous)
117
+ const reporting = hides !== undefined && previous?.hides === hides ? previous.reporting : undefined
118
+ const entry: Entry = { id, type, message, options, actions, ...(hides === undefined ? {} : { hides }), isOpen: previous?.isOpen ?? false, reporting, timer: undefined }
119
+ if (type === 'error' && previous?.message !== message) claude.ui.log(`${name}: ${message}${typeof options.description === 'string' ? `: ${options.description}` : ''}`, { to: 'debug' })
120
+ entries.set(id, entry)
121
+ changed()
122
+ return id
123
+ }
124
+
125
+ const show =
126
+ (type: ToastType) =>
127
+ (message: string, options: ToastOptions = {}): ToastId =>
128
+ put(type, message, options, options.action === undefined ? [] : [options.action])
129
+
130
+ const hide = (entry: Entry) => {
131
+ dismiss(entry.id)
132
+ if (entry.hides === undefined) return
133
+ hidden.set(String(entry.id), entry.hides)
134
+ 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' }))
135
+ }
136
+
137
+ const toast: Toast = Object.assign(show('default'), {
138
+ success: show('success'),
139
+ info: show('info'),
140
+ warning: show('warning'),
141
+ error: show('error'),
142
+ loading: show('loading'),
143
+ dismiss,
144
+ promise<T>(task: Promise<T> | (() => Promise<T>), messages: Parameters<Toast['promise']>[1]) {
145
+ const id = show('loading')(messages.loading)
146
+ const settled = Promise.resolve()
147
+ .then(() => (typeof task === 'function' ? task() : task))
148
+ .then(
149
+ (value) => {
150
+ show('success')(typeof messages.success === 'function' ? messages.success(value) : messages.success, { id })
151
+ return value
152
+ },
153
+ (error: unknown) => {
154
+ show('error')(typeof messages.error === 'function' ? messages.error(error) : messages.error, { id })
155
+ throw error
156
+ },
157
+ )
158
+ settled.catch(() => undefined)
159
+ return { unwrap: () => settled }
160
+ },
161
+ })
162
+
163
+ const copy = async (text: string) => {
164
+ const copied = await claude.ui.copy({ text })
165
+ if (!copied.isCopied) throw new Error(copied.reason)
166
+ }
167
+
168
+ const press = (entry: Entry, run: () => unknown) => () =>
169
+ void Promise.resolve()
170
+ .then(run)
171
+ .catch((error: unknown) => fail({ id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
172
+
173
+ const fail = ({ id, message, error, description = error === undefined ? undefined : describe(error), retry }: Failure): ToastId => {
174
+ const hides = `${version() ?? ''}\n${message}\n${error === undefined ? '' : describe(error)}`
175
+ if (hidden.get(id) === hides) return id
176
+ hidden.delete(id)
177
+ const stack = error instanceof Error && error.stack !== undefined ? `\n\n${error.stack}` : ''
178
+ const details = async () => {
179
+ const modVersion = await claude.fs
180
+ .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
181
+ .then((text) => String((JSON.parse(text) as { version?: unknown }).version))
182
+ .catch((failure: unknown) => `unknown (${messageOf(failure)})`)
183
+ return `${name} ${modVersion}, @cmodjs/core ${coreVersion}\n${message}${description === undefined ? '' : `\n${description}`}${stack}`
184
+ }
185
+ const startReporting = async () => {
186
+ const shown = entries.get(id)
187
+ if (shown === undefined) return
188
+ shown.reporting = { contact: await contactOf(claude), details: await details() }
189
+ disarm(shown)
190
+ changed()
191
+ if (bandId === undefined) return
192
+ const focused = await claude.ui.focus({ requestId: bandId, key: `toast:${id}:ask-claude` })
193
+ if ('deny' in focused) claude.ui.log(`${name}: the Report choices did not take the keys: ${String(focused.deny)}`, { to: 'debug' })
194
+ }
195
+ const actions = [...(retry === undefined ? [] : [{ label: 'Retry', onClick: retry }]), { label: 'Report', onClick: startReporting }]
196
+ return put('error', message, { id, ...(description === undefined ? {} : { description }), duration: Infinity }, actions, hides)
197
+ }
198
+
199
+ const toggle = (entry: Entry) => () => {
200
+ entry.isOpen = !entry.isOpen
201
+ disarm(entry)
202
+ changed()
203
+ }
204
+
205
+ const row = (entry: Entry): RenderElement => {
206
+ const { description } = entry.options
207
+ const isText = typeof description === 'string'
208
+ const icon = entry.type === 'loading' ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
209
+ const message = isText ? Button({ plain: true, key: `toast:${entry.id}:open`, label: entry.message, onPress: toggle(entry) }) : Text({ children: entry.message })
210
+ const details = !isText ? description : Text({ dimColor: true, wrap: entry.isOpen ? 'wrap' : 'truncate-end', children: entry.isOpen ? description : firstLine(description) })
211
+ const fixed = (child: RenderElement) => Box({ flexShrink: 0, children: child })
212
+ const close = Button({ plain: true, dimColor: true, key: `toast:${entry.id}:close`, label: entry.hides === undefined ? '✕' : 'Hide', onPress: () => hide(entry) })
213
+ 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) }))
214
+ const text = details === undefined ? [message] : [message, details]
215
+ const under = entry.reporting === undefined ? [Box({ gap: 2, flexWrap: 'wrap', children: [...actions, close] })] : reportChoices(entry, entry.reporting)
216
+ return Box({
217
+ gap: 2,
218
+ children: [
219
+ fixed(Text({ ...(icon.color === undefined ? {} : { color: icon.color }), children: icon.glyph })),
220
+ fixed(Text({ dimColor: true, children: name })),
221
+ Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: entry.hides === undefined ? text : [...text, ...under] }),
222
+ ...(entry.hides === undefined ? [...actions.map(fixed), fixed(close)] : []),
223
+ ],
224
+ })
225
+ }
226
+
227
+ const reportChoices = (entry: Entry, { contact, details, status }: Reporting): RenderElement[] => {
228
+ const say = (said: string) => {
229
+ entry.reporting = { contact, details, status: said }
230
+ changed()
231
+ }
232
+ const back = () => {
233
+ entry.reporting = undefined
234
+ changed()
235
+ }
236
+ const askClaude = async () => {
237
+ await claude.prompt.submit({ text: reportPrompt(name, contact, details), asUser: true })
238
+ back()
239
+ }
240
+ const copyError = async () => {
241
+ const copied = await claude.ui.copy({ text: details })
242
+ say(copied.isCopied ? 'Copied the error.' : `Could not copy it: ${copied.reason}`)
243
+ }
244
+ 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)' })]
245
+ return [
246
+ Text({ dimColor: true, children: 'Claude writes up the problem and files it with your tools. You approve each step before anything is posted.' }),
247
+ Box({
248
+ gap: 2,
249
+ flexWrap: 'wrap',
250
+ children: [
251
+ Button({ plain: true, key: `toast:${entry.id}:ask-claude`, label: 'Ask Claude to report it', onPress: press(entry, askClaude) }),
252
+ ...report,
253
+ Button({ plain: true, key: `toast:${entry.id}:copy`, label: 'Copy the error', onPress: press(entry, copyError) }),
254
+ Button({ plain: true, dimColor: true, key: `toast:${entry.id}:back`, label: 'Back', onPress: back }),
255
+ ],
256
+ }),
257
+ ...(status === undefined ? [] : [Text({ dimColor: true, children: status })]),
258
+ ]
259
+ }
260
+
261
+ return {
262
+ toast,
263
+ copy,
264
+ fail,
265
+ get shown() {
266
+ return [...entries.values()].map(({ type, message, options }) => ({ type, message, ...(typeof options.description === 'string' ? { description: options.description } : {}) }))
267
+ },
268
+ draw(drawn, table, band, drawnIn) {
269
+ bandId = drawnIn
270
+ isHeld = band.hasSurvey === true
271
+ if (isHeld || entries.size === 0) return drawn
272
+ const all = [...entries.values()]
273
+ const hidden = showsAll ? 0 : Math.max(all.length - visibleToasts, 0)
274
+ for (const entry of all.slice(hidden)) arm(entry)
275
+ const more = Button({
276
+ plain: true,
277
+ dimColor: true,
278
+ key: 'toasts:more',
279
+ label: `+${hidden} more`,
280
+ onPress: () => {
281
+ showsAll = true
282
+ changed()
283
+ },
284
+ })
285
+ return drawWith(table, () => Box({ flexDirection: 'column', children: [drawn, ...(hidden > 0 ? [more] : []), ...all.slice(hidden).map(row)] }))
286
+ },
287
+ }
288
+ }
289
+
290
+ function describe(error: unknown): string {
291
+ const messages: string[] = []
292
+ for (let cause = error; cause !== undefined; cause = cause instanceof Error ? cause.cause : undefined) {
293
+ const text = messageOf(cause)
294
+ if (!messages.some((earlier) => earlier.includes(text) || text.includes(earlier))) messages.push(text)
295
+ }
296
+ return messages.join('\n')
297
+ }
298
+
299
+ async function contactOf(claude: Claude): Promise<Contact | undefined> {
300
+ const manifest: Record<string, unknown> = await claude.fs
301
+ .read(`${claude.plugin.root}/.claude-plugin/plugin.json`)
302
+ .then((text) => JSON.parse(text) as Record<string, unknown>)
303
+ .catch((error: unknown) => {
304
+ claude.ui.log(`${claude.plugin.name}: could not read whom to report to: ${messageOf(error)}`, { to: 'debug' })
305
+ return {}
306
+ })
307
+ const repositoryUrl = isObject(manifest['repository']) ? manifest['repository']['url'] : manifest['repository']
308
+ const repository = [manifest['supportUrl'], repositoryUrl].map((url) => (typeof url === 'string' ? githubRepository.exec(url)?.[1] : undefined)).find((found) => found !== undefined)
309
+ if (repository !== undefined) return { kind: 'github', repository }
310
+ const author = manifest['author']
311
+ const address = isObject(author) ? author['email'] : undefined
312
+ return typeof address === 'string' && address !== '' ? { kind: 'email', address } : undefined
313
+ }
314
+
315
+ function reportLink(contact: Contact, name: string, message: string, details: string): string {
316
+ const title = `${name}: ${message}`.slice(0, 120)
317
+ const linkOf = (body: string) =>
318
+ contact.kind === 'github'
319
+ ? `https://github.com/${contact.repository}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
320
+ : `mailto:${contact.address}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
321
+ let body = details
322
+ while (linkOf(body).length > linkLimit && body.length > 0) body = `${body.slice(0, Math.floor(body.length * 0.8))}…`
323
+ return linkOf(body)
324
+ }
325
+
326
+ function reportPrompt(name: string, contact: Contact | undefined, details: string): string {
327
+ const where =
328
+ contact === undefined
329
+ ? `${name} names no place to report it, so find its author from its plugin.json or its marketplace.`
330
+ : contact.kind === 'github'
331
+ ? `Report it as an issue on https://github.com/${contact.repository}.`
332
+ : `Email it to ${contact.address}.`
333
+ 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}`
334
+ }
package/src/runtime/ui.ts CHANGED
@@ -1,125 +1,19 @@
1
- import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement, Timer } from 'claude-code'
2
- import type { Mod, PaneHandle, ProgressStep } from '../mod.js'
3
- import { ProgressBar } from '../ui/components.js'
1
+ import type { ElementTable, MarkdownProps, PaneOpenArgs, RenderElement } from 'claude-code'
2
+ import type { Disposable, Mod, PaneHandle } from '../mod.js'
4
3
  import type { Pane } from '../ui/define-pane.js'
5
4
  import { Box, drawWith, Text } from '../ui/elements.js'
6
- import type { MarkdownKind, MarkdownReader } from '../ui/markdown.js'
7
- import type { Slot, SlotProps } from '../ui/slots.js'
5
+ import type { MarkdownReader } from '../ui/markdown.js'
6
+ import { componentOf, nameOf, type Place, type PlaceProps } from '../ui/places.js'
8
7
  import { messageOf } from '../utils/text.js'
9
8
  import type { Claude } from './claude.js'
10
9
  import type { Router } from './router.js'
11
-
12
- export type ProgressLine = {
13
- report(step: ProgressStep): void
14
- wait(text: string): void
15
- fail(reason: string, fix: string): void
16
- end(): void
17
- }
18
-
19
- export type Progress = {
20
- readonly isShown: boolean
21
- start(title: string): ProgressLine
22
- draw(drawn: RenderElement, table: ElementTable, columns: number | undefined): RenderElement
23
- }
24
-
25
- type Line = {
26
- readonly title: string
27
- step: ProgressStep | undefined
28
- waiting: string | undefined
29
- failure: { readonly reason: string; readonly fix: string } | undefined
30
- }
10
+ import type { Failure, Toaster } from './toaster.js'
31
11
 
32
12
  type PaneSize = Pick<PaneOpenArgs, 'columns' | 'rows'>
33
13
 
34
- const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
35
- const spinnerMs = 100
36
14
  const bullet = '⏺'
37
- const widestBar = 30
38
- const narrowestBar = 10
39
15
  const sizeKeys = ['columns', 'rows'] as const
40
16
 
41
- export function createProgress(claude: Claude): Progress {
42
- const lines: Line[] = []
43
- let frame = 0
44
- let timer: Timer | undefined
45
-
46
- const changed = () => {
47
- const isSpinning = lines.some((line) => line.failure === undefined && line.waiting === undefined)
48
- if (isSpinning && timer === undefined) {
49
- timer = claude.clock.every(spinnerMs, () => {
50
- frame += 1
51
- claude.ui.invalidate('ui.render')
52
- })
53
- }
54
- if (!isSpinning && timer !== undefined) {
55
- timer.cancel()
56
- timer = undefined
57
- }
58
- claude.ui.invalidate('ui.render')
59
- }
60
-
61
- return {
62
- get isShown() {
63
- return lines.length > 0
64
- },
65
- start(title) {
66
- const line: Line = { title, step: undefined, waiting: undefined, failure: undefined }
67
- lines.push(line)
68
- changed()
69
- return {
70
- report(step) {
71
- line.step = step
72
- line.waiting = undefined
73
- changed()
74
- },
75
- wait(text) {
76
- line.waiting = text
77
- changed()
78
- },
79
- fail(reason, fix) {
80
- line.failure = { reason, fix }
81
- changed()
82
- },
83
- end() {
84
- const index = lines.indexOf(line)
85
- if (index >= 0) lines.splice(index, 1)
86
- changed()
87
- },
88
- }
89
- },
90
- draw(drawn, table, columns) {
91
- return drawWith(table, () => Box({ flexDirection: 'column', children: [drawn, ...lines.map((line) => drawLine(line, frame, columns))] }))
92
- },
93
- }
94
- }
95
-
96
- function drawLine(line: Line, frame: number, columns: number | undefined): RenderElement {
97
- const title = Text({ bold: true, children: line.title })
98
- if (line.failure !== undefined) {
99
- return Box({
100
- flexDirection: 'column',
101
- children: [
102
- Box({ children: [Box({ minWidth: 2, children: Text({ color: 'error', children: '✗' }) }), Text({ children: [title, ' ', Text({ color: 'error', children: line.failure.reason })] })] }),
103
- Box({ paddingLeft: 2, children: Text({ dimColor: true, children: line.failure.fix }) }),
104
- ],
105
- })
106
- }
107
- if (line.waiting !== undefined) {
108
- return Text({ wrap: 'truncate-end', children: [Text({ color: 'warning', children: '◌' }), ' ', title, ' ', Text({ dimColor: true, children: line.waiting })] })
109
- }
110
- const glyph = Text({ color: 'claude', children: spinner[frame % spinner.length] })
111
- if (line.step === undefined) return Text({ wrap: 'truncate-end', children: [glyph, ' ', title, Text({ dimColor: true, children: '…' })] })
112
- const { done, total, label = '' } = line.step
113
- const width = barWidth(columns, `${line.title}${done}/${total}${label}`.length)
114
- return Text({ wrap: 'truncate-end', children: [glyph, ' ', title, ' ', ProgressBar({ done, total, width }), ...(label === '' ? [] : [' ', label])] })
115
- }
116
-
117
- function barWidth(columns: number | undefined, textLength: number): number {
118
- if (columns === undefined) return widestBar
119
- const spacing = 2 + 2 + 2 + 2
120
- return Math.max(narrowestBar, Math.min(widestBar, columns - textLength - spacing))
121
- }
122
-
123
17
  function paneSize<State extends object>(pane: Pane<State>, state: State): PaneSize {
124
18
  const size: { columns?: number; rows?: number } = {}
125
19
  for (const key of sizeKeys) {
@@ -197,21 +91,31 @@ export type UiOptions<State extends object> = {
197
91
  readonly name: string
198
92
  readonly claude: Claude
199
93
  readonly router: Router
200
- readonly progress: Progress
94
+ readonly toaster: Toaster
95
+ readonly report: (failure: Failure) => void
201
96
  readonly announce: (feature: string) => void
202
97
  readonly mod: () => Mod<State>
203
98
  }
204
99
 
205
100
  export type UiArea<State extends object> = {
206
- readonly ui: Mod<State>['ui']
101
+ readonly ui: Omit<Mod<State>['ui'], 'notice'>
207
102
  changed(): void
208
103
  restorePanes(): Promise<void>
209
104
  }
210
105
 
211
- export function createUi<State extends object>({ name, claude, router, progress, announce, mod }: UiOptions<State>): UiArea<State> {
106
+ export function createUi<State extends object>({ name, claude, router, toaster, report, announce, mod }: UiOptions<State>): UiArea<State> {
212
107
  const panes = new Map<string, Pane<State>>()
213
108
  const paneLogs = new Map<string, (failure: string) => void>()
214
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' }))
215
119
  const requestedSizes = new Map<string, PaneSize>()
216
120
  const renders = new Set<string>()
217
121
  const markdownRenders = new Map<string, MarkdownRender>()
@@ -222,7 +126,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
222
126
  const logOnce = (what: string) => {
223
127
  let hasLogged = false
224
128
  return (failure: string) => {
225
- if (!hasLogged) claude.ui.log(`${name}: the ${what} render ${failure}`)
129
+ if (!hasLogged) report({ id: `render:${what}`, message: `The ${what} render failed`, description: failure })
226
130
  hasLogged = true
227
131
  }
228
132
  }
@@ -250,9 +154,9 @@ export function createUi<State extends object>({ name, claude, router, progress,
250
154
  }
251
155
  }
252
156
 
253
- const routeMarkdown = (kind: MarkdownKind) => {
157
+ const routeMarkdown = (createReader: () => MarkdownReader) => {
254
158
  if (read !== undefined) return
255
- const reader = kind.createReader()
159
+ const reader = createReader()
256
160
  read = reader
257
161
  router.add('ui.render', async (e, next) => {
258
162
  if (e.component !== 'AssistantMessage') return next(e)
@@ -302,6 +206,8 @@ export function createUi<State extends object>({ name, claude, router, progress,
302
206
  const pane = panes.get(e.requestId)
303
207
  if (pane === undefined || e.component !== 'Pane') return next(e)
304
208
  openPanes.add(pane.id)
209
+ paneStates.set(pane.id, { isShown: true, isFocused: e.props.isFocused })
210
+ readPanes()
305
211
  const table = claude.ui.resolve(e)
306
212
  try {
307
213
  return drawWith(table, () => pane.render(mod(), e.props), markdownIn(table))
@@ -319,6 +225,8 @@ export function createUi<State extends object>({ name, claude, router, progress,
319
225
  })
320
226
  router.add('ui.close', async (e, next) => {
321
227
  openPanes.delete(e.id)
228
+ paneStates.delete(e.id)
229
+ readPanes()
322
230
  const closed = await next(e)
323
231
  const pane = panes.get(e.id)
324
232
  if (pane?.onClose !== undefined) await handle(pane, 'onClose', () => pane.onClose?.(mod(), e))
@@ -330,7 +238,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
330
238
  try {
331
239
  await run()
332
240
  } catch (error) {
333
- claude.ui.log(`${name}: the ${pane.title} pane's ${handler} threw: ${messageOf(error)}`)
241
+ report({ id: `pane:${pane.id}:${handler}`, message: `The ${pane.title} pane's ${handler} threw`, error })
334
242
  }
335
243
  }
336
244
 
@@ -346,38 +254,55 @@ export function createUi<State extends object>({ name, claude, router, progress,
346
254
  get isOpen() {
347
255
  return openPanes.has(pane.id)
348
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
+ },
349
263
  async open(options) {
350
264
  const { isPlaced } = await openAt(pane, paneSize(pane, mod().state), options?.focus)
351
265
  if (isPlaced) openPanes.add(pane.id)
266
+ await syncPanes()
352
267
  },
353
268
  async close() {
354
269
  await claude.ui.close({ id: pane.id })
355
270
  openPanes.delete(pane.id)
271
+ paneStates.delete(pane.id)
272
+ await syncPanes()
356
273
  },
357
274
  toggle: (options) => (openPanes.has(pane.id) ? handle.close() : handle.open(options)),
358
275
  }
359
276
  return handle
360
277
  },
361
- render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement) {
362
- const place: Slot = slot
363
- const feature = 'markdown' in place ? `a render of markdown ${place.markdown.name}` : `a render of ${place.component}`
364
- if (renders.has(feature)) throw new Error(`${name}: ${feature} is already added. Render each slot once.`)
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.`)
365
285
  renders.add(feature)
366
286
  announce(feature)
367
- if ('markdown' in place) {
368
- const kind = place.markdown
369
- markdownRenders.set(kind.type, { Component: Component as unknown as MarkdownRender['Component'], log: logOnce(`markdown ${kind.name}`) })
370
- routeMarkdown(kind)
371
- return
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
+ }
372
298
  }
373
- const { component } = place
374
- const log = logOnce(component)
375
- router.add('ui.render', async (e, next) => {
299
+ const log = logOnce(nameOf(place))
300
+ const drawing = router.add('ui.render', async (e, next) => {
376
301
  if (e.component !== component) return next(e)
377
302
  const table = claude.ui.resolve(e)
378
303
  const drawn = await drawComponent(
379
304
  table,
380
- (Default) => Component({ ...e.props, Default } as unknown as SlotProps<S>),
305
+ (Default) => Component({ ...e.props, Default } as unknown as PlaceProps<P>),
381
306
  (given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
382
307
  )
383
308
  if ('failure' in drawn) {
@@ -387,16 +312,16 @@ export function createUi<State extends object>({ name, claude, router, progress,
387
312
  if (e.component !== 'AssistantMessage') return drawn.drawing
388
313
  return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
389
314
  })
390
- },
391
- toast: (text, options) => claude.ui.toast(text, options),
392
- async progress(title, task) {
393
- const line = progress.start(title)
394
- try {
395
- return await task((step) => line.report(step))
396
- } finally {
397
- line.end()
315
+ return {
316
+ dispose() {
317
+ drawing.dispose()
318
+ renders.delete(feature)
319
+ claude.ui.invalidate('ui.render')
320
+ },
398
321
  }
399
322
  },
323
+ toast: toaster.toast,
324
+ copy: toaster.copy,
400
325
  ask: (question, options) => claude.ui.ask(question, options),
401
326
  scroll: (args) => claude.ui.scroll(args),
402
327
  },
@@ -413,10 +338,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
413
338
  })
414
339
  },
415
340
  async restorePanes() {
416
- if (panes.size === 0) return
417
- for (const open of await claude.ui.panes()) {
418
- if (open.isPlaced && panes.has(open.id)) openPanes.add(open.id)
419
- }
341
+ if (panes.size > 0) await syncPanes()
420
342
  },
421
343
  }
422
344
  }
@@ -0,0 +1,14 @@
1
+ import type { RecordPart } from '../session.js'
2
+ import type { Turn } from './turn/turn.js'
3
+
4
+ export const ContextStart = 'contextStart'
5
+
6
+ export const ContextEnd = 'contextEnd'
7
+
8
+ export type Context = RecordPart & {
9
+ readonly number: number
10
+ readonly openedAt: string
11
+ readonly closedAt?: string
12
+ readonly task?: string
13
+ turns(): Promise<Turn[]>
14
+ }
@@ -0,0 +1,8 @@
1
+ import type { MessageOf } from './message.js'
2
+
3
+ export const Document = 'document'
4
+
5
+ export type Document = MessageOf<typeof Document> & {
6
+ readonly path: string
7
+ readonly text: string
8
+ }