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,19 @@
|
|
|
1
|
+
# pi-umbra-inputbar
|
|
2
|
+
|
|
3
|
+
pi's input bar, rebuilt: a chevron prompt, the session name on the border, a colour you pick, and a command to rename the session.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi-umbra-inputbar/
|
|
7
|
+
├── checks
|
|
8
|
+
│ └── umbra-inputbar.check.ts
|
|
9
|
+
└── extensions
|
|
10
|
+
└── umbra-inputbar.ts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:pi-umbra-inputbar
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
MIT.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// inputbar.ts: the title splice, the colour resolver, and the editor itself - pi's real
|
|
2
|
+
// CustomEditor under stub tui/theme/keybindings - to prove the name lands on the top border
|
|
3
|
+
// and the /umb-color override outlives pi's own borderColor assignments.
|
|
4
|
+
//
|
|
5
|
+
// Run it with: bun run umbra-inputbar.check.ts
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import inputbar, { InputBar, NAMED, paintWith, resolvePaint, withTitle } from "../extensions/umbra-inputbar.ts";
|
|
12
|
+
|
|
13
|
+
assert.equal(withTitle("─".repeat(20), undefined), "─".repeat(20), "no name, no change");
|
|
14
|
+
const titled = withTitle("─".repeat(20), "π - repo");
|
|
15
|
+
assert.equal(visibleWidth(titled), 20, "same width after the splice");
|
|
16
|
+
assert.ok(titled.endsWith(" π - repo ──"), "label at the right, two dashes after it");
|
|
17
|
+
assert.equal(withTitle("─".repeat(8), "π - a long name"), "─".repeat(8), "no room: untouched");
|
|
18
|
+
const wrapped = withTitle(`\x1b[34m${"─".repeat(30)}\x1b[39m`, "x");
|
|
19
|
+
assert.ok(wrapped.startsWith("\x1b[34m") && wrapped.endsWith(" x ──\x1b[39m"), "label lands inside the colour wrap");
|
|
20
|
+
assert.ok(withTitle("── ✻ working ─────────────────", "s").startsWith("── ✻ working "), "the left-side spinner is left alone");
|
|
21
|
+
// The label carries the footer's colour, so its reset has to be followed by the border's own.
|
|
22
|
+
const painted = withTitle("─".repeat(20), "n", (t) => `[${t}]`, (t) => `{${t}}`);
|
|
23
|
+
assert.ok(painted.endsWith("[ n ]{──}"), "label painted, and the two dashes after it repainted");
|
|
24
|
+
assert.equal(visibleWidth(withTitle("─".repeat(20), "n")), 20, "unpainted by default, so width is unchanged");
|
|
25
|
+
|
|
26
|
+
assert.equal(resolvePaint("off"), undefined, "off restores the theme");
|
|
27
|
+
assert.equal(resolvePaint("orange")!("ab"), "\x1b[38;2;255;137;4mab\x1b[39m", "a palette name paints its fixed truecolor");
|
|
28
|
+
assert.equal(resolvePaint("#ff8800")!("ab"), "\x1b[38;2;255;136;0mab\x1b[39m", "a raw hex still works");
|
|
29
|
+
assert.deepEqual(Object.keys(NAMED), ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"], "the eight names");
|
|
30
|
+
assert.throws(() => resolvePaint("nope"), /unknown colour "nope" - one of: off, red/, "an unknown name lists the palette");
|
|
31
|
+
|
|
32
|
+
// The editor. The stub pi only has to answer getSessionName; on/registerCommand are recorded.
|
|
33
|
+
const commands: Record<string, object> = {};
|
|
34
|
+
inputbar({ on() {}, registerCommand: (name: string, def: object) => (commands[name] = def), getSessionName: () => "π - t" } as never);
|
|
35
|
+
assert.ok(commands["umb-color"], "registers /umb-color");
|
|
36
|
+
const bar = new InputBar({ requestRender() {} } as never, { borderColor: (s: string) => `<${s}>`, selectList: {} } as never, {} as never);
|
|
37
|
+
const render = bar as unknown as { renderTopBorder(w: number, h: number): string; renderBottomBorder(w: number, h: number): string };
|
|
38
|
+
// borderColor is the stub `<...>`: the label sits unpainted (no ui yet), the two dashes after it
|
|
39
|
+
// carry a wrap of their own, and pi's own wrap still closes the line.
|
|
40
|
+
assert.ok(render.renderTopBorder(24, 0).endsWith(" π - t <──>>"), "session name on the top border, tail repainted");
|
|
41
|
+
assert.equal(visibleWidth(render.renderTopBorder(24, 0).replace(/[<>]/g, "")), 24, "top border keeps its width");
|
|
42
|
+
|
|
43
|
+
bar.borderColor = (s) => `[${s}]`; // what pi does on a thinking-level change
|
|
44
|
+
assert.equal(render.renderBottomBorder(4, 0), "[────]", "pi's assignment is honoured while no override is set");
|
|
45
|
+
paintWith("orange");
|
|
46
|
+
assert.equal(render.renderBottomBorder(4, 0), "\x1b[38;2;255;137;4m────\x1b[39m", "/umb-color paints the border");
|
|
47
|
+
bar.borderColor = (s) => `(${s})`; // pi assigns again...
|
|
48
|
+
assert.equal(render.renderBottomBorder(4, 0), "\x1b[38;2;255;137;4m────\x1b[39m", "...and the override still wins");
|
|
49
|
+
paintWith("off");
|
|
50
|
+
assert.equal(render.renderBottomBorder(4, 0), "(────)", "off hands the border back to pi's last colour");
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
console.log("umbra-inputbar.check.ts ok - title splice 8 cases, paint 5 cases, editor: name on border, override beats 2 reassignments");
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The input bar: the session name on its top border (right side) and /umb-color for its border
|
|
2
|
+
// colour. Both ride on pi's own extension point - ctx.ui.setEditorComponent with a subclass of
|
|
3
|
+
// CustomEditor - so no bundle patch is involved. The name is read from pi.getSessionName() at
|
|
4
|
+
// every render, so /umb-rename shows on the next repaint; /umb-color persists in
|
|
5
|
+
// ~/.pi/agent/input-color.
|
|
6
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const COLOR_FILE = join(homedir(), ".pi", "agent", "input-color");
|
|
13
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
14
|
+
// The palette /umb-color offers: the Tailwind 400 family, which reads on every dark theme.
|
|
15
|
+
export const NAMED: Record<string, string> = {
|
|
16
|
+
red: "#ff6467",
|
|
17
|
+
orange: "#ff8904",
|
|
18
|
+
yellow: "#ffb900",
|
|
19
|
+
green: "#00d492",
|
|
20
|
+
cyan: "#00d3f2",
|
|
21
|
+
blue: "#51a2ff",
|
|
22
|
+
purple: "#a78bfa",
|
|
23
|
+
pink: "#fb64b6",
|
|
24
|
+
};
|
|
25
|
+
const NAMES = ["off", ...Object.keys(NAMED)];
|
|
26
|
+
|
|
27
|
+
type Paint = (s: string) => string;
|
|
28
|
+
// The factory type lives on the ui context, not on the package root.
|
|
29
|
+
type EditorFactory = NonNullable<Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0]>;
|
|
30
|
+
let paint: Paint | undefined; // the /umb-color override; undefined = the theme's border colour
|
|
31
|
+
let sessionName: () => string | undefined = () => undefined;
|
|
32
|
+
let ui: ExtensionContext["ui"] | undefined;
|
|
33
|
+
|
|
34
|
+
// Neither the terminal background nor the spinner frames are handled here. umbra-background owns
|
|
35
|
+
// the first and umbra-working the second. Both were written from two files at once for a while,
|
|
36
|
+
// and in each case the last writer won at random.
|
|
37
|
+
|
|
38
|
+
// Right-aligns the label inside the last run of "─" on an already rendered border line, leaving
|
|
39
|
+
// the working spinner pi embeds on the left alone. Too little room: the line is returned unchanged.
|
|
40
|
+
//
|
|
41
|
+
// The label is painted rather than left to inherit the border, so the session name reads in the
|
|
42
|
+
// same colour as the "Working" text at the other end of the row. Its colour reset would hand the
|
|
43
|
+
// rest of the line back to the terminal default, so the two dashes after it are repainted.
|
|
44
|
+
export const withTitle = (
|
|
45
|
+
line: string,
|
|
46
|
+
label: string | undefined,
|
|
47
|
+
paintLabel: Paint = (s) => s,
|
|
48
|
+
paintTail: Paint = (s) => s,
|
|
49
|
+
): string => {
|
|
50
|
+
if (!label) return line;
|
|
51
|
+
const text = ` ${label} `;
|
|
52
|
+
const w = visibleWidth(text);
|
|
53
|
+
return line.replace(/─{2,}(?!.*─)/s, (run) =>
|
|
54
|
+
run.length >= w + 4 ? "─".repeat(run.length - w - 2) + paintLabel(text) + paintTail("──") : run,
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A palette name (or a raw "#rrggbb") paints truecolor; "off" restores the theme's border.
|
|
59
|
+
export const resolvePaint = (spec: string): Paint | undefined => {
|
|
60
|
+
if (spec === "off") return undefined;
|
|
61
|
+
const hex = NAMED[spec] ?? (HEX.test(spec) ? spec : undefined);
|
|
62
|
+
if (!hex) throw new Error(`unknown colour "${spec}" - one of: ${NAMES.join(", ")}`);
|
|
63
|
+
const on = `\x1b[38;2;${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(";")}m`;
|
|
64
|
+
return (s) => `${on}${s}\x1b[39m`;
|
|
65
|
+
};
|
|
66
|
+
export const paintWith = (spec: string): Paint | undefined => (paint = resolvePaint(spec));
|
|
67
|
+
|
|
68
|
+
export class InputBar extends CustomEditor {
|
|
69
|
+
constructor(...args: ConstructorParameters<typeof CustomEditor>) {
|
|
70
|
+
// `prompt` is an editor option, so the chevron needs no patch. pi's default is "> ".
|
|
71
|
+
super(args[0], args[1], args[2], { prompt: "❯ ", ...args[3], embedWorkingStatus: true });
|
|
72
|
+
// pi assigns borderColor again on bash mode and thinking-level changes; the override has to
|
|
73
|
+
// win every time, so the field becomes an accessor over whatever pi last assigned.
|
|
74
|
+
let stored = this.borderColor;
|
|
75
|
+
Object.defineProperty(this, "borderColor", {
|
|
76
|
+
get: () => paint ?? stored,
|
|
77
|
+
set: (fn: Paint) => {
|
|
78
|
+
stored = fn;
|
|
79
|
+
},
|
|
80
|
+
configurable: true,
|
|
81
|
+
enumerable: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
protected override renderTopBorder(width: number, hiddenLineCount: number): string {
|
|
85
|
+
return withTitle(
|
|
86
|
+
super.renderTopBorder(width, hiddenLineCount),
|
|
87
|
+
sessionName(),
|
|
88
|
+
(text) => ui?.theme.fg("muted", text) ?? text,
|
|
89
|
+
(text) => this.borderColor(text),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const factory: EditorFactory = (tui, theme, keybindings) => new InputBar(tui, theme, keybindings);
|
|
95
|
+
|
|
96
|
+
export default function (pi: ExtensionAPI) {
|
|
97
|
+
sessionName = () => pi.getSessionName();
|
|
98
|
+
pi.on("session_start", (_event, ctx) => {
|
|
99
|
+
ui = ctx.ui;
|
|
100
|
+
ctx.ui.setEditorComponent(factory);
|
|
101
|
+
if (!existsSync(COLOR_FILE)) return;
|
|
102
|
+
try {
|
|
103
|
+
paintWith(readFileSync(COLOR_FILE, "utf8").trim());
|
|
104
|
+
} catch {
|
|
105
|
+
paint = undefined;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
pi.registerCommand("umb-color", {
|
|
109
|
+
description: "input bar colour: /umb-color red | orange | yellow | green | cyan | blue | purple | pink | off",
|
|
110
|
+
getArgumentCompletions(text) {
|
|
111
|
+
const items = NAMES.filter((n) => n.startsWith(text.trim())).map((n) => ({ value: n, label: n }));
|
|
112
|
+
return items.length ? items : null;
|
|
113
|
+
},
|
|
114
|
+
async handler(args, ctx) {
|
|
115
|
+
const spec = args.trim();
|
|
116
|
+
if (!spec) {
|
|
117
|
+
const now = existsSync(COLOR_FILE) ? readFileSync(COLOR_FILE, "utf8").trim() : "theme border";
|
|
118
|
+
return ctx.ui.notify(`input bar: ${now} - /umb-color ${NAMES.join(" | ")}`);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
paintWith(spec);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return ctx.ui.notify((error as Error).message, "error");
|
|
124
|
+
}
|
|
125
|
+
if (paint) writeFileSync(COLOR_FILE, `${spec}\n`);
|
|
126
|
+
else if (existsSync(COLOR_FILE)) unlinkSync(COLOR_FILE);
|
|
127
|
+
ctx.ui.notify(`input bar: ${spec}`);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-umbra-inputbar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi's input bar, rebuilt: a chevron prompt, the session name on the border, a colour you pick, and a command to rename the session.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"tui",
|
|
10
|
+
"editor",
|
|
11
|
+
"prompt"
|
|
12
|
+
],
|
|
13
|
+
"author": "grkn",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/grknbyk/pi-umbra.git",
|
|
18
|
+
"directory": "pi-umbra-inputbar"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-inputbar#readme",
|
|
21
|
+
"bugs": "https://github.com/grknbyk/pi-umbra/issues",
|
|
22
|
+
"files": [
|
|
23
|
+
"extensions",
|
|
24
|
+
"checks",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,32 @@
|
|
|
1
|
+
# pi-umbra-preview
|
|
2
|
+
|
|
3
|
+
Render a document with pandoc and open it in the browser, from inside pi.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi-umbra-preview/
|
|
7
|
+
├── checks
|
|
8
|
+
│ └── umbra-preview.check.ts
|
|
9
|
+
└── extensions
|
|
10
|
+
└── umbra-preview.ts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Needs pandoc
|
|
14
|
+
|
|
15
|
+
Not bundled, never installed for you. It is looked for when a preview is asked for, not at
|
|
16
|
+
load, so a missing pandoc costs one message rather than a failed extension.
|
|
17
|
+
|
|
18
|
+
| | |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Windows | `winget install --id JohnMacFarlane.Pandoc -e` |
|
|
21
|
+
| macOS | `brew install pandoc` |
|
|
22
|
+
| Linux | `sudo apt install pandoc` |
|
|
23
|
+
|
|
24
|
+
A PDF or an HTML file opens directly and needs no pandoc.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pi install npm:pi-umbra-preview
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
MIT.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The two things in preview.ts that can be wrong without anything failing: the path a user typed
|
|
2
|
+
// is resolved to the wrong file, and pandoc is reported missing on a machine that has it.
|
|
3
|
+
//
|
|
4
|
+
// bun run umbra-preview.check.ts
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { findPandoc, resolvePath } from "../extensions/umbra-preview.ts";
|
|
8
|
+
|
|
9
|
+
const cwd = process.platform === "win32" ? "C:\\work" : "/work";
|
|
10
|
+
|
|
11
|
+
// pi writes `@path` when a file is picked from its own completion, and a shell leaves quotes on a
|
|
12
|
+
// path with a space in it. Both reach the handler as typed.
|
|
13
|
+
assert.equal(resolvePath("notes.md", cwd), resolve(cwd, "notes.md"), "a relative path joins cwd");
|
|
14
|
+
assert.equal(resolvePath("@notes.md", cwd), resolve(cwd, "notes.md"), "the @ prefix is pi's, not the filename's");
|
|
15
|
+
assert.equal(resolvePath(' "a b.md" ', cwd), resolve(cwd, "a b.md"), "quotes and padding come off");
|
|
16
|
+
assert.equal(resolvePath(join(cwd, "x.md"), cwd), join(cwd, "x.md"), "an absolute path is left alone");
|
|
17
|
+
|
|
18
|
+
// PATH wins, and nothing else is looked at when it answers.
|
|
19
|
+
const onPath = await findPandoc(
|
|
20
|
+
() => {
|
|
21
|
+
throw new Error("the fallback list must not be read when PATH answers");
|
|
22
|
+
},
|
|
23
|
+
async () => true,
|
|
24
|
+
);
|
|
25
|
+
assert.equal(onPath, "pandoc", "a pandoc on PATH is used as a bare command");
|
|
26
|
+
|
|
27
|
+
// The reason the fallback list exists: pandoc's own installer puts it somewhere the running shell
|
|
28
|
+
// does not know about yet, so the first preview after installing must still work.
|
|
29
|
+
const installed = "C:\\Users\\x\\AppData\\Local\\Pandoc\\pandoc.exe";
|
|
30
|
+
const found = await findPandoc((path) => path.endsWith("pandoc.exe") || path === "/usr/bin/pandoc", async () => false);
|
|
31
|
+
assert.ok(found !== undefined, "a pandoc off PATH must still be found");
|
|
32
|
+
assert.ok(found !== "pandoc", "an off-PATH pandoc is used by its full path, not by name");
|
|
33
|
+
|
|
34
|
+
// Nowhere on this machine: the caller has to be told, not handed a command that will not start.
|
|
35
|
+
assert.equal(await findPandoc(() => false, async () => false), undefined, "no pandoc means no pandoc");
|
|
36
|
+
|
|
37
|
+
void installed;
|
|
38
|
+
console.log("umbra-preview: all checks passed");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Reading a Markdown file in a terminal means reading its source. This renders it instead: pandoc
|
|
2
|
+
// turns the document into one self-contained HTML file and the system browser opens it.
|
|
3
|
+
//
|
|
4
|
+
// pandoc is not bundled and is never installed for you. It is looked for when a preview is asked
|
|
5
|
+
// for, not at load, so a missing pandoc costs one message rather than a failed extension. The
|
|
6
|
+
// message names the install command for the platform it is printed on.
|
|
7
|
+
//
|
|
8
|
+
// A PDF or an HTML file is opened directly. pandoc reads neither, and converting a PDF to HTML to
|
|
9
|
+
// look at a PDF is work with nothing at the end of it.
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
13
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
/** Formats the browser opens as they are. Converting these would lose more than it gained. */
|
|
17
|
+
const DIRECT = new Set([".pdf", ".html", ".htm", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
18
|
+
|
|
19
|
+
/** Everything pandoc is asked to read. An unlisted extension is still tried, and pandoc says no. */
|
|
20
|
+
const MARKDOWN = new Set([".md", ".markdown", ".mdx"]);
|
|
21
|
+
|
|
22
|
+
// Where pandoc lands when its own installer runs and the shell has not been restarted, so the
|
|
23
|
+
// first preview after `winget install` works instead of reporting a missing program that is there.
|
|
24
|
+
const FALLBACK_PATHS: Record<string, string[]> = {
|
|
25
|
+
win32: [
|
|
26
|
+
join(homedir(), "AppData", "Local", "Pandoc", "pandoc.exe"),
|
|
27
|
+
"C:\\Program Files\\Pandoc\\pandoc.exe",
|
|
28
|
+
],
|
|
29
|
+
darwin: ["/usr/local/bin/pandoc", "/opt/homebrew/bin/pandoc"],
|
|
30
|
+
linux: ["/usr/bin/pandoc", "/usr/local/bin/pandoc"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const INSTALL: Record<string, string> = {
|
|
34
|
+
win32: "winget install --id JohnMacFarlane.Pandoc -e",
|
|
35
|
+
darwin: "brew install pandoc",
|
|
36
|
+
linux: "sudo apt install pandoc",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Mermaid is a diagram language pandoc has no renderer for: it passes the block through as code.
|
|
40
|
+
// Loading mermaid's own script in the produced page is what draws it, and the block only carries
|
|
41
|
+
// its weight when the document actually has a diagram in it.
|
|
42
|
+
const MERMAID_HEADER = `<script type="module">
|
|
43
|
+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
|
44
|
+
for (const block of document.querySelectorAll("pre.mermaid > code, code.language-mermaid")) {
|
|
45
|
+
const host = document.createElement("pre");
|
|
46
|
+
host.className = "mermaid";
|
|
47
|
+
host.textContent = block.textContent;
|
|
48
|
+
(block.closest("pre") ?? block).replaceWith(host);
|
|
49
|
+
}
|
|
50
|
+
mermaid.initialize({ startOnLoad: true });
|
|
51
|
+
</script>
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const run = (command: string, args: string[]): Promise<{ code: number; stderr: string }> =>
|
|
55
|
+
new Promise((done) => {
|
|
56
|
+
const child = spawn(command, args, { windowsHide: true });
|
|
57
|
+
let stderr = "";
|
|
58
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
59
|
+
child.on("error", () => done({ code: -1, stderr: "could not start " + command }));
|
|
60
|
+
child.on("close", (code) => done({ code: code ?? -1, stderr }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The pandoc this machine has, or undefined. PATH first, then where the installer puts it. */
|
|
64
|
+
export const findPandoc = async (
|
|
65
|
+
exists: (path: string) => boolean = existsSync,
|
|
66
|
+
probe: (command: string) => Promise<boolean> = async (command) =>
|
|
67
|
+
(await run(command, ["--version"])).code === 0,
|
|
68
|
+
): Promise<string | undefined> => {
|
|
69
|
+
if (await probe("pandoc")) return "pandoc";
|
|
70
|
+
return (FALLBACK_PATHS[platform()] ?? []).find(exists);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Opening a file is one command per platform and none of them take the same arguments. */
|
|
74
|
+
const openInBrowser = (path: string): void => {
|
|
75
|
+
const [command, args] =
|
|
76
|
+
platform() === "win32"
|
|
77
|
+
? ["cmd", ["/c", "start", "", path]]
|
|
78
|
+
: platform() === "darwin"
|
|
79
|
+
? ["open", [path]]
|
|
80
|
+
: ["xdg-open", [path]];
|
|
81
|
+
spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The path a user typed, which may be relative, quoted, or prefixed with pi's own `@`. */
|
|
85
|
+
export const resolvePath = (argument: string, cwd: string): string => {
|
|
86
|
+
const bare = argument.trim().replace(/^@/, "").replace(/^["']|["']$/g, "");
|
|
87
|
+
return isAbsolute(bare) ? bare : resolve(cwd, bare);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default function preview(pi: ExtensionAPI) {
|
|
91
|
+
const out = join(tmpdir(), "pi-umbra-preview");
|
|
92
|
+
|
|
93
|
+
const show = async (argument: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
94
|
+
if (argument.trim() === "") {
|
|
95
|
+
ctx.ui.notify("Which file? /umb-preview <path>", "warning");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const source = resolvePath(argument, process.cwd());
|
|
99
|
+
if (!existsSync(source)) {
|
|
100
|
+
ctx.ui.notify(`No such file: ${source}`, "error");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const extension = extname(source).toLowerCase();
|
|
105
|
+
if (DIRECT.has(extension)) {
|
|
106
|
+
openInBrowser(source);
|
|
107
|
+
ctx.ui.notify(`Opened ${basename(source)}.`, "info");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pandoc = await findPandoc();
|
|
112
|
+
if (pandoc === undefined) {
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
`pandoc is needed to render ${extension || "this file"} and was not found. Install it with: ${INSTALL[platform()] ?? "your package manager"}`,
|
|
115
|
+
"warning",
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
mkdirSync(out, { recursive: true });
|
|
121
|
+
const target = join(out, `${basename(source, extension)}.html`);
|
|
122
|
+
// --standalone gives a whole page rather than a fragment, --embed-resources inlines the
|
|
123
|
+
// images so the file in the temp directory still shows them, and math is rendered by the
|
|
124
|
+
// browser. `--mathml` is the spelling pandoc deprecated; this one is the current name.
|
|
125
|
+
const args = [source, "-o", target, "--standalone", "--embed-resources", "--math-method=mathml"];
|
|
126
|
+
|
|
127
|
+
// Only markdown gets the mermaid loader, and only when a diagram is actually in the file.
|
|
128
|
+
if (MARKDOWN.has(extension) && readFileSync(source, "utf8").includes("```mermaid")) {
|
|
129
|
+
const header = join(out, "mermaid.html");
|
|
130
|
+
writeFileSync(header, MERMAID_HEADER);
|
|
131
|
+
args.push("--include-in-header", header);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { code, stderr } = await run(pandoc, args);
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
ctx.ui.notify(`pandoc failed: ${stderr.split("\n")[0] ?? `exit ${code}`}`, "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
openInBrowser(target);
|
|
140
|
+
ctx.ui.notify(`Rendered ${basename(source)} and opened it.`, "info");
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
pi.registerCommand("umb-preview", {
|
|
144
|
+
description: "Render a document with pandoc and open it in the browser: /umb-preview <path>",
|
|
145
|
+
handler: show,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-umbra-preview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render a document with pandoc and open it in the browser, from inside pi.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pandoc",
|
|
10
|
+
"preview",
|
|
11
|
+
"markdown"
|
|
12
|
+
],
|
|
13
|
+
"author": "grkn",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/grknbyk/pi-umbra.git",
|
|
18
|
+
"directory": "pi-umbra-preview"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-preview#readme",
|
|
21
|
+
"bugs": "https://github.com/grknbyk/pi-umbra/issues",
|
|
22
|
+
"files": [
|
|
23
|
+
"extensions",
|
|
24
|
+
"checks",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,19 @@
|
|
|
1
|
+
# pi-umbra-rename
|
|
2
|
+
|
|
3
|
+
One command that renames a pi session and the terminal window title together, which pi keeps behind two separate APIs.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi-umbra-rename/
|
|
7
|
+
├── checks
|
|
8
|
+
│ └── umbra-rename.check.ts
|
|
9
|
+
└── extensions
|
|
10
|
+
└── umbra-rename.ts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:pi-umbra-rename
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
MIT.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// /umb-rename writes two names through two different APIs, and the terminal title is built from
|
|
2
|
+
// a third value. A fake pi is enough to pin all three, because the command is the whole extension.
|
|
3
|
+
//
|
|
4
|
+
// bun run umbra-rename.check.ts
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
import rename from "../extensions/umbra-rename.ts";
|
|
7
|
+
|
|
8
|
+
type Handler = (args: string, ctx: unknown) => Promise<void>;
|
|
9
|
+
|
|
10
|
+
const load = (sessionName?: string) => {
|
|
11
|
+
const calls = { name: undefined as string | undefined, title: undefined as string | undefined, notice: "" };
|
|
12
|
+
let handler: Handler | undefined;
|
|
13
|
+
const pi = {
|
|
14
|
+
registerCommand: (_name: string, options: { handler: Handler }) => void (handler = options.handler),
|
|
15
|
+
getSessionName: () => sessionName,
|
|
16
|
+
setSessionName: (value: string) => void (calls.name = value),
|
|
17
|
+
};
|
|
18
|
+
rename(pi as never);
|
|
19
|
+
const ctx = {
|
|
20
|
+
ui: {
|
|
21
|
+
setTitle: (value: string) => void (calls.title = value),
|
|
22
|
+
notify: (value: string) => void (calls.notice = value),
|
|
23
|
+
input: async () => prompted,
|
|
24
|
+
},
|
|
25
|
+
sessionManager: { getCwd: () => "/home/me/projects/real-estate" },
|
|
26
|
+
};
|
|
27
|
+
return { calls, run: (args: string) => handler!(args, ctx) };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let prompted: string | undefined;
|
|
31
|
+
|
|
32
|
+
// The argument form. The prefix goes on the terminal title only: pi already spells the session
|
|
33
|
+
// name on the input bar, so a π in the name itself would show up three times in two places.
|
|
34
|
+
const given = load();
|
|
35
|
+
await given.run(" ledger ");
|
|
36
|
+
assert.equal(given.calls.name, "ledger", "the name is trimmed and carries no prefix");
|
|
37
|
+
assert.equal(given.calls.title, "π - ledger - real-estate", "the title carries the prefix and the cwd");
|
|
38
|
+
assert.equal(given.calls.notice, "renamed: ledger");
|
|
39
|
+
|
|
40
|
+
// No argument: pi asks, and the current name is the prefill.
|
|
41
|
+
prompted = "ledger";
|
|
42
|
+
const asked = load("old-name");
|
|
43
|
+
await asked.run("");
|
|
44
|
+
assert.equal(asked.calls.name, "ledger", "the answer to the prompt is the new name");
|
|
45
|
+
|
|
46
|
+
// Nothing typed, or the prompt dismissed, must leave both names alone rather than blanking them.
|
|
47
|
+
for (const answer of [undefined, "", " "]) {
|
|
48
|
+
prompted = answer;
|
|
49
|
+
const cancelled = load("old-name");
|
|
50
|
+
await cancelled.run("");
|
|
51
|
+
assert.equal(cancelled.calls.name, undefined, `"${answer}" must not rename`);
|
|
52
|
+
assert.equal(cancelled.calls.title, undefined, `"${answer}" must not retitle`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log("umbra-rename.check.ts ok - argument form, prompt form, 3 cancel cases");
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
// /umb-rename sets both names at once: the session display name (session selector,
|
|
5
|
+
// /resume) and the terminal window/tab title, which are separate APIs in pi.
|
|
6
|
+
export default function (pi: ExtensionAPI) {
|
|
7
|
+
pi.registerCommand("umb-rename", {
|
|
8
|
+
description: "Rename this session and the terminal title: /umb-rename <name>",
|
|
9
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
10
|
+
// setSessionName lives on the API object; setTitle lives on the command context's UI.
|
|
11
|
+
let newName = args.trim() || (await ctx.ui.input("Rename session", pi.getSessionName() ?? ""))?.trim();
|
|
12
|
+
if (!newName) return;
|
|
13
|
+
|
|
14
|
+
// The prefix belongs to the terminal title only. pi builds that title itself as
|
|
15
|
+
// `π - <session name> - <cwd>`, so putting π in the session name too spelled it twice
|
|
16
|
+
// in the tab and once more on the input bar, which shows the session name alone.
|
|
17
|
+
pi.setSessionName(newName);
|
|
18
|
+
ctx.ui.setTitle(`π - ${newName} - ${basename(ctx.sessionManager.getCwd())}`);
|
|
19
|
+
ctx.ui.notify(`renamed: ${newName}`);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|