Skip to content

feat(render): capture terminal width at the wire boundary (kz8.4)#9

Merged
brandon-fryslie merged 3 commits into
mainfrom
bf/kz8.4-terminal-width-no-spawn
May 17, 2026
Merged

feat(render): capture terminal width at the wire boundary (kz8.4)#9
brandon-fryslie merged 3 commits into
mainfrom
bf/kz8.4-terminal-width-no-spawn

Conversation

@brandon-fryslie

Copy link
Copy Markdown
Contributor

Summary

Captures terminal width in the client's live shell context (where `COLUMNS`/`ioctl` are meaningful) and trusts it at the daemon. The daemon's own env reflects whichever shell launched it (minutes/hours ago) and can't measure the active terminal — only the live client can.

Bumps protocol to v3 with an optional `termCols` field on the render request. The daemon falls back to its own pure lookup chain when `termCols` is absent.

Test plan

  • `pnpm test` passes
  • `pnpm typecheck`, `pnpm lint`, `pnpm check:protocol` clean
  • `cargo build --release` clean

Eliminates the per-render `ps` → `stty` → `tput` cascade in
src/utils/terminal-width.ts (up to 3 spawns per render inside the
daemon, every render).

The shape: terminal width is data flowing in from the live client's
shell context — captured once by the Rust client (env $COLUMNS, then
TIOCGWINSZ ioctl on stderr) or the Node fallback (env, then
process.stdout.columns), threaded through the wire protocol as
RenderRequest.termCols, and consumed unchanged by the renderer. The
daemon's own env is meaningless here (it reflects whichever shell
launched the daemon hours ago), so only the client can answer.

`src/utils/terminal-width.ts` collapses from 117 lines (4 spawn sites)
to 33 lines of pure resolution: hint → env → stdout → null. Dead code
(getRawTerminalWidth, Windows mode con, parent-tty walking) is gone.
"terminal-width" is removed from LAUNCH_CATEGORIES so the metering
surface shrinks with the spawn surface.

Wire protocol bumps to v3 (TS + Rust in lockstep). Existing daemons
hit VERSION_MISMATCH and respawn from the new binary — the documented
restart path.

[LAW:single-enforcer] One source for terminal width: the client.
[LAW:dataflow-not-control-flow] Renderer runs the same code path
every time; the data (termCols set or not) decides what happens.

Tests: +7 terminal-width unit tests (purity, hint precedence, env
fallback, malformed input), +1 render-request integration test
proving termCols actually reaches the autoWrap layer. 704 tests pass.
"This module no longer spawns subprocesses" reads against a past state
that the future reader doesn't have context for. Replace with the
timeless shape ("is a pure resolver", "subprocess fallbacks belong at
the wire boundary") so the comment stays true regardless of when it's
read.
Copilot AI review requested due to automatic review settings May 17, 2026 08:54
@brandon-fryslie brandon-fryslie merged commit ceb736a into main May 17, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Captures terminal width in the live client context and forwards it to the daemon via a protocol v3 termCols field so rendering/wrapping decisions are based on the active terminal, not the daemon’s stale environment.

Changes:

  • Add optional termCols to the render request wire format (protocol bump to v3) and thread it through daemon → renderer.
  • Replace subprocess-based terminal-width detection with a pure resolver that prefers the wire hint, then ambient sources.
  • Add/adjust tests to cover terminal-width resolution and ensure termCols affects wrapping.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/terminal-width.test.ts Adds unit tests for the new pure getTerminalWidth(hint) contract.
test/render-request.test.ts Extends render helper to pass termCols and adds an integration-style wrap behavior assertion.
test/proc/launch.test.ts Updates launchSync test category to match removed terminal-width category.
src/utils/terminal-width.ts Removes subprocess probing; makes width resolution pure and accepts an optional hint.
src/proc/launch.ts Removes terminal-width launch category and updates launchSync comment.
src/powerline.ts Introduces RenderOptions and threads termCols into getTerminalWidth.
src/index.ts Captures terminal width at the client wire boundary and passes it to the daemon client call.
src/daemon/server.ts Passes termCols from the request into the renderer.
src/daemon/protocol.ts Bumps protocol to v3 and adds optional termCols to RenderRequest.
src/daemon/client.ts Sends termCols as part of the render request.
rust-client/src/main.rs Bumps protocol to v3 and sends optional termCols captured from env/ioctl.
.gitignore Ignores .claude/*.lock files.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/index.ts
Comment on lines +24 to +25
const cols = process.stdout.columns;
if (cols && cols > 0) return cols;
@@ -96,22 +30,5 @@ export function getTerminalWidth(): number | null {
return applyReserve(process.stdout.columns);
}

Comment on lines +32 to +37
Object.defineProperty(process.stdout, "columns", {
configurable: true,
writable: true,
value: savedStdoutColumns,
});
});
Comment on lines +283 to +306
const savedStdoutCols = process.stdout.columns;
Object.defineProperty(process.stdout, "columns", {
configurable: true,
writable: true,
value: undefined,
});
try {
const narrow = await render(BASE_HOOK, ARGS, { termCols: 60 });
const wide = await render(BASE_HOOK, ARGS, { termCols: 500 });
// termCols=60 is below the natural width of three segments + reserve
// (45); the wide variant has slack. Compare line counts in the visible
// output — narrow must produce strictly more lines than wide.
const narrowLines = plain(narrow).split("\n").filter(Boolean).length;
const wideLines = plain(wide).split("\n").filter(Boolean).length;
expect(narrowLines).toBeGreaterThan(wideLines);
} finally {
if (savedCols === undefined) delete process.env.COLUMNS;
else process.env.COLUMNS = savedCols;
Object.defineProperty(process.stdout, "columns", {
configurable: true,
writable: true,
value: savedStdoutCols,
});
}
brandon-fryslie pushed a commit that referenced this pull request May 17, 2026
* feat(render): capture terminal width at the wire boundary (kz8.4)

Eliminates the per-render `ps` → `stty` → `tput` cascade in
src/utils/terminal-width.ts (up to 3 spawns per render inside the
daemon, every render).

The shape: terminal width is data flowing in from the live client's
shell context — captured once by the Rust client (env $COLUMNS, then
TIOCGWINSZ ioctl on stderr) or the Node fallback (env, then
process.stdout.columns), threaded through the wire protocol as
RenderRequest.termCols, and consumed unchanged by the renderer. The
daemon's own env is meaningless here (it reflects whichever shell
launched the daemon hours ago), so only the client can answer.

`src/utils/terminal-width.ts` collapses from 117 lines (4 spawn sites)
to 33 lines of pure resolution: hint → env → stdout → null. Dead code
(getRawTerminalWidth, Windows mode con, parent-tty walking) is gone.
"terminal-width" is removed from LAUNCH_CATEGORIES so the metering
surface shrinks with the spawn surface.

Wire protocol bumps to v3 (TS + Rust in lockstep). Existing daemons
hit VERSION_MISMATCH and respawn from the new binary — the documented
restart path.

[LAW:single-enforcer] One source for terminal width: the client.
[LAW:dataflow-not-control-flow] Renderer runs the same code path
every time; the data (termCols set or not) decides what happens.

Tests: +7 terminal-width unit tests (purity, hint precedence, env
fallback, malformed input), +1 render-request integration test
proving termCols actually reaches the autoWrap layer. 704 tests pass.

* gitignore

* docs(terminal-width): describe what the module IS, not what it once WAS

"This module no longer spawns subprocesses" reads against a past state
that the future reader doesn't have context for. Replace with the
timeless shape ("is a pure resolver", "subprocess fallbacks belong at
the wire boundary") so the comment stays true regardless of when it's
read.
brandon-fryslie added a commit that referenced this pull request Jun 10, 2026
…ep (1qn) (#102)

Sheriff finding #9 [LAW:no-ambient-temporal-coupling]: handleRequest
scheduled setTimeout(() => shutdown(0), 50) after a newer-client version
mismatch and after the shutdown verb, encoding "the response has flushed
by then" as a magic delay nobody owned. A slow flush meant the client saw
a dead socket (transient/io_error) instead of the VERSION_MISMATCH
diagnostic (permanent/version_mismatch) — exactly during upgrades.

The flush completion now owns the ordering: handleRequest returns
{ resp, exitAfterFlush: number | null } — the exit wish as data
[LAW:effects-at-boundaries] — and respond() at the socket boundary
performs it via sock.end(frame, cb), whose callback fires on finish OR
error (total signal; a vanished peer cannot strand the exit). The
version-mismatch asymmetry is now literally the value
`req.v > PROTOCOL_VERSION ? 0 : null` [LAW:dataflow-not-control-flow].
The SIGKILL backstop inside shutdown() is untouched.

New integration test pins the contract: a v+1 client receives the flushed
VERSION_MISMATCH frame, then the daemon exits within budget.

Co-authored-by: elton-prawn <264370887+elton-prawn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants