@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.
- package/README.md +35 -8
- package/README.zh-CN.md +33 -8
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +306 -68
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +10 -6
- 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 +49 -21
- package/extensions/bytefly/header/config.ts +28 -21
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- 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/component.ts +2 -1
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +18 -38
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +12 -5
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +17 -16
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +54 -10
- package/extensions/file-browse/client.ts +110 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +180 -8
- package/extensions/file-browse/html.ts +401 -25
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +33 -0
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +13 -25
- package/extensions/goal/tools.ts +11 -22
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +22 -13
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +26 -21
- package/extensions/job/tools.ts +15 -12
- package/extensions/job/tui.ts +148 -222
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +7 -1
- package/extensions/memory/format.ts +5 -3
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +86 -36
- 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 +43 -27
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +386 -40
- package/extensions/plan/web-page.ts +138 -33
- package/extensions/plan/web-router.ts +46 -13
- package/extensions/plan/web.ts +63 -12
- package/extensions/question/README.md +14 -2
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +22 -16
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +3 -1
- package/extensions/rewind/format.ts +35 -2
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +108 -66
- package/extensions/todo/README.md +20 -13
- package/extensions/todo/format.ts +47 -34
- package/extensions/todo/tools.ts +6 -4
- package/extensions/todo/tui.ts +46 -26
- package/extensions/watch/README.md +16 -8
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +29 -24
- package/extensions/watch/tools.ts +16 -9
- package/extensions/watch/tui.ts +142 -196
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +8 -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/README.md +6 -0
- 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 +11 -7
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +17 -34
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
- package/lib/tui/gauge.ts +78 -0
- package/lib/{ui.ts → tui/glyphs.ts} +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +27 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +143 -0
- package/lib/tui/rules.ts +94 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +72 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- 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,
|
|
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{
|
|
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;
|
|
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:
|
|
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
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.bf-
|
|
84
|
-
.bf-block
|
|
85
|
-
.bf-
|
|
86
|
-
.bf-
|
|
87
|
-
.bf-
|
|
88
|
-
.bf-block
|
|
89
|
-
.bf-
|
|
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
|
|
93
|
-
.bf-block pre.bf-code{margin:
|
|
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
|
-
.
|
|
97
|
-
.
|
|
98
|
-
.
|
|
99
|
-
.
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
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 & 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…</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--
|
|
158
|
-
|
|
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
|
|
33
|
+
/** Called after a successful save; the server resolves the session once the response has flushed. */
|
|
32
34
|
onSaved: (annotations: PlanAnnotation[]) => void;
|
|
33
|
-
/** Called
|
|
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
|
|
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: {
|
|
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
|
-
|
|
141
|
-
|
|
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
|
}
|
package/extensions/plan/web.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
262
|
+
const preferred = deps.port ?? ports.get(plan.path) ?? 0;
|
|
263
|
+
let port: number | undefined;
|
|
226
264
|
try {
|
|
227
|
-
port = await listen(server,
|
|
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
|
-
|
|
303
|
+
abortHandler = (): void => void close();
|
|
253
304
|
if (signal.aborted) void close();
|
|
254
|
-
else signal.addEventListener("abort",
|
|
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
|
-
/**
|
|
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?:
|
|
86
|
-
options?:
|
|
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) =>
|
|
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) => ({
|
|
98
|
-
.filter(({
|
|
99
|
-
.map(({
|
|
100
|
-
|
|
101
|
-
|
|
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)";
|