@gavin-hu/my-pi-agent 0.9.0 → 0.11.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 +28 -2
- package/README.zh-CN.md +27 -2
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +261 -36
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/tools.ts +5 -2
- package/extensions/bytefly/header/component.ts +21 -10
- package/extensions/bytefly/header/config.ts +0 -3
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- package/extensions/bytefly/separator/component.ts +2 -1
- package/extensions/bytefly/separator/format.ts +17 -10
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +11 -4
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +7 -7
- package/extensions/file-browse/README.md +3 -1
- package/extensions/file-browse/client.ts +9 -2
- package/extensions/file-browse/git.ts +4 -7
- package/extensions/file-browse/html.ts +27 -17
- package/extensions/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +7 -20
- package/extensions/goal/tools.ts +5 -17
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +113 -35
- package/extensions/job/commands.ts +1 -0
- package/extensions/job/config.ts +4 -0
- package/extensions/job/format.ts +19 -7
- package/extensions/job/index.ts +25 -20
- package/extensions/job/process.ts +24 -2
- package/extensions/job/registry.ts +3 -0
- package/extensions/job/runtime.ts +253 -18
- package/extensions/job/schema.ts +34 -0
- package/extensions/job/store.ts +13 -1
- package/extensions/job/tools.ts +55 -15
- package/extensions/job/tui.ts +41 -15
- package/extensions/job/types.ts +9 -1
- package/extensions/memory/README.md +6 -0
- package/extensions/memory/format.ts +5 -3
- package/extensions/plan/README.md +32 -12
- package/extensions/plan/tools.ts +20 -5
- package/extensions/plan/web-client.ts +58 -17
- package/extensions/plan/web-page.ts +34 -9
- package/extensions/plan/web-router.ts +40 -9
- package/extensions/plan/web.ts +22 -2
- package/extensions/question/README.md +13 -1
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +11 -6
- package/extensions/rewind/README.md +2 -0
- package/extensions/rewind/format.ts +6 -1
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +56 -29
- package/extensions/todo/README.md +19 -12
- package/extensions/todo/format.ts +17 -25
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/tui.ts +3 -2
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/format.ts +2 -2
- package/extensions/watch/index.ts +28 -23
- package/extensions/watch/tools.ts +5 -3
- package/extensions/watch/tui.ts +12 -7
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +1 -1
- package/extensions/web-access/search/tool.ts +3 -3
- package/extensions/wechat/README.md +6 -0
- package/extensions/worktree/README.md +6 -0
- package/lib/README.md +2 -1
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/tui/chrome.ts +8 -4
- package/lib/tui/cursor.ts +2 -1
- package/lib/tui/glyphs.ts +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +3 -1
- package/lib/tui/rows.ts +39 -5
- package/lib/tui/rules.ts +9 -3
- package/lib/tui/tokens.ts +14 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- package/assets/bytefly-chrome.png +0 -0
package/lib/notice.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared transcript notice: one custom-message type that any extension in
|
|
3
|
+
* the package can post, plus the model-facing content and the renderer-facing
|
|
4
|
+
* details it carries.
|
|
5
|
+
*
|
|
6
|
+
* Pi renders a custom message only when it was sent with `display: true`, and
|
|
7
|
+
* it resolves a renderer by the exact `customType` — there is no wildcard — so
|
|
8
|
+
* the type and the shape behind it are one contract the producing extension and
|
|
9
|
+
* bytefly's renderer must agree on. Keep both here, never re-typed on either
|
|
10
|
+
* side.
|
|
11
|
+
*
|
|
12
|
+
* `content` is what the model reads; `details` is never sent to the model and
|
|
13
|
+
* is the renderer's whole input, so a producer states the facts once and the
|
|
14
|
+
* renderer decides the look. Value-only: constants and pure functions, no
|
|
15
|
+
* state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
|
+
|
|
20
|
+
/** The custom-message type every transcript notice is sent under. */
|
|
21
|
+
export const NOTICE_TYPE = "my-pi-agent/notice";
|
|
22
|
+
|
|
23
|
+
/** How a notice, or one of its rows, reads at a glance. */
|
|
24
|
+
export type NoticeSeverity = "info" | "ok" | "warn" | "bad";
|
|
25
|
+
|
|
26
|
+
/** One row under a notice's headline. */
|
|
27
|
+
export interface NoticeItem {
|
|
28
|
+
/** The row's text, e.g. `j1 build`. Sanitized by the renderer. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Short trailing detail, e.g. `exit 1`. Sanitized by the renderer. */
|
|
31
|
+
detail?: string;
|
|
32
|
+
/** The row's severity; falls back to the notice's own. */
|
|
33
|
+
severity?: NoticeSeverity;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everything the renderer draws, and nothing the model sees. */
|
|
37
|
+
export interface NoticeDetails {
|
|
38
|
+
/** Who raised it, e.g. `job` or `watch`. */
|
|
39
|
+
source: string;
|
|
40
|
+
/** One-line headline. */
|
|
41
|
+
title: string;
|
|
42
|
+
/** The headline's severity; a row may override it. */
|
|
43
|
+
severity?: NoticeSeverity;
|
|
44
|
+
/** Rows under the headline, in order. */
|
|
45
|
+
items?: readonly NoticeItem[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NoticeInput {
|
|
49
|
+
/** Model-facing text; the notice is also shown when non-empty. */
|
|
50
|
+
content: string;
|
|
51
|
+
/** Renderer-facing structure. */
|
|
52
|
+
details: NoticeDetails;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The message shape both send paths accept (`pi.sendMessage`, `before_agent_start`). */
|
|
56
|
+
export interface NoticeMessage {
|
|
57
|
+
customType: string;
|
|
58
|
+
content: string;
|
|
59
|
+
display: true;
|
|
60
|
+
details: NoticeDetails;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A conversation message narrowed to a notice. */
|
|
64
|
+
export type NoticeEntry = AgentMessage & { customType: string; details?: NoticeDetails };
|
|
65
|
+
|
|
66
|
+
/** Build a visible notice message: shared type, `display: true`, the given content and details. */
|
|
67
|
+
export function noticeMessage(input: NoticeInput): NoticeMessage {
|
|
68
|
+
return { customType: NOTICE_TYPE, content: input.content, display: true, details: input.details };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether a conversation message is a notice. */
|
|
72
|
+
export function isNotice(message: AgentMessage): message is NoticeEntry {
|
|
73
|
+
return message.role === "custom" && (message as { customType?: string }).customType === NOTICE_TYPE;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Keep only the newest notice of `source` in a transcript, so stale injected
|
|
78
|
+
* context does not accumulate. Notices of other sources and every non-notice
|
|
79
|
+
* are left alone. Returns `undefined` when nothing is stale, so a `context`
|
|
80
|
+
* handler can leave the messages untouched.
|
|
81
|
+
*/
|
|
82
|
+
export function pruneStaleNotices(messages: readonly AgentMessage[], source: string): AgentMessage[] | undefined {
|
|
83
|
+
const ofSource = messages.filter((message) => isNotice(message) && message.details?.source === source);
|
|
84
|
+
if (ofSource.length <= 1) return undefined;
|
|
85
|
+
const keep = ofSource[ofSource.length - 1];
|
|
86
|
+
return messages.filter((message) => !(isNotice(message) && message.details?.source === source && message !== keep));
|
|
87
|
+
}
|
package/lib/tui/chrome.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
|
|
21
21
|
import { SEPARATORS } from "./glyphs.ts";
|
|
22
|
+
import { BODY_INDENT } from "./indent.ts";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* `ctx.ui.custom` options that give a screen the whole terminal.
|
|
@@ -65,23 +66,26 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
|
|
|
65
66
|
export type ScreenHint = string | { text: string; color?: ThemeColor };
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
|
-
* A
|
|
69
|
+
* A key-hint footer indented by `indent` columns (default `BODY_INDENT`, which
|
|
70
|
+
* hangs it under a screen's title rule), joined with ` · `. Plain hints are dim;
|
|
69
71
|
* an object hint uses its color (dim when omitted). Hints are added while they
|
|
70
72
|
* fit, so a narrow terminal drops whole keys instead of truncating one in half.
|
|
71
73
|
* The first hint is always kept; the result is clipped to `width`.
|
|
72
74
|
*/
|
|
73
|
-
export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): string {
|
|
75
|
+
export function screenHint(theme: Theme, width: number, hints: ScreenHint[], indent = BODY_INDENT): string {
|
|
74
76
|
const target = Math.max(1, width);
|
|
77
|
+
// Keep a column for the hint itself, however deep the caller indents.
|
|
78
|
+
const lead = " ".repeat(Math.max(0, Math.min(Math.floor(indent), target - 1)));
|
|
75
79
|
const render = (hint: ScreenHint): string =>
|
|
76
80
|
typeof hint === "string" ? theme.fg("dim", hint) : theme.fg(hint.color ?? "dim", hint.text);
|
|
77
81
|
const separator = theme.fg("dim", SEPARATORS.item);
|
|
78
82
|
const kept: ScreenHint[] = [];
|
|
79
83
|
for (const hint of hints) {
|
|
80
84
|
const candidate = [...kept, hint].map(render).join(separator);
|
|
81
|
-
if (kept.length > 0 && visibleWidth(
|
|
85
|
+
if (kept.length > 0 && visibleWidth(`${lead}${candidate}`) > target) break;
|
|
82
86
|
kept.push(hint);
|
|
83
87
|
}
|
|
84
|
-
return truncateToWidth(
|
|
88
|
+
return truncateToWidth(`${lead}${kept.map(render).join(separator)}`, target);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
package/lib/tui/cursor.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
21
21
|
import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
|
|
22
|
+
import { GLYPHS } from "./glyphs.ts";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Rows that fit for a list of `itemCount`, reserving one extra line when the
|
|
@@ -75,7 +76,7 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
|
|
|
75
76
|
|
|
76
77
|
/** The `❯ ` selection marker (accent) or a same-width blank. */
|
|
77
78
|
export function selectionMarker(theme: Theme, selected: boolean): string {
|
|
78
|
-
return selected ? theme.fg("accent",
|
|
79
|
+
return selected ? theme.fg("accent", `${GLYPHS.prompt} `) : " ";
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/**
|
package/lib/tui/glyphs.ts
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Leading glyphs for status chips and
|
|
13
|
+
* Leading glyphs for status chips, footer segments, and transcript rails.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* CJK-width profile
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* A glyph is a single terminal column: never wide, fullwidth, or emoji, which
|
|
16
|
+
* are two columns in every profile. East-Asian *ambiguous* glyphs are allowed —
|
|
17
|
+
* a CJK-width profile draws those two columns, so one costs a column there, but
|
|
18
|
+
* the vocabulary is no longer limited to narrow/neutral characters. Add a glyph
|
|
19
|
+
* only after checking its `East_Asian_Width` against `EastAsianWidth.txt`;
|
|
20
|
+
* `lib/tui/glyphs.test.ts` bans wide, fullwidth, and emoji.
|
|
21
21
|
*/
|
|
22
22
|
export const GLYPHS = {
|
|
23
23
|
branch: "⎇",
|
|
@@ -34,6 +34,7 @@ export const GLYPHS = {
|
|
|
34
34
|
gaugeFull: "▰",
|
|
35
35
|
gaugeEmpty: "▱",
|
|
36
36
|
turnRule: "╌",
|
|
37
|
+
divider: "╎",
|
|
37
38
|
check: "✓",
|
|
38
39
|
warn: "!",
|
|
39
40
|
cross: "✕",
|
|
@@ -47,18 +48,16 @@ export const GLYPHS = {
|
|
|
47
48
|
boxOff: "◻",
|
|
48
49
|
prev: "‹",
|
|
49
50
|
next: "›",
|
|
51
|
+
prompt: "❯",
|
|
50
52
|
} as const;
|
|
51
53
|
|
|
52
|
-
/** Columns before a transcript rail body glyph, shared by `goal` and `todo`. */
|
|
53
|
-
export const BODY_INDENT = 2;
|
|
54
|
-
|
|
55
54
|
/** Columns between a rail body glyph and its text. */
|
|
56
55
|
export const GLYPH_GAP = 1;
|
|
57
56
|
|
|
58
57
|
/** Padding used to join (`item`) or group (`group`) UI segments. */
|
|
59
58
|
export const SEPARATORS = {
|
|
60
59
|
item: " · ",
|
|
61
|
-
group:
|
|
60
|
+
group: ` ${GLYPHS.divider} `,
|
|
62
61
|
} as const;
|
|
63
62
|
|
|
64
63
|
/** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript indent: how a non-prose block sits under its headline.
|
|
3
|
+
*
|
|
4
|
+
* A tool block, a shared notice, and a subagent's quoted output all hang from one
|
|
5
|
+
* left edge. A block may lead with a headline (a tool's own call line) at the
|
|
6
|
+
* outer indent; the body sits `BODY_INDENT` further in, directly under it with no
|
|
7
|
+
* separator line, which is what keeps a tool block compact. The body's first
|
|
8
|
+
* indent column may instead carry a rail glyph, passed in pre-painted by the
|
|
9
|
+
* caller so this module stays value-only.
|
|
10
|
+
*
|
|
11
|
+
* Value-only: pure functions and constants, no theme.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Component, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
|
|
16
|
+
/** Columns the body sits in beyond a block's outer indent. */
|
|
17
|
+
export const BODY_INDENT = 2;
|
|
18
|
+
|
|
19
|
+
/** How an indented block is placed. */
|
|
20
|
+
export interface IndentOptions {
|
|
21
|
+
/** Columns the body is indented; a headline sits `BODY_INDENT` to its left. */
|
|
22
|
+
indent?: number;
|
|
23
|
+
/**
|
|
24
|
+
* A pre-painted, one-column rail glyph for the body's first indent column; the
|
|
25
|
+
* rest of the indent stays spaces. Ignored below `BODY_INDENT`, where there is
|
|
26
|
+
* no column to spare. Default: no rail.
|
|
27
|
+
*/
|
|
28
|
+
rail?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Start the rail after the headline's continuation: keep the leading run of
|
|
31
|
+
* non-blank body lines plain-indented, up to the first blank line. Set when the
|
|
32
|
+
* headline degenerated to the bare subject name (a tool's call line whose path
|
|
33
|
+
* moved onto the body), so the rail begins with the result instead of the
|
|
34
|
+
* wrapped call. Ignored without a rail. Default false.
|
|
35
|
+
*/
|
|
36
|
+
railAfterCall?: boolean;
|
|
37
|
+
/** Drop leading lines with no visible content. Default false. */
|
|
38
|
+
trimLeading?: boolean;
|
|
39
|
+
/** Drop trailing lines with no visible content. Default true. */
|
|
40
|
+
trimTrailing?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Lead with the first non-blank line as the block's headline, at
|
|
43
|
+
* `indent - BODY_INDENT`, with the body immediately after it. Default false.
|
|
44
|
+
*/
|
|
45
|
+
headline?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Suffix appended when a body line is clipped. Default empty: a body is clipped
|
|
48
|
+
* silently. The headline always clips with `…`. A notice sets `…` so a
|
|
49
|
+
* truncated row reads as truncated.
|
|
50
|
+
*/
|
|
51
|
+
bodyClip?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A line with no visible text. Tested after stripping terminal sequences, so a
|
|
56
|
+
* line that is only spaces carrying a background (a `Box`'s own padding) counts
|
|
57
|
+
* as blank rather than as content.
|
|
58
|
+
*/
|
|
59
|
+
function isBlank(line: string): boolean {
|
|
60
|
+
return stripTerminalSequences(line).trim().length === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An image-protocol line must pass through unprefixed, or the sequence corrupts. */
|
|
64
|
+
const IMAGE_LINE = /^\u001b(?:P|_G|\]1337;)/;
|
|
65
|
+
|
|
66
|
+
/** The body indent, clamped so the prefix always leaves at least one column. */
|
|
67
|
+
function clampIndent(indent: number, width: number): number {
|
|
68
|
+
return Math.max(0, Math.min(Math.floor(indent), Math.max(0, width - 1)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The body prefix: the rail glyph in the indent's first body column, or plain
|
|
73
|
+
* spaces. The glyph must be one visible column, so the prefix is exactly `indent`
|
|
74
|
+
* columns wide; the gap after it is the remaining `BODY_INDENT - 1`.
|
|
75
|
+
*/
|
|
76
|
+
function bodyPrefix(indent: number, rail: string | undefined): string {
|
|
77
|
+
if (!rail || indent < BODY_INDENT) return " ".repeat(indent);
|
|
78
|
+
const base = Math.max(0, indent - BODY_INDENT);
|
|
79
|
+
return `${" ".repeat(base)}${rail}${" ".repeat(indent - base - 1)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Indent each line of an already-rendered block by `indent` columns, clipping
|
|
84
|
+
* each to `width`. The block should be built at `width - indent` so nothing is
|
|
85
|
+
* lost to the prefix; the clip is only a safety net. A line carrying an image
|
|
86
|
+
* protocol sequence is passed through untouched, and trailing lines with no
|
|
87
|
+
* visible content are dropped (by default). When `headline` is set, the first
|
|
88
|
+
* non-blank line leads at `indent - BODY_INDENT` and the body follows directly —
|
|
89
|
+
* no separator line — and its own leading blanks are dropped so a tool's spacing
|
|
90
|
+
* never leaves a gap under the headline. With `rail` set, the body's first indent
|
|
91
|
+
* column carries the glyph instead of a space; `railAfterCall` keeps the call's
|
|
92
|
+
* continuation lines plain, so the rail starts with the result.
|
|
93
|
+
*/
|
|
94
|
+
export function indentBlockLines(body: readonly string[], width: number, options: IndentOptions = {}): string[] {
|
|
95
|
+
const w = Math.max(1, width);
|
|
96
|
+
const indent = clampIndent(options.indent ?? 0, w);
|
|
97
|
+
const prefix = bodyPrefix(indent, options.rail);
|
|
98
|
+
const plainPrefix = " ".repeat(indent);
|
|
99
|
+
|
|
100
|
+
// The headline is the first line with something to say; it leads at the base.
|
|
101
|
+
let headline: string | undefined;
|
|
102
|
+
let rest: readonly string[] = body;
|
|
103
|
+
if (options.headline) {
|
|
104
|
+
const index = body.findIndex((line) => !IMAGE_LINE.test(line) && !isBlank(line));
|
|
105
|
+
if (index >= 0) {
|
|
106
|
+
headline = body[index];
|
|
107
|
+
rest = body.slice(index + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A call whose subject moved onto the body keeps those lines unrailed, so the
|
|
112
|
+
// rail starts with the result rather than under a bare tool name.
|
|
113
|
+
let railStart = 0;
|
|
114
|
+
if (options.rail && options.railAfterCall) {
|
|
115
|
+
while (railStart < rest.length && !isBlank(rest[railStart] ?? "")) railStart++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
const blank: boolean[] = [];
|
|
120
|
+
for (const [index, line] of rest.entries()) {
|
|
121
|
+
if (IMAGE_LINE.test(line)) {
|
|
122
|
+
lines.push(line);
|
|
123
|
+
blank.push(false);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
lines.push(truncateToWidth(`${index < railStart ? plainPrefix : prefix}${line}`, w, options.bodyClip ?? ""));
|
|
127
|
+
blank.push(isBlank(line));
|
|
128
|
+
}
|
|
129
|
+
if (options.trimLeading) {
|
|
130
|
+
while (blank.length > 0 && blank[0]) {
|
|
131
|
+
lines.shift();
|
|
132
|
+
blank.shift();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (options.trimTrailing !== false) {
|
|
136
|
+
while (blank.length > 0 && blank[blank.length - 1]) {
|
|
137
|
+
lines.pop();
|
|
138
|
+
blank.pop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (headline !== undefined) {
|
|
142
|
+
const base = Math.max(0, indent - BODY_INDENT);
|
|
143
|
+
lines.unshift(truncateToWidth(`${" ".repeat(base)}${headline}`, w, "…"));
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render `inner` at the width the indent leaves, so nothing is lost to the
|
|
150
|
+
* prefix. An absent `inner` renders nothing, so a tool with no output adds no
|
|
151
|
+
* blank block. The lines are not indented yet; a caller that has to inspect the
|
|
152
|
+
* block before placing it passes them to `indentBlockLines`.
|
|
153
|
+
*/
|
|
154
|
+
export function bodyLines(inner: Component | undefined, width: number, indent = 0): string[] {
|
|
155
|
+
if (!inner) return [];
|
|
156
|
+
const w = Math.max(1, width);
|
|
157
|
+
const prefixWidth = visibleWidth(bodyPrefix(clampIndent(indent, w), undefined));
|
|
158
|
+
return inner.render(Math.max(1, w - prefixWidth));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Render `inner` at the width the indent leaves, then indent it. */
|
|
162
|
+
export function indentedLines(inner: Component | undefined, width: number, options: IndentOptions = {}): string[] {
|
|
163
|
+
const w = Math.max(1, width);
|
|
164
|
+
const indent = clampIndent(options.indent ?? 0, w);
|
|
165
|
+
return indentBlockLines(bodyLines(inner, w, indent), w, { ...options, indent });
|
|
166
|
+
}
|
package/lib/tui/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* `chrome.ts` (screen frame, hints, viewport, overlay), `cursor.ts` (list
|
|
8
8
|
* scroll and navigation), `columns.ts` (width math), `rules.ts` (the rule
|
|
9
9
|
* grammar), `rows.ts` (list rows, checkboxes, chips, empty states), `gauge.ts`
|
|
10
|
-
* (the context gauge),
|
|
10
|
+
* (the context gauge), `indent.ts` (the transcript block indent), and
|
|
11
|
+
* `line-pane.ts` (the scrollable log/match pane).
|
|
11
12
|
*
|
|
12
13
|
* Value-only, like every `lib/` module: pure functions, constants, and classes,
|
|
13
14
|
* with no module-level state.
|
|
@@ -19,6 +20,7 @@ export * from "./cursor.ts";
|
|
|
19
20
|
export * from "./gauge.ts";
|
|
20
21
|
export * from "./glyphs.ts";
|
|
21
22
|
export * from "./line-pane.ts";
|
|
23
|
+
export * from "./indent.ts";
|
|
22
24
|
export * from "./rows.ts";
|
|
23
25
|
export * from "./rules.ts";
|
|
24
26
|
export * from "./status-controller.ts";
|
package/lib/tui/rows.ts
CHANGED
|
@@ -15,13 +15,20 @@
|
|
|
15
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { clip, padTo, splitRow } from "./columns.ts";
|
|
17
17
|
import { selectionMarker } from "./cursor.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { GLYPHS } from "./glyphs.ts";
|
|
19
|
+
import { BODY_INDENT } from "./indent.ts";
|
|
19
20
|
import { paint, type TuiRole } from "./tokens.ts";
|
|
20
21
|
|
|
21
22
|
/** One selectable list row. */
|
|
22
23
|
export interface ListRow {
|
|
23
24
|
/** Draw the `❯` marker in the accent role. A blank marker column is reserved either way. */
|
|
24
25
|
selected?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Reserve the `❯ ` marker column. Default true, so a column of rows stays
|
|
28
|
+
* aligned; a non-interactive row (a transcript result) sets false to drop the
|
|
29
|
+
* columns it can never use.
|
|
30
|
+
*/
|
|
31
|
+
marker?: boolean;
|
|
25
32
|
/**
|
|
26
33
|
* A pre-themed cell between the marker and the glyph, e.g. a `checkbox`.
|
|
27
34
|
* Reserved by the caller (pass a blank when the row has none) so a column of
|
|
@@ -45,13 +52,14 @@ export interface ListRow {
|
|
|
45
52
|
* One list row: an optional indent, the selection marker, an optional glyph,
|
|
46
53
|
* the label, and right-aligned trailing meta.
|
|
47
54
|
*
|
|
48
|
-
* The marker column is
|
|
49
|
-
*
|
|
50
|
-
* `splitRow`), so a value at the
|
|
55
|
+
* The marker column is reserved by default, so selected and unselected rows
|
|
56
|
+
* align; `marker: false` drops it for a non-interactive row. The trailing meta
|
|
57
|
+
* is the anchor when the terminal is narrow (see `splitRow`), so a value at the
|
|
58
|
+
* end of the row survives.
|
|
51
59
|
*/
|
|
52
60
|
export function listRow(theme: Theme, width: number, row: ListRow): string {
|
|
53
61
|
const indent = " ".repeat(Math.max(0, Math.floor(row.indent ?? 0)));
|
|
54
|
-
const marker = selectionMarker(theme, row.selected === true);
|
|
62
|
+
const marker = row.marker === false ? "" : selectionMarker(theme, row.selected === true);
|
|
55
63
|
const leading = row.leading ? `${row.leading} ` : "";
|
|
56
64
|
const glyph = row.glyph ? `${paint(theme, row.glyphRole ?? "info", row.glyph)} ` : "";
|
|
57
65
|
const label = paint(theme, row.labelRole ?? "label", row.label);
|
|
@@ -59,6 +67,32 @@ export function listRow(theme: Theme, width: number, row: ListRow): string {
|
|
|
59
67
|
return splitRow(`${indent}${marker}${leading}${glyph}${label}`, trailing, width);
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
/** A status row (a job, a watcher): the marker, a status glyph, a label, and right-aligned meta. */
|
|
71
|
+
export interface StatusRow {
|
|
72
|
+
selected?: boolean;
|
|
73
|
+
/** Reserve the marker column. Default true. */
|
|
74
|
+
marker?: boolean;
|
|
75
|
+
glyph: string;
|
|
76
|
+
glyphRole: TuiRole;
|
|
77
|
+
label: string;
|
|
78
|
+
trailing: string;
|
|
79
|
+
/** Role for the trailing meta. Default `faint`. */
|
|
80
|
+
trailingRole?: TuiRole;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One row of a live status list, on the shared `listRow` grammar. */
|
|
84
|
+
export function statusRow(theme: Theme, width: number, row: StatusRow): string {
|
|
85
|
+
return listRow(theme, width, {
|
|
86
|
+
selected: row.selected === true,
|
|
87
|
+
marker: row.marker !== false,
|
|
88
|
+
glyph: row.glyph,
|
|
89
|
+
glyphRole: row.glyphRole,
|
|
90
|
+
label: row.label,
|
|
91
|
+
trailing: row.trailing,
|
|
92
|
+
trailingRole: row.trailingRole ?? "faint",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
/** The on/off checkbox glyph, in one role per state. */
|
|
63
97
|
export function checkbox(theme: Theme, on: boolean, roles: { on?: TuiRole; off?: TuiRole } = {}): string {
|
|
64
98
|
return on ? paint(theme, roles.on ?? "ok", GLYPHS.boxOn) : paint(theme, roles.off ?? "faint", GLYPHS.boxOff);
|
package/lib/tui/rules.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface CenteredRuleStyle extends RuleStyle {
|
|
|
33
33
|
pad?: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The solid rule fill: the glyph a divider repeats. One constant names it, so
|
|
38
|
+
* the header rule, a panel title, and the user-message nameplate cannot drift.
|
|
39
|
+
*/
|
|
40
|
+
export const RULE_GLYPH = "─";
|
|
41
|
+
|
|
36
42
|
/** A usable origin: terminal arithmetic starts from one column, never zero. */
|
|
37
43
|
function widthOf(width: number): number {
|
|
38
44
|
return Math.max(1, Math.floor(width));
|
|
@@ -41,7 +47,7 @@ function widthOf(width: number): number {
|
|
|
41
47
|
/** A full-width divider in one color. */
|
|
42
48
|
export function plainRule(theme: Theme, width: number, style: RuleStyle = {}): string {
|
|
43
49
|
const w = widthOf(width);
|
|
44
|
-
return paint(theme, style.fillRole ?? "rule", (style.glyph ??
|
|
50
|
+
return paint(theme, style.fillRole ?? "rule", (style.glyph ?? RULE_GLYPH).repeat(w));
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** The label-first shape a screen or panel title uses. */
|
|
@@ -63,14 +69,14 @@ function labelRule(
|
|
|
63
69
|
if (!text) return plainRule(theme, w, style);
|
|
64
70
|
const labelWidth = visibleWidth(text);
|
|
65
71
|
if (labelWidth + 1 >= w) return plainRule(theme, w, style);
|
|
66
|
-
const fill = (style.glyph ??
|
|
72
|
+
const fill = (style.glyph ?? RULE_GLYPH).repeat(w - labelWidth - 1);
|
|
67
73
|
return paint(theme, style.labelRole ?? labelRole, text) + " " + paint(theme, style.fillRole ?? fillRole, fill);
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** A centered rule: fill, label, and the rest of the fill. */
|
|
71
77
|
export function centeredRule(theme: Theme, width: number, label: string, style: CenteredRuleStyle = {}): string {
|
|
72
78
|
const w = widthOf(width);
|
|
73
|
-
const glyph = style.glyph ??
|
|
79
|
+
const glyph = style.glyph ?? RULE_GLYPH;
|
|
74
80
|
const fillRole = style.fillRole ?? "ruleStrong";
|
|
75
81
|
if (!label.trim()) return paint(theme, fillRole, glyph.repeat(w));
|
|
76
82
|
const pad = " ".repeat(Math.max(0, Math.floor(style.pad ?? 1)));
|
package/lib/tui/tokens.ts
CHANGED
|
@@ -56,3 +56,17 @@ export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
|
|
|
56
56
|
export function paint(theme: Theme, role: TuiRole, text: string): string {
|
|
57
57
|
return theme.fg(TUI_TOKENS[role], text);
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The opening sequence for a role, without its reset. A frame that must repaint
|
|
62
|
+
* a run in place — mid-line, where the surrounding text already carries the
|
|
63
|
+
* closing reset — opens with this instead of wrapping a whole string.
|
|
64
|
+
*
|
|
65
|
+
* Derived from `paint` with a sentinel, so it tracks the theme's own escape
|
|
66
|
+
* shape rather than assuming one.
|
|
67
|
+
*/
|
|
68
|
+
export function paintPrefix(theme: Theme, role: TuiRole): string {
|
|
69
|
+
const marker = "\u0000";
|
|
70
|
+
const wrapped = paint(theme, role, marker);
|
|
71
|
+
return wrapped.slice(0, wrapped.indexOf(marker));
|
|
72
|
+
}
|
package/lib/web/base.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
// Every focusable element gets the same ring; components must not re-declare
|
|
10
10
|
// their own, so the keyboard affordance cannot disappear surface by surface.
|
|
11
|
-
export const WEB_BASE_CSS =
|
|
11
|
+
export const WEB_BASE_CSS = `html,body{margin:0}
|
|
12
|
+
*{box-sizing:border-box}
|
|
12
13
|
[hidden]{display:none!important}
|
|
13
14
|
a{color:var(--accent);text-decoration:none}
|
|
14
15
|
a:hover{text-decoration:underline}
|
package/lib/web/components.ts
CHANGED
|
@@ -117,7 +117,9 @@ th[aria-sort="descending"] .bf-sort::after{content:" \\25BC";font-size:9px;color
|
|
|
117
117
|
.bf-md .md-check{color:var(--success)}
|
|
118
118
|
.bf-md table{margin:0;border-collapse:collapse;table-layout:auto;width:auto;font-size:var(--fs-sm)}
|
|
119
119
|
.bf-md .md-table-wrap{overflow-x:auto;margin:0 0 1em}
|
|
120
|
-
.bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top}
|
|
120
|
+
.bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;overflow-wrap:break-word}
|
|
121
|
+
.bf-md th{white-space:nowrap}
|
|
122
|
+
.bf-md th code,.bf-md td code{white-space:nowrap}
|
|
121
123
|
.bf-md thead th{background:var(--surface);font-weight:var(--fw-medium)}
|
|
122
124
|
.bf-md .md-left{text-align:left}
|
|
123
125
|
.bf-md .md-center{text-align:center}
|
package/lib/web/layout.ts
CHANGED
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Only the shared 860px collapse lives here; the plan-specific rules stay in
|
|
13
13
|
* `extensions/plan/web-page.ts`.
|
|
14
|
+
*
|
|
15
|
+
* The shell is a definite full-height column so the panes scroll inside it; the
|
|
16
|
+
* 860px breakpoint drops that back to normal page scroll for narrow screens.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
|
-
export const WEB_LAYOUT_CSS = `.bf-app{display:flex;flex-direction:column;min-height:
|
|
19
|
+
export const WEB_LAYOUT_CSS = `.bf-app{display:flex;flex-direction:column;height:100vh;min-height:0;overflow:hidden;background:var(--canvas);color:var(--text);font:var(--fs-base)/var(--lh-normal) var(--font-ui)}
|
|
17
20
|
.bf-app__header{position:sticky;top:0;z-index:2;flex:0 0 auto;background:var(--surface);border-bottom:1px solid var(--border)}
|
|
18
21
|
.bf-app__body{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:260px minmax(0,1fr)}
|
|
19
22
|
.bf-app__sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:var(--surface);border-right:1px solid var(--border)}
|
|
20
23
|
.bf-app__main{min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto;padding:var(--space-5) var(--space-6)}
|
|
21
24
|
.bf-app__footer{flex:0 0 auto;border-top:1px solid var(--border);color:var(--muted);font-size:var(--fs-sm);padding:var(--space-3) var(--space-6)}
|
|
22
25
|
@media (max-width:860px){
|
|
26
|
+
.bf-app{height:auto;min-height:100vh;overflow:visible}
|
|
23
27
|
.bf-app__body{flex:none;grid-template-columns:1fr}
|
|
24
28
|
.bf-app__sidebar{display:block;position:static;max-height:none;overflow:visible;border-right:0;border-bottom:1px solid var(--border)}
|
|
25
29
|
.bf-app__main{overflow:visible}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"themes": [
|
|
91
91
|
"./themes/*.json"
|
|
92
92
|
],
|
|
93
|
-
"image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/
|
|
93
|
+
"image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/chrome.png"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"test": "bun test --parallel=4 --timeout 30000",
|
|
@@ -102,7 +102,11 @@
|
|
|
102
102
|
"check": "bun run format:check && bun run typecheck && bun run test",
|
|
103
103
|
"check:serial": "bun run format:check && bun run typecheck && bun run test:serial",
|
|
104
104
|
"release:notes": "bun run scripts/release-notes.ts",
|
|
105
|
-
"config:migrate": "bun run scripts/migrate-config.ts"
|
|
105
|
+
"config:migrate": "bun run scripts/migrate-config.ts",
|
|
106
|
+
"screenshot:plan": "bun run scripts/plan-review-shot.ts",
|
|
107
|
+
"screenshot:tools": "bun run scripts/tool-renderings.ts gallery assets/tool-renderings.png",
|
|
108
|
+
"screenshot:tool-pngs": "bun run scripts/tool-renderings.ts tools assets/tools",
|
|
109
|
+
"screenshots": "bun run scripts/screenshots.ts"
|
|
106
110
|
},
|
|
107
111
|
"dependencies": {
|
|
108
112
|
"exceljs": "^4.4.0",
|
package/themes/bytefly-dark.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://
|
|
2
|
+
"$schema": "https://pi.dev/schemas/theme.schema.json",
|
|
3
3
|
"name": "bytefly-dark",
|
|
4
4
|
"appearance": "dark",
|
|
5
5
|
"vars": {
|
|
6
6
|
"canvas": "#0d1117",
|
|
7
7
|
"canvasSubtle": "#161b22",
|
|
8
8
|
"border": "#6e7681",
|
|
9
|
-
"borderMuted": "#
|
|
9
|
+
"borderMuted": "#5a626b",
|
|
10
10
|
"text": "#c9d1d9",
|
|
11
11
|
"muted": "#8b949e",
|
|
12
12
|
"dim": "#6e7681",
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
"purple": "#bc8cff",
|
|
18
18
|
"pink": "#f778ba",
|
|
19
19
|
"selectionBg": "#17273a",
|
|
20
|
-
"userBg": "#1e2a44",
|
|
21
20
|
"customBg": "#26233a",
|
|
22
21
|
"successBg": "#12271a",
|
|
23
22
|
"errorBg": "#3d1a21",
|
|
@@ -39,10 +38,10 @@
|
|
|
39
38
|
"thinkingText": "muted",
|
|
40
39
|
"selectedBg": "selectionBg",
|
|
41
40
|
"scrollbarTrack": "borderMuted",
|
|
42
|
-
"scrollbarThumb": "
|
|
41
|
+
"scrollbarThumb": "muted",
|
|
43
42
|
"searchMatchBg": "searchBg",
|
|
44
43
|
"searchMatchText": "text",
|
|
45
|
-
"userMessageBg": "
|
|
44
|
+
"userMessageBg": "",
|
|
46
45
|
"userMessageText": "text",
|
|
47
46
|
"customMessageBg": "customBg",
|
|
48
47
|
"customMessageText": "muted",
|
|
@@ -50,14 +49,14 @@
|
|
|
50
49
|
"toolPendingBg": "canvasSubtle",
|
|
51
50
|
"toolSuccessBg": "successBg",
|
|
52
51
|
"toolErrorBg": "errorBg",
|
|
53
|
-
"toolTitle": "
|
|
52
|
+
"toolTitle": "blue",
|
|
54
53
|
"toolOutput": "muted",
|
|
55
|
-
"mdHeading": "
|
|
54
|
+
"mdHeading": "blue",
|
|
56
55
|
"mdLink": "blue",
|
|
57
56
|
"mdLinkUrl": "muted",
|
|
58
57
|
"mdCode": "pink",
|
|
59
58
|
"mdCodeBlock": "text",
|
|
60
|
-
"mdCodeBlockBorder": "
|
|
59
|
+
"mdCodeBlockBorder": "muted",
|
|
61
60
|
"mdQuote": "muted",
|
|
62
61
|
"mdQuoteBorder": "border",
|
|
63
62
|
"mdHr": "border",
|