@leviyuan/lodestar 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviyuan/lodestar",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cards.ts CHANGED
@@ -337,6 +337,11 @@ export function askUserQuestionElement(
337
337
  toolUseId: string,
338
338
  questions: AskQuestion[],
339
339
  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,
340
345
  resolvedNote?: string,
341
346
  ): object {
342
347
  const primary = questions[0]
@@ -345,18 +350,38 @@ export function askUserQuestionElement(
345
350
  const bodyElements: any[] = []
346
351
  if (primary) {
347
352
  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',
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.
359
+ for (let optIdx = 0; optIdx < primary.options.length; optIdx++) {
360
+ 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 {
378
+ bodyElements.push({
379
+ tag: 'interactive_container',
380
+ background_style: 'default',
381
+ has_border: true,
382
+ corner_radius: '6px',
383
+ padding: '8px 12px',
384
+ margin: '4px 0',
360
385
  behaviors: [{
361
386
  type: 'callback',
362
387
  value: {
@@ -366,15 +391,10 @@ export function askUserQuestionElement(
366
391
  option_idx: optIdx,
367
392
  },
368
393
  }],
369
- }],
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 })
394
+ elements: [rowContent],
395
+ })
396
+ }
397
+ }
378
398
  }
379
399
  // Secondary questions get text-only treatment (TODO: multi-question
380
400
  // panels when actually requested by a real prompt).
package/src/session.ts CHANGED
@@ -76,11 +76,20 @@ export class Session {
76
76
  private proc: ClaudeProcess | null = null
77
77
  private currentTurn: TurnState | null = null
78
78
  private pendingPermissions = new Map<string, { toolUseId: string }>()
79
- /** Open AskUserQuestion tool calls — keyed by tool_use_id, valued
80
- * with the questions array so we can construct the `answers` reply
81
- * once a button is clicked. Cleared when the answer is sent or the
82
- * turn ends. */
83
- private pendingAsks = new Map<string, { questions: cards.AskQuestion[]; i: number }>()
79
+ /** Open AskUserQuestion tool calls — keyed by tool_use_id. The SDK
80
+ * routes AskUserQuestion through the can_use_tool flow even under
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
84
+ * BEFORE can_use_tool arrives (addTool fires on the assistant
85
+ * message; can_use_tool is a separate control_request that lands
86
+ * slightly later). */
87
+ private pendingAsks = new Map<string, {
88
+ questions: cards.AskQuestion[]
89
+ i: number
90
+ requestId?: string
91
+ deferredAnswer?: { questionIdx: number; optionIdx: number; user: string }
92
+ }>()
84
93
  private turnCounter = 0
85
94
  // Last seen sessionId — preserved across `kill`/`stop` so a later
86
95
  // `restart` can resume the same Claude conversation even after the
@@ -317,40 +326,70 @@ export class Session {
317
326
  }
318
327
  }
319
328
 
320
- /** Handle a click on an AskUserQuestion option button. We construct
321
- * the SDK-shaped `answers` map (question text picked option label),
322
- * send it back as a `tool_result`, and freeze the panel showing the
323
- * picked option. The permission half of the flow was already auto-
324
- * allowed in `renderPermission`, so no second action needed there. */
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). */
325
335
  async onAskAnswer(toolUseId: string, questionIdx: number, optionIdx: number, user: string): Promise<void> {
326
336
  const pending = this.pendingAsks.get(toolUseId)
327
337
  if (!pending) { log(`session "${this.sessionName}": stray ask answer for ${toolUseId}`); return }
338
+ if (pending.requestId) {
339
+ this.resolveAsk(toolUseId, pending.requestId, questionIdx, optionIdx, user)
340
+ } else {
341
+ // can_use_tool hasn't landed yet — park the click. renderPermission
342
+ // will drain it the moment the request arrives.
343
+ pending.deferredAnswer = { questionIdx, optionIdx, user }
344
+ log(`session "${this.sessionName}": ask answer deferred for ${toolUseId} (no requestId yet)`)
345
+ }
346
+ }
347
+
348
+ /** 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. */
353
+ private resolveAsk(
354
+ toolUseId: string,
355
+ requestId: string,
356
+ questionIdx: number,
357
+ optionIdx: number,
358
+ user: string,
359
+ ): void {
360
+ const pending = this.pendingAsks.get(toolUseId)
361
+ if (!pending) return
328
362
  const q = pending.questions[questionIdx]
329
363
  const opt = q?.options?.[optionIdx]
330
364
  if (!q || !opt) {
331
365
  log(`session "${this.sessionName}": ask answer out of range q=${questionIdx} o=${optionIdx}`)
332
366
  return
333
367
  }
334
- // Build the answers map. SDK keys answers by the question text
335
- // (matches what the AskUserQuestion contract expects). For
336
- // unanswered secondary questions we don't include them — the SDK
337
- // tolerates a partial map; Claude will see whichever it got.
368
+ const turn = this.currentTurn
369
+ const meta = turn?.toolByUseId.get(toolUseId)
370
+ const originalInput = meta?.input ?? {}
371
+ // SDK keys the answer record by the question's text — confirmed
372
+ // 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.
338
375
  const answers: Record<string, string> = { [q.question]: opt.label }
339
- const content = JSON.stringify({ answers })
340
- this.proc?.sendToolResult(toolUseId, content)
376
+ this.proc?.sendPermissionResponse(requestId, 'allow', {
377
+ updatedInput: { ...originalInput, answers },
378
+ })
379
+ this.pendingPermissions.delete(requestId)
341
380
  this.pendingAsks.delete(toolUseId)
342
381
 
343
- // Repaint the panel to reflect the final choice — same element_id,
344
- // status flips to ✅, body shows what was picked.
345
- const turn = this.currentTurn
346
- const meta = turn?.toolByUseId.get(toolUseId)
347
382
  if (turn && meta) {
348
- meta.output = content
383
+ meta.output = JSON.stringify({ answers })
349
384
  meta.isError = false
350
- const resolvedNote = `\n\n **已回答** by ${user || '匿名'}: ${opt.label}`
351
- const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', resolvedNote)
385
+ const resolvedNote = `\n\n*已由 ${user || '匿名'} 回答*`
386
+ const el = cards.askUserQuestionElement(meta.i, toolUseId, pending.questions, '✅', optionIdx, resolvedNote)
352
387
  void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
353
388
  }
389
+
390
+ if (this.pendingPermissions.size === 0 && this.status === 'awaiting_permission') {
391
+ this.status = 'working'
392
+ }
354
393
  }
355
394
 
356
395
  async onConsoleAction(action: string): Promise<void> {
@@ -567,6 +606,12 @@ export class Session {
567
606
  // can't discard the output after the first paint.
568
607
  meta.output = output
569
608
  meta.isError = isError
609
+ // AskUserQuestion already had its final panel painted by resolveAsk
610
+ // (✅ + the chosen option marked, others dimmed). The tool_result
611
+ // arriving here is just the SDK's synthesised echo — re-rendering
612
+ // via toolCallElement would clobber the nice option-row layout
613
+ // with a generic JSON dump. Bail out; the panel is done.
614
+ if (meta.name === 'AskUserQuestion') return
570
615
  // Update the local todo mirror BEFORE rendering so the just-
571
616
  // completed panel shows the new state too (e.g. a TaskCreate panel
572
617
  // already lists the task it just created).
@@ -680,17 +725,32 @@ export class Session {
680
725
  this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
681
726
  return
682
727
  }
683
- // AskUserQuestion: daemon already rendered the choice UI in addTool
684
- // and is collecting the user's answer asynchronously. The SDK
685
- // wraps it in the standard permission flow anyway (even under
686
- // bypassPermissions), so we auto-allow the request otherwise
687
- // Claude would block on can_use_tool forever, AND our permission
688
- // renderer would overwrite the ask UI with the wrong 3-button
689
- // panel. Keeping it allow-by-default also matches the user's
690
- // mental model: they expect to choose an option, not "approve"
691
- // the question itself.
728
+ // AskUserQuestion: SDK routes it through can_use_tool even under
729
+ // bypass. The PAYLOAD of "user has answered" is the permission
730
+ // response itself specifically `updatedInput.answers`. So we
731
+ // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
732
+ // answers map and immediately synthesised a "User has answered
733
+ // your questions: ." tool_result). Park the requestId on the
734
+ // pendingAsk record and wait for the user to click an option;
735
+ // onAskAnswer will then send allow + updatedInput.answers in one
736
+ // shot. If the user already clicked between addTool and now —
737
+ // the deferredAnswer slot — settle immediately.
692
738
  if (meta.name === 'AskUserQuestion') {
693
- this.proc?.sendPermissionResponse(req.request_id, 'allow')
739
+ const ask = this.pendingAsks.get(toolUseId)
740
+ if (!ask) {
741
+ // Defensive: addTool should have populated pendingAsks. If it
742
+ // didn't, fall back to a denial so Claude doesn't hang.
743
+ log(`session "${this.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
744
+ this.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
745
+ return
746
+ }
747
+ ask.requestId = req.request_id
748
+ this.pendingPermissions.set(req.request_id, { toolUseId })
749
+ if (ask.deferredAnswer) {
750
+ const { questionIdx, optionIdx, user } = ask.deferredAnswer
751
+ ask.deferredAnswer = undefined
752
+ this.resolveAsk(toolUseId, req.request_id, questionIdx, optionIdx, user)
753
+ }
694
754
  return
695
755
  }
696
756
  this.status = 'awaiting_permission'