@cmodjs/core 0.2.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +58 -10
- package/docs/dependencies.md +12 -12
- package/docs/hooks.md +41 -28
- package/docs/index.md +29 -25
- package/docs/install-steps.md +9 -7
- package/docs/jobs.md +59 -111
- package/docs/mod.md +200 -174
- package/docs/options.md +2 -2
- package/docs/permissions.md +27 -16
- package/docs/session.md +248 -0
- package/docs/state.md +11 -7
- package/docs/testing.md +66 -23
- package/docs/ui.md +108 -76
- package/jobs/check.d.ts.map +1 -1
- package/jobs/check.js +5 -2
- package/jobs/context.d.ts +7 -8
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +23 -73
- package/jobs/permissions/decide-permission.d.ts +5 -3
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +6 -4
- package/jobs/permissions/find-project-scope.d.ts +1 -1
- package/jobs/permissions/find-project-scope.d.ts.map +1 -1
- package/jobs/permissions/find-project-scope.js +8 -11
- package/jobs/permissions/match-target.d.ts +2 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +6 -4
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +8 -3
- package/jobs/prompt.d.ts +4 -2
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +16 -15
- package/jobs/schedule.d.ts +2 -2
- package/jobs/schedule.d.ts.map +1 -1
- package/jobs/schedule.js +9 -9
- package/jobs/slash-command.d.ts +4 -4
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +14 -9
- package/jobs/status-line.d.ts +1 -11
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +8 -12
- package/jobs/tool.d.ts +2 -2
- package/jobs/tool.d.ts.map +1 -1
- package/jobs/tool.js +15 -9
- package/mod.d.ts +154 -75
- package/mod.d.ts.map +1 -1
- package/mod.js +94 -3
- package/options.d.ts +2 -2
- package/options.js +19 -19
- package/package.json +3 -3
- package/records.d.ts +10 -8
- package/records.d.ts.map +1 -1
- package/records.js +28 -17
- package/register.d.ts.map +1 -1
- package/register.js +18 -6
- package/runtime/announce.d.ts +18 -0
- package/runtime/announce.d.ts.map +1 -0
- package/runtime/announce.js +66 -0
- package/runtime/bound.d.ts +4 -0
- package/runtime/bound.d.ts.map +1 -0
- package/runtime/bound.js +9 -0
- package/runtime/claude.d.ts +3 -3
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +16 -0
- package/runtime/conversations.d.ts.map +1 -0
- package/runtime/conversations.js +291 -0
- package/runtime/deadline.d.ts +0 -1
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +1 -1
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +9 -0
- package/runtime/engine.d.ts +17 -0
- package/runtime/engine.d.ts.map +1 -0
- package/runtime/engine.js +10 -0
- package/runtime/grants.d.ts +4 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +63 -33
- package/runtime/hooks.d.ts +5 -4
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +10 -5
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +11 -9
- package/runtime/lifecycle.d.ts +3 -1
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +317 -186
- package/runtime/metadata.d.ts +3 -1
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +21 -11
- package/runtime/options.d.ts +3 -1
- package/runtime/options.d.ts.map +1 -1
- package/runtime/options.js +4 -4
- package/runtime/programs.d.ts +8 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +39 -1
- package/runtime/router.d.ts +2 -1
- package/runtime/router.d.ts.map +1 -1
- package/runtime/router.js +11 -3
- package/runtime/sessions.d.ts +36 -0
- package/runtime/sessions.d.ts.map +1 -0
- package/runtime/sessions.js +235 -0
- package/runtime/state.d.ts +6 -2
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +46 -13
- package/runtime/toaster.d.ts +31 -0
- package/runtime/toaster.d.ts.map +1 -0
- package/runtime/toaster.js +176 -0
- package/runtime/ui.d.ts +6 -17
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +36 -112
- package/session/context/context.d.ts +10 -0
- package/session/context/context.d.ts.map +1 -0
- package/session/context/context.js +2 -0
- package/session/context/turn/message/document.d.ts +7 -0
- package/session/context/turn/message/document.d.ts.map +1 -0
- package/session/context/turn/message/document.js +1 -0
- package/session/context/turn/message/message.d.ts +54 -0
- package/session/context/turn/message/message.d.ts.map +1 -0
- package/session/context/turn/message/message.js +137 -0
- package/session/context/turn/message/note.d.ts +8 -0
- package/session/context/turn/message/note.d.ts.map +1 -0
- package/session/context/turn/message/note.js +1 -0
- package/session/context/turn/message/notice.d.ts +6 -0
- package/session/context/turn/message/notice.d.ts.map +1 -0
- package/session/context/turn/message/notice.js +1 -0
- package/session/context/turn/message/prompt.d.ts +8 -0
- package/session/context/turn/message/prompt.d.ts.map +1 -0
- package/session/context/turn/message/prompt.js +1 -0
- package/session/context/turn/message/reply.d.ts +6 -0
- package/session/context/turn/message/reply.d.ts.map +1 -0
- package/session/context/turn/message/reply.js +1 -0
- package/session/context/turn/message/sender.d.ts +14 -0
- package/session/context/turn/message/sender.d.ts.map +1 -0
- package/session/context/turn/message/sender.js +30 -0
- package/session/context/turn/message/skill.d.ts +7 -0
- package/session/context/turn/message/skill.d.ts.map +1 -0
- package/session/context/turn/message/skill.js +1 -0
- package/session/context/turn/message/tool-call.d.ts +9 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -0
- package/session/context/turn/message/tool-call.js +1 -0
- package/session/context/turn/message/tool-result.d.ts +8 -0
- package/session/context/turn/message/tool-result.d.ts.map +1 -0
- package/session/context/turn/message/tool-result.js +1 -0
- package/session/context/turn/turn.d.ts +12 -0
- package/session/context/turn/turn.d.ts.map +1 -0
- package/session/context/turn/turn.js +2 -0
- package/session/conversation.d.ts +64 -0
- package/session/conversation.d.ts.map +1 -0
- package/session/conversation.js +123 -0
- package/session/goal/goal.d.ts +10 -0
- package/session/goal/goal.d.ts.map +1 -0
- package/session/goal/goal.js +1 -0
- package/session/goal/task.d.ts +16 -0
- package/session/goal/task.d.ts.map +1 -0
- package/session/goal/task.js +3 -0
- package/session/session.d.ts +99 -0
- package/session/session.d.ts.map +1 -0
- package/session/session.js +1 -0
- package/src/jobs/check.ts +7 -4
- package/src/jobs/context.ts +24 -82
- package/src/jobs/permissions/decide-permission.ts +10 -8
- package/src/jobs/permissions/find-project-scope.ts +8 -14
- package/src/jobs/permissions/match-target.ts +3 -3
- package/src/jobs/permissions.ts +8 -6
- package/src/jobs/program.ts +7 -3
- package/src/jobs/prompt.ts +21 -24
- package/src/jobs/schedule.ts +14 -14
- package/src/jobs/slash-command.ts +20 -14
- package/src/jobs/status-line.ts +9 -21
- package/src/jobs/tool.ts +17 -11
- package/src/mod.ts +209 -97
- package/src/options.ts +22 -22
- package/src/records.ts +35 -23
- package/src/register.ts +18 -6
- package/src/runtime/announce.ts +74 -0
- package/src/runtime/bound.ts +12 -0
- package/src/runtime/claude.ts +5 -3
- package/src/runtime/conversations.ts +302 -0
- package/src/runtime/deadline.ts +2 -2
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/engine.ts +22 -0
- package/src/runtime/grants.ts +66 -31
- package/src/runtime/hooks.ts +19 -10
- package/src/runtime/installer.ts +10 -9
- package/src/runtime/lifecycle.ts +313 -181
- package/src/runtime/metadata.ts +33 -11
- package/src/runtime/options.ts +19 -4
- package/src/runtime/programs.ts +40 -1
- package/src/runtime/router.ts +11 -4
- package/src/runtime/sessions.ts +242 -0
- package/src/runtime/state.ts +50 -14
- package/src/runtime/toaster.ts +218 -0
- package/src/runtime/ui.ts +42 -139
- package/src/session/context/context.ts +12 -0
- package/src/session/context/turn/message/document.ts +8 -0
- package/src/session/context/turn/message/message.ts +165 -0
- package/src/session/context/turn/message/note.ts +9 -0
- package/src/session/context/turn/message/notice.ts +7 -0
- package/src/session/context/turn/message/prompt.ts +9 -0
- package/src/session/context/turn/message/reply.ts +7 -0
- package/src/session/context/turn/message/sender.ts +38 -0
- package/src/session/context/turn/message/skill.ts +8 -0
- package/src/session/context/turn/message/tool-call.ts +10 -0
- package/src/session/context/turn/message/tool-result.ts +9 -0
- package/src/session/context/turn/turn.ts +14 -0
- package/src/session/conversation.ts +169 -0
- package/src/session/goal/goal.ts +9 -0
- package/src/session/goal/task.ts +19 -0
- package/src/session/session.ts +94 -0
- package/src/testing/fake-claude.ts +23 -18
- package/src/testing/fake-elements.ts +3 -2
- package/src/testing/fake-files.ts +1 -1
- package/src/testing/fake-sessions.ts +203 -0
- package/src/testing.ts +112 -33
- package/src/ui/components.ts +3 -3
- package/src/ui/elements.ts +22 -8
- package/src/ui/markdown.ts +22 -26
- package/src/ui/places.ts +49 -0
- package/src/utils/call-effects.ts +3 -3
- package/src/utils/changelog.ts +32 -0
- package/src/utils/matcher.ts +37 -0
- package/src/utils/metadata.ts +17 -15
- package/src/utils/parse-shell.ts +24 -16
- package/src/utils/paths.ts +15 -1
- package/src/utils/text.ts +4 -0
- package/testing/fake-claude.d.ts +4 -2
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -12
- package/testing/fake-elements.js +4 -2
- package/testing/fake-files.js +1 -1
- package/testing/fake-sessions.d.ts +74 -0
- package/testing/fake-sessions.d.ts.map +1 -0
- package/testing/fake-sessions.js +170 -0
- package/testing.d.ts +33 -13
- package/testing.d.ts.map +1 -1
- package/testing.js +92 -21
- package/ui/components.d.ts +2 -3
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +3 -3
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +19 -9
- package/ui/markdown.d.ts +35 -33
- package/ui/markdown.d.ts.map +1 -1
- package/ui/markdown.js +14 -16
- package/ui/places.d.ts +43 -0
- package/ui/places.d.ts.map +1 -0
- package/ui/places.js +31 -0
- package/utils/call-effects.d.ts +1 -1
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +2 -2
- package/utils/changelog.d.ts +9 -0
- package/utils/changelog.d.ts.map +1 -0
- package/utils/changelog.js +29 -0
- package/utils/matcher.d.ts +13 -0
- package/utils/matcher.d.ts.map +1 -0
- package/utils/matcher.js +32 -0
- package/utils/metadata.d.ts +3 -3
- package/utils/metadata.js +18 -14
- package/utils/parse-shell.d.ts +1 -0
- package/utils/parse-shell.d.ts.map +1 -1
- package/utils/parse-shell.js +22 -13
- package/utils/paths.d.ts +2 -0
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +13 -1
- package/utils/text.d.ts +1 -0
- package/utils/text.d.ts.map +1 -1
- package/utils/text.js +3 -0
- package/src/ui/slots.ts +0 -36
- package/ui/slots.d.ts +0 -208
- package/ui/slots.d.ts.map +0 -1
- package/ui/slots.js +0 -19
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { isObject } from '../records.js'
|
|
2
|
+
import { Document } from '../session/context/turn/message/document.js'
|
|
3
|
+
import { Message, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
|
|
4
|
+
import { Note } from '../session/context/turn/message/note.js'
|
|
5
|
+
import { Notice } from '../session/context/turn/message/notice.js'
|
|
6
|
+
import { Prompt } from '../session/context/turn/message/prompt.js'
|
|
7
|
+
import { Reply } from '../session/context/turn/message/reply.js'
|
|
8
|
+
import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
|
|
9
|
+
import { Task } from '../session/goal/task.js'
|
|
10
|
+
import { Skill } from '../session/context/turn/message/skill.js'
|
|
11
|
+
import { ToolCall } from '../session/context/turn/message/tool-call.js'
|
|
12
|
+
import { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
13
|
+
import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
|
|
14
|
+
import { conversationMethods, isHeardBy, type Subscription } from '../runtime/sessions.js'
|
|
15
|
+
import { receivedMatcher } from '../utils/matcher.js'
|
|
16
|
+
import { firstLine, messageOf } from '../utils/text.js'
|
|
17
|
+
|
|
18
|
+
export type MessageDraft =
|
|
19
|
+
| { readonly type: typeof Prompt; readonly text: string; readonly from?: Sender }
|
|
20
|
+
| { readonly type: typeof Note; readonly text: string; readonly from?: Sender }
|
|
21
|
+
| { readonly type: typeof Reply; readonly text: string }
|
|
22
|
+
| { readonly type: typeof ToolCall; readonly name: string; readonly input?: Readonly<Record<string, unknown>>; readonly callId?: string }
|
|
23
|
+
| { readonly type: typeof ToolResult; readonly callId: string; readonly text: string; readonly isError?: boolean }
|
|
24
|
+
| { readonly type: typeof Skill; readonly name: string; readonly text: string }
|
|
25
|
+
| { readonly type: typeof Document; readonly path: string; readonly text: string }
|
|
26
|
+
| { readonly type: typeof Notice; readonly text: string }
|
|
27
|
+
|
|
28
|
+
export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
|
|
29
|
+
readonly id: string
|
|
30
|
+
readonly messages?: readonly MessageDraft[]
|
|
31
|
+
readonly goal?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Kept = {
|
|
35
|
+
data: SessionData
|
|
36
|
+
readonly conversation: Conversation
|
|
37
|
+
readonly messages: Message[]
|
|
38
|
+
readonly turns: Map<string, TurnData>
|
|
39
|
+
readonly contexts: Map<number, ContextData>
|
|
40
|
+
readonly tasks: Map<string, TaskData>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export type FakeSessions = {
|
|
47
|
+
answer(caller: string, method: string, input: unknown): unknown
|
|
48
|
+
add(draft: MessageDraft, task?: string): Message[]
|
|
49
|
+
endTurn(): void
|
|
50
|
+
compact(): void
|
|
51
|
+
startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): string
|
|
52
|
+
start(id: string): void
|
|
53
|
+
readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function fakeSessions({ project, given, push }: { readonly project: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void }): FakeSessions {
|
|
57
|
+
const kept = new Map<string, Kept>()
|
|
58
|
+
const heard = new Map<string, Map<string, readonly Heard[]>>()
|
|
59
|
+
const sent: FakeSessions['sent'] = []
|
|
60
|
+
let current = ''
|
|
61
|
+
let clock = Date.parse('2026-01-01T00:00:00.000Z')
|
|
62
|
+
let ids = 0
|
|
63
|
+
const now = () => new Date((clock += 1000)).toISOString()
|
|
64
|
+
|
|
65
|
+
const keep = (data: SessionData): Kept => {
|
|
66
|
+
const session: Kept = { data, conversation: createConversation(emptyConversation(data.id)), messages: [], turns: new Map(), contexts: new Map(), tasks: new Map() }
|
|
67
|
+
kept.set(data.id, session)
|
|
68
|
+
return session
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const record = (session: Kept, changes: readonly SessionChange[]) => {
|
|
72
|
+
for (const change of changes) {
|
|
73
|
+
if (change.type === Message) session.messages.push(change.message)
|
|
74
|
+
if ('turn' in change) session.turns.set(change.turn.prompt.id, change.turn)
|
|
75
|
+
if ('context' in change) session.contexts.set(change.context.number, change.context)
|
|
76
|
+
if ('task' in change) session.tasks.set(change.task.id, change.task)
|
|
77
|
+
session.data = { ...session.data, lastAt: change.type === Message ? change.message.at : session.data.lastAt, ...(session.data.title === '' && change.type === Message && change.message.type === Prompt && change.message.from.type === Person ? { title: firstLine(change.message.text) } : {}) }
|
|
78
|
+
for (const [mod, sessions] of heard) {
|
|
79
|
+
if ((sessions.get(session.data.id) ?? []).some(({ event, matcher }) => isHeardBy(event, matcher, change))) push(mod, session.data.id, change)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const add = (session: Kept, draft: MessageDraft, task?: string) => {
|
|
85
|
+
const before = session.messages.length
|
|
86
|
+
record(session, session.conversation.add(rowOf(draft, `message-${(ids += 1)}`, now()), task))
|
|
87
|
+
return session.messages.slice(before)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const each of given) {
|
|
91
|
+
const session = keep({ title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: '', isRunning: false, ...each })
|
|
92
|
+
for (const draft of each.messages ?? []) add(session, draft)
|
|
93
|
+
if (each.goal !== undefined) record(session, session.conversation.changeGoal(each.goal))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sessionOf = (id: string) => {
|
|
97
|
+
const session = kept.get(id)
|
|
98
|
+
if (session === undefined) throw new Error(`No Claude Code transcript is named ${id}.jsonl. Give it to testMod as { sessions: [{ id: '${id}' }] }.`)
|
|
99
|
+
return session
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const currentSession = () => sessionOf(current)
|
|
103
|
+
|
|
104
|
+
const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
|
|
105
|
+
|
|
106
|
+
const isInScope = (session: Kept, request: Readonly<Record<string, unknown>>) =>
|
|
107
|
+
(request['everyProject'] === true || session.data.project === project()) && session.data.lastAt >= (typeof request['since'] === 'string' ? request['since'] : '') && (request['running'] !== true || session.data.isRunning)
|
|
108
|
+
|
|
109
|
+
const methods: Readonly<Record<string, (request: Readonly<Record<string, unknown>>, caller: string) => unknown>> = {
|
|
110
|
+
[conversationMethods.sessions]: (request) =>
|
|
111
|
+
[...kept.values()]
|
|
112
|
+
.filter((session) => isInScope(session, request))
|
|
113
|
+
.map((session) => session.data)
|
|
114
|
+
.toSorted((first, second) => second.lastAt.localeCompare(first.lastAt)),
|
|
115
|
+
[conversationMethods.search](request) {
|
|
116
|
+
const text = String(request['text'])
|
|
117
|
+
if ([...text].length < 3) throw new Error('cmod history search takes at least 3 characters.')
|
|
118
|
+
const found = [...kept.values()]
|
|
119
|
+
.filter((session) => isInScope(session, request))
|
|
120
|
+
.flatMap((session) => session.messages)
|
|
121
|
+
.filter((message) => searchText(message).toLowerCase().includes(text.toLowerCase()))
|
|
122
|
+
.toSorted((first, second) => second.at.localeCompare(first.at))
|
|
123
|
+
const before = isObject(request['before']) ? request['before'] : undefined
|
|
124
|
+
const start = before === undefined ? 0 : found.findIndex((message) => message.session === before['session'] && message.id === before['id']) + 1
|
|
125
|
+
return found.slice(start, start + (typeof request['limit'] === 'number' ? request['limit'] : searchLimit))
|
|
126
|
+
},
|
|
127
|
+
[conversationMethods.messages](request) {
|
|
128
|
+
const session = sessionOf(String(request['session']))
|
|
129
|
+
const fits = session.messages.filter((message) => message.task === request['task'] && (request['turn'] === undefined || message.turn === request['turn']))
|
|
130
|
+
const after = typeof request['after'] === 'string' ? fits.findIndex((message) => message.id === request['after']) + 1 : 0
|
|
131
|
+
return lastOf(fits.slice(after), request['last'])
|
|
132
|
+
},
|
|
133
|
+
[conversationMethods.turns]: (request) => lastOf([...sessionOf(String(request['session'])).turns.values()].filter((turn) => request['context'] === undefined || turn.context === request['context']), request['last']),
|
|
134
|
+
[conversationMethods.contexts]: (request) => [...sessionOf(String(request['session'])).contexts.values()],
|
|
135
|
+
[conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => task.parent === request['task']),
|
|
136
|
+
[conversationMethods.goal]: (request) => ({ text: sessionOf(String(request['session'])).conversation.state.goal ?? '' }),
|
|
137
|
+
[conversationMethods.updateGoal](request) {
|
|
138
|
+
const session = sessionOf(String(request['session']))
|
|
139
|
+
record(session, session.conversation.changeGoal(String(request['text'])))
|
|
140
|
+
return null
|
|
141
|
+
},
|
|
142
|
+
[conversationMethods.subscribe](request, caller) {
|
|
143
|
+
const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
|
|
144
|
+
const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
|
|
145
|
+
sessions.set(String(request['session']), list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
|
|
146
|
+
heard.set(caller, sessions)
|
|
147
|
+
return null
|
|
148
|
+
},
|
|
149
|
+
[conversationMethods.send](request) {
|
|
150
|
+
sent.push({ session: String(request['session']), type: String(request['type']), text: String(request['text']), ...(typeof request['as'] === 'string' ? { as: request['as'] } : {}) })
|
|
151
|
+
return null
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
answer(caller, method, input) {
|
|
157
|
+
const run = methods[method]
|
|
158
|
+
if (run === undefined) return undefined
|
|
159
|
+
try {
|
|
160
|
+
return { value: run(isObject(input) ? input : {}, caller) }
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { deny: messageOf(error) }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
add: (draft, task) => add(currentSession(), draft, task),
|
|
166
|
+
endTurn() {
|
|
167
|
+
const session = currentSession()
|
|
168
|
+
record(session, session.conversation.add({ id: `turn-end-${(ids += 1)}`, at: now(), type: 'system', name: 'turn_duration', isMeta: false, isSummary: false, content: [] }))
|
|
169
|
+
},
|
|
170
|
+
compact() {
|
|
171
|
+
const session = currentSession()
|
|
172
|
+
const at = now()
|
|
173
|
+
record(session, [...session.conversation.endContext(at), ...session.conversation.startContext(at)])
|
|
174
|
+
},
|
|
175
|
+
startTask({ title, agentType = 'general-purpose', model, openedBy = '' }) {
|
|
176
|
+
const session = currentSession()
|
|
177
|
+
const id = `task-${(ids += 1)}`
|
|
178
|
+
record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }) }))
|
|
179
|
+
return id
|
|
180
|
+
},
|
|
181
|
+
start(id) {
|
|
182
|
+
current = id
|
|
183
|
+
const session = kept.get(id) ?? keep({ id, title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: now(), isRunning: true })
|
|
184
|
+
session.data = { ...session.data, isRunning: true }
|
|
185
|
+
if (session.conversation.state.context === 0) record(session, session.conversation.startContext(now()))
|
|
186
|
+
},
|
|
187
|
+
sent,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rowOf(draft: MessageDraft, id: string, at: string): Row {
|
|
192
|
+
const base = { id, at, isMeta: false, isSummary: false }
|
|
193
|
+
const text = (value: string) => [{ type: 'text', text: value }]
|
|
194
|
+
const origin = (from: Sender | undefined) => (from === undefined || from.type === Person ? { kind: 'human' } : from.type === Mod ? { kind: 'plugin', name: from.name, ...(from.as === Person ? { asUser: true } : {}) } : from.type === 'session' ? { kind: 'peer' } : from.type === Task ? { kind: 'task-notification' } : { kind: from.type === Schedule ? 'scheduled-trigger' : from.type, ...(from.name === undefined ? {} : { server: from.name }) })
|
|
195
|
+
if (draft.type === Prompt) return { ...base, type: 'user', content: text(draft.text), origin: origin(draft.from) }
|
|
196
|
+
if (draft.type === Note) return { ...base, type: 'user', isMeta: true, content: text(draft.text), origin: origin(draft.from ?? { type: Mod, name: 'test' }) }
|
|
197
|
+
if (draft.type === Reply) return { ...base, type: 'assistant', content: text(draft.text) }
|
|
198
|
+
if (draft.type === ToolCall) return { ...base, type: 'assistant', content: [{ type: 'tool_use', id: draft.callId ?? `toolu_${id}`, name: draft.name, input: draft.input ?? {} }], cwd: '/test/project' }
|
|
199
|
+
if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.text, ...(draft.isError === true ? { is_error: true } : {}) }] }
|
|
200
|
+
if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
|
|
201
|
+
if (draft.type === Document) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
|
|
202
|
+
return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
|
|
203
|
+
}
|
package/src/testing.ts
CHANGED
|
@@ -17,23 +17,35 @@ import type {
|
|
|
17
17
|
UiPressArgument,
|
|
18
18
|
UiSelectArgument,
|
|
19
19
|
} from 'claude-code'
|
|
20
|
-
import type {
|
|
21
|
-
import type {
|
|
20
|
+
import type { CommandReply } from './jobs/slash-command.js'
|
|
21
|
+
import type { Hook, ModDefinition } from './mod.js'
|
|
22
22
|
import type { Options, OptionValues } from './options.js'
|
|
23
23
|
import { readSteps, storeFolder, type Steps } from './records.js'
|
|
24
24
|
import type { Claude } from './runtime/claude.js'
|
|
25
|
-
import { answerCall, notInstalled } from './runtime/dependencies.js'
|
|
25
|
+
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
|
|
26
26
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
27
27
|
import { toolInputOf } from './runtime/tool-calls.js'
|
|
28
28
|
import { createLifecycle } from './runtime/lifecycle.js'
|
|
29
29
|
import { MissingOptions } from './runtime/options.js'
|
|
30
|
-
import {
|
|
30
|
+
import { conversationMethods } from './runtime/sessions.js'
|
|
31
|
+
import type { ShownToast } from './runtime/toaster.js'
|
|
32
|
+
import type { Message } from './session/context/turn/message/message.js'
|
|
33
|
+
import { Note } from './session/context/turn/message/note.js'
|
|
34
|
+
import { Prompt } from './session/context/turn/message/prompt.js'
|
|
35
|
+
import { Mod, Person } from './session/context/turn/message/sender.js'
|
|
36
|
+
import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
|
|
31
37
|
import { elements, findElement, rowsOf } from './testing/fake-elements.js'
|
|
32
38
|
import { fakeFiles } from './testing/fake-files.js'
|
|
39
|
+
import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
|
|
33
40
|
import { listed } from './utils/text.js'
|
|
34
|
-
import
|
|
41
|
+
import { componentOf, type ComponentPlace, type PlaceProps } from './ui/places.js'
|
|
35
42
|
|
|
36
|
-
export type { Fakes,
|
|
43
|
+
export type { Fakes, TestCall } from './testing/fake-claude.js'
|
|
44
|
+
export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
|
|
45
|
+
export type { RoutedEvent } from './runtime/hooks.js'
|
|
46
|
+
export type { ShownToast } from './runtime/toaster.js'
|
|
47
|
+
|
|
48
|
+
export type Shown = ShownByClaude & { readonly toasts: readonly ShownToast[]; readonly sent: FakeSessions['sent'] }
|
|
37
49
|
|
|
38
50
|
export type TestOptions<State extends object, Declared extends Options = Options> = {
|
|
39
51
|
readonly state?: { readonly [Lifetime in keyof State]?: Partial<State[Lifetime]> }
|
|
@@ -45,23 +57,28 @@ export type TestOptions<State extends object, Declared extends Options = Options
|
|
|
45
57
|
readonly dependencies?: { readonly [Name in keyof CmodDependencies]?: CmodDependencies[Name] }
|
|
46
58
|
readonly files?: Readonly<Record<string, string>>
|
|
47
59
|
readonly links?: Readonly<Record<string, string>>
|
|
60
|
+
readonly sessions?: readonly FakeSession[]
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
type FilledField = 'session_id' | 'transcript_path' | 'cwd' | 'hook_event_name' | 'tool_use_id'
|
|
51
64
|
|
|
52
|
-
export type TestInput<
|
|
65
|
+
export type TestInput<H extends Hook> = Omit<ClassicHookInputs[H], FilledField> & Partial<Pick<ClassicHookInputs[H], FilledField & keyof ClassicHookInputs[H]>>
|
|
53
66
|
|
|
54
67
|
export type TestedMod<State extends object> = {
|
|
55
68
|
start(): Promise<void>
|
|
56
|
-
fire<
|
|
69
|
+
fire<H extends Hook>(hook: H, input: TestInput<H>, below?: EventResult<`classic.${H}`>): Promise<EventResult<`classic.${H}`>>
|
|
57
70
|
fire<N extends RoutedEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
|
|
58
71
|
settle(): Promise<void>
|
|
72
|
+
add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
|
|
73
|
+
endTurn(): Promise<void>
|
|
74
|
+
compact(): Promise<void>
|
|
75
|
+
startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): Promise<string>
|
|
59
76
|
lines(paneId: string): Promise<string[]>
|
|
60
|
-
lines<
|
|
61
|
-
type(line: string): Promise<
|
|
77
|
+
lines<P extends ComponentPlace>(place: P, props: Omit<PlaceProps<P>, 'Default'>, requestId?: string): Promise<string[]>
|
|
78
|
+
type(line: string): Promise<CommandReply>
|
|
62
79
|
callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
|
|
63
80
|
press(paneId: string, key: string, link?: string): Promise<void>
|
|
64
|
-
input(paneId: string, key: string, text: string,
|
|
81
|
+
input(paneId: string, key: string, text: string, type?: UiInputArgument['kind']): Promise<void>
|
|
65
82
|
select(paneId: string, key: string, value: string): Promise<void>
|
|
66
83
|
moveTo(projectRoot: string, cwd?: string): Promise<void>
|
|
67
84
|
readonly state: Readonly<State>
|
|
@@ -76,7 +93,7 @@ const defaultRows = 24
|
|
|
76
93
|
|
|
77
94
|
const presentation: CommandPresentation = { isFullscreen: false, columns: defaultColumns }
|
|
78
95
|
|
|
79
|
-
const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly
|
|
96
|
+
const toolUseEvents: readonly string[] = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'] satisfies readonly Hook[]
|
|
80
97
|
|
|
81
98
|
export function testMod<State extends object, Declared extends Options = Options>(definition: ModDefinition<State, string, Declared>, options: TestOptions<State, Declared> = {}): TestedMod<State> {
|
|
82
99
|
installJsx()
|
|
@@ -93,14 +110,37 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
93
110
|
fake.claude.session.id = async () => sessionId
|
|
94
111
|
fake.claude.session.root = async () => projectRoot
|
|
95
112
|
fake.claude.session.cwd = async () => cwd
|
|
96
|
-
|
|
113
|
+
const conversations = fakeSessions({
|
|
114
|
+
project: () => projectRoot,
|
|
115
|
+
given: options.sessions ?? [],
|
|
116
|
+
push: (mod, session, change) => {
|
|
117
|
+
if (mod !== name) return
|
|
118
|
+
const pushed = { to: mod, method: conversationMethods.changed, input: { session, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
|
|
119
|
+
void lifecycle.route('cmod.call', pushed, async () => ({ deny: notInstalled(mod) }))
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
conversations.start(sessionId)
|
|
123
|
+
fake.fakes.cmod.call = fakeDependencies(name, options.dependencies ?? {}, conversations)
|
|
124
|
+
const appendRow = fake.claude.session.append
|
|
125
|
+
fake.claude.session.append = async (args) => {
|
|
126
|
+
const added = await appendRow(args)
|
|
127
|
+
if (added.deny === undefined) conversations.add({ type: Note, text: args.message.content.map((block) => (typeof block['text'] === 'string' ? block['text'] : '')).join(''), from: { type: Mod, name } })
|
|
128
|
+
return added
|
|
129
|
+
}
|
|
130
|
+
const submitRow = fake.claude.prompt.submit
|
|
131
|
+
fake.claude.prompt.submit = async (args) => {
|
|
132
|
+
const submitted = await submitRow(args)
|
|
133
|
+
conversations.add({ type: Prompt, text: args.text, from: { type: Mod, name, ...(args.asUser === true ? { as: Person } : {}) } })
|
|
134
|
+
return submitted
|
|
135
|
+
}
|
|
97
136
|
const firedAgents = new Map<string, string>()
|
|
98
137
|
const listAgents = fake.claude.agent.list
|
|
99
138
|
fake.claude.agent.list = async () => {
|
|
100
139
|
if (fake.fakes.agent.list !== undefined || firedAgents.size === 0) return listAgents()
|
|
101
140
|
return [...firedAgents].map(([id, type]) => ({ id, type, description: '', status: 'running' as const }))
|
|
102
141
|
}
|
|
103
|
-
|
|
142
|
+
const files = fakeFiles(options.files ?? {}, options.links)
|
|
143
|
+
Object.assign(fake.fakes.fs, files)
|
|
104
144
|
const starting = options.state as Record<string, object> | undefined
|
|
105
145
|
const declared = Object.entries(definition.state ?? {}) as [string, object][]
|
|
106
146
|
const state = Object.fromEntries(declared.map(([lifetime, values]) => [lifetime, { ...values, ...starting?.[lifetime] }])) as State
|
|
@@ -122,6 +162,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
122
162
|
}
|
|
123
163
|
})
|
|
124
164
|
await started
|
|
165
|
+
await fake.settle()
|
|
125
166
|
const { failure } = lifecycle
|
|
126
167
|
if (failure instanceof MissingOptions) {
|
|
127
168
|
const given = failure.keys.map((key) => `${key}: …`).join(', ')
|
|
@@ -166,14 +207,19 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
166
207
|
|
|
167
208
|
const fire = ((event: string, input: object, below?: unknown) => {
|
|
168
209
|
if (event.includes('.')) return route(event as RoutedEvent, input, below)
|
|
169
|
-
if (event === 'SessionStart')
|
|
210
|
+
if (event === 'SessionStart') {
|
|
211
|
+
sessionId = (input as { session_id?: string }).session_id ?? sessionId
|
|
212
|
+
conversations.start(sessionId)
|
|
213
|
+
}
|
|
170
214
|
if (event === 'UserPromptSubmit') {
|
|
171
215
|
cwd = cwdAfterPrompt ?? cwd
|
|
172
216
|
cwdAfterPrompt = undefined
|
|
173
217
|
}
|
|
174
218
|
const toolUse = toolUseEvents.includes(event) ? { tool_use_id: `toolu_${(toolUses += 1)}` } : {}
|
|
175
|
-
const
|
|
176
|
-
|
|
219
|
+
const transcript = `/test/transcripts/${sessionId}.jsonl`
|
|
220
|
+
const filled = { session_id: sessionId, transcript_path: transcript, cwd, hook_event_name: event, ...toolUse, ...input }
|
|
221
|
+
if (event === 'SessionStart' && !('transcript_path' in input)) return files.write(transcript, '').then(() => route('classic.SessionStart', filled, below))
|
|
222
|
+
if (event !== 'PreToolUse') return route(`classic.${event as Exclude<Hook, 'PreToolUse'>}`, filled, below)
|
|
177
223
|
return firePreToolUse(filled as ClassicHookInputs['PreToolUse'], below as { deny?: string } | undefined)
|
|
178
224
|
}) as TestedMod<State>['fire']
|
|
179
225
|
|
|
@@ -187,7 +233,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
187
233
|
const answer = await lifecycle.route('tool.call', envelope, async (e) => {
|
|
188
234
|
reached = e
|
|
189
235
|
check = await lifecycle.route('tool.check', { tool: e.tool, input: toolInputOf(e), tool_use_id: e.tool_use_id, ...(e.agentId === undefined ? {} : { agentId: e.agentId }) }, async () => asks)
|
|
190
|
-
return
|
|
236
|
+
return check.decision === 'deny' ? { deny: check.reason ?? '' } : { result: '' }
|
|
191
237
|
})
|
|
192
238
|
const updatedInput = toolInputOf(reached)
|
|
193
239
|
const decided = check === asks ? {} : check.decision === 'allow' ? { allow: true } : check.decision === 'ask' ? { ask: check.reason ?? '' } : {}
|
|
@@ -198,14 +244,37 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
198
244
|
} as EventResult<'classic.PreToolUse'>
|
|
199
245
|
}
|
|
200
246
|
|
|
247
|
+
const settled = async <Value>(value: Value) => {
|
|
248
|
+
await fake.settle()
|
|
249
|
+
return value
|
|
250
|
+
}
|
|
251
|
+
|
|
201
252
|
return {
|
|
202
253
|
start,
|
|
203
254
|
fire,
|
|
204
255
|
settle: fake.settle,
|
|
205
|
-
|
|
206
|
-
|
|
256
|
+
add: async (message, added) => {
|
|
257
|
+
await start()
|
|
258
|
+
return settled(conversations.add(message, added?.task))
|
|
259
|
+
},
|
|
260
|
+
endTurn: async () => {
|
|
261
|
+
await start()
|
|
262
|
+
await settled(conversations.endTurn())
|
|
263
|
+
},
|
|
264
|
+
compact: async () => {
|
|
265
|
+
await start()
|
|
266
|
+
await settled(conversations.compact())
|
|
267
|
+
},
|
|
268
|
+
startTask: async (task) => {
|
|
269
|
+
await start()
|
|
270
|
+
return settled(conversations.startTask(task))
|
|
271
|
+
},
|
|
272
|
+
lines: async (target: string, props?: object, requestId?: string) => {
|
|
273
|
+
const component = componentOf(target as ComponentPlace)
|
|
274
|
+
if (props !== undefined && component === undefined) throw new Error(`lines takes a pane id, or a place render draws and its props, and ${target} is neither a place such as ToolCall nor called without props.`)
|
|
275
|
+
const { drawing, columns } = props === undefined || component === undefined ? await drawPane(target) : await drawSlot(component, props, requestId)
|
|
207
276
|
return rowsOf(drawing, columns).map((row) => row.trimEnd())
|
|
208
|
-
}
|
|
277
|
+
},
|
|
209
278
|
async type(line) {
|
|
210
279
|
const typed = /^\/(\S+)\s*(.*)$/s.exec(line)
|
|
211
280
|
if (typed === null) throw new Error(`type takes a slash command the way the user types it, such as tested.type('/help'), and "${line}" does not start with /.`)
|
|
@@ -230,16 +299,16 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
230
299
|
const props = await drawnElement(paneId, link === undefined ? 'Button' : 'Markdown', key)
|
|
231
300
|
if (link !== undefined && props['onLinkPress'] === undefined) throw new Error(`The Markdown "${key}" in the pane "${paneId}" of ${name} has no onLinkPress, so Claude Code opens its links itself.`)
|
|
232
301
|
const pressed: UiPressArgument = { ...eventIn(paneId, String(props['key'])), ...(link === undefined ? {} : { link: { href: link } }) }
|
|
233
|
-
await lifecycle.route('ui.press', pressed
|
|
302
|
+
await lifecycle.route('ui.press', pressed, async (e) => {
|
|
234
303
|
if (e.link === undefined) await (props['onPress'] as (e: UiPressArgument) => unknown)(e)
|
|
235
304
|
else await (props['onLinkPress'] as (link: PressedLink, e: UiPressArgument) => unknown)(e.link, e)
|
|
236
305
|
return { element: e.element }
|
|
237
306
|
})
|
|
238
307
|
},
|
|
239
|
-
async input(paneId, key, text,
|
|
308
|
+
async input(paneId, key, text, type = 'submit') {
|
|
240
309
|
const props = await drawnElement(paneId, 'Input', key)
|
|
241
|
-
const typed: UiInputArgument = { ...eventIn(paneId, key), kind, value: text }
|
|
242
|
-
await (props[
|
|
310
|
+
const typed: UiInputArgument = { ...eventIn(paneId, key), kind: type, value: text }
|
|
311
|
+
await (props[type === 'submit' ? 'onSubmit' : 'onInput'] as ((value: string, e: UiInputArgument) => unknown) | undefined)?.(text, typed)
|
|
243
312
|
},
|
|
244
313
|
async select(paneId, key, value) {
|
|
245
314
|
const props = await drawnElement(paneId, 'Select', key)
|
|
@@ -269,17 +338,24 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
269
338
|
},
|
|
270
339
|
calls: fake.calls,
|
|
271
340
|
fakes: fake.fakes,
|
|
272
|
-
shown:
|
|
341
|
+
shown: {
|
|
342
|
+
...fake.shown,
|
|
343
|
+
get toasts() {
|
|
344
|
+
return lifecycle.toasts
|
|
345
|
+
},
|
|
346
|
+
sent: conversations.sent,
|
|
347
|
+
},
|
|
273
348
|
}
|
|
274
349
|
}
|
|
275
350
|
|
|
276
|
-
function fakeDependencies(dependencies: NonNullable<TestOptions<object>['dependencies']
|
|
277
|
-
const
|
|
351
|
+
function fakeDependencies(caller: string, dependencies: NonNullable<TestOptions<object>['dependencies']>, conversations: FakeSessions): Claude['cmod']['call'] {
|
|
352
|
+
const cmod = { forkParent: () => null, updateSettings: () => null }
|
|
353
|
+
const apis = { [cmodPluginName]: cmod, ...dependencies } as Readonly<Record<string, Parameters<typeof answerCall>[1] | undefined>>
|
|
278
354
|
return async (call) => {
|
|
355
|
+
const served = call.to === cmodPluginName ? conversations.answer(caller, call.method, call.input) : undefined
|
|
356
|
+
if (served !== undefined) return calledAnswer(caller, served as EventResult<'cmod.call'>)
|
|
279
357
|
const api = apis[call.to]
|
|
280
|
-
|
|
281
|
-
if (answer.deny !== undefined) throw new Error(answer.deny)
|
|
282
|
-
return answer.value
|
|
358
|
+
return calledAnswer(caller, api === undefined ? { deny: notInstalled(call.to) } : await answerCall(call.to, api, call))
|
|
283
359
|
}
|
|
284
360
|
}
|
|
285
361
|
|
|
@@ -294,7 +370,7 @@ function plainOutput({ component, props }: Args<'ui.render'>): RenderElement {
|
|
|
294
370
|
return elements.Box({ flexDirection: 'column', children: [elements.Text({ children: component }), elements.Box({ flexDirection: 'column', paddingLeft: 2, children: rows })] })
|
|
295
371
|
}
|
|
296
372
|
|
|
297
|
-
function replyOf({ text, context }: CommandRunResult):
|
|
373
|
+
function replyOf({ text, context }: CommandRunResult): CommandReply {
|
|
298
374
|
return { ...(text === undefined ? {} : { text }), ...(context === undefined ? {} : { context: context.join('\n') }) }
|
|
299
375
|
}
|
|
300
376
|
|
|
@@ -302,7 +378,10 @@ async function modSteps(): Promise<Steps> {
|
|
|
302
378
|
const manifest = '../../../package.json'
|
|
303
379
|
const loaded: unknown = await import(manifest, { with: { type: 'json' } }).then(
|
|
304
380
|
(module: { default: unknown }) => module.default,
|
|
305
|
-
() =>
|
|
381
|
+
(error: { readonly code?: unknown }) => {
|
|
382
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND') return undefined
|
|
383
|
+
throw error
|
|
384
|
+
},
|
|
306
385
|
)
|
|
307
386
|
return readSteps(loaded) ?? {}
|
|
308
387
|
}
|
package/src/ui/components.ts
CHANGED
|
@@ -49,9 +49,9 @@ export function Pagination({ page, pages, onPage }: { readonly page: number; rea
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function
|
|
53
|
-
const filled =
|
|
54
|
-
return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) })
|
|
52
|
+
export function Progress({ value, width = 24 }: { readonly value: number; readonly width?: number }): RenderElement {
|
|
53
|
+
const filled = Math.round((Math.min(Math.max(value, 0), 100) / 100) * width)
|
|
54
|
+
return Text({ children: [Text({ color: 'claude', children: '█'.repeat(filled) }), Text({ color: 'subtle', children: '░'.repeat(width - filled) })] })
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const cardColumns = 36
|
package/src/ui/elements.ts
CHANGED
|
@@ -6,25 +6,39 @@ type CompletedTable = Record<ElementName, (props: object) => RenderElement>
|
|
|
6
6
|
|
|
7
7
|
type Drawing = { readonly table: CompletedTable; readonly markdown: ((props: MarkdownProps) => RenderElement) | undefined }
|
|
8
8
|
|
|
9
|
+
const unbuilt = Symbol('an element built outside a render')
|
|
10
|
+
|
|
11
|
+
type Unbuilt = { readonly [unbuilt]: { readonly name: ElementName; readonly props: object } }
|
|
12
|
+
|
|
9
13
|
let drawing: Drawing | undefined
|
|
10
14
|
|
|
11
15
|
export function drawWith<Drawn>(table: ElementTable, draw: () => Drawn, markdown?: (props: MarkdownProps) => RenderElement): Drawn {
|
|
12
16
|
const outer = drawing
|
|
13
|
-
|
|
17
|
+
const current = { table: table as CompletedTable, markdown }
|
|
18
|
+
drawing = current
|
|
14
19
|
try {
|
|
15
|
-
return draw()
|
|
20
|
+
return built(current, draw()) as Drawn
|
|
16
21
|
} finally {
|
|
17
22
|
drawing = outer
|
|
18
23
|
}
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
function element<Name extends keyof Terminal & ElementName>(name: Name): Terminal[Name] {
|
|
22
|
-
const construct = (props: object) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const construct = (props: object) => (drawing === undefined ? ({ [unbuilt]: { name, props } } satisfies Unbuilt as unknown as RenderElement) : build(drawing, name, props))
|
|
28
|
+
return construct
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function build(current: Drawing, name: ElementName, props: object): RenderElement {
|
|
32
|
+
const ready = 'children' in props ? { ...props, children: built(current, props.children) } : props
|
|
33
|
+
if (name === 'Markdown' && current.markdown !== undefined) return current.markdown(ready as MarkdownProps)
|
|
34
|
+
return current.table[name](ready)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function built(current: Drawing, value: unknown): unknown {
|
|
38
|
+
if (Array.isArray(value)) return value.map((child: unknown) => built(current, child))
|
|
39
|
+
if (typeof value !== 'object' || value === null || !(unbuilt in value)) return value
|
|
40
|
+
const { name, props } = (value as Unbuilt)[unbuilt]
|
|
41
|
+
return build(current, name, props)
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export const Box = element('Box')
|
package/src/ui/markdown.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { Nodes, RootContent } from 'mdast'
|
|
2
2
|
import { fromMarkdown, gfm, gfmFromMarkdown, toString } from '../vendor-markdown.js'
|
|
3
|
-
import type {
|
|
3
|
+
import type { PlaceProps } from './places.js'
|
|
4
4
|
|
|
5
5
|
type Block = { readonly text: string; readonly source: string }
|
|
6
6
|
|
|
7
|
-
type MarkdownSlot<Props extends object = object> = Slot<Props, { readonly source?: string }, { readonly markdown: MarkdownKind }>
|
|
8
|
-
|
|
9
7
|
type BlockFields = {
|
|
10
8
|
heading: { readonly depth: 1 | 2 | 3 | 4 | 5 | 6 }
|
|
11
9
|
code: { readonly lang: string | undefined; readonly meta: string | undefined; readonly value: string }
|
|
@@ -14,18 +12,31 @@ type BlockFields = {
|
|
|
14
12
|
html: { readonly value: string }
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
|
|
15
|
+
export type BlockProps<Type extends string> = Type extends keyof BlockFields ? Block & BlockFields[Type] : Block
|
|
18
16
|
|
|
19
17
|
export type MarkdownPiece<Render> = { readonly text: string } | { readonly render: Render; readonly props: Block }
|
|
20
18
|
|
|
21
19
|
export type MarkdownReader = <Render>(text: string, renderOf: (type: string) => Render | undefined, replyId?: string) => readonly MarkdownPiece<Render>[]
|
|
22
20
|
|
|
23
|
-
export type
|
|
21
|
+
export type MarkdownPlace<Type extends string = string> = {
|
|
24
22
|
readonly name: string
|
|
25
|
-
readonly
|
|
26
|
-
|
|
23
|
+
readonly markdown: Type
|
|
24
|
+
createReader(): MarkdownReader
|
|
27
25
|
}
|
|
28
26
|
|
|
27
|
+
function markdownPlace<const Type extends string>(name: string, markdown: Type): MarkdownPlace<Type> {
|
|
28
|
+
return { name, markdown, createReader }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const Heading = markdownPlace('Heading', 'heading')
|
|
32
|
+
export const Paragraph = markdownPlace('Paragraph', 'paragraph')
|
|
33
|
+
export const CodeBlock = markdownPlace('CodeBlock', 'code')
|
|
34
|
+
export const BlockQuote = markdownPlace('BlockQuote', 'blockquote')
|
|
35
|
+
export const List = markdownPlace('List', 'list')
|
|
36
|
+
export const Table = markdownPlace('Table', 'table')
|
|
37
|
+
export const ThematicBreak = markdownPlace('ThematicBreak', 'thematicBreak')
|
|
38
|
+
export const HtmlBlock = markdownPlace('HtmlBlock', 'html')
|
|
39
|
+
|
|
29
40
|
type Parsed = {
|
|
30
41
|
readonly type: string
|
|
31
42
|
readonly start: number
|
|
@@ -52,7 +63,7 @@ const blankLine = /\n[^\S\n]*\n/
|
|
|
52
63
|
|
|
53
64
|
const fence = /^ {0,3}(`{3}|~{3})/
|
|
54
65
|
|
|
55
|
-
function createReader(): MarkdownReader {
|
|
66
|
+
export function createReader(): MarkdownReader {
|
|
56
67
|
const replies: Reply[] = []
|
|
57
68
|
|
|
58
69
|
const remember = (reply: Reply) => {
|
|
@@ -152,22 +163,7 @@ function plainText(node: Nodes): string {
|
|
|
152
163
|
return node.children.map(plainText).join(node.type === 'tableRow' ? '\t' : '\n')
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
export const markdownSlots = {
|
|
160
|
-
Heading: markdownSlot('Heading', 'heading'),
|
|
161
|
-
Paragraph: markdownSlot('Paragraph', 'paragraph'),
|
|
162
|
-
CodeBlock: markdownSlot('CodeBlock', 'code'),
|
|
163
|
-
BlockQuote: markdownSlot('BlockQuote', 'blockquote'),
|
|
164
|
-
List: markdownSlot('List', 'list'),
|
|
165
|
-
Table: markdownSlot('Table', 'table'),
|
|
166
|
-
ThematicBreak: markdownSlot('ThematicBreak', 'thematicBreak'),
|
|
167
|
-
HtmlBlock: markdownSlot('HtmlBlock', 'html'),
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function markdownBlocks<S extends MarkdownSlot>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[] {
|
|
171
|
-
const blocks = parsed(text, 0).filter((block) => block.type === slot.markdown.type)
|
|
172
|
-
return blocks.map((block) => block.props) as unknown as Omit<SlotProps<S>, 'Default'>[]
|
|
166
|
+
export function markdownBlocks<P extends MarkdownPlace>(text: string, place: P): Omit<PlaceProps<P>, 'Default'>[] {
|
|
167
|
+
const blocks = parsed(text, 0).filter((block) => block.type === place.markdown)
|
|
168
|
+
return blocks.map((block) => block.props) as unknown as Omit<PlaceProps<P>, 'Default'>[]
|
|
173
169
|
}
|