@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/src/cards.ts CHANGED
@@ -1,798 +1,32 @@
1
1
  /**
2
- * Schema 2.0 Feishu card templates.
3
- *
4
- * Element-id convention (must be unique within a card):
5
- * user_input — the collapsible "你说" panel
6
- * thinking the de-emphasized thinking stream
7
- * tool_<i> one collapsible per tool call, indexed from 0
8
- * assistant the main streaming assistant answer
9
- * footer — runtime footer (timing / status)
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 const ELEMENTS = {
13
- thinking: 'thinking',
14
- footer: 'footer',
15
- tool: (i: number) => `tool_${i}`,
16
- /** Assistant text is segmented: every tool call closes the running segment
17
- * and the next assistant chunk opens a new one, so element order in the
18
- * card matches Claude's emission order. */
19
- assistant: (i: number) => `assistant_${i}`,
20
- /** Console (hi) card — the subscription-usage row is rendered as its
21
- * own element so we can replace it after the initial card lands,
22
- * decoupling the slow ccusage fetch from the rest of the panel's
23
- * synchronous data. */
24
- consoleUsage: 'console_usage',
25
- } as const
26
-
27
- /** Minimal projection of an SDK task — used by Session's local mirror,
28
- * built incrementally from observed TaskCreate / TaskUpdate input+output
29
- * pairs. Not authoritative (the SDK is the source of truth), but enough
30
- * to render the "全部任务清单" footer on every Task* panel. */
31
- export interface Todo {
32
- id: number
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'