@gavin-hu/my-pi-agent 0.8.0 → 0.9.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 (98) hide show
  1. package/README.md +9 -8
  2. package/README.zh-CN.md +8 -8
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +62 -14
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +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[];
@@ -58,59 +58,106 @@ export const REVIEW_CSS = `${WEB_BASE_CSS}
58
58
  ${WEB_THEME_CSS}
59
59
  ${WEB_LAYOUT_CSS}
60
60
  ${WEB_COMPONENTS_CSS}
61
- .bf-review{display:flex;flex-direction:column;padding:var(--space-5) var(--space-6) 96px}
61
+ .bf-review{--review-measure:1012px;--review-rail:26px;--review-gutter:34px;flex:1 1 auto;padding:var(--space-5) var(--space-6) 120px}
62
+ /* The document is one content column at GitHub's Markdown body width, with the
63
+ comment rail inside it, so a plan reads the way the same file does on GitHub. */
64
+ /* Sticky header: identity, the connection/save chip, the actions, and a
65
+ hairline reading-progress bar pinned to the header's bottom edge. */
62
66
  .review-bar{align-items:center;gap:var(--space-2) var(--space-4)}
63
67
  .review-bar .bf-brand{flex:0 0 auto}
64
68
  .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
69
  .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}
70
+ .review-state{flex:0 0 auto;white-space:nowrap}
66
71
  .review-status{flex:0 0 auto;max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
67
72
  .review-status:empty{display:none}
68
73
  .review-controls{display:inline-flex;align-items:center;gap:var(--space-2);flex:0 0 auto}
74
+ .review-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
75
+ .review-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
76
+ /* Review panel: the plan's review facts, the section outline, then the keys. */
69
77
  .bf-app__body--single{grid-template-columns:minmax(0,1fr)}
70
- .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0}
78
+ .bf-app__sidebar .bf-panel{flex:1 1 auto;min-height:0;min-width:0}
79
+ .review-panel{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4) var(--space-4) var(--space-2)}
80
+ .review-facts{display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-3);border-bottom:1px solid var(--border)}
81
+ .review-fact{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);font-size:var(--fs-sm);color:var(--muted)}
82
+ .review-fact .bf-progress{color:var(--text)}
83
+ .review-fact .bf-progress__track{width:96px}
84
+ .review-fact__value{color:var(--text);font-family:var(--font-mono);font-variant-numeric:tabular-nums}
71
85
  .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}
86
+ .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
87
  .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}
88
+ .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
89
  .review-nav a:hover{background:var(--accent-soft);color:var(--accent);text-decoration:none}
76
90
  .review-nav a[aria-current="true"]{background:var(--accent-soft);color:var(--accent);font-weight:var(--fw-semibold)}
91
+ .review-nav__link{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
+ .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
93
  .review-nav .lvl-2 a{padding-left:18px}
78
94
  .review-nav .lvl-3 a{padding-left:30px}
79
95
  .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}
96
+ .review-hints{margin:auto 0 0;padding-top:var(--space-3);color:var(--muted);font-size:var(--fs-xs);line-height:var(--lh-relaxed)}
97
+ .review-hints .review-kbd{margin:0 1px}
98
+ /* The document: one measure, centred, with a comment rail in its left margin. */
99
+ .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}
100
+ .bf-block[id]{scroll-margin-top:88px}
101
+ .bf-block__rail{position:absolute;left:0;top:6px;display:flex;flex-direction:column;align-items:center;gap:6px;width:var(--review-rail)}
102
+ .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)}
103
+ .bf-block__add:hover{border-color:var(--accent);color:var(--accent)}
104
+ .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}
105
+ .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)}
106
+ .bf-block:hover,.bf-block--active{background:var(--surface)}
107
+ .bf-block--active{box-shadow:inset 0 0 0 1px var(--border)}
108
+ .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)}
109
+ .bf-block h1{font-size:var(--fs-xl);padding-bottom:6px;border-bottom:1px solid var(--border)}
110
+ .bf-block h2{font-size:var(--fs-lg);padding-bottom:4px;border-bottom:1px solid var(--border)}
111
+ .bf-block h3{font-size:17px}
112
+ .bf-block h4,.bf-block h5,.bf-block h6{font-size:var(--fs-base)}
113
+ .bf-block p{margin:11px 0;line-height:var(--lh-relaxed)}
114
+ .bf-block blockquote{margin:11px 0;padding:2px 14px;border-left:3px solid var(--border);color:var(--muted)}
115
+ .bf-block hr{border:0;border-top:1px solid var(--border);margin:22px 0}
116
+ .bf-block__li{display:flex;gap:8px;margin:4px 0;line-height:var(--lh-relaxed)}
90
117
  .bf-block__marker{flex:0 0 auto;min-width:1.4em;color:var(--muted)}
91
118
  .bf-block__marker--done{color:var(--success)}
92
119
  .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}
120
+ .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
121
  .bf-block pre.bf-code code{background:none;border:0;padding:0;font-size:inherit}
95
122
  .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)}
123
+ .review-table-wrap{margin:11px 0;overflow-x:auto}
124
+ .review-table{width:100%;border-collapse:collapse;font-size:var(--fs-sm)}
125
+ .review-table th,.review-table td{padding:6px 10px;border:1px solid var(--border);text-align:left;vertical-align:top}
126
+ .review-table th{background:var(--surface);color:var(--muted);font-weight:var(--fw-medium)}
127
+ /* Comment threads sit in the document flow, aligned with the block's text. */
128
+ .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
129
  .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)}
130
+ .bf-comments--orphans{margin-top:26px;padding:var(--space-3) 0 0 var(--review-gutter);border-top:1px dashed var(--border)}
131
+ .bf-comments--orphans>p{margin:0 0 8px;color:var(--muted);font-size:var(--fs-sm)}
132
+ .bf-comment{padding:var(--space-3) var(--space-4)}
133
+ .bf-comment__head{display:flex;align-items:center;gap:var(--space-2);margin-bottom:6px}
134
+ .bf-comment__label{font:var(--fw-semibold) var(--fs-xs) var(--font-mono);color:var(--warn);text-transform:uppercase;letter-spacing:.06em}
135
+ .bf-comment__spacer{flex:1 1 auto}
136
+ .bf-comment__actions{display:flex;gap:4px}
137
+ .bf-comment__actions .bf-btn{padding:1px 8px;font-size:var(--fs-xs)}
138
+ .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}
139
+ .bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere;line-height:var(--lh-snug)}
140
+ .bf-composer{margin:0}
141
+ .bf-block--composing .bf-block__add{opacity:0}
142
+ /* The keyboard sheet, reusing the shared dialog shell. */
143
+ .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)}
144
+ .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
145
+ .review-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
146
+ .review-keys dt{white-space:nowrap}
147
+ .review-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
148
+ .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
149
  @media (max-width:1180px){
104
150
  .bf-app__body{grid-template-columns:1fr;align-content:start}
105
151
  .bf-app__sidebar{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--border)}
106
- .review-nav{padding:4px 16px 12px}
152
+ .review-panel{padding:var(--space-2) var(--space-4) var(--space-3)}
153
+ .review-facts{flex-direction:row;flex-wrap:wrap;gap:var(--space-3) var(--space-5);border-bottom:0;padding-bottom:0}
154
+ .review-nav__title{padding:0}
155
+ .review-nav ul{display:flex;flex-wrap:wrap;gap:2px}
156
+ .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}
157
+ .review-hints{display:none}
107
158
  }
108
159
  @media (max-width:860px){
109
160
  .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
161
  .review-nav__title{display:none}
115
162
  }
116
163
  @media (max-width:820px){
@@ -119,10 +166,25 @@ ${WEB_COMPONENTS_CSS}
119
166
  .review-controls{order:2;margin-left:auto}
120
167
  .review-status{order:4;flex:1 1 100%;max-width:none;white-space:normal}
121
168
  }
122
- @media (prefers-reduced-motion:no-preference){.bf-block{transition:background .08s ease}}
169
+ @media (prefers-reduced-motion:no-preference){.bf-block{transition:background var(--motion-fast) var(--motion-ease)}}
123
170
  @media (hover:none){.bf-block__add{opacity:1}}
124
171
  `.trim();
125
172
 
173
+ /** The keyboard sheet, hidden until the client opens it. */
174
+ const KEYS_DIALOG = `<div class="bf-dialog" id="keys-dialog" hidden>
175
+ <div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
176
+ <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>
177
+ <dl class="review-keys">
178
+ <div><dt><kbd class="review-kbd">j</kbd> <kbd class="review-kbd">k</kbd></dt><dd>Next and previous block</dd></div>
179
+ <div><dt><kbd class="review-kbd">c</kbd></dt><dd>Comment on the current block</dd></div>
180
+ <div><dt><kbd class="review-kbd">Enter</kbd></dt><dd>Add the comment you are writing</dd></div>
181
+ <div><dt><kbd class="review-kbd">s</kbd></dt><dd>Save feedback and return to Pi</dd></div>
182
+ <div><dt><kbd class="review-kbd">Esc</kbd></dt><dd>Close the composer or this sheet</dd></div>
183
+ <div><dt><kbd class="review-kbd">?</kbd></dt><dd>Toggle this sheet</dd></div>
184
+ </dl>
185
+ </div>
186
+ </div>`;
187
+
126
188
  /**
127
189
  * Render the review page for `data`. The markup is a fixed shell; the client
128
190
  * fills every dynamic field, so this function is the only place that needs the
@@ -135,7 +197,11 @@ export function renderReviewPage(data: ReviewPageData): string {
135
197
  const outlinePanel = hasOutline
136
198
  ? `<details class="bf-app__sidebar review-outline" open>
137
199
  <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>
200
+ <div class="bf-panel review-panel">
201
+ ${reviewFacts()}
202
+ <nav class="review-nav" aria-labelledby="outline-title"><p class="review-nav__title" id="outline-title">Sections</p><ul id="outline-list"></ul></nav>
203
+ <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, <kbd class="review-kbd">s</kbd> to save.</p>
204
+ </div>
139
205
  </details>
140
206
  `
141
207
  : "";
@@ -150,20 +216,34 @@ ${docHead({ title: "Plan review", css: REVIEW_CSS })}
150
216
  ${brandLockup("plan review")}
151
217
  <div class="review-heading">
152
218
  <span class="review-title" id="plan-title">Plan review</span>
219
+ <span class="bf-chip bf-chip--accent review-state" id="review-state">Connecting&#8230;</span>
153
220
  </div>
154
221
  <span class="bf-status review-status" id="status" role="status" aria-live="polite"></span>
155
222
  <div class="review-controls">
156
223
  ${themeToggle()}
224
+ <button type="button" class="bf-btn bf-btn--icon" id="shortcuts" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
157
225
  <button type="button" class="bf-btn bf-btn--primary" id="save">Save feedback</button>
158
226
  <button type="button" class="bf-btn" id="cancel">Close</button>
159
227
  </div>
160
- </div></header>
228
+ </div><div class="review-progress" aria-hidden="true"><span class="review-progress__fill" id="progress-fill"></span></div></header>
161
229
  <div class="${bodyClass}">
162
230
  ${outlinePanel}<main class="bf-app__main bf-review" id="plan" aria-label="Plan"></main>
163
231
  </div>
232
+ ${KEYS_DIALOG}
164
233
  <script type="application/json" id="review-data">${island}</script>
165
234
  <script src="/client.js" defer></script>
166
235
  </body>
167
236
  </html>
168
237
  `;
169
238
  }
239
+
240
+ /**
241
+ * The review facts panel: the plan's step progress and total comment count.
242
+ * Static placeholders; the client fills them from the blocks it rendered.
243
+ */
244
+ function reviewFacts(): string {
245
+ return `<div class="review-facts" id="review-facts">
246
+ <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>
247
+ <div class="review-fact"><span>Comments</span><span class="review-fact__value" id="comment-total">0</span></div>
248
+ </div>`;
249
+ }
@@ -28,9 +28,9 @@ export interface ReviewContext {
28
28
  page: () => Promise<ReviewPageData>;
29
29
  /** Persist the annotations and report the outcome. */
30
30
  save: (annotations: PlanAnnotation[]) => Promise<SaveResult>;
31
- /** Called after a successful save, to resolve the session. */
31
+ /** Called after a successful save; the server resolves the session once the response has flushed. */
32
32
  onSaved: (annotations: PlanAnnotation[]) => void;
33
- /** Called when the user cancels, to resolve the session. */
33
+ /** Called when the user cancels; the server resolves the session once the response has flushed. */
34
34
  onCancel: () => void;
35
35
  }
36
36
 
@@ -89,8 +89,10 @@ function checkEnvelope(
89
89
  if (upper !== "GET" && upper !== "HEAD" && upper !== "POST") {
90
90
  return { ...errorResponse(405, "Only GET and POST are served."), headers: { ...JSON_HEADERS, Allow: "GET, POST" } };
91
91
  }
92
+ // Fail closed: a missing or non-string Host is rejected too, not just a
93
+ // foreign one, so the DNS-rebinding guard cannot be skipped.
92
94
  const host = headers.host;
93
- if (typeof host === "string" && host && !HOST_PATTERN.test(host)) {
95
+ if (typeof host !== "string" || !HOST_PATTERN.test(host)) {
94
96
  return errorResponse(403, "Unexpected Host header.");
95
97
  }
96
98
  return undefined;
@@ -120,7 +122,7 @@ export async function handleReviewRequest(
120
122
  return errorResponse(400, "Malformed request URL.");
121
123
  }
122
124
 
123
- if (pathname === "/favicon.ico") return { status: 204, headers: { ...SCRIPT_HEADERS } };
125
+ if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
124
126
  if (pathname === "/theme.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: THEME_JS };
125
127
  if (pathname === "/client.js") return { status: 200, headers: { ...SCRIPT_HEADERS }, body: REVIEW_CLIENT_JS };
126
128
 
@@ -92,6 +92,9 @@ async function listen(server: Server, port: number): Promise<number> {
92
92
 
93
93
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps = {}): PlanWebReview {
94
94
  const sessions = new Set<PlanReviewSession>();
95
+ // Last bound port per plan path, so the same plan keeps one origin across
96
+ // review turns (and its saved browser theme with it).
97
+ const ports = new Map<string, number>();
95
98
 
96
99
  const start = async (cwd: string, plan: StoredPlan, signal?: AbortSignal): Promise<PlanReviewSession | undefined> => {
97
100
  const token = (deps.token ?? (() => randomBytes(16).toString("hex")))();
@@ -113,11 +116,15 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
113
116
  // that still matches it, or the comments land beside the wrong text.
114
117
  let baseline = stripFeedback(plan.content);
115
118
  let savedPlan: StoredPlan | undefined;
119
+ // Set by `onSaved`/`onCancel`; resolved once the HTTP response has flushed,
120
+ // so the caller cannot close the socket before the browser sees the reply.
121
+ let pendingResult: PlanReviewResult | undefined;
116
122
 
117
123
  const page = async (): Promise<ReviewPageData> => {
118
124
  const current = (await store.read(cwd, plan.path)) ?? plan;
119
125
  baseline = stripFeedback(current.content);
120
126
  const blocks = planBlocks(baseline);
127
+ const headings = planHeadings(blocks);
121
128
  // Highlight code blocks once, at the boundary: the client copies only the
122
129
  // tok-* spans out of the server-escaped HTML.
123
130
  const code: Record<string, string[]> = {};
@@ -127,10 +134,12 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
127
134
  if (language) code[String(block.start)] = highlightLines(codeBody(block), language);
128
135
  }
129
136
  return {
130
- title: planTitle(basename(current.path)),
137
+ // The document's own H1 reads better than the file-name slug, and the
138
+ // slug is still the fallback for a plan that starts with something else.
139
+ title: headings.find((heading) => heading.level === 1)?.text || planTitle(basename(current.path)),
131
140
  blocks,
132
141
  annotations: parseFeedback(current.content),
133
- outline: planHeadings(blocks).map((heading) => ({
142
+ outline: headings.map((heading) => ({
134
143
  id: `s-${heading.line}`,
135
144
  line: heading.line,
136
145
  level: heading.level,
@@ -160,10 +169,11 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
160
169
  page,
161
170
  save,
162
171
  onSaved: (annotations) => {
163
- if (savedPlan) finish({ status: "saved", plan: savedPlan, annotations });
164
- else finish({ status: "cancelled" });
172
+ pendingResult = savedPlan ? { status: "saved", plan: savedPlan, annotations } : { status: "cancelled" };
173
+ },
174
+ onCancel: () => {
175
+ pendingResult = { status: "cancelled" };
165
176
  },
166
- onCancel: () => finish({ status: "cancelled" }),
167
177
  };
168
178
 
169
179
  // Live event streams, used only to notice a closed tab. The explicit Cancel
@@ -218,23 +228,44 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
218
228
  response.end();
219
229
  return;
220
230
  }
221
- response.end(result.body ?? "");
231
+ // A save or cancel only resolves the session after its response is on the
232
+ // wire; otherwise `close()` would force-close the socket mid-reply.
233
+ response.end(result.body ?? "", () => {
234
+ if (!pendingResult) return;
235
+ const terminal = pendingResult;
236
+ pendingResult = undefined;
237
+ finish(terminal);
238
+ });
222
239
  })();
223
240
  });
224
241
 
225
- let port: number;
242
+ const preferred = deps.port ?? ports.get(plan.path) ?? 0;
243
+ let port: number | undefined;
226
244
  try {
227
- port = await listen(server, deps.port ?? 0);
245
+ port = await listen(server, preferred);
228
246
  } catch {
247
+ // The remembered port may have been taken between turns; fall back once.
248
+ if (preferred !== 0) {
249
+ try {
250
+ port = await listen(server, 0);
251
+ } catch {
252
+ port = undefined;
253
+ }
254
+ }
255
+ }
256
+ if (port === undefined) {
229
257
  server.close();
230
258
  return undefined;
231
259
  }
260
+ ports.set(plan.path, port);
232
261
 
233
262
  const url = `http://127.0.0.1:${port}/?token=${token}`;
234
263
  let closed = false;
264
+ let abortHandler: (() => void) | undefined;
235
265
  const close = async (): Promise<void> => {
236
266
  if (closed) return;
237
267
  closed = true;
268
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
238
269
  sessions.delete(session);
239
270
  for (const client of clients) client.end();
240
271
  clients.clear();
@@ -249,9 +280,9 @@ export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps =
249
280
  sessions.add(session);
250
281
 
251
282
  if (signal) {
252
- const onAbort = (): void => void close();
283
+ abortHandler = (): void => void close();
253
284
  if (signal.aborted) void close();
254
- else signal.addEventListener("abort", onAbort, { once: true });
285
+ else signal.addEventListener("abort", abortHandler, { once: true });
255
286
  }
256
287
 
257
288
  (deps.open ?? openInBrowser)(url);
@@ -173,7 +173,7 @@ None in v1 — the tool is either available (a UI exists) or not.
173
173
  | `types.ts` | Shared raw/normalized types |
174
174
  | `answers.ts` | Answer normalization and model-facing formatting (pure) |
175
175
  | `tui-state.ts` | Pure keyboard/navigation state machine |
176
- | `tui.ts` | Custom terminal component (render + input) |
176
+ | `tui.ts` | Custom terminal component (render + input), drawn from the `lib/tui/` kit (`titleRule`/`plainRule`, `checkbox`, `flowCells`, `screenHint`, `editorTheme`). |
177
177
  | `dialogs.ts` | `select`/`input`/`editor` fallback driver |
178
178
  | `tools.ts` | Tool registration and transcript renderers |
179
179
 
@@ -6,8 +6,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
6
6
  import { askHuman } from "../../lib/interaction.ts";
7
7
  import { sanitizeLine } from "../../lib/format.ts";
8
8
  import { withRailsSuppressed } from "../../lib/rails.ts";
9
- import { reuseText, toolDetails } from "../../lib/tui.ts";
10
- import { GLYPHS } from "../../lib/ui.ts";
9
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
10
+ import { paint } from "../../lib/tui/tokens.ts";
11
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
11
12
  import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
12
13
  import { UNAVAILABLE_TEXT, formatAnswerText, optionSummary, questionHeaders, unavailableResult } from "./answers.ts";
13
14
  import { askViaDialogs, type QuestionUI } from "./dialogs.ts";
@@ -73,12 +74,12 @@ export function registerTools(pi: ExtensionAPI): void {
73
74
  const count = raw.length;
74
75
  const noun = count === 1 ? "question" : "questions";
75
76
  const headers = questionHeaders(raw);
76
- let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme.fg("muted", `${count} ${noun}`);
77
- if (headers.length > 0) text += theme.fg("dim", ` (${headers.join(", ")})`);
77
+ let text = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", `${count} ${noun}`);
78
+ if (headers.length > 0) text += paint(theme, "faint", ` (${headers.join(", ")})`);
78
79
 
79
80
  // One indented line per question that offers options, so a long option list
80
81
  // stays mapped to its question instead of wrapping on a single row.
81
- for (const line of optionSummary(raw)) text += `\n${theme.fg("dim", ` ${line}`)}`;
82
+ for (const line of optionSummary(raw)) text += `\n${paint(theme, "faint", ` ${line}`)}`;
82
83
 
83
84
  const component = reuseText(context?.lastComponent);
84
85
  component.setText(text);
@@ -94,26 +95,26 @@ export function registerTools(pi: ExtensionAPI): void {
94
95
  return component;
95
96
  }
96
97
  if (details.unavailable) {
97
- component.setText(theme.fg("dim", UNAVAILABLE_TEXT));
98
+ component.setText(paint(theme, "faint", UNAVAILABLE_TEXT));
98
99
  return component;
99
100
  }
100
101
  if (details.answers.length === 0) {
101
- component.setText(theme.fg("warning", "Cancelled"));
102
+ component.setText(paint(theme, "warn", "Cancelled"));
102
103
  return component;
103
104
  }
104
105
 
105
106
  const lines = details.answers.map((answer) => {
106
- const header = theme.fg("accent", sanitizeLine(answer.header));
107
+ const header = paint(theme, "accent", sanitizeLine(answer.header));
107
108
  if (answer.wasCustom) {
108
109
  const values = answer.values.map((value) => sanitizeLine(value)).join("; ");
109
- return `${theme.fg("success", `${GLYPHS.check} `)}${header}: ${theme.fg("muted", "(wrote) ")}${values}`;
110
+ return `${paint(theme, "ok", `${GLYPHS.check} `)}${header}: ${paint(theme, "meta", "(wrote) ")}${values}`;
110
111
  }
111
112
  const numbered = answer.labels
112
113
  .map((label, i) => `${answer.indices?.[i] ?? "?"}. ${sanitizeLine(label)}`)
113
114
  .join(", ");
114
- return `${theme.fg("success", `${GLYPHS.check} `)}${header}: ${numbered}`;
115
+ return `${paint(theme, "ok", `${GLYPHS.check} `)}${header}: ${numbered}`;
115
116
  });
116
- if (details.cancelled) lines.push(theme.fg("warning", "(cancelled)"));
117
+ if (details.cancelled) lines.push(paint(theme, "warn", "(cancelled)"));
117
118
  // A blank line separates the call header from a multi-line answer body.
118
119
  component.setText(lines.length > 1 ? `\n${lines.join("\n")}` : lines.join("\n"));
119
120
  return component;