@cmodjs/core 4.0.2 → 4.1.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 +43 -8
- package/docs/index.md +3 -3
- package/docs/install-steps.md +6 -6
- package/docs/jobs.md +2 -1
- package/docs/mod.md +1 -1
- package/docs/options.md +1 -1
- package/docs/permissions.md +1 -1
- package/docs/session.md +44 -13
- package/docs/testing.md +14 -10
- package/docs/ui.md +43 -11
- package/installer.d.ts +3 -2
- package/installer.d.ts.map +1 -1
- package/jobs/program.d.ts +1 -0
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +39 -23
- package/mod.d.ts +3 -2
- package/mod.d.ts.map +1 -1
- package/mod.js +1 -0
- package/package.json +1 -1
- package/records.d.ts +15 -1
- package/records.d.ts.map +1 -1
- package/records.js +39 -1
- package/register.d.ts.map +1 -1
- package/register.js +5 -2
- package/runtime/before.d.ts +6 -2
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +56 -18
- package/runtime/buttons.d.ts +10 -0
- package/runtime/buttons.d.ts.map +1 -0
- package/runtime/buttons.js +12 -0
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +134 -25
- package/runtime/engine.d.ts +2 -0
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.d.ts +1 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +5 -2
- package/runtime/hotkeys.d.ts.map +1 -1
- package/runtime/hotkeys.js +7 -4
- package/runtime/installer.d.ts +9 -6
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +104 -47
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +121 -40
- package/runtime/pages.d.ts +3 -1
- package/runtime/pages.d.ts.map +1 -1
- package/runtime/pages.js +34 -10
- package/runtime/parts.d.ts +1 -0
- package/runtime/parts.d.ts.map +1 -1
- package/runtime/parts.js +6 -3
- package/runtime/programs.d.ts +1 -0
- package/runtime/programs.d.ts.map +1 -1
- package/runtime/programs.js +4 -1
- package/runtime/sessions.d.ts +2 -2
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +88 -58
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +23 -9
- package/runtime/ui.d.ts +21 -4
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +158 -58
- package/runtime/watches.d.ts.map +1 -1
- package/runtime/watches.js +18 -4
- package/runtime/widgets.d.ts +7 -5
- package/runtime/widgets.d.ts.map +1 -1
- package/runtime/widgets.js +29 -20
- package/session/context/turn/message/attached-file.d.ts +13 -0
- package/session/context/turn/message/attached-file.d.ts.map +1 -0
- package/session/context/turn/message/attached-file.js +21 -0
- package/session/context/turn/message/message.d.ts +3 -2
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +20 -6
- package/session/conversation.d.ts +14 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +23 -4
- package/session/goal/task.d.ts +5 -0
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +35 -7
- package/session/session.d.ts.map +1 -1
- package/session/session.js +2 -1
- package/src/installer.ts +3 -2
- package/src/jobs/program.ts +41 -21
- package/src/mod.ts +3 -2
- package/src/records.ts +49 -1
- package/src/register.ts +9 -6
- package/src/runtime/before.ts +61 -22
- package/src/runtime/buttons.ts +22 -0
- package/src/runtime/conversations.ts +138 -32
- package/src/runtime/engine.ts +2 -0
- package/src/runtime/grants.ts +6 -2
- package/src/runtime/hotkeys.ts +7 -4
- package/src/runtime/installer.ts +144 -82
- package/src/runtime/lifecycle.ts +128 -43
- package/src/runtime/pages.ts +45 -8
- package/src/runtime/parts.ts +8 -4
- package/src/runtime/programs.ts +5 -1
- package/src/runtime/sessions.ts +83 -57
- package/src/runtime/toaster.ts +27 -9
- package/src/runtime/ui.ts +163 -61
- package/src/runtime/watches.ts +20 -5
- package/src/runtime/widgets.ts +34 -22
- package/src/session/context/turn/message/attached-file.ts +36 -0
- package/src/session/context/turn/message/message.ts +20 -7
- package/src/session/conversation.ts +30 -6
- package/src/session/goal/task.ts +6 -0
- package/src/session/session.ts +17 -7
- package/src/testing/fake-claude.ts +21 -6
- package/src/testing/fake-elements.ts +51 -8
- package/src/testing/fake-sessions.ts +60 -23
- package/src/testing.ts +86 -28
- package/src/ui/areas.ts +1 -0
- package/src/ui/components.ts +14 -8
- package/src/ui/elements.ts +6 -2
- package/src/ui/markdown.ts +3 -3
- package/src/ui/panes.ts +2 -2
- package/src/ui/styles.ts +6 -2
- package/testing/fake-claude.d.ts +3 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +18 -5
- package/testing/fake-elements.d.ts.map +1 -1
- package/testing/fake-elements.js +46 -7
- package/testing/fake-sessions.d.ts +21 -10
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +58 -20
- package/testing.d.ts +8 -12
- package/testing.d.ts.map +1 -1
- package/testing.js +82 -21
- package/ui/areas.d.ts +4 -0
- package/ui/areas.d.ts.map +1 -1
- package/ui/components.d.ts +1 -0
- package/ui/components.d.ts.map +1 -1
- package/ui/components.js +11 -7
- package/ui/elements.d.ts +3 -0
- package/ui/elements.d.ts.map +1 -1
- package/ui/elements.js +6 -1
- package/ui/markdown.d.ts +2 -2
- package/ui/markdown.d.ts.map +1 -1
- package/ui/panes.d.ts +2 -1
- package/ui/panes.d.ts.map +1 -1
- package/ui/styles.d.ts +11 -2
- package/ui/styles.d.ts.map +1 -1
|
@@ -17,7 +17,7 @@ function element(type: string) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export const elements = {
|
|
20
|
+
export const elements: ElementTable = {
|
|
21
21
|
Box: element('Box'),
|
|
22
22
|
Text: element('Text'),
|
|
23
23
|
Button: element('Button'),
|
|
@@ -27,7 +27,10 @@ export const elements = {
|
|
|
27
27
|
Input: element('Input'),
|
|
28
28
|
Select: element('Select'),
|
|
29
29
|
Image: element('Image'),
|
|
30
|
-
|
|
30
|
+
Svg: element('Svg'),
|
|
31
|
+
Raster: element('Raster'),
|
|
32
|
+
Client: element('Client'),
|
|
33
|
+
}
|
|
31
34
|
|
|
32
35
|
export function findElement(node: RenderNode, type: string, key: string): Drawn['props'] | undefined {
|
|
33
36
|
if (typeof node === 'string') return undefined
|
|
@@ -41,11 +44,12 @@ export function findElement(node: RenderNode, type: string, key: string): Drawn[
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export function rowsOf(node: RenderNode, width: number): string[] {
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
const drawn = typeof node === 'string' ? undefined : insideOf(node)
|
|
48
|
+
if (drawn?.type === 'Box') return boxRows(drawn, width)
|
|
49
|
+
if (drawn?.type === 'Svg') return []
|
|
46
50
|
return textOf(node)
|
|
47
51
|
.split('\n')
|
|
48
|
-
.flatMap((line) => fitted(line, width, wrap))
|
|
52
|
+
.flatMap((line) => fitted(line, width, drawn?.props['wrap']))
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export function textOf(node: RenderNode): string {
|
|
@@ -89,11 +93,50 @@ function boxRows({ props, children = [] }: Drawn, width: number): string[] {
|
|
|
89
93
|
const size = (key: string) => (typeof props[key] === 'number' ? props[key] : undefined)
|
|
90
94
|
const space = (side: string, axis: string) => (size(`padding${side}`) ?? size(`padding${axis}`) ?? size('padding') ?? 0) + (size(`margin${side}`) ?? size(`margin${axis}`) ?? size('margin') ?? 0)
|
|
91
95
|
const left = space('Left', 'X')
|
|
96
|
+
const right = space('Right', 'X')
|
|
97
|
+
const border = typeof props['borderStyle'] === 'string' ? 1 : 0
|
|
92
98
|
const direction = String(props['flexDirection'] ?? 'row')
|
|
93
|
-
const
|
|
94
|
-
const
|
|
99
|
+
const flowing = children.filter((child) => !isPlaced(child))
|
|
100
|
+
const ordered = direction.endsWith('-reverse') ? [...flowing].reverse() : flowing
|
|
101
|
+
const outer = Math.min(width, size('width') ?? width)
|
|
102
|
+
const inner = outer - left - right - border * 2
|
|
95
103
|
const rows = direction.startsWith('column') ? stacked(ordered, inner, size('rowGap') ?? size('gap') ?? 0) : sideBySide(ordered, inner, size('columnGap') ?? size('gap') ?? 0, props['justifyContent'] === 'space-between')
|
|
96
|
-
|
|
104
|
+
const content = border === 1 && Number.isFinite(inner) ? inner : right === 0 && border === 0 ? 0 : Math.max(widthOf(rows), (size('minWidth') ?? 0) - left - right - border * 2)
|
|
105
|
+
const padded = [...blankRows(space('Top', 'Y')), ...rows.map((row) => `${' '.repeat(left)}${paddedTo(row, content)}${' '.repeat(right)}`), ...blankRows(space('Bottom', 'Y'))]
|
|
106
|
+
const framed = border === 0 ? padded : framedRows(padded, left + content + right, props['borderStyle'])
|
|
107
|
+
return placedOver(
|
|
108
|
+
framed.map((row) => paddedTo(row, size('minWidth') ?? 0)),
|
|
109
|
+
children.filter(isPlaced),
|
|
110
|
+
outer,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isPlaced(node: RenderNode): node is RenderElement {
|
|
115
|
+
if (typeof node === 'string') return false
|
|
116
|
+
const { props } = insideOf(node)
|
|
117
|
+
return props['position'] === 'absolute' && typeof props['top'] === 'number'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function framedRows(rows: readonly string[], columns: number, style: unknown): string[] {
|
|
121
|
+
const [topLeft, topRight, bottomLeft, bottomRight] = style === 'round' ? ['╭', '╮', '╰', '╯'] : ['┌', '┐', '└', '┘']
|
|
122
|
+
return [`${topLeft}${'─'.repeat(columns)}${topRight}`, ...rows.map((row) => `│${paddedTo(row, columns)}│`), `${bottomLeft}${'─'.repeat(columns)}${bottomRight}`]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function placedOver(rows: readonly string[], placed: readonly RenderElement[], width: number): string[] {
|
|
126
|
+
const drawn = [...rows]
|
|
127
|
+
for (const node of placed) {
|
|
128
|
+
const { props } = insideOf(node)
|
|
129
|
+
const top = Number(props['top'])
|
|
130
|
+
const left = typeof props['left'] === 'number' ? props['left'] : 0
|
|
131
|
+
rowsOf(node, width - left).forEach((line, index) => {
|
|
132
|
+
const row = top + index
|
|
133
|
+
if (row < 0 || row >= drawn.length) return
|
|
134
|
+
const cells = [...paddedTo(drawn[row] ?? '', left)]
|
|
135
|
+
cells.splice(left, [...line].length, ...line)
|
|
136
|
+
drawn[row] = cells.join('')
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return drawn
|
|
97
140
|
}
|
|
98
141
|
|
|
99
142
|
function stacked(children: readonly RenderNode[], width: number, gap: number): string[] {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isObject } from '../records.js'
|
|
2
|
+
import { AttachedFile, attachmentNameOf, type AttachmentType } from '../session/context/turn/message/attached-file.js'
|
|
2
3
|
import { InstructionFile } from '../session/context/turn/message/instruction-file.js'
|
|
3
4
|
import { Message, messagesOf, noFiles, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
|
|
4
5
|
import { Note } from '../session/context/turn/message/note.js'
|
|
@@ -7,12 +8,12 @@ import { Prompt } from '../session/context/turn/message/prompt.js'
|
|
|
7
8
|
import { Reply } from '../session/context/turn/message/reply.js'
|
|
8
9
|
import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
|
|
9
10
|
import { GoalChange } from '../session/goal/goal.js'
|
|
10
|
-
import { Task } from '../session/goal/task.js'
|
|
11
|
+
import { Task, TaskStart } from '../session/goal/task.js'
|
|
11
12
|
import { Skill } from '../session/context/turn/message/skill.js'
|
|
12
13
|
import { ToolCall } from '../session/context/turn/message/tool-call.js'
|
|
13
14
|
import { ToolResult } from '../session/context/turn/message/tool-result.js'
|
|
14
|
-
import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
15
|
-
import type { Place } from '../runtime/before.js'
|
|
15
|
+
import { createConversation, emptyConversation, searchLimit, tasksWithAgents, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
|
|
16
|
+
import type { Decide, Place } from '../runtime/before.js'
|
|
16
17
|
import type { Claude } from '../runtime/claude.js'
|
|
17
18
|
import { conversationMethods, everySession, isHeardBy, resultOf, type Listened } from '../runtime/sessions.js'
|
|
18
19
|
import { receivedMatcher } from '../utils/matcher.js'
|
|
@@ -26,6 +27,7 @@ export type MessageDraft =
|
|
|
26
27
|
| { readonly type: typeof ToolResult; readonly callId: string; readonly output: string; readonly isError?: boolean }
|
|
27
28
|
| { readonly type: typeof Skill; readonly name: string; readonly text: string }
|
|
28
29
|
| { readonly type: typeof InstructionFile; readonly path: string; readonly text: string }
|
|
30
|
+
| { readonly type: typeof AttachedFile; readonly path: string; readonly attachmentType: AttachmentType; readonly text: string }
|
|
29
31
|
| { readonly type: typeof Notice; readonly text: string }
|
|
30
32
|
|
|
31
33
|
export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
|
|
@@ -45,6 +47,8 @@ type Kept = {
|
|
|
45
47
|
|
|
46
48
|
type Heard = { readonly event: Listened['event']; readonly matcher: unknown }
|
|
47
49
|
|
|
50
|
+
export type StartedTask = { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string; readonly isBackground?: true; readonly forkedFrom?: string }
|
|
51
|
+
|
|
48
52
|
|
|
49
53
|
export type FakeSessions = {
|
|
50
54
|
answer(caller: string, method: string, input: unknown): unknown
|
|
@@ -52,15 +56,15 @@ export type FakeSessions = {
|
|
|
52
56
|
draft(draft: MessageDraft, task?: string): Message
|
|
53
57
|
place(): Place
|
|
54
58
|
wants(mod: string, event: string): boolean
|
|
55
|
-
endTurn(ending?: TurnEnding): void
|
|
59
|
+
endTurn(ending?: TurnEnding, task?: string): void
|
|
56
60
|
compact(): void
|
|
57
|
-
startTask(task:
|
|
61
|
+
startTask(task: StartedTask): string
|
|
58
62
|
start(id: string): void
|
|
59
63
|
lastTurn(): TurnData | undefined
|
|
60
64
|
readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
export function fakeSessions({ project, cwd, given, push, callTool }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call'] }): FakeSessions {
|
|
67
|
+
export function fakeSessions({ project, cwd, given, push, callTool, listAgents, decide }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call']; readonly listAgents: Claude['agent']['list']; readonly decide: Decide }): FakeSessions {
|
|
64
68
|
const kept = new Map<string, Kept>()
|
|
65
69
|
const heard = new Map<string, Map<string, readonly Heard[]>>()
|
|
66
70
|
const watches = new Map<string, Map<string, readonly string[]>>()
|
|
@@ -96,6 +100,14 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
96
100
|
return session.messages.slice(before)
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
const openTask = (session: Kept, { title, agentType, model, openedBy = '', parent, isBackground, forkedFrom }: StartedTask): TaskData => {
|
|
104
|
+
const owner = forkedFrom !== undefined ? { forkedFrom } : isBackground === true ? (agentType === undefined ? {} : { agentType }) : { agentType: agentType ?? 'general-purpose' }
|
|
105
|
+
const data: TaskData = { id: `task-${(ids += 1)}`, title, ...owner, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }), ...(isBackground === true ? { isBackground } : {}) }
|
|
106
|
+
if (isBackground === true) keep({ id: data.id, title, project: session.data.project, cwd: session.data.cwd, model: model ?? session.data.model, startedAt: data.openedAt, lastAt: data.openedAt, isRunning: true, ...owner })
|
|
107
|
+
record(session, session.conversation.startTask(data))
|
|
108
|
+
return data
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
for (const each of given) {
|
|
100
112
|
const session = keep({ title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: '', isRunning: false, ...each })
|
|
101
113
|
for (const draft of each.messages ?? []) add(session, draft)
|
|
@@ -115,6 +127,8 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
115
127
|
return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
|
|
116
128
|
}
|
|
117
129
|
|
|
130
|
+
const wantsBefore = (mod: string, event: string) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event))
|
|
131
|
+
|
|
118
132
|
const taskOf = (session: Kept, id: string) => {
|
|
119
133
|
const task = session.tasks.get(id)
|
|
120
134
|
if (task === undefined) throw new Error(`Session ${session.data.id} has no task ${id}. Start it with tested.startTask.`)
|
|
@@ -172,7 +186,11 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
172
186
|
const task = taskOf(session, request['task'])
|
|
173
187
|
return [{ number: session.conversation.state.tasks[task.id]?.context ?? 1, openedAt: task.openedAt, ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }) }]
|
|
174
188
|
},
|
|
175
|
-
[conversationMethods.tasks]
|
|
189
|
+
async [conversationMethods.tasks](request) {
|
|
190
|
+
const session = String(request['session'])
|
|
191
|
+
const tasks = [...sessionOf(session).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task'])
|
|
192
|
+
return session === current ? tasksWithAgents(tasks, await listAgents()) : tasks
|
|
193
|
+
},
|
|
176
194
|
[conversationMethods.goal](request) {
|
|
177
195
|
const session = sessionOf(String(request['session']))
|
|
178
196
|
const task = request['task']
|
|
@@ -196,7 +214,7 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
196
214
|
watches.set(caller, targets)
|
|
197
215
|
return null
|
|
198
216
|
},
|
|
199
|
-
async [conversationMethods.act](request) {
|
|
217
|
+
async [conversationMethods.act](request, caller) {
|
|
200
218
|
const session = sessionOf(String(request['session']))
|
|
201
219
|
const action = String(request['action'])
|
|
202
220
|
const input = isObject(request['input']) ? request['input'] : {}
|
|
@@ -214,11 +232,24 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
214
232
|
sent.push({ session: session.data.id, ...(task === undefined ? {} : { task }), type: String(input['type']), text: String(input['text']), ...(typeof input['as'] === 'string' ? { as: input['as'] } : {}) })
|
|
215
233
|
return null
|
|
216
234
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
235
|
+
const text = (key: string) => (typeof input[key] === 'string' ? input[key] : undefined)
|
|
236
|
+
const title = text('title') ?? ''
|
|
237
|
+
if (action === 'startTask' || action === 'fork' || action === 'startBackgroundTask' || action === 'clone') {
|
|
238
|
+
if (title.trim() === '') throw new Error('a task needs a title.')
|
|
239
|
+
const isClone = action === 'clone'
|
|
240
|
+
const isBackground = isClone || action === 'startBackgroundTask'
|
|
241
|
+
const forkedFrom = isClone || action === 'fork' ? session.data.id : undefined
|
|
242
|
+
const agentType = forkedFrom === undefined ? text('agentType') : undefined
|
|
243
|
+
const model = forkedFrom === undefined ? text('model') : undefined
|
|
244
|
+
const fresh = { ...(agentType === undefined ? {} : { agentType }), ...(model === undefined ? {} : { model }), ...(forkedFrom === undefined ? {} : { forkedFrom }) }
|
|
245
|
+
if (!isBackground) return openTask(session, { title, ...fresh })
|
|
246
|
+
if (input['name'] !== undefined) throw new Error('a background task takes no name.')
|
|
247
|
+
const asked: TaskData = { id: '', title, ...fresh, openedBy: '', openedAt: now(), isBackground: true }
|
|
248
|
+
const answer = wantsBefore(caller, TaskStart) ? await decide(TaskStart, { type: TaskStart, task: { ...asked, context: session.conversation.state.context } }) : undefined
|
|
249
|
+
if (answer?.refuse !== undefined) throw new Error(`a beforeTaskStart listener refused the task: ${answer.refuse}`)
|
|
250
|
+
const owner = forkedFrom === undefined ? (answer?.owner ?? {}) : {}
|
|
251
|
+
const chosen = { ...(owner.agentType === undefined ? {} : { agentType: owner.agentType }), ...(owner.model === undefined ? {} : { model: owner.model }) }
|
|
252
|
+
return openTask(session, { ...fresh, ...chosen, title: answer?.title ?? title, isBackground: true })
|
|
222
253
|
}
|
|
223
254
|
if (session.data.id === current) return resultOf(place(), { name: String(input['name']), input: input['input'] as Readonly<Record<string, unknown>>, cwd: project() }, await callTool({ ...input['input'] as object, tool: String(input['name']) } as never))
|
|
224
255
|
return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', name: '', input: {}, cwd: project(), files: noFiles, output: '', isError: false }
|
|
@@ -242,7 +273,10 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
242
273
|
return { deny: messageOf(error) }
|
|
243
274
|
}
|
|
244
275
|
},
|
|
245
|
-
add
|
|
276
|
+
add(draft, task) {
|
|
277
|
+
const session = currentSession()
|
|
278
|
+
return task !== undefined && session.tasks.get(task)?.isBackground === true ? add(sessionOf(task), draft) : add(session, draft, task)
|
|
279
|
+
},
|
|
246
280
|
draft(draft, task) {
|
|
247
281
|
const where = place()
|
|
248
282
|
const [unplaced] = messagesOf(rowOf(draft, `message-${(ids += 1)}`, now(), cwd()), task !== undefined)
|
|
@@ -252,21 +286,23 @@ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly
|
|
|
252
286
|
return { ...unplaced, ...call, session: where.session, context: task === undefined ? where.context : (where.tasks[task]?.context ?? where.context), ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) }
|
|
253
287
|
},
|
|
254
288
|
place,
|
|
255
|
-
wants:
|
|
256
|
-
endTurn(ending = {}) {
|
|
289
|
+
wants: wantsBefore,
|
|
290
|
+
endTurn(ending = {}, task) {
|
|
257
291
|
const session = currentSession()
|
|
258
|
-
|
|
292
|
+
const background = task === undefined ? undefined : session.tasks.get(task)
|
|
293
|
+
if (background?.isBackground !== true) {
|
|
294
|
+
record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }, task))
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const owner = sessionOf(background.id)
|
|
298
|
+
record(owner, owner.conversation.endTurn(now(), { reason: 'answer', ...ending }))
|
|
299
|
+
record(session, session.conversation.endTask(background, now()))
|
|
259
300
|
},
|
|
260
301
|
compact() {
|
|
261
302
|
const session = currentSession()
|
|
262
303
|
record(session, session.conversation.add({ id: `compact-${(ids += 1)}`, at: now(), type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }))
|
|
263
304
|
},
|
|
264
|
-
startTask(
|
|
265
|
-
const session = currentSession()
|
|
266
|
-
const id = `task-${(ids += 1)}`
|
|
267
|
-
record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }) }))
|
|
268
|
-
return id
|
|
269
|
-
},
|
|
305
|
+
startTask: (task) => openTask(currentSession(), task).id,
|
|
270
306
|
start(id) {
|
|
271
307
|
current = id
|
|
272
308
|
const session = kept.get(id) ?? keep({ id, title: '', project: project(), cwd: project(), model: 'test-model', startedAt: now(), lastAt: now(), isRunning: true })
|
|
@@ -289,5 +325,6 @@ function rowOf(draft: MessageDraft, id: string, at: string, cwd: string): Row {
|
|
|
289
325
|
if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.output, ...(draft.isError === true ? { is_error: true } : {}) }] }
|
|
290
326
|
if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
|
|
291
327
|
if (draft.type === InstructionFile) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
|
|
328
|
+
if (draft.type === AttachedFile) return { ...base, type: 'attachment', name: attachmentNameOf(draft.attachmentType), content: text(draft.text), path: draft.path }
|
|
292
329
|
return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
|
|
293
330
|
}
|
package/src/testing.ts
CHANGED
|
@@ -4,8 +4,10 @@ import type {
|
|
|
4
4
|
CmodDependencies,
|
|
5
5
|
CommandPresentation,
|
|
6
6
|
CommandRunResult,
|
|
7
|
+
ElementTable,
|
|
7
8
|
EventResult,
|
|
8
9
|
Frozen,
|
|
10
|
+
MarkdownProps,
|
|
9
11
|
PaneOpenArgs,
|
|
10
12
|
PluginOptions,
|
|
11
13
|
PressedLink,
|
|
@@ -23,7 +25,7 @@ import type { Options, OptionValues } from './options.js'
|
|
|
23
25
|
import { hotkeyCommand, readSteps, storeFolder, type Steps } from './records.js'
|
|
24
26
|
import type { Claude } from './runtime/claude.js'
|
|
25
27
|
import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
|
|
26
|
-
import { closingContext, foldBefore, installBefore, withAnswer, type Answers } from './runtime/before.js'
|
|
28
|
+
import { closingContext, foldBefore, installBefore, withAnswer, type Answers, type Decide } from './runtime/before.js'
|
|
27
29
|
import { permissionRoutes } from './runtime/permission-check.js'
|
|
28
30
|
import type { RoutedEvent } from './runtime/hooks.js'
|
|
29
31
|
import { keepClaudeCode, type ClaudeCode } from './testing/claude-code.js'
|
|
@@ -45,19 +47,19 @@ import { TaskStart } from './session/goal/task.js'
|
|
|
45
47
|
import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
|
|
46
48
|
import { elements, findElement, rowsOf } from './testing/fake-elements.js'
|
|
47
49
|
import { fakeFiles } from './testing/fake-files.js'
|
|
48
|
-
import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
|
|
50
|
+
import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft, type StartedTask } from './testing/fake-sessions.js'
|
|
49
51
|
import { listed, messageOf } from './utils/text.js'
|
|
50
|
-
import { componentOf, type Row, type
|
|
52
|
+
import { componentOf, type KindProps, type Row, type StyledAt } from './ui/styles.js'
|
|
51
53
|
import { areas, TurnEnd as TurnEndArea, type Area } from './ui/areas.js'
|
|
52
54
|
import { drawWith, elementOf } from './ui/elements.js'
|
|
53
55
|
import { drawnParts } from './runtime/parts.js'
|
|
54
56
|
import { serveWidgets, type Widgets } from './runtime/widgets.js'
|
|
55
57
|
import { engineOf } from './runtime/engine.js'
|
|
56
|
-
import { pagesOf } from './runtime/pages.js'
|
|
58
|
+
import { pagesOf, servePages } from './runtime/pages.js'
|
|
57
59
|
import { partMethods, type DrawnPart, type PageRequest, type PartPress, type PartsUpdate } from './runtime/ui.js'
|
|
58
60
|
|
|
59
61
|
export type { Fakes, TestCall } from './testing/fake-claude.js'
|
|
60
|
-
export type { FakeSession, MessageDraft } from './testing/fake-sessions.js'
|
|
62
|
+
export type { FakeSession, MessageDraft, StartedTask } from './testing/fake-sessions.js'
|
|
61
63
|
export type { RoutedEvent } from './runtime/hooks.js'
|
|
62
64
|
export type { ShownToast } from './runtime/toaster.js'
|
|
63
65
|
|
|
@@ -116,12 +118,12 @@ export type TestedMod<State extends object> = {
|
|
|
116
118
|
fire<N extends FiredEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
|
|
117
119
|
settle(): Promise<void>
|
|
118
120
|
add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
|
|
119
|
-
endTurn(ending?: Pick<Turn, 'answer' | 'failure'
|
|
121
|
+
endTurn(ending?: Pick<Turn, 'answer' | 'failure'>, options?: { readonly task?: string }): Promise<void>
|
|
120
122
|
compact(): Promise<void>
|
|
121
123
|
startSession(start: { readonly source: 'startup' | 'resume' | 'clear' | 'fork'; readonly id?: string }): Promise<void>
|
|
122
|
-
startTask(task:
|
|
124
|
+
startTask(task: StartedTask): Promise<string>
|
|
123
125
|
lines(area: Area, props?: { readonly columns?: number }): Promise<string[]>
|
|
124
|
-
lines<K extends Row>(kind: K, props:
|
|
126
|
+
lines<K extends Row>(kind: K, props: KindProps<K>, requestId?: string): Promise<string[]>
|
|
125
127
|
type(line: string): Promise<CommandReply>
|
|
126
128
|
hotkey(name: string): Promise<void>
|
|
127
129
|
callTool(name: string, input: Record<string, unknown>): Promise<EventResult<'tool.call'>>
|
|
@@ -159,9 +161,11 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
159
161
|
fake.claude.session.cwd = async () => cwd
|
|
160
162
|
const conversations = fakeSessions({
|
|
161
163
|
callTool: (args: Parameters<Claude["tool"]["call"]>[0]) => fake.claude.tool.call(args),
|
|
164
|
+
listAgents: () => fake.fakes.agent.list?.() ?? Promise.resolve([]),
|
|
162
165
|
project: () => projectRoot,
|
|
163
166
|
cwd: () => cwd,
|
|
164
167
|
given: options.sessions ?? [],
|
|
168
|
+
decide: (event, change) => decide(event, change),
|
|
165
169
|
push: (mod, session, change) => {
|
|
166
170
|
if (mod !== name) return
|
|
167
171
|
const pushed = { to: mod, method: conversationMethods.changed, input: { session, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
|
|
@@ -191,33 +195,61 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
191
195
|
await tellShown()
|
|
192
196
|
}
|
|
193
197
|
let widgets: Promise<Widgets> | undefined
|
|
198
|
+
let stylesMarkdown = false
|
|
199
|
+
const ownMarkdown = new Map<string, DrawnPart | null>()
|
|
200
|
+
const missedMarkdown = new Map<string, { readonly markdown: string } & StyledAt>()
|
|
194
201
|
const updated = async ({ parts, redraw, requests }: PartsUpdate) => {
|
|
195
|
-
if (parts !== undefined)
|
|
202
|
+
if (parts !== undefined) {
|
|
203
|
+
declaredPages = pagesOf(parts)
|
|
204
|
+
stylesMarkdown = parts.stylesMarkdown === true
|
|
205
|
+
ownMarkdown.clear()
|
|
206
|
+
}
|
|
196
207
|
if (parts !== undefined || redraw !== undefined) (await widgetsOf()).update(name, { ...(parts === undefined ? {} : { parts }), ...(redraw === undefined ? {} : { redraw }) })
|
|
197
208
|
if (requests !== undefined && requests.length > 0) await applyRequests(requests)
|
|
198
209
|
}
|
|
199
210
|
const pressPart = (input: PartPress) => callMod(partMethods.press, input).then((update) => updated((update ?? {}) as PartsUpdate))
|
|
211
|
+
const markdownIn = (table: ElementTable, at: StyledAt) => (props: MarkdownProps): RenderElement => {
|
|
212
|
+
const drawnAt = `${at.surface}\u0000${at.columns}\u0000${props.text}`
|
|
213
|
+
const drawn = stylesMarkdown ? ownMarkdown.get(drawnAt) : null
|
|
214
|
+
if (drawn === undefined) missedMarkdown.set(drawnAt, { markdown: props.text, ...at })
|
|
215
|
+
if (drawn === undefined || drawn === null || drawn.tree === null) return table.Markdown(props)
|
|
216
|
+
const { draw, tree } = drawn
|
|
217
|
+
return drawWith(table, () => elementOf(tree, '', (handler, args) => void pressPart({ draw, handler, args }))) as RenderElement
|
|
218
|
+
}
|
|
219
|
+
const withOwnMarkdown = async <Drawn>(draw: () => Promise<Drawn>): Promise<Drawn> => {
|
|
220
|
+
const first = await draw()
|
|
221
|
+
if (missedMarkdown.size === 0) return first
|
|
222
|
+
for (const [drawnAt, input] of missedMarkdown) ownMarkdown.set(drawnAt, (await callMod(partMethods.draw, input)) as DrawnPart | null)
|
|
223
|
+
missedMarkdown.clear()
|
|
224
|
+
return draw()
|
|
225
|
+
}
|
|
226
|
+
const cmodClaude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
|
|
227
|
+
const reportPart = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
|
|
228
|
+
const parts = drawnParts({ claude: cmodClaude, report: reportPart, press: (_mod: string, input: PartPress) => void pressPart(input).catch((error: unknown) => fake.claude.ui.log(`cmod: ${name} did not take the press: ${messageOf(error)}`, { to: 'debug' })) })
|
|
200
229
|
const servedWidgets = async (): Promise<Widgets> => {
|
|
201
230
|
const own = name === cmodPluginName && lifecycle.mod !== undefined ? engineOf(lifecycle.mod).widgets : undefined
|
|
202
231
|
if (own !== undefined) return own
|
|
203
|
-
const claude: Claude = { ...fake.claude, cmod: { call: async (input) => (input.to === name ? callMod(input.method, input.input) : fake.claude.cmod.call(input)) } }
|
|
204
|
-
const report = (_id: string, message: string, error: unknown) => fake.claude.ui.log(`cmod: ${message}: ${messageOf(error)}`, { to: 'debug' })
|
|
205
|
-
const press = (_mod: string, input: PartPress) => void pressPart(input).catch((error: unknown) => fake.claude.ui.log(`cmod: ${name} did not take the press: ${messageOf(error)}`, { to: 'debug' }))
|
|
206
232
|
return serveWidgets({
|
|
207
|
-
claude,
|
|
233
|
+
claude: cmodClaude,
|
|
208
234
|
router: cmodPlugin,
|
|
209
235
|
store: storeFolder({ HOME: await fake.claude.env.home(), XDG_DATA_HOME: await fake.claude.env.dataHome() }),
|
|
210
|
-
report,
|
|
236
|
+
report: reportPart,
|
|
211
237
|
turnClosedBy: () => conversations.lastTurn(),
|
|
212
|
-
parts
|
|
238
|
+
parts,
|
|
239
|
+
markdownIn,
|
|
213
240
|
})
|
|
214
241
|
}
|
|
215
242
|
const widgetsOf = () => (widgets ??= servedWidgets())
|
|
243
|
+
const cmodBeneath = createRouter()
|
|
244
|
+
const pages = servePages({ claude: cmodClaude, router: cmodBeneath, report: reportPart, widgets: { update: (mod, update) => void widgetsOf().then((served) => served.update(mod, update)) }, parts, tellStylers: () => undefined })
|
|
245
|
+
pages.apply((_mod, requests) => applyRequests(requests))
|
|
216
246
|
const dependencies = fakeDependencies(name, options.dependencies ?? {}, conversations)
|
|
217
247
|
fake.fakes.cmod.call = async (call) => {
|
|
218
248
|
if (call.to !== cmodPluginName || call.method !== partMethods.changed) return dependencies(call)
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
const { requests, isAsked, ...listing } = call.input as PartsUpdate
|
|
250
|
+
const isHeld = isAsked === true && requests !== undefined && pages.update(name, { requests, isAsked })
|
|
251
|
+
await updated(isAsked === true ? listing : (call.input as PartsUpdate))
|
|
252
|
+
return { isHeld }
|
|
221
253
|
}
|
|
222
254
|
const appendRow = fake.claude.session.append
|
|
223
255
|
fake.claude.session.append = async (args) => {
|
|
@@ -274,39 +306,61 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
274
306
|
const asked = { to: name, method: conversationMethods.before, input: { session: sessionId, event, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
|
|
275
307
|
return calledAnswer(cmodPluginName, await lifecycle.route('cmod.call', asked, async () => ({ deny: notInstalled(name) })))
|
|
276
308
|
}
|
|
309
|
+
const decide: Decide = (event, change) => foldBefore(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' }))
|
|
277
310
|
installBefore({
|
|
278
311
|
claude: fake.claude,
|
|
279
312
|
router: cmodPlugin,
|
|
280
313
|
hooks: {
|
|
281
314
|
place: async () => conversations.place(),
|
|
282
315
|
wants: async (event) => conversations.wants(name, event),
|
|
283
|
-
fold: () =>
|
|
316
|
+
fold: () => decide,
|
|
284
317
|
},
|
|
285
318
|
})
|
|
286
319
|
|
|
287
320
|
const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
|
|
288
321
|
await start()
|
|
289
322
|
if (event === 'ui.render') {
|
|
323
|
+
const { component, requestId } = input as Args<'ui.render'>
|
|
324
|
+
if (component === 'Pane') fake.placePane(requestId)
|
|
290
325
|
await widgetsOf()
|
|
291
326
|
await fake.settle()
|
|
292
327
|
}
|
|
293
|
-
return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (
|
|
328
|
+
return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (await claudeCodeRuns(event, e, below)) as EventResult<N>))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const claudeCodeRuns = async (event: RoutedEvent, e: unknown, below: unknown): Promise<unknown> => {
|
|
332
|
+
if (event === 'session.compact' && (e as Args<'session.compact'>).trigger !== 'precompute') {
|
|
333
|
+
const started = await fire('SessionStart', { source: 'compact' })
|
|
334
|
+
fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
|
|
335
|
+
}
|
|
336
|
+
if (below !== undefined) return below
|
|
337
|
+
const answer = claudeCodeAnswer(event, e)
|
|
338
|
+
if (event === 'ui.close') {
|
|
339
|
+
const { id, origin } = e as Args<'ui.close'>
|
|
340
|
+
if (origin.kind === 'person' && fake.shown.openPanes.has(id)) await drawPane(id)
|
|
341
|
+
fake.shown.openPanes.delete(id)
|
|
342
|
+
}
|
|
343
|
+
if (event === 'agent.spawn') {
|
|
344
|
+
const started = await route('classic.SubagentStart', { hook_event_name: 'SubagentStart', agent_id: (answer as { agentId: string }).agentId, agent_type: (e as Args<'agent.spawn'>).subagentType }, undefined)
|
|
345
|
+
fake.shown.notes.push(...(started.additionalContext ?? []).map(untagged))
|
|
346
|
+
}
|
|
347
|
+
return answer
|
|
294
348
|
}
|
|
295
349
|
|
|
296
350
|
const runCommand = async (command: string, args: string): Promise<CommandRunResult> => {
|
|
297
351
|
await start()
|
|
298
352
|
const reached: Set<string> = new Set()
|
|
299
353
|
const ownRun = async (): Promise<CommandRunResult> => (reached.add(command), {})
|
|
300
|
-
const answer = await cmodPlugin.dispatch('command.run', { command, args, origin: { kind: 'composer' }, presentation }, (passed) => lifecycle.route('command.run', passed, ownRun))
|
|
354
|
+
const answer = await cmodPlugin.dispatch('command.run', { command, args, origin: { kind: 'composer' }, presentation }, (passed) => lifecycle.route('command.run', passed, (e) => cmodBeneath.dispatch('command.run', e, ownRun)))
|
|
301
355
|
if (reached.has(command) && answer.text === undefined) throw new Error(!fake.shown.commands.includes(command) ? `${name} has no slash command /${command}. Add it in setup with mod.use(slashCommand({ name: '${command}', … })).` : `${name}: /${command} reached Claude Code's own run of the command, so Claude Code shows "${name} registered /${command} but no command.run hook answered it". Answer it without calling next(e).`)
|
|
302
356
|
return answer
|
|
303
357
|
}
|
|
304
358
|
|
|
305
|
-
const drawPane = async (paneId: string) => {
|
|
359
|
+
const drawPane = async (paneId: string, asked?: number) => {
|
|
306
360
|
await start()
|
|
307
361
|
if (!fake.shown.openPanes.has(paneId)) throw new Error(`The pane "${paneId}" of ${name} closed, and a user sees a pane only while it is open.`)
|
|
308
362
|
const opened = fake.calls.findLast((call) => call.call === 'ui.open' && (call.args[0] as PaneOpenArgs).id === paneId)?.args[0] as PaneOpenArgs | undefined
|
|
309
|
-
const columns = opened?.columns ?? defaultColumns
|
|
363
|
+
const columns = asked ?? opened?.columns ?? defaultColumns
|
|
310
364
|
const isFocused = (await fake.claude.ui.panes()).some((open) => open.id === paneId && open.isFocused)
|
|
311
365
|
const props: RenderPropsOf['Pane'] = { title: opened?.title ?? paneId, isFocused, bodyColumns: columns, placement: 'dock', scroll: { offset: 0, bodyRows: defaultRows }, view: {} }
|
|
312
366
|
const drawing = await route('ui.render', { surface: 'terminal', component: 'Pane', requestId: paneId, props }, elements.Box({}))
|
|
@@ -320,7 +374,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
320
374
|
const drawn = (await callMod(partMethods.draw, { page: pageId, params: page.params, columns, isFocused: page.isFocused })) as DrawnPart | null
|
|
321
375
|
await fake.settle()
|
|
322
376
|
const tree = drawn?.tree ?? null
|
|
323
|
-
const drawing = tree === null || drawn === null ? elements.Box({}) : (drawWith(elements, () => elementOf(tree, '', (handler, args) => void pressPart({ draw: drawn.draw, handler, args }))) as RenderElement)
|
|
377
|
+
const drawing = tree === null || drawn === null ? elements.Box({}) : await withOwnMarkdown(async () => drawWith(elements, () => elementOf(tree, '', (handler, args) => void pressPart({ draw: drawn.draw, handler, args })), markdownIn(elements, { surface: 'terminal', columns })) as RenderElement)
|
|
324
378
|
return { drawing, columns }
|
|
325
379
|
}
|
|
326
380
|
|
|
@@ -339,7 +393,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
339
393
|
const draw = async (columns?: number) => {
|
|
340
394
|
await start()
|
|
341
395
|
const { id } = shownNow(isPane)
|
|
342
|
-
return { id, ...(isPane ? await drawPane(id) : await drawPage(id, columns)) }
|
|
396
|
+
return { id, ...(isPane ? await drawPane(id, columns) : await drawPage(id, columns)) }
|
|
343
397
|
}
|
|
344
398
|
const drawnElement = async (type: 'Button' | 'Input' | 'Select' | 'Markdown', key: string) => {
|
|
345
399
|
const { id, drawing } = await draw()
|
|
@@ -368,7 +422,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
368
422
|
else await (props['onLinkPress'] as (link: PressedLink, e: Frozen<UiPressArgument>) => unknown)(e.link, e)
|
|
369
423
|
return { element: e.element }
|
|
370
424
|
}
|
|
371
|
-
if (isPane) await lifecycle.route('ui.press', pressed, run)
|
|
425
|
+
if (isPane) await lifecycle.route('ui.press', pressed, (e) => cmodBeneath.dispatch('ui.press', e, run))
|
|
372
426
|
else await run(pressed)
|
|
373
427
|
await fake.settle()
|
|
374
428
|
},
|
|
@@ -397,7 +451,7 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
397
451
|
await fake.settle()
|
|
398
452
|
const turn = area === TurnEndArea ? conversations.lastTurn() : undefined
|
|
399
453
|
if (area === TurnEndArea && turn?.closedAt === undefined) throw new Error(`${name} has no ended turn, so TurnEnd draws nothing. End one with tested.add({ type: Prompt, text: '…' }), then tested.endTurn().`)
|
|
400
|
-
return { drawing: elements.Box({ flexDirection: 'column', children: [...(await served.draw(area, elements, columns, turn))] }), columns }
|
|
454
|
+
return { drawing: elements.Box({ flexDirection: 'column', children: [...(await withOwnMarkdown(() => served.draw(area, elements, { surface: 'terminal', columns }, turn)))] }), columns }
|
|
401
455
|
}
|
|
402
456
|
|
|
403
457
|
const drawSlot = async (component: RenderComponent, props: object, requestId = `${component}_${(slotDraws += 1)}`) => {
|
|
@@ -500,9 +554,9 @@ export function testMod<State extends object, Declared extends Options = Options
|
|
|
500
554
|
await start()
|
|
501
555
|
return settled(conversations.add(message, added?.task))
|
|
502
556
|
},
|
|
503
|
-
endTurn: async (ending) => {
|
|
557
|
+
endTurn: async (ending, options) => {
|
|
504
558
|
await start()
|
|
505
|
-
await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }))
|
|
559
|
+
await settled(conversations.endTurn(ending === undefined ? undefined : { ...ending, ...(ending.failure === undefined ? {} : { reason: 'error' as const }) }, options?.task))
|
|
506
560
|
},
|
|
507
561
|
compact: async () => {
|
|
508
562
|
await start()
|
|
@@ -617,6 +671,10 @@ function claudeCodeAnswer(event: RoutedEvent, e: unknown): unknown {
|
|
|
617
671
|
const { model, parentModel } = e as Args<'agent.spawn'>
|
|
618
672
|
return { agentId: 'agent-1', model: model ?? parentModel }
|
|
619
673
|
}
|
|
674
|
+
if (event === 'session.append') {
|
|
675
|
+
const { message, uuid } = e as Args<'session.append'>
|
|
676
|
+
return { message, uuid }
|
|
677
|
+
}
|
|
620
678
|
if (event !== 'prompt.submit') return {}
|
|
621
679
|
const { text, context, origin } = e as Args<'prompt.submit'>
|
|
622
680
|
return { text, ...(context === undefined ? {} : { context }), origin }
|
package/src/ui/areas.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type WidgetInit<State extends object = Record<never, never>> = {
|
|
|
17
17
|
readonly id: string
|
|
18
18
|
readonly title: string
|
|
19
19
|
readonly areas: readonly [Area, ...Area[]]
|
|
20
|
+
readonly place?: { readonly side?: 'left' | 'right'; readonly size?: 'fit' | 'fill' }
|
|
20
21
|
render(mod: Mod<State>, context: WidgetContext): RenderElement | undefined
|
|
21
22
|
}
|
|
22
23
|
|
package/src/ui/components.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { RenderChildren, RenderElement } from 'claude-code'
|
|
2
2
|
import { Box, Button, Text } from './elements.js'
|
|
3
3
|
|
|
4
|
-
export type Tab = { readonly key: string; readonly label: string }
|
|
4
|
+
export type Tab = { readonly key: string; readonly label: string; readonly hotkey?: string }
|
|
5
5
|
|
|
6
6
|
export function Tabs({ tabs, selected, onSelect, children }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void; readonly children?: RenderChildren }): RenderElement {
|
|
7
|
-
const row = tabs.map((
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
})
|
|
7
|
+
const row = tabs.map(({ key, label, hotkey }) =>
|
|
8
|
+
key === selected
|
|
9
|
+
? Text({ children: [...(hotkey === undefined ? [] : [Text({ color: 'claude', children: hotkey }), ': ']), Text({ bold: true, underline: true, children: label })] })
|
|
10
|
+
: Button({ plain: true, dimColor: true, key: `tab:${key}`, ...(hotkey === undefined ? {} : { hotkey }), label, onPress: () => onSelect(key) }),
|
|
11
|
+
)
|
|
13
12
|
return Box({ flexDirection: 'column', gap: 1, children: [Box({ gap: 3, children: row }), children] })
|
|
14
13
|
}
|
|
15
14
|
|
|
@@ -19,7 +18,14 @@ export function Split({ children }: { readonly children: readonly RenderElement[
|
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export function Panel({ title, children }: { readonly title: string; readonly children?: RenderChildren }): RenderElement {
|
|
22
|
-
return Box({
|
|
21
|
+
return Box({
|
|
22
|
+
flexDirection: 'column',
|
|
23
|
+
flexGrow: 1,
|
|
24
|
+
children: [
|
|
25
|
+
Box({ flexDirection: 'column', flexGrow: 1, minWidth: [...title].length + 6, borderStyle: 'round', borderColor: 'subtle', paddingX: 1, children }),
|
|
26
|
+
Box({ position: 'absolute', top: 0, left: 2, children: Text({ bold: true, wrap: 'truncate-end', children: ` ${title} ` }) }),
|
|
27
|
+
],
|
|
28
|
+
})
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export function Toggle({ label, checked, onChange }: { readonly label: string; readonly checked: boolean; readonly onChange: (checked: boolean) => void }): RenderElement {
|
package/src/ui/elements.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ElementName, ElementTable, Elements, MarkdownProps, RenderElement } from 'claude-code'
|
|
2
2
|
|
|
3
|
-
type Terminal = Elements['terminal']
|
|
3
|
+
type Terminal = Elements['terminal'] & Elements['desktop']
|
|
4
4
|
|
|
5
5
|
type CompletedTable = Record<ElementName, (props: object) => RenderElement>
|
|
6
6
|
|
|
@@ -53,6 +53,7 @@ export function elementData(value: unknown, handlers: Handler[]): unknown {
|
|
|
53
53
|
if (typeof value !== 'object' || value === null) return value
|
|
54
54
|
if (unbuilt in value) {
|
|
55
55
|
const { name, props } = (value as Unbuilt)[unbuilt]
|
|
56
|
+
if (name === 'Client') throw new Error('Client draws only in panes and styles.')
|
|
56
57
|
return { [elementKey]: name, props: elementData(props, handlers) }
|
|
57
58
|
}
|
|
58
59
|
if (Object.getPrototypeOf(value) !== Object.prototype) throw new Error('a widget draws only the elements of ui/elements.js and ui/components.js, text, numbers, and plain objects.')
|
|
@@ -83,5 +84,8 @@ export const Markdown = element('Markdown')
|
|
|
83
84
|
export const Input = element('Input')
|
|
84
85
|
export const Select = element('Select')
|
|
85
86
|
export const Image = element('Image')
|
|
87
|
+
export const Svg = element('Svg')
|
|
88
|
+
export const Raster = element('Raster')
|
|
89
|
+
export const Client = element('Client')
|
|
86
90
|
|
|
87
|
-
const constructors = { Box, Text, Button, Link, Code, Markdown, Input, Select, Image }
|
|
91
|
+
const constructors = { Box, Text, Button, Link, Code, Markdown, Input, Select, Image, Svg, Raster }
|