little-coder 1.9.4 → 1.9.5

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.
@@ -8,7 +8,6 @@ import {
8
8
  type SubCoderResult,
9
9
  } from "./spawn.ts";
10
10
  import { SubCoderTracker } from "./tracker.ts";
11
- import { truncateLineToWidth } from "../_shared/width.ts";
12
11
 
13
12
  // The `dispatch` tool: the main little-coder spawns isolated child little-coder
14
13
  // sessions ("sub-coders") to research a focused question — they read the repo
@@ -191,22 +190,106 @@ export default function (pi: ExtensionAPI) {
191
190
  });
192
191
  }
193
192
 
194
- /** A minimal pi-tui Component backed by precomputed lines.
193
+ /**
194
+ * Sanitize and wrap lines for the dispatch tool-result panel.
195
195
  *
196
- * pi paints custom tool-result panels with a 1-char left margin + background-
197
- * frame fill, so any line we hand back that's wider than `width - 1` overflows
198
- * the terminal and crashes pi-tui (issue #51 / reopen of #48 the dispatch
199
- * renderer fed an unbounded sub-coder report sentence into the panel, and on
200
- * `--resume` the same renderer paints session history, so the crash recurred
201
- * even after v1.9.2 capped the *live* tracker). We respect the pi-supplied
202
- * `width` here and truncate every line to `width - 2`, leaving a 2-char
203
- * safety margin so wide unicode chars in the report can't sneak past our
204
- * char-count-based visibleWidth approximation. */
196
+ * Two problems we solve:
197
+ * 1. Long whitespace-free tokens (URLs, file paths, base64) are broken up
198
+ * so word-wrap has somewhere to splitfollows openclaw-cn's
199
+ * tui-formatters.ts sanitizer (commit 8c822da).
200
+ * 2. Each line is wrapped to the pi-supplied width so no rendered line
201
+ * exceeds the terminal the failure mode of issues #48 / #51 (a 134-char
202
+ * sub-coder report sentence + pi's 1-char panel left margin overflowed
203
+ * pi-tui's strict line-width check, including on `--resume` because the
204
+ * same renderer paints session history).
205
+ *
206
+ * Word-wrap was contributed by @steverhoades in PR #49; v1.9.5 cherry-picked
207
+ * it onto v1.9.4 because wrapping is a strictly better UX for markdown
208
+ * report bodies than the truncate-with-ellipsis we shipped in v1.9.4 — the
209
+ * user sees the whole sentence across multiple lines instead of a cut-off
210
+ * tail. The 2-char safety margin (`width - 2`) survives wide-unicode chars
211
+ * our char-count-based stripAnsi/length undercounts, and absorbs pi's panel
212
+ * frame margin so the rendered output still fits.
213
+ *
214
+ * pi-tui's own visibleWidth / truncateToWidth aren't importable here (pi
215
+ * 0.79 stopped hoisting pi-tui for extensions), so the ANSI-aware helpers
216
+ * are inlined.
217
+ */
218
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
219
+ const MAX_TOKEN_CHARS = 32;
220
+ const LONG_TOKEN_RE = /\S{33,}/g;
221
+
222
+ function chunkToken(token: string): string[] {
223
+ const chunks: string[] = [];
224
+ for (let i = 0; i < token.length; i += MAX_TOKEN_CHARS) {
225
+ chunks.push(token.slice(i, i + MAX_TOKEN_CHARS));
226
+ }
227
+ return chunks;
228
+ }
229
+
230
+ function sanitizeLongTokens(text: string): string {
231
+ return LONG_TOKEN_RE.test(text)
232
+ ? text.replace(LONG_TOKEN_RE, (token) => chunkToken(token).join(" "))
233
+ : text;
234
+ }
235
+
236
+ /** Extract leading ANSI SGR codes so wrapped lines can re-apply them. */
237
+ function extractAnsiPrefix(text: string): { prefix: string; rest: string } {
238
+ let end = 0;
239
+ while (end < text.length && text.slice(end, end + 2) === "\x1b[") {
240
+ const mPos = text.indexOf("m", end + 2);
241
+ if (mPos === -1) break;
242
+ end = mPos + 1;
243
+ }
244
+ return { prefix: text.slice(0, end), rest: text.slice(end) };
245
+ }
246
+
247
+ function stripAnsi(text: string): string {
248
+ return text.replace(ANSI_RE, "");
249
+ }
250
+
251
+ /** Word-wrap plain text at whitespace; assumes long tokens are pre-chunked. */
252
+ function wrapPlainText(text: string, maxWidth: number): string[] {
253
+ if (text.length <= maxWidth) return [text];
254
+ const words = text.split(/\s+/);
255
+ const result: string[] = [];
256
+ let current = "";
257
+ for (const word of words) {
258
+ if (!word) continue;
259
+ if (current.length === 0) {
260
+ current = word;
261
+ } else if (current.length + 1 + word.length <= maxWidth) {
262
+ current += " " + word;
263
+ } else {
264
+ result.push(current);
265
+ current = word;
266
+ }
267
+ }
268
+ if (current) result.push(current);
269
+ return result.length > 0 ? result : [text];
270
+ }
271
+
272
+ /** Wrap one ANSI-aware line to width, re-applying any leading SGR prefix. */
273
+ function wrapLine(line: string, width: number): string[] {
274
+ const plain = stripAnsi(line);
275
+ if (plain.length <= width) return [line];
276
+ const { prefix } = extractAnsiPrefix(line);
277
+ const wrappedLines = wrapPlainText(plain, width);
278
+ return wrappedLines.map((l) => prefix + l);
279
+ }
280
+
205
281
  export function makeComponent(lines: string[]) {
206
282
  return {
207
283
  render(width: number): string[] {
208
284
  const cap = Math.max(1, width - 2);
209
- return lines.map((l) => truncateLineToWidth(l, cap));
285
+ const output: string[] = [];
286
+ for (const line of lines) {
287
+ const sanitized = sanitizeLongTokens(line);
288
+ for (const wrapped of wrapLine(sanitized, cap)) {
289
+ output.push(wrapped);
290
+ }
291
+ }
292
+ return output;
210
293
  },
211
294
  invalidate() {},
212
295
  };
@@ -8,14 +8,20 @@ import { makeComponent } from "./index.ts";
8
8
  // `--resume` because pi re-renders saved tool results from session history.
9
9
  //
10
10
  // The user's crash log showed a 134-char sub-coder report sentence rendered
11
- // at terminal width 133 → 135 > 133. This test drives makeComponent at the
12
- // same width with the same shape and asserts no emitted line exceeds.
11
+ // at terminal width 133 → 135 > 133.
12
+ //
13
+ // v1.9.4 fixed this by truncating to width-2. v1.9.5 (PR #49 by
14
+ // @steverhoades) replaced the truncation with **word-wrap**: a 134-char
15
+ // sentence becomes two visual lines that together preserve the full
16
+ // sentence, instead of dropping the tail. Both behaviors satisfy the
17
+ // width invariant — this test asserts (a) no emitted line exceeds and (b)
18
+ // the wide content is preserved across the wrapped lines (no data loss).
13
19
 
14
20
  const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
15
21
  const visibleWidth = (s: string) => stripAnsi(s).length;
16
22
 
17
23
  describe("issue #51 — dispatch renderResult doesn't overflow", () => {
18
- it("caps a wide sub-coder report line to fit the pi-supplied width", () => {
24
+ it("wraps a wide sub-coder report line to fit the pi-supplied width", () => {
19
25
  const wideSentence =
20
26
  "There is **no `rate_limits` table**. The entire file defines a single class, `ConversationStore`, which manages only one SQLite table:";
21
27
  // Sanity: this is the exact 134-char shape from the user's crash log.
@@ -30,12 +36,23 @@ describe("issue #51 — dispatch renderResult doesn't overflow", () => {
30
36
  "(Ctrl+O to expand)",
31
37
  ]);
32
38
  const out = comp.render(133);
39
+ // Width invariant: no emitted line exceeds the pi-supplied width.
33
40
  const max = Math.max(...out.map((l) => visibleWidth(l)));
34
41
  expect(max).toBeLessThanOrEqual(133);
35
- // The truncated wide sentence keeps its prefix verbatim it's not blanked
36
- // out, just clipped with an ellipsis so the user can still read most of it.
37
- const truncated = out[3];
38
- expect(stripAnsi(truncated).startsWith("There is **no")).toBe(true);
42
+ // The wide sentence wraps but is preserved: rejoining the wrapped lines
43
+ // (collapsing whitespace) reproduces the original prose verbatim. That's
44
+ // the user-visible win over v1.9.4's truncate-with-ellipsis.
45
+ const wrappedRoundtrip = out
46
+ .map((l) => stripAnsi(l))
47
+ .join(" ")
48
+ .replace(/\s+/g, " ")
49
+ .trim();
50
+ expect(wrappedRoundtrip).toContain(
51
+ "There is **no `rate_limits` table**. The entire file defines a single class, `ConversationStore`, which manages only one SQLite table:",
52
+ );
53
+ // And it's wrapped, not truncated — there are more emitted lines than
54
+ // input lines because the long sentence split.
55
+ expect(out.length).toBeGreaterThan(7);
39
56
  });
40
57
 
41
58
  it("survives a narrow terminal (40 cols) without throwing", () => {
@@ -51,4 +68,13 @@ describe("issue #51 — dispatch renderResult doesn't overflow", () => {
51
68
  const comp = makeComponent(["short", "tiny"]);
52
69
  expect(comp.render(133)).toEqual(["short", "tiny"]);
53
70
  });
71
+
72
+ it("chunks long whitespace-free tokens (URLs/paths) so wrapping has room to split", () => {
73
+ // A 200-char URL-ish token has no spaces; without sanitizeLongTokens it
74
+ // would defeat word-wrap and overflow at any narrow width.
75
+ const url = "https://example.com/" + "a".repeat(200);
76
+ const comp = makeComponent([url]);
77
+ const out = comp.render(60);
78
+ expect(Math.max(...out.map((l) => visibleWidth(l)))).toBeLessThanOrEqual(60);
79
+ });
54
80
  });
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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.5] — 2026-06-18
6
+
7
+ ### Changed
8
+ - **Dispatch tool-result panel now word-wraps wide report lines instead of truncating them** ([PR #49](https://github.com/itayinbarr/little-coder/pull/49) by [@steverhoades](https://github.com/steverhoades), closes [#48](https://github.com/itayinbarr/little-coder/issues/48) and [#51](https://github.com/itayinbarr/little-coder/issues/51)). v1.9.4 fixed the width-overflow crash by truncating each panel line to `width - 2` with an ellipsis; v1.9.5 replaces the truncation with **word-wrap** so the full sentence survives across multiple visual lines — a strictly better UX for markdown sub-coder reports than dropping the tail at char 131. The cherry-picked commit (steverhoades's authorship preserved) keeps the wrap helpers (ANSI-aware prefix extraction, long-token chunking for whitespace-free URLs/paths/base64 that would otherwise defeat word-wrap, plain-text word-wrap), and the `makeComponent.render(width)` is rebased onto v1.9.4's `width - 2` safety margin so wide-unicode chars our char-count `visibleWidth` undercounts still can't sneak past pi's strict line-width check. Inspiration for the long-token sanitizer credited in-source to [openclaw-cn's tui-formatters.ts](https://github.com/mf-yang/openclaw-cn/commit/8c822da26f0a77396107a31f09df60817bf39c98). `issue-51-repro.test.ts` updated for wrap semantics (4 cases): no emitted line exceeds; the wrapped lines round-trip to the original 134-char sentence verbatim (no data loss); narrow terminal (40 cols) survives; 200-char URL-ish tokens get chunked so wrapping has room to split.
9
+
10
+ ### Notes for upgraders
11
+ - No CLI-flag or public-API changes. If you upgraded from v1.9.3 → v1.9.4 → v1.9.5, the user-visible difference between the last two is just wrap-vs-truncate in the dispatch tool's expanded report panel — both eliminate the crash. If you saw an ellipsis at the right edge of a sub-coder report on v1.9.4, you'll now see the full sentence wrapped onto the next line instead.
12
+
13
+ ---
14
+
5
15
  ## [v1.9.4] — 2026-06-18
6
16
 
7
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.9.4",
3
+ "version": "1.9.5",
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": {