@cmodjs/core 0.2.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/docs/commands.md +58 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +41 -28
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +66 -23
  13. package/docs/ui.md +108 -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 +8 -3
  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 +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -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 +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +18 -6
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -3
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -33
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +317 -186
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +18 -6
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +5 -3
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -31
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +313 -181
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +23 -18
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +24 -16
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -2
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -12
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.d.ts +1 -0
  262. package/utils/parse-shell.d.ts.map +1 -1
  263. package/utils/parse-shell.js +22 -13
  264. package/utils/paths.d.ts +2 -0
  265. package/utils/paths.d.ts.map +1 -1
  266. package/utils/paths.js +13 -1
  267. package/utils/text.d.ts +1 -0
  268. package/utils/text.d.ts.map +1 -1
  269. package/utils/text.js +3 -0
  270. package/src/ui/slots.ts +0 -36
  271. package/ui/slots.d.ts +0 -208
  272. package/ui/slots.d.ts.map +0 -1
  273. package/ui/slots.js +0 -19
@@ -0,0 +1,218 @@
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'
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 }
11
+
12
+ export type Toaster = {
13
+ readonly toast: Toast
14
+ copy(text: string): Promise<void>
15
+ readonly shown: readonly ShownToast[]
16
+ draw(drawn: RenderElement, table: ElementTable, band: RenderPropsOf['AbovePrompt']): RenderElement
17
+ }
18
+
19
+ type Entry = {
20
+ readonly id: ToastId
21
+ readonly type: ToastType
22
+ readonly message: string
23
+ readonly options: ToastOptions
24
+ isOpen: boolean
25
+ timer: Timer | undefined
26
+ }
27
+
28
+ const defaultDuration = 4000
29
+ const visibleToasts = 3
30
+ const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
31
+ const spinnerMs = 100
32
+ const icons: Record<Exclude<ToastType, 'loading'>, { readonly glyph: string; readonly color?: string }> = {
33
+ default: { glyph: ' ' },
34
+ success: { glyph: '✓', color: 'success' },
35
+ info: { glyph: 'ℹ', color: 'suggestion' },
36
+ warning: { glyph: '⚠', color: 'warning' },
37
+ error: { glyph: '✗', color: 'error' },
38
+ }
39
+
40
+ export function createToaster(claude: Claude, name: string): Toaster {
41
+ const entries = new Map<ToastId, Entry>()
42
+ let lastId = 0
43
+ let frame = 0
44
+ let spinning: Timer | undefined
45
+ let isHeld = false
46
+ let showsAll = false
47
+
48
+ const changed = () => {
49
+ const isLoading = [...entries.values()].some((entry) => entry.type === 'loading')
50
+ if (isLoading && spinning === undefined) {
51
+ spinning = claude.clock.every(spinnerMs, () => {
52
+ frame += 1
53
+ claude.ui.invalidate('ui.render')
54
+ })
55
+ }
56
+ if (!isLoading && spinning !== undefined) {
57
+ spinning.cancel()
58
+ spinning = undefined
59
+ }
60
+ if (entries.size <= visibleToasts) showsAll = false
61
+ claude.ui.invalidate('ui.render')
62
+ }
63
+
64
+ const disarm = (entry: Entry) => {
65
+ entry.timer?.cancel()
66
+ entry.timer = undefined
67
+ }
68
+
69
+ const arm = (entry: Entry) => {
70
+ const duration = entry.options.duration ?? defaultDuration
71
+ if (entry.timer !== undefined || entry.type === 'loading' || entry.isOpen || !Number.isFinite(duration)) return
72
+ entry.timer = claude.clock.after(duration, () => {
73
+ entry.timer = undefined
74
+ if (isHeld) arm(entry)
75
+ else dismiss(entry.id)
76
+ })
77
+ }
78
+
79
+ const dismiss = (id?: ToastId) => {
80
+ for (const entry of entries.values()) {
81
+ if (id !== undefined && entry.id !== id) continue
82
+ disarm(entry)
83
+ entries.delete(entry.id)
84
+ }
85
+ changed()
86
+ }
87
+
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)
97
+ changed()
98
+ return id
99
+ }
100
+
101
+ const toast: Toast = Object.assign(show('default'), {
102
+ success: show('success'),
103
+ info: show('info'),
104
+ warning: show('warning'),
105
+ error: show('error'),
106
+ loading: show('loading'),
107
+ dismiss,
108
+ promise<T>(task: Promise<T> | (() => Promise<T>), messages: Parameters<Toast['promise']>[1]) {
109
+ const id = show('loading')(messages.loading)
110
+ const settled = Promise.resolve()
111
+ .then(() => (typeof task === 'function' ? task() : task))
112
+ .then(
113
+ (value) => {
114
+ show('success')(typeof messages.success === 'function' ? messages.success(value) : messages.success, { id })
115
+ return value
116
+ },
117
+ (error: unknown) => {
118
+ show('error')(typeof messages.error === 'function' ? messages.error(error) : messages.error, { id })
119
+ throw error
120
+ },
121
+ )
122
+ settled.catch(() => undefined)
123
+ return { unwrap: () => settled }
124
+ },
125
+ })
126
+
127
+ const copy = async (text: string) => {
128
+ const copied = await claude.ui.copy({ text })
129
+ if (!copied.isCopied) throw new Error(copied.reason)
130
+ }
131
+
132
+ const press = (entry: Entry, run: () => unknown) => () =>
133
+ void Promise.resolve()
134
+ .then(run)
135
+ .catch((error: unknown) => showFailure({ name, claude, ui: { toast, copy } }, { id: `press:${entry.id}`, message: `${entry.message}: the press failed`, error }))
136
+
137
+ const toggle = (entry: Entry) => () => {
138
+ entry.isOpen = !entry.isOpen
139
+ disarm(entry)
140
+ changed()
141
+ }
142
+
143
+ const row = (entry: Entry): RenderElement => {
144
+ const { description, action } = entry.options
145
+ const isText = typeof description === 'string'
146
+ const icon = entry.type === 'loading' ? { glyph: spinner[frame % spinner.length] as string, color: 'claude' } : icons[entry.type]
147
+ const message = isText ? Button({ plain: true, key: `toast:${entry.id}:open`, label: entry.message, onPress: toggle(entry) }) : Text({ children: entry.message })
148
+ const details = !isText ? description : Text({ dimColor: true, wrap: entry.isOpen ? 'wrap' : 'truncate-end', children: entry.isOpen ? description : firstLine(description) })
149
+ const fixed = (child: RenderElement) => Box({ flexShrink: 0, children: child })
150
+ return Box({
151
+ gap: 2,
152
+ children: [
153
+ fixed(Text({ ...(icon.color === undefined ? {} : { color: icon.color }), children: icon.glyph })),
154
+ 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) })),
158
+ ],
159
+ })
160
+ }
161
+
162
+ return {
163
+ toast,
164
+ copy,
165
+ get shown() {
166
+ return [...entries.values()].map(({ type, message, options }) => ({ type, message, ...(typeof options.description === 'string' ? { description: options.description } : {}) }))
167
+ },
168
+ draw(drawn, table, band) {
169
+ isHeld = band.hasSurvey === true
170
+ if (isHeld || entries.size === 0) return drawn
171
+ const all = [...entries.values()]
172
+ const hidden = showsAll ? 0 : Math.max(all.length - visibleToasts, 0)
173
+ for (const entry of all.slice(hidden)) arm(entry)
174
+ const more = Button({
175
+ plain: true,
176
+ dimColor: true,
177
+ key: 'toasts:more',
178
+ label: `+${hidden} more`,
179
+ onPress: () => {
180
+ showsAll = true
181
+ changed()
182
+ },
183
+ })
184
+ return drawWith(table, () => Box({ flexDirection: 'column', children: [drawn, ...(hidden > 0 ? [more] : []), ...all.slice(hidden).map(row)] }))
185
+ },
186
+ }
187
+ }
188
+
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
+ function describe(error: unknown): string {
212
+ const messages: string[] = []
213
+ for (let cause = error; cause !== undefined; cause = cause instanceof Error ? cause.cause : undefined) {
214
+ const text = messageOf(cause)
215
+ if (!messages.some((earlier) => earlier.includes(text) || text.includes(earlier))) messages.push(text)
216
+ }
217
+ return messages.join('\n')
218
+ }
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,18 +91,19 @@ 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>()
@@ -222,7 +117,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
222
117
  const logOnce = (what: string) => {
223
118
  let hasLogged = false
224
119
  return (failure: string) => {
225
- if (!hasLogged) claude.ui.log(`${name}: the ${what} render ${failure}`)
120
+ if (!hasLogged) report({ id: `render:${what}`, message: `The ${what} render failed`, description: failure })
226
121
  hasLogged = true
227
122
  }
228
123
  }
@@ -250,9 +145,9 @@ export function createUi<State extends object>({ name, claude, router, progress,
250
145
  }
251
146
  }
252
147
 
253
- const routeMarkdown = (kind: MarkdownKind) => {
148
+ const routeMarkdown = (createReader: () => MarkdownReader) => {
254
149
  if (read !== undefined) return
255
- const reader = kind.createReader()
150
+ const reader = createReader()
256
151
  read = reader
257
152
  router.add('ui.render', async (e, next) => {
258
153
  if (e.component !== 'AssistantMessage') return next(e)
@@ -330,7 +225,7 @@ export function createUi<State extends object>({ name, claude, router, progress,
330
225
  try {
331
226
  await run()
332
227
  } catch (error) {
333
- claude.ui.log(`${name}: the ${pane.title} pane's ${handler} threw: ${messageOf(error)}`)
228
+ report({ id: `pane:${pane.id}:${handler}`, message: `The ${pane.title} pane's ${handler} threw`, error })
334
229
  }
335
230
  }
336
231
 
@@ -358,26 +253,34 @@ export function createUi<State extends object>({ name, claude, router, progress,
358
253
  }
359
254
  return handle
360
255
  },
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.`)
256
+ render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable {
257
+ const target: Place = place
258
+ const markdown = typeof target === 'string' ? undefined : target
259
+ const component = componentOf(place)
260
+ if (markdown === undefined && component === undefined) throw new Error(`${name}: ${String(place)} is not a place render draws, such as ToolCall or CodeBlock.`)
261
+ const feature = `a render of ${nameOf(place)}`
262
+ if (renders.has(feature)) throw new Error(`${name}: ${feature} is already added. Render each place once.`)
365
263
  renders.add(feature)
366
264
  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
265
+ if (markdown !== undefined) {
266
+ const markdownType = markdown.markdown
267
+ markdownRenders.set(markdownType, { Component: Component as unknown as MarkdownRender['Component'], log: logOnce(nameOf(place)) })
268
+ routeMarkdown(markdown.createReader)
269
+ return {
270
+ dispose() {
271
+ markdownRenders.delete(markdownType)
272
+ renders.delete(feature)
273
+ claude.ui.invalidate('ui.render')
274
+ },
275
+ }
372
276
  }
373
- const { component } = place
374
- const log = logOnce(component)
375
- router.add('ui.render', async (e, next) => {
277
+ const log = logOnce(nameOf(place))
278
+ const drawing = router.add('ui.render', async (e, next) => {
376
279
  if (e.component !== component) return next(e)
377
280
  const table = claude.ui.resolve(e)
378
281
  const drawn = await drawComponent(
379
282
  table,
380
- (Default) => Component({ ...e.props, Default } as unknown as SlotProps<S>),
283
+ (Default) => Component({ ...e.props, Default } as unknown as PlaceProps<P>),
381
284
  (given, isClaudesRow) => next({ ...e, props: pieceProps(e.props, given, isClaudesRow) } as typeof e),
382
285
  )
383
286
  if ('failure' in drawn) {
@@ -387,16 +290,16 @@ export function createUi<State extends object>({ name, claude, router, progress,
387
290
  if (e.component !== 'AssistantMessage') return drawn.drawing
388
291
  return drawWith(table, () => drawPieces([drawn], e.props.isFirstOfReply ? 'opensReply' : 'inReply'))
389
292
  })
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()
293
+ return {
294
+ dispose() {
295
+ drawing.dispose()
296
+ renders.delete(feature)
297
+ claude.ui.invalidate('ui.render')
298
+ },
398
299
  }
399
300
  },
301
+ toast: toaster.toast,
302
+ copy: toaster.copy,
400
303
  ask: (question, options) => claude.ui.ask(question, options),
401
304
  scroll: (args) => claude.ui.scroll(args),
402
305
  },
@@ -0,0 +1,12 @@
1
+ import type { Turn } from './turn/turn.js'
2
+
3
+ export const ContextStart = 'contextStart'
4
+
5
+ export const ContextEnd = 'contextEnd'
6
+
7
+ export type Context = {
8
+ readonly number: number
9
+ readonly openedAt: string
10
+ readonly closedAt?: string
11
+ turns(): Promise<Turn[]>
12
+ }
@@ -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
+ }
@@ -0,0 +1,165 @@
1
+ import { isObject } from '../../../../records.js'
2
+ import { Task } from '../../../goal/task.js'
3
+ import { Document } from './document.js'
4
+ import { Note } from './note.js'
5
+ import { Notice } from './notice.js'
6
+ import { Prompt } from './prompt.js'
7
+ import { Reply } from './reply.js'
8
+ import { senderOf } from './sender.js'
9
+ import { Skill } from './skill.js'
10
+ import { ToolCall } from './tool-call.js'
11
+ import { ToolResult } from './tool-result.js'
12
+
13
+ export const Message = 'message'
14
+
15
+ export type MessageType = typeof Prompt | typeof Note | typeof Reply | typeof ToolCall | typeof ToolResult | typeof Skill | typeof Document | typeof Notice
16
+
17
+ export type MessageOf<Type extends MessageType> = {
18
+ readonly id: string
19
+ readonly type: Type
20
+ readonly at: string
21
+ readonly session: string
22
+ readonly context: number
23
+ readonly turn?: string
24
+ readonly task?: string
25
+ }
26
+
27
+ export type Message = Prompt | Note | Reply | ToolCall | ToolResult | Skill | Document | Notice
28
+
29
+ export type Placed = 'session' | 'context' | 'turn' | 'task'
30
+
31
+ export type Unplaced<M extends Message = Message> = M extends Message ? Omit<M, Placed> : never
32
+
33
+ export type Row = {
34
+ readonly id: string
35
+ readonly at: string
36
+ readonly type: 'user' | 'assistant' | 'attachment' | 'system'
37
+ readonly name?: string
38
+ readonly isMeta: boolean
39
+ readonly isSummary: boolean
40
+ readonly content: readonly unknown[]
41
+ readonly origin?: unknown
42
+ readonly cwd?: string
43
+ readonly path?: string
44
+ readonly model?: string
45
+ }
46
+
47
+ export const messageTypes: readonly MessageType[] = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, Document, Notice]
48
+
49
+ export const skillPrefix = 'Base directory for this skill:'
50
+
51
+ const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
52
+
53
+ const memoryPath = /^Contents of (.+?) \(/
54
+
55
+ export function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined {
56
+ const { uuid, timestamp, type } = row
57
+ if (typeof uuid !== 'string' || typeof timestamp !== 'string' || typeof type !== 'string' || !rowTypes.includes(type)) return undefined
58
+ const message = isObject(row['message']) ? row['message'] : {}
59
+ const attachment = isObject(row['attachment']) ? row['attachment'] : {}
60
+ const content = type === 'attachment' ? attachment['content'] : type === 'system' ? row['content'] : message['content']
61
+ const name = type === 'attachment' ? attachment['type'] : row['subtype']
62
+ return {
63
+ id: uuid,
64
+ at: timestamp,
65
+ type: type as Row['type'],
66
+ ...(typeof name === 'string' ? { name } : {}),
67
+ isMeta: row['isMeta'] === true,
68
+ isSummary: row['isCompactSummary'] === true,
69
+ content: blocksOf(content),
70
+ ...(row['origin'] === undefined ? {} : { origin: row['origin'] }),
71
+ ...(typeof row['cwd'] === 'string' ? { cwd: row['cwd'] } : {}),
72
+ ...(typeof attachment['path'] === 'string' ? { path: attachment['path'] } : {}),
73
+ ...(typeof message['model'] === 'string' ? { model: message['model'] } : {}),
74
+ }
75
+ }
76
+
77
+ export function rowOfAppend(append: { readonly uuid: string; readonly door: string; readonly origin: unknown; readonly message: { readonly type: Row['type']; readonly name?: string; readonly isMeta?: true; readonly content: readonly unknown[] } }, at: string, cwd: string): Row {
78
+ const { message, origin } = append
79
+ const text = textOf(message.content)
80
+ const path = message.type === 'attachment' ? memoryPath.exec(text)?.[1] : undefined
81
+ const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined
82
+ return {
83
+ id: append.uuid,
84
+ at,
85
+ type: message.type,
86
+ ...(message.name === undefined ? {} : { name: message.name }),
87
+ isMeta: message.isMeta === true,
88
+ isSummary: append.door === 'compaction',
89
+ content: message.content,
90
+ origin,
91
+ cwd,
92
+ ...(path === undefined ? {} : { path }),
93
+ ...(model === undefined ? {} : { model }),
94
+ }
95
+ }
96
+
97
+ export function messagesOf(row: Row, isInTask: boolean): Unplaced[] {
98
+ const found = unplacedOf(row, isInTask)
99
+ return found.length === 1 ? found : found.map((message, index) => ({ ...message, id: `${row.id}:${index}` }))
100
+ }
101
+
102
+ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
103
+ const base = { id: row.id, at: row.at }
104
+ const text = textOf(row.content).trim()
105
+ if (row.type === 'system') return row.name === 'turn_duration' || row.name === 'compact_boundary' || text === '' ? [] : [{ ...base, type: Notice, text }]
106
+ if (row.type === 'attachment') return row.name === 'nested_memory' ? [{ ...base, type: Document, path: row.path ?? '', text }] : []
107
+ if (row.type === 'assistant') {
108
+ return row.content.filter(isObject).flatMap((block): Unplaced[] => {
109
+ if (block['type'] === 'text' && typeof block['text'] === 'string' && block['text'].trim() !== '') return [{ ...base, type: Reply, text: block['text'].trim() }]
110
+ if (block['type'] !== 'tool_use' || typeof block['id'] !== 'string' || typeof block['name'] !== 'string') return []
111
+ return [{ ...base, type: ToolCall, callId: block['id'], name: block['name'], input: isObject(block['input']) ? block['input'] : {}, cwd: row.cwd ?? '' }]
112
+ })
113
+ }
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>>[]
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
+ if (text === '') return []
118
+ if (text.startsWith(skillPrefix)) return [{ ...base, type: Skill, name: basename(text.slice(skillPrefix.length).split('\n')[0]?.trim() ?? ''), text }]
119
+ if (text.startsWith('<task-notification')) return [{ ...base, type: Note, text: tagOf(text, 'summary') ?? text, from: { type: Task } }]
120
+ if (row.isMeta) return [{ ...base, type: Note, text, from: senderOf(row.origin, isInTask) }]
121
+ const prompt = promptText(text)
122
+ return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
123
+ }
124
+
125
+ export function textOf(content: unknown): string {
126
+ if (typeof content === 'string') return content
127
+ if (!Array.isArray(content)) return ''
128
+ return content.flatMap((block) => (isObject(block) && block['type'] === 'text' && typeof block['text'] === 'string' ? [block['text']] : [])).join('\n')
129
+ }
130
+
131
+ export function tagOf(text: string, tag: string): string | undefined {
132
+ return new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)?.[1]?.trim()
133
+ }
134
+
135
+ const toolValueChars = 1024
136
+
137
+ export function searchText(message: Unplaced): string {
138
+ if (message.type === ToolCall) return [message.name, ...stringsIn(message.input).map((value) => value.slice(0, toolValueChars))].join('\n')
139
+ if (message.type === ToolResult) return ''
140
+ if (message.type === Document) return `${message.path}\n${message.text}`
141
+ return message.text
142
+ }
143
+
144
+ function blocksOf(content: unknown): readonly unknown[] {
145
+ if (Array.isArray(content)) return content
146
+ return typeof content === 'string' && content !== '' ? [{ type: 'text', text: content }] : []
147
+ }
148
+
149
+ function promptText(text: string): string | undefined {
150
+ if (text.startsWith('<local-command-') || text.startsWith('<bash-stdout') || text.startsWith('<bash-stderr')) return undefined
151
+ const command = tagOf(text, 'command-name')
152
+ if (command === undefined) return text
153
+ const args = tagOf(text, 'command-args') ?? ''
154
+ return args === '' ? command : `${command} ${args}`
155
+ }
156
+
157
+ function basename(path: string): string {
158
+ return path.slice(path.lastIndexOf('/') + 1)
159
+ }
160
+
161
+ function stringsIn(value: unknown): string[] {
162
+ if (typeof value === 'string') return [value]
163
+ if (Array.isArray(value)) return value.flatMap(stringsIn)
164
+ return isObject(value) ? Object.values(value).flatMap(stringsIn) : []
165
+ }
@@ -0,0 +1,9 @@
1
+ import type { MessageOf } from './message.js'
2
+ import type { Sender } from './sender.js'
3
+
4
+ export const Note = 'note'
5
+
6
+ export type Note = MessageOf<typeof Note> & {
7
+ readonly text: string
8
+ readonly from: Sender
9
+ }