little-coder 1.9.0 → 1.9.2

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,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
- return ["", logo, tagline, "", hints, ""];
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(_width: number): string[] {
115
- return buildHeader(theme);
119
+ render(width: number): string[] {
120
+ return buildHeader(theme, width);
116
121
  },
117
122
  invalidate() {},
118
123
  }));
@@ -9,11 +9,12 @@ 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
  //
15
- // shift+tab toggles plan mode (an indicator appears below the input). While it
16
- // is on, submitting a prompt does NOT run a normal coding turn; instead the
16
+ // alt+p toggles plan mode (an indicator appears below the input). While it is
17
+ // on, submitting a prompt does NOT run a normal coding turn; instead the
17
18
  // extension orchestrates:
18
19
  // 1. decompose the request into 1-4 exploration tasks (a reasoning sub-coder),
19
20
  // 2. dispatch those as read-only explorer sub-coders (isolated context; only
@@ -27,9 +28,8 @@ import { PlanStatus } from "./status.ts";
27
28
  // child little-coder (spawned via ../subagent/spawn.ts), and the final plan is
28
29
  // injected as a normal turn via pi.sendUserMessage so it lands in the chat.
29
30
  //
30
- // shift+tab is normally pi's thinking-level cycle; extension shortcuts take
31
- // precedence (custom-editor.js checks them first), so we shadow it and move the
32
- // thinking-level cycle to alt+t to keep it reachable.
31
+ // alt+p is unbound by pi, so the extension can claim it cleanly without
32
+ // shadowing any built-in (shift+tab stays pi's thinking-level cycle issue #47).
33
33
 
34
34
  const honey = (s: string) => `\x1b[38;2;225;90;31m${s}\x1b[39m`;
35
35
  const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;
@@ -50,7 +50,11 @@ let pendingSynthesis: { digest: string; answers: string } | null = null;
50
50
  let synthesisActive = false;
51
51
 
52
52
  function indicatorLines(): string[] {
53
- return [`${honey("◆")} ${honey("PLAN MODE")} ${gray("(shift+tab to exit)")}`];
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())];
54
58
  }
55
59
 
56
60
  function setIndicator(ctx: any, on: boolean): void {
@@ -269,8 +273,10 @@ async function orchestrate(pi: ExtensionAPI, ctx: any, prompt: string): Promise<
269
273
  }
270
274
 
271
275
  export default function (pi: ExtensionAPI) {
272
- // shift+tab toggles plan mode (shadows pi's thinking-level cycle).
273
- pi.registerShortcut("shift+tab", {
276
+ // alt+p toggles plan mode. pi leaves alt+p unbound, so this doesn't collide
277
+ // with any built-in and shift+tab stays bound to pi's thinking-level cycle
278
+ // (issue #47).
279
+ pi.registerShortcut("alt+p", {
274
280
  description: "Toggle plan mode",
275
281
  handler: (ctx: any) => {
276
282
  if (orchestrating) return; // mid-plan: ignore toggles
@@ -280,10 +286,6 @@ export default function (pi: ExtensionAPI) {
280
286
  },
281
287
  });
282
288
 
283
- // The thinking-level cycle keeps working on alt+t: the launcher rebinds pi's
284
- // built-in `app.thinking.cycle` from shift+tab to alt+t so shift+tab is free
285
- // for plan mode (see bin/little-coder.mjs §8b). No extension shortcut needed.
286
-
287
289
  // Intercept a submitted prompt while plan mode is on and run the orchestration
288
290
  // instead of a normal coding turn.
289
291
  pi.on("input", async (event, ctx) => {
@@ -357,7 +359,7 @@ export default function (pi: ExtensionAPI) {
357
359
  });
358
360
  } else {
359
361
  (ctx as any).ui?.notify?.(
360
- "plan not implemented — refine your request, or shift+tab to leave plan mode",
362
+ "plan not implemented — refine your request, or alt+p to leave plan mode",
361
363
  "info",
362
364
  );
363
365
  }
@@ -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 line = `${honey(frame)} ${this.message} ${gray(fmtElapsed(now - this.startMs))}`;
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.length > 56 ? `${firstLine.slice(0, 55)}…` : 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
- const lines = [header, ...rows];
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.2] — 2026-06-18
6
+
7
+ ### Fixed
8
+ - **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.
9
+
10
+ ### Notes for upgraders
11
+ - 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.
12
+
13
+ ---
14
+
15
+ ## [v1.9.1] — 2026-06-08
16
+
17
+ ### Fixed
18
+ - **Plan Mode shortcut moved to `alt+p` so `shift+tab` stays pi's thinking-level cycle** ([#47](https://github.com/itayinbarr/little-coder/issues/47)). v1.9.0 claimed `shift+tab` for Plan Mode by rebinding pi's built-in `app.thinking.cycle` to `alt+t` in `~/.pi/agent/keybindings.json`. That collided with the muscle memory of every existing pi user — `shift+tab` is the documented thinking cycle — and pi (≥ 0.79) also surfaced an `[Extension issues]` warning whenever the rebind hadn't taken yet. Plan Mode now registers on **`alt+p`** instead (unbound by pi, so the extension claims it cleanly with no shadowing), and `shift+tab` returns to pi's default behavior. The launcher also performs a **one-time cleanup**: on first run after upgrade, if `~/.pi/agent/keybindings.json` still has the v1.9.0 rewrite (`app.thinking.cycle: "alt+t"` exactly), it is removed; any binding you set yourself is preserved untouched. README and the Plan-Mode indicator (`(alt+p to exit)`) updated to match.
19
+
20
+ ### Notes for upgraders
21
+ - No CLI-flag or public-API changes. **Plan Mode is now `alt+p`** (was `shift+tab` in v1.9.0). `shift+tab` is again pi's thinking-level cycle. If you customized `app.thinking.cycle` yourself in `~/.pi/agent/keybindings.json`, your binding is left alone.
22
+
23
+ ---
24
+
5
25
  ## [v1.9.0] — 2026-06-15
6
26
 
7
27
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ The agent uses the directory you launched it from as its working directory — `
62
62
 
63
63
  ### Interactive features
64
64
 
65
- - **Plan Mode** — press **shift+tab** to toggle (a `◆ PLAN MODE` indicator shows below the input). Submit a request and little-coder researches it with sub-coders, asks you 1-3 clarifying questions (each with suggested answers and a free-text option), then writes a plan in the chat instead of editing anything. **Esc** cancels a plan mid-run. (shift+tab used to cycle the thinking level — that's now **alt+t**.)
65
+ - **Plan Mode** — press **alt+p** to toggle (a `◆ PLAN MODE` indicator shows below the input). Submit a request and little-coder researches it with sub-coders, asks you 1-3 clarifying questions (each with suggested answers and a free-text option), then writes a plan in the chat instead of editing anything. **Esc** cancels a plan mid-run. (**shift+tab** stays pi's thinking-level cycle.)
66
66
  - **Prompt history** — from an empty input, **↑** recalls your recent prompts (most-recent first), **↓** walks forward. History persists across sessions, so a fresh session can recall prompts from earlier runs.
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.
@@ -342,7 +342,7 @@ little-coder/
342
342
  │ ├── settings.json # per-model profiles + benchmark_overrides (terminal_bench, gaia)
343
343
  │ └── extensions/ # 27 TypeScript extensions, auto-discovered by pi
344
344
  │ ├── branding/ # little-coder startup header + terminal title + session auto-naming
345
- │ ├── plan-mode/ # shift+tab "research → ask → plan" flow (sub-coders + clarifying questions → written plan)
345
+ │ ├── plan-mode/ # alt+p "research → ask → plan" flow (sub-coders + clarifying questions → written plan)
346
346
  │ ├── subagent/ # `dispatch` tool: isolated read/browse-only sub-coders + live tracker (spawn.ts engine)
347
347
  │ ├── prompt-history/ # up-arrow recall of recent prompts (from an empty input)
348
348
  │ ├── llama-cpp-provider/ # data-driven provider registration from models.json — ships llamacpp, ollama, lmstudio (+ user override file)
@@ -9,6 +9,7 @@ import {
9
9
  mkdirSync,
10
10
  readdirSync,
11
11
  readFileSync,
12
+ rmSync,
12
13
  statSync,
13
14
  writeFileSync,
14
15
  } from "node:fs";
@@ -244,32 +245,31 @@ if (!isSubagent) try {
244
245
  writeFileSync(globalSettingsPath, JSON.stringify(globalSettings, null, 2));
245
246
  }
246
247
 
247
- // ---- 8b. Free shift+tab for Plan Mode ----
248
- // little-coder binds shift+tab to its plan-mode toggle (the plan-mode
249
- // extension registers it). But shift+tab is pi's built-in "cycle thinking
250
- // level", and pi (>= 0.79) refuses an extension shortcut that collides with a
251
- // RESERVED built-in it skips it with an "[Extension issues]" warning. pi
252
- // builds its conflict map from the *resolved* keybindings, so moving the
253
- // thinking-cycle action to another key in the user keybindings file removes
254
- // shift+tab from that map and lets the extension claim it. We rebind the
255
- // cycle to alt+t (the key plan-mode used to register itself) — only when the
256
- // user hasn't already chosen their own binding for it, so a real user
257
- // customization always wins. Non-destructive: every other binding is left
258
- // untouched.
248
+ // ---- 8b. One-time cleanup of the v1.9.0 keybinding rewrite ----
249
+ // v1.9.0 wrote `app.thinking.cycle: "alt+t"` into ~/.pi/agent/keybindings.json
250
+ // so the plan-mode extension could claim shift+tab (issue #47). Plan mode now
251
+ // lives on alt+p, so shift+tab should go back to pi's default thinking-cycle
252
+ // bindingbut only if the value is *exactly* the one we wrote. A user who
253
+ // chose their own binding (anything "alt+t") wins.
259
254
  const keybindingsPath = join(agentDir, "keybindings.json");
260
- let keybindings = {};
261
255
  if (existsSync(keybindingsPath)) {
262
256
  try {
263
257
  const parsed = JSON.parse(readFileSync(keybindingsPath, "utf-8"));
264
- if (parsed && typeof parsed === "object") keybindings = parsed;
258
+ if (parsed && typeof parsed === "object" && parsed["app.thinking.cycle"] === "alt+t") {
259
+ delete parsed["app.thinking.cycle"];
260
+ if (Object.keys(parsed).length === 0) {
261
+ // Don't leave an empty {} sitting around — remove the file so pi
262
+ // reads its defaults cleanly.
263
+ rmSync(keybindingsPath);
264
+ } else {
265
+ writeFileSync(keybindingsPath, JSON.stringify(parsed, null, 2));
266
+ }
267
+ }
265
268
  } catch {
266
- keybindings = {};
269
+ // Corrupted JSON or unreadable — leave it alone; pi will surface its own error.
267
270
  }
268
271
  }
269
- if (keybindings["app.thinking.cycle"] === undefined) {
270
- keybindings["app.thinking.cycle"] = "alt+t";
271
- writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2));
272
- }
272
+
273
273
  } catch {
274
274
  // Best-effort. If we can't write the settings (read-only HOME, etc.) pi
275
275
  // falls back to its built-in defaults — the [Extensions] block will show
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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": {