@leviyuan/lodestar 0.2.8 → 0.2.9
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/README.md +48 -0
- package/daemon.ts +2 -0
- package/package.json +1 -1
- package/src/cards/console.ts +352 -0
- package/src/cards/elements.ts +22 -0
- package/src/cards/turn.ts +530 -0
- package/src/cards.ts +29 -795
- package/src/claude-process.ts +26 -4
- package/src/config.ts +16 -1
- package/src/feishu.ts +14 -47
- package/src/notify.ts +132 -0
- package/src/session-ask.ts +165 -0
- package/src/session-permission.ts +136 -0
- package/src/session-tools.ts +233 -0
- package/src/session-types.ts +91 -0
- package/src/session.ts +173 -642
- package/src/sysinfo.ts +273 -0
package/src/cards.ts
CHANGED
|
@@ -1,798 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema 2.0 Feishu card templates.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Schema 2.0 Feishu card templates — barrel re-export. Each call site
|
|
3
|
+
* uses `import * as cards from './cards'` and reaches everything through
|
|
4
|
+
* this file. Internal split so each module stays under Claude Code's
|
|
5
|
+
* per-read token budget:
|
|
6
|
+
* - cards/elements.ts — ELEMENTS (shared element-id helpers)
|
|
7
|
+
* - cards/turn.ts — main turn card, tool panels, ask panels, todos
|
|
8
|
+
* - cards/console.ts — console + menu cards, formatters,
|
|
9
|
+
* streamingOffSettings
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
subject?: string
|
|
34
|
-
description?: string
|
|
35
|
-
status: 'pending' | 'in_progress' | 'completed' | string
|
|
36
|
-
owner?: string
|
|
37
|
-
activeForm?: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function todoStatusIcon(s: string): string {
|
|
41
|
-
switch (s) {
|
|
42
|
-
case 'pending': return '☐'
|
|
43
|
-
case 'in_progress': return '🔄'
|
|
44
|
-
case 'completed': return '✅'
|
|
45
|
-
default: return '·'
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Render the session's full todo mirror as a markdown list. Empty list
|
|
50
|
-
* yields '' so callers can unconditionally concat. Sorted by numeric id
|
|
51
|
-
* so the order matches creation order regardless of Map iteration. */
|
|
52
|
-
function renderTodoList(todos: Todo[]): string {
|
|
53
|
-
if (!todos || todos.length === 0) return ''
|
|
54
|
-
const sorted = [...todos].sort((a, b) => a.id - b.id)
|
|
55
|
-
const lines = ['', '---', `**📋 当前任务清单(${sorted.length} 项)**`, '']
|
|
56
|
-
for (const t of sorted) {
|
|
57
|
-
const icon = todoStatusIcon(t.status)
|
|
58
|
-
const subject = t.subject ?? '(无 subject)'
|
|
59
|
-
const ownerTag = t.owner ? ` · ${t.owner}` : ''
|
|
60
|
-
lines.push(`- ${icon} **#${t.id}** ${subject}${ownerTag}`)
|
|
61
|
-
}
|
|
62
|
-
return lines.join('\n')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Single-line summary used as a collapsible-panel header for a tool call. */
|
|
66
|
-
export function summarizeToolInput(name: string, input: any): string {
|
|
67
|
-
if (!input || typeof input !== 'object') return ''
|
|
68
|
-
const truncate = (s: string, n: number) => s.length > n ? s.slice(0, n) + '…' : s
|
|
69
|
-
// Task workflow tools (TaskCreate / TaskUpdate / TaskList / ...) carry
|
|
70
|
-
// structured fields that summarize much better as natural language than
|
|
71
|
-
// as truncated JSON. Routed first so they don't fall through to the
|
|
72
|
-
// generic Agent/Task case below.
|
|
73
|
-
if (name.startsWith('Task') && name !== 'Task') {
|
|
74
|
-
return truncate(summarizeTaskWorkflow(name, input), 80)
|
|
75
|
-
}
|
|
76
|
-
switch (name) {
|
|
77
|
-
case 'Bash': return truncate(String(input.description ?? input.command ?? ''), 80)
|
|
78
|
-
case 'Read':
|
|
79
|
-
case 'Write':
|
|
80
|
-
case 'Edit':
|
|
81
|
-
case 'NotebookEdit': return truncate(String(input.file_path ?? ''), 80)
|
|
82
|
-
case 'Glob': return truncate(String(input.pattern ?? ''), 80)
|
|
83
|
-
case 'Grep': return truncate(`${input.pattern ?? ''}${input.path ? ' in ' + input.path : ''}`, 80)
|
|
84
|
-
case 'WebFetch':
|
|
85
|
-
case 'WebSearch': return truncate(String(input.url ?? input.query ?? ''), 80)
|
|
86
|
-
case 'Agent':
|
|
87
|
-
case 'Task': return truncate(String(input.description ?? input.subject ?? ''), 80)
|
|
88
|
-
case 'Skill': return truncate(String(input.skill ?? ''), 80)
|
|
89
|
-
}
|
|
90
|
-
// generic fallback: first string-valued field
|
|
91
|
-
for (const v of Object.values(input)) {
|
|
92
|
-
if (typeof v === 'string' && v) return truncate(v, 80)
|
|
93
|
-
}
|
|
94
|
-
return ''
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Header summary for Task* workflow tools — `Task` (singular) is the
|
|
98
|
-
* separate subagent-spawn tool and is handled above; everything else
|
|
99
|
-
* (TaskCreate / TaskUpdate / TaskList / TaskGet / TaskStop / TaskOutput /
|
|
100
|
-
* TaskDelete) summarises through here. */
|
|
101
|
-
function summarizeTaskWorkflow(name: string, input: any): string {
|
|
102
|
-
switch (name) {
|
|
103
|
-
case 'TaskCreate':
|
|
104
|
-
return `📝 创建: ${input.subject ?? '(无 subject)'}`
|
|
105
|
-
case 'TaskUpdate': {
|
|
106
|
-
const parts: string[] = []
|
|
107
|
-
if (input.status) parts.push(`→ ${input.status}`)
|
|
108
|
-
if (input.owner) parts.push(`owner=${input.owner}`)
|
|
109
|
-
if (input.subject) parts.push(`subject="${input.subject}"`)
|
|
110
|
-
if (input.addBlocks) parts.push(`blocks=[${(input.addBlocks ?? []).join(',')}]`)
|
|
111
|
-
if (input.addBlockedBy) parts.push(`blockedBy=[${(input.addBlockedBy ?? []).join(',')}]`)
|
|
112
|
-
const tail = parts.length ? ' ' + parts.join(', ') : ''
|
|
113
|
-
return `✏️ #${input.taskId ?? '?'}${tail}`
|
|
114
|
-
}
|
|
115
|
-
case 'TaskList': return '📋 查询任务列表'
|
|
116
|
-
case 'TaskGet': return `🔍 查询 #${input.taskId ?? '?'}`
|
|
117
|
-
case 'TaskStop': return `⏹ 停止 #${input.taskId ?? '?'}`
|
|
118
|
-
case 'TaskOutput': return `📤 取输出 #${input.taskId ?? '?'}`
|
|
119
|
-
case 'TaskDelete': return `🗑 删除 #${input.taskId ?? '?'}`
|
|
120
|
-
}
|
|
121
|
-
return name
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Markdown body for Task* workflow tools — replaces the generic JSON
|
|
125
|
-
* dump with a human-readable description of the operation plus, once the
|
|
126
|
-
* tool result is in, the SDK's text reply (which already contains "Task
|
|
127
|
-
* #N created" / "Updated task #X" / a rendered list for TaskList). When
|
|
128
|
-
* `todos` is non-empty, the full mirror is appended as a "📋 当前任务
|
|
129
|
-
* 清单" footer so every Task* panel doubles as a current-state readout. */
|
|
130
|
-
function renderTaskWorkflowBody(name: string, input: any, output: string | null, todos?: Todo[]): string {
|
|
131
|
-
const lines: string[] = []
|
|
132
|
-
switch (name) {
|
|
133
|
-
case 'TaskCreate':
|
|
134
|
-
lines.push(`**📝 创建任务**`)
|
|
135
|
-
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
136
|
-
if (input.description) lines.push(`- 描述: ${input.description}`)
|
|
137
|
-
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
138
|
-
break
|
|
139
|
-
case 'TaskUpdate': {
|
|
140
|
-
lines.push(`**✏️ 更新 #${input.taskId ?? '?'}**`)
|
|
141
|
-
if (input.status) lines.push(`- status → \`${input.status}\``)
|
|
142
|
-
if (input.subject) lines.push(`- subject: ${input.subject}`)
|
|
143
|
-
if (input.description) lines.push(`- description: ${input.description}`)
|
|
144
|
-
if (input.owner) lines.push(`- owner: ${input.owner}`)
|
|
145
|
-
if (input.activeForm) lines.push(`- 进行时: ${input.activeForm}`)
|
|
146
|
-
if (input.addBlocks) lines.push(`- blocks → ${(input.addBlocks).join(', ')}`)
|
|
147
|
-
if (input.addBlockedBy) lines.push(`- blockedBy → ${(input.addBlockedBy).join(', ')}`)
|
|
148
|
-
if (input.metadata) lines.push(`- metadata: \`${JSON.stringify(input.metadata)}\``)
|
|
149
|
-
break
|
|
150
|
-
}
|
|
151
|
-
case 'TaskList': lines.push('**📋 查询当前任务清单**'); break
|
|
152
|
-
case 'TaskGet': lines.push(`**🔍 查询 #${input.taskId ?? '?'}**`); break
|
|
153
|
-
case 'TaskStop': lines.push(`**⏹ 停止 #${input.taskId ?? '?'}**`); break
|
|
154
|
-
case 'TaskOutput': lines.push(`**📤 取 #${input.taskId ?? '?'} 输出**`); break
|
|
155
|
-
case 'TaskDelete': lines.push(`**🗑 删除 #${input.taskId ?? '?'}**`); break
|
|
156
|
-
default:
|
|
157
|
-
lines.push(`**${name}**`)
|
|
158
|
-
lines.push('```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 1000) + '\n```')
|
|
159
|
-
}
|
|
160
|
-
if (output != null) {
|
|
161
|
-
lines.push('')
|
|
162
|
-
lines.push('---')
|
|
163
|
-
lines.push('**结果**')
|
|
164
|
-
lines.push(output.slice(0, 3000))
|
|
165
|
-
}
|
|
166
|
-
return lines.join('\n') + renderTodoList(todos ?? [])
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
interface MainCardOpts {
|
|
170
|
-
sessionName: string
|
|
171
|
-
turn: number
|
|
172
|
-
model?: string
|
|
173
|
-
effort?: string
|
|
174
|
-
/** What started this turn. `'scheduled'` adds a top-of-card banner so
|
|
175
|
-
* the user can tell a cron-fired wakeup apart from one of their own
|
|
176
|
-
* messages — the user's message bubble is otherwise the only visual
|
|
177
|
-
* cue, and scheduled turns have no preceding bubble in the chat. */
|
|
178
|
-
kind?: 'user_message' | 'scheduled'
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Initial card sent at the start of each turn. Streaming on. */
|
|
182
|
-
export function mainConversationCard(opts: MainCardOpts): object {
|
|
183
|
-
const banner = opts.kind === 'scheduled'
|
|
184
|
-
? [{ tag: 'markdown', content: '⏰ 触发' }]
|
|
185
|
-
: []
|
|
186
|
-
return {
|
|
187
|
-
schema: '2.0',
|
|
188
|
-
config: {
|
|
189
|
-
streaming_mode: true,
|
|
190
|
-
summary: { content: '[Lodestar 正在生成…]' },
|
|
191
|
-
streaming_config: {
|
|
192
|
-
print_frequency_ms: { default: 60, android: 60, ios: 60, pc: 30 },
|
|
193
|
-
print_step: { default: 2, android: 2, ios: 2, pc: 4 },
|
|
194
|
-
print_strategy: 'fast',
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
body: {
|
|
198
|
-
// Initial body has just thinking + footer; assistant segments and tool
|
|
199
|
-
// panels are inserted between them in real time as Claude streams.
|
|
200
|
-
// Note: empty-string content is rejected by CardKit PUT so the
|
|
201
|
-
// thinking element starts with a single space placeholder; the first
|
|
202
|
-
// real append overwrites it. No echo of the user's message inside
|
|
203
|
-
// the card — the chat bubble above already shows it.
|
|
204
|
-
elements: [
|
|
205
|
-
...banner,
|
|
206
|
-
{ tag: 'markdown', element_id: ELEMENTS.thinking, content: ' ' },
|
|
207
|
-
{ tag: 'markdown', element_id: ELEMENTS.footer, content: '⏳ working…' },
|
|
208
|
-
],
|
|
209
|
-
},
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Empty assistant segment to be inserted just before the footer. */
|
|
214
|
-
export function assistantSegmentElement(i: number): object {
|
|
215
|
-
return { tag: 'markdown', element_id: ELEMENTS.assistant(i), content: ' ' }
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Final state for the thinking section once a turn closes — collapse the
|
|
219
|
-
* full thinking text into a panel so the card stays clean. Replaces the
|
|
220
|
-
* top-level `thinking` markdown element via PUT /elements/:id. */
|
|
221
|
-
export function thinkingCollapsedPanel(fullText: string): object {
|
|
222
|
-
const trimmed = fullText.trim()
|
|
223
|
-
return {
|
|
224
|
-
tag: 'collapsible_panel',
|
|
225
|
-
element_id: ELEMENTS.thinking,
|
|
226
|
-
header: { title: { tag: 'plain_text', content: `💭 思考过程 (${trimmed.length} 字)` } },
|
|
227
|
-
expanded: false,
|
|
228
|
-
elements: [
|
|
229
|
-
{ tag: 'markdown', content: trimmed.slice(0, 8000) || '_(空)_' },
|
|
230
|
-
],
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Element to insert for each tool call. Expandable for big results.
|
|
235
|
-
*
|
|
236
|
-
* Header is a one-line summary: status + name + summarized input.
|
|
237
|
-
* Body holds the full input + (after completion) the full output.
|
|
238
|
-
* `resolvedNote` is an optional one-liner appended below the input —
|
|
239
|
-
* used to surface "✅ 允许 by Alice" inline after a permission decision
|
|
240
|
-
* lands but before the actual tool execution completes. */
|
|
241
|
-
export function toolCallElement(
|
|
242
|
-
i: number,
|
|
243
|
-
name: string,
|
|
244
|
-
input: any,
|
|
245
|
-
output: string | null,
|
|
246
|
-
status: '⏳' | '✅' | '❌' = '⏳',
|
|
247
|
-
resolvedNote?: string,
|
|
248
|
-
/** Session's full todo mirror — only rendered when the tool is a Task*
|
|
249
|
-
* workflow op. Other tools ignore it. Passed in by Session so every
|
|
250
|
-
* Task* panel shows the *current* state, not just this op's diff. */
|
|
251
|
-
todos?: Todo[],
|
|
252
|
-
): object {
|
|
253
|
-
const summary = summarizeToolInput(name, input)
|
|
254
|
-
const headerText = summary
|
|
255
|
-
? `${status} 🔧 ${name}: ${summary}`
|
|
256
|
-
: `${status} 🔧 ${name}`
|
|
257
|
-
const isTaskWorkflow = name.startsWith('Task') && name !== 'Task'
|
|
258
|
-
const noteBlock = resolvedNote ? `\n\n${resolvedNote}` : ''
|
|
259
|
-
// Task* gets a narrative body (operation + result + current todo list),
|
|
260
|
-
// the rest keeps the JSON-input + raw-output split — generic dump is
|
|
261
|
-
// better for unfamiliar tools where users can't predict what fields
|
|
262
|
-
// matter.
|
|
263
|
-
const body = isTaskWorkflow
|
|
264
|
-
? renderTaskWorkflowBody(name, input, output, todos) + noteBlock
|
|
265
|
-
: '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
266
|
-
+ noteBlock
|
|
267
|
-
+ (output != null ? '\n---\n**output:**\n```\n' + output.slice(0, 3000) + '\n```' : '')
|
|
268
|
-
return {
|
|
269
|
-
tag: 'collapsible_panel',
|
|
270
|
-
element_id: ELEMENTS.tool(i),
|
|
271
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
272
|
-
expanded: false,
|
|
273
|
-
elements: [
|
|
274
|
-
{ tag: 'markdown', content: body },
|
|
275
|
-
],
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Merged panel for a run of consecutive `Read` tool calls in one turn.
|
|
280
|
-
* Header shows the dynamic count (`Read · 3 次`), body lists one row per
|
|
281
|
-
* Read with its own status + file path. Replaces the individual panels
|
|
282
|
-
* once a second Read joins the batch — single Reads still render as the
|
|
283
|
-
* full `toolCallElement` (with file-contents dump on completion). */
|
|
284
|
-
export function readBatchElement(
|
|
285
|
-
i: number,
|
|
286
|
-
items: Array<{ input: any; output: string | null; isError: boolean }>,
|
|
287
|
-
): object {
|
|
288
|
-
const n = items.length
|
|
289
|
-
const anyError = items.some(it => it.isError)
|
|
290
|
-
const allDone = items.every(it => it.output !== null)
|
|
291
|
-
const status = anyError ? '❌' : allDone ? '✅' : '⏳'
|
|
292
|
-
const headerText = `${status} 🔧 Read · ${n} 次`
|
|
293
|
-
const lines = items.map(it => `\`${String(it.input.file_path ?? '(无 path)')}\``)
|
|
294
|
-
return {
|
|
295
|
-
tag: 'collapsible_panel',
|
|
296
|
-
element_id: ELEMENTS.tool(i),
|
|
297
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
298
|
-
expanded: false,
|
|
299
|
-
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Same tool panel as `toolCallElement`, but with the 🔐 status and
|
|
304
|
-
* three inline action buttons (allow / allow_always / deny). Expanded
|
|
305
|
-
* by default so the user can read the request without clicking through.
|
|
306
|
-
* This is the "merge into tool panel" UX — the permission decision
|
|
307
|
-
* lives on the same row as the tool call instead of as a separate
|
|
308
|
-
* floating card. */
|
|
309
|
-
export function toolCallPermissionElement(
|
|
310
|
-
i: number,
|
|
311
|
-
name: string,
|
|
312
|
-
input: any,
|
|
313
|
-
requestId: string,
|
|
314
|
-
): object {
|
|
315
|
-
const summary = summarizeToolInput(name, input)
|
|
316
|
-
const headerText = summary
|
|
317
|
-
? `🔐 等审批 · ${name}: ${summary}`
|
|
318
|
-
: `🔐 等审批 · ${name}`
|
|
319
|
-
const inputBlock = '```\n' + JSON.stringify(input ?? {}, null, 2).slice(0, 2000) + '\n```'
|
|
320
|
-
return {
|
|
321
|
-
tag: 'collapsible_panel',
|
|
322
|
-
element_id: ELEMENTS.tool(i),
|
|
323
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
324
|
-
expanded: true,
|
|
325
|
-
elements: [
|
|
326
|
-
{ tag: 'markdown', content: inputBlock },
|
|
327
|
-
{
|
|
328
|
-
tag: 'column_set',
|
|
329
|
-
columns: [
|
|
330
|
-
permissionButtonColumn('✅ 允许', 'primary', requestId, 'allow'),
|
|
331
|
-
permissionButtonColumn('♾️ 始终允许', 'default', requestId, 'allow_always'),
|
|
332
|
-
permissionButtonColumn('❌ 拒绝', 'danger', requestId, 'deny'),
|
|
333
|
-
],
|
|
334
|
-
},
|
|
335
|
-
],
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function permissionButtonColumn(label: string, type: string, requestId: string, decision: string): object {
|
|
340
|
-
return {
|
|
341
|
-
tag: 'column', width: 'weighted', weight: 1,
|
|
342
|
-
elements: [{
|
|
343
|
-
tag: 'button',
|
|
344
|
-
text: { tag: 'plain_text', content: label },
|
|
345
|
-
type,
|
|
346
|
-
behaviors: [{ type: 'callback', value: { kind: 'permission', request_id: requestId, decision } }],
|
|
347
|
-
}],
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** Schema of an AskUserQuestion question, projected to just the fields
|
|
352
|
-
* the panel needs. Mirrors the SDK tool's input — kept loose since the
|
|
353
|
-
* runtime guarantees it matches. */
|
|
354
|
-
export interface AskQuestion {
|
|
355
|
-
question: string
|
|
356
|
-
header?: string
|
|
357
|
-
multiSelect?: boolean
|
|
358
|
-
options: { label: string; description?: string }[]
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Tool-panel renderer for `AskUserQuestion` — the SDK's structured
|
|
362
|
-
* multiple-choice question. Daemon takes over the client-side role:
|
|
363
|
-
* instead of letting the request fall through to the generic JSON
|
|
364
|
-
* dump (or worse, the permission flow that misappropriates it), we
|
|
365
|
-
* render each question with one button per option, callbacks tagged
|
|
366
|
-
* `kind:'ask'` so the Lark handler can route the answer back as a
|
|
367
|
-
* `tool_result`.
|
|
368
|
-
*
|
|
369
|
-
* Single-question is the common case; multi-question gets buttons on
|
|
370
|
-
* the first question only and a text-only listing for the rest (an
|
|
371
|
-
* acceptable limitation — these are rare in practice and we can lift
|
|
372
|
-
* it once the UX is validated). */
|
|
373
|
-
/** Per-question final-state. Mutually-exclusive branches: option pick
|
|
374
|
-
* vs. free-form custom text. */
|
|
375
|
-
export interface AskAnswered {
|
|
376
|
-
optionIdx?: number
|
|
377
|
-
customText?: string
|
|
378
|
-
user?: string
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** State the panel renders against. `currentIdx` undefined → terminal
|
|
382
|
-
* (every question answered). Otherwise it's the question currently on
|
|
383
|
-
* screen; everything in `answered` is history. */
|
|
384
|
-
export interface AskState {
|
|
385
|
-
currentIdx?: number
|
|
386
|
-
answered: Map<number, AskAnswered>
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Render one question's body — either as clickable interactive_container
|
|
390
|
-
* rows (when picked === undefined) or as plain markdown summary
|
|
391
|
-
* (already-answered, shown in history-panel context). */
|
|
392
|
-
function renderAskQuestionBody(
|
|
393
|
-
q: AskQuestion,
|
|
394
|
-
toolUseId: string,
|
|
395
|
-
questionIdx: number,
|
|
396
|
-
picked?: AskAnswered,
|
|
397
|
-
): any[] {
|
|
398
|
-
const els: any[] = []
|
|
399
|
-
els.push({ tag: 'markdown', content: `**${q.question}**` })
|
|
400
|
-
for (let oi = 0; oi < q.options.length; oi++) {
|
|
401
|
-
const opt = q.options[oi]
|
|
402
|
-
const desc = opt.description ? ` · ${opt.description}` : ''
|
|
403
|
-
if (picked) {
|
|
404
|
-
const isPicked = picked.optionIdx === oi
|
|
405
|
-
els.push({
|
|
406
|
-
tag: 'markdown',
|
|
407
|
-
content: isPicked
|
|
408
|
-
? `✅ **${opt.label}**${desc}`
|
|
409
|
-
: `~~◯ ${opt.label}${desc}~~`,
|
|
410
|
-
})
|
|
411
|
-
} else {
|
|
412
|
-
els.push({
|
|
413
|
-
tag: 'interactive_container',
|
|
414
|
-
background_style: 'default',
|
|
415
|
-
has_border: true,
|
|
416
|
-
corner_radius: '6px',
|
|
417
|
-
padding: '8px 12px',
|
|
418
|
-
margin: '4px 0px 4px 0px',
|
|
419
|
-
behaviors: [{
|
|
420
|
-
type: 'callback',
|
|
421
|
-
value: {
|
|
422
|
-
kind: 'ask',
|
|
423
|
-
tool_use_id: toolUseId,
|
|
424
|
-
question_idx: questionIdx,
|
|
425
|
-
option_idx: oi,
|
|
426
|
-
},
|
|
427
|
-
}],
|
|
428
|
-
elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
|
|
429
|
-
})
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
if (picked?.customText) {
|
|
433
|
-
els.push({ tag: 'markdown', content: `✏️ **自定义回答**:${picked.customText}` })
|
|
434
|
-
}
|
|
435
|
-
return els
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** Folded "📜 已答 N 题" panel — option C from the multi-question
|
|
439
|
-
* design discussion. Returns null when there's no history to show. */
|
|
440
|
-
function renderAskHistoryPanel(
|
|
441
|
-
questions: AskQuestion[],
|
|
442
|
-
answered: Map<number, AskAnswered>,
|
|
443
|
-
): any | null {
|
|
444
|
-
if (answered.size === 0) return null
|
|
445
|
-
const lines: string[] = []
|
|
446
|
-
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
447
|
-
for (const idx of sortedIdx) {
|
|
448
|
-
const q = questions[idx]
|
|
449
|
-
const a = answered.get(idx)!
|
|
450
|
-
const tag = q?.header ?? `Q${idx + 1}`
|
|
451
|
-
const value = a.customText
|
|
452
|
-
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
453
|
-
?? '?'
|
|
454
|
-
lines.push(`- ✅ **${tag}**:${value}`)
|
|
455
|
-
}
|
|
456
|
-
return {
|
|
457
|
-
tag: 'collapsible_panel',
|
|
458
|
-
header: {
|
|
459
|
-
title: { tag: 'plain_text', content: `📜 已答 ${answered.size} 题(点击展开)` },
|
|
460
|
-
},
|
|
461
|
-
expanded: false,
|
|
462
|
-
elements: [{ tag: 'markdown', content: lines.join('\n') }],
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
export function askUserQuestionElement(
|
|
467
|
-
i: number,
|
|
468
|
-
toolUseId: string,
|
|
469
|
-
questions: AskQuestion[],
|
|
470
|
-
status: '🤔' | '✅' | '❌' = '🤔',
|
|
471
|
-
state?: AskState,
|
|
472
|
-
): object {
|
|
473
|
-
const total = questions.length
|
|
474
|
-
const answered = state?.answered ?? new Map<number, AskAnswered>()
|
|
475
|
-
const currentIdx = state?.currentIdx
|
|
476
|
-
const isTerminal = currentIdx === undefined && answered.size > 0
|
|
477
|
-
const bodyElements: any[] = []
|
|
478
|
-
let headerText: string
|
|
479
|
-
|
|
480
|
-
if (isTerminal) {
|
|
481
|
-
// All questions resolved — collapse and roll up answers in header
|
|
482
|
-
// + body. Single-question case keeps the old "已回答:xxx" header
|
|
483
|
-
// style; multi-question gets a "已回答 · N 题" count and a flat
|
|
484
|
-
// listing of Q→A pairs in the body.
|
|
485
|
-
if (total === 1) {
|
|
486
|
-
const q0 = questions[0]
|
|
487
|
-
const a0 = answered.get(0)
|
|
488
|
-
const value = a0?.customText
|
|
489
|
-
?? (a0?.optionIdx !== undefined ? q0?.options[a0.optionIdx]?.label : undefined)
|
|
490
|
-
?? '?'
|
|
491
|
-
const headerTag = q0?.header ? ` · ${q0.header}` : ''
|
|
492
|
-
headerText = `${status} 已回答${headerTag}:${value}`
|
|
493
|
-
} else {
|
|
494
|
-
headerText = `${status} 已回答 · ${total} 题`
|
|
495
|
-
}
|
|
496
|
-
const sortedIdx = [...answered.keys()].sort((a, b) => a - b)
|
|
497
|
-
for (const idx of sortedIdx) {
|
|
498
|
-
const q = questions[idx]
|
|
499
|
-
const a = answered.get(idx)!
|
|
500
|
-
const tag = q?.header ?? `Q${idx + 1}`
|
|
501
|
-
const value = a.customText
|
|
502
|
-
?? (a.optionIdx !== undefined ? q?.options[a.optionIdx]?.label : undefined)
|
|
503
|
-
?? '?'
|
|
504
|
-
bodyElements.push({
|
|
505
|
-
tag: 'markdown',
|
|
506
|
-
content: `**${tag}**:${value}`,
|
|
507
|
-
})
|
|
508
|
-
}
|
|
509
|
-
const lastUser = [...answered.values()].reverse().find(a => a.user)?.user
|
|
510
|
-
if (lastUser) {
|
|
511
|
-
bodyElements.push({
|
|
512
|
-
tag: 'markdown',
|
|
513
|
-
content: `\n*— 由 ${lastUser} 回答*`,
|
|
514
|
-
})
|
|
515
|
-
}
|
|
516
|
-
} else if (currentIdx !== undefined && questions[currentIdx]) {
|
|
517
|
-
// In-progress: render current question + folded history above.
|
|
518
|
-
// Progress tag in header lets the user see how many are left,
|
|
519
|
-
// even with the history panel folded.
|
|
520
|
-
const q = questions[currentIdx]
|
|
521
|
-
const headerTag = q.header ? ` · ${q.header}` : ''
|
|
522
|
-
const progress = total > 1 ? ` (${currentIdx + 1}/${total})` : ''
|
|
523
|
-
headerText = `${status} 🤔 AskUserQuestion${headerTag}${progress}`
|
|
524
|
-
const history = renderAskHistoryPanel(questions, answered)
|
|
525
|
-
if (history) bodyElements.push(history)
|
|
526
|
-
bodyElements.push(...renderAskQuestionBody(q, toolUseId, currentIdx))
|
|
527
|
-
bodyElements.push({
|
|
528
|
-
tag: 'markdown',
|
|
529
|
-
content: '_💬 也可以直接在群里回复你的答案(裸词命令 `hi`/`kill`/`restart`/`clear` 仍然优先)_',
|
|
530
|
-
})
|
|
531
|
-
} else {
|
|
532
|
-
// Defensive fallback — neither answered nor a valid currentIdx.
|
|
533
|
-
headerText = `${status} 🤔 AskUserQuestion`
|
|
534
|
-
if (questions[0]) {
|
|
535
|
-
bodyElements.push({ tag: 'markdown', content: `**${questions[0].question}**` })
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return {
|
|
540
|
-
tag: 'collapsible_panel',
|
|
541
|
-
element_id: ELEMENTS.tool(i),
|
|
542
|
-
header: { title: { tag: 'plain_text', content: headerText } },
|
|
543
|
-
expanded: !isTerminal,
|
|
544
|
-
elements: bodyElements,
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
interface ConsoleOpts {
|
|
549
|
-
sessionName: string
|
|
550
|
-
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
551
|
-
model?: string
|
|
552
|
-
effort?: string
|
|
553
|
-
/** ms since this ClaudeProcess spawned — formatted to "1h 23m" inside. */
|
|
554
|
-
uptimeMs?: number
|
|
555
|
-
/** All sessions currently running Claude across every Feishu group
|
|
556
|
-
* this daemon owns. Each entry is a sibling project. Empty/undefined
|
|
557
|
-
* → omit the section. The session matching this card's chat is
|
|
558
|
-
* flagged `isCurrent` so the row can be marked. */
|
|
559
|
-
peers?: Array<{
|
|
560
|
-
name: string
|
|
561
|
-
isCurrent: boolean
|
|
562
|
-
status: 'idle' | 'working' | 'awaiting_permission' | 'starting' | 'stopped'
|
|
563
|
-
uptimeMs?: number
|
|
564
|
-
}>
|
|
565
|
-
/** Subscription usage snapshot from ccusage. When `installed: false`
|
|
566
|
-
* the row renders an install hint; otherwise we surface the current
|
|
567
|
-
* 5h billing block + this week's aggregate. Undefined → omit row. */
|
|
568
|
-
usage?: import('./usage').UsageSnapshot
|
|
569
|
-
/** Current context-window occupancy estimate (input + cache tokens of
|
|
570
|
-
* the last assistant message). 0 if no turn has completed yet. */
|
|
571
|
-
contextTokens?: number
|
|
572
|
-
/** Window upper bound. Defaults to 1M (claude-opus-4-7[1m]). */
|
|
573
|
-
contextLimit?: number
|
|
574
|
-
cumStats?: { tokens: number; costUsd: number; turns: number }
|
|
575
|
-
lastTurn?: { tokens: number; costUsd: number; durationMs: number }
|
|
576
|
-
sessionId?: string | null
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/** Format token counts as a compact human-readable string: 1,234 → 1.2K. */
|
|
580
|
-
function fmtTokens(n: number): string {
|
|
581
|
-
if (!n) return '0'
|
|
582
|
-
if (n < 1000) return String(n)
|
|
583
|
-
if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '') + 'K'
|
|
584
|
-
return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, '') + 'M'
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function fmtCost(c: number): string {
|
|
588
|
-
if (c < 0.01) return `$${c.toFixed(4)}`
|
|
589
|
-
return `$${c.toFixed(2)}`
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function fmtDurationMs(ms: number): string {
|
|
593
|
-
if (ms < 1000) return `${ms}ms`
|
|
594
|
-
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
|
|
595
|
-
const m = Math.floor(ms / 60_000)
|
|
596
|
-
const s = Math.round((ms % 60_000) / 1000)
|
|
597
|
-
return `${m}m ${s}s`
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function fmtUptime(ms: number): string {
|
|
601
|
-
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
602
|
-
const totalMin = Math.floor(ms / 60_000)
|
|
603
|
-
if (totalMin < 60) return `${totalMin}m`
|
|
604
|
-
const h = Math.floor(totalMin / 60)
|
|
605
|
-
const m = totalMin % 60
|
|
606
|
-
if (h < 24) return `${h}h ${m}m`
|
|
607
|
-
const d = Math.floor(h / 24)
|
|
608
|
-
return `${d}d ${h % 24}h`
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Human-readable "time until" — null/past dates collapse to '已重置'. */
|
|
612
|
-
function fmtResetIn(date: Date | null): string {
|
|
613
|
-
if (!date) return '?'
|
|
614
|
-
const ms = date.getTime() - Date.now()
|
|
615
|
-
if (ms <= 0) return '已重置'
|
|
616
|
-
if (ms < 60 * 60 * 1000) return `${Math.max(1, Math.round(ms / 60_000))}m`
|
|
617
|
-
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h`
|
|
618
|
-
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d`
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/** Human-readable "time since" — clamps sub-minute values to "刚刚". */
|
|
622
|
-
function fmtAgo(timestamp: number): string {
|
|
623
|
-
const ms = Date.now() - timestamp
|
|
624
|
-
if (ms < 60_000) return '刚刚'
|
|
625
|
-
if (ms < 60 * 60 * 1000) return `${Math.round(ms / 60_000)}m 前`
|
|
626
|
-
if (ms < 24 * 60 * 60 * 1000) return `${Math.round(ms / (60 * 60 * 1000))}h 前`
|
|
627
|
-
return `${Math.round(ms / (24 * 60 * 60 * 1000))}d 前`
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
const PEER_STATUS_EMOJI: Record<string, string> = {
|
|
631
|
-
idle: '🟢', working: '⚙️', awaiting_permission: '🔐',
|
|
632
|
-
starting: '🚀', stopped: '⚪',
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/** Render the subscription-usage section of the console card. Pulled out
|
|
636
|
-
* of `consoleCard` so the caller can patch it in after the initial card
|
|
637
|
-
* is on screen (网络往返可能慢于第一次 paint;先占位、回包后替换)。
|
|
638
|
-
*
|
|
639
|
-
* 数据源是 Anthropic 官方 OAuth Usage API (见 src/usage.ts)。
|
|
640
|
-
* 百分比是真实 utilization,失败态按 state 区分显示具体原因。
|
|
641
|
-
*
|
|
642
|
-
* `usage === undefined` → 初始 loading 占位。
|
|
643
|
-
*/
|
|
644
|
-
export function consoleUsageContent(
|
|
645
|
-
usage: import('./usage').UsageSnapshot | undefined,
|
|
646
|
-
): string {
|
|
647
|
-
if (usage === undefined) return '**📊 订阅额度** _加载中…_'
|
|
648
|
-
switch (usage.state) {
|
|
649
|
-
case 'no_credentials':
|
|
650
|
-
return '**📊 订阅额度** 未找到 OAuth 凭据 (`~/.claude/.credentials.json`)'
|
|
651
|
-
case 'auth_failed':
|
|
652
|
-
return '**📊 订阅额度** Token 已过期且刷新失败 — 重新 `claude auth login`'
|
|
653
|
-
case 'rate_limited':
|
|
654
|
-
return '**📊 订阅额度** API 429 限流,稍后重试'
|
|
655
|
-
case 'network':
|
|
656
|
-
return `**📊 订阅额度** 拉取失败${usage.reason ? ' — `' + usage.reason + '`' : ''}`
|
|
657
|
-
}
|
|
658
|
-
// state === 'ok' —— stale 时 head 加 "缓存 Xm 前",重置时间加 `~`
|
|
659
|
-
// 前缀,沿用 omchud HUD 的 stale 标记约定。
|
|
660
|
-
const staleNote = usage.stale ? ` _· 缓存 ${fmtAgo(usage.fetchedAt)}_` : ''
|
|
661
|
-
const resetPrefix = usage.stale ? '~' : ''
|
|
662
|
-
const head = usage.subscriptionType
|
|
663
|
-
? `**📊 订阅额度** · ${usage.subscriptionType}${staleNote}`
|
|
664
|
-
: `**📊 订阅额度**${staleNote}`
|
|
665
|
-
const lines: string[] = [head]
|
|
666
|
-
if (usage.fiveHour) {
|
|
667
|
-
const parts = [`${Math.round(usage.fiveHour.percent)}%`]
|
|
668
|
-
if (usage.fiveHour.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.fiveHour.resetsAt)}`)
|
|
669
|
-
lines.push(` · 5h ${parts.join(' · ')}`)
|
|
670
|
-
}
|
|
671
|
-
if (usage.weekly) {
|
|
672
|
-
const parts = [`${Math.round(usage.weekly.percent)}%`]
|
|
673
|
-
if (usage.weekly.resetsAt) parts.push(`重置 ${resetPrefix}${fmtResetIn(usage.weekly.resetsAt)}`)
|
|
674
|
-
lines.push(` · 7d ${parts.join(' · ')}`)
|
|
675
|
-
}
|
|
676
|
-
return lines.length === 1 ? '**📊 订阅额度** _无数据_' : lines.join('\n')
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
export function consoleCard(opts: ConsoleOpts): object {
|
|
680
|
-
const {
|
|
681
|
-
sessionName, status, model, effort, uptimeMs, peers, usage,
|
|
682
|
-
contextTokens, contextLimit, cumStats, lastTurn, sessionId,
|
|
683
|
-
} = opts
|
|
684
|
-
const statusEmoji = {
|
|
685
|
-
idle: '🟢 闲', working: '⚙️ 工作中', awaiting_permission: '🔐 等审批',
|
|
686
|
-
starting: '🚀 启动中', stopped: '⚪ 未运行',
|
|
687
|
-
}[status]
|
|
688
|
-
|
|
689
|
-
const modelLine = model ? `${model}${effort ? `/${effort}` : ''}` : null
|
|
690
|
-
const headerLine = [statusEmoji, modelLine].filter(Boolean).join(' · ')
|
|
691
|
-
|
|
692
|
-
// Build the metric lines that make this panel useful. Each is "label
|
|
693
|
-
// <tab> value" rendered as plain markdown — keeps it readable inside
|
|
694
|
-
// the small Feishu card area without competing with the button row.
|
|
695
|
-
const lines: string[] = [headerLine]
|
|
696
|
-
|
|
697
|
-
if (peers && peers.length > 0) {
|
|
698
|
-
lines.push(`**🗂 活跃项目** (${peers.length})`)
|
|
699
|
-
for (const p of peers) {
|
|
700
|
-
const dot = PEER_STATUS_EMOJI[p.status] ?? '·'
|
|
701
|
-
const up = p.uptimeMs != null && p.uptimeMs > 0 ? ` · ${fmtUptime(p.uptimeMs)}` : ''
|
|
702
|
-
const mark = p.isCurrent ? ' ← 当前' : ''
|
|
703
|
-
lines.push(` · ${dot} \`${p.name}\`${up}${mark}`)
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
if (contextTokens != null && contextTokens > 0) {
|
|
707
|
-
const limit = contextLimit ?? 1_000_000
|
|
708
|
-
const pct = limit > 0 ? Math.round((contextTokens / limit) * 100) : 0
|
|
709
|
-
lines.push(`**📦 上下文** ${fmtTokens(contextTokens)} / ${fmtTokens(limit)} (${pct}%)`)
|
|
710
|
-
}
|
|
711
|
-
void uptimeMs // session-level uptime is already shown per-project in
|
|
712
|
-
// the 活跃项目 list above (peers[].uptimeMs); the dedicated row would
|
|
713
|
-
// duplicate it for the current session.
|
|
714
|
-
if (cumStats && (cumStats.tokens > 0 || cumStats.costUsd > 0 || cumStats.turns > 0)) {
|
|
715
|
-
lines.push(`**💬 累计** ${fmtTokens(cumStats.tokens)} tokens · ${fmtCost(cumStats.costUsd)} · ${cumStats.turns} turn${cumStats.turns === 1 ? '' : 's'}`)
|
|
716
|
-
}
|
|
717
|
-
if (lastTurn) {
|
|
718
|
-
lines.push(`**🔄 上一轮** +${fmtTokens(lastTurn.tokens)} · ${fmtCost(lastTurn.costUsd)} · ${fmtDurationMs(lastTurn.durationMs)}`)
|
|
719
|
-
}
|
|
720
|
-
if (sessionId) {
|
|
721
|
-
lines.push(`**🆔 session** \`${sessionId.slice(0, 8)}…\``)
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const template = status === 'working' ? 'blue'
|
|
725
|
-
: status === 'awaiting_permission' ? 'orange'
|
|
726
|
-
: status === 'stopped' ? 'grey'
|
|
727
|
-
: 'green'
|
|
728
|
-
|
|
729
|
-
return {
|
|
730
|
-
schema: '2.0',
|
|
731
|
-
config: { update_multi: true },
|
|
732
|
-
header: {
|
|
733
|
-
title: { tag: 'plain_text', content: `🌟 Lodestar · ${sessionName}` },
|
|
734
|
-
template,
|
|
735
|
-
},
|
|
736
|
-
body: {
|
|
737
|
-
elements: [
|
|
738
|
-
{ tag: 'markdown', content: lines.join('\n') },
|
|
739
|
-
// Separate element so showConsole() can replace it after the
|
|
740
|
-
// ccusage fetch completes — initial paint goes out immediately
|
|
741
|
-
// with `_加载中…_`, then this row swaps to real data.
|
|
742
|
-
{
|
|
743
|
-
tag: 'markdown',
|
|
744
|
-
element_id: ELEMENTS.consoleUsage,
|
|
745
|
-
content: consoleUsageContent(usage),
|
|
746
|
-
},
|
|
747
|
-
],
|
|
748
|
-
},
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
interface MenuOpts {
|
|
753
|
-
question: string
|
|
754
|
-
options: string[]
|
|
755
|
-
requestId: string
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
export function menuCard(opts: MenuOpts): object {
|
|
759
|
-
const { question, options, requestId } = opts
|
|
760
|
-
return {
|
|
761
|
-
schema: '2.0',
|
|
762
|
-
config: { update_multi: true },
|
|
763
|
-
header: {
|
|
764
|
-
title: { tag: 'plain_text', content: '📋 等待选择' },
|
|
765
|
-
template: 'turquoise',
|
|
766
|
-
},
|
|
767
|
-
body: {
|
|
768
|
-
elements: [
|
|
769
|
-
{ tag: 'markdown', content: question || '_请选择一项:_' },
|
|
770
|
-
...options.map((opt, i) => ({
|
|
771
|
-
tag: 'button',
|
|
772
|
-
text: { tag: 'plain_text', content: opt },
|
|
773
|
-
type: 'default',
|
|
774
|
-
behaviors: [{ type: 'callback', value: { kind: 'menu', request_id: requestId, choice: i } }],
|
|
775
|
-
})),
|
|
776
|
-
],
|
|
777
|
-
},
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/** Settings patch applied when a turn finishes — flips streaming off
|
|
782
|
-
* and updates the chat-list preview with `⏱ duration · NK tokens`
|
|
783
|
-
* (or just the suffix if interrupted before a result event). */
|
|
784
|
-
export function streamingOffSettings(opts: {
|
|
785
|
-
durationSec: string
|
|
786
|
-
tokens?: number
|
|
787
|
-
suffix?: string
|
|
788
|
-
}): object {
|
|
789
|
-
const parts: string[] = []
|
|
790
|
-
parts.push(opts.suffix ?? '✅')
|
|
791
|
-
parts.push(`⏱ ${opts.durationSec}s`)
|
|
792
|
-
if (opts.tokens != null && opts.tokens > 0) {
|
|
793
|
-
parts.push(`${fmtTokens(opts.tokens)} tokens`)
|
|
794
|
-
}
|
|
795
|
-
return {
|
|
796
|
-
config: { streaming_mode: false, summary: { content: parts.join(' · ') } },
|
|
797
|
-
}
|
|
798
|
-
}
|
|
12
|
+
export { ELEMENTS } from './cards/elements'
|
|
13
|
+
export {
|
|
14
|
+
type Todo,
|
|
15
|
+
type AskQuestion,
|
|
16
|
+
type AskAnswered,
|
|
17
|
+
type AskState,
|
|
18
|
+
summarizeToolInput,
|
|
19
|
+
mainConversationCard,
|
|
20
|
+
assistantSegmentElement,
|
|
21
|
+
thinkingCollapsedPanel,
|
|
22
|
+
toolCallElement,
|
|
23
|
+
readBatchElement,
|
|
24
|
+
toolCallPermissionElement,
|
|
25
|
+
askUserQuestionElement,
|
|
26
|
+
} from './cards/turn'
|
|
27
|
+
export {
|
|
28
|
+
consoleUsageContent,
|
|
29
|
+
consoleCard,
|
|
30
|
+
menuCard,
|
|
31
|
+
streamingOffSettings,
|
|
32
|
+
} from './cards/console'
|