From 273d8863938d835c5a589c3b1efa93414468e1ed Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Thu, 30 Jul 2026 23:50:07 +0200 Subject: [PATCH 1/2] fix(web): default the mirror to wrap on Panes herdr spawns get the desktop terminal's column width (190 in the issue reporter's session), but a phone in portrait shows roughly 45-50 columns. With wrap off by default, the mirror pans horizontally for most lines even though it is showing agent prose, not TUI tables, most of the time. Flip the default to wrap: true so the common case reads without panning; column-faithful no-wrap for TUI tables stays one tap away in View. Bump the display-prefs storage key from v3 to v4 so devices that already persisted wrap: false pick up the new default instead of being stuck on the old one. This is part 1 of #53 only. Part 2 (reading source: "recent-unwrapped" so wrap re-flows logical lines instead of re-wrapping desktop-width fragments) is out of scope here and keeps the issue open. Refs #53 Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-chat.test.tsx | 4 ++-- web/src/components/ansi-output.tsx | 7 +++++-- web/src/hooks/use-display-prefs.test.ts | 8 ++++---- web/src/hooks/use-display-prefs.ts | 9 ++++++--- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index 00c0197..58db070 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -164,7 +164,7 @@ describe("AgentChat — raw-terminal escape hatch", () => { it("shows the plain mirror (no buttons, menu as raw text) when raw terminal is on", () => { localStorage.setItem( - "collie:display-prefs:v3", + "collie:display-prefs:v4", JSON.stringify({ wrap: true, fontSize: 11, rawTerminal: true }), ); renderChat({ text: MENU_TEXT }); @@ -184,7 +184,7 @@ describe("AgentChat — raw-terminal escape hatch", () => { it("raw terminal bypasses the wizard too — the dialog shows verbatim, keys-pad drivable", () => { localStorage.setItem( - "collie:display-prefs:v3", + "collie:display-prefs:v4", JSON.stringify({ wrap: true, fontSize: 11, rawTerminal: true }), ); renderChat({ text: WIZARD_TEXT }); diff --git a/web/src/components/ansi-output.tsx b/web/src/components/ansi-output.tsx index f71bdc9..83dde2a 100644 --- a/web/src/components/ansi-output.tsx +++ b/web/src/components/ansi-output.tsx @@ -36,7 +36,10 @@ type MultiBlock = Extract; export interface AnsiOutputProps { text: string; className?: string; - /** false = no wrap; the block scrolls horizontally, preserving column alignment. Default false — enable Wrap in View for prose. */ + /** true = wrap; the block breaks at the viewport width instead of scrolling horizontally. Default + * true — the mirror is mostly agent prose, and a phone shows far fewer columns than the desktop + * width panes are spawned at, so panning was the common case. Disable Wrap in View for + * column-faithful TUI tables. */ wrap?: boolean; /** Monospace font size in px. Default 11. */ fontSize?: number; @@ -154,7 +157,7 @@ function preClass(wrap: boolean, className?: string): string { export const AnsiOutput = memo(function AnsiOutput({ text, className, - wrap = false, + wrap = true, fontSize = 11, query = "", currentMatch = -1, diff --git a/web/src/hooks/use-display-prefs.test.ts b/web/src/hooks/use-display-prefs.test.ts index 82343f8..daee3bc 100644 --- a/web/src/hooks/use-display-prefs.test.ts +++ b/web/src/hooks/use-display-prefs.test.ts @@ -2,14 +2,14 @@ import { renderHook, act } from "@testing-library/react"; import { useDisplayPrefs } from "./use-display-prefs"; // Minimal localStorage stub — Vitest/jsdom includes a real one but this ensures it's clean per test. -const STORAGE_KEY = "collie:display-prefs:v3"; +const STORAGE_KEY = "collie:display-prefs:v4"; describe("useDisplayPrefs", () => { beforeEach(() => localStorage.clear()); it("returns defaults when localStorage is empty", () => { const { result } = renderHook(() => useDisplayPrefs()); - expect(result.current.prefs).toEqual({ wrap: false, fontSize: 12, rawTerminal: false }); + expect(result.current.prefs).toEqual({ wrap: true, fontSize: 12, rawTerminal: false }); }); it("persists wrap=true and reloads it on mount", () => { @@ -75,12 +75,12 @@ describe("useDisplayPrefs", () => { it("falls back to defaults on malformed JSON", () => { localStorage.setItem(STORAGE_KEY, "not-json{{{"); const { result } = renderHook(() => useDisplayPrefs()); - expect(result.current.prefs).toEqual({ wrap: false, fontSize: 12, rawTerminal: false }); + expect(result.current.prefs).toEqual({ wrap: true, fontSize: 12, rawTerminal: false }); }); it("falls back to defaults when stored value is not an object", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(42)); const { result } = renderHook(() => useDisplayPrefs()); - expect(result.current.prefs).toEqual({ wrap: false, fontSize: 12, rawTerminal: false }); + expect(result.current.prefs).toEqual({ wrap: true, fontSize: 12, rawTerminal: false }); }); }); diff --git a/web/src/hooks/use-display-prefs.ts b/web/src/hooks/use-display-prefs.ts index 1119070..dd3e1d4 100644 --- a/web/src/hooks/use-display-prefs.ts +++ b/web/src/hooks/use-display-prefs.ts @@ -4,7 +4,10 @@ import { useCallback, useState } from "react"; // Safe to call in SSR contexts (localStorage guarded throughout). export interface DisplayPrefs { - /** Whether the mirror wraps long lines (default: false — preserves column alignment like desktop Herdr; enable Wrap in View for prose). */ + /** Whether the mirror wraps long lines (default: true). The mirror is mostly agent prose, and a + * phone shows ~45-50 columns against panes herdr spawns at desktop width (190 in one reporter's + * session), so panning was the common case, not the exception. Column-faithful no-wrap for TUI + * tables stays one tap away in View. */ wrap: boolean; /** Font size in px for the mirror pre (default: 12, range: 9–16). */ fontSize: number; @@ -17,10 +20,10 @@ export interface DisplayPrefs { rawTerminal: boolean; } -const STORAGE_KEY = "collie:display-prefs:v3"; +const STORAGE_KEY = "collie:display-prefs:v4"; const FONT_MIN = 9; const FONT_MAX = 16; -const DEFAULTS: DisplayPrefs = { wrap: false, fontSize: 12, rawTerminal: false }; +const DEFAULTS: DisplayPrefs = { wrap: true, fontSize: 12, rawTerminal: false }; function clampFont(n: number): number { return Math.max(FONT_MIN, Math.min(FONT_MAX, Math.round(n))); From 73cc7da1b3937650226c9ae4f288def86afe777d Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Thu, 30 Jul 2026 23:51:44 +0200 Subject: [PATCH 2/2] test(web): pin both wrap branches, not just the new default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping the default left the no-wrap rendering reachable only through the View toggle and asserted by nothing — the shape a later refactor drops without a single test noticing. Pin both: wrap breaks at the viewport, no-wrap stays column-faithful and pans. Refs #53 Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/ansi-output.test.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/web/src/components/ansi-output.test.tsx b/web/src/components/ansi-output.test.tsx index 16bd92c..f8c1b11 100644 --- a/web/src/components/ansi-output.test.tsx +++ b/web/src/components/ansi-output.test.tsx @@ -47,6 +47,30 @@ describe("terminal mirror colour space", () => { }); }); +// Wrap defaults ON (#53): the mirror is mostly agent prose and a phone shows far fewer columns than +// the desktop width panes are spawned at. The no-wrap branch is still the right rendering for TUI +// tables and box drawing, but it is now reachable ONLY through the View toggle — so it is exactly +// the kind of code a later refactor can drop without any test noticing. +describe("mirror line wrapping", () => { + function preFor(props: Partial>) { + const { container } = render(); + return container.querySelector("pre")!; + } + + it("wraps by default rather than making the block a horizontal panner", () => { + const cls = preFor({}).className; + expect(cls).toContain("whitespace-pre-wrap"); + expect(cls).not.toContain("overflow-x-auto"); + }); + + it("still pans, column-faithful, when wrap is turned off", () => { + const cls = preFor({ wrap: false }).className; + expect(cls).toContain("whitespace-pre"); + expect(cls).toContain("overflow-x-auto"); + expect(cls).not.toContain("whitespace-pre-wrap"); + }); +}); + // URLs printed by an agent are plain characters — the mirror finds them and wraps those ranges in // anchors. The invariants worth guarding are the ones a refactor would silently break: the text is // still exactly what the terminal printed, and nothing but http(s) ever becomes an href.