@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.
- package/README.md +9 -8
- package/README.zh-CN.md +8 -8
- package/assets/file-browse.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +49 -36
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +6 -5
- package/extensions/bytefly/footer/config.ts +1 -1
- package/extensions/bytefly/footer/render/format.ts +5 -18
- package/extensions/bytefly/footer/render/layout.ts +2 -1
- package/extensions/bytefly/footer/render/segments.ts +51 -78
- package/extensions/bytefly/header/component.ts +33 -16
- package/extensions/bytefly/header/config.ts +28 -18
- package/extensions/bytefly/index.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +69 -35
- package/extensions/bytefly/panel/format.ts +8 -8
- package/extensions/bytefly/panel/tui.ts +45 -70
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +13 -40
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/document/README.md +1 -1
- package/extensions/document/render.ts +11 -10
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +62 -14
- package/extensions/file-browse/client.ts +103 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +176 -1
- package/extensions/file-browse/html.ts +412 -23
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +98 -5
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/goal/format.ts +11 -10
- package/extensions/goal/tools.ts +6 -5
- package/extensions/goal/tui.ts +1 -1
- package/extensions/job/README.md +2 -2
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +1 -1
- package/extensions/job/tools.ts +11 -10
- package/extensions/job/tui.ts +141 -220
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +64 -34
- package/extensions/plan/blocks.ts +44 -2
- package/extensions/plan/feedback.ts +20 -5
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/runtime.ts +3 -2
- package/extensions/plan/tools.ts +23 -22
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +335 -30
- package/extensions/plan/web-page.ts +110 -30
- package/extensions/plan/web-router.ts +6 -4
- package/extensions/plan/web.ts +41 -10
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +12 -11
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +1 -1
- package/extensions/rewind/format.ts +29 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/render.ts +62 -47
- package/extensions/todo/README.md +2 -2
- package/extensions/todo/format.ts +31 -10
- package/extensions/todo/tools.ts +5 -4
- package/extensions/todo/tui.ts +45 -26
- package/extensions/watch/README.md +2 -2
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +1 -1
- package/extensions/watch/tools.ts +12 -7
- package/extensions/watch/tui.ts +135 -194
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +2 -2
- package/extensions/wechat/index.ts +4 -3
- package/extensions/wechat/login-screen.ts +8 -5
- package/extensions/wechat/status-screen.ts +41 -22
- package/extensions/wechat/tools.ts +6 -5
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/index.ts +1 -1
- package/extensions/worktree/runtime.ts +1 -1
- package/lib/README.md +10 -7
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +9 -30
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
- package/lib/tui/gauge.ts +78 -0
- package/lib/tui/index.ts +25 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +109 -0
- package/lib/tui/rules.ts +88 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +58 -0
- package/package.json +2 -2
- /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,
|
|
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{
|
|
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;
|
|
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:
|
|
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
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.bf-
|
|
84
|
-
.bf-block
|
|
85
|
-
.bf-
|
|
86
|
-
.bf-
|
|
87
|
-
.bf-
|
|
88
|
-
.bf-block
|
|
89
|
-
.bf-
|
|
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:
|
|
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
|
-
.
|
|
97
|
-
.
|
|
98
|
-
.
|
|
99
|
-
.
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
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…</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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
|
package/extensions/plan/web.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
242
|
+
const preferred = deps.port ?? ports.get(plan.path) ?? 0;
|
|
243
|
+
let port: number | undefined;
|
|
226
244
|
try {
|
|
227
|
-
port = await listen(server,
|
|
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
|
-
|
|
283
|
+
abortHandler = (): void => void close();
|
|
253
284
|
if (signal.aborted) void close();
|
|
254
|
-
else signal.addEventListener("abort",
|
|
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 {
|
|
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
|
|
77
|
-
if (headers.length > 0) text += theme
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
115
|
+
return `${paint(theme, "ok", `${GLYPHS.check} `)}${header}: ${numbered}`;
|
|
115
116
|
});
|
|
116
|
-
if (details.cancelled) lines.push(theme
|
|
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;
|