@cmodjs/core 3.1.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +38 -21
- package/docs/dependencies.md +27 -20
- package/docs/index.md +21 -17
- package/docs/install-steps.md +37 -29
- package/docs/jobs.md +25 -52
- package/docs/mod.md +93 -65
- package/docs/options.md +2 -2
- package/docs/permissions.md +4 -4
- package/docs/session.md +14 -12
- package/docs/testing.md +55 -32
- package/docs/ui.md +255 -263
- package/hotkeys.d.ts +22 -0
- package/hotkeys.d.ts.map +1 -0
- package/hotkeys.js +26 -0
- package/installer.d.ts +1 -1
- package/installer.d.ts.map +1 -1
- package/installer.js +11 -6
- package/jobs/check.d.ts +2 -1
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +2 -2
- package/jobs/context.d.ts +2 -1
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +1 -1
- package/jobs/permissions.d.ts +2 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +2 -2
- package/jobs/program.js +1 -1
- package/jobs/prompt.d.ts +2 -1
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +20 -13
- package/jobs/schedule.d.ts +2 -1
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +15 -1
- package/jobs/slash-command.d.ts +6 -2
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +6 -5
- package/jobs/tool.d.ts +6 -4
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +3 -3
- package/mod.d.ts +63 -42
- package/mod.d.ts.map +1 -1
- package/mod.js +11 -1
- package/package.json +1 -1
- package/records.d.ts +13 -8
- package/records.d.ts.map +1 -1
- package/records.js +41 -31
- package/register.d.ts.map +1 -1
- package/register.js +1 -2
- package/runtime/announce.d.ts +1 -1
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +6 -7
- package/runtime/before.d.ts +2 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +1 -1
- package/runtime/claude.d.ts +0 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +4 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +42 -14
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +2 -1
- package/runtime/engine.d.ts +5 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.js +2 -2
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hotkeys.d.ts +15 -0
- package/runtime/hotkeys.d.ts.map +1 -0
- package/runtime/hotkeys.js +58 -0
- package/runtime/installer.d.ts +5 -2
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +23 -12
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +190 -141
- package/runtime/options.js +2 -2
- package/runtime/pages.d.ts +28 -0
- package/runtime/pages.d.ts.map +1 -0
- package/runtime/pages.js +98 -0
- package/runtime/parts.d.ts +14 -0
- package/runtime/parts.d.ts.map +1 -0
- package/runtime/parts.js +59 -0
- package/runtime/permission-check.d.ts +2 -2
- package/runtime/permission-check.d.ts.map +1 -1
- package/runtime/programs.js +1 -1
- package/runtime/router.d.ts +2 -2
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +1 -1
- package/runtime/sessions.d.ts +10 -9
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +14 -5
- package/runtime/state.d.ts +2 -1
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +1 -0
- package/runtime/toaster.d.ts +1 -1
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +10 -4
- package/runtime/ui.d.ts +87 -5
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +374 -109
- package/runtime/watches.d.ts +9 -0
- package/runtime/watches.d.ts.map +1 -0
- package/runtime/watches.js +73 -0
- package/runtime/widgets.d.ts +48 -0
- package/runtime/widgets.d.ts.map +1 -0
- package/runtime/widgets.js +219 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +7 -2
- package/session/session.d.ts +11 -5
- package/session/session.d.ts.map +1 -1
- package/src/hotkeys.ts +51 -0
- package/src/installer.ts +8 -4
- package/src/jobs/check.ts +4 -3
- package/src/jobs/context.ts +3 -2
- package/src/jobs/permissions.ts +4 -3
- package/src/jobs/program.ts +1 -1
- package/src/jobs/prompt.ts +23 -14
- package/src/jobs/schedule.ts +16 -2
- package/src/jobs/slash-command.ts +10 -7
- package/src/jobs/tool.ts +7 -5
- package/src/mod.ts +59 -47
- package/src/records.ts +46 -31
- package/src/register.ts +2 -3
- package/src/runtime/announce.ts +6 -7
- package/src/runtime/before.ts +4 -4
- package/src/runtime/claude.ts +0 -1
- package/src/runtime/conversations.ts +66 -20
- package/src/runtime/dependencies.ts +2 -1
- package/src/runtime/engine.ts +5 -0
- package/src/runtime/grants.ts +2 -2
- package/src/runtime/hooks.ts +1 -0
- package/src/runtime/hotkeys.ts +80 -0
- package/src/runtime/installer.ts +24 -12
- package/src/runtime/lifecycle.ts +189 -136
- package/src/runtime/options.ts +2 -2
- package/src/runtime/pages.ts +127 -0
- package/src/runtime/parts.ts +70 -0
- package/src/runtime/permission-check.ts +2 -2
- package/src/runtime/programs.ts +1 -1
- package/src/runtime/router.ts +3 -3
- package/src/runtime/sessions.ts +24 -15
- package/src/runtime/state.ts +3 -1
- package/src/runtime/toaster.ts +10 -4
- package/src/runtime/ui.ts +407 -115
- package/src/runtime/watches.ts +75 -0
- package/src/runtime/widgets.ts +287 -0
- package/src/session/context/turn/message/message.ts +7 -2
- package/src/session/session.ts +10 -6
- package/src/testing/fake-claude.ts +9 -6
- package/src/testing/fake-elements.ts +7 -4
- package/src/testing/fake-sessions.ts +6 -4
- package/src/testing.ts +203 -46
- package/src/ui/areas.ts +27 -0
- package/src/ui/elements.ts +35 -0
- package/src/ui/markdown.ts +18 -18
- package/src/ui/panes.ts +73 -0
- package/src/ui/styles.ts +53 -0
- package/src/utils/subscription.ts +1 -0
- package/testing/fake-claude.d.ts +0 -1
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +9 -5
- package/testing/fake-elements.js +7 -4
- package/testing/fake-sessions.d.ts +2 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +1 -0
- package/testing.d.ts +21 -6
- package/testing.d.ts.map +1 -1
- package/testing.js +206 -45
- package/ui/areas.d.ts +29 -0
- package/ui/areas.d.ts.map +1 -0
- package/ui/areas.js +6 -0
- package/ui/elements.d.ts +3 -0
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +36 -0
- package/ui/markdown.d.ts +14 -14
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +11 -11
- package/ui/panes.d.ts +71 -0
- package/ui/panes.d.ts.map +1 -0
- package/ui/panes.js +16 -0
- package/ui/styles.d.ts +44 -0
- package/ui/styles.d.ts.map +1 -0
- package/ui/styles.js +24 -0
- package/utils/subscription.d.ts +4 -0
- package/utils/subscription.d.ts.map +1 -0
- package/jobs/status-line.d.ts +0 -6
- package/jobs/status-line.d.ts.map +0 -1
- package/jobs/status-line.js +0 -39
- package/src/jobs/status-line.ts +0 -44
- package/src/ui/define-pane.ts +0 -27
- package/src/ui/places.ts +0 -49
- package/src/utils/disposable.ts +0 -3
- package/ui/define-pane.d.ts +0 -15
- package/ui/define-pane.d.ts.map +0 -1
- package/ui/define-pane.js +0 -12
- package/ui/places.d.ts +0 -43
- package/ui/places.d.ts.map +0 -1
- package/ui/places.js +0 -31
- package/utils/disposable.d.ts +0 -4
- package/utils/disposable.d.ts.map +0 -1
- /package/utils/{disposable.js → subscription.js} +0 -0
package/src/runtime/announce.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isObject, marketplaceOf, permissionWords } from '../records.js'
|
|
2
2
|
import { changeLines, changelogSections, unreleased } from '../utils/changelog.js'
|
|
3
3
|
import { ifMissing } from '../utils/paths.js'
|
|
4
4
|
import { listed } from '../utils/text.js'
|
|
5
5
|
import type { Claude } from './claude.js'
|
|
6
|
-
import { cmodPluginName } from './dependencies.js'
|
|
7
6
|
import type { Plugin } from './lifecycle.js'
|
|
8
7
|
|
|
9
8
|
export type Announced = { readonly version: string; readonly added: readonly string[] }
|
|
@@ -20,20 +19,20 @@ export function announcedOf(saved: unknown): Announced | undefined {
|
|
|
20
19
|
return { version: saved['version'], added: Array.isArray(saved['added']) ? saved['added'].map(String) : [] }
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export async function announcementOf(claude: Claude, plugin: Plugin, granted: ReadonlySet<string>, added: readonly string[], previous: Announced | undefined): Promise<Announcement> {
|
|
22
|
+
export async function announcementOf(claude: Claude, plugin: Plugin, granted: ReadonlySet<string>, added: readonly string[], previous: Announced | undefined, openSettings: (tab?: 'changelog') => Promise<void>): Promise<Announcement> {
|
|
24
23
|
const read = (path: string) => claude.fs.read(`${plugin.root}/${path}`).catch(ifMissing(undefined))
|
|
25
24
|
const manifest = jsonOf(await read('.claude-plugin/plugin.json'))
|
|
26
25
|
const field = (key: string) => (isObject(manifest) && typeof manifest[key] === 'string' ? manifest[key] : undefined)
|
|
27
|
-
const open = (
|
|
26
|
+
const open = (tab?: 'changelog') => () => openSettings(tab)
|
|
28
27
|
if (previous === undefined) {
|
|
29
|
-
const { program,
|
|
28
|
+
const { program, hotkeys } = plugin.steps
|
|
30
29
|
const rules = (await claude.fs.list(`${plugin.root}/rules`).catch(ifMissing([]))).filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md')).map((entry) => entry.name.slice(0, -'.md'.length)).toSorted()
|
|
31
30
|
const lines = [
|
|
32
31
|
field('description'),
|
|
33
32
|
added.length > 0 ? `Adds ${listed(added)}.` : undefined,
|
|
34
33
|
rules.length > 0 ? `Adds ${rules.length} ${rules.length === 1 ? 'rule' : 'rules'}: ${listed(rules)}.` : undefined,
|
|
35
34
|
program === undefined ? undefined : `Installed the ${program} program.`,
|
|
36
|
-
...Object.
|
|
35
|
+
...Object.values(hotkeys ?? {}).map(({ title, default: combo }) => `Binds ${combo} to ${title}.`),
|
|
37
36
|
...[...granted].map((item) => `${permissionWords(item)}.`),
|
|
38
37
|
]
|
|
39
38
|
return { message: 'Installed', ...described(lines), action: { label: 'Settings', onClick: open() } }
|
|
@@ -57,7 +56,7 @@ export async function announcementOf(claude: Claude, plugin: Plugin, granted: Re
|
|
|
57
56
|
return {
|
|
58
57
|
message: `Updated to ${version}`,
|
|
59
58
|
...described(lines),
|
|
60
|
-
...(sections.length > 0 ? { action: { label: 'Changelog', onClick: open(
|
|
59
|
+
...(sections.length > 0 ? { action: { label: 'Changelog', onClick: open('changelog') } } : {}),
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
|
package/src/runtime/before.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Args, Frozen } from 'claude-code'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Subscription } from '../utils/subscription.js'
|
|
3
3
|
import { ContextEnd } from '../session/context/context.js'
|
|
4
4
|
import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
|
|
5
5
|
import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
|
|
@@ -55,7 +55,7 @@ export function relayOf(e: Frozen<Args<'session.receive'>>): string | undefined
|
|
|
55
55
|
return isFromCmod ? relayTag.exec(e.text)?.[1] : undefined
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export function installBefore({ claude, router, hooks }: { readonly claude: Claude; readonly router: Router; readonly hooks: BeforeHooks }):
|
|
58
|
+
export function installBefore({ claude, router, hooks }: { readonly claude: Claude; readonly router: Router; readonly hooks: BeforeHooks }): Subscription {
|
|
59
59
|
const { place, wants, fold } = hooks
|
|
60
60
|
const now = () => new Date().toISOString()
|
|
61
61
|
const asked = async (event: BeforeEvent, change: SessionChange, decide: Decide = fold()) => (await decide(event, change)) ?? {}
|
|
@@ -82,7 +82,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
82
82
|
const failed = answer.isError === true && !isFailure ? { block: answer.note === undefined ? `${name} failed.` : untagged(answer.note) } : {}
|
|
83
83
|
return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
|
|
84
84
|
}
|
|
85
|
-
const added:
|
|
85
|
+
const added: Subscription[] = [
|
|
86
86
|
router.add('prompt.submit', async (e, next) => {
|
|
87
87
|
if (!(await wants(Prompt))) return next(e)
|
|
88
88
|
const where = await place()
|
|
@@ -215,7 +215,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
|
|
|
215
215
|
return answer.refuse === undefined ? next(e) : { skip: answer.refuse }
|
|
216
216
|
}),
|
|
217
217
|
]
|
|
218
|
-
return {
|
|
218
|
+
return { remove: () => added.forEach((each) => each.remove()) }
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
export function closingContext(where: Place, task: RunningTask | undefined, closedAt: string): ContextData {
|
package/src/runtime/claude.ts
CHANGED
|
@@ -46,7 +46,6 @@ import type {
|
|
|
46
46
|
export type Claude = {
|
|
47
47
|
readonly plugin: { readonly name: string; readonly root: string }
|
|
48
48
|
readonly ui: {
|
|
49
|
-
status(text: string | undefined): void
|
|
50
49
|
log(text: string, options?: UiLogOptions): void
|
|
51
50
|
notice(toolUseId: string, text: string | undefined): void
|
|
52
51
|
invalidate(event: InvalidatableEventName): void
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Args, EventResult, Frozen } from 'claude-code'
|
|
2
|
-
import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
|
|
2
|
+
import { consentPath, coreVersion, dataFolder, grantFix, isObject, parseConsent } from '../records.js'
|
|
3
3
|
import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
|
|
4
4
|
import { modNote, Note } from '../session/context/turn/message/note.js'
|
|
5
5
|
import { Person } from '../session/context/turn/message/sender.js'
|
|
6
6
|
import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
7
7
|
import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
|
|
8
8
|
import { GoalChange } from '../session/goal/goal.js'
|
|
9
|
+
import { TurnEnd, TurnStart } from '../session/context/turn/turn.js'
|
|
9
10
|
import { receivedMatcher } from '../utils/matcher.js'
|
|
10
11
|
import { ifMissing } from '../utils/paths.js'
|
|
11
12
|
import { formatExit, messageOf } from '../utils/text.js'
|
|
@@ -15,13 +16,13 @@ import { cmodPluginName } from './dependencies.js'
|
|
|
15
16
|
import { readLines, runCmod } from './programs.js'
|
|
16
17
|
import type { RoutedHook, Router } from './router.js'
|
|
17
18
|
import { PassedTimeout } from './deadline.js'
|
|
18
|
-
import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type
|
|
19
|
+
import { actPermission, conversationMethods, everySession, isAct, isHeardBy, isInScope, resultOf, spawnTask, usageOf, type Act, type Listened } from './sessions.js'
|
|
19
20
|
|
|
20
21
|
type Request = Readonly<Record<string, unknown>>
|
|
21
22
|
|
|
22
|
-
type Heard = { readonly event:
|
|
23
|
+
type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
|
|
23
24
|
|
|
24
|
-
type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly
|
|
25
|
+
type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[]; readonly refusing?: readonly Listened[] }
|
|
25
26
|
|
|
26
27
|
type Watcher = Watch & { readonly from: string }
|
|
27
28
|
|
|
@@ -31,6 +32,10 @@ type Question =
|
|
|
31
32
|
|
|
32
33
|
type Relay = (Question & { readonly id: string; readonly from: string }) | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string } | { readonly kind: 'watch' } | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
|
|
33
34
|
|
|
35
|
+
const relayKinds: ReadonlySet<unknown> = new Set(['act', 'before', 'answer', 'watch', 'goal'] satisfies Relay['kind'][])
|
|
36
|
+
|
|
37
|
+
const isRelay = (value: unknown): value is Relay => isObject(value) && relayKinds.has(value['kind'])
|
|
38
|
+
|
|
34
39
|
type Asked = { resolve(value: unknown): void; reject(error: Error): void }
|
|
35
40
|
|
|
36
41
|
|
|
@@ -46,9 +51,28 @@ const livenessMs = 5000
|
|
|
46
51
|
export type Conversations = {
|
|
47
52
|
answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
|
|
48
53
|
moved(): void
|
|
54
|
+
turnClosedBy(rowId: string): TurnData | undefined
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
const keptClosings = 200
|
|
58
|
+
|
|
59
|
+
const keptMessages = 200
|
|
60
|
+
|
|
61
|
+
export function serveConversations({
|
|
62
|
+
claude,
|
|
63
|
+
router,
|
|
64
|
+
store,
|
|
65
|
+
project,
|
|
66
|
+
report,
|
|
67
|
+
clear,
|
|
68
|
+
}: {
|
|
69
|
+
readonly claude: Claude
|
|
70
|
+
readonly router: Router
|
|
71
|
+
readonly store: string
|
|
72
|
+
readonly project: () => string
|
|
73
|
+
readonly report: (id: string, message: string, error: unknown) => void
|
|
74
|
+
readonly clear: (id: string) => void
|
|
75
|
+
}): Conversations {
|
|
52
76
|
const folder = dataFolder(store, cmodPluginName)
|
|
53
77
|
const goals = `${folder}/goals`
|
|
54
78
|
const watches = `${folder}/before`
|
|
@@ -98,16 +122,23 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
98
122
|
|
|
99
123
|
const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
|
|
100
124
|
|
|
125
|
+
let lastTurn: TurnData | undefined
|
|
126
|
+
const closings = new Map<string, TurnData>()
|
|
101
127
|
const dispatch = (to: string, changes: readonly SessionChange[], isInProject = true) => {
|
|
102
128
|
for (const change of changes) {
|
|
129
|
+
if (to === session && (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.task === undefined) lastTurn = change.turn
|
|
103
130
|
if (change.type === Message && !liveIds.has(change.message.id) && to === session) {
|
|
104
131
|
live.push(change.message)
|
|
105
132
|
liveIds.add(change.message.id)
|
|
133
|
+
if (live.length > keptMessages) liveIds.delete(live.shift()?.id ?? '')
|
|
106
134
|
}
|
|
107
135
|
for (const [mod, sessions] of heard) {
|
|
108
136
|
const subscriptions = [...(sessions.get(to) ?? []), ...(isInProject ? (sessions.get(everySession) ?? []) : [])]
|
|
109
137
|
if (!subscriptions.some(({ event, matcher }) => isHeardBy(event, matcher, change))) continue
|
|
110
|
-
void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).
|
|
138
|
+
void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).then(
|
|
139
|
+
() => clear(`changed:${mod}`),
|
|
140
|
+
(error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error),
|
|
141
|
+
)
|
|
111
142
|
}
|
|
112
143
|
}
|
|
113
144
|
}
|
|
@@ -239,7 +270,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
239
270
|
}
|
|
240
271
|
|
|
241
272
|
const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
|
|
242
|
-
if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session.
|
|
273
|
+
if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. ${grantFix}`)
|
|
243
274
|
return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
|
|
244
275
|
}
|
|
245
276
|
|
|
@@ -333,6 +364,9 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
333
364
|
return
|
|
334
365
|
}
|
|
335
366
|
dispatch(session, conversation.add(row, task))
|
|
367
|
+
if (task !== undefined || row.type !== 'system' || row.name !== 'turn_duration' || lastTurn === undefined) return
|
|
368
|
+
closings.set(row.id, lastTurn)
|
|
369
|
+
if (closings.size > keptClosings) closings.delete(closings.keys().next().value as string)
|
|
336
370
|
}
|
|
337
371
|
|
|
338
372
|
router.add('classic.SessionStart', async (e, next) => {
|
|
@@ -386,11 +420,18 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
386
420
|
router.add('session.receive', async (e, next) => {
|
|
387
421
|
const sent = relayOf(e)
|
|
388
422
|
if (sent === undefined) return next(e)
|
|
389
|
-
const relay = JSON.parse(sent) as
|
|
390
|
-
claude.clock.after(0, () => void received(relay).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
|
|
423
|
+
const relay = JSON.parse(sent) as unknown
|
|
424
|
+
claude.clock.after(0, () => void (isRelay(relay) ? received(relay) : refuse(relay)).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
|
|
391
425
|
return { consumed: 'cmod relay' }
|
|
392
426
|
})
|
|
393
427
|
|
|
428
|
+
const unknownTo = async (what: string) => new Error(`Session ${await claude.session.id()} runs cmod ${coreVersion}, which has no ${what}.`)
|
|
429
|
+
|
|
430
|
+
const refuse = async (relay: unknown) => {
|
|
431
|
+
if (!isObject(relay) || typeof relay['id'] !== 'string' || typeof relay['from'] !== 'string') return
|
|
432
|
+
await post(relay['from'], { kind: 'answer', id: relay['id'], deny: (await unknownTo(`"${String(relay['kind'])}" relay`)).message })
|
|
433
|
+
}
|
|
434
|
+
|
|
394
435
|
const received = async (relay: Relay) => {
|
|
395
436
|
if (relay.kind === 'answer') {
|
|
396
437
|
const waiting = asked.get(relay.id)
|
|
@@ -411,13 +452,13 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
411
452
|
await post(relay.from, { kind: 'answer', id: relay.id, deny: messageOf(error) })
|
|
412
453
|
}
|
|
413
454
|
}
|
|
414
|
-
if (relay.kind === 'act') return answered(() => perform(relay.caller, relay.action, relay.input, relay.task))
|
|
455
|
+
if (relay.kind === 'act') return answered(async () => (isAct(relay.action) ? perform(relay.caller, relay.action, relay.input, relay.task) : Promise.reject(await unknownTo(`"${String(relay.action)}" action`))))
|
|
415
456
|
return answered(() => askHere(relay.mod, relay.event, relay.change, relay.from, relay.project === project()))
|
|
416
457
|
}
|
|
417
458
|
|
|
418
459
|
const checkAct = async (caller: string, action: Act, input: unknown) => {
|
|
419
460
|
const needed = actPermission(action, input)
|
|
420
|
-
if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}.
|
|
461
|
+
if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}. ${grantFix}`)
|
|
421
462
|
}
|
|
422
463
|
|
|
423
464
|
const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
|
|
@@ -509,6 +550,8 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
509
550
|
|
|
510
551
|
const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
|
|
511
552
|
|
|
553
|
+
const isHeld = (kept: readonly unknown[], last: unknown) => typeof last === 'number' && kept.length >= last
|
|
554
|
+
|
|
512
555
|
const methods: Readonly<Record<string, (request: Request, caller: string | undefined) => Promise<unknown>>> = {
|
|
513
556
|
async [conversationMethods.sessions](request) {
|
|
514
557
|
const found = await json<SessionData[]>(['history', 'sessions', ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : [])])
|
|
@@ -522,21 +565,23 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
522
565
|
async [conversationMethods.messages](request) {
|
|
523
566
|
const id = String(request['session'])
|
|
524
567
|
const filters = [...textArg('--turn', request['turn']), ...textArg('--task', request['task']), ...textArg('--after', request['after'])]
|
|
525
|
-
const saved =
|
|
526
|
-
if (id !== session) return saved
|
|
568
|
+
const saved = () => json<Message[]>(['history', 'messages', id, ...filters, ...countArg('--last', request['last'])])
|
|
569
|
+
if (id !== session) return saved()
|
|
527
570
|
await starting
|
|
528
571
|
const fits = (message: Message) => (request['turn'] === undefined || message.turn === request['turn']) && message.task === request['task']
|
|
529
572
|
const after = typeof request['after'] === 'string' ? live.findIndex((message) => message.id === request['after']) : -1
|
|
530
573
|
const kept = live.slice(after + 1).filter(fits)
|
|
531
|
-
|
|
574
|
+
if (after >= 0 || isHeld(kept, request['last'])) return lastOf(kept, request['last'])
|
|
575
|
+
return lastOf(merged(await saved(), kept, (message) => message.id), request['last'])
|
|
532
576
|
},
|
|
533
577
|
async [conversationMethods.turns](request) {
|
|
534
578
|
const id = String(request['session'])
|
|
535
|
-
const saved =
|
|
536
|
-
if (id !== session) return saved
|
|
579
|
+
const saved = () => json<TurnData[]>(['history', 'turns', id, ...textArg('--task', request['task']), ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
|
|
580
|
+
if (id !== session) return saved()
|
|
537
581
|
await starting
|
|
538
582
|
const kept = liveTurns(request['task']).filter((turn) => request['context'] === undefined || turn.context === request['context'])
|
|
539
|
-
|
|
583
|
+
if (isHeld(kept, request['last'])) return lastOf(kept, request['last'])
|
|
584
|
+
return lastOf(merged(await saved(), kept, (turn) => turn.prompt.id), request['last'])
|
|
540
585
|
},
|
|
541
586
|
async [conversationMethods.contexts](request) {
|
|
542
587
|
const id = String(request['session'])
|
|
@@ -583,7 +628,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
583
628
|
async [conversationMethods.subscribe](request, caller) {
|
|
584
629
|
if (caller === undefined) throw new Error('cmod hears no caller for this subscription.')
|
|
585
630
|
const id = String(request['session'])
|
|
586
|
-
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly
|
|
631
|
+
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
|
|
587
632
|
const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
|
|
588
633
|
sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
|
|
589
634
|
heard.set(caller, sessions)
|
|
@@ -593,7 +638,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
593
638
|
async [conversationMethods.watchBefore](request, caller) {
|
|
594
639
|
if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
|
|
595
640
|
const target = String(request['session'])
|
|
596
|
-
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly
|
|
641
|
+
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Listened[]) : []
|
|
597
642
|
ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))], refusing: list.filter((each) => each.onFailure === 'refuse') })
|
|
598
643
|
await publishWatches()
|
|
599
644
|
return null
|
|
@@ -610,6 +655,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
610
655
|
}
|
|
611
656
|
|
|
612
657
|
return {
|
|
658
|
+
turnClosedBy: (rowId) => closings.get(rowId),
|
|
613
659
|
moved() {
|
|
614
660
|
for (const [key, watch] of ownWatches) ownWatches.set(key, { ...watch, project: project() })
|
|
615
661
|
void publishWatches().catch((error: unknown) => report('before', 'Kept its before listeners on the project from before', error))
|
|
@@ -621,7 +667,7 @@ export function serveConversations({ claude, router, store, project, report }: {
|
|
|
621
667
|
if (method === undefined) return undefined
|
|
622
668
|
const input = isObject(e.input) ? e.input : {}
|
|
623
669
|
const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
|
|
624
|
-
if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant.
|
|
670
|
+
if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. ${grantFix}` }
|
|
625
671
|
try {
|
|
626
672
|
return { value: await method(input, e.caller) }
|
|
627
673
|
} catch (error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
|
|
2
|
+
import { installFix } from '../records.js'
|
|
2
3
|
import { messageOf } from '../utils/text.js'
|
|
3
4
|
import type { Claude } from './claude.js'
|
|
4
5
|
import type { Router } from './router.js'
|
|
@@ -46,5 +47,5 @@ export function answeringOwnCalls(claude: Claude, router: Router): Claude {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export function notInstalled(name: string): string {
|
|
49
|
-
return `${name} is not installed.
|
|
50
|
+
return `${name} is not installed. ${installFix}`
|
|
50
51
|
}
|
package/src/runtime/engine.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Claude } from './claude.js'
|
|
2
|
+
import type { Pages } from './pages.js'
|
|
2
3
|
import type { Router } from './router.js'
|
|
3
4
|
import type { Failure } from './toaster.js'
|
|
4
5
|
import type { ToolCalls } from './tool-calls.js'
|
|
6
|
+
import type { Widgets } from './widgets.js'
|
|
5
7
|
|
|
6
8
|
export type Engine = {
|
|
7
9
|
readonly claude: Claude
|
|
@@ -9,6 +11,9 @@ export type Engine = {
|
|
|
9
11
|
readonly toolCalls: ToolCalls
|
|
10
12
|
readonly report: (failure: Failure) => void
|
|
11
13
|
readonly hasPermissionCheck: () => boolean
|
|
14
|
+
readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
|
|
15
|
+
readonly widgets?: Widgets
|
|
16
|
+
readonly pages?: Pages
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
const engines = new WeakMap<object, Engine>()
|
package/src/runtime/grants.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isObject, isSettingCovered, permissionWords, settingPaths } from '../records.js'
|
|
1
|
+
import { grantFix, isObject, isSettingCovered, permissionWords, settingPaths } from '../records.js'
|
|
2
2
|
import { relativePath } from '../utils/paths.js'
|
|
3
3
|
import type { Claude } from './claude.js'
|
|
4
4
|
import { cmodPluginName } from './dependencies.js'
|
|
@@ -198,7 +198,7 @@ function writeItem(grants: Grants, path: string): string | undefined {
|
|
|
198
198
|
|
|
199
199
|
export function refusal(grants: Pick<Grants, 'name' | 'declared' | 'home'>, call: string, item: string): string {
|
|
200
200
|
const { name } = grants
|
|
201
|
-
if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}".
|
|
201
|
+
if (isCovered(new Set(grants.declared), item, grants.home)) return `${name} calls ${call} without your grant to "${permissionWords(item)}". ${grantFix}`
|
|
202
202
|
return `${name} calls ${call}, which needs ${declaration(item, grants.home)} in package.json "cmod".`
|
|
203
203
|
}
|
|
204
204
|
|
package/src/runtime/hooks.ts
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Hotkey, Hotkeys } from '../hotkeys.js'
|
|
2
|
+
import { boundCombos, hotkeyCommand } from '../records.js'
|
|
3
|
+
import { ifMissing } from '../utils/paths.js'
|
|
4
|
+
import { messageOf } from '../utils/text.js'
|
|
5
|
+
import type { Claude } from './claude.js'
|
|
6
|
+
import type { Router } from './router.js'
|
|
7
|
+
import type { Failure } from './toaster.js'
|
|
8
|
+
|
|
9
|
+
export async function readCombos(claude: Claude, configRoot: string | undefined): Promise<Map<string, string>> {
|
|
10
|
+
if (configRoot === undefined) return new Map()
|
|
11
|
+
const text = await claude.fs.read(`${configRoot}/keybindings.json`).catch(ifMissing('{}'))
|
|
12
|
+
try {
|
|
13
|
+
return boundCombos(JSON.parse(text))
|
|
14
|
+
} catch (error) {
|
|
15
|
+
claude.ui.log(`${configRoot}/keybindings.json is not JSON, so cmod reads no combos from it: ${messageOf(error)}`, { to: 'debug' })
|
|
16
|
+
return new Map()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function createHotkeys({
|
|
21
|
+
name,
|
|
22
|
+
declared,
|
|
23
|
+
configRoot,
|
|
24
|
+
claude,
|
|
25
|
+
router,
|
|
26
|
+
answerInput,
|
|
27
|
+
report,
|
|
28
|
+
}: {
|
|
29
|
+
readonly name: string
|
|
30
|
+
readonly declared: Hotkeys
|
|
31
|
+
readonly configRoot: string | undefined
|
|
32
|
+
readonly claude: Claude
|
|
33
|
+
readonly router: Router
|
|
34
|
+
readonly answerInput: <Answer>(run: () => Promise<Answer>, passOn: () => Promise<unknown>) => Promise<Answer>
|
|
35
|
+
readonly report: (failure: Failure) => void
|
|
36
|
+
}): Promise<Readonly<Record<string, Hotkey>>> {
|
|
37
|
+
const entries = Object.entries(declared)
|
|
38
|
+
if (entries.length === 0) return {}
|
|
39
|
+
const combos = await readCombos(claude, configRoot)
|
|
40
|
+
const listeners = new Map<string, Set<() => unknown>>()
|
|
41
|
+
const commands = new Map<string, string>()
|
|
42
|
+
for (const [hotkey, { title }] of entries) {
|
|
43
|
+
listeners.set(hotkey, new Set())
|
|
44
|
+
claude.command.register({ name: hotkeyCommand(name, hotkey), description: title, immediate: true }).then(
|
|
45
|
+
({ command }) => void commands.set(command, hotkey),
|
|
46
|
+
(error: unknown) => report({ id: `hotkey:${hotkey}`, message: `The ${title} hotkey is not added`, error }),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
router.add('command.describe', async (e, next) => {
|
|
50
|
+
const described = await next(e)
|
|
51
|
+
return commands.has(e.command) ? { ...described, isHidden: true } : described
|
|
52
|
+
})
|
|
53
|
+
router.add('command.run', async (e, next) => {
|
|
54
|
+
const hotkey = commands.get(e.command)
|
|
55
|
+
if (hotkey === undefined) return next(e)
|
|
56
|
+
return answerInput(async () => {
|
|
57
|
+
for (const listener of listeners.get(hotkey) ?? []) {
|
|
58
|
+
try {
|
|
59
|
+
await listener()
|
|
60
|
+
} catch (error) {
|
|
61
|
+
report({ id: `hotkey:${hotkey}`, message: `The ${declared[hotkey]?.title ?? hotkey} hotkey failed`, error })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {}
|
|
65
|
+
}, () => next(e))
|
|
66
|
+
})
|
|
67
|
+
return Object.fromEntries(
|
|
68
|
+
entries.map(([hotkey, { title }]): [string, Hotkey] => [
|
|
69
|
+
hotkey,
|
|
70
|
+
{
|
|
71
|
+
title,
|
|
72
|
+
combo: combos.get(hotkeyCommand(name, hotkey)) as Hotkey['combo'],
|
|
73
|
+
onPress(listener) {
|
|
74
|
+
listeners.get(hotkey)?.add(listener)
|
|
75
|
+
return { remove: () => void listeners.get(hotkey)?.delete(listener) }
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]),
|
|
79
|
+
)
|
|
80
|
+
}
|
package/src/runtime/installer.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Args, Frozen, RenderElement, RenderPropsOf } from 'claude-code'
|
|
|
2
2
|
import type { Step } from '../installer.js'
|
|
3
3
|
import type { Mod } from '../mod.js'
|
|
4
4
|
import type { Option } from '../options.js'
|
|
5
|
-
import { permissionWords } from '../records.js'
|
|
5
|
+
import { permissionWords, secretFix } from '../records.js'
|
|
6
6
|
import { Box, Button, drawWith, Input, Text } from '../ui/elements.js'
|
|
7
7
|
import { messageOf } from '../utils/text.js'
|
|
8
8
|
import type { Claude } from './claude.js'
|
|
@@ -12,7 +12,7 @@ export const installerPaneOf = (name: string): string => `installer-${name.repla
|
|
|
12
12
|
export type Consent = {
|
|
13
13
|
readonly install: string
|
|
14
14
|
readonly uninstall: string
|
|
15
|
-
readonly
|
|
15
|
+
readonly hotkeys: string
|
|
16
16
|
readonly permissions: readonly string[]
|
|
17
17
|
readonly updates?: string | undefined
|
|
18
18
|
}
|
|
@@ -32,25 +32,32 @@ export type Installer = {
|
|
|
32
32
|
close(): Promise<void>
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function createInstaller(name: string, claude: () => Claude): Installer {
|
|
35
|
+
export function createInstaller(name: string, claude: () => Claude, waiting: { show(open: () => Promise<void>): void; hide(): void }): Installer {
|
|
36
36
|
const paneId = installerPaneOf(name)
|
|
37
37
|
let page: Page | undefined
|
|
38
38
|
let isOpen = false
|
|
39
39
|
|
|
40
|
+
const open = async () => {
|
|
41
|
+
isOpen = true
|
|
42
|
+
await claude().ui.open({ id: paneId, title: `Install ${name}`, focus: true, holdToasts: true, closeOnEscape: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
const show = async <Answer>(build: (answer: (value: Answer) => void) => Omit<Page, 'cancel'>, cancelled: Answer): Promise<Answer> => {
|
|
41
46
|
page?.cancel()
|
|
42
47
|
const answer = new Promise<Answer>((resolve) => {
|
|
43
48
|
const done = (value: Answer) => {
|
|
44
49
|
if (page === shown) page = undefined
|
|
50
|
+
waiting.hide()
|
|
45
51
|
resolve(value)
|
|
46
52
|
}
|
|
47
53
|
const shown: Page = { ...build(done), cancel: () => done(cancelled) }
|
|
48
54
|
page = shown
|
|
49
55
|
})
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
waiting.show(open)
|
|
57
|
+
if (!isOpen) await open().catch((error: unknown) => {
|
|
58
|
+
waiting.hide()
|
|
59
|
+
throw error
|
|
60
|
+
})
|
|
54
61
|
claude().ui.invalidate('ui.render')
|
|
55
62
|
return answer
|
|
56
63
|
}
|
|
@@ -70,11 +77,11 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
|
|
|
70
77
|
isOpen = false
|
|
71
78
|
page?.cancel()
|
|
72
79
|
},
|
|
73
|
-
consent({ install, uninstall,
|
|
80
|
+
consent({ install, uninstall, hotkeys, permissions, updates }) {
|
|
74
81
|
const changes = [
|
|
75
82
|
...(install === '' ? [] : [`Run ${install} now${uninstall === '' ? '' : `, and ${uninstall} when you remove it`}`]),
|
|
76
83
|
...(install === '' && uninstall !== '' ? [`Run ${uninstall} when you remove it`] : []),
|
|
77
|
-
...(
|
|
84
|
+
...(hotkeys === '' ? [] : [`Bind ${hotkeys}`]),
|
|
78
85
|
...(updates === undefined ? [] : [`Update ${name} automatically from the ${updates} marketplace`]),
|
|
79
86
|
]
|
|
80
87
|
return show<boolean>(
|
|
@@ -85,7 +92,7 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
|
|
|
85
92
|
[
|
|
86
93
|
...(permissions.length === 0 ? [] : [Box({ flexDirection: 'column', children: permissions.map((item) => bullet(permissionWords(item))) })]),
|
|
87
94
|
...(changes.length === 0 ? [] : [Text({ children: permissions.length === 0 ? 'Change your computer:' : 'and change your computer:' }), Box({ flexDirection: 'column', children: changes.map(bullet) })]),
|
|
88
|
-
Text({ dimColor: true, children: 'You can turn each permission off later in
|
|
95
|
+
Text({ dimColor: true, children: 'You can turn each permission off later in Settings.' }),
|
|
89
96
|
],
|
|
90
97
|
[Button({ key: 'not-now', hotkey: 'n', label: 'Not now', onPress: () => answer(false) }), Button({ key: 'accept', hotkey: 'a', label: 'Accept', onPress: () => answer(true) })],
|
|
91
98
|
),
|
|
@@ -103,11 +110,16 @@ export function createInstaller(name: string, claude: () => Claude): Installer {
|
|
|
103
110
|
`${name} needs ${missing.length === 1 ? 'one setting' : `${missing.length} settings`}:`,
|
|
104
111
|
missing.map(([key, option]) => {
|
|
105
112
|
const label = Text({ children: [Text({ bold: true, children: option.title }), option.description === '' ? '' : Text({ dimColor: true, children: ` ${option.description}` })] })
|
|
106
|
-
if (option.type === 'secret')
|
|
113
|
+
if (option.type === 'secret') {
|
|
114
|
+
return Box({
|
|
115
|
+
flexDirection: 'column',
|
|
116
|
+
children: [label, Text({ dimColor: true, children: `${secretFix} Then press Next.` }), Button({ key: `secret:${key}`, label: 'Open /config', onPress: () => void claude().command.run({ command: 'config' }) })],
|
|
117
|
+
})
|
|
118
|
+
}
|
|
107
119
|
const failure = failures.get(key)
|
|
108
120
|
const field = Input({
|
|
109
121
|
key: `option:${key}`,
|
|
110
|
-
label: '
|
|
122
|
+
label: 'Value',
|
|
111
123
|
placeholder: option.type === 'choice' ? (option.choices ?? []).join(', ') : option.type === 'toggle' ? 'true or false' : option.type === 'list' ? 'comma-separated' : '',
|
|
112
124
|
value: saved.get(key) ?? '',
|
|
113
125
|
submitLabel: 'save',
|