@gavin-hu/my-pi-agent 0.8.1 → 0.10.0

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.
Files changed (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -39,7 +39,7 @@ export interface ReviewOutlineItem {
39
39
 
40
40
  /** Everything the browser needs to render one plan review. */
41
41
  export interface ReviewPageData {
42
- /** Plan title, derived from the file name. */
42
+ /** Plan title: the document's H1, or the file-name slug when it has none. */
43
43
  title: string;
44
44
  /** Source-line blocks of the feedback-stripped plan. */
45
45
  blocks: PlanBlock[];
@@ -49,6 +49,8 @@ export interface ReviewPageData {
49
49
  outline: ReviewOutlineItem[];
50
50
  /** Highlighted HTML per line, keyed by the code block's 0-based start line. */
51
51
  code: Record<string, string[]>;
52
+ /** Whether the review may approve the plan (only the `exit_plan_mode` flow). */
53
+ canApprove: boolean;
52
54
  /** Per-session secret the client echoes on every request. */
53
55
  token: string;
54
56
  }
@@ -58,59 +60,106 @@ export const REVIEW_CSS = `${WEB_BASE_CSS}
58
60
  ${WEB_THEME_CSS}
59
61
  ${WEB_LAYOUT_CSS}
60
62
  ${WEB_COMPONENTS_CSS}
61
- .bf-review{display:flex;flex-direction:column;padding:var(--space-5) var(--space-6) 96px}
63
+ .bf-review{--review-measure:1012px;--review-rail:26px;--review-gutter:34px;flex:1 1 auto;padding:var(--space-5) var(--space-6) 120px}
64
+ /* The document is one content column at GitHub's Markdown body width, with the
65
+ comment rail inside it, so a plan reads the way the same file does on GitHub. */
66
+ /* Sticky header: identity, the connection/save chip, the actions, and a
67
+ hairline reading-progress bar pinned to the header's bottom edge. */
62
68
  .review-bar{align-items:center;gap:var(--space-2) var(--space-4)}
63
69
  .review-bar .bf-brand{flex:0 0 auto}
64
70
  .review-heading{display:flex;align-items:center;flex:1 1 160px;min-width:0;overflow:hidden;border-left:1px solid var(--border);padding-left:var(--space-4)}
65
71
  .review-title{display:block;flex:1 1 auto;min-width:0;font-size:var(--fs-lg);font-weight:var(--fw-semibold);line-height:var(--lh-tight);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
72
+ .review-state{flex:0 0 auto;white-space:nowrap}
66
73
  .review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
67
74
  .review-status:empty{display:none}
68
75
  .review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
76
+ .review-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
77
+ .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
78
+ /* Review panel: the plan's review facts, the section outline, then the keys. */
69
79
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
70
- .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0}
80
+ .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
81
+ .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
82
+ .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
83
+ .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
84
+ .review-fact .bf-progress{color:var(--text)}
85
+ .review-fact .bf-progress__track{width:96px}
86
+ .review-fact__value{color:var(--text);font-family:var(--font-mono);font-variant-numeric:tabular-nums}
71
87
  .review-nav{min-width:0}
72
- .review-nav__title{margin:0;padding:var(--space-3) var(--space-4) var(--space-2);font:var(--fw-semibold) var(--fs-sm) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
88
+ .review-nav__title{margin:0;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
73
89
  .review-nav ul{list-style:none;margin:0;padding:0}
74
- .review-nav a{display:block;padding:4px 8px;border-radius:var(--radius-sm);color:var(--muted);font-size:var(--fs-sm);line-height:var(--lh-snug);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
90
+ .review-nav a{display:flex;align-items:baseline;gap:var(--space-2);padding:4px 8px;border-radius:var(--radius-sm);color:var(--muted);font-size:var(--fs-sm);line-height:var(--lh-snug)}
75
91
  .review-nav a:hover{background:var(--accent-soft);color:var(--accent);text-decoration:none}
76
92
  .review-nav a[aria-current="true"]{background:var(--accent-soft);color:var(--accent);font-weight:var(--fw-semibold)}
93
+ .review-nav__link{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
94
+ .review-nav__count{flex:0 0 auto;min-width:18px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fs-xs)/18px var(--font-mono);text-align:center}
77
95
  .review-nav .lvl-2 a{padding-left:18px}
78
96
  .review-nav .lvl-3 a{padding-left:30px}
79
97
  .review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:42px}
80
- .bf-review>.bf-block{position:relative;padding:2px 96px 2px 0;border-radius:var(--radius-md);overflow-wrap:break-word}
81
- .bf-block[id]{scroll-margin-top:76px}
82
- .bf-block:hover{background:var(--surface)}
83
- .bf-block h1,.bf-block h2,.bf-block h3,.bf-block h4,.bf-block h5,.bf-block h6{margin:20px 0 8px;line-height:var(--lh-tight)}
84
- .bf-block h1{font-size:24px;padding-bottom:6px;border-bottom:1px solid var(--border)}
85
- .bf-block h2{font-size:19px;padding-bottom:4px;border-bottom:1px solid var(--border)}
86
- .bf-block h3{font-size:16px}
87
- .bf-block p{margin:10px 0}
88
- .bf-block blockquote{margin:10px 0;padding:2px 14px;border-left:3px solid var(--border);color:var(--muted)}
89
- .bf-block__li{display:flex;gap:8px;margin:3px 0}
98
+ .review-hints{margin:auto 0 0;padding-top:var(--space-3);color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-relaxed)}
99
+ .review-hints .review-kbd{margin:0 1px}
100
+ /* The document: one measure, centred, with a comment rail in its left margin. */
101
+ .bf-review>.bf-block{position:relative;width:100%;max-width:var(--review-measure);margin:0 auto;padding:3px 8px 3px var(--review-gutter);border-radius:var(--radius-md);overflow-wrap:break-word}
102
+ .bf-block[id]{scroll-margin-top:88px}
103
+ .bf-block__rail{position:absolute;left:0;top:6px;display:flex;flex-direction:column;align-items:center;gap:6px;width:var(--review-rail)}
104
+ .bf-block__add{appearance:none;width:22px;height:22px;min-width:22px;padding:0;border:1px solid var(--border);border-radius:var(--radius-pill);background:var(--surface);color:var(--muted);font:var(--fs-md)/1 var(--font-ui);cursor:pointer;opacity:0;transition:opacity var(--motion-fast) var(--motion-ease)}
105
+ .bf-block__add:hover{border-color:var(--accent);color:var(--accent)}
106
+ .bf-block:hover .bf-block__add,.bf-block:focus-within .bf-block__add,.bf-block--active .bf-block__add,.bf-block--commented .bf-block__add{opacity:1}
107
+ .bf-block__count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-pill);background:var(--accent-soft);color:var(--accent);font:var(--fw-semibold) var(--fs-xs)/1 var(--font-mono)}
108
+ .bf-block:hover,.bf-block--active{background:var(--surface)}
109
+ .bf-block--active{box-shadow:inset 0 0 0 1px var(--border)}
110
+ .bf-block h1,.bf-block h2,.bf-block h3,.bf-block h4,.bf-block h5,.bf-block h6{margin:24px 0 8px;line-height:var(--lh-tight)}
111
+ .bf-block h1{font-size:var(--fs-xl);padding-bottom:6px;border-bottom:1px solid var(--border)}
112
+ .bf-block h2{font-size:var(--fs-lg);padding-bottom:4px;border-bottom:1px solid var(--border)}
113
+ .bf-block h3{font-size:17px}
114
+ .bf-block h4,.bf-block h5,.bf-block h6{font-size:var(--fs-base)}
115
+ .bf-block p{margin:11px 0;line-height:var(--lh-relaxed)}
116
+ .bf-block blockquote{margin:11px 0;padding:2px 14px;border-left:3px solid var(--border);color:var(--muted)}
117
+ .bf-block hr{border:0;border-top:1px solid var(--border);margin:22px 0}
118
+ .bf-block__li{display:flex;gap:8px;margin:4px 0;line-height:var(--lh-relaxed)}
90
119
  .bf-block__marker{flex:0 0 auto;min-width:1.4em;color:var(--muted)}
91
120
  .bf-block__marker--done{color:var(--success)}
92
- .bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:.1em .35em}
93
- .bf-block pre.bf-code{margin:10px 0;padding:var(--space-3) 0;overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);font:var(--fs-code)/1.55 var(--font-mono);tab-size:4;flex:none}
121
+ .bf-block code{font-family:var(--font-mono);font-size:.9em;background:var(--surface);border-radius:var(--radius-sm);padding:.1em .35em}
122
+ .bf-block pre.bf-code{margin:11px 0;padding:var(--space-3) 0;overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);font:var(--fs-code)/1.55 var(--font-mono);tab-size:4;flex:none}
94
123
  .bf-block pre.bf-code code{background:none;border:0;padding:0;font-size:inherit}
95
124
  .bf-block pre.bf-code .bf-code__line{display:block;padding:0 14px;white-space:pre;min-height:1.55em}
96
- .bf-block__add{position:absolute;top:4px;right:4px;display:inline-flex;align-items:center;gap:6px;opacity:0;font-size:var(--fs-xs);padding:2px 8px}
97
- .bf-block:hover .bf-block__add,.bf-block__add:focus-visible{opacity:1}
98
- .bf-block--composing .bf-block__add{opacity:0}
99
- .bf-comments{display:flex;flex-direction:column;gap:8px;margin:4px 0 14px;padding-left:var(--space-5)}
125
+ .review-table-wrap{margin:11px 0;overflow-x:auto}
126
+ .review-table{width:100%;border-collapse:collapse;font-size:var(--fs-sm)}
127
+ .review-table th,.review-table td{padding:6px 10px;border:1px solid var(--border);text-align:left;vertical-align:top}
128
+ .review-table th{background:var(--surface);color:var(--muted);font-weight:var(--fw-medium)}
129
+ /* Comment threads sit in the document flow, aligned with the block's text. */
130
+ .bf-comments{display:flex;flex-direction:column;gap:8px;width:100%;max-width:var(--review-measure);margin:0 auto 14px;padding-left:var(--review-gutter)}
100
131
  .bf-comments:empty{display:none}
101
- .bf-comments--orphans{margin-top:26px;padding:var(--space-3) 0 0;border-top:1px dashed var(--border)}
102
- .bf-comments--orphans p{margin:0 0 8px;color:var(--muted);font-size:var(--fs-sm)}
132
+ .bf-comments--orphans{margin-top:26px;padding:var(--space-3) 0 0 var(--review-gutter);border-top:1px dashed var(--border)}
133
+ .bf-comments--orphans>p{margin:0 0 8px;color:var(--muted);font-size:var(--fs-sm)}
134
+ .bf-comment{padding:var(--space-3) var(--space-4)}
135
+ .bf-comment__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:6px}
136
+ .bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
137
+ .bf-comment__spacer{flex:1 1 auto}
138
+ .bf-comment__actions{display:flex;gap:4px}
139
+ .bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
140
+ .bf-comment__quote{margin-bottom:6px;padding-left:8px;border-left:2px solid var(--border);color:var(--muted);font-size:var(--fs-sm);overflow-wrap:anywhere}
141
+ .bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:var(--lh-snug)}
142
+ .bf-composer{margin:0}
143
+ .bf-block--composing .bf-block__add{opacity:0}
144
+ /* The keyboard sheet, reusing the shared dialog shell. */
145
+ .bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
146
+ .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
147
+ .review-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
148
+ .review-keys dt{white-space:nowrap}
149
+ .review-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
150
+ .review-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
103
151
  @media (max-width:1180px){
104
152
  .bf-app__body{grid-template-columns:1fr;align-content:start}
105
153
  .bf-app__sidebar{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--border)}
106
- .review-nav{padding:4px 16px 12px}
154
+ .review-panel{padding:var(--space-2) var(--space-4) var(--space-3)}
155
+ .review-facts{flex-direction:row;flex-wrap:wrap;gap:var(--space-3) var(--space-5);border-bottom:0;padding-bottom:0}
156
+ .review-nav__title{padding:0}
157
+ .review-nav ul{display:flex;flex-wrap:wrap;gap:2px}
158
+ .review-nav .lvl-2 a,.review-nav .lvl-3 a,.review-nav .lvl-4 a,.review-nav .lvl-5 a,.review-nav .lvl-6 a{padding-left:8px}
159
+ .review-hints{display:none}
107
160
  }
108
161
  @media (max-width:860px){
109
162
  .bf-review{padding:var(--space-4) 18px 80px}
110
- .bf-review>.bf-block{padding-right:36px}
111
- .bf-block__add-label{display:none}
112
- }
113
- @media (max-width:860px){
114
163
  .review-nav__title{display:none}
115
164
  }
116
165
  @media (max-width:820px){
@@ -119,10 +168,41 @@ ${WEB_COMPONENTS_CSS}
119
168
  .review-controls{order:2;margin-left:auto}
120
169
  .review-status{order:4;flex:1 1 100%;max-width:none;white-space:normal}
121
170
  }
122
- @media (prefers-reduced-motion:no-preference){.bf-block{transition:background .08s ease}}
171
+ @media (prefers-reduced-motion:no-preference){.bf-block{transition:background var(--motion-fast) var(--motion-ease)}}
123
172
  @media (hover:none){.bf-block__add{opacity:1}}
124
173
  `.trim();
125
174
 
175
+ /**
176
+ * The sidebar's one-line keyboard reminder. It names the keys a reviewer reaches
177
+ * for first and defers to the shortcut sheet (`?`) for the rest, so the line does
178
+ * not have to grow every time a binding is added.
179
+ */
180
+ function reviewHints(canApprove: boolean): string {
181
+ const approve = canApprove ? `, <kbd class="review-kbd">a</kbd> to approve` : "";
182
+ return `<p class="review-hints">Press <kbd class="review-kbd">j</kbd>/<kbd class="review-kbd">k</kbd> to move, <kbd class="review-kbd">c</kbd> to comment${approve}, <kbd class="review-kbd">s</kbd> to save, <kbd class="review-kbd">?</kbd> for all shortcuts.</p>`;
183
+ }
184
+
185
+ /** The keyboard sheet, hidden until the client opens it. */
186
+ function keysDialog(canApprove: boolean): string {
187
+ const approveRow = canApprove
188
+ ? `<div><dt><kbd class="review-kbd">a</kbd></dt><dd>Approve and run the plan</dd></div>
189
+ `
190
+ : "";
191
+ return `<div class="bf-dialog" id="keys-dialog" hidden>
192
+ <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
193
+ <div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
194
+ <dl class="review-keys">
195
+ ${approveRow}<div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
196
+ <div><dt><kbd class="review-kbd">c</kbd></dt><dd>Comment on the current block</dd></div>
197
+ <div><dt><kbd class="review-kbd">Enter</kbd></dt><dd>Add the comment you are writing</dd></div>
198
+ <div><dt><kbd class="review-kbd">s</kbd></dt><dd>Save feedback and return to Pi</dd></div>
199
+ <div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer, the sheet, or the review</dd></div>
200
+ <div><dt><kbd class="review-kbd">?</kbd></dt><dd>Toggle this sheet</dd></div>
201
+ </dl>
202
+ </div>
203
+ </div>`;
204
+ }
205
+
126
206
  /**
127
207
  * Render the review page for `data`. The markup is a fixed shell; the client
128
208
  * fills every dynamic field, so this function is the only place that needs the
@@ -135,10 +215,21 @@ export function renderReviewPage(data: ReviewPageData): string {
135
215
  const outlinePanel = hasOutline
136
216
  ? `<details class="bf-app__sidebar review-outline" open>
137
217
  <summary class="bf-panel__summary">Sections</summary>
138
- <nav class="bf-panel review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
218
+ <div class="bf-panel review-panel">
219
+ ${reviewFacts()}
220
+ <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
221
+ ${reviewHints(data.canApprove)}
222
+ </div>
139
223
  </details>
140
224
  `
141
225
  : "";
226
+ // Approve is the decisive action, so it takes the primary button and Save is
227
+ // demoted; a comment-only review has no approve button and keeps Save primary.
228
+ const approveButton = data.canApprove
229
+ ? `
230
+ <button type="button" class="bf-btn bf-btn--primary" id="approve">Approve &amp; run</button>`
231
+ : "";
232
+ const saveButton = `<button type="button" class="${data.canApprove ? "bf-btn" : "bf-btn bf-btn--primary"}" id="save">Save feedback</button>`;
142
233
  return `<!doctype html>
143
234
  <html lang="en">
144
235
  <head>
@@ -150,20 +241,34 @@ ${docHead({ title: "Plan review", css: REVIEW_CSS })}
150
241
  ${brandLockup("plan review")}
151
242
  <div class="review-heading">
152
243
  <span class="review-title" id="plan-title">Plan review</span>
244
+ <span class="bf-chip bf-chip--accent review-state" id="review-state">Connecting&#8230;</span>
153
245
  </div>
154
246
  <span class="bf-status review-status" id="status" role="status" aria-live="polite"></span>
155
247
  <div class="review-controls">
156
248
  ${themeToggle()}
157
- <button type="button" class="bf-btn bf-btn--primary" id="save">Save feedback</button>
158
- <button type="button" class="bf-btn" id="cancel">Close</button>
249
+ <button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>${approveButton}
250
+ ${saveButton}
251
+ <button type="button" class="bf-btn" id="cancel">Back to Pi</button>
159
252
  </div>
160
- </div></header>
253
+ </div><div class="review-progress" aria-hidden="true"><span class="review-progress__fill" id="progress-fill"></span></div></header>
161
254
  <div class="${bodyClass}">
162
255
  ${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
163
256
  </div>
257
+ ${keysDialog(data.canApprove)}
164
258
  <script type="application/json" id="review-data">${island}</script>
165
259
  <script src="/client.js" defer></script>
166
260
  </body>
167
261
  </html>
168
262
  `;
169
263
  }
264
+
265
+ /**
266
+ * The review facts panel: the plan's step progress and total comment count.
267
+ * Static placeholders; the client fills them from the blocks it rendered.
268
+ */
269
+ function reviewFacts(): string {
270
+ return `<div class="review-facts" id="review-facts">
271
+ <div class="review-fact"><span>Steps</span><span class="bf-progress"><span class="bf-progress__track"><span class="bf-progress__fill" id="step-fill"></span></span><span class="review-fact__value" id="step-count">0/0</span></span></div>
272
+ <div class="review-fact"><span>Comments</span><span class="review-fact__value" id="comment-total">0</span></div>
273
+ </div>`;
274
+ }
@@ -12,9 +12,9 @@
12
12
  * local page from driving the session.
13
13
  */
14
14
 
15
- import { REVIEW_CLIENT_JS } from "./web-client.ts";
16
15
  import { THEME_JS } from "../../lib/web/index.ts";
17
16
  import { coerceAnnotations, type PlanAnnotation } from "./feedback.ts";
17
+ import { REVIEW_CLIENT_JS } from "./web-client.ts";
18
18
  import { renderReviewPage, type ReviewPageData } from "./web-page.ts";
19
19
 
20
20
  /** What a save callback reports back. */
@@ -24,13 +24,17 @@ export type SaveResult = { ok: true } | { ok: false; status: number; message: st
24
24
  export interface ReviewContext {
25
25
  /** Per-session secret required on the page, event stream, and API routes. */
26
26
  token: string;
27
+ /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
28
+ canApprove: boolean;
27
29
  /** Build the page data (recomputed per request from the current plan). */
28
30
  page: () => Promise<ReviewPageData>;
29
31
  /** Persist the annotations and report the outcome. */
30
32
  save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
31
- /** Called after a successful save, to resolve the session. */
33
+ /** Called after a successful save; the server resolves the session once the response has flushed. */
32
34
  onSaved: (annotations: PlanAnnotation[]) => void;
33
- /** Called when the user cancels, to resolve the session. */
35
+ /** Called after a successful approve; the server resolves the session once the response has flushed. */
36
+ onApprove: (annotations: PlanAnnotation[]) => void;
37
+ /** Called when the user cancels; the server resolves the session once the response has flushed. */
34
38
  onCancel: () => void;
35
39
  }
36
40
 
@@ -80,6 +84,27 @@ function errorResponse(status: number, message: string): ServeResponse {
80
84
  return jsonResponse(status, { error: message });
81
85
  }
82
86
 
87
+ /**
88
+ * Parse and validate an `{ annotations }` request body. Returns the annotations,
89
+ * or an error response to send instead.
90
+ */
91
+ function parseAnnotations(body: string): PlanAnnotation[] | ServeResponse {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(body);
95
+ } catch {
96
+ return errorResponse(400, "The request body must be JSON.");
97
+ }
98
+ const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
99
+ if (!annotations) return errorResponse(400, "The annotations are malformed.");
100
+ return annotations;
101
+ }
102
+
103
+ /** A parsed body is either the annotations or the error response to send. */
104
+ function isResponse(value: PlanAnnotation[] | ServeResponse): value is ServeResponse {
105
+ return !Array.isArray(value);
106
+ }
107
+
83
108
  /** Validate method and `Host`; returns an error response to send instead. */
84
109
  function checkEnvelope(
85
110
  method: string,
@@ -89,8 +114,10 @@ function checkEnvelope(
89
114
  if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
90
115
  return { ...errorResponse(405, "Only GET and POST are served."), headers: { ...JSON_HEADERS, Allow: "GET, POST" } };
91
116
  }
117
+ // Fail closed: a missing or non-string Host is rejected too, not just a
118
+ // foreign one, so the DNS-rebinding guard cannot be skipped.
92
119
  const host = headers.host;
93
- if (typeof host === "string" && host && !HOST_PATTERN.test(host)) {
120
+ if (typeof host !== "string" || !HOST_PATTERN.test(host)) {
94
121
  return errorResponse(403, "Unexpected Host header.");
95
122
  }
96
123
  return undefined;
@@ -120,7 +147,7 @@ export async function handleReviewRequest(
120
147
  return errorResponse(400, "Malformed request URL.");
121
148
  }
122
149
 
123
- if (pathname === "/favicon.ico") return { status: 204, headers: { ...SCRIPT_HEADERS } };
150
+ if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
124
151
  if (pathname === "/theme.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: THEME_JS };
125
152
  if (pathname === "/client.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: REVIEW_CLIENT_JS };
126
153
 
@@ -137,19 +164,25 @@ export async function handleReviewRequest(
137
164
  }
138
165
  if (pathname === "/api/feedback") {
139
166
  if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to save feedback.");
140
- let parsed: unknown;
141
- try {
142
- parsed = JSON.parse(body);
143
- } catch {
144
- return errorResponse(400, "The request body must be JSON.");
145
- }
146
- const annotations = coerceAnnotations((parsed as { annotations?: unknown } | null)?.annotations);
147
- if (!annotations) return errorResponse(400, "The annotations are malformed.");
167
+ const annotations = parseAnnotations(body);
168
+ if (isResponse(annotations)) return annotations;
148
169
  const result = await ctx.save(annotations);
149
170
  if (!result.ok) return errorResponse(result.status, result.message);
150
171
  ctx.onSaved(annotations);
151
172
  return jsonResponse(200, { ok: true });
152
173
  }
174
+ if (pathname === "/api/approve") {
175
+ if (method.toUpperCase() !== "POST") return errorResponse(405, "Use POST to approve.");
176
+ // Only the exit_plan_mode flow owns the approval side effects, so a comment-only
177
+ // review refuses this even if a client posts it directly.
178
+ if (!ctx.canApprove) return errorResponse(403, "This review cannot be approved from the browser.");
179
+ const annotations = parseAnnotations(body);
180
+ if (isResponse(annotations)) return annotations;
181
+ const result = await ctx.save(annotations);
182
+ if (!result.ok) return errorResponse(result.status, result.message);
183
+ ctx.onApprove(annotations);
184
+ return jsonResponse(200, { ok: true });
185
+ }
153
186
 
154
187
  return errorResponse(404, "That page does not exist.");
155
188
  }
@@ -24,6 +24,7 @@ import type { ReviewPageData } from "./web-page.ts";
24
24
  /** How a browser review ended. */
25
25
  export type PlanReviewResult =
26
26
  | { status: "saved"; plan: StoredPlan; annotations: PlanAnnotation[] }
27
+ | { status: "approved"; plan: StoredPlan; annotations: PlanAnnotation[] }
27
28
  | { status: "cancelled" };
28
29
 
29
30
  /** A running review the caller awaits and then closes. */
@@ -38,9 +39,17 @@ export interface PlanReviewSession {
38
39
  close(): Promise<void>;
39
40
  }
40
41
 
42
+ /** Per-session options for {@link PlanWebReview.start}. */
43
+ export interface PlanReviewOptions {
44
+ /** Abort signal; aborting closes the session as cancelled. */
45
+ signal?: AbortSignal;
46
+ /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
47
+ canApprove?: boolean;
48
+ }
49
+
41
50
  /** Starts and tracks review sessions. */
42
51
  export interface PlanWebReview {
43
- start(cwd: string, plan: StoredPlan, signal?: AbortSignal): Promise<PlanReviewSession | undefined>;
52
+ start(cwd: string, plan: StoredPlan, options?: PlanReviewOptions): Promise<PlanReviewSession | undefined>;
44
53
  /** Stop every running session (session shutdown). */
45
54
  closeAll(): Promise<void>;
46
55
  }
@@ -92,8 +101,17 @@ async function listen(server: Server, port: number): Promise<number> {
92
101
 
93
102
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
94
103
  const sessions = new Set<PlanReviewSession>();
104
+ // Last bound port per plan path, so the same plan keeps one origin across
105
+ // review turns (and its saved browser theme with it).
106
+ const ports = new Map<string, number>();
95
107
 
96
- const start = async (cwd: string, plan: StoredPlan, signal?: AbortSignal): Promise<PlanReviewSession | undefined> => {
108
+ const start = async (
109
+ cwd: string,
110
+ plan: StoredPlan,
111
+ options: PlanReviewOptions = {},
112
+ ): Promise<PlanReviewSession | undefined> => {
113
+ const { signal } = options;
114
+ const canApprove = options.canApprove === true;
97
115
  const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
98
116
  let settled = false;
99
117
  let resolveDone!: (result: PlanReviewResult) => void;
@@ -113,11 +131,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
113
131
  // that still matches it, or the comments land beside the wrong text.
114
132
  let baseline = stripFeedback(plan.content);
115
133
  let savedPlan: StoredPlan | undefined;
134
+ // Set by `onSaved`/`onCancel`; resolved once the HTTP response has flushed,
135
+ // so the caller cannot close the socket before the browser sees the reply.
136
+ let pendingResult: PlanReviewResult | undefined;
116
137
 
117
138
  const page = async (): Promise<ReviewPageData> => {
118
139
  const current = (await store.read(cwd, plan.path)) ?? plan;
119
140
  baseline = stripFeedback(current.content);
120
141
  const blocks = planBlocks(baseline);
142
+ const headings = planHeadings(blocks);
121
143
  // Highlight code blocks once, at the boundary: the client copies only the
122
144
  // tok-* spans out of the server-escaped HTML.
123
145
  const code: Record<string, string[]> = {};
@@ -127,10 +149,12 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
127
149
  if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
128
150
  }
129
151
  return {
130
- title: planTitle(basename(current.path)),
152
+ // The document's own H1 reads better than the file-name slug, and the
153
+ // slug is still the fallback for a plan that starts with something else.
154
+ title: headings.find((heading) => heading.level === 1)?.text || planTitle(basename(current.path)),
131
155
  blocks,
132
156
  annotations: parseFeedback(current.content),
133
- outline: planHeadings(blocks).map((heading) => ({
157
+ outline: headings.map((heading) => ({
134
158
  id: `s-${heading.line}`,
135
159
  line: heading.line,
136
160
  level: heading.level,
@@ -138,6 +162,7 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
138
162
  })),
139
163
  code,
140
164
  token,
165
+ canApprove,
141
166
  };
142
167
  };
143
168
 
@@ -157,13 +182,18 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
157
182
 
158
183
  const context: ReviewContext = {
159
184
  token,
185
+ canApprove,
160
186
  page,
161
187
  save,
162
188
  onSaved: (annotations) => {
163
- if (savedPlan) finish({ status: "saved", plan: savedPlan, annotations });
164
- else finish({ status: "cancelled" });
189
+ pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
190
+ },
191
+ onApprove: (annotations) => {
192
+ pendingResult = savedPlan ? { status: "approved", plan: savedPlan, annotations } : { status: "cancelled" };
193
+ },
194
+ onCancel: () => {
195
+ pendingResult = { status: "cancelled" };
165
196
  },
166
- onCancel: () => finish({ status: "cancelled" }),
167
197
  };
168
198
 
169
199
  // Live event streams, used only to notice a closed tab. The explicit Cancel
@@ -218,23 +248,44 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
218
248
  response.end();
219
249
  return;
220
250
  }
221
- response.end(result.body ?? "");
251
+ // A save or cancel only resolves the session after its response is on the
252
+ // wire; otherwise `close()` would force-close the socket mid-reply.
253
+ response.end(result.body ?? "", () => {
254
+ if (!pendingResult) return;
255
+ const terminal = pendingResult;
256
+ pendingResult = undefined;
257
+ finish(terminal);
258
+ });
222
259
  })();
223
260
  });
224
261
 
225
- let port: number;
262
+ const preferred = deps.port ?? ports.get(plan.path) ?? 0;
263
+ let port: number | undefined;
226
264
  try {
227
- port = await listen(server, deps.port ?? 0);
265
+ port = await listen(server, preferred);
228
266
  } catch {
267
+ // The remembered port may have been taken between turns; fall back once.
268
+ if (preferred !== 0) {
269
+ try {
270
+ port = await listen(server, 0);
271
+ } catch {
272
+ port = undefined;
273
+ }
274
+ }
275
+ }
276
+ if (port === undefined) {
229
277
  server.close();
230
278
  return undefined;
231
279
  }
280
+ ports.set(plan.path, port);
232
281
 
233
282
  const url = `http://127.0.0.1:${port}/?token=${token}`;
234
283
  let closed = false;
284
+ let abortHandler: (() => void) | undefined;
235
285
  const close = async (): Promise<void> => {
236
286
  if (closed) return;
237
287
  closed = true;
288
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
238
289
  sessions.delete(session);
239
290
  for (const client of clients) client.end();
240
291
  clients.clear();
@@ -249,9 +300,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
249
300
  sessions.add(session);
250
301
 
251
302
  if (signal) {
252
- const onAbort = (): void => void close();
303
+ abortHandler = (): void => void close();
253
304
  if (signal.aborted) void close();
254
- else signal.addEventListener("abort", onAbort, { once: true });
305
+ else signal.addEventListener("abort", abortHandler, { once: true });
255
306
  }
256
307
 
257
308
  (deps.open ?? openInBrowser)(url);
@@ -4,12 +4,20 @@ A single model-facing tool, `ask_user_question`, that lets the model ask the
4
4
  human one or more structured questions mid-turn and wait for typed answers.
5
5
  Modelled on Claude Code's `AskUserQuestion`.
6
6
 
7
+ <img src="../../assets/question.png" alt="The ask_user_question dialog: Auth and Scope tabs, a question, four numbered options with descriptions, and the navigation hint" width="100%">
8
+
7
9
  ```
8
10
  pi --extension ./extensions/question # load just this extension
9
11
  pi -e . # load the whole @gavin-hu/my-pi-agent package
10
12
  pi install ./ # install the package
11
13
  ```
12
14
 
15
+ ## Tool rendering
16
+
17
+ How the `ask_user_question` call and its result look in the transcript — the call line as a headline and the body indented under it:
18
+
19
+ <img src="../../assets/tools/ask_user_question.png" alt="The ask_user_question tool's call and result: the call line and an indented body with the question headers, the options, and the answers" width="100%">
20
+
13
21
  ## What it does
14
22
 
15
23
  The model calls `ask_user_question` with 1–4 questions. Each question offers up
@@ -162,7 +170,11 @@ None in v1 — the tool is either available (a UI exists) or not.
162
170
  - **Transcript rendering is driven by `details`.** `renderCall` summarises the
163
171
  question count and headers and lists one option line per question;
164
172
  `renderResult` renders `✓ Auth: 1. OAuth` per answer, `(wrote)` for custom
165
- text, warning-colored `Cancelled`, and dim `No UI` when unavailable.
173
+ text, warning-colored `Cancelled`, and dim `No UI` when unavailable. The call
174
+ summary reads the call's *streaming* args, so a field that has not arrived yet
175
+ — a question `header`, an option `label` — reads as absent instead of
176
+ throwing; a throwing renderer makes Pi fall back to its own call line, a
177
+ column to the left of the framed one.
166
178
 
167
179
  ## Files
168
180
 
@@ -173,7 +185,7 @@ None in v1 — the tool is either available (a UI exists) or not.
173
185
  | `types.ts` | Shared raw/normalized types |
174
186
  | `answers.ts` | Answer normalization and model-facing formatting (pure) |
175
187
  | `tui-state.ts` | Pure keyboard/navigation state machine |
176
- | `tui.ts` | Custom terminal component (render + input) |
188
+ | `tui.ts` | Custom terminal component (render + input), drawn from the `lib/tui/` kit (`titleRule`/`plainRule`, `checkbox`, `flowCells`, `screenHint`, `editorTheme`). |
177
189
  | `dialogs.ts` | `select`/`input`/`editor` fallback driver |
178
190
  | `tools.ts` | Tool registration and transcript renderers |
179
191
 
@@ -80,37 +80,44 @@ export function formatAnswerText(result: AskResult): string {
80
80
  return lines.join("\n");
81
81
  }
82
82
 
83
- /** Minimal shape needed to summarize a call, before or after normalization. */
83
+ /**
84
+ * Minimal shape needed to summarize a call, before or after normalization.
85
+ *
86
+ * The transcript renderer reads this from a call's *streaming* args, where a
87
+ * field can be missing or still be a fragment: a partial parse can hand back an
88
+ * option before its label has arrived. Every field is therefore `unknown`, and
89
+ * the helpers below read a non-string as absent rather than throw.
90
+ */
84
91
  export interface CallQuestion {
85
- header?: string;
86
- options?: { label: string }[];
92
+ header?: unknown;
93
+ options?: unknown;
94
+ }
95
+
96
+ /** A partial value read as a single line; anything but a string reads empty. */
97
+ function asLine(value: unknown): string {
98
+ return typeof value === "string" ? sanitizeLine(value) : "";
87
99
  }
88
100
 
89
101
  /** Display headers with positional defaults (`Q1`, `Q2`, …). */
90
102
  export function questionHeaders(questions: CallQuestion[]): string[] {
91
- return questions.map((question, i) => sanitizeLine(question?.header?.trim() || `Q${i + 1}`));
103
+ return questions.map((question, i) => asLine(question?.header) || `Q${i + 1}`);
92
104
  }
93
105
 
94
106
  /** One `Header: a, b, Other` summary per question that offers options. */
95
107
  export function optionSummary(questions: CallQuestion[]): string[] {
96
108
  return questions
97
- .map((question, i) => ({ question, header: question?.header?.trim() || `Q${i + 1}` }))
98
- .filter(({ question }) => (question?.options?.length ?? 0) > 0)
99
- .map(({ question, header }) => {
100
- const labels = question.options?.map((option) => sanitizeLine(option.label)).join(", ");
101
- return `${sanitizeLine(header)}: ${labels}, Other`;
109
+ .map((question, i) => ({ header: asLine(question?.header) || `Q${i + 1}`, options: question?.options }))
110
+ .filter(({ options }) => Array.isArray(options) && options.length > 0)
111
+ .map(({ header, options }) => {
112
+ // A partial option has no label yet; list the ones that have arrived.
113
+ const labels = (options as unknown[])
114
+ .map((option) => (option as { label?: unknown } | undefined)?.label)
115
+ .filter((label): label is string => typeof label === "string")
116
+ .map((label) => sanitizeLine(label));
117
+ return `${header}: ${labels.length > 0 ? `${labels.join(", ")}, Other` : "Other"}`;
102
118
  });
103
119
  }
104
120
 
105
- /** One-line summary of the questions, used by the transcript call renderer. */
106
- export function formatCallText(questions: CallQuestion[]): string {
107
- const noun = questions.length === 1 ? "question" : "questions";
108
- const lines = [`ask_user_question ${questions.length} ${noun} (${questionHeaders(questions).join(", ")})`];
109
- const options = optionSummary(questions);
110
- if (options.length > 0) lines.push(` Options: ${options.join(" · ")}`);
111
- return lines.join("\n");
112
- }
113
-
114
121
  /** Human-readable summary of a selection, used in the Submit tab. */
115
122
  export function describeSelection(selection: Selection | undefined): string {
116
123
  if (!selection) return "(unanswered)";