@gavin-hu/my-pi-agent 0.20.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 -30
- package/README.zh-CN.md +40 -25
- 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 -23
- package/docs/extensions/board.md +397 -168
- package/docs/extensions/bytefly.md +234 -197
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +57 -63
- 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 +197 -140
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +145 -0
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +83 -44
- 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 +79 -25
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +17 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +5 -20
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +65 -93
- package/extensions/board/result.ts +30 -39
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +49 -11
- package/extensions/board/snapshot.ts +8 -6
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +154 -55
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +36 -42
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +35 -16
- 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 +90 -28
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +38 -13
- package/extensions/board/view/render.ts +219 -277
- package/extensions/board/view/styles.ts +130 -32
- 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 +7 -8
- package/extensions/bytefly/footer/register.ts +33 -4
- 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 +10 -7
- package/extensions/bytefly/footer/render/segments.ts +41 -38
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/component.ts +4 -20
- 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/render.ts +10 -1
- 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/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +89 -79
- 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/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/goal/tui.ts +12 -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 +21 -9
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
- 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/feedback.ts +62 -16
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +58 -51
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +114 -0
- 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 +61 -41
- 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-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +102 -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-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +10 -68
- 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-router.ts +18 -9
- 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 +67 -131
- 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 +39 -0
- package/extensions/serve/index.ts +85 -0
- package/extensions/serve/registry.ts +260 -0
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +17 -9
- 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/result.ts +1 -27
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/tools.ts +5 -4
- 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 -5
- 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/{tui.ts → view/todo-screen.ts} +18 -90
- 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 +28 -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 +30 -5
- 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 +199 -51
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +119 -36
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +81 -63
- package/lib/bus-request.ts +83 -0
- 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 +17 -11
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +30 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +46 -21
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +10 -10
- package/lib/session-label.ts +129 -7
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +39 -22
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +29 -11
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +11 -11
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +33 -4
- package/lib/web/README.md +21 -0
- package/lib/web/client-runtime.ts +27 -1
- 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 +20 -2
- package/lib/web/runtime.ts +9 -10
- package/package.json +8 -2
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/config.ts +0 -36
- package/extensions/board/seat.ts +0 -28
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import type
|
|
13
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
|
|
15
15
|
import { budgetBody, budgetNotice, budgetState } from "./budget.ts";
|
|
16
16
|
import { createSnapshotReader, type SnapshotReader } from "./collect/snapshot.ts";
|
|
@@ -32,6 +32,30 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
|
32
32
|
const delegation = createDelegationLedger();
|
|
33
33
|
pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
|
|
34
34
|
|
|
35
|
+
// The label a peer knows this session by, once the follower below reports one; `null` is
|
|
36
|
+
// no record, and the bar keeps Pi's own session name.
|
|
37
|
+
let rosterLabel: string | null = null;
|
|
38
|
+
/** Whether this session's label is being followed: a tree change re-installs, it is not new. */
|
|
39
|
+
let followed = false;
|
|
40
|
+
const followLabel = (ctx: ExtensionContext): void => {
|
|
41
|
+
if (followed) return;
|
|
42
|
+
followed = true;
|
|
43
|
+
// Loaded where it is used: the follower walks the bus's layout on disk and subscribes to
|
|
44
|
+
// its label, and the registration graph is a budget that may only fall.
|
|
45
|
+
void import("./collect/label.ts")
|
|
46
|
+
.then(({ followRosterLabel }) =>
|
|
47
|
+
followRosterLabel({
|
|
48
|
+
pi,
|
|
49
|
+
ctx,
|
|
50
|
+
agentDir: getAgentDir(),
|
|
51
|
+
sink: (label) => {
|
|
52
|
+
rosterLabel = label;
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
};
|
|
58
|
+
|
|
35
59
|
/** The installed reader and the budget it was priced with, while a footer is up. */
|
|
36
60
|
let budget: { limit: number; read: SnapshotReader } | undefined;
|
|
37
61
|
/** One warning per session: the notice stays in scrollback, and a re-install must not repeat it. */
|
|
@@ -41,8 +65,11 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
|
41
65
|
|
|
42
66
|
const install = (ctx: ExtensionContext): void => {
|
|
43
67
|
if (ctx.mode !== "tui") return;
|
|
68
|
+
// Not awaited: a footer is installed synchronously, and the bar draws Pi's name until the
|
|
69
|
+
// label lands — which is what a session with no record shows anyway.
|
|
70
|
+
followLabel(ctx);
|
|
44
71
|
const config = loadByteflyConfig(ctx.cwd);
|
|
45
|
-
const read = createSnapshotReader(config, delegation);
|
|
72
|
+
const read = createSnapshotReader(config, delegation, () => rosterLabel);
|
|
46
73
|
budget = { limit: config.budget, read };
|
|
47
74
|
|
|
48
75
|
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
@@ -57,9 +84,11 @@ export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
|
57
84
|
};
|
|
58
85
|
|
|
59
86
|
pi.on("session_start", (_event, ctx) => {
|
|
60
|
-
// A new session in this process starts from zero: the ledger is not branch state,
|
|
61
|
-
// and a budget that was already reported is unreported again.
|
|
87
|
+
// A new session in this process starts from zero: the ledger is not branch state, the
|
|
88
|
+
// label belongs to the session, and a budget that was already reported is unreported again.
|
|
62
89
|
delegation.reset();
|
|
90
|
+
followed = false;
|
|
91
|
+
rosterLabel = null;
|
|
63
92
|
warned = false;
|
|
64
93
|
warnedUnpriced = false;
|
|
65
94
|
install(ctx);
|
|
@@ -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
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turn a `StatusSnapshot` into the two footer lines.
|
|
3
3
|
*
|
|
4
|
-
* Line 1 — state: left `pwd` and the session name, right the alert, the
|
|
5
|
-
* chips, then `⎇ branch · ⑂ worktree ·
|
|
6
|
-
* the context gauge and usage
|
|
4
|
+
* Line 1 — state: left `pwd` and the session name, right the alert, the remaining
|
|
5
|
+
* status chips, then `⎇ branch · ⑂ worktree · ◉ 4780`. Line 2 — meters: left the
|
|
6
|
+
* mode, the context gauge, and the 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
|
|
9
9
|
* they ride the line with slack; the meters are the dense row, and a fact that
|
|
10
|
-
* moves onto the top row is one the bottom row stops dropping to fit.
|
|
10
|
+
* moves onto the top row is one the bottom row stops dropping to fit. The mode
|
|
11
|
+
* leads the meters because it qualifies every number after it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
costSegment,
|
|
20
21
|
delegationSegment,
|
|
21
22
|
modelSegment,
|
|
23
|
+
modeSegment,
|
|
22
24
|
modesSegment,
|
|
23
25
|
portsSegment,
|
|
24
26
|
pwdSegment,
|
|
@@ -46,14 +48,15 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
|
|
|
46
48
|
modesSegment(snapshot, theme),
|
|
47
49
|
branchSegment(snapshot, theme),
|
|
48
50
|
worktreeSegment(snapshot, theme),
|
|
49
|
-
// Last, so the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
51
|
+
// Last, so the page port rides behind the location chips. A page starting or
|
|
52
|
+
// stopping then changes the end of the line, and `⎇ branch · ⑂ worktree` stays the
|
|
53
|
+
// adjacent pair a reader scans for where the work lands.
|
|
52
54
|
portsSegment(snapshot, theme),
|
|
53
55
|
]),
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
const line2Left = defined([
|
|
59
|
+
modeSegment(snapshot, theme),
|
|
57
60
|
contextSegment(snapshot, theme),
|
|
58
61
|
windowSegment(snapshot, theme),
|
|
59
62
|
costSegment(snapshot, theme),
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
thinkingColor,
|
|
25
25
|
truncateLabel,
|
|
26
26
|
} from "./format.ts";
|
|
27
|
-
import { compactStatus
|
|
27
|
+
import { compactStatus } from "./status.ts";
|
|
28
28
|
import type { Segment, StatusSnapshot } from "../types.ts";
|
|
29
29
|
|
|
30
30
|
/** Tertiary text: paths and de-emphasized meters. */
|
|
@@ -55,7 +55,11 @@ export function pwdSegment(snapshot: StatusSnapshot, theme: Theme, home: string
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function sessionSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
58
|
-
|
|
58
|
+
// The roster's label first: Pi's session name is what the terminal calls this session, and
|
|
59
|
+
// the label is what a card, a handoff, and `/team` use. No record means no team, so the bar
|
|
60
|
+
// keeps the name the user knows.
|
|
61
|
+
const raw = snapshot.rosterLabel ?? snapshot.sessionName;
|
|
62
|
+
const name = raw ? sanitizeLine(raw) : "";
|
|
59
63
|
if (!name) return null;
|
|
60
64
|
const short = truncateLabel(name, CONFIG.sessionLabelMax);
|
|
61
65
|
// Brackets and a brighter token keep the session name from reading as a
|
|
@@ -101,41 +105,23 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
|
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/**
|
|
104
|
-
* The
|
|
108
|
+
* The session's page port, while anything is served on it.
|
|
105
109
|
*
|
|
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.
|
|
110
|
+
* One server serves every page of a session, so there is one number to draw: the chip
|
|
111
|
+
* the `serve` extension publishes, with the glyph alone as the form that survives a bar
|
|
112
|
+
* too narrow for a port.
|
|
112
113
|
*/
|
|
113
114
|
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
|
-
];
|
|
115
|
+
const chip = snapshot.statuses.get(CONFIG.httpStatusKey);
|
|
116
|
+
if (chip === undefined) return null;
|
|
117
|
+
const icon = paint(theme, "ok", CONFIG.icons.http);
|
|
118
|
+
const port = chipLabel(chip);
|
|
133
119
|
return {
|
|
134
120
|
id: "ports",
|
|
135
121
|
weight: 3,
|
|
136
122
|
droppable: true,
|
|
137
123
|
separator: faint(theme, CONFIG.separators.item),
|
|
138
|
-
forms,
|
|
124
|
+
forms: [...new Set([port ? `${icon} ${paint(theme, "accent", port)}` : icon, icon])],
|
|
139
125
|
};
|
|
140
126
|
}
|
|
141
127
|
|
|
@@ -169,21 +155,36 @@ export function alertSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
|
|
|
169
155
|
};
|
|
170
156
|
}
|
|
171
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The mode: `≡ plan` while planning, `▶ auto` otherwise.
|
|
160
|
+
*
|
|
161
|
+
* Plan owns the flag, so the presence of its status key is what the bar reads; the
|
|
162
|
+
* mode's word and colour are the bar's own, so neither the plan file name nor a
|
|
163
|
+
* truncated chip can reach it. Planning wears `warn`, the same role the composer's
|
|
164
|
+
* border takes while it is on. It leads the resource line, before the gauge, so the
|
|
165
|
+
* mode is read before how much room is left. A session without `plan` still reads
|
|
166
|
+
* `auto`: that is the mode it is in.
|
|
167
|
+
*/
|
|
168
|
+
export function modeSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
169
|
+
if (snapshot.statuses.has(CONFIG.planStatusKey)) {
|
|
170
|
+
const plan = paint(theme, "warn", `${CONFIG.icons.planMode} ${CONFIG.labels.planMode}`);
|
|
171
|
+
return { id: "mode", weight: 1, droppable: false, separator: "", forms: [plan, compactStatus(plan)] };
|
|
172
|
+
}
|
|
173
|
+
const auto = paint(theme, "accent", `${CONFIG.icons.autoMode} ${CONFIG.labels.autoMode}`);
|
|
174
|
+
return { id: "mode", weight: 1, droppable: false, separator: "", forms: [auto] };
|
|
175
|
+
}
|
|
176
|
+
|
|
172
177
|
export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
173
178
|
const others = [...snapshot.statuses.entries()]
|
|
174
179
|
.filter(
|
|
175
180
|
([key]) =>
|
|
181
|
+
key !== CONFIG.planStatusKey &&
|
|
176
182
|
key !== CONFIG.worktreeStatusKey &&
|
|
177
|
-
key !== CONFIG.
|
|
178
|
-
key !== CONFIG.browseStatusKey &&
|
|
183
|
+
key !== CONFIG.httpStatusKey &&
|
|
179
184
|
!isAlert(key),
|
|
180
185
|
)
|
|
181
186
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
182
|
-
.map(([
|
|
183
|
-
const status = sanitize(value);
|
|
184
|
-
// The plan chip trails the plan file name; the slot shows the mode only.
|
|
185
|
-
return key === CONFIG.planStatusKey ? dropStatusDetail(status) : status;
|
|
186
|
-
})
|
|
187
|
+
.map(([, value]) => sanitize(value))
|
|
187
188
|
.filter(Boolean);
|
|
188
189
|
if (others.length === 0) return null;
|
|
189
190
|
const full = others.join(faint(theme, CONFIG.separators.item));
|
|
@@ -193,7 +194,7 @@ export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
|
|
|
193
194
|
const icons = others.map((status) => compactStatus(status)).join(" ");
|
|
194
195
|
return {
|
|
195
196
|
id: "statuses",
|
|
196
|
-
// Weight 2, ahead of the branch: a
|
|
197
|
+
// Weight 2, ahead of the branch: a status is stated again by the session, while
|
|
197
198
|
// `⎇` is the only thing on the bar that says where the work will land.
|
|
198
199
|
weight: 2,
|
|
199
200
|
droppable: true,
|
|
@@ -212,7 +213,9 @@ export function contextSegment(snapshot: StatusSnapshot, theme: Theme): Segment
|
|
|
212
213
|
const forms = widths.map((blocks) =>
|
|
213
214
|
blocks > 0 ? `${gaugeBar(theme, { percent, blocks, role, emptyRole: "faint" })} ${percentText}` : percentText,
|
|
214
215
|
);
|
|
215
|
-
|
|
216
|
+
// The mode always leads this segment, so the separator prints on every frame;
|
|
217
|
+
// the gauge's offset is the mode's width, not whether one is present.
|
|
218
|
+
return { id: "context", weight: 1, droppable: false, separator: faint(theme, CONFIG.separators.item), forms };
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
@@ -29,14 +29,3 @@ export function compactStatus(status: string): string {
|
|
|
29
29
|
const text = tokens.length === 2 && /^\d+$/.test(tokens[1]) ? `${icon}${tokens[1]}` : icon;
|
|
30
30
|
return `${prefix}${text}${suffix}`;
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Drop a status's trailing ` · detail` while keeping its SGR styling.
|
|
35
|
-
*
|
|
36
|
-
* The `plan-mode` chip carries the plan file name as detail (`≡ plan · name`);
|
|
37
|
-
* the bar shows only the mode. Matching `[^ESC]*` removes the visible suffix
|
|
38
|
-
* without consuming the closing reset, so the chip's color stays balanced.
|
|
39
|
-
*/
|
|
40
|
-
export function dropStatusDetail(status: string): string {
|
|
41
|
-
return status.replace(/ · [^\x1b]*/, "");
|
|
42
|
-
}
|
|
@@ -84,6 +84,13 @@ export interface StatusSnapshot {
|
|
|
84
84
|
cwd: string;
|
|
85
85
|
/** Session name set by the user, `null` when unnamed. */
|
|
86
86
|
sessionName: string | null;
|
|
87
|
+
/**
|
|
88
|
+
* The name peers address this session by, when the bus has recorded one.
|
|
89
|
+
*
|
|
90
|
+
* `null` means no record — bytefly runs without the bus and the bar keeps Pi's own
|
|
91
|
+
* session name, which is all a session that never joined has.
|
|
92
|
+
*/
|
|
93
|
+
rosterLabel: string | null;
|
|
87
94
|
/** Branch name, `null` when not in a repository, `"detached"` on detached HEAD. */
|
|
88
95
|
branch: string | null;
|
|
89
96
|
/** Extension status texts (from `ctx.ui.setStatus`), including `worktree`. */
|
|
@@ -16,6 +16,7 @@ import { screenHint } from "../../../lib/tui/chrome.ts";
|
|
|
16
16
|
import { keyHintText } from "../../../lib/tui/glyphs.ts";
|
|
17
17
|
import { BODY_INDENT } from "../../../lib/tui/indent.ts";
|
|
18
18
|
import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
|
|
19
|
+
import { RenderCache } from "../../../lib/tui/render-cache.ts";
|
|
19
20
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
20
21
|
import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
|
|
21
22
|
import { identityCells } from "./identity.ts";
|
|
@@ -69,11 +70,7 @@ function helpParts(entry: HelpEntry): { key: string; description: string } {
|
|
|
69
70
|
|
|
70
71
|
/** The themed header component. */
|
|
71
72
|
export class ByteflyHeader implements Component {
|
|
72
|
-
private
|
|
73
|
-
private cachedTheme: Theme | undefined;
|
|
74
|
-
private cachedView = "";
|
|
75
|
-
private cachedKeys = "";
|
|
76
|
-
private cachedLines: string[] = [];
|
|
73
|
+
private readonly cache = new RenderCache();
|
|
77
74
|
|
|
78
75
|
constructor(
|
|
79
76
|
private readonly getTheme: () => Theme,
|
|
@@ -82,7 +79,7 @@ export class ByteflyHeader implements Component {
|
|
|
82
79
|
) {}
|
|
83
80
|
|
|
84
81
|
invalidate(): void {
|
|
85
|
-
this.
|
|
82
|
+
this.cache.invalidate();
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
/**
|
|
@@ -97,20 +94,7 @@ export class ByteflyHeader implements Component {
|
|
|
97
94
|
const view = this.view();
|
|
98
95
|
const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
|
|
99
96
|
const keys = this.keySignature();
|
|
100
|
-
|
|
101
|
-
this.cachedWidth === w &&
|
|
102
|
-
this.cachedTheme === theme &&
|
|
103
|
-
this.cachedView === viewKey &&
|
|
104
|
-
this.cachedKeys === keys
|
|
105
|
-
) {
|
|
106
|
-
return this.cachedLines;
|
|
107
|
-
}
|
|
108
|
-
this.cachedLines = this.build(w, theme, view);
|
|
109
|
-
this.cachedWidth = w;
|
|
110
|
-
this.cachedTheme = theme;
|
|
111
|
-
this.cachedView = viewKey;
|
|
112
|
-
this.cachedKeys = keys;
|
|
113
|
-
return this.cachedLines;
|
|
97
|
+
return this.cache.keyed([w, theme, viewKey, keys], () => this.build(w, theme, view));
|
|
114
98
|
}
|
|
115
99
|
|
|
116
100
|
/** The chord of every help binding, so a rebind repaints the help rows. */
|
|
@@ -93,7 +93,7 @@ export const HEADER_CONFIG: HeaderConfig = {
|
|
|
93
93
|
{ binding: "app.thinking.toggle", description: "to expand thinking" },
|
|
94
94
|
{ binding: "app.model.select", description: "to select model" },
|
|
95
95
|
{ binding: "app.model.cycleForward", description: "to cycle models" },
|
|
96
|
-
{
|
|
96
|
+
{ raw: "shift+tab", description: "to toggle plan" },
|
|
97
97
|
{ binding: "app.editor.external", description: "for external editor" },
|
|
98
98
|
],
|
|
99
99
|
[
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* bytefly — the package's terminal chrome and diagnostics, composed from
|
|
2
|
+
* bytefly — the package's terminal chrome and diagnostics, composed from twelve
|
|
3
3
|
* feature folders: `header/` (the themed startup mark and version), `footer/`
|
|
4
4
|
* (the two-line, width-adaptive status bar with its price calculation),
|
|
5
5
|
* `message/` (the user-message nameplate), `notice/` (the shared transcript
|
|
6
6
|
* notice renderer), `block/` (the unified tool-block frame), `separator/` (the
|
|
7
|
-
* transcript turn rule), `working/` (the live run line on
|
|
8
|
-
* `panel/` (the unified `/bytefly` screen and its
|
|
7
|
+
* transcript turn rule), `working/` (the live run line on the composer's top
|
|
8
|
+
* border), `panel/` (the unified `/bytefly` screen and its
|
|
9
9
|
* `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
|
|
10
|
-
* probe runtime), `notify/` (OS notifications for unattended runs),
|
|
11
|
-
*
|
|
10
|
+
* probe runtime), `notify/` (OS notifications for unattended runs), `mode/`
|
|
11
|
+
* (the editor's Shift+Tab plan-mode toggle and its border), and `update/` (Pi's
|
|
12
|
+
* package list, the host suppression, and the header marker).
|
|
12
13
|
*
|
|
13
14
|
* The header, footer, turn rule, and working line are installed
|
|
14
15
|
* only in interactive (`tui`) sessions, and are always on; the user-message
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
* Load with: pi --extension ./extensions/bytefly
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
27
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
27
28
|
import { registerToolBlocks } from "./block/register.ts";
|
|
28
29
|
import { registerChrome } from "./chrome.ts";
|
|
@@ -87,4 +88,29 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
|
|
|
87
88
|
// the one repaint hook rather than installing a header of its own.
|
|
88
89
|
registerPiRelease(pi, piUpdates, { ...deps.release, onUpdate: () => header.refresh() });
|
|
89
90
|
registerPanel(pi, { doctor, update, discover: deps.extensions?.discover });
|
|
91
|
+
|
|
92
|
+
// Shift+Tab is a chord Pi reserves for the thinking cycle, so the mode key is
|
|
93
|
+
// an editor rather than a shortcut. It loads on the first TUI session and only
|
|
94
|
+
// when `plan` is loaded, so a session without it keeps Pi's key and pays for
|
|
95
|
+
// nothing; the mode module owns that availability check.
|
|
96
|
+
let detachModeKey: (() => void) | undefined;
|
|
97
|
+
let modeEpoch = 0;
|
|
98
|
+
const installModeKey = async (ctx: ExtensionContext): Promise<void> => {
|
|
99
|
+
const epoch = ++modeEpoch;
|
|
100
|
+
detachModeKey?.();
|
|
101
|
+
detachModeKey = undefined;
|
|
102
|
+
if (ctx.mode !== "tui") return;
|
|
103
|
+
const { attachModeKey } = await import("./mode/editor.ts");
|
|
104
|
+
// A shutdown or a newer session may land while the module loads; the stale
|
|
105
|
+
// reply must not install an editor nothing will release.
|
|
106
|
+
if (epoch !== modeEpoch) return;
|
|
107
|
+
detachModeKey = attachModeKey(pi, ctx);
|
|
108
|
+
};
|
|
109
|
+
pi.on("session_start", (_event, ctx) => installModeKey(ctx));
|
|
110
|
+
pi.on("session_tree", (_event, ctx) => installModeKey(ctx));
|
|
111
|
+
pi.on("session_shutdown", () => {
|
|
112
|
+
modeEpoch++;
|
|
113
|
+
detachModeKey?.();
|
|
114
|
+
detachModeKey = undefined;
|
|
115
|
+
});
|
|
90
116
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Shift+Tab chord at the main editor: the plan-mode toggle and the border
|
|
3
|
+
* that says which mode the composer is in, with the live working line riding it.
|
|
4
|
+
*
|
|
5
|
+
* Pi reserves `app.thinking.cycle` on Shift+Tab, so an extension shortcut for the
|
|
6
|
+
* key is refused. The editor is the narrower place to own it: an overlay or a
|
|
7
|
+
* picker has focus instead, so a dialog keeps its own Shift+Tab. Attaching also
|
|
8
|
+
* unsets the built-in cycle binding, because a key that acts and a key the help
|
|
9
|
+
* still claims are two different answers to the same press.
|
|
10
|
+
*
|
|
11
|
+
* Replacing Pi's editor means opting back into what that editor did:
|
|
12
|
+
* `embedWorkingStatus` is what makes the host hand the working indicator over for
|
|
13
|
+
* the top border, as Pi's own editor asks for. Without it the indicator falls back
|
|
14
|
+
* to a row above the composer, and the border would carry the mode colour alone.
|
|
15
|
+
*
|
|
16
|
+
* The mode is plan's to own; this module only asks over the shared channel and
|
|
17
|
+
* reads the branch for the border. Attach once per session and release with the
|
|
18
|
+
* returned cleanup, which reverts only the editor and binding it still owns.
|
|
19
|
+
* Without `plan` there is nothing to toggle, so attach does nothing and Pi keeps
|
|
20
|
+
* its thinking-cycle chord.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
CustomEditor,
|
|
25
|
+
type ExtensionAPI,
|
|
26
|
+
type ExtensionContext,
|
|
27
|
+
type KeybindingsManager as AppKeybindingsManager,
|
|
28
|
+
type Theme,
|
|
29
|
+
} from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type EditorTheme, getKeybindings, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui";
|
|
31
|
+
import { isPlanExtensionLoaded, requestPlanToggle } from "../../../lib/plan-entry.ts";
|
|
32
|
+
import { isPlanModeEnabled } from "../../../lib/plan-state.ts";
|
|
33
|
+
import { isShiftTab } from "../../../lib/tui/cursor.ts";
|
|
34
|
+
import { paint } from "../../../lib/tui/tokens.ts";
|
|
35
|
+
|
|
36
|
+
/** What the editor reads on every input and frame. */
|
|
37
|
+
interface ModeEditorOptions {
|
|
38
|
+
/** Ask the mode's owner to flip it. */
|
|
39
|
+
onToggle: () => void;
|
|
40
|
+
/** The branch is the truth, whoever toggled it. */
|
|
41
|
+
isPlanning: () => boolean;
|
|
42
|
+
/** The role theme, read each frame so a theme change repaints. */
|
|
43
|
+
theme: () => Theme;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The composer with Shift+Tab bound to plan mode and its border to the mode's own colour. */
|
|
47
|
+
export class ModeEditor extends CustomEditor {
|
|
48
|
+
constructor(
|
|
49
|
+
tui: TUI,
|
|
50
|
+
theme: EditorTheme,
|
|
51
|
+
keybindings: KeybindingsManager,
|
|
52
|
+
private readonly options: ModeEditorOptions,
|
|
53
|
+
) {
|
|
54
|
+
// Pi's manager carries the resolved keybindings config on top of the TUI one;
|
|
55
|
+
// the editor only calls `matches()`, which both provide. The option is the same
|
|
56
|
+
// one Pi's own editor sets, so the working line lands on this border instead of
|
|
57
|
+
// in a row of its own above the composer.
|
|
58
|
+
super(tui, theme, keybindings as unknown as AppKeybindingsManager, { embedWorkingStatus: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override handleInput(data: string): void {
|
|
62
|
+
if (isShiftTab(data)) {
|
|
63
|
+
this.options.onToggle();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
super.handleInput(data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override render(width: number): string[] {
|
|
70
|
+
// The two roles the footer's mode segment draws, so the bar and the composer agree.
|
|
71
|
+
const role = this.options.isPlanning() ? "warn" : "accent";
|
|
72
|
+
this.borderColor = (text) => paint(this.options.theme(), role, text);
|
|
73
|
+
return super.render(width);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The binding the editor takes over; Pi's default chord for the thinking cycle. */
|
|
78
|
+
const THINKING_CYCLE = "app.thinking.cycle";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Install the mode editor and take Shift+Tab from the thinking cycle, or return
|
|
82
|
+
* `undefined` when the `plan` extension is not loaded.
|
|
83
|
+
*
|
|
84
|
+
* Returns an idempotent cleanup. It clears the editor only while ours is the
|
|
85
|
+
* installed factory, and restores the binding only while the empty override it
|
|
86
|
+
* wrote is still there, so a later extension's editor or binding is never undone.
|
|
87
|
+
*/
|
|
88
|
+
export function attachModeKey(
|
|
89
|
+
pi: Pick<ExtensionAPI, "events" | "getAllTools">,
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
): (() => void) | undefined {
|
|
92
|
+
if (!isPlanExtensionLoaded(pi)) return undefined;
|
|
93
|
+
const factory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
|
|
94
|
+
new ModeEditor(tui, theme, keybindings, {
|
|
95
|
+
onToggle: () => {
|
|
96
|
+
void requestPlanToggle(pi, ctx);
|
|
97
|
+
},
|
|
98
|
+
isPlanning: () => isPlanModeEnabled(ctx.sessionManager.getBranch()),
|
|
99
|
+
theme: () => ctx.ui.theme,
|
|
100
|
+
});
|
|
101
|
+
const bindings = getKeybindings();
|
|
102
|
+
const previous = bindings.getUserBindings()[THINKING_CYCLE];
|
|
103
|
+
ctx.ui.setEditorComponent(factory);
|
|
104
|
+
bindings.setUserBindings({ ...bindings.getUserBindings(), [THINKING_CYCLE]: [] });
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(undefined);
|
|
108
|
+
const current = bindings.getUserBindings()[THINKING_CYCLE];
|
|
109
|
+
if (!Array.isArray(current) || current.length > 0) return;
|
|
110
|
+
const restored = { ...bindings.getUserBindings() };
|
|
111
|
+
if (previous === undefined) delete restored[THINKING_CYCLE];
|
|
112
|
+
else restored[THINKING_CYCLE] = previous;
|
|
113
|
+
bindings.setUserBindings(restored);
|
|
114
|
+
};
|
|
115
|
+
}
|