@gavin-hu/my-pi-agent 0.21.0 → 0.22.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 +46 -31
- package/README.zh-CN.md +40 -26
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -24
- package/docs/extensions/board.md +361 -147
- package/docs/extensions/bytefly.md +152 -111
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +30 -31
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +166 -126
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +34 -18
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +72 -33
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +56 -15
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +5 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +4 -40
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +13 -11
- package/extensions/board/result.ts +30 -16
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +42 -10
- package/extensions/board/snapshot.ts +2 -1
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +131 -44
- package/extensions/board/types.ts +36 -32
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +33 -15
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +89 -21
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +23 -9
- package/extensions/board/view/render.ts +208 -271
- package/extensions/board/view/styles.ts +123 -31
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +4 -2
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/lines.ts +7 -4
- package/extensions/bytefly/footer/render/segments.ts +37 -10
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/index.ts +67 -36
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +3 -3
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +53 -52
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +49 -39
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-client.ts +3 -0
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +9 -67
- package/extensions/plan/web-payload.ts +3 -3
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web.ts +1 -12
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +10 -8
- package/extensions/serve/index.ts +17 -3
- package/extensions/serve/registry.ts +26 -17
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +2 -1
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -7
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/view/todo-screen.ts +122 -0
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +27 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +1 -1
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +136 -54
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +87 -30
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +70 -58
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +14 -12
- package/lib/http/port.ts +15 -10
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +39 -10
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +8 -8
- package/lib/session-label.ts +111 -5
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +5 -4
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +23 -8
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +9 -7
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +3 -3
- package/lib/web/README.md +21 -0
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +9 -10
- package/package.json +6 -1
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/seat.ts +0 -28
- package/extensions/todo/tui.ts +0 -212
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
package/lib/session-roster.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The Team tab lists teammates, and `session-bus` owns those records. Neither
|
|
5
5
|
* extension may import the other, and the board must keep working when the bus is
|
|
6
6
|
* not loaded — so the records are read where the bus already put them,
|
|
7
|
-
* `<
|
|
7
|
+
* `<agent-dir>/session-bus/<repo-key>/`, as files rather than through its code. The
|
|
8
8
|
* directory names, the record fields this reader trusts, and the liveness rule live
|
|
9
9
|
* here once, and `session-bus` imports them, so a layout change cannot land in one
|
|
10
10
|
* copy and not the other.
|
|
@@ -158,6 +158,48 @@ async function mailCount(dir: string, stem: string): Promise<number> {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* One session's own label, found by the id it belongs to.
|
|
163
|
+
*
|
|
164
|
+
* The record is filed under the repository's key, which a caller without a board store
|
|
165
|
+
* would have to resolve with git first — and a footer asking for a name that may not
|
|
166
|
+
* exist should not cost two subprocesses at every session start. So the bus root is
|
|
167
|
+
* walked instead and the record is matched on its id: a session id belongs to one
|
|
168
|
+
* session, so the first record that names it is the answer wherever the bus filed it.
|
|
169
|
+
*
|
|
170
|
+
* Every miss is ordinary: a machine with no bus, or with records for other sessions
|
|
171
|
+
* only, answers `undefined` rather than an error.
|
|
172
|
+
*/
|
|
173
|
+
export async function readSessionLabel(agentDir: string, id: string): Promise<string | undefined> {
|
|
174
|
+
const root = join(agentDir, BUS_DIR_NAME);
|
|
175
|
+
let keys: string[];
|
|
176
|
+
try {
|
|
177
|
+
keys = await readdir(root);
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
for (const key of keys) {
|
|
182
|
+
const dir = join(root, key, SESSIONS_DIR_NAME);
|
|
183
|
+
let names: string[];
|
|
184
|
+
try {
|
|
185
|
+
names = await readdir(dir);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
if (!name.endsWith(".json")) continue;
|
|
191
|
+
let parsed: unknown;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(await readFile(join(dir, name), "utf-8"));
|
|
194
|
+
} catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (isSessionPayload(parsed) && parsed.id === id) return parsed.label;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
161
203
|
/**
|
|
162
204
|
* Every session the bus has recorded for one repository, or `undefined` when the bus
|
|
163
205
|
* has never been used here.
|
package/lib/session-tool.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The `session` tool, as the extensions that are not `session-bus` call it.
|
|
3
3
|
*
|
|
4
4
|
* `board` and `team` are the user's surface for the team; the bus is the transport
|
|
5
|
-
* underneath, and neither may import it (`test/
|
|
5
|
+
* underneath, and neither may import it (`test/boundaries.test.ts`). So the one place
|
|
6
6
|
* that turns a call into a typed answer lives here, next to the tool's name in
|
|
7
7
|
* `lib/tool-names.ts` — two copies would be two copies of the details-shape
|
|
8
8
|
* knowledge, and a bus that renamed a field would be broken in one of them.
|
|
@@ -59,9 +59,10 @@ export const TEAM_JOIN_REQUEST = "my-pi-agent/team-join/request";
|
|
|
59
59
|
* What a join asks for.
|
|
60
60
|
*
|
|
61
61
|
* `label` is the name to be on the team as; without one the bus settles its own. `name`
|
|
62
|
-
* is the separate question of whether that label also becomes Pi's session name —
|
|
63
|
-
*
|
|
64
|
-
* `/team join <label>` means "call me this from now on".
|
|
62
|
+
* is the separate question of whether that label also becomes Pi's session name — what
|
|
63
|
+
* `/resume` lists — and it is opt-in, because a join is not a rename: only
|
|
64
|
+
* `/team join <label>` means "call me this from now on". The footer needs no opt-in: it
|
|
65
|
+
* draws the label itself, so a plain join moves what the bar names this session.
|
|
65
66
|
*/
|
|
66
67
|
export interface TeamJoinInput {
|
|
67
68
|
label?: string;
|
package/lib/stray-fields.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* Value-only, as `lib/` must be: the tables are the callers'.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { listOf } from "./format.ts";
|
|
22
|
+
|
|
21
23
|
/** The fields each action reads, keyed in the order the actions are documented. */
|
|
22
24
|
export type ActionFields = Readonly<Record<string, readonly string[]>>;
|
|
23
25
|
|
|
@@ -38,8 +40,11 @@ export function strayField(action: string, args: Record<string, unknown>, fields
|
|
|
38
40
|
const named = (field: string): string => {
|
|
39
41
|
const taking = Object.keys(fields).filter((candidate) => fields[candidate]?.includes(field));
|
|
40
42
|
if (taking.length === 0) return `${field} belongs to no action`;
|
|
41
|
-
const verbs = taking.map((one) => `\`${one}\``)
|
|
42
|
-
|
|
43
|
+
const verbs = taking.map((one) => `\`${one}\``);
|
|
44
|
+
// Two read as "a and b"; three or more take the serial comma, or the list stops saying
|
|
45
|
+
// where one verb ends and the next begins.
|
|
46
|
+
const list = verbs.length === 2 ? verbs.join(" and ") : listOf(verbs, "and");
|
|
47
|
+
return `${list} ${taking.length === 1 ? "takes" : "take"} ${stray.length === 1 ? "it" : field}`;
|
|
43
48
|
};
|
|
44
49
|
return `${action} does not act on ${stray.join(", ")}: ${stray.map(named).join("; ")}.`;
|
|
45
50
|
}
|
package/lib/tool-names.ts
CHANGED
|
@@ -17,11 +17,12 @@ export const TODO_TOOL = "todo";
|
|
|
17
17
|
export const GOAL_TOOL = "goal";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* The `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The `plan` tool. `plan` registers it and `goal` offers the same phase to the user,
|
|
21
|
+
* so the name is shared: the request channel and the registration probe both look
|
|
22
|
+
* it up. Its actions are the planning verbs, so one name replaces the five tools
|
|
23
|
+
* that used to spell them.
|
|
23
24
|
*/
|
|
24
|
-
export const
|
|
25
|
+
export const PLAN_TOOL = "plan";
|
|
25
26
|
|
|
26
27
|
/** The `subagent` tool, which plan mode pins to read-only delegation. */
|
|
27
28
|
export const SUBAGENT_TOOL = "subagent";
|
|
@@ -52,12 +53,6 @@ export const SUBAGENT_READ_ONLY_PARAM = "readOnly";
|
|
|
52
53
|
*/
|
|
53
54
|
export const BOARD_TOOL = "board";
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* The one `board` action that reads. Plan mode admits it and refuses the rest, which
|
|
57
|
-
* is the distinction the tool's `readOnlyHint` cannot carry: the same tool writes.
|
|
58
|
-
*/
|
|
59
|
-
export const BOARD_READ_ACTION = "list";
|
|
60
|
-
|
|
61
56
|
/**
|
|
62
57
|
* The `read_doc` tool. `file-read` registers it, and the plan, rewind, and
|
|
63
58
|
* subagent reader lists carry the name, so the document reader is one reader
|
package/lib/tui/README.md
CHANGED
|
@@ -111,17 +111,32 @@ clipped mid-word at the widths the screen runs at.
|
|
|
111
111
|
|
|
112
112
|
Resolve colors through a [`tokens.ts`](./tokens.ts) role
|
|
113
113
|
(`paint(theme, role, text)`), never a literal ANSI sequence, hex value, or Pi
|
|
114
|
-
token named inline; a theme swap must re-skin every surface.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
token named inline; a theme swap must re-skin every surface. The role table is
|
|
115
|
+
the one place a Pi token is named, its tool vocabulary (`toolTitle`,
|
|
116
|
+
`toolOutput`) included, so a renderer draws a tool block the way it draws a
|
|
117
|
+
list. Use the shared `selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS`
|
|
118
|
+
instead of retyping their literals, `screenHint` / Pi's `keyHint` for the hint
|
|
119
|
+
row, and `screenFooter` for the hint row with its blank and closing rule.
|
|
118
120
|
|
|
119
121
|
A list accents the focused row's label (`labelRole: selected ? "accent" :
|
|
120
122
|
"label"`); the `❯` marker alone is not enough to tell which row is live. The
|
|
121
|
-
hint row colors by role, not by taste
|
|
122
|
-
`
|
|
123
|
-
forget), and `
|
|
124
|
-
or select stay
|
|
123
|
+
hint row colors by role, not by taste — `ScreenHint.color` is a `TuiRole`:
|
|
124
|
+
`accent` for the screen's primary action, `warn` for one that discards state (a
|
|
125
|
+
revert, a kill, a stop, a clear, a forget), and `bad` for one that ends the
|
|
126
|
+
session. Keys that navigate, scroll, or select stay `faint`, so a colored key is
|
|
127
|
+
one worth a second thought.
|
|
128
|
+
|
|
129
|
+
## A hue identifies, a verdict judges
|
|
130
|
+
|
|
131
|
+
`ok` / `warn` / `bad` say how something *is*, and a surface that shows them is
|
|
132
|
+
making a judgement. `hue1`…`hue5` — blue, purple, pink, green, amber — say only
|
|
133
|
+
which category a row belongs to, which is what a table or a bar needs to be
|
|
134
|
+
told apart at a glance. They are the same tokens in places (green is `success`,
|
|
135
|
+
amber is `warning`), so the two vocabularies do not mix on one surface: a screen
|
|
136
|
+
that paints hues keeps its verdicts to the gauge, a status word, or a feedback
|
|
137
|
+
line, and a hue never lands in a slot that also carries a judgement. Color is
|
|
138
|
+
redundant there — the label, the order, and the printed number already say what
|
|
139
|
+
the hue repeats — so nothing may be encoded by hue alone.
|
|
125
140
|
|
|
126
141
|
## Width is columns
|
|
127
142
|
|
package/lib/tui/chrome.ts
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
* in, and the viewport options are explicit.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { Theme
|
|
19
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
|
|
21
21
|
import { SEPARATORS } from "./glyphs.ts";
|
|
22
22
|
import { BODY_INDENT } from "./indent.ts";
|
|
23
23
|
import { plainRule } from "./rules.ts";
|
|
24
|
+
import { paint, type TuiRole } from "./tokens.ts";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* `ctx.ui.custom` options that give a screen the whole terminal.
|
|
@@ -61,15 +62,15 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
|
-
* One key-hint entry: plain
|
|
65
|
-
*
|
|
65
|
+
* One key-hint entry: plain faint text, or text in a chosen role so a primary
|
|
66
|
+
* action (approve, submit) stands out from the scroll hints.
|
|
66
67
|
*/
|
|
67
|
-
export type ScreenHint = string | { text: string; color?:
|
|
68
|
+
export type ScreenHint = string | { text: string; color?: TuiRole };
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* A key-hint footer indented by `indent` columns (default `BODY_INDENT`, which
|
|
71
|
-
* hangs it under a screen's title rule), joined with ` · `. Plain hints are
|
|
72
|
-
* an object hint
|
|
72
|
+
* hangs it under a screen's title rule), joined with ` · `. Plain hints are
|
|
73
|
+
* `faint`; an object hint names a role (`faint` when omitted). Hints are added while they
|
|
73
74
|
* fit, so a narrow terminal drops whole keys instead of truncating one in half.
|
|
74
75
|
* The first hint is always kept; the result is clipped to `width`.
|
|
75
76
|
*/
|
|
@@ -78,8 +79,8 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[], ind
|
|
|
78
79
|
// Keep a column for the hint itself, however deep the caller indents.
|
|
79
80
|
const lead = " ".repeat(Math.max(0, Math.min(Math.floor(indent), target - 1)));
|
|
80
81
|
const render = (hint: ScreenHint): string =>
|
|
81
|
-
typeof hint === "string" ? theme
|
|
82
|
-
const separator = theme
|
|
82
|
+
typeof hint === "string" ? paint(theme, "faint", hint) : paint(theme, hint.color ?? "faint", hint.text);
|
|
83
|
+
const separator = paint(theme, "faint", SEPARATORS.item);
|
|
83
84
|
const kept: ScreenHint[] = [];
|
|
84
85
|
for (const hint of hints) {
|
|
85
86
|
const candidate = [...kept, hint].map(render).join(separator);
|
|
@@ -196,16 +197,16 @@ export function toolDetails<T extends object>(details: unknown): T | undefined {
|
|
|
196
197
|
return Object.keys(details).length > 0 ? (details as T) : undefined;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
/** Minimal editor theme for an extension-owned inline editor, on semantic
|
|
200
|
+
/** Minimal editor theme for an extension-owned inline editor, on semantic roles. */
|
|
200
201
|
export function editorTheme(theme: Theme): EditorTheme {
|
|
201
202
|
return {
|
|
202
|
-
borderColor: (text) => theme
|
|
203
|
+
borderColor: (text) => paint(theme, "rule", text),
|
|
203
204
|
selectList: {
|
|
204
|
-
selectedPrefix: (text) => theme
|
|
205
|
-
selectedText: (text) => theme
|
|
206
|
-
description: (text) => theme
|
|
207
|
-
scrollInfo: (text) => theme
|
|
208
|
-
noMatch: (text) => theme
|
|
205
|
+
selectedPrefix: (text) => paint(theme, "accent", text),
|
|
206
|
+
selectedText: (text) => paint(theme, "accent", text),
|
|
207
|
+
description: (text) => paint(theme, "meta", text),
|
|
208
|
+
scrollInfo: (text) => paint(theme, "faint", text),
|
|
209
|
+
noMatch: (text) => paint(theme, "warn", text),
|
|
209
210
|
},
|
|
210
211
|
};
|
|
211
212
|
}
|
package/lib/tui/cursor.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
21
21
|
import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
|
|
22
22
|
import { GLYPHS } from "./glyphs.ts";
|
|
23
|
+
import { paint } from "./tokens.ts";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Rows that fit for a list of `itemCount`, reserving one extra line when the
|
|
@@ -76,11 +77,11 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
|
|
|
76
77
|
|
|
77
78
|
/** The `❯ ` selection marker (accent) or a same-width blank. */
|
|
78
79
|
export function selectionMarker(theme: Theme, selected: boolean): string {
|
|
79
|
-
return selected ? theme
|
|
80
|
+
return selected ? paint(theme, "accent", `${GLYPHS.prompt} `) : " ";
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
|
-
* One
|
|
84
|
+
* One faint, two-space-indented range row: `showing A–B of N` by default, or
|
|
84
85
|
* `line A–B of N` for a log pane. Clipped to `width`.
|
|
85
86
|
*/
|
|
86
87
|
export function formatRange(
|
|
@@ -90,7 +91,7 @@ export function formatRange(
|
|
|
90
91
|
): string {
|
|
91
92
|
const label = range.label ?? "showing";
|
|
92
93
|
const text = ` ${label} ${range.start + 1}–${range.end} of ${range.total}`;
|
|
93
|
-
return truncateToWidth(theme
|
|
94
|
+
return truncateToWidth(paint(theme, "faint", text), Math.max(1, width));
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
@@ -247,6 +248,20 @@ export function isCloseKey(data: string): boolean {
|
|
|
247
248
|
return matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"));
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/** Shift+Tab arrives as this sequence on terminals without the Kitty protocol. */
|
|
252
|
+
export const SHIFT_TAB = "\u001b[Z";
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether `data` is Shift+Tab, in either encoding.
|
|
256
|
+
*
|
|
257
|
+
* `matchesKey` decodes the Kitty and modifyOtherKeys forms but not the legacy
|
|
258
|
+
* `\u001b[Z` one, so every screen or editor that binds Shift+Tab needs both tests;
|
|
259
|
+
* this is that one test.
|
|
260
|
+
*/
|
|
261
|
+
export function isShiftTab(data: string): boolean {
|
|
262
|
+
return data === SHIFT_TAB || matchesKey(data, Key.shift("tab"));
|
|
263
|
+
}
|
|
264
|
+
|
|
250
265
|
/**
|
|
251
266
|
* The next scroll offset for a standard navigation key, or `undefined` when
|
|
252
267
|
* `data` is not a navigation key.
|
package/lib/tui/glyphs.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { paint } from "./tokens.ts";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Leading glyphs for status chips, footer segments, and transcript rails.
|
|
@@ -50,6 +51,7 @@ export const GLYPHS = {
|
|
|
50
51
|
prev: "‹",
|
|
51
52
|
next: "›",
|
|
52
53
|
update: "↑",
|
|
54
|
+
autoMode: "▶",
|
|
53
55
|
prompt: "❯",
|
|
54
56
|
} as const;
|
|
55
57
|
|
|
@@ -82,25 +84,25 @@ export const STATUS_KEYS = {
|
|
|
82
84
|
*/
|
|
83
85
|
export const EXPAND_KEYBINDING = "app.tools.expand";
|
|
84
86
|
|
|
85
|
-
/** The bound expand key,
|
|
87
|
+
/** The bound expand key, in the faint role, or `""` when the binding is unknown. */
|
|
86
88
|
export function expandKey(theme: Theme): string {
|
|
87
89
|
const key = keyText(EXPAND_KEYBINDING);
|
|
88
|
-
return key ? theme
|
|
90
|
+
return key ? paint(theme, "faint", key) : "";
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
|
-
* Pi's key-hint format: the key
|
|
94
|
+
* Pi's key-hint format: the key faint, its description meta. An unbound key
|
|
93
95
|
* (`""`) yields `""`, so a hint never prints a stray description.
|
|
94
96
|
*/
|
|
95
97
|
export function keyHintText(theme: Theme, key: string, description: string): string {
|
|
96
|
-
return key ? theme
|
|
98
|
+
return key ? paint(theme, "faint", key) + paint(theme, "meta", ` ${description}`) : "";
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
|
-
* The built-in-style expand affordance: a
|
|
101
|
-
* key in
|
|
102
|
+
* The built-in-style expand affordance: a meta `(ctrl+o to expand)` with the
|
|
103
|
+
* key in faint. Degrades to `(to expand)` when the binding is unknown.
|
|
102
104
|
*/
|
|
103
105
|
export function expandHint(theme: Theme): string {
|
|
104
106
|
const hint = keyHintText(theme, keyText(EXPAND_KEYBINDING), "to expand)");
|
|
105
|
-
return theme
|
|
107
|
+
return paint(theme, "meta", "(") + (hint || paint(theme, "meta", "to expand)"));
|
|
106
108
|
}
|
package/lib/tui/tokens.ts
CHANGED
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
* A component names a role (`title`, `meta`, `bad`) and the active theme
|
|
6
6
|
* supplies the color, so switching themes re-skins every surface without a
|
|
7
7
|
* component changing. The mapping is deliberately indirect: Pi's token set is
|
|
8
|
-
* closed, so the kit only ever references it, never a literal color
|
|
8
|
+
* closed, so the kit only ever references it, never a literal color — and this
|
|
9
|
+
* table is the one place it does, tool vocabulary included, so a remap here
|
|
10
|
+
* reaches every surface the kit and the renderers draw.
|
|
9
11
|
*
|
|
10
12
|
* Value-only: one frozen lookup table and pure helpers.
|
|
13
|
+
*
|
|
14
|
+
* Two vocabularies live here and they do not mix: `ok`/`warn`/`bad` are verdicts —
|
|
15
|
+
* they say how something *is* — and `hue1`…`hue5` are categories, which say only
|
|
16
|
+
* "this is not those". A surface that shows hues keeps its verdicts to the places
|
|
17
|
+
* that earn one, because a reader cannot tell a borrowed green from a judgement.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
@@ -35,7 +42,24 @@ export type TuiRole =
|
|
|
35
42
|
/** Error. */
|
|
36
43
|
| "bad"
|
|
37
44
|
/** Neutral notice. */
|
|
38
|
-
| "info"
|
|
45
|
+
| "info"
|
|
46
|
+
/**
|
|
47
|
+
* A category hue: identity, never a verdict.
|
|
48
|
+
*
|
|
49
|
+
* Blue, purple, pink, green, and amber are the shipped palette's five colourful
|
|
50
|
+
* tokens, so a categorical surface reads as a chart. Green and amber are the same
|
|
51
|
+
* tokens `ok` and `warn` resolve to — that is the cost of Pi's closed token set —
|
|
52
|
+
* which is why a hue never appears in a slot that also carries a judgement.
|
|
53
|
+
*/
|
|
54
|
+
| "hue1"
|
|
55
|
+
| "hue2"
|
|
56
|
+
| "hue3"
|
|
57
|
+
| "hue4"
|
|
58
|
+
| "hue5"
|
|
59
|
+
/** A tool call's own name: the transcript block's only accent. */
|
|
60
|
+
| "toolTitle"
|
|
61
|
+
/** A tool result's body text. */
|
|
62
|
+
| "toolOutput";
|
|
39
63
|
|
|
40
64
|
/** Role -> theme color. The single place the kit names a Pi token. */
|
|
41
65
|
export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
|
|
@@ -50,6 +74,13 @@ export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
|
|
|
50
74
|
warn: "warning",
|
|
51
75
|
bad: "error",
|
|
52
76
|
info: "muted",
|
|
77
|
+
hue1: "accent",
|
|
78
|
+
hue2: "mdListBullet",
|
|
79
|
+
hue3: "mdCode",
|
|
80
|
+
hue4: "success",
|
|
81
|
+
hue5: "warning",
|
|
82
|
+
toolTitle: "toolTitle",
|
|
83
|
+
toolOutput: "toolOutput",
|
|
53
84
|
};
|
|
54
85
|
|
|
55
86
|
/** Paint `text` in a role's color. */
|
package/lib/tui/tool.ts
CHANGED
|
@@ -31,9 +31,9 @@ export interface ToolMessageResult {
|
|
|
31
31
|
isError?: boolean;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** The bold tool name in
|
|
34
|
+
/** The bold tool name in the toolTitle role, the block's only accent. */
|
|
35
35
|
export function toolCallTitle(theme: Theme, name: string): string {
|
|
36
|
-
return theme
|
|
36
|
+
return paint(theme, "toolTitle", theme.bold(name));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -275,7 +275,7 @@ export class TailResult<Row> implements Component {
|
|
|
275
275
|
return lines;
|
|
276
276
|
}
|
|
277
277
|
if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
|
|
278
|
-
for (const line of input.lines) lines.push(this.theme
|
|
278
|
+
for (const line of input.lines) lines.push(paint(this.theme, "toolOutput", clip(line, w)));
|
|
279
279
|
return lines;
|
|
280
280
|
}
|
|
281
281
|
}
|
package/lib/web/README.md
CHANGED
|
@@ -42,6 +42,27 @@ What stays page-side: what a status *says*. `paintStatus` owns the text, its too
|
|
|
42
42
|
the `bf-status--<tone>` class; the board composes `live · 3m` and appends its own class.
|
|
43
43
|
A shared runtime that also decided the wording would be a second copy of the page's voice.
|
|
44
44
|
|
|
45
|
+
## One inline Markdown renderer, spliced in
|
|
46
|
+
|
|
47
|
+
Two served pages read Markdown written by a model or a person: the plan review's document
|
|
48
|
+
and the board's card notes. Both need the same two answers — what an inline token is, and
|
|
49
|
+
which target a link may point at — so `markdown.ts` holds one body of each and exports
|
|
50
|
+
`INLINE_MARKDOWN_JS` for a bundle to splice. The review page's block half
|
|
51
|
+
([`../../extensions/plan/web-markdown.ts`](../../extensions/plan/web-markdown.ts)) calls
|
|
52
|
+
`renderInline` for the text inside a block; the board calls it for each note. A page that
|
|
53
|
+
writes its own is how one of them starts accepting a `javascript:` target the other refuses.
|
|
54
|
+
|
|
55
|
+
The renderer appends a text node or one of four atoms — `code`, `strong`, `em`, `a` — and
|
|
56
|
+
never parses markup, so the board's rule that a stored field is never `innerHTML` holds with
|
|
57
|
+
Markdown in play. A link is made only when `safeHref` allows the target: `http`, `https`, or
|
|
58
|
+
`mailto`, no credentials, and no protocol-relative or disguised relative target, resolved
|
|
59
|
+
against the page's own URL at call time.
|
|
60
|
+
|
|
61
|
+
The body is JavaScript on purpose, like the runtime's: no annotation survives into the
|
|
62
|
+
string, and no backtick or `${` may appear in one. It is the inline half only — a card note
|
|
63
|
+
is one sanitized line, so inline is the whole of it, while blocks, tables, and fenced code
|
|
64
|
+
stay the review page's own half.
|
|
65
|
+
|
|
45
66
|
## Name a class once, in a table
|
|
46
67
|
|
|
47
68
|
A page's document is TypeScript and its client is a string inside a template literal, so
|
package/lib/web/components.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
|
|
|
28
28
|
.bf-btn:disabled{opacity:.5;cursor:default}
|
|
29
29
|
.bf-chip{display:inline-flex;align-items:center;gap:5px;font-size:var(--fs-xs);color:var(--muted);white-space:nowrap}
|
|
30
30
|
.bf-chip--accent{color:var(--accent);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
|
|
31
|
+
/* A field the card carries rather than a state it is in: the same pill, worn quietly. */
|
|
32
|
+
.bf-chip--outline{color:var(--muted);border:1px solid currentColor;border-radius:var(--radius-pill);padding:2px 10px}
|
|
31
33
|
.bf-chip--success{color:var(--success)}
|
|
32
34
|
.bf-chip--warn{color:var(--warn)}
|
|
33
35
|
.bf-chip--danger{color:var(--danger)}
|
|
@@ -35,7 +37,7 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
|
|
|
35
37
|
.bf-segmented__item{appearance:none;border:0;border-radius:0;background:transparent;color:var(--text);font:var(--fs-xs) var(--font-ui);padding:5px var(--space-3);cursor:pointer}
|
|
36
38
|
.bf-segmented__item+.bf-segmented__item{border-left:1px solid var(--border)}
|
|
37
39
|
.bf-segmented__item:hover{color:var(--accent);text-decoration:none}
|
|
38
|
-
.bf-segmented__item[aria-current="page"],.bf-segmented__item[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent)}
|
|
40
|
+
.bf-segmented__item[aria-current="page"],.bf-segmented__item[aria-selected="true"],.bf-segmented__item[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent)}
|
|
39
41
|
.bf-input{border:0;background:transparent;color:var(--text);font:var(--fs-sm) var(--font-mono);width:100%;outline:none}
|
|
40
42
|
.bf-field{display:flex;align-items:center;gap:var(--space-1);background:var(--canvas);border:1px solid var(--border);border-radius:var(--radius-md);padding:var(--space-1) var(--space-2);color:var(--muted);font-size:var(--fs-sm)}
|
|
41
43
|
/* The field is the control: ringing the input inside it drew a border within a border. */
|
|
@@ -73,6 +75,8 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
|
|
|
73
75
|
.bf-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
|
|
74
76
|
.bf-keys dt{white-space:nowrap}
|
|
75
77
|
.bf-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
|
|
78
|
+
/* One line under the table, for a rule that is not a key: what an open dialog keeps. */
|
|
79
|
+
.bf-keys__note{margin:0;padding:0 var(--space-4) var(--space-4);color:var(--muted);font-size:var(--fs-xs)}
|
|
76
80
|
.bf-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}
|
|
77
81
|
.bf-tree{font-family:var(--font-mono);font-size:var(--fs-sm);overflow-x:auto}
|
|
78
82
|
.bf-tree ul{list-style:none;margin:0;padding-left:14px}
|
package/lib/web/index.ts
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline Markdown, for every served page: one renderer and one link policy.
|
|
3
|
+
*
|
|
4
|
+
* Two pages read Markdown written by a model or a person — the plan review's document and
|
|
5
|
+
* the board's card notes — and each needs the same two answers: what an inline token is,
|
|
6
|
+
* and which target a link may point at. A second copy of either is how one page starts
|
|
7
|
+
* accepting a `javascript:` target the other refuses, so the body lives here and each page
|
|
8
|
+
* splices it into its own bundle.
|
|
9
|
+
*
|
|
10
|
+
* The renderer appends **text nodes and four elements only** — `code`, `strong`, `em`, and
|
|
11
|
+
* `a` — so plan, comment, and note text never becomes markup and no page needs `innerHTML`.
|
|
12
|
+
* A link is made only for a target `safeHref` allows: `http`, `https`, or `mailto`, no
|
|
13
|
+
* credentials, and no protocol-relative or disguised relative target.
|
|
14
|
+
*
|
|
15
|
+
* It is the inline half only. A card note is one sanitized line, so inline is the whole of
|
|
16
|
+
* it; the review page's blocks, tables, fenced code, and lists are its own half, and they
|
|
17
|
+
* call `renderInline` for the text inside each.
|
|
18
|
+
*
|
|
19
|
+
* The body is JavaScript: no annotation survives into a string, and no backtick or `${`
|
|
20
|
+
* may appear in one. `base` is the page's own URL at call time, which is what makes a
|
|
21
|
+
* target that only looks relative land as text.
|
|
22
|
+
*/
|
|
23
|
+
export const INLINE_MARKDOWN_JS = String.raw` // A link is rendered only for an http(s) or mailto target. Resolving against
|
|
24
|
+
// the review origin is what catches a target that only looks relative: the
|
|
25
|
+
// URL parser strips a tab or newline, so "/\t/evil.com" is cross-origin once
|
|
26
|
+
// it is parsed, and a disguised target must stay text.
|
|
27
|
+
function safeHref(url, base) {
|
|
28
|
+
var trimmed = String(url).trim();
|
|
29
|
+
if (!trimmed || /^\/\//.test(trimmed)) return null;
|
|
30
|
+
var resolved;
|
|
31
|
+
try { resolved = new URL(trimmed, base); } catch (error) { return null; }
|
|
32
|
+
if (resolved.protocol === "mailto:") return trimmed;
|
|
33
|
+
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
|
|
34
|
+
if (resolved.username || resolved.password) return null;
|
|
35
|
+
if (resolved.origin === new URL(base).origin) return resolved.href;
|
|
36
|
+
return /^https?:\/\//i.test(trimmed) ? resolved.href : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Inline token order matters: code, emphasis, and links are matched before a
|
|
40
|
+
// bare URL, so a URL inside a link target or a code span is never re-split.
|
|
41
|
+
var INLINE = /(\x60[^\x60]+\x60|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|(?:https?:\/\/|www\.)[^\s<]+)/g;
|
|
42
|
+
|
|
43
|
+
function renderInline(container, text) {
|
|
44
|
+
String(text).split(INLINE).forEach(function (part) {
|
|
45
|
+
if (!part) return;
|
|
46
|
+
var match;
|
|
47
|
+
if ((match = /^\x60([^\x60]+)\x60$/.exec(part))) {
|
|
48
|
+
var code = el("code"); code.textContent = match[1]; container.appendChild(code); return;
|
|
49
|
+
}
|
|
50
|
+
if ((match = /^\*\*([^*]+)\*\*$/.exec(part))) {
|
|
51
|
+
var strong = el("strong"); strong.textContent = match[1]; container.appendChild(strong); return;
|
|
52
|
+
}
|
|
53
|
+
if ((match = /^\*([^*]+)\*$/.exec(part))) {
|
|
54
|
+
var em = el("em"); em.textContent = match[1]; container.appendChild(em); return;
|
|
55
|
+
}
|
|
56
|
+
if ((match = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part))) {
|
|
57
|
+
var href = safeHref(match[2], location.href);
|
|
58
|
+
if (!href) { container.appendChild(document.createTextNode(part)); return; }
|
|
59
|
+
var link = el("a"); link.textContent = match[1]; link.href = href; link.target = "_blank"; link.rel = "noreferrer noopener";
|
|
60
|
+
container.appendChild(link); return;
|
|
61
|
+
}
|
|
62
|
+
// GFM extended autolink: a bare http(s) or www. URL in running text.
|
|
63
|
+
if ((match = /^(?:https?:\/\/|www\.)[^\s<]+$/.exec(part))) {
|
|
64
|
+
var url = match[0].replace(/[.,;:!?*_~]+$/, "");
|
|
65
|
+
while (url.charAt(url.length - 1) === ")" && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
|
66
|
+
url = url.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
|
|
67
|
+
}
|
|
68
|
+
var dot = url.indexOf(".");
|
|
69
|
+
var target = /^www\./i.test(url) ? "http://" + url : url;
|
|
70
|
+
var safe = dot > 0 && /\.[^./]/.test(url.slice(dot)) ? safeHref(target, location.href) : null;
|
|
71
|
+
if (safe) {
|
|
72
|
+
var auto = el("a");
|
|
73
|
+
auto.textContent = url;
|
|
74
|
+
auto.href = safe;
|
|
75
|
+
auto.target = "_blank";
|
|
76
|
+
auto.rel = "noreferrer noopener";
|
|
77
|
+
container.appendChild(auto);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
container.appendChild(document.createTextNode(part));
|
|
82
|
+
});
|
|
83
|
+
}`;
|
package/lib/web/markup.ts
CHANGED
|
@@ -219,7 +219,7 @@ export interface KeyHint {
|
|
|
219
219
|
* for the sheet, `#keys-close` for its own close button — and the trigger button
|
|
220
220
|
* is the page's own concern, since the two pages name it differently.
|
|
221
221
|
*/
|
|
222
|
-
export function keysSheet(rows: readonly KeyHint[]): string {
|
|
222
|
+
export function keysSheet(rows: readonly KeyHint[], note?: string): string {
|
|
223
223
|
const kbd = (key: string): string => `<kbd class="bf-kbd">${escapeHtml(key)}</kbd>`;
|
|
224
224
|
const body = rows
|
|
225
225
|
.map((row) => {
|
|
@@ -231,6 +231,7 @@ export function keysSheet(rows: readonly KeyHint[]): string {
|
|
|
231
231
|
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
|
|
232
232
|
<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>
|
|
233
233
|
<dl class="bf-keys">${body}</dl>
|
|
234
|
+
${note === undefined ? "" : `<p class="bf-keys__note">${escapeHtml(note)}</p>`}
|
|
234
235
|
</div>
|
|
235
236
|
</div>`;
|
|
236
237
|
}
|
package/lib/web/runtime.ts
CHANGED
|
@@ -156,20 +156,19 @@ export function dialogScript(options: DialogOptions): string {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
// Tab cycles inside the dialog: leaving it would put focus behind the scrim,
|
|
159
|
-
// where the page looks inert but is not.
|
|
159
|
+
// where the page looks inert but is not. Every Tab is redirected rather than only
|
|
160
|
+
// the two that wrap, because the panel is the first node and the *container* of the
|
|
161
|
+
// others: a browser's own backwards step from the last node has no sibling to land
|
|
162
|
+
// on and walks out of the dialog instead.
|
|
160
163
|
dialog.addEventListener("keydown", function (event) {
|
|
161
164
|
if (event.key !== "Tab") return;
|
|
162
165
|
var nodes = focusables();
|
|
163
166
|
if (nodes.length === 0) { event.preventDefault(); return; }
|
|
164
|
-
var
|
|
165
|
-
var
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
} else if (!event.shiftKey && document.activeElement === last) {
|
|
170
|
-
event.preventDefault();
|
|
171
|
-
first.focus();
|
|
172
|
-
}
|
|
167
|
+
var at = nodes.indexOf(document.activeElement);
|
|
168
|
+
var step = event.shiftKey ? -1 : 1;
|
|
169
|
+
var next = at === -1 ? (event.shiftKey ? nodes.length - 1 : 0) : (at + step + nodes.length) % nodes.length;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
nodes[next].focus();
|
|
173
172
|
});
|
|
174
173
|
|
|
175
174
|
// Capture, so Esc closes the dialog before a page handler peels another layer.
|