@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
@@ -3,6 +3,7 @@ import type { Deadline } from '../runtime/deadline.js'
3
3
  import { engineOf } from '../runtime/engine.js'
4
4
  import { onStateChange } from '../runtime/state.js'
5
5
  import { modNote } from '../session/context/turn/message/note.js'
6
+ import type { Subscription } from '../utils/subscription.js'
6
7
  import { listed, messageOf } from '../utils/text.js'
7
8
  import { callEffects, type Call } from '../utils/call-effects.js'
8
9
  import { matchTarget, targetOf, type Target } from './permissions/match-target.js'
@@ -18,13 +19,13 @@ type PromptOptions<State extends object> = {
18
19
 
19
20
  const deadline: Deadline = { ms: 5000, job: 'prompt' }
20
21
 
21
- export function prompt<State extends object = Record<never, never>>({ name, prompt: text, after }: PromptOptions<State>): Job<void, State> {
22
+ export function prompt<State extends object = Record<never, never>>({ name, prompt: text, after }: PromptOptions<State>): Job<Subscription, State> {
22
23
  if (name.trim() === '') throw new Error('prompt: give the block a name. Claude reads the text under it.')
23
24
  const targets = after === undefined ? [] : Array.isArray(after) ? after : [after]
24
25
  for (const target of targets) targetOf(target)
25
26
 
26
27
  return (job) => {
27
- job.reserveName('prompt', name, `prompt: ${job.mod.name} adds the name "${name}" two times. Give each prompt its own name.`)
28
+ const reserved = job.reserveName('prompt', name, `prompt: ${job.mod.name} adds the name "${name}" two times. Give each prompt its own name.`)
28
29
 
29
30
  const log = (reason: string) => job.mod.log(`prompt "${name}" added nothing: ${reason}`)
30
31
  const textFor = async (input: PromptInput) => {
@@ -42,29 +43,30 @@ export function prompt<State extends object = Record<never, never>>({ name, prom
42
43
  return value === undefined ? undefined : `# ${name}\n${value}`
43
44
  }
44
45
 
45
- if (targets.length > 0) {
46
- addAfterCalls(job, targets, headedText, log)
47
- job.announce(`the ${name} prompt after ${listed(targets.map(targetWords))}`)
48
- } else {
49
- addKeptCurrent(job, name, () => textFor({}), log)
50
- job.announce(`the ${name} prompt`)
46
+ job.announce(targets.length > 0 ? `the ${name} prompt after ${listed(targets.map(targetWords))}` : `the ${name} prompt`)
47
+ const added = targets.length > 0 ? addAfterCalls(job, targets, headedText, log) : addKeptCurrent(job, name, () => textFor({}), log)
48
+ return {
49
+ remove() {
50
+ added.remove()
51
+ reserved.remove()
52
+ },
51
53
  }
52
54
  }
53
55
  }
54
56
 
55
- function addKeptCurrent<State extends object>(job: JobContext<State>, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): void {
57
+ function addKeptCurrent<State extends object>(job: JobContext<State>, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): Subscription {
56
58
  const { claude, router } = engineOf(job.mod)
57
59
  let given: string | undefined
58
60
  let hasStarted = false
59
61
  let isQueued = false
60
62
  let updates = Promise.resolve()
61
63
 
62
- router.add('classic.SessionStart', (e, next) => {
64
+ const starting = router.add('classic.SessionStart', (e, next) => {
63
65
  hasStarted = e.source === 'resume' || e.source === 'compact'
64
66
  if (!hasStarted) given = undefined
65
67
  return next(e)
66
68
  })
67
- router.add('prompt.context', async (e, next) => {
69
+ const adding = router.add('prompt.context', async (e, next) => {
68
70
  const below = await next(e)
69
71
  hasStarted = true
70
72
  if (below.blocks.some((block) => block.name === name)) {
@@ -85,15 +87,22 @@ function addKeptCurrent<State extends object>(job: JobContext<State>, name: stri
85
87
  if (added.deny !== undefined) log(`Claude Code refused the new text: ${added.deny}`)
86
88
  })
87
89
  }
88
- onStateChange(job.mod.state, () => {
90
+ const following = onStateChange(job.mod.state, () => {
89
91
  if (isQueued) return
90
92
  isQueued = true
91
93
  updates = updates.then(update).catch((error: unknown) => log(messageOf(error)))
92
94
  })
95
+ return {
96
+ remove() {
97
+ starting.remove()
98
+ adding.remove()
99
+ following.remove()
100
+ },
101
+ }
93
102
  }
94
103
 
95
- function addAfterCalls<State extends object>(job: JobContext<State>, targets: Target[], textFor: (input: PromptInput) => Promise<string | undefined>, log: (reason: string) => void): void {
96
- afterCall(
104
+ function addAfterCalls<State extends object>(job: JobContext<State>, targets: Target[], textFor: (input: PromptInput) => Promise<string | undefined>, log: (reason: string) => void): Subscription {
105
+ return afterCall(
97
106
  job,
98
107
  async (use, workspace) => {
99
108
  const effects = callEffects(use, workspace)
@@ -1,12 +1,13 @@
1
1
  import type { Job } from '../mod.js'
2
2
  import { onStateChange } from '../runtime/state.js'
3
3
  import { engineOf } from '../runtime/engine.js'
4
+ import type { Subscription } from '../utils/subscription.js'
4
5
 
5
6
  export type ScheduledPrompt = { readonly cron: string; readonly prompt: string }
6
7
 
7
8
  const scheduleCounts = new WeakMap<object, number>()
8
9
 
9
- export function schedule<State extends object = Record<never, never>>(entry: ScheduledPrompt | ((state: State) => ScheduledPrompt | undefined)): Job<void, State> {
10
+ export function schedule<State extends object = Record<never, never>>(entry: ScheduledPrompt | ((state: State) => ScheduledPrompt | undefined)): Job<Subscription, State> {
10
11
  if (typeof entry !== 'function') checked(entry)
11
12
 
12
13
  return (job) => {
@@ -51,12 +52,25 @@ export function schedule<State extends object = Record<never, never>>(entry: Sch
51
52
  .then((left) => (typeof left === 'string' ? remove(left) : undefined))
52
53
  .then(update)
53
54
  .catch((error: unknown) => report('failed', error))
54
- onStateChange(mod.state, () => {
55
+ const following = onStateChange(mod.state, () => {
55
56
  if (isQueued) return
56
57
  isQueued = true
57
58
  updates = updates.then(update).catch((error: unknown) => report('failed', error))
58
59
  })
59
60
  job.announce(typeof entry === 'function' ? 'a schedule from its state' : `a schedule at ${entry.cron}`)
61
+ return {
62
+ remove() {
63
+ following.remove()
64
+ updates = updates
65
+ .then(async () => {
66
+ if (current === undefined) return
67
+ const { id } = current
68
+ current = undefined
69
+ await remove(id)
70
+ })
71
+ .catch((error: unknown) => report('failed', error))
72
+ },
73
+ }
60
74
  }
61
75
  }
62
76
 
@@ -1,5 +1,5 @@
1
1
  import type { CommandRunResult } from 'claude-code'
2
- import type { Disposable, Job, Mod } from '../mod.js'
2
+ import type { Job, Mod } from '../mod.js'
3
3
  import { parse } from '../vendor.js'
4
4
  import type { Deadline } from '../runtime/deadline.js'
5
5
  import { engineOf } from '../runtime/engine.js'
@@ -9,6 +9,8 @@ import { withinDeadline } from './context.js'
9
9
 
10
10
  export type CommandReply = string | { text?: string; note?: string } | undefined
11
11
 
12
+ export type SlashCommand = { readonly name: string; remove(): void }
13
+
12
14
  const deadline: Deadline = { ms: 30000, job: 'slashCommand' }
13
15
 
14
16
  const commandName = /^[A-Za-z0-9_-]{1,64}$/
@@ -19,11 +21,11 @@ export function slashCommand<State extends object = Record<never, never>>(option
19
21
  readonly argumentHint?: string
20
22
  readonly immediate?: true
21
23
  readonly reply: (input: { args: string; positionals: string[] }, mod: Mod<State>) => CommandReply | void | Promise<CommandReply | void>
22
- }): Job<Disposable, State> {
24
+ }): Job<SlashCommand, State> {
23
25
  const { name, description, argumentHint, immediate, reply } = options
24
26
  return (job) => {
25
27
  const { mod, announce, reserveName } = job
26
- const { claude, router, report } = engineOf(mod)
28
+ const { claude, router, report, answerInput } = engineOf(mod)
27
29
  if (!commandName.test(name)) throw new Error(`${mod.name}: "${name}" is not a slash command name. Use 1 to 64 letters, digits, "_", or "-", without the slash.`)
28
30
  const reserved = reserveName('slashCommand', name, `${mod.name}: the slash command /${name} is already added. Give each slashCommand its own name.`)
29
31
 
@@ -41,15 +43,16 @@ export function slashCommand<State extends object = Record<never, never>>(option
41
43
  const answering = router.add('command.run', async (e, next) => {
42
44
  if (e.command !== registered) return next(e)
43
45
  try {
44
- return answerOf(mod.name, await withinDeadline(mod, deadline, `/${name}`, () => reply({ args: e.args, positionals: splitWords(e.args) }, mod)))
46
+ return await answerInput(async () => answerOf(mod.name, await withinDeadline(mod, deadline, `/${name}`, () => reply({ args: e.args, positionals: splitWords(e.args) }, mod))), () => next(e))
45
47
  } catch (error) {
46
48
  return { text: `/${name} failed: ${oneLine(error)}` }
47
49
  }
48
50
  })
49
51
  return {
50
- dispose() {
51
- answering.dispose()
52
- reserved.dispose()
52
+ name,
53
+ remove() {
54
+ answering.remove()
55
+ reserved.remove()
53
56
  },
54
57
  }
55
58
  }
package/src/jobs/tool.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FromSchema, JSONSchema } from 'json-schema-to-ts'
2
- import type { Disposable, Job, Mod } from '../mod.js'
2
+ import type { Job, Mod } from '../mod.js'
3
3
  import { Validator } from '../vendor.js'
4
4
  import { toolDeadline } from '../runtime/deadline.js'
5
5
  import { engineOf } from '../runtime/engine.js'
@@ -9,12 +9,14 @@ import { withinDeadline } from './context.js'
9
9
 
10
10
  const toolName = /^[A-Za-z0-9_-]{1,64}$/
11
11
 
12
+ export type Tool = { readonly name: string; remove(): void }
13
+
12
14
  export function tool<const Schema extends object = { type: 'object' }, State extends object = Record<never, never>>(options: {
13
15
  readonly name: string
14
16
  readonly description: string
15
17
  readonly inputSchema?: Schema
16
18
  readonly execute: (input: Schema extends JSONSchema ? FromSchema<Schema> : never, mod: Mod<State>) => unknown
17
- }): Job<Disposable & { readonly name: string }, State> {
19
+ }): Job<Tool, State> {
18
20
  const { name, description, inputSchema, execute } = options
19
21
  return (job) => {
20
22
  const { mod, announce, reserveName } = job
@@ -50,9 +52,9 @@ export function tool<const Schema extends object = { type: 'object' }, State ext
50
52
  })
51
53
  return {
52
54
  name: `mcp__${claude.plugin.name}__${name}`,
53
- dispose() {
54
- answering.dispose()
55
- named.dispose()
55
+ remove() {
56
+ answering.remove()
57
+ named.remove()
56
58
  },
57
59
  }
58
60
  }
package/src/mod.ts CHANGED
@@ -9,7 +9,6 @@ import type {
9
9
  ModelCompleteOptions,
10
10
  ModelCompleteRequest,
11
11
  ModelCompleteResult,
12
- PaneOpenArgs,
13
12
  ProcessRunInit,
14
13
  ProcessRunResult,
15
14
  ProcessSpawnChunk,
@@ -17,20 +16,24 @@ import type {
17
16
  ProcessSpawnResult,
18
17
  RenderElement,
19
18
  Settings,
20
- UiScrollArgs,
21
- UiScrollResult,
22
19
  } from 'claude-code'
23
- import type { Step } from './installer.js'
20
+ import { checkHotkeys, type Hotkey, type HotkeyInit, type Hotkeys } from './hotkeys.js'
21
+ import { checkSteps, type Step } from './installer.js'
24
22
  import { checkOptions, type Option, type Options, type OptionValues } from './options.js'
25
23
  import { bound } from './runtime/bound.js'
26
24
  import { Mod as modSender } from './session/context/turn/message/sender.js'
27
25
  import { sessionEvents, type BeforeMethod, type CommandRun, type CommandRunAnswer, type OnMethod, type OnMethods, type Sessions } from './session/session.js'
28
- import type { Pane } from './ui/define-pane.js'
29
- import type { Place, PlaceProps } from './ui/places.js'
30
- import type { Disposable } from './utils/disposable.js'
26
+ import type { Widget, WidgetInit } from './ui/areas.js'
27
+ import type { Page, PageInit, Pane, PaneInit } from './ui/panes.js'
28
+ import type { Kind, Style, StyleInit } from './ui/styles.js'
31
29
  import type { Matcher } from './utils/matcher.js'
30
+ import type { Subscription } from './utils/subscription.js'
32
31
 
33
- export type { Disposable }
32
+ export type { Subscription }
33
+
34
+ export type Timer = { readonly isRunning: boolean; stop(): void }
35
+
36
+ export type Reservation = { remove(): void }
34
37
 
35
38
  export type ToastId = string | number
36
39
 
@@ -56,15 +59,6 @@ export type Toast = {
56
59
  dismiss(id?: ToastId): void
57
60
  }
58
61
 
59
- export type PaneHandle = {
60
- open(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
61
- close(): Promise<void>
62
- toggle(options?: Readonly<Pick<PaneOpenArgs, 'focus'>>): Promise<void>
63
- readonly isOpen: boolean
64
- readonly isShown: boolean
65
- readonly isFocused: boolean
66
- }
67
-
68
62
  export type Metadata = Readonly<Record<string, string>>
69
63
 
70
64
  export type FileMatch = {
@@ -138,8 +132,8 @@ export type Imports = {
138
132
  readonly user: SettingsFile
139
133
  readonly project: SettingsFile
140
134
  readonly local: SettingsFile
141
- beforeFileChange(listener: SettingsFileChangeListener): Disposable
142
- beforeFileChange(matcher: Matcher<SettingsFileChange>, listener: SettingsFileChangeListener): Disposable
135
+ beforeFileChange(listener: SettingsFileChangeListener): Subscription
136
+ beforeFileChange(matcher: Matcher<SettingsFileChange>, listener: SettingsFileChangeListener): Subscription
143
137
  }
144
138
  readonly model: { complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult> }
145
139
  readonly fs: {
@@ -149,7 +143,7 @@ export type Imports = {
149
143
  find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
150
144
  list(path?: string): Promise<Entry[]>
151
145
  stat(path: string, options?: FsStatOptions): Promise<Stat>
152
- onChange(matcher: { readonly path: string | readonly string[] }, listener: (change: FileChange) => unknown): Disposable
146
+ onChange(matcher: { readonly path: string | readonly string[] }, listener: (change: FileChange) => unknown): Subscription
153
147
  }
154
148
  readonly metadata: {
155
149
  read(path: string): Promise<Metadata>
@@ -160,6 +154,7 @@ export type Imports = {
160
154
  spawn(argv: readonly string[], init?: Omit<ProcessSpawnRequest, 'argv'>): HookStream<ProcessSpawnChunk, ProcessSpawnResult>
161
155
  }
162
156
  readonly http: { fetch(url: string, init?: HttpInit): Promise<HttpResponse> }
157
+ readonly clipboard: { write(text: string): Promise<void> }
163
158
  }
164
159
 
165
160
  export const sessions: Imports['sessions'] = {
@@ -168,7 +163,7 @@ export const sessions: Imports['sessions'] = {
168
163
  },
169
164
  list: (options) => bound().sessions.list(options),
170
165
  search: (text, matcher, options) => bound().sessions.search(text, matcher, options),
171
- ...(Object.fromEntries(Object.keys(sessionEvents).map((named) => [`on${named}`, (...args: unknown[]) => (bound().sessions[`on${named}` as keyof OnMethods] as (...each: unknown[]) => Disposable)(...args)])) as OnMethods),
166
+ ...(Object.fromEntries(Object.keys(sessionEvents).map((named) => [`on${named}`, (...args: unknown[]) => (bound().sessions[`on${named}` as keyof OnMethods] as (...each: unknown[]) => Subscription)(...args)])) as OnMethods),
172
167
  }
173
168
 
174
169
  export const settings: Imports['settings'] = {
@@ -181,8 +176,8 @@ export const settings: Imports['settings'] = {
181
176
  export const commands: Imports['commands'] = {
182
177
  list: () => bound().commands.list(),
183
178
  run: (name, args) => bound().commands.run(name, args),
184
- beforeRun: ((...args: never[]) => (bound().commands.beforeRun as (...each: never[]) => Disposable)(...args)) as Imports['commands']['beforeRun'],
185
- onRun: ((...args: never[]) => (bound().commands.onRun as (...each: never[]) => Disposable)(...args)) as Imports['commands']['onRun'],
179
+ beforeRun: ((...args: never[]) => (bound().commands.beforeRun as (...each: never[]) => Subscription)(...args)) as Imports['commands']['beforeRun'],
180
+ onRun: ((...args: never[]) => (bound().commands.onRun as (...each: never[]) => Subscription)(...args)) as Imports['commands']['onRun'],
186
181
  }
187
182
 
188
183
  export const config: Imports['config'] = {
@@ -217,30 +212,32 @@ export const http: Imports['http'] = {
217
212
  fetch: (url, init) => bound().http.fetch(url, init),
218
213
  }
219
214
 
220
- export type Mod<State extends object = Record<never, never>, Declared extends Options = Options> = {
215
+ export const clipboard: Imports['clipboard'] = {
216
+ write: (text) => bound().clipboard.write(text),
217
+ }
218
+
219
+ export type Mod<State extends object = Record<never, never>, Declared extends Options = Options, Keys extends Hotkeys = Hotkeys> = {
221
220
  readonly name: string
222
221
  readonly folder: string
223
222
  readonly dataFolder: string
224
223
  readonly state: Readonly<State>
225
224
  readonly options: OptionValues<Declared>
226
225
  readonly permissions: Permissions
227
- every(ms: number, listener: () => unknown): Disposable
228
- after(ms: number, listener: () => unknown): Disposable
226
+ readonly hotkeys: { readonly [Key in keyof Keys]: Hotkey }
227
+ every(ms: number, listener: () => unknown): Timer
228
+ after(ms: number, listener: () => unknown): Timer
229
229
  use<Handle>(job: Job<Handle, State>): Handle
230
230
  log(text: string, options?: { readonly to?: 'debug' | 'transcript' }): void
231
- readonly ui: {
232
- pane(pane: Pane<State>): PaneHandle
233
- render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable
234
- readonly toast: Toast
235
- ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
236
- copy(text: string): Promise<void>
237
- scroll(args: UiScrollArgs): Promise<UiScrollResult>
238
- notice(callId: string, text: string | undefined): void
239
- }
231
+ readonly panes: { add<Params = undefined>(pane: PaneInit<State, Params>): Pane<Params> }
232
+ readonly sidebar: { add<Params = undefined>(page: PageInit<State, Params>): Page<Params> }
240
233
  readonly settings: {
241
- page(page: Pane<State>): void
242
- open(pageId?: string): Promise<void>
234
+ add<Params = undefined>(page: PageInit<State, Params>): Page<Params>
235
+ open(): Promise<void>
243
236
  }
237
+ readonly widgets: { add(widget: WidgetInit<State>): Widget }
238
+ readonly styles: { add<K extends Kind>(style: StyleInit<K, State>): Style }
239
+ readonly toast: Toast
240
+ ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
244
241
  readonly dependencies: CmodDependencies
245
242
  }
246
243
 
@@ -253,36 +250,51 @@ type StateGroups = {
253
250
  readonly global?: object
254
251
  }
255
252
 
256
- type Api<Contract, State extends object> = {
257
- readonly [Method in keyof Contract]: Contract[Method] extends (input: infer Input) => infer Result ? (input: Input, mod: Mod<State>) => Result | Awaited<Result> : never
258
- }
253
+ type AnyApi = { readonly [method: string]: (input: never) => unknown } | void
254
+
255
+ type Api<Name extends string> = 0 extends 1 & CmodDependencies
256
+ ? AnyApi
257
+ : Name extends keyof CmodDependencies
258
+ ? { readonly [Method in keyof CmodDependencies[Name]]: CmodDependencies[Name][Method] extends (input: infer Input) => infer Result ? (input: Input) => Result | Awaited<Result> : never }
259
+ : AnyApi
259
260
 
260
- export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string, Declared extends Options = Record<never, Option>> = {
261
+ export type ModDefinition<State extends StateGroups = Record<never, never>, Name extends string = string, Declared extends Options = Record<never, Option>, Keys extends Hotkeys = Record<never, HotkeyInit>> = {
261
262
  readonly name: Name
262
263
  readonly state?: State
263
264
  readonly options?: Declared
264
- readonly api?: Name extends keyof CmodDependencies ? Api<CmodDependencies[Name], State> : { readonly [method: string]: (input: never, mod: Mod<State>) => unknown }
265
+ readonly hotkeys?: Keys
265
266
  readonly installer?: readonly Step<State>[]
266
- setup(mod: Mod<State, Declared>): unknown
267
+ setup(mod: Mod<State, Declared, Keys>): Api<Name> | Promise<Api<Name>>
267
268
  }
268
269
 
269
270
  export type JobContext<State extends object = Record<never, never>> = {
270
271
  readonly mod: Mod<State>
271
272
  announce(feature: string): void
272
- reserveName(type: string, name: string, taken: string): Disposable
273
+ reserveName(type: string, name: string, taken: string): Reservation
273
274
  }
274
275
 
275
276
  export type Job<Handle, State extends object = Record<never, never>> = (context: JobContext<State>) => Handle
276
277
 
277
- export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string, const Declared extends Options = Record<never, Option>>(definition: ModDefinition<State, Name, Declared>): ModDefinition<State, Name, Declared> {
278
+ export function defineMod<State extends StateGroups = Record<never, never>, Name extends string = string, const Declared extends Options = Record<never, Option>, const Keys extends Hotkeys = Record<never, HotkeyInit>>(
279
+ definition: ModDefinition<State, Name, Declared, Keys>,
280
+ ): ModDefinition<State, Name, Declared, Keys> {
278
281
  if (definition.name.trim() === '') throw new Error('defineMod: the mod needs a name, such as the name in .claude-plugin/plugin.json.')
279
282
  if (definition.options !== undefined) checkOptions(definition.name, definition.options)
283
+ if (definition.hotkeys !== undefined) checkHotkeys(definition.name, definition.hotkeys)
284
+ if (definition.installer !== undefined) checkSteps(definition.name, definition.installer)
280
285
  return definition
281
286
  }
282
287
 
283
288
  export type { Matcher } from './utils/matcher.js'
284
- export type { Place, PlaceProps } from './ui/places.js'
285
- export { AbovePrompt, AskUserQuestion, CommandOutput, PromptHint, SessionMode, Spinner, ToolGroup, ToolProgress, TurnDuration } from './ui/places.js'
289
+ export type { Combo, Hotkey, HotkeyInit, Key } from './hotkeys.js'
290
+ export type { OpenOptions, Page, PageInit, PageProps, Pane, PaneInit, PaneProps, ScrollOptions, ScrollTarget } from './ui/panes.js'
291
+ export type { Kind, Row, Style, StyleInit, StyleProps } from './ui/styles.js'
292
+ export type { Block } from './ui/markdown.js'
293
+ export { AskUserQuestion, CommandOutput, ToolGroup, ToolProgress } from './ui/styles.js'
294
+ export { AbovePrompt, BelowPrompt, Dashboard, TurnEnd, Working, type Area, type Widget, type WidgetContext, type WidgetInit } from './ui/areas.js'
295
+ export type { Step } from './installer.js'
296
+ export type { SlashCommand } from './jobs/slash-command.js'
297
+ export type { Tool } from './jobs/tool.js'
286
298
  export { Session, type AnswerOptions, type BeforeMethods, type Surface, type Listener, type ListenerTime, type OnMethods, type ReadOptions, type RecordPart, type Refusal, type SearchOptions, type SessionScope, type SessionSend, type Sessions, type CurrentSession, type Placement, type ProjectSetup, type SessionStartAsked, type SessionStarted, type SessionEnded, type ModelChange, type CwdChange, type DirectoryAdd, type Worktree, type Notification, type Elicitation, type ElicitationResponse, type McpServer, type PermissionUpdate, type PermissionCheck, type PermissionRequest, type PermissionDenial, type BatchedCall, type ToolBatch, type Todo, type ReplyDisplay, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage, type CommandRun, type CommandRunAnswer } from './session/session.js'
287
299
  export type { Context } from './session/context/context.js'
288
300
  export type { Turn } from './session/context/turn/turn.js'
package/src/records.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkHotkeys, type HotkeyInit, type Hotkeys } from './hotkeys.js'
1
2
  import { listed, messageOf } from './utils/text.js'
2
3
 
3
4
  export type InstallRecord = {
@@ -15,7 +16,7 @@ export type Steps = {
15
16
  install?: string
16
17
  uninstall?: string
17
18
  program?: string
18
- keys?: Readonly<Record<string, string>>
19
+ hotkeys?: Hotkeys
19
20
  permissions?: readonly string[]
20
21
  }
21
22
 
@@ -39,7 +40,7 @@ export type ReadFile = (path: string) => Promise<string | undefined>
39
40
  export type RunnerEvent =
40
41
  | { type: 'progress'; done: number; total: number; label: string }
41
42
  | { type: 'log'; text: string }
42
- | { type: 'needs-consent'; sha256: string; install: string; uninstall: string; keys: string; permissions: readonly string[] }
43
+ | { type: 'needs-consent'; sha256: string; install: string; uninstall: string; hotkeys: string; permissions: readonly string[] }
43
44
  | { type: 'done'; name: string; version?: string }
44
45
  | { type: 'missing'; name: string }
45
46
  | { type: 'retries'; when: 'changed' | 'later' }
@@ -139,7 +140,7 @@ export function readSteps(pkg: unknown): Steps | undefined {
139
140
  ...readCommand(steps, 'install'),
140
141
  ...readCommand(steps, 'uninstall'),
141
142
  ...(program === undefined ? {} : { program }),
142
- ...readKeys(steps['keys']),
143
+ ...readHotkeys(steps['hotkeys']),
143
144
  ...(permissions.length === 0 ? {} : { permissions }),
144
145
  }
145
146
  }
@@ -173,6 +174,12 @@ function readPermissions(steps: Record<string, unknown>): string[] {
173
174
  return items
174
175
  }
175
176
 
177
+ export const grantFix = 'Turn it on in Settings.'
178
+
179
+ export const installFix = 'Install it in Plugins.'
180
+
181
+ export const secretFix = 'Set it in /config, where Claude Code keeps it in your keychain.'
182
+
176
183
  export function permissionWords(item: string): string {
177
184
  const split = item.indexOf(':')
178
185
  const target = item.slice(split + 1)
@@ -211,12 +218,6 @@ export function isSettingCovered(granted: Iterable<string>, path: string): boole
211
218
  return [...granted].some((each) => item === each || item.startsWith(`${each}.`))
212
219
  }
213
220
 
214
- export const settingsPagesMethod = 'cmod:settingsPages'
215
-
216
- export const changelogPage = 'changelog'
217
-
218
- export const openPageMethod = 'cmod:openPage'
219
-
220
221
  export const pendingStepsMethod = 'cmod:pendingSteps'
221
222
 
222
223
  export const finishStepsMethod = 'cmod:finishSteps'
@@ -239,6 +240,14 @@ export function consentPath(store: string): string {
239
240
  return `${store}/consent.json`
240
241
  }
241
242
 
243
+ export function pluginsChangedPath(store: string): string {
244
+ return `${store}/plugins-changed`
245
+ }
246
+
247
+ export function widgetsPath(store: string): string {
248
+ return `${store}/widgets.json`
249
+ }
250
+
242
251
  export function parseConsent(value: unknown, path: string): Record<string, string[]> {
243
252
  if (value === undefined) return Object.create(null)
244
253
  if (!isObject(value) || Object.values(value).some((items) => !Array.isArray(items) || items.some((item) => typeof item !== 'string'))) {
@@ -247,29 +256,35 @@ export function parseConsent(value: unknown, path: string): Record<string, strin
247
256
  return Object.assign(Object.create(null), value) as Record<string, string[]>
248
257
  }
249
258
 
250
- function readKeys(keys: unknown): Steps {
251
- if (keys === undefined) return {}
252
- if (!isObject(keys) || Object.keys(keys).length === 0) throw new Error('package.json "cmod.keys" must bind each key to a command, such as "keys": { "shift+tab": "/mode" }.')
253
- const commands: Record<string, string> = {}
254
- for (const [key, value] of Object.entries(keys)) {
255
- const command = typeof value === 'string' ? /^\/([A-Za-z0-9][A-Za-z0-9._:-]*)$/.exec(value)?.[1] : undefined
256
- if (key.trim() === '' || command === undefined) throw new Error(`package.json "cmod.keys" binds "${key}" to ${JSON.stringify(value)}. Bind each key to one of the mod's commands, such as "shift+tab": "/mode".`)
257
- commands[key] = command
258
- }
259
- return { keys: commands }
259
+ function readHotkeys(hotkeys: unknown): Steps {
260
+ if (hotkeys === undefined) return {}
261
+ const fix = 'cmod check writes it from the hotkeys defineMod declares, so run cmod check.'
262
+ if (!isObject(hotkeys) || !Object.values(hotkeys).every((each) => isObject(each) && typeof each['title'] === 'string' && typeof each['default'] === 'string')) throw new Error(`package.json "cmod.hotkeys" is not a map of hotkeys to their title and default combo. ${fix}`)
263
+ return { hotkeys: checkHotkeys('package.json "cmod"', hotkeys as Record<string, HotkeyInit>) }
260
264
  }
261
265
 
262
- export const coreVersion = '3.0.0'
266
+ export const coreVersion = '4.0.0'
263
267
 
264
- export function oldestCmodFor(steps: Steps): string {
265
- if (steps.permissions?.some((item) => item.startsWith('config:')) === true) return '1.6.0'
266
- if (steps.permissions?.includes('tasks') === true) return '1.0.0'
267
- if (steps.permissions !== undefined) return '0.2.0'
268
- return Object.keys(steps.keys ?? {}).length > 0 ? '0.1.12' : '0.0.0'
268
+ export function hotkeyCommand(mod: string, hotkey: string): string {
269
+ return `${mod}--${hotkey}`
269
270
  }
270
271
 
271
- export function keyWords(keys: Readonly<Record<string, string>> | undefined): string {
272
- return listed(Object.entries(keys ?? {}).map(([key, command]) => `${key} to /${command}`))
272
+ export function hotkeyBindings(mod: string, hotkeys: Hotkeys | undefined): Record<string, string> {
273
+ return Object.fromEntries(Object.entries(hotkeys ?? {}).map(([name, { default: combo }]) => [combo, hotkeyCommand(mod, name)]))
274
+ }
275
+
276
+ export function boundCombos(keybindings: unknown): Map<string, string> {
277
+ const blocks = isObject(keybindings) && Array.isArray(keybindings['bindings']) ? keybindings['bindings'] : []
278
+ const combos = new Map<string, string>()
279
+ for (const block of blocks) {
280
+ if (!isObject(block) || block['context'] !== 'Chat' || !isObject(block['bindings'])) continue
281
+ for (const [combo, action] of Object.entries(block['bindings'])) if (typeof action === 'string' && action.startsWith('command:')) combos.set(action.slice('command:'.length), combo)
282
+ }
283
+ return combos
284
+ }
285
+
286
+ export function hotkeyWords(hotkeys: Hotkeys | undefined): string {
287
+ return listed(Object.values(hotkeys ?? {}).map(({ title, default: combo }) => `${combo} to ${title}`))
273
288
  }
274
289
 
275
290
  function readCommand(steps: Record<string, unknown>, key: 'install' | 'uninstall'): Steps {
@@ -282,6 +297,10 @@ function readCommand(steps: Record<string, unknown>, key: 'install' | 'uninstall
282
297
  return { [key]: command }
283
298
  }
284
299
 
300
+ export function bundleDefines(permissions: readonly string[]): Record<string, string> {
301
+ return permissions.includes('approve') ? {} : { 'globalThis.cmodWithoutApprove': 'true' }
302
+ }
303
+
285
304
  export function scriptPaths(command: string): string[] {
286
305
  const words = command
287
306
  .split(/[\s;&|()<>]+/)
@@ -310,7 +329,7 @@ export async function scriptsSha256(steps: Steps, files: { read: ReadFile; list:
310
329
  steps.install ?? '',
311
330
  steps.uninstall ?? '',
312
331
  steps.program ?? '',
313
- ...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())]),
332
+ ...(steps.hotkeys === undefined ? [] : [JSON.stringify(Object.entries(steps.hotkeys).sort())]),
314
333
  ...(steps.permissions === undefined ? [] : [JSON.stringify([...steps.permissions].sort())]),
315
334
  ].join('\0')
316
335
  for (const folder of [...folders].sort()) {
@@ -332,7 +351,7 @@ export function formatEvent(event: RunnerEvent): string {
332
351
  case 'log':
333
352
  return `log ${event.text}`
334
353
  case 'needs-consent':
335
- return `needs-consent ${[event.sha256, event.install, event.uninstall, event.keys, ...event.permissions].join('\t')}`
354
+ return `needs-consent ${[event.sha256, event.install, event.uninstall, event.hotkeys, ...event.permissions].join('\t')}`
336
355
  case 'done':
337
356
  return event.version === undefined ? `done ${event.name}` : `done ${event.name} ${event.version}`
338
357
  case 'missing':
@@ -349,8 +368,8 @@ export function parseEvent(line: string): RunnerEvent {
349
368
  if (progress !== null) return { type: 'progress', done: Number(progress[1]), total: Number(progress[2]), label: progress[3] ?? '' }
350
369
  const consent = /^needs-consent (\S+)\t([^\t]*)\t([^\t]*)(?:\t(.*))?$/.exec(line)
351
370
  if (consent !== null) {
352
- const [keys = '', ...permissions] = (consent[4] ?? '').split('\t')
353
- return { type: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, keys, permissions: permissions.filter((item) => item !== '') }
371
+ const [hotkeys = '', ...permissions] = (consent[4] ?? '').split('\t')
372
+ return { type: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, hotkeys, permissions: permissions.filter((item) => item !== '') }
354
373
  }
355
374
  const done = /^done (\S+)(?: (\S+))?$/.exec(line)
356
375
  if (done !== null) return { type: 'done', name: done[1] as string, ...(done[2] === undefined ? {} : { version: done[2] }) }
package/src/register.ts CHANGED
@@ -21,7 +21,6 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
21
21
  const claude: Claude = {
22
22
  plugin: { name: $.plugin.name, root: $.plugin.root },
23
23
  ui: {
24
- status: (text) => $.ui.status(text),
25
24
  log: (text, options) => $.ui.log(text, options),
26
25
  notice: (toolUseId, text) => $.ui.notice(toolUseId, text),
27
26
  invalidate: (event) => $.ui.invalidate(event),
@@ -112,7 +111,7 @@ function routeCallFailure(_$: EngineInterface, eventInput: Frozen<Args<'cmod.cal
112
111
 
113
112
  export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
114
113
  permissionCheck = undefined
115
- registered = definition as ModDefinition<object>
114
+ registered = definition
116
115
  lifecycle = createLifecycle(definition, () => permissionCheck, options)
117
116
  addHook('session.start', startMod)
118
117
  addHook('classic.Setup', routeToMod).catch(routeFailure)
@@ -142,13 +141,13 @@ export function registerMod<State extends object, Declared extends Options>(addH
142
141
  addHook('classic.PostCompact', routeToMod).catch(routeFailure)
143
142
  addHook('classic.Stop', routeToMod).catch(routeFailure)
144
143
  addHook('classic.StopFailure', routeToMod).catch(routeFailure)
145
- addHook('classic.FileChanged', routeToMod).catch(routeFailure)
146
144
  addHook('classic.WorktreeCreate', routeToMod).catch(routeFailure)
147
145
  addHook('classic.WorktreeRemove', routeToMod).catch(routeFailure)
148
146
  addHook('tool.call', routeToMod).catch(routeFailure)
149
147
  addHook('prompt.submit', routeToMod).catch(routeFailure)
150
148
  addHook('prompt.context', routeToMod).catch(routeFailure)
151
149
  addHook('command.run', routeToMod).catch(routeFailure)
150
+ addHook('command.describe', routeToMod).catch(routeFailure)
152
151
  addHook('session.measure', routeToMod).catch(routeFailure)
153
152
  addHook('skill.prompt', routeToMod).catch(routeFailure)
154
153
  addHook('ui.render', routeToMod).catch(routeFailure)