pi-umbra 0.1.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 +27 -0
- package/node_modules/pi-umbra-ask/LICENSE +21 -0
- package/node_modules/pi-umbra-ask/README.md +21 -0
- package/node_modules/pi-umbra-ask/checks/umbra-ask.check.ts +132 -0
- package/node_modules/pi-umbra-ask/extensions/umbra-ask.ts +484 -0
- package/node_modules/pi-umbra-ask/lib/clean.ts +25 -0
- package/node_modules/pi-umbra-ask/package.json +37 -0
- package/node_modules/pi-umbra-copy-chat/LICENSE +21 -0
- package/node_modules/pi-umbra-copy-chat/README.md +19 -0
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +154 -0
- package/node_modules/pi-umbra-copy-chat/package.json +32 -0
- package/node_modules/pi-umbra-help/LICENSE +21 -0
- package/node_modules/pi-umbra-help/README.md +21 -0
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +108 -0
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +223 -0
- package/node_modules/pi-umbra-help/lib/umbra-settings.ts +52 -0
- package/node_modules/pi-umbra-help/package.json +34 -0
- package/node_modules/pi-umbra-inputbar/LICENSE +21 -0
- package/node_modules/pi-umbra-inputbar/README.md +19 -0
- package/node_modules/pi-umbra-inputbar/checks/umbra-inputbar.check.ts +53 -0
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +130 -0
- package/node_modules/pi-umbra-inputbar/package.json +33 -0
- package/node_modules/pi-umbra-preview/LICENSE +21 -0
- package/node_modules/pi-umbra-preview/README.md +32 -0
- package/node_modules/pi-umbra-preview/checks/umbra-preview.check.ts +38 -0
- package/node_modules/pi-umbra-preview/extensions/umbra-preview.ts +147 -0
- package/node_modules/pi-umbra-preview/package.json +33 -0
- package/node_modules/pi-umbra-rename/LICENSE +21 -0
- package/node_modules/pi-umbra-rename/README.md +19 -0
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +55 -0
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +22 -0
- package/node_modules/pi-umbra-rename/package.json +33 -0
- package/node_modules/pi-umbra-shimmer/LICENSE +21 -0
- package/node_modules/pi-umbra-shimmer/README.md +19 -0
- package/node_modules/pi-umbra-shimmer/checks/umbra-shimmer.check.ts +89 -0
- package/node_modules/pi-umbra-shimmer/extensions/umbra-shimmer.ts +183 -0
- package/node_modules/pi-umbra-shimmer/package.json +33 -0
- package/node_modules/pi-umbra-skill-matcher/LICENSE +21 -0
- package/node_modules/pi-umbra-skill-matcher/README.md +19 -0
- package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +94 -0
- package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +124 -0
- package/node_modules/pi-umbra-skill-matcher/package.json +35 -0
- package/node_modules/pi-umbra-theme/LICENSE +21 -0
- package/node_modules/pi-umbra-theme/README.md +38 -0
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +158 -0
- package/node_modules/pi-umbra-theme/checks/umbra-footer.check.ts +72 -0
- package/node_modules/pi-umbra-theme/checks/umbra-gutter.check.ts +59 -0
- package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +71 -0
- package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +120 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +155 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-footer.ts +150 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-gutter.ts +35 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox.ts +129 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +209 -0
- package/node_modules/pi-umbra-theme/lib/umbra-format.ts +16 -0
- package/node_modules/pi-umbra-theme/lib/umbra-settings.ts +52 -0
- package/node_modules/pi-umbra-theme/package.json +45 -0
- package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-violet-forge.json +88 -0
- package/package.json +68 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-umbra-skill-matcher",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type /huh and reach skill:huh. pi lists every skill under its bare name, which is not the name that dispatches.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"autocomplete",
|
|
9
|
+
"skills"
|
|
10
|
+
],
|
|
11
|
+
"author": "grkn",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/grknbyk/pi-umbra.git",
|
|
16
|
+
"directory": "pi-umbra-skill-matcher"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-skill-matcher#readme",
|
|
19
|
+
"bugs": "https://github.com/grknbyk/pi-umbra/issues",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"files": [
|
|
22
|
+
"extensions",
|
|
23
|
+
"checks",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grkn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# pi-umbra-theme
|
|
2
|
+
|
|
3
|
+
pi's look in one package: seven umbra themes and the terminal background they set, a status footer, a gutter beside model text, a frame around every tool call, and a working line that names the running tool.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi-umbra-theme/
|
|
7
|
+
├── checks
|
|
8
|
+
│ ├── umbra-background.check.ts
|
|
9
|
+
│ ├── umbra-footer.check.ts
|
|
10
|
+
│ ├── umbra-gutter.check.ts
|
|
11
|
+
│ ├── umbra-toolbox.check.ts
|
|
12
|
+
│ └── umbra-working.check.ts
|
|
13
|
+
├── extensions
|
|
14
|
+
│ ├── umbra-background.ts
|
|
15
|
+
│ ├── umbra-footer.ts
|
|
16
|
+
│ ├── umbra-gutter.ts
|
|
17
|
+
│ ├── umbra-toolbox.ts
|
|
18
|
+
│ └── umbra-working.ts
|
|
19
|
+
├── lib
|
|
20
|
+
│ ├── umbra-format.ts
|
|
21
|
+
│ └── umbra-settings.ts
|
|
22
|
+
└── themes
|
|
23
|
+
├── umbra-astral-veil.json
|
|
24
|
+
├── umbra-deep-current.json
|
|
25
|
+
├── umbra-ember-ash.json
|
|
26
|
+
├── umbra-onyx-slate.json
|
|
27
|
+
├── umbra-tidal-drift.json
|
|
28
|
+
├── umbra-venom-dusk.json
|
|
29
|
+
└── umbra-violet-forge.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pi install npm:pi-umbra-theme
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
MIT.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The one runnable check for umbra-background: the colour it picks, and the bytes it writes.
|
|
2
|
+
// A pi upgrade that renames Theme.sourcePath, or an edit that drops the OSC 111 reset, fails
|
|
3
|
+
// here instead of leaving someone's terminal recoloured after pi exits.
|
|
4
|
+
//
|
|
5
|
+
// bun run umbra-background.check.ts
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "umbra-"));
|
|
12
|
+
const themePath = join(dir, "violet-forge.json");
|
|
13
|
+
writeFileSync(themePath, JSON.stringify({ name: "violet-forge", export: { pageBg: "#191830" } }));
|
|
14
|
+
const bareThemePath = join(dir, "bare.json");
|
|
15
|
+
writeFileSync(bareThemePath, JSON.stringify({ name: "bare" }));
|
|
16
|
+
|
|
17
|
+
const settingsPath = join(dir, "settings.json");
|
|
18
|
+
const setSetting = (value: unknown) =>
|
|
19
|
+
writeFileSync(settingsPath, JSON.stringify(value === undefined ? {} : { piUmbraTheme: { background: value } }));
|
|
20
|
+
process.env.PI_AGENT_DIR = dir;
|
|
21
|
+
|
|
22
|
+
const written: string[] = [];
|
|
23
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
24
|
+
process.stdout.write = ((chunk: string) => {
|
|
25
|
+
if (typeof chunk === "string" && chunk.startsWith("\x1b]")) {
|
|
26
|
+
written.push(chunk);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return realWrite(chunk as never);
|
|
30
|
+
}) as never;
|
|
31
|
+
|
|
32
|
+
const { default: umbraBackground } = await import("../extensions/umbra-background.ts");
|
|
33
|
+
|
|
34
|
+
type Handler = (event: unknown, ctx: unknown) => void;
|
|
35
|
+
const handlers = new Map<string, Handler>();
|
|
36
|
+
let command: { name: string; handler: (args: string, ctx: unknown) => Promise<void> } | undefined;
|
|
37
|
+
const pi = {
|
|
38
|
+
on: (name: string, handler: Handler) => handlers.set(name, handler),
|
|
39
|
+
// Two arguments, matching pi: registerCommand(name, options). Recording only the options is
|
|
40
|
+
// what this check exists to keep honest - the one-object form is what crashed pi at "/".
|
|
41
|
+
registerCommand: (name: string, options: never) => (command = { name, ...(options as object) }),
|
|
42
|
+
};
|
|
43
|
+
umbraBackground(pi as never);
|
|
44
|
+
|
|
45
|
+
const notices: string[] = [];
|
|
46
|
+
// Real pi hands the extension a Theme with NO sourcePath; the file it was read from is only on
|
|
47
|
+
// the list getAllThemes() returns, under the name `path`. The check used to invent a sourcePath
|
|
48
|
+
// and then assert against its own invention, which is why it passed while nothing happened on
|
|
49
|
+
// screen. Both shapes are exercised now, and the pi-shaped one is the default.
|
|
50
|
+
const ctx = (sourcePath: string | undefined, mode = "tui") => ({
|
|
51
|
+
mode,
|
|
52
|
+
ui: {
|
|
53
|
+
theme: { name: "violet-forge", sourcePath },
|
|
54
|
+
getAllThemes: () => [],
|
|
55
|
+
notify: (m: string) => notices.push(m),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const piShapedCtx = (path: string | undefined, mode = "tui") => ({
|
|
59
|
+
mode,
|
|
60
|
+
ui: {
|
|
61
|
+
theme: { name: "violet-forge" },
|
|
62
|
+
getAllThemes: () => (path === undefined ? [] : [{ name: "violet-forge", path }]),
|
|
63
|
+
notify: (m: string) => notices.push(m),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const start = () => handlers.get("session_start")!;
|
|
68
|
+
const shutdown = () => handlers.get("session_shutdown")!;
|
|
69
|
+
|
|
70
|
+
// auto: the colour comes from the theme's own export.pageBg, and shutdown puts it back.
|
|
71
|
+
setSetting(undefined);
|
|
72
|
+
written.length = 0;
|
|
73
|
+
start()({}, ctx(themePath));
|
|
74
|
+
assert.deepEqual(written, ["\x1b]11;#191830\x07"], "auto must set the theme's pageBg");
|
|
75
|
+
shutdown()({}, ctx(themePath));
|
|
76
|
+
assert.equal(written[1], "\x1b]111\x07", "shutdown must reset the background");
|
|
77
|
+
|
|
78
|
+
// A theme with no export.pageBg is left alone. Nothing is guessed.
|
|
79
|
+
setSetting(undefined);
|
|
80
|
+
written.length = 0;
|
|
81
|
+
start()({}, ctx(bareThemePath));
|
|
82
|
+
assert.deepEqual(written, [], "a theme without pageBg must write nothing");
|
|
83
|
+
shutdown()({}, ctx(bareThemePath));
|
|
84
|
+
assert.deepEqual(written, [], "nothing applied means nothing to reset");
|
|
85
|
+
|
|
86
|
+
// off wins over the theme.
|
|
87
|
+
setSetting("off");
|
|
88
|
+
written.length = 0;
|
|
89
|
+
start()({}, ctx(themePath));
|
|
90
|
+
assert.deepEqual(written, [], "off must write nothing");
|
|
91
|
+
|
|
92
|
+
// An explicit colour wins over the theme.
|
|
93
|
+
setSetting("#0c0c0c");
|
|
94
|
+
written.length = 0;
|
|
95
|
+
start()({}, ctx(themePath));
|
|
96
|
+
assert.deepEqual(written, ["\x1b]11;#0c0c0c\x07"], "an explicit hex must win");
|
|
97
|
+
|
|
98
|
+
// Outside a TUI there is no terminal to recolour.
|
|
99
|
+
setSetting(undefined);
|
|
100
|
+
written.length = 0;
|
|
101
|
+
start()({}, ctx(themePath, "print"));
|
|
102
|
+
assert.deepEqual(written, [], "non-tui mode must write nothing");
|
|
103
|
+
|
|
104
|
+
// The command takes a colour, a reset, and rejects anything else without writing.
|
|
105
|
+
setSetting(undefined);
|
|
106
|
+
written.length = 0;
|
|
107
|
+
await command!.handler("#abcdef", ctx(themePath));
|
|
108
|
+
assert.deepEqual(written, ["\x1b]11;#abcdef\x07"]);
|
|
109
|
+
await command!.handler("off", ctx(themePath));
|
|
110
|
+
assert.equal(written[1], "\x1b]111\x07");
|
|
111
|
+
written.length = 0;
|
|
112
|
+
await command!.handler("purple", ctx(themePath));
|
|
113
|
+
assert.deepEqual(written, [], "a bad argument must not write to the terminal");
|
|
114
|
+
|
|
115
|
+
// The shape real pi actually passes: no sourcePath anywhere, the file only on getAllThemes().
|
|
116
|
+
setSetting(undefined);
|
|
117
|
+
written.length = 0;
|
|
118
|
+
start()({}, piShapedCtx(themePath));
|
|
119
|
+
assert.deepEqual(written, ["]11;#191830"], "the theme file must be found via getAllThemes().path");
|
|
120
|
+
shutdown()({}, piShapedCtx(themePath));
|
|
121
|
+
|
|
122
|
+
// A theme pi does not list is left alone rather than guessed at.
|
|
123
|
+
written.length = 0;
|
|
124
|
+
start()({}, piShapedCtx(undefined));
|
|
125
|
+
assert.deepEqual(written, [], "an unlisted theme writes nothing");
|
|
126
|
+
|
|
127
|
+
// Switching themes in pi's own picker fires no event of any kind, so the extension watches the
|
|
128
|
+
// theme name. This is the one case that has to wait on a real tick rather than a faked clock.
|
|
129
|
+
setSetting(undefined);
|
|
130
|
+
written.length = 0;
|
|
131
|
+
const live = { name: "bare" };
|
|
132
|
+
const liveCtx = {
|
|
133
|
+
mode: "tui",
|
|
134
|
+
ui: {
|
|
135
|
+
get theme() {
|
|
136
|
+
return live;
|
|
137
|
+
},
|
|
138
|
+
getAllThemes: () => [
|
|
139
|
+
{ name: "bare", path: bareThemePath },
|
|
140
|
+
{ name: "violet-forge", path: themePath },
|
|
141
|
+
],
|
|
142
|
+
notify: (m: string) => notices.push(m),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
start()({}, liveCtx);
|
|
146
|
+
assert.deepEqual(written, [], "a theme with no export.pageBg writes nothing");
|
|
147
|
+
live.name = "violet-forge";
|
|
148
|
+
await new Promise((done) => setTimeout(done, 700));
|
|
149
|
+
assert.deepEqual(written, ["]11;#191830"], "a theme switch must be followed without any event");
|
|
150
|
+
shutdown()({}, liveCtx);
|
|
151
|
+
written.length = 0;
|
|
152
|
+
live.name = "bare";
|
|
153
|
+
await new Promise((done) => setTimeout(done, 700));
|
|
154
|
+
assert.deepEqual(written, [], "the watcher must stop at shutdown");
|
|
155
|
+
|
|
156
|
+
process.stdout.write = realWrite;
|
|
157
|
+
|
|
158
|
+
console.log("umbra-background: all checks passed");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The one runnable check for footer.ts: it renders every frame, so an API drift in ctx,
|
|
2
|
+
// footerData or the theme fails silently as a blank or a thrown line nobody sees. This
|
|
3
|
+
// drives the extension through a stub pi and asserts the six columns land on one line
|
|
4
|
+
// of exactly the width asked for.
|
|
5
|
+
//
|
|
6
|
+
// Run it with: bun run umbra-footer.check.ts (or: node --experimental-strip-types footer.check.ts)
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import footer from "../extensions/umbra-footer.ts";
|
|
10
|
+
|
|
11
|
+
const handlers: Record<string, (event: unknown, ctx: unknown) => unknown> = {};
|
|
12
|
+
const pi = { on: (name: string, fn: (event: unknown, ctx: unknown) => unknown) => (handlers[name] = fn) };
|
|
13
|
+
|
|
14
|
+
let factory: ((tui: unknown, theme: unknown, footerData: unknown) => { render(width: number): string[] }) | undefined;
|
|
15
|
+
let renders = 0;
|
|
16
|
+
const usage = { input: 6800, output: 33, cacheRead: 128_000, cacheWrite: 0, cost: { total: 1.23 } };
|
|
17
|
+
const ctx = {
|
|
18
|
+
model: { name: "MiniMax: MiniMax M3 (CC)" },
|
|
19
|
+
thinkingLevel: "medium",
|
|
20
|
+
getContextUsage: () => ({ tokens: 147_000, contextWindow: 1_000_000 }),
|
|
21
|
+
sessionManager: {
|
|
22
|
+
getCwd: () => "C:/Users/grkn/.pi",
|
|
23
|
+
getBranch: () => [{ type: "message", message: { role: "assistant", usage } }],
|
|
24
|
+
},
|
|
25
|
+
ui: { setFooter: (f: typeof factory) => (factory = f) },
|
|
26
|
+
};
|
|
27
|
+
const tui = { requestRender: () => renders++ };
|
|
28
|
+
const theme = { fg: (_color: string, text: string) => text };
|
|
29
|
+
const footerData = {
|
|
30
|
+
onBranchChange: () => () => {},
|
|
31
|
+
getExtensionStatuses: () => new Map<string, string>([["ponytail", "ponytail: full"]]),
|
|
32
|
+
getGitBranch: () => "main",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
footer(pi as never);
|
|
36
|
+
assert.ok(handlers.session_start && handlers.agent_end, "registers session_start and agent_end");
|
|
37
|
+
await handlers.session_start({}, ctx);
|
|
38
|
+
assert.ok(factory, "session_start installs the footer");
|
|
39
|
+
const view = factory!(tui, theme, footerData);
|
|
40
|
+
|
|
41
|
+
const [line, ...rest] = view.render(160);
|
|
42
|
+
assert.equal(rest.length, 0, "one line - the rule above it belongs to the editor, not to this");
|
|
43
|
+
assert.equal(visibleWidth(line), 160, "fills the width exactly, one column of padding left and two right");
|
|
44
|
+
for (const column of ["MiniMax M3 [medium]", "147k/1000k", "$1.23", "R128k W0 CH95.0%", "↑ 6.8k ↓ 33", "ponytail:full", "🗀 C:/Users/grkn/.pi", "⎇ main"]) {
|
|
45
|
+
assert.ok(line.includes(column), `shows ${column}`);
|
|
46
|
+
}
|
|
47
|
+
assert.ok(!line.includes("MiniMax:") && !line.includes("(CC)"), "provider prefix and parenthetical stripped");
|
|
48
|
+
|
|
49
|
+
// A narrow terminal takes as many rows as the groups need. The count is not the assertion - the
|
|
50
|
+
// assertion is that nothing is cut off the end and nothing runs past the edge. Truncating instead
|
|
51
|
+
// always lost whichever group came last, and the groups are ordered by how often they are read.
|
|
52
|
+
const rows = view.render(40);
|
|
53
|
+
assert.ok(rows.length > 1, "narrow: one row cannot hold all of it");
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
assert.ok(visibleWidth(row) <= 40, `narrow: a row ran past the edge: ${JSON.stringify(row)}`);
|
|
56
|
+
}
|
|
57
|
+
const joined = rows.join("\n");
|
|
58
|
+
for (const column of ["MiniMax M3 [medium]", "147k/1000k", "$1.23", "R128k W0 CH95.0%", "ponytail:full", "🗀 C:/Users/grkn/.pi", "⎇ main"]) {
|
|
59
|
+
assert.ok(joined.includes(column), `narrow: ${column} survives the wrap`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The lag fix: agent_end re-reads the branch and asks for a repaint itself.
|
|
63
|
+
const before = renders;
|
|
64
|
+
await handlers.agent_end({}, ctx);
|
|
65
|
+
assert.equal(renders, before + 1, "agent_end requests exactly one repaint");
|
|
66
|
+
|
|
67
|
+
// A free model prices every turn at zero, and then the cost column must be absent.
|
|
68
|
+
usage.cost.total = 0;
|
|
69
|
+
await handlers.agent_end({}, ctx);
|
|
70
|
+
assert.ok(!view.render(160)[0].includes("$"), "zero cost hides the $ column");
|
|
71
|
+
|
|
72
|
+
console.log("umbra-footer.check.ts ok - one row at 160, wraps at 40 with nothing lost, repaint on agent_end");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The one runnable check for umbra-gutter. The interesting part is not that a prefix is added —
|
|
2
|
+
// it is that adding it does not destroy the Markdown underneath. A fence that stops being a
|
|
3
|
+
// fence, or a blank line that splits one answer into two quote blocks, is the failure this
|
|
4
|
+
// catches.
|
|
5
|
+
//
|
|
6
|
+
// bun run umbra-gutter.check.ts
|
|
7
|
+
import assert from "node:assert";
|
|
8
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const directory = mkdtempSync(join(tmpdir(), "umbra-gutter-"));
|
|
13
|
+
process.env.PI_AGENT_DIR = directory;
|
|
14
|
+
const settingsPath = join(directory, "settings.json");
|
|
15
|
+
const enableGutter = (enabled: boolean) =>
|
|
16
|
+
writeFileSync(settingsPath, JSON.stringify({ piUmbraTheme: { messages: { assistantPrefix: enabled } } }));
|
|
17
|
+
|
|
18
|
+
enableGutter(true);
|
|
19
|
+
const { default: umbraGutter, asQuote } = await import("../extensions/umbra-gutter.ts");
|
|
20
|
+
|
|
21
|
+
type Transformer = (markdown: string, context: { messageType: string }) => string;
|
|
22
|
+
const register = () => {
|
|
23
|
+
let transformer: Transformer | undefined;
|
|
24
|
+
umbraGutter({ registerMarkdownTransformer: (fn: Transformer) => (transformer = fn) } as never);
|
|
25
|
+
return transformer;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const transform = register();
|
|
29
|
+
assert.ok(transform !== undefined, "an enabled gutter must register a transformer");
|
|
30
|
+
|
|
31
|
+
// A fenced code block has to survive, markers and all. This is the whole reason it is a quote.
|
|
32
|
+
const fenced = "Here:\n\n```ts\nconst x = 1;\n```\n";
|
|
33
|
+
const quotedFence = transform("Here:\n\n```ts\nconst x = 1;\n```", { messageType: "assistant" });
|
|
34
|
+
assert.equal(quotedFence, "> Here:\n>\n> ```ts\n> const x = 1;\n> ```", `fence broke: ${quotedFence}`);
|
|
35
|
+
assert.ok(quotedFence.includes("> ```ts"), "the opening fence must stay a fence inside the quote");
|
|
36
|
+
assert.equal(fenced.match(/```/g)?.length, quotedFence.match(/```/g)?.length, "no fence may be lost");
|
|
37
|
+
|
|
38
|
+
// A blank line must carry the marker, or the answer splits into two separate quote blocks.
|
|
39
|
+
assert.equal(asQuote("a\n\nb"), "> a\n>\n> b");
|
|
40
|
+
assert.equal(asQuote("a\n \nb"), "> a\n>\n> b", "whitespace-only lines count as blank");
|
|
41
|
+
|
|
42
|
+
// Only model text is touched. Your own lines and the thinking block are left alone.
|
|
43
|
+
const mine = "@src/app.ts bunu sadeleştir";
|
|
44
|
+
assert.equal(transform(mine, { messageType: "user" }), mine);
|
|
45
|
+
assert.equal(transform(mine, { messageType: "assistant-thinking" }), mine);
|
|
46
|
+
|
|
47
|
+
// An empty answer stays empty rather than becoming a lone quote marker.
|
|
48
|
+
assert.equal(transform("", { messageType: "assistant" }), "");
|
|
49
|
+
assert.equal(transform(" ", { messageType: "assistant" }), " ");
|
|
50
|
+
|
|
51
|
+
// Turkish text passes through byte for byte; the gutter is a prefix, not a rewrite.
|
|
52
|
+
const turkish = "Işıkçı şoföre çağrı yaptı.";
|
|
53
|
+
assert.equal(transform(turkish, { messageType: "assistant" }), `> ${turkish}`);
|
|
54
|
+
|
|
55
|
+
// Off means off: nothing is registered at all, so there is no render cost to pay.
|
|
56
|
+
enableGutter(false);
|
|
57
|
+
assert.equal(register(), undefined, "a disabled gutter must register nothing");
|
|
58
|
+
|
|
59
|
+
console.log("umbra-gutter: all checks passed");
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The one runnable check for umbra-toolbox. Two things can go wrong and both are silent: the
|
|
2
|
+
// frame can misalign because a line's colour codes were counted as width, and the patch can be
|
|
3
|
+
// applied to a `render` that is no longer the method it was written against.
|
|
4
|
+
//
|
|
5
|
+
// bun run umbra-toolbox.check.ts
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const directory = mkdtempSync(join(tmpdir(), "umbra-toolbox-"));
|
|
13
|
+
process.env.PI_AGENT_DIR = directory;
|
|
14
|
+
writeFileSync(
|
|
15
|
+
join(directory, "settings.json"),
|
|
16
|
+
JSON.stringify({ piUmbraTheme: { tools: { chrome: "boxed" } } }),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const { frame, framedWidth, usableWidth } = await import("../extensions/umbra-toolbox.ts");
|
|
20
|
+
|
|
21
|
+
const plain = (text: string) => text;
|
|
22
|
+
const RED = "\x1b[31m";
|
|
23
|
+
const OFF = "\x1b[39m";
|
|
24
|
+
const WIDTH = 20;
|
|
25
|
+
|
|
26
|
+
// The frame is exactly `width` columns wide, on every row, whatever the content was.
|
|
27
|
+
const framed = frame(["abc", `${RED}de${OFF}`], WIDTH, plain);
|
|
28
|
+
assert.equal(framed.length, 4, "two content rows plus a top and a bottom");
|
|
29
|
+
assert.equal(framed[0], `╭${"─".repeat(18)}╮`);
|
|
30
|
+
assert.equal(framed[3], `╰${"─".repeat(18)}╯`);
|
|
31
|
+
assert.equal(framed[1], `│ abc${" ".repeat(13)} │`, `row misaligned: ${JSON.stringify(framed[1])}`);
|
|
32
|
+
|
|
33
|
+
// Colour must not steal columns. The painted row has to close at the same place as the plain one.
|
|
34
|
+
const colouredRow = framed[2] ?? "";
|
|
35
|
+
const stripped = colouredRow.replace(/\x1b\[[0-9;]*m/g, "");
|
|
36
|
+
assert.equal(stripped.length, WIDTH, `coloured row is ${stripped.length} columns, not ${WIDTH}`);
|
|
37
|
+
assert.ok(colouredRow.endsWith(" │"), "the right border must survive the colour codes");
|
|
38
|
+
|
|
39
|
+
// pi's tool component opens with a blank spacer. It belongs outside the box, not as an empty
|
|
40
|
+
// first row inside it.
|
|
41
|
+
const spaced = frame(["", "body", ""], WIDTH, plain);
|
|
42
|
+
assert.equal(spaced[0], "", "a leading blank stays above the frame");
|
|
43
|
+
assert.equal(spaced[1], `╭${"─".repeat(18)}╮`, "the frame starts after the blank");
|
|
44
|
+
assert.equal(spaced[spaced.length - 1], "", "a trailing blank stays below the frame");
|
|
45
|
+
|
|
46
|
+
// Nothing to frame, or nowhere to draw it: return pi's own lines untouched rather than a box
|
|
47
|
+
// around nothing or a box wider than the terminal.
|
|
48
|
+
assert.deepEqual(frame(["", " "], WIDTH, plain), ["", " "], "all-blank input is left alone");
|
|
49
|
+
assert.deepEqual(frame(["body"], 8, plain), ["body"], "a terminal too narrow keeps pi's own row");
|
|
50
|
+
|
|
51
|
+
// A line already wider than the inner column is not padded, and must not be silently cut either:
|
|
52
|
+
// pi drew it, and losing characters is worse than a frame that bulges.
|
|
53
|
+
const wide = frame(["x".repeat(40)], WIDTH, plain);
|
|
54
|
+
assert.ok((wide[1] ?? "").includes("x".repeat(40)), "an over-wide line keeps all its characters");
|
|
55
|
+
|
|
56
|
+
// pi must be asked for the inner width. Ask it for the full width and every full-width line it
|
|
57
|
+
// draws ends up four columns past the right border, which is a frame that leaks rather than holds.
|
|
58
|
+
// The last column belongs to the scrollbar, so the frame stops one short of it and the content
|
|
59
|
+
// gives up that column too. Getting this sum wrong is what pushes a full row past the edge.
|
|
60
|
+
assert.equal(framedWidth(80), 79, "the frame leaves the scrollbar its column");
|
|
61
|
+
assert.equal(usableWidth(80), 75, "the border, its padding and that margin take five columns");
|
|
62
|
+
assert.equal(usableWidth(80) + 4 + 1, 80, "content plus frame plus margin is exactly the width");
|
|
63
|
+
assert.equal(usableWidth(8), 8, "below MIN_WIDTH nothing is framed, so nothing is taken");
|
|
64
|
+
|
|
65
|
+
// The guard: this is the shape the wrapper was written against. If a pi upgrade changes it, the
|
|
66
|
+
// extension must stand down rather than patch something else.
|
|
67
|
+
const render: unknown = ToolExecutionComponent?.prototype?.render;
|
|
68
|
+
assert.equal(typeof render, "function", "pi still has ToolExecutionComponent.prototype.render");
|
|
69
|
+
assert.equal((render as (width: number) => string[]).length, 1, "render still takes one argument");
|
|
70
|
+
|
|
71
|
+
console.log("umbra-toolbox: all checks passed");
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The status line, without a terminal. Everything worth checking is a pure function of the turn
|
|
2
|
+
// state, so the event wiring is the only part left untested - and that part is four assignments.
|
|
3
|
+
//
|
|
4
|
+
// bun run umbra-working.check.ts
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { compact, elapsed } from "../lib/umbra-format.ts";
|
|
8
|
+
import { type Turn, entryFor, indicatorFor, labelFor, newTurn, statusLine } from "../extensions/umbra-working.ts";
|
|
9
|
+
|
|
10
|
+
const ON = { tokens: true, elapsed: true };
|
|
11
|
+
const TOOLS = ["read", "ls", "find", "grep", "write", "edit", "bash", "powershell"];
|
|
12
|
+
const KEYS = [...TOOLS, "thinking", "idle", "custom"];
|
|
13
|
+
|
|
14
|
+
// pi's eight typed tools collapse into four words. The mapping is the whole point of the file, so
|
|
15
|
+
// every name in `ToolCallEvent` is named here rather than sampled.
|
|
16
|
+
assert.equal(labelFor("read"), "Reading");
|
|
17
|
+
assert.equal(labelFor("ls"), "Listing");
|
|
18
|
+
assert.equal(labelFor("find"), "Listing");
|
|
19
|
+
assert.equal(labelFor("grep"), "Listing");
|
|
20
|
+
assert.equal(labelFor("write"), "Editing");
|
|
21
|
+
assert.equal(labelFor("edit"), "Editing");
|
|
22
|
+
assert.equal(labelFor("bash"), "Shell");
|
|
23
|
+
assert.equal(labelFor("powershell"), "Shell");
|
|
24
|
+
assert.equal(labelFor("thinking"), "Thinking");
|
|
25
|
+
assert.equal(labelFor("idle"), "Working");
|
|
26
|
+
|
|
27
|
+
// CustomToolCallEvent - anything an extension registered - lands on one entry, animation included.
|
|
28
|
+
assert.equal(labelFor("fabric_exec"), "Tool call");
|
|
29
|
+
assert.equal(labelFor("ask_user_question"), "Tool call");
|
|
30
|
+
assert.deepEqual(indicatorFor("fabric_exec").frames, indicatorFor("custom").frames);
|
|
31
|
+
|
|
32
|
+
// --- frames ----------------------------------------------------------------------------------
|
|
33
|
+
// Every glyph must measure one column: a two-column frame shifts the whole status line on every
|
|
34
|
+
// tick, which is what ruled the trigrams out of Listing.
|
|
35
|
+
for (const key of KEYS) {
|
|
36
|
+
const { frames } = indicatorFor(key);
|
|
37
|
+
assert.ok(frames.length >= 4, `${key} needs enough frames to read as motion`);
|
|
38
|
+
for (const frame of frames) assert.equal(visibleWidth(frame), 1, `${key}: ${frame} is not one column`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tools that share a word share its animation, and no two words look alike - otherwise the glyph
|
|
42
|
+
// says nothing the word did not already say.
|
|
43
|
+
assert.deepEqual(indicatorFor("find").frames, indicatorFor("ls").frames, "one word, one animation");
|
|
44
|
+
assert.deepEqual(indicatorFor("powershell").frames, indicatorFor("bash").frames);
|
|
45
|
+
const distinct = new Set(KEYS.map((key) => indicatorFor(key).frames.join("")));
|
|
46
|
+
assert.equal(distinct.size, 7, "seven words, seven animations");
|
|
47
|
+
|
|
48
|
+
// Listing bounces rather than sweeping one way: a vertical sweep that resets jumps the full cell
|
|
49
|
+
// height and reads as a stutter.
|
|
50
|
+
assert.deepEqual(indicatorFor("ls").frames, ["⎺", "⎻", "⎼", "⎽", "⎼", "⎻"]);
|
|
51
|
+
assert.deepEqual(indicatorFor("write").frames, ["▖", "▘", "▝", "▗"]);
|
|
52
|
+
|
|
53
|
+
// The sets are 4, 6 and 8 long, so a fixed per-frame interval would run them at three speeds. One
|
|
54
|
+
// full turn is the number held steady and the interval is derived from it.
|
|
55
|
+
assert.equal(indicatorFor("write").intervalMs, 120, "4 frames");
|
|
56
|
+
assert.equal(indicatorFor("read").intervalMs, 80, "6 frames");
|
|
57
|
+
assert.equal(indicatorFor("idle").intervalMs, 60, "8 frames");
|
|
58
|
+
for (const key of KEYS) {
|
|
59
|
+
const { frames, intervalMs } = indicatorFor(key);
|
|
60
|
+
assert.equal(frames.length * intervalMs, 480, `${key} must take the same time to come round`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- settings --------------------------------------------------------------------------------
|
|
64
|
+
// Fields merge one by one, so naming a tool keeps the animation it already had.
|
|
65
|
+
const renamed = { tools: { fabric_exec: { label: "Fabric" } } };
|
|
66
|
+
assert.equal(labelFor("fabric_exec", renamed), "Fabric");
|
|
67
|
+
assert.deepEqual(indicatorFor("fabric_exec", renamed).frames, indicatorFor("custom").frames);
|
|
68
|
+
assert.equal(labelFor("read", renamed), "Reading", "an override must leave the other tools alone");
|
|
69
|
+
|
|
70
|
+
// A frames array replaces rather than merges, and the turn holds, so a shorter set runs slower per
|
|
71
|
+
// frame instead of running the whole animation faster.
|
|
72
|
+
const shell = { tools: { bash: { frames: ["$", "#", "%"] } } };
|
|
73
|
+
assert.deepEqual(indicatorFor("bash", shell).frames, ["$", "#", "%"]);
|
|
74
|
+
assert.equal(indicatorFor("bash", shell).intervalMs, 160, "3 frames, same 480ms turn");
|
|
75
|
+
assert.deepEqual(indicatorFor("powershell", shell).frames, indicatorFor("powershell").frames, "not its twin");
|
|
76
|
+
assert.deepEqual(indicatorFor("bash", { tools: { bash: { frames: [] } } }).frames.length, 4, "empty is no override");
|
|
77
|
+
|
|
78
|
+
// `cycleMs` is one full turn, so it survives a change of frame count.
|
|
79
|
+
assert.equal(indicatorFor("read", { tools: { read: { cycleMs: 1200 } } }).intervalMs, 200);
|
|
80
|
+
assert.deepEqual(entryFor("bash", { tools: { bash: { label: "Bash", cycleMs: 240 } } }), {
|
|
81
|
+
label: "Bash",
|
|
82
|
+
cycleMs: 240,
|
|
83
|
+
frames: indicatorFor("bash").frames,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- the line --------------------------------------------------------------------------------
|
|
87
|
+
const at = (turn: Partial<Turn>): Turn => ({ ...newTurn(0), ...turn });
|
|
88
|
+
|
|
89
|
+
// A turn that has produced nothing yet shows no arrow, because "↓ 0 tokens" is noise.
|
|
90
|
+
assert.equal(statusLine(at({}), 1_000, ON), "Working… (1s)");
|
|
91
|
+
assert.equal(statusLine(at({ key: "read", settled: 180 }), 5_000, ON), "Reading… (5s · ↓ 180 tokens)");
|
|
92
|
+
assert.equal(statusLine(at({ key: "bash", settled: 1_200 }), 12_000, ON), "Shell… (12s · ↓ 1.2k tokens)");
|
|
93
|
+
assert.equal(statusLine(at({ key: "fabric_exec" }), 1_000, ON), "Tool call… (1s)");
|
|
94
|
+
assert.equal(statusLine(at({ key: "fabric_exec" }), 1_000, ON, renamed), "Fabric… (1s)");
|
|
95
|
+
|
|
96
|
+
// Streaming output counts before the message ends, or the number stands still mid-answer.
|
|
97
|
+
assert.equal(statusLine(at({ settled: 100, streaming: 80 }), 5_000, ON), "Working… (5s · ↓ 180 tokens)");
|
|
98
|
+
|
|
99
|
+
// "thought for" belongs to the Thinking line alone and counts from zero at the block, so the two
|
|
100
|
+
// numbers on that line are the turn and this thought - not the turn twice.
|
|
101
|
+
const thinking = at({ key: "thinking", thinkingAt: 9_000, settled: 50 });
|
|
102
|
+
assert.equal(statusLine(thinking, 12_000, ON), "Thinking… (12s · ↓ 50 tokens · thought for 3s)");
|
|
103
|
+
assert.doesNotMatch(statusLine(at({ key: "read", thinkingAt: 9_000 }), 12_000, ON), /thought for/);
|
|
104
|
+
assert.doesNotMatch(statusLine(at({ key: "thinking" }), 12_000, ON), /thought for/);
|
|
105
|
+
|
|
106
|
+
// Switches, and the both-off case, which must still say what is happening.
|
|
107
|
+
assert.equal(statusLine(at({ settled: 180 }), 5_000, { tokens: false, elapsed: true }), "Working… (5s)");
|
|
108
|
+
assert.equal(statusLine(at({ settled: 180 }), 5_000, { tokens: true, elapsed: false }), "Working… (↓ 180 tokens)");
|
|
109
|
+
assert.equal(statusLine(at({ settled: 180 }), 5_000, { tokens: false, elapsed: false }), "Working…");
|
|
110
|
+
|
|
111
|
+
// Past a minute, seconds alone stop reading as a duration.
|
|
112
|
+
assert.equal(elapsed(59_400), "59s");
|
|
113
|
+
assert.equal(elapsed(60_000), "1m 0s");
|
|
114
|
+
assert.equal(elapsed(205_000), "3m 25s");
|
|
115
|
+
assert.equal(elapsed(-5), "0s", "a clock that went backwards must not print a negative");
|
|
116
|
+
assert.equal(compact(695), "695");
|
|
117
|
+
assert.equal(compact(7_013), "7.0k");
|
|
118
|
+
assert.equal(compact(224_310), "224k");
|
|
119
|
+
|
|
120
|
+
console.log(`umbra-working.check.ts ok - ${KEYS.length} keys, frames 8 cases, settings 9, line 12, format 7`);
|