@gavin-hu/my-pi-agent 0.13.0 → 0.14.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 +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +163 -61
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +18 -1
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +7 -5
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +269 -42
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/job/format.ts +4 -7
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +13 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/wechat/README.md +2 -0
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +5 -3
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- package/themes/bytefly-light.json +2 -3
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session spend guardrail: one warning when the session's cost reaches the
|
|
3
|
+
* configured budget.
|
|
4
|
+
*
|
|
5
|
+
* A budget is an alert, not a limit. Nothing is blocked or aborted — a hard stop
|
|
6
|
+
* loses work, and the footer already shows the running cost — so this decides only
|
|
7
|
+
* whether the threshold has been crossed and what the one notice says.
|
|
8
|
+
*
|
|
9
|
+
* Pure: the caller has the priced spend and the currency, this shapes the answer.
|
|
10
|
+
* The amount formatting reuses the footer's own `formatCost`, so the notice and the
|
|
11
|
+
* status bar cannot disagree about precision or about a sub-minor-unit amount.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
|
|
15
|
+
import { formatCost } from "./render/format.ts";
|
|
16
|
+
import type { Currency } from "./price/types.ts";
|
|
17
|
+
|
|
18
|
+
/** Where the session stands against its budget. */
|
|
19
|
+
export interface BudgetState {
|
|
20
|
+
/** The configured budget, in `currency`; `0` means the guardrail is off. */
|
|
21
|
+
limit: number;
|
|
22
|
+
/** What the session has spent so far, in `currency`. */
|
|
23
|
+
spent: number;
|
|
24
|
+
/** Whether the budget is on and has been reached. Inclusive: exactly `limit` counts. */
|
|
25
|
+
exceeded: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session's position against its budget. */
|
|
29
|
+
export function budgetState(limit: number, spent: number): BudgetState {
|
|
30
|
+
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 0;
|
|
31
|
+
const safeSpent = Number.isFinite(spent) && spent > 0 ? spent : 0;
|
|
32
|
+
return { limit: safeLimit, spent: safeSpent, exceeded: safeLimit > 0 && safeSpent >= safeLimit };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The one notice a crossing posts.
|
|
37
|
+
*
|
|
38
|
+
* `warn` rather than `bad`: nothing failed, and nothing will be stopped. The
|
|
39
|
+
* content names the figures so the model reads the same sentence the user does,
|
|
40
|
+
* and says plainly that the session keeps running.
|
|
41
|
+
*/
|
|
42
|
+
export function budgetNotice(state: BudgetState, currency: Currency): NoticeMessage {
|
|
43
|
+
const spent = formatCost(state.spent, currency);
|
|
44
|
+
const limit = formatCost(state.limit, currency);
|
|
45
|
+
return noticeMessage({
|
|
46
|
+
content:
|
|
47
|
+
`Session spend reached the configured budget: ${spent} of ${limit}. ` +
|
|
48
|
+
"This is a warning only — the session continues; raise or clear `price.budget` to silence it.",
|
|
49
|
+
details: {
|
|
50
|
+
source: "bytefly",
|
|
51
|
+
title: "Session budget reached",
|
|
52
|
+
severity: "warn",
|
|
53
|
+
items: [
|
|
54
|
+
{ label: "spent", detail: spent },
|
|
55
|
+
{ label: "budget", detail: limit },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The one-line body an OS notification carries. */
|
|
62
|
+
export function budgetBody(state: BudgetState, currency: Currency): string {
|
|
63
|
+
return `Session budget reached · ${formatCost(state.spent, currency)} of ${formatCost(state.limit, currency)}`;
|
|
64
|
+
}
|
|
@@ -17,7 +17,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import type { DelegationLedger } from "../delegation.ts";
|
|
18
18
|
import { createPricer } from "../price/price.ts";
|
|
19
19
|
import type { ByteflyConfig } from "../price/types.ts";
|
|
20
|
-
import type { ContextInfo, FooterData, StatusSnapshot } from "../types.ts";
|
|
20
|
+
import type { ContextInfo, FooterData, StatusSnapshot, UsageTotals } from "../types.ts";
|
|
21
21
|
import { emptyUsage, usageRecords } from "./entries.ts";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -28,7 +28,20 @@ import { emptyUsage, usageRecords } from "./entries.ts";
|
|
|
28
28
|
* are stale; this avoids copying the full entry list and rebuilding the session
|
|
29
29
|
* projection per frame. Pi's own footer memoizes its stats on the same idea.
|
|
30
30
|
*/
|
|
31
|
-
export
|
|
31
|
+
export interface SnapshotReader {
|
|
32
|
+
/** Read a snapshot, re-pricing only when the session moved. */
|
|
33
|
+
(ctx: ExtensionContext, footerData: FooterData): StatusSnapshot;
|
|
34
|
+
/**
|
|
35
|
+
* The usage totals from the last read, or undefined before the first frame.
|
|
36
|
+
*
|
|
37
|
+
* The budget guardrail reads the number the footer already priced instead of
|
|
38
|
+
* walking the session a second time; if no frame has rendered yet there is
|
|
39
|
+
* nothing stale to act on.
|
|
40
|
+
*/
|
|
41
|
+
peek(): UsageTotals | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createSnapshotReader(config: ByteflyConfig, delegation: DelegationLedger): SnapshotReader {
|
|
32
45
|
const price = createPricer(config);
|
|
33
46
|
let key: string | null = null;
|
|
34
47
|
let usage = emptyUsage();
|
|
@@ -36,7 +49,9 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
|
|
|
36
49
|
let cacheHitRate: number | null = null;
|
|
37
50
|
let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
|
|
38
51
|
|
|
39
|
-
|
|
52
|
+
let last: UsageTotals | undefined;
|
|
53
|
+
|
|
54
|
+
const reader = ((ctx: ExtensionContext, footerData: FooterData): StatusSnapshot => {
|
|
40
55
|
const session = ctx.sessionManager;
|
|
41
56
|
const current = ctx.model;
|
|
42
57
|
const nextKey = `${session.getSessionId()}:${session.getLeafId() ?? ""}:${current?.provider ?? ""}/${current?.id ?? ""}:${delegation.version()}`;
|
|
@@ -56,6 +71,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
|
|
|
56
71
|
percent: usageInfo?.percent ?? null,
|
|
57
72
|
};
|
|
58
73
|
key = nextKey;
|
|
74
|
+
last = usage;
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
const model = current
|
|
@@ -76,5 +92,7 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
|
|
|
76
92
|
providerCount: footerData.getAvailableProviderCount(),
|
|
77
93
|
delegation: delegationTotals,
|
|
78
94
|
};
|
|
79
|
-
};
|
|
95
|
+
}) as SnapshotReader;
|
|
96
|
+
reader.peek = () => last;
|
|
97
|
+
return reader;
|
|
80
98
|
}
|
|
@@ -79,6 +79,18 @@ export const CURRENCY_DEFAULTS: Readonly<Record<CurrencyCode, Currency>> = {
|
|
|
79
79
|
USD: { code: "USD", decimals: 3 },
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The day the built-in tables were last checked against the published page.
|
|
84
|
+
*
|
|
85
|
+
* `doctor` reports how old this is, so a tariff change is visible instead of
|
|
86
|
+
* silently wrong. What was checked on this date (UTC): DeepSeek's published USD figures
|
|
87
|
+
* (flash $0.3 in / $1.2 out per 1M, cache hit $0.006; v4-pro $1.32 / $3.96, cache
|
|
88
|
+
* hit $0.044), the off-peak rule (half the peak rate), and the peak windows (01:00
|
|
89
|
+
* - 04:00 and 06:00 - 10:00 UTC, Monday to Friday). The yuan table is the same
|
|
90
|
+
* page's CNY view. Re-check the page, then update this date.
|
|
91
|
+
*/
|
|
92
|
+
export const PRICE_VERIFIED_AT = "2026-10-18";
|
|
93
|
+
|
|
82
94
|
/** Yuan, the currency `DEFAULT_RATES` is stated in. */
|
|
83
95
|
export const DEFAULT_CURRENCY: Currency = CURRENCY_DEFAULTS.CNY;
|
|
84
96
|
|
|
@@ -86,9 +98,13 @@ export const DEFAULT_BYTEFLY_CONFIG: ByteflyConfig = {
|
|
|
86
98
|
currency: DEFAULT_CURRENCY,
|
|
87
99
|
rates: DEFAULT_RATES,
|
|
88
100
|
peak: DEFAULT_PEAK,
|
|
101
|
+
budget: 0,
|
|
89
102
|
};
|
|
90
103
|
|
|
91
|
-
|
|
104
|
+
/** The highest `currency.decimals` the screen and the loader both accept. */
|
|
105
|
+
export const MAX_DECIMALS = 4;
|
|
106
|
+
/** Upper bound for `budget`; large enough for any real session, small enough to clamp a typo. */
|
|
107
|
+
export const MAX_BUDGET = 10_000_000;
|
|
92
108
|
const MAX_RATE = 1_000_000;
|
|
93
109
|
const MAX_RATE_KEYS = 200;
|
|
94
110
|
const MAX_WINDOWS = 16;
|
|
@@ -182,6 +198,7 @@ export function normalizeByteflyConfig(raw: Record<string, unknown> | undefined,
|
|
|
182
198
|
currency,
|
|
183
199
|
rates: ratesValue(raw.rates, rateBase),
|
|
184
200
|
peak: peakValue(raw.peak, base.peak),
|
|
201
|
+
budget: clampNumber(raw.budget, base.budget, 0, MAX_BUDGET),
|
|
185
202
|
};
|
|
186
203
|
}
|
|
187
204
|
|
|
@@ -56,4 +56,9 @@ export interface ByteflyConfig {
|
|
|
56
56
|
/** Rates keyed by `provider/model`, matched through wildcards. */
|
|
57
57
|
rates: Readonly<Record<string, ModelRates>>;
|
|
58
58
|
peak: PeakSchedule;
|
|
59
|
+
/**
|
|
60
|
+
* Session spend that triggers one warning, in `currency`; `0` turns the
|
|
61
|
+
* guardrail off. It warns, it does not stop anything.
|
|
62
|
+
*/
|
|
63
|
+
budget: number;
|
|
59
64
|
}
|
|
@@ -12,22 +12,35 @@
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { budgetBody, budgetNotice, budgetState } from "./budget.ts";
|
|
16
|
+
import { createSnapshotReader, type SnapshotReader } from "./collect/snapshot.ts";
|
|
16
17
|
import { createDelegationLedger } from "./delegation.ts";
|
|
17
18
|
import { loadByteflyConfig } from "./price/config.ts";
|
|
18
19
|
import { createFooter } from "./render/footer.ts";
|
|
19
20
|
|
|
21
|
+
/** Host seams, injected by the composition root and by tests. */
|
|
22
|
+
export interface FooterDeps {
|
|
23
|
+
/** Raise one OS notification through bytefly's notify capability. */
|
|
24
|
+
notify?: (body: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
/** Register the footer install/restore lifecycle. */
|
|
21
|
-
export function registerFooter(pi: ExtensionAPI): void {
|
|
28
|
+
export function registerFooter(pi: ExtensionAPI, deps: FooterDeps = {}): void {
|
|
22
29
|
// One ledger per load: it is this session's child-run accounting, priced by
|
|
23
30
|
// whichever reader is installed (a config change installs a new one).
|
|
24
31
|
const delegation = createDelegationLedger();
|
|
25
32
|
pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
|
|
26
33
|
|
|
34
|
+
/** The installed reader and the budget it was priced with, while a footer is up. */
|
|
35
|
+
let budget: { limit: number; read: SnapshotReader } | undefined;
|
|
36
|
+
/** One warning per session: the notice stays in scrollback, and a re-install must not repeat it. */
|
|
37
|
+
let warned = false;
|
|
38
|
+
|
|
27
39
|
const install = (ctx: ExtensionContext): void => {
|
|
28
40
|
if (ctx.mode !== "tui") return;
|
|
29
41
|
const config = loadByteflyConfig(ctx.cwd);
|
|
30
42
|
const read = createSnapshotReader(config, delegation);
|
|
43
|
+
budget = { limit: config.budget, read };
|
|
31
44
|
|
|
32
45
|
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
33
46
|
createFooter(
|
|
@@ -41,12 +54,30 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
41
54
|
};
|
|
42
55
|
|
|
43
56
|
pi.on("session_start", (_event, ctx) => {
|
|
44
|
-
// A new session in this process starts from zero: the ledger is not branch state
|
|
57
|
+
// A new session in this process starts from zero: the ledger is not branch state,
|
|
58
|
+
// and a budget that was already reported is unreported again.
|
|
45
59
|
delegation.reset();
|
|
60
|
+
warned = false;
|
|
46
61
|
install(ctx);
|
|
47
62
|
});
|
|
48
63
|
pi.on("session_tree", (_event, ctx) => install(ctx));
|
|
64
|
+
|
|
65
|
+
// A turn's cost is final when the run settles, and that is when the user can act
|
|
66
|
+
// on it. Rendering never posts anything: the footer is derived state.
|
|
67
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
68
|
+
if (warned || !budget || budget.limit <= 0 || ctx.mode !== "tui") return;
|
|
69
|
+
const usage = budget.read.peek();
|
|
70
|
+
if (!usage) return;
|
|
71
|
+
const state = budgetState(budget.limit, usage.cost);
|
|
72
|
+
if (!state.exceeded) return;
|
|
73
|
+
warned = true;
|
|
74
|
+
const currency = loadByteflyConfig(ctx.cwd).currency;
|
|
75
|
+
pi.sendMessage(budgetNotice(state, currency));
|
|
76
|
+
deps.notify?.(budgetBody(state, currency));
|
|
77
|
+
});
|
|
78
|
+
|
|
49
79
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
80
|
+
budget = undefined;
|
|
50
81
|
if (ctx.mode !== "tui") return;
|
|
51
82
|
ctx.ui.setFooter(undefined);
|
|
52
83
|
});
|
|
@@ -18,9 +18,9 @@ import { BODY_INDENT } from "../../../lib/tui/indent.ts";
|
|
|
18
18
|
import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
|
|
19
19
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
20
20
|
import { updateMarker } from "../update/format.ts";
|
|
21
|
-
import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
|
|
21
|
+
import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
|
|
22
22
|
|
|
23
|
-
/** The header's shape
|
|
23
|
+
/** The header's shape: everything but the live terminal height is resolved once per apply. */
|
|
24
24
|
export interface HeaderView {
|
|
25
25
|
/** Print Pi's version on the agent line. */
|
|
26
26
|
showVersion: boolean;
|
|
@@ -28,6 +28,8 @@ export interface HeaderView {
|
|
|
28
28
|
halfBlocks: boolean;
|
|
29
29
|
/** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
|
|
30
30
|
pad: number;
|
|
31
|
+
/** Terminal rows, when the host reports them; short terminals shed help rows. */
|
|
32
|
+
rows?: number;
|
|
31
33
|
/** A newer published version of this package, when one is known. */
|
|
32
34
|
latest?: string;
|
|
33
35
|
}
|
|
@@ -35,6 +37,15 @@ export interface HeaderView {
|
|
|
35
37
|
/** The mark block is four columns on both rows, so the identity column aligns. */
|
|
36
38
|
const MARK_WIDTH = 4;
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The tallest shape whose threshold the terminal meets. An unknown or impossible
|
|
42
|
+
* height keeps the full header, so a host without terminal rows still reads well.
|
|
43
|
+
*/
|
|
44
|
+
function heightShape(rows: number | undefined): HeaderHeight {
|
|
45
|
+
if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return HEADER_CONFIG.heights[0];
|
|
46
|
+
return HEADER_CONFIG.heights.find((shape) => rows >= shape.minRows) ?? HEADER_CONFIG.heights[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Pad a themed mark line to the block width with plain spaces. */
|
|
39
50
|
function markBlock(line: string): string {
|
|
40
51
|
return line + " ".repeat(Math.max(0, MARK_WIDTH - visibleWidth(line)));
|
|
@@ -81,7 +92,7 @@ export class ByteflyHeader implements Component {
|
|
|
81
92
|
const w = Math.max(1, width);
|
|
82
93
|
const theme = this.getTheme();
|
|
83
94
|
const view = this.view();
|
|
84
|
-
const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
|
|
95
|
+
const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
|
|
85
96
|
const keys = this.keySignature();
|
|
86
97
|
if (
|
|
87
98
|
this.cachedWidth === w &&
|
|
@@ -111,6 +122,7 @@ export class ByteflyHeader implements Component {
|
|
|
111
122
|
private build(w: number, theme: Theme, view: HeaderView): string[] {
|
|
112
123
|
const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
|
|
113
124
|
HEADER_CONFIG;
|
|
125
|
+
const shape = heightShape(view.rows);
|
|
114
126
|
const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
|
|
115
127
|
const pad = " ".repeat(padWidth);
|
|
116
128
|
const [markTop, markBottom] = markLines(theme, view.halfBlocks);
|
|
@@ -145,13 +157,17 @@ export class ByteflyHeader implements Component {
|
|
|
145
157
|
|
|
146
158
|
// The header owns its leading blank: Pi mounts the built-in header inside
|
|
147
159
|
// `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
|
|
148
|
-
// screen adds none, and `setHeader` swaps only the header child.
|
|
160
|
+
// screen adds none, and `setHeader` swaps only the header child. A short
|
|
161
|
+
// terminal gives that row back, then sheds whole help groups.
|
|
149
162
|
return [
|
|
150
|
-
"",
|
|
163
|
+
...(shape.blank ? [""] : []),
|
|
151
164
|
this.identityLine(pad, markTop, agentCell, w),
|
|
152
165
|
this.identityLine(pad, markBottom, chromeCell, w),
|
|
153
166
|
this.rule(theme, w, pad),
|
|
154
|
-
...
|
|
167
|
+
...shape.helpRows.flatMap((index) => {
|
|
168
|
+
const group = help[index];
|
|
169
|
+
return group ? [this.helpLine(theme, w, group, padWidth)] : [];
|
|
170
|
+
}),
|
|
155
171
|
];
|
|
156
172
|
}
|
|
157
173
|
|
|
@@ -193,6 +209,12 @@ export class ByteflyHeader implements Component {
|
|
|
193
209
|
}
|
|
194
210
|
|
|
195
211
|
/** Header factory passed to `ctx.ui.setHeader`. */
|
|
196
|
-
export function createHeader(
|
|
197
|
-
|
|
212
|
+
export function createHeader(
|
|
213
|
+
getTheme: () => Theme,
|
|
214
|
+
view: (rows: number | undefined) => HeaderView,
|
|
215
|
+
): (tui: TUI, theme: Theme) => Component {
|
|
216
|
+
// Read the height on every render, so a terminal resize reflows the header
|
|
217
|
+
// instead of keeping the shape it was installed with. Tests may pass a bare
|
|
218
|
+
// object as the host, so the terminal is optional at the boundary.
|
|
219
|
+
return (tui) => new ByteflyHeader(getTheme, () => view(tui.terminal?.rows), VERSION);
|
|
198
220
|
}
|
|
@@ -19,6 +19,16 @@ export type HelpEntry =
|
|
|
19
19
|
| { readonly binding: AppKeybinding; readonly description: string }
|
|
20
20
|
| { readonly raw: string; readonly description: string };
|
|
21
21
|
|
|
22
|
+
/** How much of the help block a terminal height can carry. */
|
|
23
|
+
export interface HeaderHeight {
|
|
24
|
+
/** Smallest terminal height, in rows, this shape applies to. Taller tiers come first. */
|
|
25
|
+
readonly minRows: number;
|
|
26
|
+
/** Help groups to print, by index in `help`; a group is dropped whole. */
|
|
27
|
+
readonly helpRows: readonly number[];
|
|
28
|
+
/** Open with a blank row above the nameplate. */
|
|
29
|
+
readonly blank: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/** The header's shape and content. */
|
|
23
33
|
export interface HeaderConfig {
|
|
24
34
|
/** The agent's name, shown before its version. */
|
|
@@ -49,6 +59,12 @@ export interface HeaderConfig {
|
|
|
49
59
|
readonly chromeVersion: string;
|
|
50
60
|
/** Grouped help rows, printed one line each under the rule. */
|
|
51
61
|
readonly help: readonly (readonly HelpEntry[])[];
|
|
62
|
+
/**
|
|
63
|
+
* The height ladder, tallest first: the first tier the terminal meets decides
|
|
64
|
+
* which help groups print and whether the leading blank row survives. The
|
|
65
|
+
* floor is `minRows: 0`, so a short terminal keeps the nameplate and rule.
|
|
66
|
+
*/
|
|
67
|
+
readonly heights: readonly HeaderHeight[];
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
export const HEADER_CONFIG: HeaderConfig = {
|
|
@@ -89,4 +105,10 @@ export const HEADER_CONFIG: HeaderConfig = {
|
|
|
89
105
|
{ binding: "app.clipboard.pasteImage", description: "paste files" },
|
|
90
106
|
],
|
|
91
107
|
],
|
|
108
|
+
heights: [
|
|
109
|
+
{ minRows: 28, helpRows: [0, 1, 2], blank: true },
|
|
110
|
+
{ minRows: 22, helpRows: [0, 2], blank: true },
|
|
111
|
+
{ minRows: 16, helpRows: [2], blank: true },
|
|
112
|
+
{ minRows: 0, helpRows: [], blank: false },
|
|
113
|
+
],
|
|
92
114
|
};
|
|
@@ -55,11 +55,12 @@ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createU
|
|
|
55
55
|
// value the tool frame and the notice inset by. An older host without
|
|
56
56
|
// `getSettings` keeps the default margin.
|
|
57
57
|
const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
|
|
58
|
-
const view = (): HeaderView => ({
|
|
58
|
+
const view = (rows: number | undefined): HeaderView => ({
|
|
59
59
|
showVersion: HEADER_CONFIG.showVersion,
|
|
60
60
|
halfBlocks,
|
|
61
61
|
pad,
|
|
62
62
|
latest: updates.latest(),
|
|
63
|
+
rows,
|
|
63
64
|
});
|
|
64
65
|
ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
|
|
65
66
|
};
|
|
@@ -4,13 +4,13 @@
|
|
|
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
|
|
8
|
-
*
|
|
7
|
+
* transcript turn rule), `working/` (the live run line on Pi's working row),
|
|
8
|
+
* `panel/` (the unified `/bytefly` screen and its
|
|
9
9
|
* `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
|
|
10
10
|
* probe runtime), `notify/` (OS notifications for unattended runs), and
|
|
11
11
|
* `update/` (the published-version check behind the header's update marker).
|
|
12
12
|
*
|
|
13
|
-
* The header, footer, turn rule, and working
|
|
13
|
+
* The header, footer, turn rule, and working line are installed only in
|
|
14
14
|
* interactive (`tui`) sessions, and are always on; the user-message marker,
|
|
15
15
|
* notice, and tool blocks register at load and take effect wherever a
|
|
16
16
|
* transcript renders. `pi config` turns bytefly's chrome off. The panel and doctor work
|
|
@@ -59,7 +59,10 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
|
|
|
59
59
|
// the header reads it, so the marker and every re-install agree.
|
|
60
60
|
const updates = createUpdateState();
|
|
61
61
|
const header = registerHeader(pi, updates);
|
|
62
|
-
|
|
62
|
+
// Notify first, so the footer's budget guardrail can raise one OS notification
|
|
63
|
+
// through the same capability the triggers use instead of a second dispatch path.
|
|
64
|
+
const notifications = registerNotify(pi, deps.notify);
|
|
65
|
+
registerFooter(pi, { notify: notifications.notify });
|
|
63
66
|
registerUserMessage(pi);
|
|
64
67
|
// One store per load: the tool frame and the notice record Pi's output pad and
|
|
65
68
|
// the turn separator reads it, so bytefly's transcript blocks share one margin.
|
|
@@ -72,7 +75,6 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
|
|
|
72
75
|
registerTurnSeparator(pi, pad);
|
|
73
76
|
registerWorkingTimer(pi);
|
|
74
77
|
const doctor = registerDoctor(pi, deps.doctor);
|
|
75
|
-
registerNotify(pi, deps.notify);
|
|
76
78
|
// The returned handle is what `/bytefly update` re-runs, so the update surface is
|
|
77
79
|
// registered before the panel that offers the command.
|
|
78
80
|
const update = registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
|
|
@@ -18,7 +18,7 @@ import { cleanString, loadConfigSection } from "../../../lib/config.ts";
|
|
|
18
18
|
import { NOTIFY_METHODS, type NotifyConfig, type NotifyMethod } from "./types.ts";
|
|
19
19
|
|
|
20
20
|
/** A notification title longer than this is clamped; it is a label, not a message. */
|
|
21
|
-
const MAX_TITLE_LENGTH = 40;
|
|
21
|
+
export const MAX_TITLE_LENGTH = 40;
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
|
|
24
24
|
enabled: false,
|
|
@@ -55,8 +55,14 @@ function isWatchMatch(value: unknown): value is WatchMatchEvent {
|
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** What the notify capability offers the rest of bytefly. */
|
|
59
|
+
export interface NotifyHandle {
|
|
60
|
+
/** Raise one notification; inert while `notify.enabled` is off. */
|
|
61
|
+
notify(body: string): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
/** Register the notification triggers. */
|
|
59
|
-
export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}):
|
|
65
|
+
export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): NotifyHandle {
|
|
60
66
|
const seams = deps.seams ?? defaultSeams();
|
|
61
67
|
let config: NotifyConfig = DEFAULT_NOTIFY_CONFIG;
|
|
62
68
|
let busy = false;
|
|
@@ -106,4 +112,12 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
|
106
112
|
while (unsubscribes.length > 0) unsubscribes.pop()?.();
|
|
107
113
|
busy = false;
|
|
108
114
|
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
notify(body) {
|
|
118
|
+
// The master switch governs every path, including one another capability asks for.
|
|
119
|
+
if (!config.enabled) return;
|
|
120
|
+
fire(body);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
109
123
|
}
|
|
@@ -172,7 +172,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
|
|
|
172
172
|
if (section === "settings" && ctx.mode !== "tui") {
|
|
173
173
|
const stores = createSettingsStores(cwd, agentDir, trusted);
|
|
174
174
|
const settings = makeSettings({ stores, cwd, agentDir, trusted, onError: settingsNotice });
|
|
175
|
-
ctx.ui.notify(formatSettingsListing(settings.views("global")
|
|
175
|
+
ctx.ui.notify(formatSettingsListing(settings.views("global")), "info");
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -31,7 +31,7 @@ export interface SummarySegment {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Row and problem counts over a model. */
|
|
34
|
-
|
|
34
|
+
interface PanelCounts {
|
|
35
35
|
extensions: number;
|
|
36
36
|
/** Rows switched off in settings (the picker's checkbox), not the env flag. */
|
|
37
37
|
off: number;
|
|
@@ -113,28 +113,21 @@ export function sectionSegments(active: PanelSection): SummarySegment[] {
|
|
|
113
113
|
export function settingsSummarySegments(scope: PanelScope, count: number): SummarySegment[] {
|
|
114
114
|
return [
|
|
115
115
|
{ text: scope === "global" ? "Global" : "Project", color: "accent" },
|
|
116
|
-
{ text: `${count}
|
|
116
|
+
{ text: `${count} settings`, color: "meta" },
|
|
117
117
|
];
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 5 settings`. */
|
|
121
|
-
export function formatSettingsHeadline(scope: PanelScope, count: number): string {
|
|
122
|
-
return `bytefly: Settings · ${settingsSummarySegments(scope, count)
|
|
123
|
-
.map((segment) => segment.text)
|
|
124
|
-
.join(" · ")}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
120
|
/**
|
|
128
|
-
* The
|
|
129
|
-
*
|
|
121
|
+
* The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 16 settings`.
|
|
122
|
+
* The listing only ever reads the personal document, so the scope is not a parameter.
|
|
130
123
|
*/
|
|
131
|
-
export function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
export function formatSettingsHeadline(count: number): string {
|
|
125
|
+
return `bytefly: Settings · Global · ${count} settings`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The grouped Settings listing for non-TUI modes. */
|
|
129
|
+
export function formatSettingsListing(views: readonly SettingView[]): string {
|
|
130
|
+
const lines = [formatSettingsHeadline(views.length)];
|
|
138
131
|
for (const group of groupViews(views)) {
|
|
139
132
|
lines.push("", `${group.label} (${group.views.length})`);
|
|
140
133
|
for (const view of group.views) {
|
|
@@ -152,7 +145,7 @@ export function formatHeadline(model: PanelModel, scope: PanelScope, options: Pa
|
|
|
152
145
|
}
|
|
153
146
|
|
|
154
147
|
/** A row's glyph and verdict, drawn from its enablement and health axes. */
|
|
155
|
-
|
|
148
|
+
interface RowBadge {
|
|
156
149
|
/** The status glyph, or `undefined` when the row has nothing to say. */
|
|
157
150
|
glyph?: string;
|
|
158
151
|
/** The role the glyph is painted in. */
|