@adeildo/pi-harness 4.0.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/LICENSE +21 -0
- package/README.md +24 -0
- package/docs/look.md +163 -0
- package/docs/providers.md +35 -0
- package/package.json +54 -0
- package/src/look/data/git.ts +58 -0
- package/src/look/data/snapshot.ts +173 -0
- package/src/look/data/telemetry.ts +188 -0
- package/src/look/data/totals.ts +149 -0
- package/src/look/desktop/palette.ts +207 -0
- package/src/look/desktop/sync.ts +86 -0
- package/src/look/index.ts +448 -0
- package/src/look/render/fit.ts +89 -0
- package/src/look/render/format.ts +136 -0
- package/src/look/render/frame.ts +118 -0
- package/src/look/render/glyphs.ts +155 -0
- package/src/look/render/paint.ts +137 -0
- package/src/look/render/segments.ts +514 -0
- package/src/look/settings.ts +237 -0
- package/src/look/ui/editor.ts +194 -0
- package/src/look/ui/footer.ts +103 -0
- package/src/look/ui/header.ts +212 -0
- package/src/look/ui/screen.ts +39 -0
- package/src/look/ui/working.ts +73 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/subscription/billing.ts +27 -0
- package/src/providers/subscription/feature.ts +43 -0
- package/src/providers/subscription/payload.ts +87 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Borders with something written in them: `╭─ left ────── right ─╮`. The frame is the one place
|
|
2
|
+
// that knows the box characters, so a new style is one entry here.
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import { type FitOptions, fitRegions, type Piece } from "./fit.ts";
|
|
6
|
+
|
|
7
|
+
export type FrameStyle = "rounded" | "square" | "heavy" | "line";
|
|
8
|
+
|
|
9
|
+
export interface BoxChars {
|
|
10
|
+
topLeft: string;
|
|
11
|
+
topRight: string;
|
|
12
|
+
bottomLeft: string;
|
|
13
|
+
bottomRight: string;
|
|
14
|
+
horizontal: string;
|
|
15
|
+
vertical: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const BOXES: Record<FrameStyle, BoxChars> = {
|
|
19
|
+
rounded: {
|
|
20
|
+
topLeft: "╭",
|
|
21
|
+
topRight: "╮",
|
|
22
|
+
bottomLeft: "╰",
|
|
23
|
+
bottomRight: "╯",
|
|
24
|
+
horizontal: "─",
|
|
25
|
+
vertical: "│",
|
|
26
|
+
},
|
|
27
|
+
square: {
|
|
28
|
+
topLeft: "┌",
|
|
29
|
+
topRight: "┐",
|
|
30
|
+
bottomLeft: "└",
|
|
31
|
+
bottomRight: "┘",
|
|
32
|
+
horizontal: "─",
|
|
33
|
+
vertical: "│",
|
|
34
|
+
},
|
|
35
|
+
heavy: {
|
|
36
|
+
topLeft: "┏",
|
|
37
|
+
topRight: "┓",
|
|
38
|
+
bottomLeft: "┗",
|
|
39
|
+
bottomRight: "┛",
|
|
40
|
+
horizontal: "━",
|
|
41
|
+
vertical: "┃",
|
|
42
|
+
},
|
|
43
|
+
// Only the horizontal rules, the way pi draws its editor, with the data written into them.
|
|
44
|
+
line: {
|
|
45
|
+
topLeft: "─",
|
|
46
|
+
topRight: "─",
|
|
47
|
+
bottomLeft: "─",
|
|
48
|
+
bottomRight: "─",
|
|
49
|
+
horizontal: "─",
|
|
50
|
+
vertical: "",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface BorderOptions extends Pick<FitOptions, "separator" | "ellipsis"> {
|
|
55
|
+
box: BoxChars;
|
|
56
|
+
edge: "top" | "bottom";
|
|
57
|
+
/** Paints the rule and the corners. */
|
|
58
|
+
paint: (text: string) => string;
|
|
59
|
+
/**
|
|
60
|
+
* Text that follows the left pieces and takes whatever room is left, like the working spinner.
|
|
61
|
+
* It goes after them because it changes width all the time, and the pieces must not move.
|
|
62
|
+
*/
|
|
63
|
+
lead?: ((budget: number) => string | undefined) | undefined;
|
|
64
|
+
/** Room kept for the lead, so the pieces never squeeze it out. */
|
|
65
|
+
leadMin?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Corners, one rule cell each side, and one rule cell between the two regions at least. */
|
|
69
|
+
const FIXED = 5;
|
|
70
|
+
/** A space on each side of a region's text. */
|
|
71
|
+
const PADDING = 2;
|
|
72
|
+
|
|
73
|
+
/** One border line of exactly `width` columns, with both regions fitted into it. */
|
|
74
|
+
export function border(
|
|
75
|
+
width: number,
|
|
76
|
+
left: readonly Piece[],
|
|
77
|
+
right: readonly Piece[],
|
|
78
|
+
options: BorderOptions,
|
|
79
|
+
): string {
|
|
80
|
+
const { box, paint } = options;
|
|
81
|
+
const [start, end] =
|
|
82
|
+
options.edge === "top" ? [box.topLeft, box.topRight] : [box.bottomLeft, box.bottomRight];
|
|
83
|
+
if (width < FIXED) return paint(box.horizontal.repeat(Math.max(0, width)));
|
|
84
|
+
|
|
85
|
+
const separatorWidth = visibleWidth(options.separator);
|
|
86
|
+
const reserve =
|
|
87
|
+
options.lead === undefined ? 0 : (options.leadMin ?? 1) + separatorWidth + PADDING;
|
|
88
|
+
const budget = width - FIXED - reserve;
|
|
89
|
+
|
|
90
|
+
const [fittedLeft = "", fittedRight = ""] =
|
|
91
|
+
budget > 0
|
|
92
|
+
? fitRegions([left, right], budget, {
|
|
93
|
+
separator: options.separator,
|
|
94
|
+
regionOverhead: PADDING,
|
|
95
|
+
ellipsis: options.ellipsis,
|
|
96
|
+
})
|
|
97
|
+
: ["", ""];
|
|
98
|
+
|
|
99
|
+
const rightCell = fittedRight === "" ? "" : ` ${fittedRight} `;
|
|
100
|
+
let leftText = fittedLeft;
|
|
101
|
+
if (options.lead !== undefined) {
|
|
102
|
+
const used =
|
|
103
|
+
visibleWidth(rightCell) + (fittedLeft === "" ? 0 : visibleWidth(fittedLeft) + separatorWidth);
|
|
104
|
+
const lead = options.lead(Math.max(1, width - FIXED - PADDING - used));
|
|
105
|
+
if (lead !== undefined && lead !== "")
|
|
106
|
+
leftText = fittedLeft === "" ? lead : `${fittedLeft}${options.separator}${lead}`;
|
|
107
|
+
}
|
|
108
|
+
const leftCell = leftText === "" ? "" : ` ${leftText} `;
|
|
109
|
+
const fill = Math.max(1, width - 4 - visibleWidth(leftCell) - visibleWidth(rightCell));
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
paint(`${start}${box.horizontal}`) +
|
|
113
|
+
leftCell +
|
|
114
|
+
paint(box.horizontal.repeat(fill)) +
|
|
115
|
+
rightCell +
|
|
116
|
+
paint(`${box.horizontal}${end}`)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// One glyph per kind of data, in three sets. Nerd Font glyphs live in the private use area, so they
|
|
2
|
+
// are written as escapes: a font without them shows a box, and a reviewer sees which code point it is.
|
|
3
|
+
export type IconMode = "auto" | "nerd" | "unicode" | "ascii";
|
|
4
|
+
export type IconSet = Exclude<IconMode, "auto">;
|
|
5
|
+
|
|
6
|
+
export interface Glyphs {
|
|
7
|
+
set: IconSet;
|
|
8
|
+
folder: string;
|
|
9
|
+
branch: string;
|
|
10
|
+
host: string;
|
|
11
|
+
session: string;
|
|
12
|
+
version: string;
|
|
13
|
+
model: string;
|
|
14
|
+
effort: string;
|
|
15
|
+
context: string;
|
|
16
|
+
speed: string;
|
|
17
|
+
wait: string;
|
|
18
|
+
running: string;
|
|
19
|
+
done: string;
|
|
20
|
+
sent: string;
|
|
21
|
+
received: string;
|
|
22
|
+
cacheRead: string;
|
|
23
|
+
cacheWrite: string;
|
|
24
|
+
cost: string;
|
|
25
|
+
clock: string;
|
|
26
|
+
plug: string;
|
|
27
|
+
ahead: string;
|
|
28
|
+
behind: string;
|
|
29
|
+
staged: string;
|
|
30
|
+
modified: string;
|
|
31
|
+
untracked: string;
|
|
32
|
+
conflicted: string;
|
|
33
|
+
stashed: string;
|
|
34
|
+
/** Full and empty cells of a gauge. Blocks, so the gauge never reads as part of a border rule. */
|
|
35
|
+
fill: string;
|
|
36
|
+
empty: string;
|
|
37
|
+
/** Six rising bars, one per effort level from minimal to max. */
|
|
38
|
+
levels: readonly string[];
|
|
39
|
+
ellipsis: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const COMMON = {
|
|
43
|
+
ahead: "↑",
|
|
44
|
+
behind: "↓",
|
|
45
|
+
staged: "+",
|
|
46
|
+
modified: "~",
|
|
47
|
+
untracked: "?",
|
|
48
|
+
conflicted: "!",
|
|
49
|
+
sent: "↑",
|
|
50
|
+
received: "↓",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const NERD: Glyphs = {
|
|
54
|
+
...COMMON,
|
|
55
|
+
set: "nerd",
|
|
56
|
+
folder: "\u{f07c}", // nf-fa-folder_open
|
|
57
|
+
branch: "\u{e0a0}", // nf-pl-branch
|
|
58
|
+
host: "\u{f108}", // nf-fa-desktop
|
|
59
|
+
session: "\u{f02b}", // nf-fa-tag
|
|
60
|
+
version: "π",
|
|
61
|
+
model: "\u{f06a9}", // nf-md-robot
|
|
62
|
+
effort: "\u{f09d1}", // nf-md-brain
|
|
63
|
+
context: "\u{f035b}", // nf-md-memory
|
|
64
|
+
speed: "\u{f04c5}", // nf-md-speedometer
|
|
65
|
+
wait: "\u{f252}", // nf-fa-hourglass_half
|
|
66
|
+
running: "\u{f017}", // nf-fa-clock_o
|
|
67
|
+
done: "\u{f00c}", // nf-fa-check
|
|
68
|
+
cacheRead: "\u{f1c0}", // nf-fa-database
|
|
69
|
+
cacheWrite: "\u{f0c7}", // nf-fa-floppy_o
|
|
70
|
+
cost: "$",
|
|
71
|
+
clock: "\u{f017}", // nf-fa-clock_o
|
|
72
|
+
plug: "\u{f1e6}", // nf-fa-plug
|
|
73
|
+
stashed: "\u{f01c}", // nf-fa-inbox
|
|
74
|
+
fill: "█",
|
|
75
|
+
empty: "░",
|
|
76
|
+
levels: ["▂", "▃", "▄", "▅", "▆", "▇"],
|
|
77
|
+
ellipsis: "…",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Symbols every monospace font with decent Unicode coverage has, all one column wide. */
|
|
81
|
+
export const UNICODE: Glyphs = {
|
|
82
|
+
...COMMON,
|
|
83
|
+
set: "unicode",
|
|
84
|
+
folder: "▸",
|
|
85
|
+
branch: "⎇",
|
|
86
|
+
host: "⌂",
|
|
87
|
+
session: "#",
|
|
88
|
+
version: "π",
|
|
89
|
+
model: "◆",
|
|
90
|
+
effort: "✦",
|
|
91
|
+
context: "◔",
|
|
92
|
+
speed: "»",
|
|
93
|
+
wait: "⧗",
|
|
94
|
+
running: "◷",
|
|
95
|
+
done: "✓",
|
|
96
|
+
cacheRead: "≋",
|
|
97
|
+
cacheWrite: "≈",
|
|
98
|
+
cost: "$",
|
|
99
|
+
clock: "◷",
|
|
100
|
+
plug: "≫",
|
|
101
|
+
stashed: "≡",
|
|
102
|
+
fill: "█",
|
|
103
|
+
empty: "░",
|
|
104
|
+
levels: ["▂", "▃", "▄", "▅", "▆", "▇"],
|
|
105
|
+
ellipsis: "…",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const ASCII: Glyphs = {
|
|
109
|
+
set: "ascii",
|
|
110
|
+
folder: "",
|
|
111
|
+
branch: "",
|
|
112
|
+
host: "@",
|
|
113
|
+
session: "#",
|
|
114
|
+
version: "v",
|
|
115
|
+
model: "",
|
|
116
|
+
effort: "",
|
|
117
|
+
context: "ctx",
|
|
118
|
+
speed: "",
|
|
119
|
+
wait: "ttft",
|
|
120
|
+
running: "",
|
|
121
|
+
done: "",
|
|
122
|
+
sent: "^",
|
|
123
|
+
received: "v",
|
|
124
|
+
cacheRead: "R",
|
|
125
|
+
cacheWrite: "W",
|
|
126
|
+
cost: "$",
|
|
127
|
+
clock: "",
|
|
128
|
+
plug: ">>",
|
|
129
|
+
ahead: "^",
|
|
130
|
+
behind: "v",
|
|
131
|
+
staged: "+",
|
|
132
|
+
modified: "~",
|
|
133
|
+
untracked: "?",
|
|
134
|
+
conflicted: "!",
|
|
135
|
+
stashed: "s",
|
|
136
|
+
fill: "#",
|
|
137
|
+
empty: "-",
|
|
138
|
+
levels: ["|", "|", "|", "|", "|", "|"],
|
|
139
|
+
ellipsis: "...",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const SETS: Record<IconSet, Glyphs> = { nerd: NERD, unicode: UNICODE, ascii: ASCII };
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Auto trusts a local UTF-8 terminal to have a Nerd Font. Over SSH the font lives on the other
|
|
146
|
+
* machine, so it drops to plain Unicode, and a terminal that does not speak UTF-8 gets ASCII.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveIcons(mode: IconMode, env: NodeJS.ProcessEnv = process.env): Glyphs {
|
|
149
|
+
if (mode !== "auto") return SETS[mode];
|
|
150
|
+
if (env.TERM === "dumb") return ASCII;
|
|
151
|
+
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG;
|
|
152
|
+
if (locale !== undefined && locale !== "" && !/utf-?8/i.test(locale)) return ASCII;
|
|
153
|
+
if (env.SSH_TTY !== undefined || env.SSH_CONNECTION !== undefined) return UNICODE;
|
|
154
|
+
return NERD;
|
|
155
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Colour, without a single colour value. Every role names a token of the active pi theme, so the look
|
|
2
|
+
// follows whatever theme is on: the built-in ones, a custom JSON, or the desktop palette this package
|
|
3
|
+
// writes from matugen. Change the theme and every piece moves with it.
|
|
4
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
/** What a piece of text is. The table below says which theme token paints it. */
|
|
7
|
+
export type Role =
|
|
8
|
+
| "path"
|
|
9
|
+
| "branch"
|
|
10
|
+
| "ahead"
|
|
11
|
+
| "behind"
|
|
12
|
+
| "staged"
|
|
13
|
+
| "modified"
|
|
14
|
+
| "untracked"
|
|
15
|
+
| "conflicted"
|
|
16
|
+
| "host"
|
|
17
|
+
| "session"
|
|
18
|
+
| "version"
|
|
19
|
+
| "provider"
|
|
20
|
+
| "model"
|
|
21
|
+
| "sent"
|
|
22
|
+
| "received"
|
|
23
|
+
| "cache"
|
|
24
|
+
| "speed"
|
|
25
|
+
| "wait"
|
|
26
|
+
| "running"
|
|
27
|
+
| "done"
|
|
28
|
+
| "cost"
|
|
29
|
+
| "clock"
|
|
30
|
+
| "brand";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The palette, as theme tokens. Syntax tokens are borrowed on purpose: a theme spends its hues there,
|
|
34
|
+
* so they give the line variety that stays in the theme's family.
|
|
35
|
+
*/
|
|
36
|
+
export const ROLE_TOKENS: Record<Role, ThemeColor> = {
|
|
37
|
+
path: "accent",
|
|
38
|
+
branch: "syntaxFunction",
|
|
39
|
+
ahead: "success",
|
|
40
|
+
behind: "warning",
|
|
41
|
+
staged: "success",
|
|
42
|
+
modified: "warning",
|
|
43
|
+
untracked: "muted",
|
|
44
|
+
conflicted: "error",
|
|
45
|
+
host: "syntaxType",
|
|
46
|
+
session: "mdHeading",
|
|
47
|
+
version: "muted",
|
|
48
|
+
provider: "syntaxKeyword",
|
|
49
|
+
model: "text",
|
|
50
|
+
sent: "mdLink",
|
|
51
|
+
received: "success",
|
|
52
|
+
cache: "syntaxType",
|
|
53
|
+
speed: "success",
|
|
54
|
+
wait: "syntaxNumber",
|
|
55
|
+
running: "accent",
|
|
56
|
+
done: "success",
|
|
57
|
+
cost: "syntaxNumber",
|
|
58
|
+
clock: "muted",
|
|
59
|
+
brand: "accent",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Pi gives every effort level its own token, which a theme draws as a ramp from cool to hot. */
|
|
63
|
+
const EFFORT_TOKENS: Record<string, ThemeColor> = {
|
|
64
|
+
off: "thinkingOff",
|
|
65
|
+
minimal: "thinkingMinimal",
|
|
66
|
+
low: "thinkingLow",
|
|
67
|
+
medium: "thinkingMedium",
|
|
68
|
+
high: "thinkingHigh",
|
|
69
|
+
xhigh: "thinkingXhigh",
|
|
70
|
+
max: "thinkingMax",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
74
|
+
|
|
75
|
+
export interface Paint {
|
|
76
|
+
/** Separators, units and labels sit back, so the data reads first. */
|
|
77
|
+
dim(text: string): string;
|
|
78
|
+
/** Quieter data. */
|
|
79
|
+
muted(text: string): string;
|
|
80
|
+
text(text: string): string;
|
|
81
|
+
bold(text: string): string;
|
|
82
|
+
italic(text: string): string;
|
|
83
|
+
role(role: Role, text: string): string;
|
|
84
|
+
/** The colour of one effort level. */
|
|
85
|
+
effort(level: string, text: string): string;
|
|
86
|
+
/** Calm under half, warm past 70%, alarming past 90%. */
|
|
87
|
+
stress(percent: number, text: string): string;
|
|
88
|
+
/** Money reads calm until it adds up. */
|
|
89
|
+
money(amount: number, text: string): string;
|
|
90
|
+
/** The editor frame, which pi recolours with the effort level and for bash mode. */
|
|
91
|
+
frame(text: string): string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function themePaint(theme: Theme, frame?: (text: string) => string): Paint {
|
|
95
|
+
return {
|
|
96
|
+
dim: (text) => theme.fg("dim", text),
|
|
97
|
+
muted: (text) => theme.fg("muted", text),
|
|
98
|
+
text: (text) => theme.fg("text", text),
|
|
99
|
+
bold: (text) => theme.bold(text),
|
|
100
|
+
italic: (text) => theme.italic(text),
|
|
101
|
+
role: (role, text) => theme.fg(ROLE_TOKENS[role], text),
|
|
102
|
+
effort: (level, text) => theme.fg(EFFORT_TOKENS[level] ?? "thinkingOff", text),
|
|
103
|
+
stress: (percent, text) => theme.fg(stressToken(percent), text),
|
|
104
|
+
money: (amount, text) => theme.fg(moneyToken(amount), text),
|
|
105
|
+
frame: frame ?? ((text) => theme.fg("border", text)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stressToken(percent: number): ThemeColor {
|
|
110
|
+
if (percent >= 90) return "error";
|
|
111
|
+
if (percent >= 70) return "warning";
|
|
112
|
+
return "success";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function moneyToken(amount: number): ThemeColor {
|
|
116
|
+
if (amount >= 10) return "error";
|
|
117
|
+
if (amount >= 1) return "warning";
|
|
118
|
+
return ROLE_TOKENS.cost;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Used by tests and by anything drawing without a theme. */
|
|
122
|
+
export const PLAIN: Paint = {
|
|
123
|
+
dim: identity,
|
|
124
|
+
muted: identity,
|
|
125
|
+
text: identity,
|
|
126
|
+
bold: identity,
|
|
127
|
+
italic: identity,
|
|
128
|
+
role: (_role, text) => text,
|
|
129
|
+
effort: (_level, text) => text,
|
|
130
|
+
stress: (_percent, text) => text,
|
|
131
|
+
money: (_amount, text) => text,
|
|
132
|
+
frame: identity,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function identity(text: string): string {
|
|
136
|
+
return text;
|
|
137
|
+
}
|