pi-short-links 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.
@@ -0,0 +1,97 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Loader, matchesKey, Text } from "@earendil-works/pi-tui";
3
+ import { formatTable } from "../lib/format-table.ts";
4
+
5
+ interface DashboardRow {
6
+ component: string;
7
+ role: string;
8
+ }
9
+
10
+ const DEMO_ROWS: DashboardRow[] = [
11
+ { component: "Box", role: "Padded container with optional background" },
12
+ { component: "Loader", role: "Animated spinner while content loads" },
13
+ { component: "Text table", role: "Column-aligned rows via formatTable()" },
14
+ ];
15
+
16
+ async function showDashboard(ctx: ExtensionCommandContext): Promise<void> {
17
+ if (!ctx.hasUI) {
18
+ return;
19
+ }
20
+
21
+ await ctx.ui.custom(
22
+ (tui, theme, _kb, done) => {
23
+ const container = new Container();
24
+ const box = new Box(1, 1, (text) => theme.bg("toolPendingBg", text));
25
+ const title = new Text(theme.fg("accent", theme.bold("Template TUI Dashboard")), 0, 0);
26
+ const tableLines = formatTable(DEMO_ROWS, [
27
+ { header: "Component", render: (row) => row.component },
28
+ { header: "Role", render: (row) => row.role },
29
+ ]);
30
+ const table = new Text(tableLines.map((line) => theme.fg("text", line)).join("\n"), 0, 0);
31
+ const footer = new Text(theme.fg("dim", "Press Enter or Esc to close"), 0, 0);
32
+ const loader = new Loader(
33
+ tui,
34
+ (text) => theme.fg("accent", text),
35
+ (text) => theme.fg("muted", text),
36
+ "Composing dashboard…",
37
+ );
38
+
39
+ box.addChild(title);
40
+ box.addChild(loader);
41
+ container.addChild(box);
42
+
43
+ loader.start();
44
+
45
+ let closed = false;
46
+ let timer: ReturnType<typeof setTimeout> | undefined;
47
+ const close = () => {
48
+ if (closed) {
49
+ return;
50
+ }
51
+ closed = true;
52
+ if (timer !== undefined) {
53
+ clearTimeout(timer);
54
+ }
55
+ loader.stop();
56
+ done(undefined);
57
+ };
58
+
59
+ timer = setTimeout(() => {
60
+ if (closed) {
61
+ return;
62
+ }
63
+ loader.stop();
64
+ box.removeChild(loader);
65
+ box.addChild(table);
66
+ box.addChild(footer);
67
+ tui.requestRender();
68
+ }, 1200);
69
+
70
+ return {
71
+ render: (width: number) => container.render(width),
72
+ invalidate: () => container.invalidate(),
73
+ handleInput: (data: string) => {
74
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
75
+ close();
76
+ }
77
+ },
78
+ };
79
+ },
80
+ { overlay: true },
81
+ );
82
+ }
83
+
84
+ export default function (pi: ExtensionAPI) {
85
+ pi.on("session_start", async (_event, ctx) => {
86
+ if (ctx.hasUI) {
87
+ ctx.ui.setStatus("template-tui", "dashboard ready");
88
+ }
89
+ });
90
+
91
+ pi.registerCommand("template-dashboard", {
92
+ description: "Show composed TUI components (Box, Loader, table)",
93
+ handler: async (_args, ctx) => {
94
+ await showDashboard(ctx);
95
+ },
96
+ });
97
+ }
@@ -0,0 +1,46 @@
1
+ export type ConfigFieldRule =
2
+ | { type: "string"; minLength: number }
3
+ | { type: "boolean" };
4
+
5
+ export const packageLayoutConfigRules = {
6
+ title: { type: "string", minLength: 1 },
7
+ statusLabel: { type: "string", minLength: 1 },
8
+ showSkillHint: { type: "boolean" },
9
+ } as const satisfies Record<string, ConfigFieldRule>;
10
+
11
+ export const packageLayoutConfigSchema = {
12
+ type: "object",
13
+ additionalProperties: false,
14
+ required: Object.keys(packageLayoutConfigRules),
15
+ properties: packageLayoutConfigRules,
16
+ } as const;
17
+
18
+ export type PackageLayoutConfig = {
19
+ [Key in keyof typeof packageLayoutConfigRules]:
20
+ (typeof packageLayoutConfigRules)[Key]["type"] extends "string" ? string : boolean;
21
+ };
22
+
23
+ export function parsePackageLayoutConfig(input: unknown): PackageLayoutConfig {
24
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
25
+ throw new TypeError("Package layout config must be an object");
26
+ }
27
+ const record = input as Record<string, unknown>;
28
+ const prototype = Object.getPrototypeOf(input);
29
+ if (prototype !== Object.prototype && prototype !== null) {
30
+ throw new TypeError("Package layout config must be a plain object");
31
+ }
32
+ const allowed = new Set(Object.keys(packageLayoutConfigRules));
33
+ for (const key of Object.keys(record)) {
34
+ if (!allowed.has(key)) throw new TypeError(`Unknown package layout config field: ${key}`);
35
+ }
36
+ for (const [key, rule] of Object.entries(packageLayoutConfigRules)) {
37
+ const value = record[key];
38
+ if (!Object.hasOwn(record, key) || typeof value !== rule.type) {
39
+ throw new TypeError(`${key} must be ${rule.type}`);
40
+ }
41
+ if (rule.type === "string" && typeof value === "string" && value.length < rule.minLength) {
42
+ throw new TypeError(`${key} must contain at least ${rule.minLength} character`);
43
+ }
44
+ }
45
+ return record as PackageLayoutConfig;
46
+ }
@@ -0,0 +1,72 @@
1
+ export type KnownErrorKind = "missing" | "invalid" | "conflict" | "permission" | "network";
2
+
3
+ export type ClassifiedError = {
4
+ kind: KnownErrorKind;
5
+ exitCode: number;
6
+ retryable: boolean;
7
+ message: string;
8
+ };
9
+
10
+ type CodeCarrier = Error & { code?: unknown };
11
+
12
+ const ERROR_CODES = {
13
+ missing: new Set(["ENOENT"]),
14
+ conflict: new Set(["EEXIST", "ENOTEMPTY"]),
15
+ permission: new Set(["EACCES", "EPERM"]),
16
+ network: new Set(["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND"]),
17
+ } as const;
18
+
19
+ const ERROR_EXIT_CODES = {
20
+ missing: 66,
21
+ invalid: 65,
22
+ conflict: 73,
23
+ permission: 77,
24
+ network: 75,
25
+ } as const satisfies Record<KnownErrorKind, number>;
26
+
27
+ function errorCode(error: unknown): string | undefined {
28
+ if (!error || typeof error !== "object") return undefined;
29
+ const code = (error as CodeCarrier).code;
30
+ return typeof code === "string" ? code : undefined;
31
+ }
32
+
33
+ function knownKindForCode(code: string | undefined): KnownErrorKind | undefined {
34
+ if (!code) return undefined;
35
+ for (const [kind, codes] of Object.entries(ERROR_CODES)) {
36
+ if ((codes as ReadonlySet<string>).has(code)) return kind as KnownErrorKind;
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function isInvalidError(error: unknown): boolean {
42
+ return error instanceof SyntaxError || error instanceof TypeError || error instanceof RangeError;
43
+ }
44
+
45
+ export function classifyErrorForCli(error: unknown): ClassifiedError {
46
+ const kind = knownKindForCode(errorCode(error)) ?? (isInvalidError(error) ? "invalid" : undefined);
47
+ if (!kind) throw error;
48
+
49
+ const message = error instanceof Error && error.message ? error.message : kind;
50
+ return {
51
+ kind,
52
+ exitCode: ERROR_EXIT_CODES[kind],
53
+ retryable: kind === "network",
54
+ message: `${kind}: ${message}`,
55
+ };
56
+ }
57
+
58
+ export async function fallbackAfterCanonicalRecheck<T>(
59
+ action: () => Promise<T>,
60
+ readCanonical: () => Promise<T | undefined>,
61
+ fallback: () => Promise<T>,
62
+ ): Promise<T> {
63
+ try {
64
+ return await action();
65
+ } catch (error) {
66
+ const classified = classifyErrorForCli(error);
67
+ if (classified.kind !== "missing") throw error;
68
+ const canonical = await readCanonical();
69
+ if (canonical !== undefined) return canonical;
70
+ return fallback();
71
+ }
72
+ }
@@ -0,0 +1,46 @@
1
+ export interface TableColumn<T> {
2
+ header: string;
3
+ width?: number;
4
+ align?: "left" | "right";
5
+ render: (row: T) => string;
6
+ }
7
+
8
+ export interface FormatTableOptions {
9
+ gap?: number;
10
+ }
11
+
12
+ function padCell(value: string, width: number, align: "left" | "right"): string {
13
+ if (value.length >= width) {
14
+ return value.slice(0, width);
15
+ }
16
+
17
+ const padding = " ".repeat(width - value.length);
18
+ return align === "right" ? `${padding}${value}` : `${value}${padding}`;
19
+ }
20
+
21
+ export function formatTable<T>(
22
+ rows: T[],
23
+ columns: TableColumn<T>[],
24
+ options: FormatTableOptions = {},
25
+ ): string[] {
26
+ const gap = options.gap ?? 2;
27
+ const gapText = " ".repeat(gap);
28
+ const renderedRows = rows.map((row) => columns.map((column) => column.render(row)));
29
+
30
+ const widths = columns.map((column, colIndex) => {
31
+ const headerWidth = column.header.length;
32
+ const dataWidth = renderedRows.reduce((max, row) => Math.max(max, row[colIndex]!.length), 0);
33
+ return column.width ?? Math.max(headerWidth, dataWidth);
34
+ });
35
+
36
+ const formatRow = (cells: string[]) =>
37
+ cells
38
+ .map((cell, index) => padCell(cell, widths[index]!, columns[index]!.align ?? "left"))
39
+ .join(gapText);
40
+
41
+ const header = formatRow(columns.map((column) => column.header));
42
+ const divider = formatRow(widths.map((width) => "─".repeat(width)));
43
+ const body = renderedRows.map((row) => formatRow(row));
44
+
45
+ return [header, divider, ...body];
46
+ }
@@ -0,0 +1,16 @@
1
+ export type GreetingMode = "short" | "friendly";
2
+
3
+ export interface GreetingInput {
4
+ name: string;
5
+ mode: GreetingMode;
6
+ }
7
+
8
+ export function formatGreeting(input: GreetingInput): string {
9
+ const name = input.name.trim() || "Pi";
10
+
11
+ if (input.mode === "friendly") {
12
+ return `Hello, ${name}! Your TypeScript-first Pi package is working.`;
13
+ }
14
+
15
+ return `Hello, ${name}!`;
16
+ }
package/lib/shorten.ts ADDED
@@ -0,0 +1,177 @@
1
+ import * as os from "node:os";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ const URL_RE = /https?:\/\/[^\s<>\[\]()"`']+/g;
5
+ const ABS_PATH_RE = /(?:^|[\s`"'(])((?:\/|~\/)[^\s<>\[\]()"`']+)/g;
6
+ const MD_LINK_RE = /\[[^\]]*\]\([^)]+\)/g;
7
+
8
+ export type ShortenOptions = {
9
+ maxWidth: number;
10
+ homeDir?: string;
11
+ cwd?: string;
12
+ };
13
+
14
+ export function getMaxLabelWidth(columns: number | undefined = process.stdout.columns): number {
15
+ const cols = typeof columns === "number" && columns > 0 ? columns : 80;
16
+ // Leave room for transcript chrome / markdown punctuation.
17
+ return Math.max(24, cols - 8);
18
+ }
19
+
20
+ export function isTruthyEnv(value: string | undefined): boolean {
21
+ return /^(1|true|yes)$/i.test(value ?? "");
22
+ }
23
+
24
+ export function shouldForceHyperlinks(
25
+ env: NodeJS.ProcessEnv = process.env,
26
+ ): boolean {
27
+ if (isTruthyEnv(env.FORCE_HYPERLINK)) return true;
28
+ const term = env.TERM_PROGRAM?.toLowerCase() ?? "";
29
+ return term === "orca";
30
+ }
31
+
32
+ function stripTrailingPunctuation(value: string): { core: string; trailing: string } {
33
+ let end = value.length;
34
+ while (end > 0 && /[.,;:!?)\]}'"]/.test(value[end - 1]!)) {
35
+ end -= 1;
36
+ }
37
+ return { core: value.slice(0, end), trailing: value.slice(end) };
38
+ }
39
+
40
+ export function formatUrlLabel(url: string, maxWidth: number): string {
41
+ try {
42
+ const parsed = new URL(url);
43
+ const host = parsed.host;
44
+ const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
45
+ const tailSource = path === "/" ? "" : path.replace(/\/+$/, "");
46
+ const tail = tailSource.slice(-12);
47
+ if (!tail) {
48
+ return host.length <= maxWidth ? host : `${host.slice(0, Math.max(1, maxWidth - 1))}…`;
49
+ }
50
+ const candidate = `${host}/…${tail.replace(/^\//, "")}`;
51
+ if (candidate.length <= maxWidth) return candidate;
52
+ const budget = Math.max(8, maxWidth - 1);
53
+ return `${candidate.slice(0, budget)}…`;
54
+ } catch {
55
+ if (url.length <= maxWidth) return url;
56
+ return `${url.slice(0, Math.max(1, maxWidth - 1))}…`;
57
+ }
58
+ }
59
+
60
+ export function formatPathLabel(
61
+ rawPath: string,
62
+ maxWidth: number,
63
+ homeDir: string = os.homedir(),
64
+ ): string {
65
+ let display = rawPath;
66
+ if (rawPath.startsWith(homeDir)) {
67
+ display = `~${rawPath.slice(homeDir.length)}`;
68
+ }
69
+
70
+ if (display.length <= maxWidth) return display;
71
+
72
+ const isHome = display.startsWith("~/");
73
+ const isAbs = display.startsWith("/");
74
+ const body = isHome ? display.slice(2) : isAbs ? display.slice(1) : display;
75
+ const parts = body.split("/").filter(Boolean);
76
+ const file = parts.at(-1) ?? body;
77
+ const first = parts[0];
78
+
79
+ let candidate: string;
80
+ if (isHome && first && first !== file) {
81
+ candidate = `~/${first}/…/${file}`;
82
+ } else if (isHome) {
83
+ candidate = `~/…/${file}`;
84
+ } else if (isAbs && first && first !== file) {
85
+ candidate = `/${first}/…/${file}`;
86
+ } else if (isAbs) {
87
+ candidate = `/…/${file}`;
88
+ } else {
89
+ candidate = `…/${file}`;
90
+ }
91
+
92
+ if (candidate.length <= maxWidth) return candidate;
93
+ const budget = Math.max(8, maxWidth - 1);
94
+ return `${candidate.slice(0, budget)}…`;
95
+ }
96
+
97
+ function toFileHref(rawPath: string, homeDir: string, cwd?: string): string {
98
+ let absolute = rawPath;
99
+ if (rawPath.startsWith("~/")) {
100
+ absolute = `${homeDir}${rawPath.slice(1)}`;
101
+ } else if (!rawPath.startsWith("/") && cwd) {
102
+ absolute = `${cwd.replace(/\/+$/, "")}/${rawPath}`;
103
+ }
104
+ return pathToFileURL(absolute).href;
105
+ }
106
+
107
+ function protectMatches(text: string, pattern: RegExp): {
108
+ text: string;
109
+ slots: string[];
110
+ } {
111
+ const slots: string[] = [];
112
+ const next = text.replace(pattern, (match) => {
113
+ const idx = slots.length;
114
+ slots.push(match);
115
+ return `\u0000MD${idx}\u0000`;
116
+ });
117
+ return { text: next, slots };
118
+ }
119
+
120
+ function restoreSlots(text: string, slots: string[]): string {
121
+ return text.replace(/\u0000MD(\d+)\u0000/g, (_m, idx) => slots[Number(idx)] ?? "");
122
+ }
123
+
124
+ function rewritePlainSegment(segment: string, options: ShortenOptions): string {
125
+ const homeDir = options.homeDir ?? os.homedir();
126
+ const maxWidth = options.maxWidth;
127
+
128
+ const protectedMd = protectMatches(segment, MD_LINK_RE);
129
+ let text = protectedMd.text;
130
+
131
+ text = text.replace(URL_RE, (raw) => {
132
+ const { core, trailing } = stripTrailingPunctuation(raw);
133
+ if (core.length <= maxWidth) return raw;
134
+ const label = formatUrlLabel(core, maxWidth);
135
+ return `[${label}](${core})${trailing}`;
136
+ });
137
+
138
+ text = text.replace(ABS_PATH_RE, (full, pathPart: string) => {
139
+ const prefix = full.slice(0, full.length - pathPart.length);
140
+ const { core, trailing } = stripTrailingPunctuation(pathPart);
141
+ if (core.length <= maxWidth) return full;
142
+ // Skip URL-looking leftovers.
143
+ if (/^https?:/i.test(core)) return full;
144
+ const label = formatPathLabel(core, maxWidth, homeDir);
145
+ const href = toFileHref(core, homeDir, options.cwd);
146
+ return `${prefix}[${label}](${href})${trailing}`;
147
+ });
148
+
149
+ return restoreSlots(text, protectedMd.slots);
150
+ }
151
+
152
+ /**
153
+ * Rewrite long bare URLs/paths outside fenced code blocks into short markdown links.
154
+ */
155
+ export function shortenAssistantText(input: string, options: ShortenOptions): string {
156
+ if (!input || options.maxWidth < 8) return input;
157
+
158
+ const parts: string[] = [];
159
+ let i = 0;
160
+ while (i < input.length) {
161
+ const open = input.indexOf("```", i);
162
+ if (open === -1) {
163
+ parts.push(rewritePlainSegment(input.slice(i), options));
164
+ break;
165
+ }
166
+ parts.push(rewritePlainSegment(input.slice(i, open), options));
167
+ const close = input.indexOf("```", open + 3);
168
+ if (close === -1) {
169
+ parts.push(input.slice(open));
170
+ break;
171
+ }
172
+ parts.push(input.slice(open, close + 3));
173
+ i = close + 3;
174
+ }
175
+
176
+ return parts.join("");
177
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-short-links",
3
+ "version": "0.1.0",
4
+ "description": "Shorten long URLs and file paths in Pi assistant output so OSC 8 / click targets fit the pane width.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "eiei114",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-extension",
12
+ "osc8",
13
+ "hyperlink",
14
+ "typescript"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/eiei114/pi-short-links.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/eiei114/pi-short-links/issues"
22
+ },
23
+ "homepage": "https://github.com/eiei114/pi-short-links#readme",
24
+ "files": [
25
+ "extensions/",
26
+ "lib/",
27
+ "docs/",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "node --test tests/shorten.test.mjs",
35
+ "pack:check": "npm pack --dry-run",
36
+ "ci": "npm run typecheck && npm test && npm run pack:check"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./extensions/short-links.ts"
41
+ ]
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "^0.84.1",
52
+ "@earendil-works/pi-tui": "^0.84.1",
53
+ "@types/node": "^26.0.0",
54
+ "typescript": "^7.0.2"
55
+ }
56
+ }