@caph42/pi-ccline 0.0.0-stage → 0.1.1

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,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+
6
+ import { extensionStatusLine, loadingNotice, missingBinaryNotice, normalizeFrame, noticeFrame, sanitizeStatusText, withStaleMarker } from "./render.ts";
7
+
8
+ const RED = "\u001b[31m";
9
+ const RESET = "\u001b[0m";
10
+
11
+ test("splits ccline output into footer lines and drops trailing blanks", () => {
12
+ const stdout = `${RED}one${RESET}\n${RED}two${RESET}\n\n`;
13
+ assert.deepEqual(normalizeFrame(stdout, 80), [`${RED}one${RESET}`, `${RED}two${RESET}`]);
14
+ });
15
+
16
+ test("strips carriage returns", () => {
17
+ assert.deepEqual(normalizeFrame("a\r\nb\r\n", 80), ["a", "b"]);
18
+ });
19
+
20
+ test("truncates by visible width, not string length", () => {
21
+ const line = `${RED}${"日".repeat(20)}${RESET}`;
22
+ assert.equal(visibleWidth(line), 40);
23
+ assert.notEqual(line.length, visibleWidth(line));
24
+ const [truncated = ""] = normalizeFrame(line, 10);
25
+ assert.ok(visibleWidth(truncated) <= 10, `visible width was ${visibleWidth(truncated)}`);
26
+ });
27
+
28
+ test("keeps a line that already fits untouched", () => {
29
+ const line = `${RED}abc${RESET}`;
30
+ assert.deepEqual(normalizeFrame(line, 3), [line]);
31
+ });
32
+
33
+ test("narrow widths do not overflow", () => {
34
+ for (const width of [1, 2, 5]) {
35
+ const [line = ""] = normalizeFrame(`${RED}model · dir · 15% · 120k tokens${RESET}`, width);
36
+ assert.ok(visibleWidth(line) <= width, `width ${width} produced ${visibleWidth(line)}`);
37
+ }
38
+ });
39
+
40
+ test("empty output normalizes to no lines so the caller can keep the last frame", () => {
41
+ assert.deepEqual(normalizeFrame("", 80), []);
42
+ assert.deepEqual(normalizeFrame("\n\n", 80), []);
43
+ });
44
+
45
+ test("notices fit the width", () => {
46
+ for (const frame of [loadingNotice(6), missingBinaryNotice(20), noticeFrame("ccline failed: exit 1", 12)]) {
47
+ assert.equal(frame.length, 1);
48
+ assert.ok(visibleWidth(frame[0] ?? "") <= 20);
49
+ }
50
+ });
51
+
52
+ test("stale marker is appended only when it still fits", () => {
53
+ const lines = ["short"];
54
+ assert.deepEqual(withStaleMarker(lines, 20, " ·stale"), ["short ·stale"]);
55
+ assert.deepEqual(withStaleMarker(lines, 7, " ·stale"), ["short"]);
56
+ assert.deepEqual(withStaleMarker([], 20, " ·stale"), []);
57
+ });
58
+
59
+ test("stale marker respects ANSI width on the last line", () => {
60
+ const lines = [`${RED}12345${RESET}`];
61
+ const marked = withStaleMarker(lines, 11, "xx");
62
+ assert.equal(marked.length, 1);
63
+ assert.ok(visibleWidth(marked[0] ?? "") <= 11);
64
+ });
65
+
66
+ test("extension statuses become one sorted row like the built-in footer", () => {
67
+ const statuses = new Map([
68
+ ["cursor-fast", "Cursor fast: on"],
69
+ ["alpha", "first"],
70
+ ]);
71
+ assert.equal(extensionStatusLine(statuses, 80), "first Cursor fast: on");
72
+ });
73
+
74
+ test("status text is collapsed to a single line", () => {
75
+ assert.equal(sanitizeStatusText("a\nb\tc \r\nd"), "a b c d");
76
+ assert.equal(extensionStatusLine(new Map([["k", "one\ntwo"]]), 80), "one two");
77
+ });
78
+
79
+ test("blank statuses are dropped and an empty set produces no row", () => {
80
+ assert.equal(extensionStatusLine(new Map([["k", " "]]) as Map<string, string>, 80), null);
81
+ assert.equal(extensionStatusLine(new Map(), 80), null);
82
+ assert.equal(extensionStatusLine(null, 80), null);
83
+ assert.equal(extensionStatusLine(undefined, 80), null);
84
+ });
85
+
86
+ test("a long status row is truncated by visible width with the dim ellipsis", () => {
87
+ const row = extensionStatusLine(new Map([["k", "日".repeat(20)]]), 10, "...");
88
+ assert.ok(row !== null);
89
+ assert.ok(visibleWidth(row) <= 10, `visible width was ${visibleWidth(row ?? "")}`);
90
+ assert.ok((row ?? "").replace(/\u001b\[[0-9;]*m/g, "").endsWith("..."));
91
+ });
@@ -0,0 +1,72 @@
1
+ // Normalizes ccline stdout into footer lines.
2
+ // ccline emits ANSI escapes, so every width decision goes through pi-tui's
3
+ // visible-width helpers instead of String.length.
4
+
5
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ /** ccline is asked to wrap at COLUMNS, but a cached frame may still be wider. */
8
+ export function normalizeFrame(stdout: string, width: number, ellipsis = "…"): string[] {
9
+ const safeWidth = Math.max(1, Math.floor(width));
10
+ const lines = stdout.replace(/\r\n?/g, "\n").split("\n");
11
+ while (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() === "") {
12
+ lines.pop();
13
+ }
14
+ return lines.map((line) => (visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth, ellipsis) : line));
15
+ }
16
+
17
+ /** Fit a plain notice into the footer width. */
18
+ export function noticeFrame(text: string, width: number): string[] {
19
+ return [truncateToWidth(text, Math.max(1, Math.floor(width)))];
20
+ }
21
+
22
+ export function missingBinaryNotice(width: number): string[] {
23
+ return noticeFrame("ccline not found · npm i -g @cometix/ccline · or set binary in ~/.pi/agent/pi-ccline/config.json", width);
24
+ }
25
+
26
+ export function loadingNotice(width: number): string[] {
27
+ return noticeFrame("ccline …", width);
28
+ }
29
+
30
+ /**
31
+ * Extension statuses are single-line by contract; collapse control characters
32
+ * the way pi's built-in footer does before measuring anything.
33
+ */
34
+ export function sanitizeStatusText(text: string): string {
35
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
36
+ }
37
+
38
+ /**
39
+ * pi's built-in footer puts every extension status on one extra row, sorted by
40
+ * key and joined by a space. Mirror that so `ctx.ui.setStatus` keeps working
41
+ * when this footer owns the slot.
42
+ */
43
+ export function extensionStatusLine(
44
+ statuses: Iterable<[string, string]> | null | undefined,
45
+ width: number,
46
+ ellipsis = "…",
47
+ ): string | null {
48
+ if (!statuses) return null;
49
+ const text = [...statuses]
50
+ .sort(([a], [b]) => a.localeCompare(b))
51
+ .map(([, value]) => sanitizeStatusText(value))
52
+ .filter((value) => value.length > 0)
53
+ .join(" ");
54
+ if (!text) return null;
55
+ const safeWidth = Math.max(1, Math.floor(width));
56
+ return visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, ellipsis) : text;
57
+ }
58
+
59
+ /**
60
+ * Append a marker to the last line when it still fits. A stale frame is kept
61
+ * visible instead of blanking the footer, and the marker stays opt-in.
62
+ */
63
+ export function withStaleMarker(lines: string[], width: number, marker: string): string[] {
64
+ if (lines.length === 0) return lines;
65
+ const safeWidth = Math.max(1, Math.floor(width));
66
+ const last = lines[lines.length - 1] ?? "";
67
+ const markerWidth = visibleWidth(marker);
68
+ if (markerWidth === 0 || visibleWidth(last) + markerWidth > safeWidth) return lines;
69
+ const next = lines.slice();
70
+ next[next.length - 1] = last + marker;
71
+ return next;
72
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "cwd": "/Users/caph/Workspace/pi-packages/pi-ccline",
3
+ "workspace": {
4
+ "current_dir": "/Users/caph/Workspace/pi-packages/pi-ccline"
5
+ },
6
+ "model": {
7
+ "id": "gpt-5.6-codex",
8
+ "display_name": "GPT-5.6 Codex (openai-codex)"
9
+ },
10
+ "effort": {
11
+ "level": "high"
12
+ },
13
+ "context_window": {
14
+ "context_window_size": 272000,
15
+ "current_usage": {
16
+ "input_tokens": 42000
17
+ },
18
+ "used_percentage": 15.4
19
+ },
20
+ "cost": {
21
+ "total_cost_usd": 1.2345,
22
+ "total_duration_ms": 1830000
23
+ },
24
+ "session_id": "4f8f2c1a-0000-4000-8000-000000000000",
25
+ "session_name": "pi-ccline demo"
26
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
- "name": "@caph42/pi-ccline",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@caph42/pi-ccline",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension that renders the footer by delegating to the CCometixLine (ccline) statusline binary",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi-coding-agent",
9
+ "statusline",
10
+ "footer",
11
+ "ccline",
12
+ "cometix"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Caph-dev/pi-ccline.git"
19
+ },
20
+ "homepage": "https://github.com/Caph-dev/pi-ccline#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/Caph-dev/pi-ccline/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "scripts",
30
+ "fixtures",
31
+ "README.md",
32
+ "README_zh.md"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/pi-ccline/index.ts"
37
+ ]
38
+ },
39
+ "scripts": {
40
+ "test": "node --test",
41
+ "render-once": "node --experimental-strip-types scripts/render-once.ts"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -0,0 +1,164 @@
1
+ // Render one ccline frame from a fixture, for manual acceptance checks.
2
+ //
3
+ // node --experimental-strip-types scripts/render-once.ts
4
+ // node --experimental-strip-types scripts/render-once.ts --fixture fixtures/basic.json --width 120 --width 40
5
+ //
6
+ // Runs the real binary (no model call, no pi session) and prints both the raw
7
+ // ANSI bytes and the footer lines pi would receive.
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { visibleWidth } from "@earendil-works/pi-tui";
15
+
16
+ import { clearBinaryCache, probeVersion, resolveBinary, spawnCcline } from "../extensions/pi-ccline/binary.ts";
17
+ import { configPath, loadConfig } from "../extensions/pi-ccline/config.ts";
18
+ import { normalizeFrame } from "../extensions/pi-ccline/render.ts";
19
+
20
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
21
+
22
+ interface Options {
23
+ fixture: string;
24
+ binary: string | null;
25
+ widths: number[];
26
+ timeoutMs: number;
27
+ raw: boolean;
28
+ home: string;
29
+ }
30
+
31
+ function parseArgs(argv: string[]): Options {
32
+ const options: Options = {
33
+ fixture: resolve(packageRoot, "fixtures/basic.json"),
34
+ binary: null,
35
+ widths: [],
36
+ timeoutMs: 5000,
37
+ raw: false,
38
+ home: homedir(),
39
+ };
40
+
41
+ for (let index = 0; index < argv.length; index += 1) {
42
+ const arg = argv[index];
43
+ const next = () => {
44
+ index += 1;
45
+ const value = argv[index];
46
+ if (value === undefined) throw new Error(`missing value for ${arg}`);
47
+ return value;
48
+ };
49
+ switch (arg) {
50
+ case "--fixture":
51
+ options.fixture = resolve(process.cwd(), next());
52
+ break;
53
+ case "--binary":
54
+ options.binary = resolve(process.cwd(), next());
55
+ break;
56
+ case "--width":
57
+ options.widths.push(Number(next()));
58
+ break;
59
+ case "--timeout":
60
+ options.timeoutMs = Number(next());
61
+ break;
62
+ case "--home":
63
+ // Isolate both config lookup and the child process (~/.claude writes).
64
+ options.home = resolve(process.cwd(), next());
65
+ break;
66
+ case "--raw":
67
+ options.raw = true;
68
+ break;
69
+ case "--help":
70
+ case "-h":
71
+ console.log(
72
+ [
73
+ "usage: render-once.ts [--fixture <path>] [--binary <path>] [--width <cols>]...",
74
+ " [--timeout <ms>] [--home <dir>] [--raw]",
75
+ "",
76
+ "Without --width the fixture renders at 120 and 40 columns.",
77
+ ].join("\n"),
78
+ );
79
+ process.exit(0);
80
+ break;
81
+ default:
82
+ throw new Error(`unknown argument: ${arg}`);
83
+ }
84
+ }
85
+
86
+ if (options.widths.length === 0) options.widths = [120, 40];
87
+ for (const width of options.widths) {
88
+ if (!Number.isFinite(width) || width < 1) throw new Error(`invalid width: ${width}`);
89
+ }
90
+ return options;
91
+ }
92
+
93
+ async function main(): Promise<number> {
94
+ const options = parseArgs(process.argv.slice(2));
95
+ const fixtureText = readFileSync(options.fixture, "utf8");
96
+ const payload = JSON.stringify(JSON.parse(fixtureText));
97
+ const config = loadConfig(configPath(options.home));
98
+
99
+ clearBinaryCache();
100
+ const resolved = options.binary ? { path: options.binary, source: "config" as const } : resolveBinary(config.binary, { home: options.home });
101
+
102
+ console.log(`fixture: ${options.fixture}`);
103
+ if (!resolved.path) {
104
+ console.error("ccline not found. Looked at config.binary, ~/.claude/ccline/ccline and PATH.");
105
+ console.error("install: npm i -g @cometix/ccline (or pass --binary <path>)");
106
+ return 1;
107
+ }
108
+
109
+ const version = await probeVersion(resolved.path, options.timeoutMs);
110
+ console.log(`binary: ${resolved.path} (${resolved.source})`);
111
+ console.log(`home: ${options.home}`);
112
+ console.log(`version: ${version ?? "unknown"}`);
113
+
114
+ const childEnv: Record<string, string> = { ...config.env, HOME: options.home, USERPROFILE: options.home };
115
+
116
+ let failures = 0;
117
+ for (const width of options.widths) {
118
+ const result = await spawnCcline({
119
+ binary: resolved.path,
120
+ payload,
121
+ timeoutMs: options.timeoutMs,
122
+ columns: width,
123
+ env: childEnv,
124
+ });
125
+
126
+ console.log("");
127
+ console.log(`--- width ${width} ---`);
128
+ if (result.timedOut) {
129
+ console.error(`timed out after ${options.timeoutMs}ms`);
130
+ failures += 1;
131
+ continue;
132
+ }
133
+ if (!result.ok) {
134
+ console.error(`failed: ${result.error ?? `exit ${result.code}`}${result.stderr.trim() ? `\n${result.stderr.trim()}` : ""}`);
135
+ failures += 1;
136
+ continue;
137
+ }
138
+ if (options.raw) console.log(`raw: ${JSON.stringify(result.stdout)}`);
139
+
140
+ const lines = normalizeFrame(result.stdout, width);
141
+ if (lines.length === 0) {
142
+ console.error("empty output");
143
+ failures += 1;
144
+ continue;
145
+ }
146
+ for (const [index, line] of lines.entries()) {
147
+ console.log(`[${index}] w=${visibleWidth(line)} ${line}`);
148
+ }
149
+ const overflow = lines.filter((line) => visibleWidth(line) > width);
150
+ if (overflow.length > 0) {
151
+ console.error(`width contract violated: ${overflow.length} line(s) wider than ${width}`);
152
+ failures += 1;
153
+ }
154
+ }
155
+
156
+ return failures === 0 ? 0 : 1;
157
+ }
158
+
159
+ main()
160
+ .then((code) => process.exit(code))
161
+ .catch((error: unknown) => {
162
+ console.error(error instanceof Error ? error.message : String(error));
163
+ process.exit(1);
164
+ });