feat(render): capture terminal width at the wire boundary (kz8.4)#9
Merged
Conversation
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.
There was a problem hiding this comment.
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
termColsto 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
termColsaffects 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 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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