@leviyuan/lodestar 0.1.3 → 0.1.5

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/daemon.ts CHANGED
@@ -140,9 +140,20 @@ async function handleCardAction(data: any): Promise<any> {
140
140
  case 'menu':
141
141
  await session.onUserMessage(`(menu choice ${value.choice + 1})`)
142
142
  return { toast: { type: 'success', content: 'OK' } }
143
- case 'ask':
143
+ case 'ask': {
144
+ // Custom-text branch: form submit packages the input under
145
+ // `form_value`. Try a couple of plausible keys since the exact
146
+ // shape can drift between Feishu schema versions; fall back to
147
+ // empty (onAskCustomAnswer ignores blank).
148
+ if (value.custom) {
149
+ const fv = action?.form_value ?? action?.input ?? {}
150
+ const customText: string = fv?.custom_answer ?? action?.input_value ?? ''
151
+ await session.onAskCustomAnswer(value.tool_use_id, value.question_idx ?? 0, customText, userId)
152
+ return { toast: { type: customText.trim() ? 'success' : 'error', content: customText.trim() ? '已回答' : '请输入答案' } }
153
+ }
144
154
  await session.onAskAnswer(value.tool_use_id, value.question_idx ?? 0, value.option_idx, userId)
145
155
  return { toast: { type: 'success', content: '已回答' } }
156
+ }
146
157
  }
147
158
  return { toast: { type: 'info', content: 'unknown action' } }
148
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -332,31 +332,57 @@ export interface AskQuestion {
332
332
  * the first question only and a text-only listing for the rest (an
333
333
  * acceptable limitation — these are rare in practice and we can lift
334
334
  * it once the UX is validated). */
335
+ /** Final-state info for an AskUserQuestion panel. Mutually-exclusive
336
+ * branches inside: either the user picked one of the SDK-provided
337
+ * options (`optionIdx`), or they typed a free-form answer
338
+ * (`customText`). When both are absent the panel is still pending. */
339
+ export interface AskAnswered {
340
+ optionIdx?: number
341
+ customText?: string
342
+ user?: string
343
+ }
344
+
335
345
  export function askUserQuestionElement(
336
346
  i: number,
337
347
  toolUseId: string,
338
348
  questions: AskQuestion[],
339
349
  status: '🤔' | '✅' | '❌' = '🤔',
340
- resolvedNote?: string,
350
+ answered?: AskAnswered,
341
351
  ): object {
342
352
  const primary = questions[0]
353
+ const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
343
354
  const headerTag = primary?.header ? ` · ${primary.header}` : ''
344
- const headerText = `${status} 🤔 AskUserQuestion${headerTag}`
355
+ let headerText: string
356
+ if (isAnswered) {
357
+ // Surface the picked answer in the header so a collapsed panel
358
+ // still tells the reader what was chosen.
359
+ const picked = answered!.customText
360
+ ?? (answered!.optionIdx !== undefined ? primary?.options[answered!.optionIdx]?.label : undefined)
361
+ ?? '?'
362
+ headerText = `${status} 已回答${headerTag}:${picked}`
363
+ } else {
364
+ headerText = `${status} 🤔 AskUserQuestion${headerTag}`
365
+ }
345
366
  const bodyElements: any[] = []
346
367
  if (primary) {
347
368
  bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
348
- // Stack option buttons in a column_set one button per option.
349
- // Each carries `kind:'ask'` + the toolUseId + question/option idx
350
- // so the daemon's card action handler can map a click back to
351
- // exactly one (question, choice) pair.
352
- bodyElements.push({
353
- tag: 'column_set',
354
- columns: primary.options.map((opt, optIdx) => ({
355
- tag: 'column', width: 'weighted', weight: 1,
356
- elements: [{
357
- tag: 'button',
358
- text: { tag: 'plain_text', content: opt.label },
359
- type: 'default',
369
+ // One row per option. While pending, each row is a full-width
370
+ // interactive_container so the entire row is the click target.
371
+ // Once answered, rows degrade to plain markdown (Feishu has no
372
+ // generic non-interactive container — `div` doesn't exist, it
373
+ // rejects the PUT with code 300121).
374
+ for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
375
+ const opt = primary.options[optIdx]
376
+ const isPicked = answered?.optionIdx === optIdx
377
+ const desc = opt.description ? ` · ${opt.description}` : ''
378
+ if (!isAnswered) {
379
+ bodyElements.push({
380
+ tag: 'interactive_container',
381
+ background_style: 'default',
382
+ has_border: true,
383
+ corner_radius: '6px',
384
+ padding: '8px 12px',
385
+ margin: '4px 0px 4px 0px',
360
386
  behaviors: [{
361
387
  type: 'callback',
362
388
  value: {
@@ -366,18 +392,70 @@ export function askUserQuestionElement(
366
392
  option_idx: optIdx,
367
393
  },
368
394
  }],
395
+ elements: [{ tag: 'markdown', content: `**${opt.label}**${desc}` }],
396
+ })
397
+ } else if (isPicked) {
398
+ bodyElements.push({
399
+ tag: 'markdown',
400
+ content: `✅ **${opt.label}**${desc}`,
401
+ })
402
+ } else {
403
+ bodyElements.push({
404
+ tag: 'markdown',
405
+ content: `~~◯ ${opt.label}${desc}~~`,
406
+ })
407
+ }
408
+ }
409
+ // Custom-answer input (pending state only). Feishu form submit
410
+ // packages the input value under `form_value` in the callback
411
+ // payload; daemon.handleCardAction reads it as
412
+ // `value.form_value.custom_answer`.
413
+ if (!isAnswered) {
414
+ bodyElements.push({ tag: 'markdown', content: '_或自己输入:_' })
415
+ bodyElements.push({
416
+ tag: 'form',
417
+ name: `ask_form_${toolUseId.slice(-12)}`,
418
+ elements: [{
419
+ tag: 'column_set',
420
+ columns: [
421
+ {
422
+ tag: 'column', width: 'weighted', weight: 3, vertical_align: 'center',
423
+ elements: [{
424
+ tag: 'input',
425
+ name: 'custom_answer',
426
+ placeholder: { tag: 'plain_text', content: '自定义回答…' },
427
+ required: false,
428
+ }],
429
+ },
430
+ {
431
+ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center',
432
+ elements: [{
433
+ tag: 'button',
434
+ text: { tag: 'plain_text', content: '发送' },
435
+ type: 'primary',
436
+ form_action_type: 'submit',
437
+ behaviors: [{
438
+ type: 'callback',
439
+ value: {
440
+ kind: 'ask',
441
+ tool_use_id: toolUseId,
442
+ question_idx: 0,
443
+ custom: true,
444
+ },
445
+ }],
446
+ }],
447
+ },
448
+ ],
369
449
  }],
370
- })),
371
- })
372
- // Inline option descriptions below the buttons so the user can
373
- // read context without hovering.
374
- const descLines = primary.options
375
- .map((o, idx) => o.description ? `- **${o.label}** — ${o.description}` : `- **${o.label}**`)
376
- .join('\n')
377
- if (descLines) bodyElements.push({ tag: 'markdown', content: descLines })
450
+ })
451
+ } else if (answered?.customText) {
452
+ bodyElements.push({
453
+ tag: 'markdown',
454
+ content: `✏️ **自定义回答**:${answered.customText}`,
455
+ })
456
+ }
378
457
  }
379
- // Secondary questions get text-only treatment (TODO: multi-question
380
- // panels when actually requested by a real prompt).
458
+ // Secondary questions (rare): text-only listing. TODO when needed.
381
459
  for (let qi = 1; qi < questions.length; qi++) {
382
460
  const q = questions[qi]
383
461
  const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
@@ -386,12 +464,19 @@ export function askUserQuestionElement(
386
464
  content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
387
465
  })
388
466
  }
389
- if (resolvedNote) bodyElements.push({ tag: 'markdown', content: resolvedNote })
467
+ if (isAnswered && answered?.user) {
468
+ bodyElements.push({
469
+ tag: 'markdown',
470
+ content: `\n*— 由 ${answered.user} 回答*`,
471
+ })
472
+ }
390
473
  return {
391
474
  tag: 'collapsible_panel',
392
475
  element_id: ELEMENTS.tool(i),
393
476
  header: { title: { tag: 'plain_text', content: headerText } },
394
- expanded: true,
477
+ // Collapse once answered: header carries the picked answer, body
478
+ // is just history at that point.
479
+ expanded: !isAnswered,
395
480
  elements: bodyElements,
396
481
  }
397
482
  }
package/src/session.ts CHANGED
@@ -79,8 +79,9 @@ export class Session {
79
79
  /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
80
80
  * routes AskUserQuestion through the can_use_tool flow even under
81
81
  * bypass; we have to thread the permission `requestId` through here
82
- * so onAskAnswer can answer it with `updatedInput.answers` populated.
83
- * `deferredAnswer` covers the race where the user clicks a button
82
+ * so the answer (option click OR custom text submit) can resolve
83
+ * the permission with `updatedInput.answers` populated.
84
+ * `deferredAnswer` covers the race where the user clicks/submits
84
85
  * BEFORE can_use_tool arrives (addTool fires on the assistant
85
86
  * message; can_use_tool is a separate control_request that lands
86
87
  * slightly later). */
@@ -88,7 +89,12 @@ export class Session {
88
89
  questions: cards.AskQuestion[]
89
90
  i: number
90
91
  requestId?: string
91
- deferredAnswer?: { questionIdx: number; optionIdx: number; user: string }
92
+ deferredAnswer?: {
93
+ questionIdx: number
94
+ optionIdx?: number
95
+ customText?: string
96
+ user: string
97
+ }
92
98
  }>()
93
99
  private turnCounter = 0
94
100
  // Last seen sessionId — preserved across `kill`/`stop` so a later
@@ -326,43 +332,72 @@ export class Session {
326
332
  }
327
333
  }
328
334
 
329
- /** Click handler for an AskUserQuestion option button. The actual
330
- * "send answer to Claude" plumbing lives in `resolveAsk` this
331
- * wrapper just chooses between answering immediately (the common
332
- * case: can_use_tool has already arrived and parked a requestId on
333
- * the pendingAsk record) versus deferring (the rare race where the
334
- * user clicks before can_use_tool lands). */
335
+ /** Click handler for an AskUserQuestion option button. Dispatches
336
+ * to `resolveAsk` if can_use_tool has already arrived, otherwise
337
+ * parks the click on the pendingAsk record for renderPermission
338
+ * to drain. */
335
339
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
336
340
  const pending = this.pendingAsks.get(toolUseId)
337
341
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
338
342
  if (pending.requestId) {
339
- this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
343
+ this.resolveAsk(toolUseId, pending.requestId, { questionIdx, optionIdx, user })
340
344
  } else {
341
- // can_use_tool hasn't landed yet — park the click. renderPermission
342
- // will drain it the moment the request arrives.
343
345
  pending.deferredAnswer = { questionIdx, optionIdx, user }
344
346
  log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
345
347
  }
346
348
  }
347
349
 
350
+ /** Form-submit handler for the custom-answer input on an
351
+ * AskUserQuestion panel. Same dispatch pattern as `onAskAnswer`,
352
+ * just routes a free-form string into `resolveAsk` instead of an
353
+ * option index. Empty/whitespace input is ignored (no answer
354
+ * sent, panel stays pending). */
355
+ async onAskCustomAnswer(toolUseId: string, questionIdx: number, customText: string, user: string): Promise<void> {
356
+ const pending = this.pendingAsks.get(toolUseId)
357
+ if (!pending) { log(`session "${this.sessionName}": stray ask custom for ${toolUseId}`); return }
358
+ const trimmed = (customText ?? '').trim()
359
+ if (!trimmed) { log(`session "${this.sessionName}": empty custom answer, ignoring`); return }
360
+ if (pending.requestId) {
361
+ this.resolveAsk(toolUseId, pending.requestId, { questionIdx, customText: trimmed, user })
362
+ } else {
363
+ pending.deferredAnswer = { questionIdx, customText: trimmed, user }
364
+ log(`session "${this.sessionName}": ask custom deferred for ${toolUseId} (no requestId yet)`)
365
+ }
366
+ }
367
+
348
368
  /** Settle an AskUserQuestion: emit the permission allow with the
349
- * picked option folded into `updatedInput.answers` (this is the
350
- * shape the SDK reads to synthesise the tool_result string), repaint
351
- * the panel ✅, drop bookkeeping. Single source of truth — both the
352
- * normal click path and the deferred-answer drain go through here. */
369
+ * picked option OR custom text folded into `updatedInput.answers`
370
+ * (this is the shape the SDK reads to synthesise the tool_result
371
+ * string), repaint the panel ✅, drop bookkeeping. Single source
372
+ * of truth — option-click, custom-submit, and the deferred drain
373
+ * all go through here. */
353
374
  private resolveAsk(
354
375
  toolUseId: string,
355
376
  requestId: string,
356
- questionIdx: number,
357
- optionIdx: number,
358
- user: string,
377
+ answer: { questionIdx: number; optionIdx?: number; customText?: string; user: string },
359
378
  ): void {
360
379
  const pending = this.pendingAsks.get(toolUseId)
361
380
  if (!pending) return
362
- const q = pending.questions[questionIdx]
363
- const opt = q?.options?.[optionIdx]
364
- if (!q || !opt) {
365
- log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
381
+ const q = pending.questions[answer.questionIdx]
382
+ if (!q) {
383
+ log(`session "${this.sessionName}": ask answer out of range q=${answer.questionIdx}`)
384
+ return
385
+ }
386
+ // Determine the literal string that will become the SDK's
387
+ // `answers` value for this question — custom wins if both are
388
+ // somehow set (shouldn't happen, but defensive).
389
+ let answerValue: string
390
+ if (answer.customText) {
391
+ answerValue = answer.customText
392
+ } else if (answer.optionIdx !== undefined) {
393
+ const opt = q.options?.[answer.optionIdx]
394
+ if (!opt) {
395
+ log(`session "${this.sessionName}": ask option out of range o=${answer.optionIdx}`)
396
+ return
397
+ }
398
+ answerValue = opt.label
399
+ } else {
400
+ log(`session "${this.sessionName}": resolveAsk called with neither optionIdx nor customText`)
366
401
  return
367
402
  }
368
403
  const turn = this.currentTurn
@@ -370,9 +405,8 @@ export class Session {
370
405
  const originalInput = meta?.input ?? {}
371
406
  // SDK keys the answer record by the question's text — confirmed
372
407
  // by the v0.1.2 jsonl trace (empty record formatted to "User has
373
- // answered your questions: ."). Populate the key matching this
374
- // question with the chosen option's label.
375
- const answers: Record<string, string> = { [q.question]: opt.label }
408
+ // answered your questions: .").
409
+ const answers: Record<string, string> = { [q.question]: answerValue }
376
410
  this.proc?.sendPermissionResponse(requestId, 'allow', {
377
411
  updatedInput: { ...originalInput, answers },
378
412
  })
@@ -382,8 +416,11 @@ export class Session {
382
416
  if (turn && meta) {
383
417
  meta.output = JSON.stringify({ answers })
384
418
  meta.isError = false
385
- const resolvedNote = `\n\n✅ **已回答** by ${user || '匿名'}: ${opt.label}`
386
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
419
+ const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '', {
420
+ optionIdx: answer.optionIdx,
421
+ customText: answer.customText,
422
+ user: answer.user || '匿名',
423
+ })
387
424
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
388
425
  }
389
426
 
@@ -606,6 +643,12 @@ export class Session {
606
643
  // can't discard the output after the first paint.
607
644
  meta.output = output
608
645
  meta.isError = isError
646
+ // AskUserQuestion already had its final panel painted by resolveAsk
647
+ // (✅ + the chosen option marked, others dimmed). The tool_result
648
+ // arriving here is just the SDK's synthesised echo — re-rendering
649
+ // via toolCallElement would clobber the nice option-row layout
650
+ // with a generic JSON dump. Bail out; the panel is done.
651
+ if (meta.name === 'AskUserQuestion') return
609
652
  // Update the local todo mirror BEFORE rendering so the just-
610
653
  // completed panel shows the new state too (e.g. a TaskCreate panel
611
654
  // already lists the task it just created).
@@ -741,9 +784,14 @@ export class Session {
741
784
  ask.requestId = req.request_id
742
785
  this.pendingPermissions.set(req.request_id, { toolUseId })
743
786
  if (ask.deferredAnswer) {
744
- const { questionIdx, optionIdx, user } = ask.deferredAnswer
787
+ const d = ask.deferredAnswer
745
788
  ask.deferredAnswer = undefined
746
- this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
789
+ this.resolveAsk(toolUseId, req.request_id, {
790
+ questionIdx: d.questionIdx,
791
+ optionIdx: d.optionIdx,
792
+ customText: d.customText,
793
+ user: d.user,
794
+ })
747
795
  }
748
796
  return
749
797
  }