@leviyuan/lodestar 0.1.4 → 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.4",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -332,56 +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
- /** When set, only this option is rendered as "picked" — every other
341
- * option turns into plain text (no click target). Used after the
342
- * user has answered so the panel freezes in a sensible terminal
343
- * state instead of inviting another click. */
344
- pickedOptionIdx?: number,
345
- resolvedNote?: string,
350
+ answered?: AskAnswered,
346
351
  ): object {
347
352
  const primary = questions[0]
353
+ const isAnswered = !!answered && (answered.optionIdx !== undefined || !!answered.customText)
348
354
  const headerTag = primary?.header ? ` · ${primary.header}` : ''
349
- 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
+ }
350
366
  const bodyElements: any[] = []
351
367
  if (primary) {
352
368
  bodyElements.push({ tag: 'markdown', content: `**${primary.question}**` })
353
- // One row per option, each a full-width interactive_container so
354
- // the entire row (label + description) is the click target. Looks
355
- // cleaner than a row of squat buttons and matches the way IM
356
- // quick-replies usually present themselves. After the user picks,
357
- // we still render the same row layout (no JSON dump) but strip
358
- // the callbacks — selected option marked ✅, others dimmed.
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).
359
374
  for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
360
375
  const opt = primary.options[optIdx]
361
- const isPicked = pickedOptionIdx === optIdx
362
- const isAnswered = pickedOptionIdx !== undefined
363
- const labelLine = isPicked
364
- ? `**✅ ${opt.label}**`
365
- : isAnswered
366
- ? `~~${opt.label}~~`
367
- : `**${opt.label}**`
368
- const descLine = opt.description ? `\n${opt.description}` : ''
369
- const rowContent = { tag: 'markdown', content: `${labelLine}${descLine}` }
370
- if (isAnswered) {
371
- // Frozen — no behaviors, plain container so it stops looking
372
- // clickable.
373
- bodyElements.push({
374
- tag: 'div',
375
- elements: [rowContent],
376
- })
377
- } else {
376
+ const isPicked = answered?.optionIdx === optIdx
377
+ const desc = opt.description ? ` · ${opt.description}` : ''
378
+ if (!isAnswered) {
378
379
  bodyElements.push({
379
380
  tag: 'interactive_container',
380
381
  background_style: 'default',
381
382
  has_border: true,
382
383
  corner_radius: '6px',
383
384
  padding: '8px 12px',
384
- margin: '4px 0',
385
+ margin: '4px 0px 4px 0px',
385
386
  behaviors: [{
386
387
  type: 'callback',
387
388
  value: {
@@ -391,13 +392,70 @@ export function askUserQuestionElement(
391
392
  option_idx: optIdx,
392
393
  },
393
394
  }],
394
- elements: [rowContent],
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}~~`,
395
406
  })
396
407
  }
397
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
+ ],
449
+ }],
450
+ })
451
+ } else if (answered?.customText) {
452
+ bodyElements.push({
453
+ tag: 'markdown',
454
+ content: `✏️ **自定义回答**:${answered.customText}`,
455
+ })
456
+ }
398
457
  }
399
- // Secondary questions get text-only treatment (TODO: multi-question
400
- // panels when actually requested by a real prompt).
458
+ // Secondary questions (rare): text-only listing. TODO when needed.
401
459
  for (let qi = 1; qi < questions.length; qi++) {
402
460
  const q = questions[qi]
403
461
  const opts = q.options.map(o => ` - ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')
@@ -406,12 +464,19 @@ export function askUserQuestionElement(
406
464
  content: `\n---\n**(其他问题 #${qi + 1}, 暂未支持回答)** ${q.question}\n${opts}`,
407
465
  })
408
466
  }
409
- 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
+ }
410
473
  return {
411
474
  tag: 'collapsible_panel',
412
475
  element_id: ELEMENTS.tool(i),
413
476
  header: { title: { tag: 'plain_text', content: headerText } },
414
- expanded: true,
477
+ // Collapse once answered: header carries the picked answer, body
478
+ // is just history at that point.
479
+ expanded: !isAnswered,
415
480
  elements: bodyElements,
416
481
  }
417
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*已由 ${user || '匿名'} 回答*`
386
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', optionIdx, 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
 
@@ -747,9 +784,14 @@ export class Session {
747
784
  ask.requestId = req.request_id
748
785
  this.pendingPermissions.set(req.request_id, { toolUseId })
749
786
  if (ask.deferredAnswer) {
750
- const { questionIdx, optionIdx, user } = ask.deferredAnswer
787
+ const d = ask.deferredAnswer
751
788
  ask.deferredAnswer = undefined
752
- 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
+ })
753
795
  }
754
796
  return
755
797
  }