@gavin-hu/my-pi-agent 0.20.0 → 0.21.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 +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The board page's
|
|
3
|
-
* attached tab, and the idle grace that
|
|
2
|
+
* The board page's mount: the prefix it holds on the session's one server, the snapshot
|
|
3
|
+
* pushed to every attached tab, and the idle grace that gives the prefix back when the
|
|
4
|
+
* last one leaves.
|
|
4
5
|
*
|
|
5
|
-
* `lib/http` owns the socket
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* stops the ticker.
|
|
6
|
+
* `lib/http` owns the socket and `serve` owns the session's port, so this module owns only
|
|
7
|
+
* the board's own decisions: where it answers (`BOARD_BASE`), when a frame is pushed (only
|
|
8
|
+
* when a card's revision moved), who receives it, and what stops the ticker.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { BOARD_BASE } from "../../../lib/board/page-pointer.ts";
|
|
12
|
+
import type { BoardView } from "../../../lib/board/view.ts";
|
|
11
13
|
import {
|
|
12
14
|
type EventSink,
|
|
15
|
+
type HttpMount,
|
|
13
16
|
LOOPBACK_HOSTS,
|
|
14
|
-
type
|
|
17
|
+
type MountSource,
|
|
18
|
+
type MountSpec,
|
|
15
19
|
mintToken,
|
|
16
|
-
serveLoopback,
|
|
17
20
|
tokenMatches,
|
|
18
21
|
} from "../../../lib/http/index.ts";
|
|
19
22
|
import type { IdleReason } from "../../../lib/http/sink.ts";
|
|
20
23
|
import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
|
|
21
|
-
import type { BoardView } from "../../../lib/board/view.ts";
|
|
22
24
|
import type { PageView } from "../view/page.ts";
|
|
23
25
|
import { type BoardRouterContext, handleBoardRequest } from "./router.ts";
|
|
24
26
|
|
|
25
27
|
/** How often the page is offered a frame; a frame is sent only when the signature moved. */
|
|
26
|
-
|
|
28
|
+
const PUSH_MS = 1_000;
|
|
27
29
|
|
|
28
30
|
/** Largest request body the page accepts: a card and a note are small. */
|
|
29
|
-
|
|
31
|
+
const MAX_BODY_BYTES = 64 * 1_024;
|
|
30
32
|
|
|
31
33
|
/** What the page is built from. The lifecycle (`page.ts`) supplies all of it. */
|
|
32
34
|
export interface BoardServerOptions {
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
+
/** Where the prefix comes from: the session's server, or a suite's own. */
|
|
36
|
+
mount: MountSource;
|
|
37
|
+
/** The Pi session asking, so the prefix lands on this session's socket. */
|
|
38
|
+
session: string;
|
|
35
39
|
/** The page's token; minted here when the caller has none, so the note carries one. */
|
|
36
40
|
token?: string;
|
|
37
41
|
page: (view: PageView) => Promise<string>;
|
|
@@ -41,8 +45,17 @@ export interface BoardServerOptions {
|
|
|
41
45
|
onIdle?: (reason: IdleReason) => void;
|
|
42
46
|
/** Grace after the last stream leaves; 0 or absent waits for `close()`. */
|
|
43
47
|
idleMs?: number;
|
|
44
|
-
/** Grace for a page no tab ever attached to; absent leaves the
|
|
48
|
+
/** Grace for a page no tab ever attached to; absent leaves the mount open. */
|
|
45
49
|
firstIdleMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* A read that failed while building a frame.
|
|
52
|
+
*
|
|
53
|
+
* A snapshot can fail for reasons the page does not control — the board directory removed
|
|
54
|
+
* or made unreadable under a running page — and a ticker nobody awaits must not turn that
|
|
55
|
+
* into an unhandled rejection in the session. The frame is dropped, the next tick tries
|
|
56
|
+
* again, and this is the one place the failure is said. Absent, the page stays quiet.
|
|
57
|
+
*/
|
|
58
|
+
onError?: (error: unknown) => void;
|
|
46
59
|
/** The interval seam, so a test drives the ticker instead of waiting. */
|
|
47
60
|
interval?: RepeatTimer;
|
|
48
61
|
}
|
|
@@ -59,10 +72,15 @@ export interface BoardServer {
|
|
|
59
72
|
close: () => Promise<void>;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
|
|
75
|
+
/** The page, or the reason the session's server has no prefix for it. */
|
|
76
|
+
export type BoardServerResult = { kind: "served"; server: BoardServer } | { kind: "refused"; reason: string };
|
|
77
|
+
|
|
78
|
+
export async function createBoardServer(options: BoardServerOptions): Promise<BoardServerResult> {
|
|
63
79
|
const token = options.token ?? mintToken();
|
|
64
80
|
const clients = new Set<EventSink>();
|
|
65
81
|
const interval = options.interval ?? defaultInterval;
|
|
82
|
+
/** A frame that could not be built is not sent; the next tick tries again. */
|
|
83
|
+
const report = (error: unknown): void => options.onError?.(error);
|
|
66
84
|
let pushed = "";
|
|
67
85
|
|
|
68
86
|
const ctx: BoardRouterContext = {
|
|
@@ -84,8 +102,10 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
|
|
|
84
102
|
for (const sink of clients) sink.write(frame);
|
|
85
103
|
};
|
|
86
104
|
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
/** The prefix this page asks for: the session's server holds it, not this module. */
|
|
106
|
+
const spec: Omit<MountSpec, "handler"> = {
|
|
107
|
+
session: options.session,
|
|
108
|
+
prefix: BOARD_BASE,
|
|
89
109
|
maxBodyBytes: MAX_BODY_BYTES,
|
|
90
110
|
...(options.idleMs === undefined || options.onIdle === undefined
|
|
91
111
|
? {}
|
|
@@ -96,6 +116,9 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
|
|
|
96
116
|
onIdle: options.onIdle,
|
|
97
117
|
},
|
|
98
118
|
}),
|
|
119
|
+
};
|
|
120
|
+
const outcome = await options.mount({
|
|
121
|
+
...spec,
|
|
99
122
|
async handler(request) {
|
|
100
123
|
const result = await handleBoardRequest(ctx, request.method, request.url, request.headers);
|
|
101
124
|
if (!result.stream) return { result };
|
|
@@ -105,10 +128,13 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
|
|
|
105
128
|
clients.add(sink);
|
|
106
129
|
// A tab that just attached gets the current frame without waiting for a
|
|
107
130
|
// card to change — otherwise a quiet board looks like a dead page.
|
|
108
|
-
void options
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
void options
|
|
132
|
+
.view()
|
|
133
|
+
.then((view) => {
|
|
134
|
+
pushed = view.sig;
|
|
135
|
+
sink.write(`data: ${JSON.stringify(view)}\n\n`);
|
|
136
|
+
})
|
|
137
|
+
.catch(report);
|
|
112
138
|
return () => {
|
|
113
139
|
clients.delete(sink);
|
|
114
140
|
};
|
|
@@ -116,8 +142,10 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
|
|
|
116
142
|
};
|
|
117
143
|
},
|
|
118
144
|
});
|
|
145
|
+
if (outcome.kind === "refused") return { kind: "refused", reason: outcome.reason };
|
|
146
|
+
const mount: HttpMount = outcome.mount;
|
|
119
147
|
|
|
120
|
-
const stopTicker = interval(PUSH_MS, () => void push());
|
|
148
|
+
const stopTicker = interval(PUSH_MS, () => void push().catch(report));
|
|
121
149
|
let closed = false;
|
|
122
150
|
const close = async (): Promise<void> => {
|
|
123
151
|
if (closed) return;
|
|
@@ -125,14 +153,17 @@ export async function createBoardServer(options: BoardServerOptions): Promise<Bo
|
|
|
125
153
|
stopTicker();
|
|
126
154
|
for (const sink of clients) sink.end();
|
|
127
155
|
clients.clear();
|
|
128
|
-
await
|
|
156
|
+
await mount.release();
|
|
129
157
|
};
|
|
130
158
|
|
|
131
159
|
return {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
160
|
+
kind: "served",
|
|
161
|
+
server: {
|
|
162
|
+
port: mount.port,
|
|
163
|
+
url: `${mount.origin}${mount.base}/?token=${token}`,
|
|
164
|
+
watchers: () => clients.size,
|
|
165
|
+
pushed: () => pushed,
|
|
166
|
+
close,
|
|
167
|
+
},
|
|
137
168
|
};
|
|
138
169
|
}
|
|
@@ -84,16 +84,6 @@ export const ACTION_FIELDS: Record<BoardAction, readonly string[]> = {
|
|
|
84
84
|
drop: ["card"],
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
* The actions that change the board: everything but a read.
|
|
89
|
-
*
|
|
90
|
-
* One definition, read by the tool (does this call need a teammate?) and by plan
|
|
91
|
-
* mode (may this call run while planning?), so "a read" cannot mean two things.
|
|
92
|
-
*/
|
|
93
|
-
export const BOARD_MUTATING_ACTIONS: ReadonlySet<BoardAction> = new Set(
|
|
94
|
-
BOARD_ACTIONS.filter((action) => action !== "list"),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
87
|
/** A result the transcript renders and the page's routes mirror. */
|
|
98
88
|
export interface BoardDetails {
|
|
99
89
|
action: BoardAction;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { MATCHES_QUERY_JS } from "../../../lib/board/card.ts";
|
|
17
|
-
import { CLIENT_RUNTIME_JS } from "../../../lib/web/index.ts";
|
|
17
|
+
import { CLIENT_BASE_JS, CLIENT_RUNTIME_JS } from "../../../lib/web/index.ts";
|
|
18
18
|
import { PLURAL_JS } from "../../../lib/board/round-text.ts";
|
|
19
19
|
import { FORMAT_AGE_JS } from "../../../lib/format.ts";
|
|
20
20
|
import { namesScript, TAB_TITLE_JS, THEME_TOGGLE_JS, dialogScript, keysDialogScript } from "../../../lib/web/index.ts";
|
|
@@ -32,7 +32,8 @@ import { BOARD_TEAM_JS } from "./team.ts";
|
|
|
32
32
|
* shape, what a `query` matches, and how an age reads: the browser has no import graph,
|
|
33
33
|
* and a second copy of any of them is exactly what this bundle is not allowed to hold.
|
|
34
34
|
*/
|
|
35
|
-
export const BOARD_CLIENT_JS = `${
|
|
35
|
+
export const BOARD_CLIENT_JS = `${CLIENT_BASE_JS}
|
|
36
|
+
${namesScript(BOARD_CLASS)}
|
|
36
37
|
${renderJs({ plural: PLURAL_JS, tabTitle: TAB_TITLE_JS, matches: MATCHES_QUERY_JS, age: FORMAT_AGE_JS, runtime: CLIENT_RUNTIME_JS })}
|
|
37
38
|
${BOARD_TEAM_JS}
|
|
38
39
|
${BOARD_DIALOG_JS}
|
|
@@ -11,12 +11,6 @@ export const BOARD_LIVE_JS = String.raw`(function () {
|
|
|
11
11
|
"use strict";
|
|
12
12
|
var board = window.bfBoard || (window.bfBoard = {});
|
|
13
13
|
var token = new URLSearchParams(window.location.search).get("token") || "";
|
|
14
|
-
var LANE_LABEL = { todo: "To do", doing: "Doing", blocked: "Blocked", done: "Done" };
|
|
15
|
-
|
|
16
|
-
/** The lane's word, for the dialog's move buttons. */
|
|
17
|
-
board.laneLabel = function (lane) {
|
|
18
|
-
return LANE_LABEL[lane] || lane;
|
|
19
|
-
};
|
|
20
14
|
|
|
21
15
|
/** The badge's text and its kit status role: connecting, live, reconnecting. */
|
|
22
16
|
function badge(state, at) {
|
|
@@ -161,7 +155,7 @@ export const BOARD_LIVE_JS = String.raw`(function () {
|
|
|
161
155
|
|
|
162
156
|
/** The live channel: a reconnect is the browser's own retry, not a second request. */
|
|
163
157
|
function stream() {
|
|
164
|
-
var source = new EventSource("/events?token=" + encodeURIComponent(token));
|
|
158
|
+
var source = new EventSource(pageBase() + "/events?token=" + encodeURIComponent(token));
|
|
165
159
|
source.onopen = function () {
|
|
166
160
|
badge("live", board.lastFrame);
|
|
167
161
|
};
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
* contract test holds every class to the kit or to this page's stylesheet.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
baseAttribute,
|
|
18
|
+
brandLockup,
|
|
19
|
+
button,
|
|
20
|
+
docHead,
|
|
21
|
+
keysSheet,
|
|
22
|
+
tabTitle,
|
|
23
|
+
themeToggle,
|
|
24
|
+
} from "../../../lib/web/index.ts";
|
|
17
25
|
import { BOARD_KEYS } from "./keys.ts";
|
|
18
26
|
import { BOARD_CLASS as N } from "./names.ts";
|
|
19
27
|
import { BOARD_CSS } from "./styles.ts";
|
|
@@ -28,12 +36,15 @@ export type PageView = "board" | "team";
|
|
|
28
36
|
* shared link opens the tab it names. The document has no form and no write affordance:
|
|
29
37
|
* a page that cannot change a card cannot offer a button the server would refuse.
|
|
30
38
|
*/
|
|
31
|
-
export function renderBoardPage(input: { view: PageView }): string {
|
|
39
|
+
export function renderBoardPage(input: { view: PageView; base?: string }): string {
|
|
32
40
|
const team = input.view === "team";
|
|
41
|
+
// The page answers under the session's mount, so its own script and theme are named
|
|
42
|
+
// from that prefix rather than from the origin's root.
|
|
43
|
+
const base = input.base ?? "";
|
|
33
44
|
return `<!doctype html>
|
|
34
|
-
<html lang="en" data-view="${team ? "team" : "board"}">
|
|
45
|
+
<html lang="en" data-view="${team ? "team" : "board"}"${baseAttribute(base)}>
|
|
35
46
|
<head>
|
|
36
|
-
${docHead({ title: tabTitle("board"), css: BOARD_CSS })}
|
|
47
|
+
${docHead({ title: tabTitle("board"), css: BOARD_CSS, base })}
|
|
37
48
|
</head>
|
|
38
49
|
<body class="bf-app">
|
|
39
50
|
<h1 class="sr-only">board — the shared feature list for this repository</h1>
|
|
@@ -111,7 +122,7 @@ ${docHead({ title: tabTitle("board"), css: BOARD_CSS })}
|
|
|
111
122
|
</div>
|
|
112
123
|
|
|
113
124
|
${keysSheet(BOARD_KEYS)}
|
|
114
|
-
<script src="/app.js" defer></script>
|
|
125
|
+
<script src="${base}/app.js" defer></script>
|
|
115
126
|
</body>
|
|
116
127
|
</html>
|
|
117
128
|
`;
|
|
@@ -68,13 +68,12 @@ __RUNTIME__
|
|
|
68
68
|
/**
|
|
69
69
|
* One button that calls back, so nothing on the page is a click handler in markup.
|
|
70
70
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* The class comes from the caller: a card's title and a lane's head are buttons drawn
|
|
72
|
+
* by the page's own rule, not by the kit's chip-with-border look — and a card draws no
|
|
73
|
+
* action button at all, since claiming, moving, and editing are the tools' work.
|
|
74
74
|
*/
|
|
75
|
-
function actionButton(label, onClick
|
|
76
|
-
var
|
|
77
|
-
var node = el("button", "bf-btn" + kind, label);
|
|
75
|
+
function actionButton(className, label, onClick) {
|
|
76
|
+
var node = el("button", className, label);
|
|
78
77
|
node.type = "button";
|
|
79
78
|
node.addEventListener("click", onClick);
|
|
80
79
|
return node;
|
|
@@ -153,7 +152,11 @@ __RUNTIME__
|
|
|
153
152
|
var shown = card.paths.slice(0, limit);
|
|
154
153
|
for (var i = 0; i < shown.length; i++) {
|
|
155
154
|
var item = el("li", "");
|
|
156
|
-
|
|
155
|
+
var hint = chip(shown[i]);
|
|
156
|
+
// A path longer than the card elides, and the whole of it is on the hover title: the
|
|
157
|
+
// card is often the only place its hints are read before opening the record.
|
|
158
|
+
hint.title = shown[i];
|
|
159
|
+
item.appendChild(hint);
|
|
157
160
|
list.appendChild(item);
|
|
158
161
|
}
|
|
159
162
|
if (card.paths.length > limit) {
|
|
@@ -173,10 +176,9 @@ __RUNTIME__
|
|
|
173
176
|
|
|
174
177
|
var head = el("p", N.cardHead);
|
|
175
178
|
head.appendChild(el("span", N.cardId, "#" + card.id));
|
|
176
|
-
var title = actionButton(card.title, function () {
|
|
179
|
+
var title = actionButton(N.cardTitle, card.title, function () {
|
|
177
180
|
board.openCard(card.id);
|
|
178
181
|
});
|
|
179
|
-
title.className = N.cardTitle;
|
|
180
182
|
head.appendChild(title);
|
|
181
183
|
item.appendChild(head);
|
|
182
184
|
|
|
@@ -248,14 +250,19 @@ __RUNTIME__
|
|
|
248
250
|
// A footer that only counts read cards would say "2 cards" over a board holding
|
|
249
251
|
// three files, so the unreadable ones are named beside the size.
|
|
250
252
|
where.textContent =
|
|
251
|
-
"store " + view.where + " · " + plural(view.counts.total, "card") + " · " + size(view.bytes) +
|
|
253
|
+
view.repo + " · store " + view.where + " · " + plural(view.counts.total, "card") + " · " + size(view.bytes) +
|
|
252
254
|
(view.skipped > 0 ? " · " + plural(view.skipped, "unreadable") : "");
|
|
255
|
+
// The name is what the line has room for; the path is what tells two clones of one
|
|
256
|
+
// repository apart when the name is the same.
|
|
257
|
+
where.title = view.root;
|
|
253
258
|
}
|
|
254
|
-
// The tab has to tell two boards apart, and a board's
|
|
255
|
-
//
|
|
256
|
-
//
|
|
259
|
+
// The tab has to tell two boards apart, and a board's subject is its repository: one
|
|
260
|
+
// board per repository, named the way the directory holding it is. The store key is a
|
|
261
|
+
// hash, which no reader can say out loud, so it is not what a tab leads with. The count
|
|
262
|
+
// is what a browser full of tabs is read for, and an empty board says nothing rather
|
|
263
|
+
// than 0.
|
|
257
264
|
var open = view.counts.total - view.counts.done;
|
|
258
|
-
document.title = tabTitle("board", view.
|
|
265
|
+
document.title = tabTitle("board", view.repo, open > 0 ? open + " open" : undefined);
|
|
259
266
|
}
|
|
260
267
|
|
|
261
268
|
/** 1 card, 2 cards — the one body, handed in by the bundle's assembler. */
|
|
@@ -289,10 +296,9 @@ __RUNTIME__
|
|
|
289
296
|
section = el("section", N.lane);
|
|
290
297
|
section.setAttribute("data-status", lane.status);
|
|
291
298
|
var head = el("h2", N.laneHead);
|
|
292
|
-
var toggle = actionButton(LANE_LABEL[lane.status] || lane.status, function () {
|
|
299
|
+
var toggle = actionButton(N.laneToggle, LANE_LABEL[lane.status] || lane.status, function () {
|
|
293
300
|
board.toggleLane(lane.status);
|
|
294
301
|
});
|
|
295
|
-
toggle.className = N.laneToggle;
|
|
296
302
|
toggle.setAttribute("aria-expanded", "true");
|
|
297
303
|
toggle.setAttribute("aria-controls", "lane-" + lane.status);
|
|
298
304
|
toggle.id = "lane-head-" + lane.status;
|
|
@@ -508,7 +514,6 @@ __RUNTIME__
|
|
|
508
514
|
board.el = el;
|
|
509
515
|
board.typing = typing;
|
|
510
516
|
board.chip = chip;
|
|
511
|
-
board.actionButton = actionButton;
|
|
512
517
|
board.ago = ago;
|
|
513
518
|
board.findCard = findCard;
|
|
514
519
|
board.drawnCards = drawnCards;
|
|
@@ -77,7 +77,7 @@ ${WEB_COMPONENTS_CSS}
|
|
|
77
77
|
.board-card[hidden]{display:none}
|
|
78
78
|
.board-card__head{display:flex;align-items:baseline;gap:var(--space-2);margin:0;min-width:0}
|
|
79
79
|
.board-card__id{font:var(--fs-xs) var(--font-mono);color:var(--dim)}
|
|
80
|
-
.board-card__title{margin:0;padding:0;border:0;background:none;text-align:left;cursor:pointer;font:var(--fw-semibold) var(--fs-base)/var(--lh-snug) var(--font-ui);color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
80
|
+
.board-card__title{margin:0;padding:0;border:0;background:none;text-align:left;cursor:pointer;font:var(--fw-semibold) var(--fs-base)/var(--lh-snug) var(--font-ui);color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
|
|
81
81
|
.board-card__title:hover{color:var(--accent)}
|
|
82
82
|
/* Finished work recedes: it is history, and the live lanes should not compete with it. */
|
|
83
83
|
.board-card[data-status="done"] .board-card__title{color:var(--muted)}
|
|
@@ -85,6 +85,12 @@ ${WEB_COMPONENTS_CSS}
|
|
|
85
85
|
.board-card[data-status="done"] .bf-chip--accent{color:var(--muted);border-color:var(--border)}
|
|
86
86
|
.board-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin:0;font:var(--fs-xs) var(--font-ui);color:var(--muted)}
|
|
87
87
|
.board-card__paths{display:flex;flex-wrap:wrap;gap:var(--space-1);margin:0;padding:0;list-style:none}
|
|
88
|
+
/* A chip is one line and nowrap by design, so an owner label or a path longer than the card
|
|
89
|
+
would spill past its border and across the lane beside it: clipped to the card instead,
|
|
90
|
+
with the whole of it on the hover title the renderer sets. The list item has to be the one
|
|
91
|
+
that shrinks, because a flex item's automatic minimum is its chip's width. */
|
|
92
|
+
.board-card__paths li{min-width:0;max-width:100%}
|
|
93
|
+
.board-card .bf-chip{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis}
|
|
88
94
|
.board-card__reason{margin:0;font:var(--fs-sm)/var(--lh-snug) var(--font-ui);color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
|
|
89
95
|
.board-card__reason--blocked{color:var(--danger)}
|
|
90
96
|
.board-card__reason--unplanned{color:var(--warn)}
|
|
@@ -28,16 +28,13 @@ export const CONFIG = {
|
|
|
28
28
|
branch: GLYPHS.branch,
|
|
29
29
|
detached: GLYPHS.detached,
|
|
30
30
|
worktree: GLYPHS.worktree,
|
|
31
|
-
|
|
32
|
-
browse: GLYPHS.browse,
|
|
31
|
+
http: GLYPHS.http,
|
|
33
32
|
delegation: GLYPHS.delegation,
|
|
34
33
|
},
|
|
35
34
|
/** Status key the worktree extension uses; routed to line 1's right zone. */
|
|
36
35
|
worktreeStatusKey: STATUS_KEYS.worktree,
|
|
37
|
-
/** Status key
|
|
38
|
-
|
|
39
|
-
/** Status key the file-browse extension uses; routed to line 1's right zone. */
|
|
40
|
-
browseStatusKey: STATUS_KEYS.browse,
|
|
36
|
+
/** Status key `serve` publishes the session's one page port under. */
|
|
37
|
+
httpStatusKey: STATUS_KEYS.http,
|
|
41
38
|
/** Status key whose ` · detail` suffix (the plan file name) is dropped. */
|
|
42
39
|
planStatusKey: STATUS_KEYS.planMode,
|
|
43
40
|
/**
|
|
@@ -9,14 +9,25 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import { RenderCache } from "../../../../lib/tui/render-cache.ts";
|
|
13
|
+
import { snapshotKey } from "./key.ts";
|
|
12
14
|
import { renderLine } from "./layout.ts";
|
|
13
15
|
import { buildLines } from "./lines.ts";
|
|
14
16
|
import type { FooterData, StatusSnapshot, TuiLike } from "../types.ts";
|
|
15
17
|
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* A two-line, width-adaptive status bar.
|
|
20
|
+
*
|
|
21
|
+
* The bar is derived from a snapshot the reader rebuilds every frame, so it memoizes on a
|
|
22
|
+
* key rather than on the snapshot's identity: the width, the theme object (its strings are
|
|
23
|
+
* what the memo holds), and `snapshotKey`'s walk over every field the bar could draw. A
|
|
24
|
+
* frame where nothing moved costs the key and a comparison; a frame that moved pays the key
|
|
25
|
+
* as well, which is why the key is measured against the build it saves.
|
|
26
|
+
*/
|
|
17
27
|
class StatusBar implements Component {
|
|
18
28
|
/** Unsubscribes the branch watcher; set by `createFooter`. */
|
|
19
29
|
dispose?: () => void;
|
|
30
|
+
private readonly cache = new RenderCache();
|
|
20
31
|
|
|
21
32
|
constructor(
|
|
22
33
|
private readonly read: () => StatusSnapshot,
|
|
@@ -25,15 +36,18 @@ class StatusBar implements Component {
|
|
|
25
36
|
) {}
|
|
26
37
|
|
|
27
38
|
invalidate(): void {
|
|
28
|
-
//
|
|
29
|
-
//
|
|
39
|
+
// Pi calls this on a theme change; the key holds the theme's identity too, so this
|
|
40
|
+
// is what drops themed strings a rebuild would otherwise keep.
|
|
41
|
+
this.cache.invalidate();
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
render(width: number): string[] {
|
|
33
45
|
const snapshot = this.read();
|
|
34
46
|
const theme = this.getTheme();
|
|
35
47
|
const target = Math.max(1, width);
|
|
36
|
-
return
|
|
48
|
+
return this.cache.keyed([target, theme, snapshotKey(snapshot)], () =>
|
|
49
|
+
buildLines(snapshot, theme, this.home).map((line) => renderLine(line, target, theme)),
|
|
50
|
+
);
|
|
37
51
|
}
|
|
38
52
|
}
|
|
39
53
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key for one status snapshot: everything the bar draws, and nothing about identity.
|
|
3
|
+
*
|
|
4
|
+
* The footer repaints on every frame, and its snapshot is a fresh object each time — the
|
|
5
|
+
* reader rebuilds it from the session and the host, and prices only what moved — so a memo
|
|
6
|
+
* cannot key on the snapshot itself. It keys on this string instead, and this string is
|
|
7
|
+
* derived from the snapshot's own shape: object keys sorted, a `Map` walked as sorted
|
|
8
|
+
* entries, a list by its items, a primitive by its value.
|
|
9
|
+
*
|
|
10
|
+
* That derivation is the point. A hand-written list of the fields a bar reads is exactly
|
|
11
|
+
* the kind of list that goes stale silently — a field added to `StatusSnapshot` and
|
|
12
|
+
* forgotten here is a bar that keeps showing the old value until something else moves —
|
|
13
|
+
* whereas a walk over the object cannot miss a field that exists.
|
|
14
|
+
*
|
|
15
|
+
* Lengths prefix strings and collection sizes, so no two different snapshots can produce
|
|
16
|
+
* one key by running values together.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { StatusSnapshot } from "../types.ts";
|
|
20
|
+
|
|
21
|
+
/** The key for one snapshot: equal strings mean a frame would draw the same bar. */
|
|
22
|
+
export function snapshotKey(snapshot: StatusSnapshot): string {
|
|
23
|
+
return keyPart(snapshot);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One value's part of the key, tagged by kind so `1` and `"1"` cannot collide. */
|
|
27
|
+
function keyPart(value: unknown): string {
|
|
28
|
+
if (value === undefined) return "~";
|
|
29
|
+
if (value === null) return "n";
|
|
30
|
+
if (typeof value === "number") return String(value);
|
|
31
|
+
if (typeof value === "boolean") return value ? "t" : "f";
|
|
32
|
+
if (typeof value === "string") return `${value.length}:${value}`;
|
|
33
|
+
if (value instanceof Map) {
|
|
34
|
+
// Sorted, because a Map's insertion order is not a fact about what the bar draws.
|
|
35
|
+
const entries = [...value.entries()].map(([key, entry]) => `${keyPart(String(key))}=${keyPart(entry)}`);
|
|
36
|
+
entries.sort();
|
|
37
|
+
return `m${value.size}{${entries.join(",")}}`;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(value)) return `a${value.length}[${value.map(keyPart).join(",")}]`;
|
|
40
|
+
if (typeof value === "object") {
|
|
41
|
+
const record = value as Record<string, unknown>;
|
|
42
|
+
const keys = Object.keys(record).sort();
|
|
43
|
+
return `o${keys.length}{${keys.map((key) => `${key}:${keyPart(record[key])}`).join(",")}}`;
|
|
44
|
+
}
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
@@ -125,16 +125,26 @@ export function renderLine(spec: LineSpec, width: number, theme: Theme): string
|
|
|
125
125
|
const target = Math.max(1, width);
|
|
126
126
|
const states = makeStates(spec);
|
|
127
127
|
|
|
128
|
+
// Each pass keeps the line it measured, so a decision never costs a second assembly and
|
|
129
|
+
// a line that already fits never reaches `truncateToWidth` — which walks the string
|
|
130
|
+
// character by character (14 µs on a footer line, against 0.2 µs for the width scan) and
|
|
131
|
+
// is the whole reason this function was measurable per frame.
|
|
132
|
+
let line = assemble(states, target);
|
|
133
|
+
let measured = visibleWidth(line);
|
|
128
134
|
let guard = 0;
|
|
129
|
-
while (
|
|
135
|
+
while (measured > target && guard++ < states.length * 8 + 16) {
|
|
130
136
|
if (!reduceOnce(states)) break;
|
|
137
|
+
line = assemble(states, target);
|
|
138
|
+
measured = visibleWidth(line);
|
|
131
139
|
}
|
|
132
140
|
// Only once every segment is at its floor do we shrink separators, so the
|
|
133
141
|
// truncation ellipsis never replaces a separator's content and adjacent
|
|
134
142
|
// segments always keep at least one column of padding between them.
|
|
135
|
-
while (
|
|
143
|
+
while (measured > target) {
|
|
136
144
|
if (!collapseSeparator(states)) break;
|
|
145
|
+
line = assemble(states, target);
|
|
146
|
+
measured = visibleWidth(line);
|
|
137
147
|
}
|
|
138
148
|
|
|
139
|
-
return truncateToWidth(
|
|
149
|
+
return measured > target ? truncateToWidth(line, target, paint(theme, "faint", "…")) : line;
|
|
140
150
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Turn a `StatusSnapshot` into the two footer lines.
|
|
3
3
|
*
|
|
4
4
|
* Line 1 — state: left `pwd` and the session name, right the alert, the mode
|
|
5
|
-
* chips, then `⎇ branch · ⑂ worktree ·
|
|
5
|
+
* chips, then `⎇ branch · ⑂ worktree · ◉ 4780`. Line 2 — meters: left
|
|
6
6
|
* the context gauge and usage, right the model and thinking level.
|
|
7
7
|
*
|
|
8
8
|
* The split is one job per row. The state chips change while a session runs, so
|
|
@@ -46,9 +46,9 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
|
|
|
46
46
|
modesSegment(snapshot, theme),
|
|
47
47
|
branchSegment(snapshot, theme),
|
|
48
48
|
worktreeSegment(snapshot, theme),
|
|
49
|
-
// Last, so the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
49
|
+
// Last, so the page port rides behind the location chips. A page starting or
|
|
50
|
+
// stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays the
|
|
51
|
+
// adjacent pair a reader scans for where the work lands.
|
|
52
52
|
portsSegment(snapshot, theme),
|
|
53
53
|
]),
|
|
54
54
|
};
|
|
@@ -101,41 +101,23 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* The
|
|
104
|
+
* The session's page port, while anything is served on it.
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* made two facts of the same kind look like two kinds of fact. Shared, they also drop
|
|
110
|
-
* together, and the surviving form is the pair of glyphs, so a bar too narrow for a port
|
|
111
|
-
* still says a page is up.
|
|
106
|
+
* One server serves every page of a session, so there is one number to draw: the chip
|
|
107
|
+
* the `serve` extension publishes, with the glyph alone as the form that survives a bar
|
|
108
|
+
* too narrow for a port.
|
|
112
109
|
*/
|
|
113
110
|
export function portsSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.filter((entry) => snapshot.statuses.has(entry.key))
|
|
119
|
-
.map((entry) => ({
|
|
120
|
-
icon: paint(theme, "ok", entry.icon),
|
|
121
|
-
port: chipLabel(snapshot.statuses.get(entry.key) ?? ""),
|
|
122
|
-
}));
|
|
123
|
-
if (open.length === 0) return null;
|
|
124
|
-
const joined = (separator: string): string =>
|
|
125
|
-
open
|
|
126
|
-
.map((entry) => (entry.port ? `${entry.icon} ${paint(theme, "accent", entry.port)}` : entry.icon))
|
|
127
|
-
.join(separator);
|
|
128
|
-
// Widest first: the item separator, then a single space, then the glyphs alone. A
|
|
129
|
-
// one-page session has two forms; two pages sharing a padded bar have three.
|
|
130
|
-
const forms = [
|
|
131
|
-
...new Set([joined(faint(theme, CONFIG.separators.item)), joined(" "), open.map((entry) => entry.icon).join(" ")]),
|
|
132
|
-
];
|
|
111
|
+
const chip = snapshot.statuses.get(CONFIG.httpStatusKey);
|
|
112
|
+
if (chip === undefined) return null;
|
|
113
|
+
const icon = paint(theme, "ok", CONFIG.icons.http);
|
|
114
|
+
const port = chipLabel(chip);
|
|
133
115
|
return {
|
|
134
116
|
id: "ports",
|
|
135
117
|
weight: 3,
|
|
136
118
|
droppable: true,
|
|
137
119
|
separator: faint(theme, CONFIG.separators.item),
|
|
138
|
-
forms,
|
|
120
|
+
forms: [...new Set([port ? `${icon} ${paint(theme, "accent", port)}` : icon, icon])],
|
|
139
121
|
};
|
|
140
122
|
}
|
|
141
123
|
|
|
@@ -171,13 +153,7 @@ export function alertSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
|
|
|
171
153
|
|
|
172
154
|
export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
173
155
|
const others = [...snapshot.statuses.entries()]
|
|
174
|
-
.filter(
|
|
175
|
-
([key]) =>
|
|
176
|
-
key !== CONFIG.worktreeStatusKey &&
|
|
177
|
-
key !== CONFIG.boardStatusKey &&
|
|
178
|
-
key !== CONFIG.browseStatusKey &&
|
|
179
|
-
!isAlert(key),
|
|
180
|
-
)
|
|
156
|
+
.filter(([key]) => key !== CONFIG.worktreeStatusKey && key !== CONFIG.httpStatusKey && !isAlert(key))
|
|
181
157
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
182
158
|
.map(([key, value]) => {
|
|
183
159
|
const status = sanitize(value);
|