@cmodjs/core 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/docs/commands.md +39 -22
  2. package/docs/dependencies.md +27 -20
  3. package/docs/index.md +21 -17
  4. package/docs/install-steps.md +37 -29
  5. package/docs/jobs.md +25 -52
  6. package/docs/mod.md +94 -66
  7. package/docs/options.md +2 -2
  8. package/docs/permissions.md +7 -5
  9. package/docs/session.md +14 -12
  10. package/docs/testing.md +55 -32
  11. package/docs/ui.md +255 -263
  12. package/hotkeys.d.ts +22 -0
  13. package/hotkeys.d.ts.map +1 -0
  14. package/hotkeys.js +26 -0
  15. package/installer.d.ts +1 -1
  16. package/installer.d.ts.map +1 -1
  17. package/installer.js +11 -6
  18. package/jobs/check.d.ts +2 -1
  19. package/jobs/check.d.ts.map +1 -1
  20. package/jobs/check.js +2 -2
  21. package/jobs/context.d.ts +2 -1
  22. package/jobs/context.d.ts.map +1 -1
  23. package/jobs/context.js +1 -1
  24. package/jobs/permissions.d.ts +2 -1
  25. package/jobs/permissions.d.ts.map +1 -1
  26. package/jobs/permissions.js +2 -2
  27. package/jobs/program.js +1 -1
  28. package/jobs/prompt.d.ts +2 -1
  29. package/jobs/prompt.d.ts.map +1 -1
  30. package/jobs/prompt.js +20 -13
  31. package/jobs/schedule.d.ts +2 -1
  32. package/jobs/schedule.d.ts.map +1 -1
  33. package/jobs/schedule.js +15 -1
  34. package/jobs/slash-command.d.ts +6 -2
  35. package/jobs/slash-command.d.ts.map +1 -1
  36. package/jobs/slash-command.js +6 -5
  37. package/jobs/tool.d.ts +6 -4
  38. package/jobs/tool.d.ts.map +1 -1
  39. package/jobs/tool.js +3 -3
  40. package/mod.d.ts +63 -42
  41. package/mod.d.ts.map +1 -1
  42. package/mod.js +11 -1
  43. package/package.json +1 -1
  44. package/records.d.ts +14 -8
  45. package/records.d.ts.map +1 -1
  46. package/records.js +44 -31
  47. package/register.d.ts.map +1 -1
  48. package/register.js +1 -2
  49. package/runtime/announce.d.ts +1 -1
  50. package/runtime/announce.d.ts.map +1 -1
  51. package/runtime/announce.js +6 -7
  52. package/runtime/before.d.ts +2 -2
  53. package/runtime/before.d.ts.map +1 -1
  54. package/runtime/before.js +1 -1
  55. package/runtime/claude.d.ts +0 -1
  56. package/runtime/claude.d.ts.map +1 -1
  57. package/runtime/conversations.d.ts +4 -1
  58. package/runtime/conversations.d.ts.map +1 -1
  59. package/runtime/conversations.js +42 -14
  60. package/runtime/dependencies.d.ts.map +1 -1
  61. package/runtime/dependencies.js +2 -1
  62. package/runtime/engine.d.ts +5 -0
  63. package/runtime/engine.d.ts.map +1 -1
  64. package/runtime/grants.js +2 -2
  65. package/runtime/hooks.d.ts +1 -1
  66. package/runtime/hooks.d.ts.map +1 -1
  67. package/runtime/hotkeys.d.ts +15 -0
  68. package/runtime/hotkeys.d.ts.map +1 -0
  69. package/runtime/hotkeys.js +58 -0
  70. package/runtime/installer.d.ts +5 -2
  71. package/runtime/installer.d.ts.map +1 -1
  72. package/runtime/installer.js +23 -12
  73. package/runtime/lifecycle.d.ts.map +1 -1
  74. package/runtime/lifecycle.js +190 -141
  75. package/runtime/options.js +2 -2
  76. package/runtime/pages.d.ts +28 -0
  77. package/runtime/pages.d.ts.map +1 -0
  78. package/runtime/pages.js +98 -0
  79. package/runtime/parts.d.ts +14 -0
  80. package/runtime/parts.d.ts.map +1 -0
  81. package/runtime/parts.js +63 -0
  82. package/runtime/permission-check.d.ts +2 -2
  83. package/runtime/permission-check.d.ts.map +1 -1
  84. package/runtime/permission-check.js +12 -4
  85. package/runtime/programs.js +1 -1
  86. package/runtime/router.d.ts +2 -2
  87. package/runtime/router.d.ts.map +1 -1
  88. package/runtime/router.js +1 -1
  89. package/runtime/sessions.d.ts +10 -9
  90. package/runtime/sessions.d.ts.map +1 -1
  91. package/runtime/sessions.js +14 -5
  92. package/runtime/state.d.ts +2 -1
  93. package/runtime/state.d.ts.map +1 -1
  94. package/runtime/state.js +1 -0
  95. package/runtime/toaster.d.ts +1 -1
  96. package/runtime/toaster.d.ts.map +1 -1
  97. package/runtime/toaster.js +10 -4
  98. package/runtime/ui.d.ts +87 -5
  99. package/runtime/ui.d.ts.map +1 -1
  100. package/runtime/ui.js +372 -109
  101. package/runtime/watches.d.ts +9 -0
  102. package/runtime/watches.d.ts.map +1 -0
  103. package/runtime/watches.js +73 -0
  104. package/runtime/widgets.d.ts +48 -0
  105. package/runtime/widgets.d.ts.map +1 -0
  106. package/runtime/widgets.js +219 -0
  107. package/session/context/turn/message/message.d.ts.map +1 -1
  108. package/session/context/turn/message/message.js +7 -2
  109. package/session/session.d.ts +11 -5
  110. package/session/session.d.ts.map +1 -1
  111. package/src/hotkeys.ts +51 -0
  112. package/src/installer.ts +8 -4
  113. package/src/jobs/check.ts +4 -3
  114. package/src/jobs/context.ts +3 -2
  115. package/src/jobs/permissions.ts +4 -3
  116. package/src/jobs/program.ts +1 -1
  117. package/src/jobs/prompt.ts +23 -14
  118. package/src/jobs/schedule.ts +16 -2
  119. package/src/jobs/slash-command.ts +10 -7
  120. package/src/jobs/tool.ts +7 -5
  121. package/src/mod.ts +59 -47
  122. package/src/records.ts +50 -31
  123. package/src/register.ts +2 -3
  124. package/src/runtime/announce.ts +6 -7
  125. package/src/runtime/before.ts +4 -4
  126. package/src/runtime/claude.ts +0 -1
  127. package/src/runtime/conversations.ts +66 -20
  128. package/src/runtime/dependencies.ts +2 -1
  129. package/src/runtime/engine.ts +5 -0
  130. package/src/runtime/grants.ts +2 -2
  131. package/src/runtime/hooks.ts +1 -0
  132. package/src/runtime/hotkeys.ts +80 -0
  133. package/src/runtime/installer.ts +24 -12
  134. package/src/runtime/lifecycle.ts +189 -136
  135. package/src/runtime/options.ts +2 -2
  136. package/src/runtime/pages.ts +127 -0
  137. package/src/runtime/parts.ts +72 -0
  138. package/src/runtime/permission-check.ts +13 -5
  139. package/src/runtime/programs.ts +1 -1
  140. package/src/runtime/router.ts +3 -3
  141. package/src/runtime/sessions.ts +24 -15
  142. package/src/runtime/state.ts +3 -1
  143. package/src/runtime/toaster.ts +10 -4
  144. package/src/runtime/ui.ts +405 -115
  145. package/src/runtime/watches.ts +75 -0
  146. package/src/runtime/widgets.ts +287 -0
  147. package/src/session/context/turn/message/message.ts +7 -2
  148. package/src/session/session.ts +10 -6
  149. package/src/testing/fake-claude.ts +9 -6
  150. package/src/testing/fake-elements.ts +7 -4
  151. package/src/testing/fake-sessions.ts +6 -4
  152. package/src/testing.ts +203 -46
  153. package/src/ui/areas.ts +27 -0
  154. package/src/ui/elements.ts +35 -0
  155. package/src/ui/markdown.ts +18 -18
  156. package/src/ui/panes.ts +73 -0
  157. package/src/ui/styles.ts +53 -0
  158. package/src/utils/subscription.ts +1 -0
  159. package/testing/fake-claude.d.ts +0 -1
  160. package/testing/fake-claude.d.ts.map +1 -1
  161. package/testing/fake-claude.js +9 -5
  162. package/testing/fake-elements.js +7 -4
  163. package/testing/fake-sessions.d.ts +2 -1
  164. package/testing/fake-sessions.d.ts.map +1 -1
  165. package/testing/fake-sessions.js +1 -0
  166. package/testing.d.ts +21 -6
  167. package/testing.d.ts.map +1 -1
  168. package/testing.js +206 -45
  169. package/ui/areas.d.ts +29 -0
  170. package/ui/areas.d.ts.map +1 -0
  171. package/ui/areas.js +6 -0
  172. package/ui/elements.d.ts +3 -0
  173. package/ui/elements.d.ts.map +1 -1
  174. package/ui/elements.js +36 -0
  175. package/ui/markdown.d.ts +14 -14
  176. package/ui/markdown.d.ts.map +1 -1
  177. package/ui/markdown.js +11 -11
  178. package/ui/panes.d.ts +71 -0
  179. package/ui/panes.d.ts.map +1 -0
  180. package/ui/panes.js +16 -0
  181. package/ui/styles.d.ts +44 -0
  182. package/ui/styles.d.ts.map +1 -0
  183. package/ui/styles.js +24 -0
  184. package/utils/subscription.d.ts +4 -0
  185. package/utils/subscription.d.ts.map +1 -0
  186. package/jobs/status-line.d.ts +0 -6
  187. package/jobs/status-line.d.ts.map +0 -1
  188. package/jobs/status-line.js +0 -39
  189. package/src/jobs/status-line.ts +0 -44
  190. package/src/ui/define-pane.ts +0 -27
  191. package/src/ui/places.ts +0 -49
  192. package/src/utils/disposable.ts +0 -3
  193. package/ui/define-pane.d.ts +0 -15
  194. package/ui/define-pane.d.ts.map +0 -1
  195. package/ui/define-pane.js +0 -12
  196. package/ui/places.d.ts +0 -43
  197. package/ui/places.d.ts.map +0 -1
  198. package/ui/places.js +0 -31
  199. package/utils/disposable.d.ts +0 -4
  200. package/utils/disposable.d.ts.map +0 -1
  201. /package/utils/{disposable.js → subscription.js} +0 -0
@@ -0,0 +1,75 @@
1
+ import type { FileChange } from '../mod.js'
2
+ import { ifMissing } from '../utils/paths.js'
3
+ import type { Subscription } from '../utils/subscription.js'
4
+ import type { Claude } from './claude.js'
5
+ import type { Failure } from './toaster.js'
6
+
7
+ export type Watches = { add(paths: readonly string[], listener: (change: FileChange) => unknown): Subscription }
8
+
9
+ type Watch = { readonly paths: readonly string[]; readonly listener: (change: FileChange) => unknown; seen: ReadonlyMap<string, number> | undefined }
10
+
11
+ const watchMs = 500
12
+
13
+ const watchedFiles = 2000
14
+
15
+ export function createWatches(claude: Claude, report: (failure: Failure) => void): Watches {
16
+ const watches = new Set<Watch>()
17
+ let polling: { cancel(): void } | undefined
18
+ let isPolling = false
19
+ const stampsOf = async (paths: readonly string[]): Promise<Map<string, number>> => {
20
+ const stamps = new Map<string, number>()
21
+ const visit = async (path: string): Promise<void> => {
22
+ const stat = await claude.fs.stat(path).catch(ifMissing(undefined))
23
+ if (stat === undefined) return
24
+ if (stat.kind !== 'dir') {
25
+ if (stamps.size === watchedFiles) throw new Error(`fs.onChange watches at most ${watchedFiles} files, and ${paths.join(', ')} holds more, so it stopped. Watch a smaller folder.`)
26
+ return void stamps.set(path, stat.mtimeMs)
27
+ }
28
+ for (const entry of await claude.fs.list(path).catch(ifMissing([]))) await visit(`${path}/${entry.name}`)
29
+ }
30
+ for (const path of paths) await visit(path)
31
+ return stamps
32
+ }
33
+ const stop = (watch: Watch) => {
34
+ watches.delete(watch)
35
+ if (watches.size > 0) return
36
+ polling?.cancel()
37
+ polling = undefined
38
+ }
39
+ const stampsOrStop = (watch: Watch) =>
40
+ stampsOf(watch.paths).catch((error: unknown) => {
41
+ stop(watch)
42
+ report({ id: 'fs:onChange', message: 'Could not read the files fs.onChange watches', error })
43
+ return undefined
44
+ })
45
+ const poll = async () => {
46
+ if (isPolling) return
47
+ isPolling = true
48
+ try {
49
+ for (const watch of watches) {
50
+ const now = await stampsOrStop(watch)
51
+ if (now === undefined) continue
52
+ const before = watch.seen
53
+ watch.seen = now
54
+ if (before === undefined) continue
55
+ const changes: FileChange[] = [
56
+ ...[...now].filter(([path, at]) => before.has(path) && before.get(path) !== at).map(([path]): FileChange => ({ path, type: 'change' })),
57
+ ...[...now.keys()].filter((path) => !before.has(path)).map((path): FileChange => ({ path, type: 'add' })),
58
+ ...[...before.keys()].filter((path) => !now.has(path)).map((path): FileChange => ({ path, type: 'unlink' })),
59
+ ]
60
+ for (const change of changes) await Promise.resolve().then(() => watch.listener(change)).catch((error: unknown) => report({ id: 'fs:onChange', message: 'The fs.onChange listener failed', error }))
61
+ }
62
+ } finally {
63
+ isPolling = false
64
+ }
65
+ }
66
+ return {
67
+ add(paths, listener) {
68
+ const watch: Watch = { paths, listener, seen: undefined }
69
+ watches.add(watch)
70
+ void stampsOrStop(watch).then((stamps) => void (watch.seen ??= stamps))
71
+ polling ??= claude.clock.every(watchMs, () => void poll())
72
+ return { remove: () => stop(watch) }
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,287 @@
1
+ import type { ElementTable, RenderElement } from 'claude-code'
2
+ import { isObject, widgetsPath } from '../records.js'
3
+ import type { TurnData } from '../session/conversation.js'
4
+ import { AbovePrompt, areas, BelowPrompt, TurnEnd, Working, type Area } from '../ui/areas.js'
5
+ import { Box, drawWith } from '../ui/elements.js'
6
+ import { ifMissing } from '../utils/paths.js'
7
+ import { messageOf } from '../utils/text.js'
8
+ import type { Claude } from './claude.js'
9
+ import { cmodPluginName } from './dependencies.js'
10
+ import type { DrawnParts } from './parts.js'
11
+ import type { Router } from './router.js'
12
+ import { partMethods, type PartsUpdate, type WidgetDeclaration } from './ui.js'
13
+
14
+ export type WidgetRow = { readonly left: readonly string[]; readonly right: readonly string[] }
15
+
16
+ export type WidgetLayout = { readonly areas: Readonly<Record<Area, readonly WidgetRow[]>>; readonly hidden: readonly string[] }
17
+
18
+ export type PlacedWidget = { readonly key: string; readonly mod: string; readonly title: string; readonly areas: readonly Area[] }
19
+
20
+ export type Move = 'up' | 'down' | 'left' | 'right'
21
+
22
+ export const claudeHint = 'claude/hint'
23
+
24
+ export const claudeModes = 'claude/modes'
25
+
26
+ export const builtInWidgets: readonly PlacedWidget[] = [
27
+ { key: claudeHint, mod: 'claude', title: "Claude Code's hint", areas: [BelowPrompt] },
28
+ { key: claudeModes, mod: 'claude', title: "Claude Code's mode labels", areas: [BelowPrompt] },
29
+ ]
30
+
31
+ const promptIndent = 4
32
+
33
+ const transcriptIndent = 2
34
+
35
+ const terminalColumns = 80
36
+
37
+ const separator = ''
38
+
39
+ export type Widgets = {
40
+ update(mod: string, update: Pick<PartsUpdate, 'parts' | 'redraw'>): void
41
+ widgets(): readonly PlacedWidget[]
42
+ layout(): WidgetLayout
43
+ change(edit: (layout: WidgetLayout) => WidgetLayout): Promise<void>
44
+ reload(): Promise<void>
45
+ recheck(): Promise<void>
46
+ rows(area: Area, columns: number): RenderElement[]
47
+ draw(area: Area, table: ElementTable, columns: number, turn?: TurnData): Promise<RenderElement[]>
48
+ }
49
+
50
+ export function layoutOf(value: unknown): WidgetLayout {
51
+ const keys = (list: unknown) => (Array.isArray(list) ? list.filter((key): key is string => typeof key === 'string') : [])
52
+ const rowsOf = (list: unknown): WidgetRow[] => (Array.isArray(list) ? list.filter(isObject).map((row) => ({ ...row, left: keys(row['left']), right: keys(row['right']) })) : [])
53
+ const stored = isObject(value) && isObject(value['areas']) ? value['areas'] : {}
54
+ return { areas: Object.fromEntries(areas.map((area) => [area, rowsOf(stored[area])])) as unknown as WidgetLayout['areas'], hidden: isObject(value) ? keys(value['hidden']) : [] }
55
+ }
56
+
57
+ const stringsIn = (value: unknown): string[] => (typeof value === 'string' ? [value] : Array.isArray(value) ? value.flatMap(stringsIn) : isObject(value) ? Object.values(value).flatMap(stringsIn) : [])
58
+
59
+ const holds = (row: WidgetRow, key: string) => row.left.includes(key) || row.right.includes(key)
60
+
61
+ export function placedIn(layout: WidgetLayout, key: string): Area | undefined {
62
+ return areas.find((area) => layout.areas[area].some((row) => holds(row, key)))
63
+ }
64
+
65
+ export function toggled(layout: WidgetLayout, key: string): WidgetLayout {
66
+ return { ...layout, hidden: layout.hidden.includes(key) ? layout.hidden.filter((each) => each !== key) : [...layout.hidden, key] }
67
+ }
68
+
69
+ export function moved(layout: WidgetLayout, widget: PlacedWidget, move: Move): WidgetLayout {
70
+ const area = placedIn(layout, widget.key)
71
+ if (area === undefined) return layout
72
+ const rows = layout.areas[area]
73
+ const at = rows.findIndex((row) => holds(row, widget.key))
74
+ const row = rows[at] as WidgetRow
75
+ const side = row.left.includes(widget.key) ? 'left' : 'right'
76
+ const withAreas = (changed: Partial<Record<Area, readonly WidgetRow[]>>): WidgetLayout => ({ ...layout, areas: { ...layout.areas, ...Object.fromEntries(Object.entries(changed).map(([name, list]) => [name, list.filter((each) => stringsIn(each).length > 0)])) } })
77
+ if (move === 'left' || move === 'right') {
78
+ const sequence = [...row.left, separator, ...row.right]
79
+ const from = sequence.indexOf(widget.key)
80
+ const to = move === 'left' ? from - 1 : from + 1
81
+ if (to < 0 || to >= sequence.length) return layout
82
+ ;[sequence[from], sequence[to]] = [sequence[to] as string, widget.key]
83
+ const split = sequence.indexOf(separator)
84
+ return withAreas({ [area]: rows.map((each, index) => (index === at ? { ...each, left: sequence.slice(0, split), right: sequence.slice(split + 1) } : each)) })
85
+ }
86
+ const without = rows.map((each, index) => (index === at ? { ...each, left: each.left.filter((key) => key !== widget.key), right: each.right.filter((key) => key !== widget.key) } : each))
87
+ const added = (target: WidgetRow | undefined): WidgetRow => ({ ...target, left: target?.left ?? [], right: target?.right ?? [], [side]: [...(target?.[side] ?? []), widget.key] })
88
+ const target = at + (move === 'up' ? -1 : 1)
89
+ if (target >= 0 && target < rows.length) return withAreas({ [area]: without.map((each, index) => (index === target ? added(each) : each)) })
90
+ if (move === 'down' && row.left.length + row.right.length > 1) return withAreas({ [area]: [...without, added(undefined)] })
91
+ const next = (move === 'up' ? areas.slice(0, areas.indexOf(area)).reverse() : areas.slice(areas.indexOf(area) + 1)).find((each) => widget.areas.includes(each))
92
+ if (next === undefined) return layout
93
+ const nextRows = layout.areas[next]
94
+ const landing = move === 'up' ? [...nextRows.slice(0, -1), added(nextRows.at(-1))] : [added(nextRows[0]), ...nextRows.slice(1)]
95
+ return withAreas({ [area]: without, [next]: landing })
96
+ }
97
+
98
+ function declarationsOf(input: unknown): WidgetDeclaration[] {
99
+ const listed = isObject(input) && Array.isArray(input['widgets']) ? (input['widgets'] as unknown[]) : []
100
+ return listed.filter(isObject).flatMap((each) => {
101
+ const known = Array.isArray(each['areas']) ? (each['areas'] as unknown[]).filter((area): area is Area => (areas as readonly unknown[]).includes(area)) : []
102
+ return typeof each['id'] === 'string' && typeof each['title'] === 'string' && known.length > 0 ? [{ id: each['id'], title: each['title'], areas: known }] : []
103
+ })
104
+ }
105
+
106
+ export function serveWidgets({
107
+ claude,
108
+ router,
109
+ store,
110
+ report,
111
+ turnClosedBy,
112
+ parts,
113
+ }: {
114
+ readonly claude: Claude
115
+ readonly router: Router
116
+ readonly store: string
117
+ readonly report: (id: string, message: string, error: unknown) => void
118
+ readonly turnClosedBy: (rowId: string) => TurnData | undefined
119
+ readonly parts: DrawnParts
120
+ }): Widgets {
121
+ const path = widgetsPath(store)
122
+ const declared = new Map<string, readonly WidgetDeclaration[]>()
123
+ let stored = layoutOf({})
124
+ let layout = stored
125
+ let saving: Promise<void> = Promise.resolve()
126
+
127
+ let placedKeys: ReadonlySet<string> = new Set()
128
+
129
+ const readFile = async () => {
130
+ const file = JSON.parse(await claude.fs.read(path).catch(ifMissing('{}'))) as unknown
131
+ placedKeys = new Set(stringsIn(isObject(file) ? file['areas'] : undefined))
132
+ return file
133
+ }
134
+
135
+ const read = async () => layoutOf(await readFile())
136
+
137
+ const redraw = () => claude.ui.invalidate('ui.render')
138
+
139
+ const placed = (): PlacedWidget[] => [...builtInWidgets, ...[...declared].flatMap(([mod, list]) => list.map(({ id, title, areas: listed }) => ({ key: `${mod}/${id}`, mod, title, areas: listed })))]
140
+
141
+ const change = (edit: (current: WidgetLayout) => WidgetLayout) => {
142
+ saving = saving
143
+ .then(async () => {
144
+ const file = await readFile()
145
+ stored = layoutOf(file)
146
+ const current = withNewWidgets(stored)
147
+ const next = edit(current)
148
+ if (next !== current) {
149
+ const kept = isObject(file) ? file : {}
150
+ await claude.fs.write(path, `${JSON.stringify({ ...kept, ...next, areas: { ...(isObject(kept['areas']) ? kept['areas'] : {}), ...next.areas } }, null, 2)}\n`)
151
+ stored = next
152
+ }
153
+ place()
154
+ })
155
+ .catch((error: unknown) => report('widgets:layout', 'Could not save the widget layout', error))
156
+ return saving
157
+ }
158
+
159
+ const place = () => {
160
+ layout = withNewWidgets(stored)
161
+ redraw()
162
+ }
163
+
164
+ const withNewWidgets = (current: WidgetLayout) =>
165
+ placed()
166
+ .filter(({ key, mod }) => mod !== 'claude' && placedIn(current, key) === undefined && !placedKeys.has(key))
167
+ .reduce((next, { key, areas: [area = AbovePrompt] }) => {
168
+ const [first, ...rest] = next.areas[area]
169
+ return { ...next, areas: { ...next.areas, [area]: [{ ...first, left: [...(first?.left ?? []), key], right: first?.right ?? [] }, ...rest] } }
170
+ }, current)
171
+
172
+ const ask = async (mod: string) => {
173
+ try {
174
+ declared.set(mod, declarationsOf(await claude.cmod.call({ to: mod, method: partMethods.list, input: null })))
175
+ } catch (error) {
176
+ declared.delete(mod)
177
+ claude.ui.log(`cmod: ${mod} did not list its widgets: ${messageOf(error)}`, { to: 'debug' })
178
+ }
179
+ }
180
+
181
+ const recheck = async () => {
182
+ const mods = [...declared.keys()]
183
+ await Promise.all(mods.map(ask))
184
+ for (const mod of mods) parts.forget(mod)
185
+ redraw()
186
+ }
187
+
188
+ const reload = async () => {
189
+ stored = await read()
190
+ place()
191
+ }
192
+
193
+ saving = (async () => {
194
+ const recorded = (await claude.fs.list(`${store}/records`).catch(ifMissing([]))).map((entry) => entry.name).filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -'.json'.length))
195
+ stored = await read().catch((error: unknown) => {
196
+ report('widgets:layout', 'Could not read the widget layout', error)
197
+ return stored
198
+ })
199
+ const named = areas.flatMap((area) => stored.areas[area].flatMap((row) => [...row.left, ...row.right])).map((key) => key.slice(0, key.indexOf('/')))
200
+ await Promise.all([...new Set([...recorded, ...named])].filter((mod) => mod !== cmodPluginName && mod !== 'claude' && !declared.has(mod)).map(ask))
201
+ place()
202
+ })().catch((error: unknown) => report('widgets:layout', 'Could not list the widgets of the mods', error))
203
+
204
+ const widgetOf = (key: string) => placed().find((widget) => widget.key === key)
205
+
206
+ const cacheKey = (key: string, area: Area, columns: number, turn: TurnData | undefined) => `${key}\u0000${area}\u0000${columns}\u0000${turn?.prompt.id ?? ''}`
207
+
208
+ const nameOf = (widget: PlacedWidget) => `The ${widget.title} widget of ${widget.mod}`
209
+
210
+ const fill = (key: string, area: Area, columns: number, turn: TurnData | undefined): Promise<void> | undefined => {
211
+ const widget = widgetOf(key)
212
+ if (widget === undefined || !widget.areas.includes(area)) return undefined
213
+ return parts.fill(widget.mod, cacheKey(key, area, columns, turn), { widget: key.slice(widget.mod.length + 1), area, columns, ...(turn === undefined ? {} : { turn }) }, nameOf(widget))
214
+ }
215
+
216
+ const elementFor = (key: string, area: Area, columns: number, turn: TurnData | undefined): RenderElement | undefined => {
217
+ const widget = widgetOf(key)
218
+ return widget === undefined ? undefined : parts.element(widget.mod, cacheKey(key, area, columns, turn), `${key}:`, nameOf(widget), `widget:${key}`)
219
+ }
220
+
221
+ const isHidden = (key: string) => layout.hidden.includes(key)
222
+
223
+ const shown = (keys: readonly string[]) => keys.filter((key) => !isHidden(key))
224
+
225
+ const rowsNow = (area: Area, columns: number, turn: TurnData | undefined): RenderElement[] =>
226
+ layout.areas[area].flatMap((row) => {
227
+ const side = (keys: readonly string[]) => shown(keys).map((key) => elementFor(key, area, columns, turn)).filter((each) => each !== undefined)
228
+ const left = side(row.left)
229
+ const right = side(row.right)
230
+ return left.length + right.length === 0 ? [] : [Box({ justifyContent: 'space-between', width: columns, children: [Box({ gap: 2, children: left }), Box({ gap: 2, children: right })] })]
231
+ })
232
+
233
+ const keysIn = (area: Area) => layout.areas[area].flatMap((row) => shown([...row.left, ...row.right]))
234
+
235
+ const draw = async (area: Area, table: ElementTable, columns: number, turn?: TurnData): Promise<RenderElement[]> => {
236
+ await Promise.all(keysIn(area).map((key) => fill(key, area, columns, turn)))
237
+ return drawWith(table, () => rowsNow(area, columns, turn))
238
+ }
239
+
240
+ const rows = (area: Area, columns: number): RenderElement[] => {
241
+ const started = keysIn(area).flatMap((key) => fill(key, area, columns, undefined) ?? [])
242
+ if (started.length > 0) void Promise.all(started).then(() => claude.ui.invalidate('ui.render'))
243
+ return rowsNow(area, columns, undefined)
244
+ }
245
+
246
+ const column = (table: ElementTable, children: readonly RenderElement[]) => drawWith(table, () => Box({ flexDirection: 'column', children }))
247
+
248
+ const below = (table: ElementTable, above: RenderElement, added: readonly RenderElement[]) => (added.length === 0 ? above : column(table, [above, ...added]))
249
+
250
+ router.add('ui.render', async (e, next) => {
251
+ const table = claude.ui.resolve(e)
252
+ const columns = (e.viewport?.columns ?? terminalColumns) - transcriptIndent
253
+ if (e.component === 'AbovePrompt') return e.props.hasSurvey ? next(e) : below(table, await next(e), await draw(AbovePrompt, table, e.props.bodyColumns))
254
+ if (e.component === 'PromptHint') {
255
+ const added = await draw(BelowPrompt, table, (e.viewport?.columns ?? terminalColumns) - promptIndent)
256
+ return isHidden(claudeHint) ? column(table, added) : below(table, await next(e), added)
257
+ }
258
+ if (e.component === 'SessionMode') return isHidden(claudeModes) ? column(table, []) : next(e)
259
+ if (e.component === 'Spinner') return below(table, await next(e), await draw(Working, table, columns))
260
+ if (e.component === 'TurnDuration') {
261
+ const turn = turnClosedBy(e.requestId)
262
+ return below(table, await next(e), turn === undefined ? [] : await draw(TurnEnd, table, columns, turn))
263
+ }
264
+ return next(e)
265
+ })
266
+
267
+ return {
268
+ update(mod, { parts: listing, redraw }) {
269
+ if (listing === undefined && redraw !== true) return
270
+ parts.forget(mod)
271
+ if (listing !== undefined) {
272
+ const listed = declarationsOf(listing)
273
+ const isNewList = JSON.stringify(listed) !== JSON.stringify(declared.get(mod) ?? [])
274
+ declared.set(mod, listed)
275
+ if (isNewList) place()
276
+ }
277
+ claude.ui.invalidate('ui.render')
278
+ },
279
+ widgets: placed,
280
+ layout: () => layout,
281
+ change,
282
+ reload,
283
+ recheck,
284
+ rows,
285
+ draw,
286
+ }
287
+ }
@@ -50,6 +50,8 @@ export const skillPrefix = 'Base directory for this skill:'
50
50
 
51
51
  const rowTypes: readonly string[] = ['user', 'assistant', 'attachment', 'system']
52
52
 
53
+ const interruption = /^\[Request interrupted by user( for tool use)?\]$/
54
+
53
55
  const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m
54
56
 
55
57
  export function instructionPathOf(text: string): string {
@@ -126,9 +128,12 @@ function unplacedOf(row: Row, isInTask: boolean): Unplaced[] {
126
128
  if (text === '') return []
127
129
  if (text.startsWith(skillPrefix)) return [{ ...base, type: Skill, name: basename(text.slice(skillPrefix.length).split('\n')[0]?.trim() ?? ''), text }]
128
130
  if (text.startsWith('<task-notification')) return [{ ...base, type: Note, text: tagOf(text, 'summary') ?? text, from: { type: Task } }]
129
- if (row.isMeta) return [{ ...base, type: Note, text, from: senderOf(row.origin, isInTask) }]
131
+ if (interruption.test(text)) return []
132
+ const from = senderOf(row.origin, isInTask)
133
+ if (row.isMeta) return [{ ...base, type: Note, text, from }]
130
134
  const prompt = promptText(text)
131
- return prompt === undefined ? [] : [{ ...base, type: Prompt, text: prompt, from: senderOf(row.origin, isInTask) }]
135
+ const isPluginCommand = from.type === Mod && tagOf(text, 'command-name') !== undefined
136
+ return prompt === undefined || isPluginCommand ? [] : [{ ...base, type: Prompt, text: prompt, from }]
132
137
  }
133
138
 
134
139
  function isModNotesRow(row: Row): boolean {
@@ -1,4 +1,4 @@
1
- import type { Disposable } from '../utils/disposable.js'
1
+ import type { Subscription } from '../utils/subscription.js'
2
2
  import type { Matcher } from '../utils/matcher.js'
3
3
  import type { Sender } from './context/turn/message/sender.js'
4
4
  import type { Context, ContextEnd, ContextStart } from './context/context.js'
@@ -107,13 +107,13 @@ export type ListenerTime = { readonly deadline: number; readonly signal: AbortSi
107
107
  export type Listener<Payload, Answer = void> = (payload: Payload, time: ListenerTime) => Answer | void | Promise<Answer | void>
108
108
 
109
109
  export type OnMethod<Payload> = {
110
- (listener: Listener<Payload, unknown>, options?: ReadOptions): Disposable
111
- (matcher: Matcher<Payload>, listener: Listener<Payload, unknown>, options?: ReadOptions): Disposable
110
+ (listener: Listener<Payload, unknown>, options?: ReadOptions): Subscription
111
+ (matcher: Matcher<Payload>, listener: Listener<Payload, unknown>, options?: ReadOptions): Subscription
112
112
  }
113
113
 
114
114
  export type BeforeMethod<Payload, Answer> = {
115
- (listener: Listener<Payload, Answer>, options?: AnswerOptions): Disposable
116
- (matcher: Matcher<Payload>, listener: Listener<Payload, Answer>, options?: AnswerOptions): Disposable
115
+ (listener: Listener<Payload, Answer>, options?: AnswerOptions): Subscription
116
+ (matcher: Matcher<Payload>, listener: Listener<Payload, Answer>, options?: AnswerOptions): Subscription
117
117
  }
118
118
 
119
119
  export type OnMethods = { readonly [Name in EventName as `on${Name}`]: OnMethod<PayloadOf<Name>> }
@@ -326,7 +326,11 @@ export type ProcessOnMethods = { readonly [Name in ProcessName as `on${Name}`]:
326
326
 
327
327
  export type ProcessBeforeMethods = { readonly [Name in keyof ProcessAnswer as `before${Name}`]: BeforeMethod<ProcessAsked[Name], ProcessAnswer[Name]> }
328
328
 
329
- export type CurrentSession = Omit<Session, 'goal'> & ProcessOnMethods & ProcessBeforeMethods & { readonly goal: Goal & Pick<ProcessBeforeMethods, 'beforeSessionStart'> }
329
+ export type CurrentSession = Omit<Session, 'goal'> & ProcessOnMethods & ProcessBeforeMethods & {
330
+ readonly goal: Goal & Pick<ProcessBeforeMethods, 'beforeSessionStart'>
331
+ scrollTo(message: Message, options?: { readonly block?: 'start' | 'center' | 'end' | 'nearest' }): Promise<void>
332
+ caption(toolCall: Message & { readonly type: typeof ToolCall }, text: string | undefined): void
333
+ }
330
334
 
331
335
  export type SessionScope = {
332
336
  readonly running?: boolean
@@ -1,6 +1,8 @@
1
1
  import type { EventResult, Timer } from 'claude-code'
2
2
  import { isObject } from '../records.js'
3
3
  import type { Claude } from '../runtime/claude.js'
4
+ import { cmodPluginName } from '../runtime/dependencies.js'
5
+ import { partMethods } from '../runtime/ui.js'
4
6
  import { untagged } from '../session/context/turn/message/note.js'
5
7
  import { elements } from './fake-elements.js'
6
8
  import { fakeFiles } from './fake-files.js'
@@ -34,7 +36,6 @@ export type Shown = {
34
36
  readonly prompts: string[]
35
37
  readonly logs: string[]
36
38
  readonly debug: string[]
37
- readonly statuses: (string | undefined)[]
38
39
  readonly openPanes: Set<string>
39
40
  readonly commands: string[]
40
41
  readonly tools: string[]
@@ -59,7 +60,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
59
60
  const calls: TestCall[] = []
60
61
  const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, config: {}, command: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
61
62
  const emptyDisk = fakeFiles({})
62
- const shown: Shown = { notes: [], prompts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [], schedules: [] }
63
+ const shown: Shown = { notes: [], prompts: [], logs: [], debug: [], openPanes: new Set(), commands: [], tools: [], schedules: [] }
63
64
  const unplacedPanes = new Set<string>()
64
65
  let focusedPane: string | undefined
65
66
  const store = new Map<string, unknown>()
@@ -79,6 +80,11 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
79
80
  return async (...args: Args): Promise<Result> => run(...args)
80
81
  }
81
82
 
83
+ const cmodTakesParts: Claude['cmod']['call'] = async (input) => {
84
+ if (input.to === cmodPluginName && input.method === partMethods.changed) return null
85
+ throw new Error(`cmod.call(${JSON.stringify(input)}) has no fake answer. Set fakes.cmod.call on the tested mod.`)
86
+ }
87
+
82
88
  const appendNote: Claude['session']['append'] = async ({ message }) => {
83
89
  shown.notes.push(untagged(message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join('')))
84
90
  return { message, uuid: `note-${shown.notes.length}` }
@@ -107,9 +113,6 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
107
113
  const claude: Claude = {
108
114
  plugin,
109
115
  ui: {
110
- status: (text) => {
111
- shown.statuses.push(text)
112
- },
113
116
  log: (text, options) => {
114
117
  const lines = options?.to === 'debug' ? shown.debug : shown.logs
115
118
  lines.push(text)
@@ -214,7 +217,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
214
217
  dataHome: async () => undefined,
215
218
  configHome: async () => undefined,
216
219
  },
217
- cmod: { call: rejected('cmod.call', () => fakes.cmod.call) },
220
+ cmod: { call: rejected('cmod.call', () => fakes.cmod.call ?? cmodTakesParts) },
218
221
  }
219
222
 
220
223
  const settle = async () => {
@@ -91,8 +91,8 @@ function boxRows({ props, children = [] }: Drawn, width: number): string[] {
91
91
  const left = space('Left', 'X')
92
92
  const direction = String(props['flexDirection'] ?? 'row')
93
93
  const ordered = direction.endsWith('-reverse') ? [...children].reverse() : children
94
- const inner = width - left - space('Right', 'X')
95
- const rows = direction.startsWith('column') ? stacked(ordered, inner, size('rowGap') ?? size('gap') ?? 0) : sideBySide(ordered, inner, size('columnGap') ?? size('gap') ?? 0)
94
+ const inner = Math.min(width, size('width') ?? width) - left - space('Right', 'X')
95
+ const rows = direction.startsWith('column') ? stacked(ordered, inner, size('rowGap') ?? size('gap') ?? 0) : sideBySide(ordered, inner, size('columnGap') ?? size('gap') ?? 0, props['justifyContent'] === 'space-between')
96
96
  return [...blankRows(space('Top', 'Y')), ...rows.map((row) => paddedTo(`${' '.repeat(left)}${row}`, size('minWidth') ?? 0)), ...blankRows(space('Bottom', 'Y'))]
97
97
  }
98
98
 
@@ -101,7 +101,7 @@ function stacked(children: readonly RenderNode[], width: number, gap: number): s
101
101
  return blocks.flatMap((block, index) => [...blankRows(index === 0 ? 0 : gap), ...block])
102
102
  }
103
103
 
104
- function sideBySide(children: readonly RenderNode[], width: number, gap: number): string[] {
104
+ function sideBySide(children: readonly RenderNode[], width: number, gap: number, isSpread = false): string[] {
105
105
  const laid = children
106
106
  .map((child) => ({ child, natural: rowsOf(child, Number.POSITIVE_INFINITY) }))
107
107
  .filter(({ natural }) => natural.length > 0)
@@ -111,7 +111,10 @@ function sideBySide(children: readonly RenderNode[], width: number, gap: number)
111
111
  const blocks = laid.map(({ child, natural, canShrink }) => (overflow <= 0 || !canShrink || total === 0 ? natural : rowsOf(child, widthOf(natural) - Math.ceil((overflow * widthOf(natural)) / total))))
112
112
  const height = Math.max(0, ...blocks.map((block) => block.length))
113
113
  const columns = blocks.map(widthOf)
114
- return Array.from({ length: height }, (_, row) => blocks.map((block, index) => paddedTo(block[row] ?? '', index === blocks.length - 1 ? 0 : (columns[index] ?? 0))).join(' '.repeat(gap)))
114
+ const spaces = blocks.length - 1
115
+ const free = isSpread && Number.isFinite(width) ? Math.max(width - columns.reduce((sum, each) => sum + each, 0) - gap * spaces, 0) : 0
116
+ const gapAfter = (index: number) => gap + Math.floor(free / spaces) + (index < free % spaces ? 1 : 0)
117
+ return Array.from({ length: height }, (_, row) => blocks.map((block, index) => `${paddedTo(block[row] ?? '', index === spaces ? 0 : (columns[index] ?? 0))}${index === spaces ? '' : ' '.repeat(gapAfter(index))}`).join(''))
115
118
  }
116
119
 
117
120
  function minWidthOf(node: RenderNode): number {
@@ -14,7 +14,7 @@ import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
14
  import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
15
15
  import type { Place } from '../runtime/before.js'
16
16
  import type { Claude } from '../runtime/claude.js'
17
- import { conversationMethods, everySession, isHeardBy, resultOf, type Subscription } from '../runtime/sessions.js'
17
+ import { conversationMethods, everySession, isHeardBy, resultOf, type Listened } from '../runtime/sessions.js'
18
18
  import { receivedMatcher } from '../utils/matcher.js'
19
19
  import { firstLine, messageOf } from '../utils/text.js'
20
20
 
@@ -43,7 +43,7 @@ type Kept = {
43
43
  readonly tasks: Map<string, TaskData>
44
44
  }
45
45
 
46
- type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
46
+ type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
47
47
 
48
48
 
49
49
  export type FakeSessions = {
@@ -56,6 +56,7 @@ export type FakeSessions = {
56
56
  compact(): void
57
57
  startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): string
58
58
  start(id: string): void
59
+ lastTurn(): TurnData | undefined
59
60
  readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
60
61
  }
61
62
 
@@ -191,7 +192,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
191
192
  },
192
193
  [conversationMethods.watchBefore](request, caller) {
193
194
  const targets = watches.get(caller) ?? new Map<string, readonly string[]>()
194
- targets.set(String(request['session']), (Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []).map(({ event }) => event))
195
+ targets.set(String(request['session']), (Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []).map(({ event }) => event))
195
196
  watches.set(caller, targets)
196
197
  return null
197
198
  },
@@ -223,7 +224,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
223
224
  return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', name: '', input: {}, cwd: project(), files: noFiles, output: '', isError: false }
224
225
  },
225
226
  [conversationMethods.subscribe](request, caller) {
226
- const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
227
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
227
228
  const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
228
229
  sessions.set(String(request['session']), list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
229
230
  heard.set(caller, sessions)
@@ -272,6 +273,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
272
273
  session.data = { ...session.data, isRunning: true }
273
274
  if (session.conversation.state.context === 0) record(session, session.conversation.startContext(now()))
274
275
  },
276
+ lastTurn: () => [...currentSession().turns.values()].filter((turn) => turn.prompt.task === undefined).at(-1),
275
277
  sent,
276
278
  }
277
279
  }