little-coder 1.9.1 → 1.9.3
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/.pi/extensions/_shared/width.test.ts +61 -0
- package/.pi/extensions/_shared/width.ts +76 -0
- package/.pi/extensions/branding/index.ts +9 -4
- package/.pi/extensions/plan-mode/index.ts +6 -1
- package/.pi/extensions/plan-mode/status.ts +7 -1
- package/.pi/extensions/subagent/issue-48-repro.test.ts +78 -0
- package/.pi/extensions/subagent/spawn.test.ts +9 -0
- package/.pi/extensions/subagent/spawn.ts +12 -3
- package/.pi/extensions/subagent/tracker.ts +8 -1
- package/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/bin/extras.mjs +56 -0
- package/bin/extras.test.mjs +119 -0
- package/bin/little-coder.mjs +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stripAnsi, visibleWidth, truncateLineToWidth } from "./width.ts";
|
|
3
|
+
|
|
4
|
+
describe("stripAnsi / visibleWidth", () => {
|
|
5
|
+
it("strips SGR color codes", () => {
|
|
6
|
+
expect(stripAnsi("\x1b[31mred\x1b[39m")).toBe("red");
|
|
7
|
+
expect(visibleWidth("\x1b[31mred\x1b[39m")).toBe(3);
|
|
8
|
+
});
|
|
9
|
+
it("strips OSC hyperlinks", () => {
|
|
10
|
+
const link = "\x1b]8;;https://example.com\x07click\x1b]8;;\x07";
|
|
11
|
+
expect(stripAnsi(link)).toBe("click");
|
|
12
|
+
expect(visibleWidth(link)).toBe(5);
|
|
13
|
+
});
|
|
14
|
+
it("counts plain ASCII", () => {
|
|
15
|
+
expect(visibleWidth("hello world")).toBe(11);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("truncateLineToWidth", () => {
|
|
20
|
+
it("returns the line unchanged when it fits", () => {
|
|
21
|
+
expect(truncateLineToWidth("hi", 10)).toBe("hi");
|
|
22
|
+
});
|
|
23
|
+
it("truncates and adds an ellipsis when it overflows", () => {
|
|
24
|
+
const out = truncateLineToWidth("abcdefghij", 5);
|
|
25
|
+
expect(visibleWidth(out)).toBeLessThanOrEqual(5);
|
|
26
|
+
expect(out).toContain("…");
|
|
27
|
+
});
|
|
28
|
+
it("preserves SGR codes through the visible portion", () => {
|
|
29
|
+
// 12 visible chars under one color, truncate to 6
|
|
30
|
+
const input = "\x1b[31m" + "abcdefghijkl" + "\x1b[39m";
|
|
31
|
+
const out = truncateLineToWidth(input, 6);
|
|
32
|
+
expect(out).toContain("\x1b[31m");
|
|
33
|
+
// visible chars in out (after stripping ansi) should be <= 6
|
|
34
|
+
expect(visibleWidth(out)).toBeLessThanOrEqual(6);
|
|
35
|
+
});
|
|
36
|
+
it("appends a reset to prevent color bleed after truncation", () => {
|
|
37
|
+
const out = truncateLineToWidth("\x1b[31mlong red string here\x1b[39m", 8);
|
|
38
|
+
expect(out.endsWith("\x1b[0m")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it("handles a width of 0 defensively", () => {
|
|
41
|
+
expect(truncateLineToWidth("anything", 0)).toBe("");
|
|
42
|
+
});
|
|
43
|
+
it("matches the issue #48 reproduction shape", () => {
|
|
44
|
+
// Construct a row roughly the shape the sub-coder tracker would build,
|
|
45
|
+
// with a real-world ~167-char errorMessage. Without truncation this is
|
|
46
|
+
// 198 visible chars — exactly the user-reported overflow.
|
|
47
|
+
const honey = (s: string) => `\x1b[38;2;225;90;31m${s}\x1b[39m`;
|
|
48
|
+
const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
|
|
49
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
|
|
50
|
+
const longError =
|
|
51
|
+
"child process exited with non-zero code 1: " +
|
|
52
|
+
"Error: provider \"llamacpp\" — failed to reach " +
|
|
53
|
+
"http://127.0.0.1:8888/v1/chat/completions: ECONNREFUSED (transport error 503, retries=3)";
|
|
54
|
+
const row = ` ${red("✗")} deep-explorer-research ${gray("0:47 ")} ${gray(longError)}`;
|
|
55
|
+
expect(visibleWidth(row)).toBeGreaterThan(184);
|
|
56
|
+
const fixed = truncateLineToWidth(row, 184);
|
|
57
|
+
expect(visibleWidth(fixed)).toBeLessThanOrEqual(184);
|
|
58
|
+
expect(fixed.startsWith(" \x1b[31m✗\x1b[39m deep-explorer")).toBe(true);
|
|
59
|
+
void honey; // keep import shape parity with the tracker
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Width-aware truncation for custom TUI widget content.
|
|
2
|
+
//
|
|
3
|
+
// pi-tui (>= ~0.75) throws "Rendered line N exceeds terminal width" when any
|
|
4
|
+
// rendered line is wider than the terminal — see issue #48. Custom widgets
|
|
5
|
+
// (subagent tracker, plan-mode status/indicator, branding header) MUST cap
|
|
6
|
+
// every line they emit to the active terminal width, or pi crashes the whole
|
|
7
|
+
// session when the widget happens to produce a long line (e.g. a failed
|
|
8
|
+
// sub-coder whose errorMessage runs ~200 chars).
|
|
9
|
+
//
|
|
10
|
+
// pi-tui itself exports visibleWidth + truncateToWidth, but pi 0.79 no longer
|
|
11
|
+
// hoists pi-tui to the top-level node_modules, so extensions can't import it
|
|
12
|
+
// directly. This module is the lightweight inline replacement: it strips
|
|
13
|
+
// SGR / OSC escapes for the width count and walks the string char-by-char to
|
|
14
|
+
// truncate while preserving any in-flight color codes.
|
|
15
|
+
|
|
16
|
+
const SGR_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
17
|
+
const OSC_RE = /\x1b\][^\x07]*\x07/g;
|
|
18
|
+
|
|
19
|
+
export function stripAnsi(s: string): string {
|
|
20
|
+
return s.replace(SGR_RE, "").replace(OSC_RE, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Approximation: ANSI-stripped char count (treats every visible char as width
|
|
24
|
+
// 1). Exact for ASCII / Latin / single-cell glyphs the tracker and status
|
|
25
|
+
// widgets emit. Wide CJK or emoji *under*count here, so callers should leave a
|
|
26
|
+
// small safety margin (see terminalColumns/SAFETY_MARGIN).
|
|
27
|
+
export function visibleWidth(s: string): number {
|
|
28
|
+
return [...stripAnsi(s)].length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Truncate `line` so its visible width fits `maxWidth`. ANSI escapes are
|
|
32
|
+
// preserved verbatim; the visible portion is cut at maxWidth-1 to leave room
|
|
33
|
+
// for an ellipsis, and a final SGR reset is appended so a half-emitted color
|
|
34
|
+
// can't bleed into the next line. If maxWidth ≤ 0, returns "" (defensive).
|
|
35
|
+
export function truncateLineToWidth(line: string, maxWidth: number, ellipsis = "…"): string {
|
|
36
|
+
if (maxWidth <= 0) return "";
|
|
37
|
+
if (visibleWidth(line) <= maxWidth) return line;
|
|
38
|
+
const cutAt = Math.max(0, maxWidth - visibleWidth(ellipsis));
|
|
39
|
+
let visible = 0;
|
|
40
|
+
let out = "";
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < line.length) {
|
|
43
|
+
const ch = line[i];
|
|
44
|
+
if (ch === "\x1b" && line[i + 1] === "[") {
|
|
45
|
+
const m = line.slice(i).match(/^\x1b\[[0-9;]*[a-zA-Z]/);
|
|
46
|
+
if (m) {
|
|
47
|
+
out += m[0];
|
|
48
|
+
i += m[0].length;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (ch === "\x1b" && line[i + 1] === "]") {
|
|
53
|
+
const end = line.indexOf("\x07", i);
|
|
54
|
+
if (end >= 0) {
|
|
55
|
+
out += line.slice(i, end + 1);
|
|
56
|
+
i = end + 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (visible >= cutAt) break;
|
|
61
|
+
out += ch;
|
|
62
|
+
visible += 1;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
return out + ellipsis + "\x1b[0m";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Current terminal width, with a small safety margin to absorb wide Unicode
|
|
69
|
+
// chars that visibleWidth's char-count approximation under-measures. Falls
|
|
70
|
+
// back to `fallback` columns when stdout isn't a TTY (headless runs).
|
|
71
|
+
const SAFETY_MARGIN = 2;
|
|
72
|
+
export function terminalColumns(fallback = 80): number {
|
|
73
|
+
const c = (process.stdout && (process.stdout as any).columns) | 0;
|
|
74
|
+
const w = c > 0 ? c : fallback;
|
|
75
|
+
return Math.max(20, w - SAFETY_MARGIN);
|
|
76
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { truncateLineToWidth } from "../_shared/width.ts";
|
|
5
6
|
|
|
6
7
|
// Replace pi's built-in startup header + terminal title with little-coder
|
|
7
8
|
// branding. The interactive TUI's "pi vX.Y.Z" logo, the "Pi can explain its
|
|
@@ -46,7 +47,7 @@ function readVersion(): string {
|
|
|
46
47
|
|
|
47
48
|
const VERSION = readVersion();
|
|
48
49
|
|
|
49
|
-
function buildHeader(theme: Theme): string[] {
|
|
50
|
+
function buildHeader(theme: Theme, width: number): string[] {
|
|
50
51
|
// Brand-book "prompt lockup" (the variant the brand reserves for terminals
|
|
51
52
|
// and dark surfaces): a honey prompt caret, the wordmark in the foreground,
|
|
52
53
|
// and the honey block cursor — "lc▌"'s ready-to-type punchline, applied to
|
|
@@ -67,7 +68,11 @@ function buildHeader(theme: Theme): string[] {
|
|
|
67
68
|
`${dim("!")} bash`,
|
|
68
69
|
`${dim("ctrl-r")} more`,
|
|
69
70
|
].join(sep);
|
|
70
|
-
|
|
71
|
+
// pi-tui throws if any rendered line exceeds the terminal width (issue #48).
|
|
72
|
+
// Truncate every line we hand it so a narrow terminal can't crash the launch.
|
|
73
|
+
return ["", logo, tagline, "", hints, ""].map((l) =>
|
|
74
|
+
l ? truncateLineToWidth(l, width) : l,
|
|
75
|
+
);
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
// Derive a short, human session name from the first user prompt. Returns
|
|
@@ -111,8 +116,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
111
116
|
if (!ctx.hasUI) return;
|
|
112
117
|
|
|
113
118
|
ctx.ui.setHeader((_tui, theme) => ({
|
|
114
|
-
render(
|
|
115
|
-
return buildHeader(theme);
|
|
119
|
+
render(width: number): string[] {
|
|
120
|
+
return buildHeader(theme, width);
|
|
116
121
|
},
|
|
117
122
|
invalidate() {},
|
|
118
123
|
}));
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { SubCoderTracker } from "../subagent/tracker.ts";
|
|
10
10
|
import { currentModelId } from "../subagent/index.ts";
|
|
11
11
|
import { PlanStatus } from "./status.ts";
|
|
12
|
+
import { terminalColumns, truncateLineToWidth } from "../_shared/width.ts";
|
|
12
13
|
|
|
13
14
|
// Plan Mode — a Claude-Code-style "research, ask, then plan" flow.
|
|
14
15
|
//
|
|
@@ -49,7 +50,11 @@ let pendingSynthesis: { digest: string; answers: string } | null = null;
|
|
|
49
50
|
let synthesisActive = false;
|
|
50
51
|
|
|
51
52
|
function indicatorLines(): string[] {
|
|
52
|
-
|
|
53
|
+
// Cap to terminal width — pi-tui throws on overflow (issue #48). The
|
|
54
|
+
// indicator is short, but truncate for defense in depth so even a narrow
|
|
55
|
+
// terminal (≤ 30 cols) doesn't crash on widget render.
|
|
56
|
+
const raw = `${honey("◆")} ${honey("PLAN MODE")} ${gray("(alt+p to exit)")}`;
|
|
57
|
+
return [truncateLineToWidth(raw, terminalColumns())];
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
function setIndicator(ctx: any, on: boolean): void {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// (needed to animate the spinner + tick the clock), colored with raw SGR so it
|
|
8
8
|
// doesn't depend on the active theme.
|
|
9
9
|
|
|
10
|
+
import { terminalColumns, truncateLineToWidth } from "../_shared/width.ts";
|
|
11
|
+
|
|
10
12
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
13
|
const honey = (s: string) => `\x1b[38;2;225;90;31m${s}\x1b[39m`;
|
|
12
14
|
const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
|
|
@@ -71,7 +73,11 @@ export class PlanStatus {
|
|
|
71
73
|
if (!this.ctx.hasUI) return;
|
|
72
74
|
const now = Date.now();
|
|
73
75
|
const frame = SPINNER[Math.floor(now / 100) % SPINNER.length];
|
|
74
|
-
const
|
|
76
|
+
const raw = `${honey(frame)} ${this.message} ${gray(fmtElapsed(now - this.startMs))}`;
|
|
77
|
+
// Cap to terminal width — pi-tui throws on overflow (issue #48). Our own
|
|
78
|
+
// phase messages are short, but the line is still passed through for
|
|
79
|
+
// defense-in-depth (a future caller, or a long custom message, won't crash).
|
|
80
|
+
const line = truncateLineToWidth(raw, terminalColumns());
|
|
75
81
|
if (line === this.lastFrame) return; // diff-guard
|
|
76
82
|
this.lastFrame = line;
|
|
77
83
|
this.ctx.ui.setWidget(this.key, [line], { placement: this.placement });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { SubCoderTracker } from "./tracker.ts";
|
|
3
|
+
|
|
4
|
+
// Live regression for issue #48 — the user's reported crash shape.
|
|
5
|
+
// pi-tui throws when any rendered widget line is wider than the terminal.
|
|
6
|
+
// Before the fix, the sub-coder tracker fed an unbounded errorMessage straight
|
|
7
|
+
// into a widget row; a ~167-char error at terminal width 184 produced a
|
|
8
|
+
// 248-char visible line and crashed the session. This test simulates the
|
|
9
|
+
// exact scenario and asserts every emitted line fits.
|
|
10
|
+
|
|
11
|
+
describe("issue #48 — tracker doesn't overflow terminal width", () => {
|
|
12
|
+
it("caps a failed sub-coder's row to the terminal width", () => {
|
|
13
|
+
const orig = (process.stdout as any).columns;
|
|
14
|
+
(process.stdout as any).columns = 184;
|
|
15
|
+
try {
|
|
16
|
+
const captured: string[] = [];
|
|
17
|
+
const ctx = {
|
|
18
|
+
hasUI: true,
|
|
19
|
+
ui: {
|
|
20
|
+
setWidget: (_k: string, lines: string[] | undefined) => {
|
|
21
|
+
if (lines) captured.push(...lines);
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const tracker = new SubCoderTracker(ctx, { key: "t", totalSince: Date.now() - 47000 });
|
|
26
|
+
tracker.begin([{ id: "a", label: "deep-explorer-research" }]);
|
|
27
|
+
const longErr =
|
|
28
|
+
"child process exited with non-zero code 1: " +
|
|
29
|
+
"Error: provider \"llamacpp\" — failed to reach " +
|
|
30
|
+
"http://127.0.0.1:8888/v1/chat/completions: ECONNREFUSED (transport error 503 after 3 retries)";
|
|
31
|
+
tracker.update([
|
|
32
|
+
{
|
|
33
|
+
id: "a",
|
|
34
|
+
label: "deep-explorer-research",
|
|
35
|
+
task: "",
|
|
36
|
+
exitCode: 1,
|
|
37
|
+
errorMessage: longErr,
|
|
38
|
+
report: "",
|
|
39
|
+
messages: [],
|
|
40
|
+
stderr: "",
|
|
41
|
+
usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 },
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
tracker.end();
|
|
45
|
+
expect(captured.length).toBeGreaterThan(0);
|
|
46
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
47
|
+
const widths = captured.map((l) => stripAnsi(l).length);
|
|
48
|
+
const max = Math.max(...widths);
|
|
49
|
+
expect(max).toBeLessThanOrEqual(184);
|
|
50
|
+
} finally {
|
|
51
|
+
(process.stdout as any).columns = orig;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("survives narrower terminals (~80 cols)", () => {
|
|
56
|
+
const orig = (process.stdout as any).columns;
|
|
57
|
+
(process.stdout as any).columns = 80;
|
|
58
|
+
try {
|
|
59
|
+
const captured: string[] = [];
|
|
60
|
+
const ctx = {
|
|
61
|
+
hasUI: true,
|
|
62
|
+
ui: { setWidget: (_k: string, lines: string[] | undefined) => { if (lines) captured.push(...lines); } },
|
|
63
|
+
};
|
|
64
|
+
const tracker = new SubCoderTracker(ctx);
|
|
65
|
+
tracker.begin([{ id: "a", label: "x" }, { id: "b", label: "y" }]);
|
|
66
|
+
tracker.update([
|
|
67
|
+
{ id: "a", label: "x", task: "", exitCode: 0, report: "ok", messages: [], stderr: "", usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 } },
|
|
68
|
+
{ id: "b", label: "y", task: "", exitCode: 1, errorMessage: "x".repeat(500), report: "", messages: [], stderr: "", usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 } },
|
|
69
|
+
]);
|
|
70
|
+
tracker.end();
|
|
71
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
72
|
+
const max = Math.max(...captured.map((l) => stripAnsi(l).length));
|
|
73
|
+
expect(max).toBeLessThanOrEqual(80);
|
|
74
|
+
} finally {
|
|
75
|
+
(process.stdout as any).columns = orig;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -64,6 +64,15 @@ describe("summarizeActivity", () => {
|
|
|
64
64
|
it("falls back to working when running with no tool call", () => {
|
|
65
65
|
expect(summarizeActivity(base)).toBe("working…");
|
|
66
66
|
});
|
|
67
|
+
it("caps long error messages on failure (issue #48 regression)", () => {
|
|
68
|
+
const longErr =
|
|
69
|
+
"child process exited with non-zero code 1: " +
|
|
70
|
+
"Error: provider \"llamacpp\" — failed to reach " +
|
|
71
|
+
"http://127.0.0.1:8888/v1/chat/completions: ECONNREFUSED";
|
|
72
|
+
const out = summarizeActivity({ ...base, exitCode: 1, errorMessage: longErr });
|
|
73
|
+
expect(out.length).toBeLessThanOrEqual(56);
|
|
74
|
+
expect(out.endsWith("…")).toBe(true);
|
|
75
|
+
});
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
describe("buildChildEnv", () => {
|
|
@@ -131,11 +131,20 @@ export function truncateReport(text: string, max = MAX_REPORT_CHARS): string {
|
|
|
131
131
|
|
|
132
132
|
/** A one-line "what is this child doing right now" string for the tracker. */
|
|
133
133
|
export function summarizeActivity(r: SubCoderResult): string {
|
|
134
|
+
const cap = (s: string, n = 56): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
134
135
|
if (r.exitCode === 0) {
|
|
135
136
|
const firstLine = r.report.split(/\r?\n/).find((l) => l.trim()) ?? "(done)";
|
|
136
|
-
return firstLine
|
|
137
|
+
return cap(firstLine);
|
|
138
|
+
}
|
|
139
|
+
if (r.exitCode > 0) {
|
|
140
|
+
// The error path used to return raw errorMessage / stderr UNCAPPED, which
|
|
141
|
+
// routinely runs ~200 chars (a child process error with a URL + stack
|
|
142
|
+
// fragment). The tracker passes that straight into a widget line, and any
|
|
143
|
+
// line wider than the terminal crashes pi-tui (issue #48). Cap here so the
|
|
144
|
+
// source string is bounded; the widget also truncates the assembled row
|
|
145
|
+
// for defense in depth.
|
|
146
|
+
return cap(r.errorMessage || r.stderr.split(/\r?\n/)[0] || "(failed)");
|
|
137
147
|
}
|
|
138
|
-
if (r.exitCode > 0) return r.errorMessage || r.stderr.split(/\r?\n/)[0] || "(failed)";
|
|
139
148
|
// running: surface the most recent tool call, else the latest partial text.
|
|
140
149
|
for (let i = r.messages.length - 1; i >= 0; i--) {
|
|
141
150
|
const m = r.messages[i];
|
|
@@ -145,7 +154,7 @@ export function summarizeActivity(r: SubCoderResult): string {
|
|
|
145
154
|
if (part?.type === "toolCall") {
|
|
146
155
|
const a = part.arguments ?? {};
|
|
147
156
|
const hint = a.pattern || a.query || a.url || a.path || a.file_path || a.command || "";
|
|
148
|
-
return `→ ${part.name}${hint ? ` ${String(hint).slice(0, 40)}` : ""}
|
|
157
|
+
return cap(`→ ${part.name}${hint ? ` ${String(hint).slice(0, 40)}` : ""}`);
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
160
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// active theme and the string[] form of setWidget can be used directly.
|
|
8
8
|
|
|
9
9
|
import { summarizeActivity, type SubCoderResult } from "./spawn.ts";
|
|
10
|
+
import { terminalColumns, truncateLineToWidth } from "../_shared/width.ts";
|
|
10
11
|
|
|
11
12
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
12
13
|
|
|
@@ -130,7 +131,13 @@ export class SubCoderTracker {
|
|
|
130
131
|
return ` ${icon} ${padEnd(r.label, labelWidth)} ${gray(padEnd(elapsed, 5))} ${gray(activity)}`;
|
|
131
132
|
});
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
// Cap every line to the active terminal width — pi-tui throws if a custom
|
|
135
|
+
// widget renders a line wider than the terminal (issue #48). The activity
|
|
136
|
+
// text in rows can be unbounded (failed sub-coders surface raw stderr /
|
|
137
|
+
// errorMessage, which routinely runs ~200 chars), so without this each
|
|
138
|
+
// failing dispatch turn would crash the whole session.
|
|
139
|
+
const width = terminalColumns();
|
|
140
|
+
const lines = [header, ...rows].map((l) => truncateLineToWidth(l, width));
|
|
134
141
|
const frameKey = lines.join("\n");
|
|
135
142
|
if (frameKey === this.lastFrame) return; // diff-guard: skip identical repaints
|
|
136
143
|
this.lastFrame = frameKey;
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
|
|
4
4
|
|
|
5
|
+
## [v1.9.3] — 2026-06-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`LITTLE_CODER_EXTRA_EXTENSIONS` env var: layer third-party pi extensions onto the bundled set without forking the installed package** ([#46](https://github.com/itayinbarr/little-coder/issues/46)). Path-delimited list (`:` on POSIX, `;` on Windows — `node:path.delimiter`) of extension paths. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js` (the launcher prefers `.ts`). A leading `~/` is expanded; missing paths log a one-line warning to stderr and are skipped (a typo in the env var doesn't kill the session). Survives upgrades — drop the env var into your shell rc once and every `little-coder` run picks up the extras. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. Parsing rules live in `bin/extras.mjs` so they're unit-testable in isolation (9 cases covering direct-file / dir-index-resolution / `index.ts`-preference / missing-path warning / `~/` expansion / multiple entries / whitespace trimming). The launcher-level integration is exercised end-to-end (warning prints for a bad path; valid paths pass through silently to pi as `--extension <entry>` flags). Closest siblings — third-party skill bundles — are not yet covered; `skill-inject` still discovers only `<pkgRoot>/skills/tools/*.md`, and a follow-up will add the same kind of override.
|
|
9
|
+
|
|
10
|
+
### Notes for upgraders
|
|
11
|
+
- No CLI-flag or public-API changes. The new env var is opt-in: unset = identical behavior to v1.9.2. If you were carrying a custom wrapper extension inside the installed npm package (which gets wiped on upgrade), you can drop it and use the env var instead.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [v1.9.2] — 2026-06-18
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Width-overflow crash from custom widgets** ([#48](https://github.com/itayinbarr/little-coder/issues/48)). pi-tui throws `Rendered line N exceeds terminal width` whenever a custom TUI component emits a line wider than the active terminal — the user saw a 198-char line at width 184 take down the whole session. Root cause was the **sub-coder tracker** (`subagent/tracker.ts`): a failed sub-coder's `errorMessage` flowed straight into a widget row without any cap, and real-world child-process errors routinely run 150-250 chars (transport error + URL + retry count is enough). The tracker now caps every emitted row to the active terminal width using a new `_shared/width.ts` utility (`visibleWidth` + `truncateLineToWidth`, ANSI-aware so SGR colour codes are preserved through the cut and a final reset prevents bleed). `summarizeActivity` also gained a 56-char cap on the failure path (was uncapped) and the running path (was uncapped on `part.name`) for defense in depth. The same width-cap is now applied to the **plan-mode status panel**, the **plan-mode indicator**, and the **branding startup header** (which now uses the `width` arg pi passes to `render()` instead of returning hardcoded-length lines), so a narrow terminal can no longer crash launch either. New `width.test.ts` (9 cases) covers ASCII / SGR / OSC hyperlink / colour-bleed / the exact issue-48 reproduction shape, and `issue-48-repro.test.ts` drives the tracker directly with a 167-char failure at width 184 and asserts no emitted row exceeds the terminal.
|
|
19
|
+
|
|
20
|
+
### Notes for upgraders
|
|
21
|
+
- No CLI-flag or public-API changes. If you ever saw `Rendered line N exceeds terminal width (… > …)` crash a session — particularly during a `dispatch` call that errored, or while Plan Mode was orchestrating sub-coders — 1.9.2 fixes it. Third-party pi extensions (e.g. `context-mode`) that emit their own widgets remain subject to pi's check; if you still see the crash with `Loaded pi extensions: <name>` listed, the offending widget is in that extension, not little-coder.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
5
25
|
## [v1.9.1] — 2026-06-08
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ The agent uses the directory you launched it from as its working directory — `
|
|
|
67
67
|
- **Sub-coders (`dispatch`)** — little-coder can spawn isolated child sessions to research a question (read the repo + browse online, read-only) and report back concisely, without cluttering the main conversation. A live panel above the input tracks them. Tune parallelism with `LITTLE_CODER_SUBCODER_CONCURRENCY` (default 2).
|
|
68
68
|
- **Sessions** — each session is auto-named from your first prompt (rename with `/name`) and shown in the terminal tab title. Use `/resume` to list and reopen past sessions for the current directory.
|
|
69
69
|
- **Read-before-edit** — editing a file requires reading it first, so edits match the file's exact current text.
|
|
70
|
+
- **Third-party extensions (`LITTLE_CODER_EXTRA_EXTENSIONS`)** — path-delimited list (`:` on POSIX, `;` on Windows) of extension paths to layer on top of the bundled set. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js`. `~/` is expanded; missing paths log a warning and are skipped. Survives upgrades, no patching the installed package. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. (Single-file extensions can still use `little-coder -e <path>` for one-off loads.)
|
|
70
71
|
|
|
71
72
|
For local providers (llama.cpp, Ollama, LM Studio) pi expects *some* value in the API-key env even though local servers ignore it:
|
|
72
73
|
|
package/bin/extras.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Helpers for layering third-party pi extensions onto little-coder's bundled
|
|
2
|
+
// set. Extracted from the launcher so the parsing rules — path-delimited list,
|
|
3
|
+
// `~/` expansion, directory-with-index resolution, missing-path warning — are
|
|
4
|
+
// directly unit-testable without spawning the whole CLI.
|
|
5
|
+
|
|
6
|
+
import { existsSync, statSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
// Given the value of LITTLE_CODER_EXTRA_EXTENSIONS (path-delimited list of
|
|
11
|
+
// extension paths), return the resolved entry files that should be passed to pi
|
|
12
|
+
// as `--extension <entry>` flags. Skips empty segments, expands a leading `~/`,
|
|
13
|
+
// resolves a directory entry to its `index.ts` (preferred) or `index.js`, and
|
|
14
|
+
// records a one-line warning for each missing/unusable path so a typo in the
|
|
15
|
+
// env var doesn't kill the session — it just doesn't load that extension.
|
|
16
|
+
export function parseExtraExtensions(
|
|
17
|
+
envValue,
|
|
18
|
+
{ home = homedir(), exists = existsSync, stat = statSync } = {},
|
|
19
|
+
) {
|
|
20
|
+
const entries = [];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
for (const raw of String(envValue ?? "").split(delimiter)) {
|
|
23
|
+
const trimmed = raw.trim();
|
|
24
|
+
if (!trimmed) continue;
|
|
25
|
+
const expanded = trimmed === "~"
|
|
26
|
+
? home
|
|
27
|
+
: trimmed.startsWith("~/")
|
|
28
|
+
? home + trimmed.slice(1)
|
|
29
|
+
: trimmed;
|
|
30
|
+
if (!exists(expanded)) {
|
|
31
|
+
warnings.push(
|
|
32
|
+
`little-coder: LITTLE_CODER_EXTRA_EXTENSIONS path not found, skipping: ${expanded}`,
|
|
33
|
+
);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
let entry = expanded;
|
|
37
|
+
try {
|
|
38
|
+
if (stat(expanded).isDirectory()) {
|
|
39
|
+
const candidates = [join(expanded, "index.ts"), join(expanded, "index.js")];
|
|
40
|
+
const found = candidates.find((p) => exists(p));
|
|
41
|
+
if (!found) {
|
|
42
|
+
warnings.push(
|
|
43
|
+
`little-coder: LITTLE_CODER_EXTRA_EXTENSIONS dir has no index.ts/index.js, skipping: ${expanded}`,
|
|
44
|
+
);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
entry = found;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// unreadable / racing stat — skip silently
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
entries.push(entry);
|
|
54
|
+
}
|
|
55
|
+
return { entries, warnings };
|
|
56
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { parseExtraExtensions } from "./extras.mjs";
|
|
6
|
+
|
|
7
|
+
function setupTmp() {
|
|
8
|
+
return mkdtempSync(join(tmpdir(), "lc-extras-"));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("parseExtraExtensions", () => {
|
|
12
|
+
it("returns no entries when env is unset / empty", () => {
|
|
13
|
+
expect(parseExtraExtensions(undefined).entries).toEqual([]);
|
|
14
|
+
expect(parseExtraExtensions("").entries).toEqual([]);
|
|
15
|
+
expect(parseExtraExtensions(delimiter + delimiter).entries).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("forwards a direct file path verbatim", () => {
|
|
19
|
+
const dir = setupTmp();
|
|
20
|
+
try {
|
|
21
|
+
const file = join(dir, "ponytail.js");
|
|
22
|
+
writeFileSync(file, "export default function(){}");
|
|
23
|
+
const { entries, warnings } = parseExtraExtensions(file);
|
|
24
|
+
expect(entries).toEqual([file]);
|
|
25
|
+
expect(warnings).toEqual([]);
|
|
26
|
+
} finally {
|
|
27
|
+
rmSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves a directory entry to its index.ts (preferred)", () => {
|
|
32
|
+
const dir = setupTmp();
|
|
33
|
+
try {
|
|
34
|
+
const extDir = join(dir, "ponytail");
|
|
35
|
+
mkdirSync(extDir);
|
|
36
|
+
writeFileSync(join(extDir, "index.ts"), "");
|
|
37
|
+
writeFileSync(join(extDir, "index.js"), "");
|
|
38
|
+
const { entries } = parseExtraExtensions(extDir);
|
|
39
|
+
expect(entries).toEqual([join(extDir, "index.ts")]);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to index.js when index.ts is absent", () => {
|
|
46
|
+
const dir = setupTmp();
|
|
47
|
+
try {
|
|
48
|
+
const extDir = join(dir, "ponytail");
|
|
49
|
+
mkdirSync(extDir);
|
|
50
|
+
writeFileSync(join(extDir, "index.js"), "");
|
|
51
|
+
const { entries } = parseExtraExtensions(extDir);
|
|
52
|
+
expect(entries).toEqual([join(extDir, "index.js")]);
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("warns and skips a directory without index.ts/index.js", () => {
|
|
59
|
+
const dir = setupTmp();
|
|
60
|
+
try {
|
|
61
|
+
const extDir = join(dir, "empty");
|
|
62
|
+
mkdirSync(extDir);
|
|
63
|
+
const { entries, warnings } = parseExtraExtensions(extDir);
|
|
64
|
+
expect(entries).toEqual([]);
|
|
65
|
+
expect(warnings).toHaveLength(1);
|
|
66
|
+
expect(warnings[0]).toContain("no index.ts/index.js");
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("warns and skips a missing path (typo doesn't kill the session)", () => {
|
|
73
|
+
const { entries, warnings } = parseExtraExtensions("/tmp/does-not-exist-zzz-xyz-123");
|
|
74
|
+
expect(entries).toEqual([]);
|
|
75
|
+
expect(warnings).toHaveLength(1);
|
|
76
|
+
expect(warnings[0]).toContain("path not found");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("expands a leading ~/ using the supplied home", () => {
|
|
80
|
+
const dir = setupTmp();
|
|
81
|
+
try {
|
|
82
|
+
const extDir = join(dir, "fake-home", "ext");
|
|
83
|
+
mkdirSync(extDir, { recursive: true });
|
|
84
|
+
writeFileSync(join(extDir, "index.js"), "");
|
|
85
|
+
const { entries } = parseExtraExtensions("~/ext", {
|
|
86
|
+
home: join(dir, "fake-home"),
|
|
87
|
+
});
|
|
88
|
+
expect(entries).toEqual([join(extDir, "index.js")]);
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(dir, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("layers multiple extensions from one path-delimited list", () => {
|
|
95
|
+
const dir = setupTmp();
|
|
96
|
+
try {
|
|
97
|
+
const a = join(dir, "a.js");
|
|
98
|
+
writeFileSync(a, "");
|
|
99
|
+
const b = join(dir, "b.js");
|
|
100
|
+
writeFileSync(b, "");
|
|
101
|
+
const { entries } = parseExtraExtensions([a, b].join(delimiter));
|
|
102
|
+
expect(entries).toEqual([a, b]);
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("trims whitespace around list entries", () => {
|
|
109
|
+
const dir = setupTmp();
|
|
110
|
+
try {
|
|
111
|
+
const a = join(dir, "a.js");
|
|
112
|
+
writeFileSync(a, "");
|
|
113
|
+
const { entries } = parseExtraExtensions(` ${a} `);
|
|
114
|
+
expect(entries).toEqual([a]);
|
|
115
|
+
} finally {
|
|
116
|
+
rmSync(dir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
package/bin/little-coder.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
|
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { checkForUpdate } from "./update-check.mjs";
|
|
20
|
+
import { parseExtraExtensions } from "./extras.mjs";
|
|
20
21
|
|
|
21
22
|
// ---- 1. Node version preflight (>= 22.19.0, matching pi.dev) ----
|
|
22
23
|
const MIN_NODE = [22, 19, 0];
|
|
@@ -110,6 +111,21 @@ if (existsSync(extDir)) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// ---- 4b. Third-party extensions via LITTLE_CODER_EXTRA_EXTENSIONS ----
|
|
115
|
+
// Path-delimited list (`:` on POSIX, `;` on Windows — node:path.delimiter)
|
|
116
|
+
// of extra extension paths to load alongside the bundled ones. Each entry can
|
|
117
|
+
// be either a direct file path (e.g. a pi-ponytail-style `extensions/ponytail.js`)
|
|
118
|
+
// or a directory containing `index.ts` / `index.js`. Survives upgrades and
|
|
119
|
+
// avoids the "fork the installed npm package" workaround that issue #46 hit.
|
|
120
|
+
// Parsing rules — ~/ expansion, directory-with-index resolution, one-line
|
|
121
|
+
// warning for missing/unusable entries — live in ./extras.mjs so they're
|
|
122
|
+
// unit-testable in isolation.
|
|
123
|
+
{
|
|
124
|
+
const { entries, warnings } = parseExtraExtensions(process.env.LITTLE_CODER_EXTRA_EXTENSIONS);
|
|
125
|
+
for (const w of warnings) console.error(w);
|
|
126
|
+
for (const entry of entries) extArgs.push("--extension", entry);
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
// ---- 5. Update check (best-effort, blocks on TTY prompt only) ----
|
|
114
130
|
let currentVersion = "0.0.0";
|
|
115
131
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "little-coder",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
|
|
5
5
|
"homepage": "https://github.com/itayinbarr/little-coder",
|
|
6
6
|
"repository": {
|