Skip to content

fix: TUI rendering for log items - #1340

Closed
ndizazzo wants to merge 1 commit into
mainfrom
codex/tui-render-fix
Closed

fix: TUI rendering for log items#1340
ndizazzo wants to merge 1 commit into
mainfrom
codex/tui-render-fix

Conversation

@ndizazzo

@ndizazzo ndizazzo commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

WIP

Summary by CodeRabbit

  • Bug Fixes

    • Improved dashboard redraw behavior by clearing and invalidating terminal content, preventing stale or misplaced output.
    • Improved narrow-terminal handling with clearer guidance and consistent minimum-width behavior.
    • Preserved debug-message styling and restored the command prompt reliably after quitting.
  • Improvements

    • Runtime warnings now appear through the dashboard while it is active without disrupting the terminal interface.
  • Documentation

    • Added guidance for maintaining dashboard integrity and capturing terminal output for troubleshooting.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b8e0e25-18d2-4887-8dbe-e01d849ce75b

📥 Commits

Reviewing files that changed from the base of the PR and between be7ecaf and cb2137a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • .agents/skills/skippy-server/SKILL.md
  • AGENTS.md
  • crates/mesh-llm-host-runtime/src/runtime/tests/tracing_writer.rs
  • crates/mesh-llm-host-runtime/src/runtime/tracing_writer.rs
  • crates/mesh-llm-tui/src/output/formatting.rs
  • crates/mesh-llm-tui/src/output/mod.rs
  • crates/mesh-llm-tui/src/output/rendering/tui.rs
  • crates/mesh-llm-tui/src/output/tests/formatting.rs
  • crates/mesh-llm-tui/src/output/tests/mod.rs
  • crates/mesh-llm-tui/src/output/tests/rendering.rs
  • crates/skippy-server/Cargo.toml
  • crates/skippy-server/src/kv_integration/config.rs
  • docs/design/TESTING.md

📝 Walkthrough

Walkthrough

The change routes skippy-server diagnostics through structured tracing and strengthens TUI redraw behavior. It adds dashboard routing tests, physical-clear tests, width-boundary tests, and terminal dashboard integrity guidance.

Changes

Terminal dashboard integrity

Layer / File(s) Summary
Dashboard-aware server diagnostics
crates/skippy-server/..., crates/mesh-llm-host-runtime/..., .agents/skills/skippy-server/SKILL.md, AGENTS.md
skippy-server warnings now use structured tracing events. Runtime tests verify dashboard routing and preserved stderr context. Documentation prohibits direct terminal writes during alternate-screen TUI operation.
Backend dashboard redraw
crates/mesh-llm-tui/src/output/mod.rs, crates/mesh-llm-tui/src/output/rendering/tui.rs
The minimum dashboard width is 100 columns. A generic backend renderer clears the backend, resets Ratatui buffers, restores cursor state, and renders the dashboard.
Terminal redraw and width validation
crates/mesh-llm-tui/src/output/tests/*, docs/design/TESTING.md
Tests cover physical clearing, stale-cell restoration, cursor-query failures, debug event styling, narrow terminals, minimum width, and terminal capture evidence.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SkippyServer
  participant MeshTracingStderrWriter
  participant OutputSink
  participant Dashboard
  SkippyServer->>MeshTracingStderrWriter: emit structured warning
  MeshTracingStderrWriter->>OutputSink: route stderr event
  OutputSink->>Dashboard: deliver OutputEvent
Loading

Possibly related PRs

Suggested labels: experimental

Suggested reviewers: michaelneale

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: fixing TUI rendering for log items.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/tui-render-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request is currently a draft. Reviews will not take place until the PR is marked as ready for review.

@ndizazzo
ndizazzo force-pushed the codex/tui-render-fix branch from c611fa2 to cb2137a Compare August 19, 2026 18:45
@ndizazzo

Copy link
Copy Markdown
Collaborator Author

Review: right diagnosis, wrong remedy on the redraw half

I reproduced this on a fresh clone of main at be7ecaf3a on carrack (Arch, CPU backend build, tmux with a real TTY, Qwen/Qwen3-0.6B-GGUF), plus measured the redraw change directly. Summary up front:

Change Verdict
Route skippy-server diagnostics through tracing Right idea, currently drops the warnings entirely. Fixable.
backend.clear() + double swap_buffers() on every draw Wrong shape. Real problem, ~78-591x the cost, and it papers over the source.
PRETTY_TUI_MIN_DASHBOARD_WIDTH 60 -> 100 Should not ship in this PR. Doesn't fix what it appears aimed at, and costs every 80-column user their dashboard.
AGENTS.md / SKILL.md / TESTING.md guidance Ship it. Good, and independently useful.

1. The premise is real. I reproduced it.

The theory behind this PR — out-of-band writes to stderr desync ratatui's back buffer and corrupt the alternate screen permanently — is correct. I verified it directly rather than inferring it. With the dashboard running at 100x40, I wrote to the process's own stderr from outside, which is exactly what a stray eprintln! does:

printf '\033[10;5HXXXX-OUT-OF-BAND-WRITE-XXXX' > /proc/<pid>/fd/2

Four seconds and ~120 redraws later:

╭  Join Token  startup=ready  mesh=98df00faf44807c8dab9c7ab405d7b04─────────── private mesh invite ╮
│  IXXXX-OUT-OF-BAND-WRITE-XXXX07c8dab9c7ab405d7b04 (keep private):                                │

It never healed. Ratatui's diff has no way to know those cells changed. So: real bug, worth fixing.

What does and doesn't repair it on main today — this is the part that changes the recommendation:

Action Repairs corruption?
Terminal resize Yes (ratatui autoresize resets buffers)
Pressing R "Refresh" No
tmux detach / reattach No
Waiting / new log events No

And here's the thing: [R] Refresh is not implemented at all. rendering/mod.rs:182 pushes the status-bar hint push_status_key_hint(&mut left_spans, "R", "Refresh"), but grep -c "Char('r')" crates/mesh-llm-tui/src/output/dashboard.rs returns 0. The dashboard advertises a repair affordance that does nothing. My live test agrees — pressing r left the corruption untouched.

So the targeted fix is sitting right there: make R do a real invalidate. That is a one-shot terminal.clear() on an explicit user action, not a clear on every frame.

2. Nick's instinct about the constant repaint is correct — here are numbers

I measured bytes emitted through a CrosstermBackend over 60 frames at 100x40, main's draw path vs this PR's, on otherwise identical state:

Scenario main (ratatui diff) This PR (clear + invalidate) Ratio
Busy dashboard, 1 new log line/frame 213 B/frame (6.3 KB/s @30fps) 16,724 B/frame (490 KB/s) 78x
Idle dashboard, nothing changed 25 B/frame (0.7 KB/s) 14,779 B/frame (433 KB/s) 591x
\x1b[2J full-screen clears in 60 frames 0 60

The idle row is the one that matters. PRETTY_TUI_SNAPSHOT_INTERVAL is 250ms and PRETTY_TUI_REDRAW_INTERVAL is 33ms, so a dashboard where nothing is happening goes from ~25 bytes to ~15 KB per repaint. Most mesh-llm nodes are operated over SSH — mesh1/mesh2 are Orins on the other end of a network — and this is a half-megabyte per second of terminal traffic to display a screen that did not change.

Each of those 60 frames begins with \x1b[2J, and there is no synchronized-update (DECSET 2026) wrapper around the clear-then-repaint. Between the clear and the repaint landing, the terminal is free to present a blank screen. That is the flicker mechanism, and over SSH — where the repaint arrives in chunks rather than atomically — it gets worse, not better.

This was also a decision someone already made deliberately and this PR reverses by deleting the test that recorded it. tui_redraw_start_repositions_without_physical_clear (added in #1019) asserted !rendered.contains("[2J") with the rationale spelled out in the failure message: "redraw start should avoid a physical full-screen clear that flickers between frames." Deleting a test is a fine thing to do when the reasoning behind it is wrong — but the PR should say why it's wrong, and I don't think it is.

Suggested direction instead — invalidate on events, not on frames:

  1. Add a force_full_redraw: bool to the formatter. When set, render_if_dirty calls terminal.clear() once, then clears the flag.
  2. Set it from: the R key (finally implementing the advertised hint), alternate-screen re-entry, and resume-from-suspend.
  3. Leave the steady-state path on ratatui's diff, untouched.

That gets a user-reachable repair for every corruption case I could produce, at zero steady-state cost.

Minor, but while you're in there: backend_mut().clear() + two swap_buffers() is a hand-rolled reimplementation of ratatui's public Terminal::clear(), which is exactly backend.clear() + reset the back buffer. The double-swap does work — and the comment explaining it is good — but it also resets the front buffer, which draw already resets each frame, so it's doing strictly more than needed via a lower-level API. One line of terminal.clear() says the same thing.

3. The skippy-server tracing change silently drops all three warnings

This is the half of the PR I think is genuinely the right fix — kill the out-of-band writes at the source rather than mopping up after them. But as written, the three converted call sites produce no output at all under a default mesh-llm serve.

runtime_tracing_subscriber() (tracing_writer.rs:300-307) builds EnvFilter::from_default_env(), whose default directive is LevelFilter::ERROR (confirmed in tracing-subscriber 0.3.23, filter/env/mod.rs:289-293). The four add_directive calls cover mesh_inference, nostr_relay_pool, nostr_sdk, noq_proto::connection. Nothing covers skippy_server. So a tracing::warn! from skippy_server::kv_integration::config is filtered out before it ever reaches MeshTracingStderrWriter.

Reproduced with a standalone harness using the identical filter construction:

RUST_LOG unset (the real `mesh-llm serve` default):
  EnvFilter renders as: noq_proto::connection=warn,nostr_relay_pool=off,mesh_inference=info,nostr_sdk=warn,error
  skippy_server WARN emitted? false          <- dropped
  skippy_server ERROR emitted? true          <- control, proves the mechanism

RUST_LOG=skippy_server=warn:
  skippy_server WARN emitted? true

So eprintln! (always visible, corrupts the TUI) becomes tracing::warn! (never visible, corrupts nothing). The corruption goes away because the diagnostic goes away. "KV disk tier disabled: insufficient free space" is exactly the kind of thing an operator debugging a slow node needs to see.

The new test can't catch this because skippy_server_warning_routes_to_dashboard_while_tui_is_active constructs MeshTracingStderrWriter directly and calls write_all on it — it bypasses the EnvFilter entirely. It proves the writer routes correctly, which is true and worth testing, but not that the event is ever emitted.

Fix is one directive: .add_directive("skippy_server=warn".parse()?). Then extend the test to go through runtime_tracing_subscriber() so the filter is in the path.

(Unrelated thing I noticed while confirming this: mesh_inference=info doesn't match any crate lib name in the workspace — grep 'name = "mesh_inference"' crates/*/Cargo.toml finds nothing. That directive may be dead. Not this PR's problem, worth a separate look.)

4. The 60 -> 100 minimum width shouldn't ship here

I rendered the dashboard at 60 / 72 / 80 / 100 / 120 columns against main to check whether narrow widths are actually broken. They aren't:

  • At 60 and 72: renders correctly. Borders align, nothing overflows the frame, no garbage. It's cramped — nested process-table headers clip to POR, sta, PORT STATU DEVI — but it is a working dashboard.
  • At 100 (this PR's new floor): still truncated. PORT: … STATUS: DEVICE:, CTX: 8…, and the llama.cpp process table's first column renders as a single character (M / l). The status bar cuts mid-token at [R.
  • At 120: still truncated. PROCES, Consol, llama-, PORT: 40… STATUS: … DEVICE: ….

That's the argument against it. The truncation this bump appears aimed at is a property of the layout's column math, not of the threshold — 100 doesn't cure it, and neither does 120. Meanwhile 80 columns is the single most common terminal width there is, and under this change an 80-column user stops getting a cramped-but-working dashboard and starts getting mesh-llm dashboard needs >= 100 columns. Resize. I verified the 80-column dashboard renders fine on main in the live tmux session.

Related, and the thing I'd most want changed regardless of where the minimum lands: several tests were widened rather than fixed.

  • tui_full_screen_events_wraps_long_log_lines: 72 -> 100
  • tui_events_scroll_repaints_long_rows_cleanly: 72 -> 100
  • tui_events_filter_empty_state_repaints_over_previous_rows: 80 -> 100
  • tui_events_live_filter_repaints_to_matching_badge_rows: 80 -> 100
  • tui_join_token_wraps_and_redraws_in_a_constrained_frame: 60 -> 100

That last one is the clearest: its entire purpose is a constrained frame, and at 100 columns it isn't constrained anymore. Whatever these tests were catching at 72 and 80, they no longer catch. If the layout genuinely breaks below 100 then that's a bug to fix in the layout with those tests still at 72 and 80; if it doesn't break, the tests didn't need widening. Either way the current diff removes coverage without replacing it.

Also tui_narrow_terminal_renders_resize_guidance_instead_of_dashboard swaps PRETTY_TUI_MIN_DASHBOARD_WIDTH - 1 for a hardcoded 64. The relative form was better — it tracks the constant automatically. 64 is now a magic number that silently stops testing the boundary if the minimum ever moves again.

I'd split the width question into its own PR with its own reasoning, and let this one stay a rendering fix.

5. The scope problem underneath all of this

AGENTS.md gains a rule that runtime code "must not call print!/println!/eprint!/eprintln!", and the PR fixes 3 call sites in one file. The ratchet added by #1376 currently grandfathers 1,044 approved occurrences across 115 files (tools/xtask/data/console_print_allowlist.json), including 39 in crates/skippy-server/src and 80 in crates/mesh-llm-host-runtime/src outside tests.

I'm not suggesting this PR fix 1,044 call sites. I'm pointing out that this is why the every-frame clear is tempting — it's the only thing that defends against all 1,044 at once. But that tradeoff is: pay 78-591x the terminal bandwidth forever, to avoid auditing which of those call sites are reachable while the TUI owns the screen. And it's a defense that hides the remaining offenders rather than surfacing them, which makes the audit permanently harder.

The ratchet is the right long-term mechanism. What'd make this PR land cleanly is fixing the source it found, giving users the R key to repair whatever slips through, and filing the audit as its own issue.

6. One thing to double-check about the TESTING.md addition

The new capture-evidence section says: "Literal warning text in a plain capture is real output corruption; SGR escape bytes visible only in the raw capture are terminal styling." That's correct and genuinely useful advice — I nearly tripped on exactly this myself. My first capture at 100x40 showed (, ((, @@@@@ at the top of the screen and I read it as corruption; with capture-pane -e it's \033[38;5;39m( — the splash logo's ANSI art, rendering exactly as intended.

Worth asking directly, since it changes how much of this PR is load-bearing: was the original report partly a misread of an ANSI capture? The new tui_debug_event_line_keeps_badge_style_out_of_plain_text test asserts that plain event text contains no control characters, which reads like it was written to close a symptom of "escape bytes showing up as literal text in log rows." If that symptom was a capture artifact rather than a real defect, then the redraw change is solving a narrower problem than it looks — and the targeted R-key invalidate covers what's actually left.


What I'd do

Keep, with a fix: the skippy-server -> tracing conversion, plus skippy_server=warn in the filter, plus a test that goes through the real subscriber.

Replace: the every-frame clear, with a one-shot force_full_redraw flag driven by the R key (currently advertised and unimplemented), alt-screen re-entry, and resume-from-suspend. Use terminal.clear() rather than the hand-rolled double-swap. If a full clear ever does need to happen mid-stream, wrap it in synchronized output so the blank frame is never presented.

Split out: the width bump, into its own PR that either fixes the column math or argues the minimum on its own merits — and restore the widened tests to 72 / 80 in this one.

Ship as-is: the AGENTS.md, SKILL.md, and TESTING.md guidance.

Happy to spec the force_full_redraw change in detail if that's the direction — it's small, and the reproduction above gives us a concrete acceptance test: inject a write to /proc/<pid>/fd/2, press R, screen is clean.

Repro environment: carrack (Arch, RTX 5090 + 3080, 64GB), fresh clone at be7ecaf3a, just build backend=cpu, tmux 100x40 / 90x40 / 80x30 with a real TTY. Byte measurements taken through CrosstermBackend into a recording writer on a scratch worktree at the same commit; probe reverted, no changes left behind.

@ndizazzo

Copy link
Copy Markdown
Collaborator Author

Follow-up: visual A/B, measured in displayed screen states rather than bytes

@nickdizazzo asked whether the flicker could be shown visually instead of inferred from byte counts. It can, and the answer is sharper than the byte measurement was — including one correction to my own numbers above.

Method

Byte counts were the wrong unit; so is a screenshot. A screenshot samples one arbitrary instant and cannot resolve a 9 ms blank. What resolves it is the layer underneath the glyphs: the character grid a terminal computes after interpreting the escape sequences.

So I ran each binary on a real PTY sized 100x40 and recorded every write() it made, with timestamps, then replayed the stream through a terminal emulator (pyte) one write at a time. After each write I read the resulting grid. That gives the exact sequence of screen states and how many milliseconds each one was displayed. No escape byte is ever inspected as if it were content.

Both runs: same host (carrack), same PTY geometry, same model (Qwen/Qwen3-0.6B-GGUF:Q8_0), same isolated empty config, run back to back.

  • main be7ecaf0.76.0-rc4+gBE7ECA
  • this PR cb2137a0.76.0-rc4+gCB2137

Measurement window is 15.5 s of idle dashboard: model loaded, no traffic, nothing on screen changing.

1. The flicker is real, and it is a fully blank screen

idle, 15.5 s, nothing changing main this PR
writes to the terminal 183 119,577
bytes 1,517 685,561
bytes/sec 97.8 44,256
full-screen erases (ESC[2J) 0 41
screen states with zero non-blank cells 0 41 of 41

Every one of the 41 erases leaves a completely blank 100x40 grid — occupancy 0, not "mostly blank". Each blank state is held 1.9–29.2 ms (median 9.1 ms) before the repaint bytes arrive, totalling 344 ms of blank screen inside 15.5 s — 2.15% of wall-clock time, while the dashboard has nothing to redraw.

Whether a given blank window is actually painted depends on where the display refresh lands. Integrating the measured gaps against a 60 Hz refresh: ≈1.24 fully-blank painted frames per second, ≈2.14 at 120 Hz. That is not a subtle artifact; that is a dashboard that blinks black about once a second while idle.

Blank frame, rendered from the captured grid (t=64.432 s, occupancy 0, held 29 ms):

PR branch: fully blank frame during idle repaint

Correction to my earlier comment. I wrote ~490 KB/s, extrapolating 16.7 KB/frame at the 33 ms redraw interval. The idle dashboard does not actually repaint at 30 fps — measured, it is ~2.6 full repaints/s, so the real figure is ~44 KB/s, and the ratio against main is 452x, not 591x. Still ~44 KB/s of terminal traffic over SSH to draw a screen that did not change, and the flicker conclusion holds — it is now measured rather than extrapolated.

2. Credit where it is due: the fix does work for the bug it targets

I reproduced the original corruption the same way as before — an out-of-band write to the running process's stderr, i.e. exactly what a stray eprintln! does:

printf '\033[10;5HXXXX...' > /proc/<pid>/fd/2
  • main: the XXXX overwrites the Join Token line and never heals. Still there 6 s later, including after pressing R.
  • this PR: the same injection is healed in 170 ms.

main, 3 s after the injection and after pressing R — corruption still present:

main: injected corruption persists, R does nothing

this PR, 1.4 s after the same injection — clean:

PR branch: corruption healed

So the diagnosis is right and the mechanism works. The disagreement is only about the dose: this buys a 170 ms self-heal for a corruption that is rare, and pays for it with a black frame about once a second, forever, on every node.

3. [R] Refresh is still advertised and still does nothing

Confirmed visually on both branches: the status bar renders [R] (rendering/mod.rs:182), and pressing r changes nothing in the grid on main or on this PR. The repair path the UI already promises the user is unimplemented, on both sides of this diff.

That is the shape of the fix I would rather see: run this exact clear-and-full-repaint, but once, on demand, bound to the key that already claims to do it. Same 170 ms heal, zero steady-state cost. Worth also firing it on terminal focus-in if that is cheap to reach.

4. On the ((( / @@@@@ question

Answering my own open question from the previous comment: it is the splash logo, and it is present in the clean frame, before any injection.

main: clean baseline frame, logo present

So if the original report was based on reading a raw capture, that part of it was not corruption. Worth confirming before sizing the fix — the new TESTING.md note warns against exactly this mistake, which suggests it was already noticed.

Verdict — unchanged, now priced

  1. skippy-server tracing conversion — ship, with the one-line EnvFilter directive so the three warn! calls are not silently filtered to nothing. Without it the corruption goes away only because the diagnostic goes away, and the new test cannot catch that because it bypasses the filter.
  2. clear() + double swap_buffers() on every draw — do not ship. Rebind it to R as a one-shot repair.
  3. 60 → 100 min-width bump — drop. It renders fine at 60 and 72 and is still truncated at 100 and 120, so the threshold is not what is broken; it just costs every 80-column user the dashboard. The five widened tests should be fixed rather than widened, particularly the one whose purpose is a constrained frame.
  4. The AGENTS.md no-raw-stdout rule — keep. That is the durable fix for the 1,044 grandfathered call sites; the blanket clear only hides them.

Harness and both raw captures are reproducible on request.

ndizazzo added a commit that referenced this pull request Aug 19, 2026
…ing them

The MODEL/PROCESSES column was laid out with `Constraint::Fill(1)` while its
cell text was truncated to a separately computed width that had a minimum of
8. The two never had to agree, and at narrow panel widths they did not: the
text was fitted to 8 characters and then rendered into whatever `Fill(1)` had
left over, which at a 100-column terminal was a single character. The table
showed a `M` header over an `l` cell.

Raising the dashboard's minimum width does not fix this — the column is still
one character at 100 columns and still truncated at 120 — it only hides the
narrow cases while costing every 80-column user the dashboard entirely.

Columns are now solved explicitly and rendered with exact `Length`
constraints, so the layout is what the text was fitted to. When the panel
cannot afford every column it surrenders them from the right (STATE, then
PORT) rather than crushing the column that identifies the row. Truncating a
header to `STA` is not an improvement over dropping it.

Measured across 80/100/120/160/200 columns: every width now renders whole,
legible columns, and the model name stays recognizable at all of them.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 19, 2026
Live PTY testing found two gaps in the capture reader that the unit tests
could not: a stray write with no trailing newline was intercepted correctly
(the frame stayed clean) but then sat in the reader's buffer forever, and
raw escape bytes were forwarded into the dashboard verbatim.

Both matter. `print!` without a newline and `\r` progress counters are
ordinary output, and holding them until the next newline means the operator
never sees them. Rendering an unfiltered escape sequence into a dashboard
cell would move the cursor and corrupt the very frame capture exists to
protect.

The reader now polls with a 150 ms idle timeout and flushes whatever partial
line is pending, treats `\r` as a line end so progress counters surface, and
strips control characters before the text reaches a cell.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 19, 2026
Live PTY testing found two gaps in the capture reader that the unit tests
could not: a stray write with no trailing newline was intercepted correctly
(the frame stayed clean) but then sat in the reader's buffer forever, and
raw escape bytes were forwarded into the dashboard verbatim.

Both matter. `print!` without a newline and `\r` progress counters are
ordinary output, and holding them until the next newline means the operator
never sees them. Rendering an unfiltered escape sequence into a dashboard
cell would move the cursor and corrupt the very frame capture exists to
protect.

The reader now polls with a 150 ms idle timeout and flushes whatever partial
line is pending, treats `\r` as a line end so progress counters surface, and
strips control characters before the text reaches a cell.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 19, 2026
Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ndizazzo

Copy link
Copy Markdown
Collaborator Author

Superseded by #1382, which takes the diagnosis here and the half I said should ship, and goes after the root cause.

Keeping from this PR: the skippy-server tracing conversion (with the skippy_server=warn EnvFilter directive it needs — without it from_default_env() defaults to ERROR and all three warnings are dropped before the writer sees them), and the clear() + double swap_buffers() mechanism verbatim, bound to the R key the status bar already advertises.

Not keeping: the per-draw clear (measured 41 erases and 344 ms of blank screen in 15.5 s), the 60 → 100 min-width bump (does not fix the truncation — still one character wide at 100, still truncated at 120 — and costs every 80-column user the dashboard), and the five widened tests.

The root cause neither of us was addressing: the dashboard rendered to io::stderr(), the same descriptor as eprintln!, inherited plugin child stderr (plugin/runtime.rs:186), and native llama.cpp output. #1382 moves rendering to the controlling terminal and redirects fd 1/2 into the dashboard, so a stray write becomes an event row rather than screen damage. Also fixes the real truncation: MODEL was laid out with Constraint::Fill(1) while its text was fitted to a floored width, so the two disagreed and the column collapsed to one character.

Full A/B on a real PTY in #1382. Thanks for the diagnosis — the reproduction and the swap_buffers insight were both right.

@ndizazzo
ndizazzo marked this pull request as ready for review August 19, 2026 20:14
@github-actions
github-actions Bot requested a review from michaelneale August 19, 2026 20:14
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@ndizazzo ndizazzo closed this Aug 19, 2026
ndizazzo added a commit that referenced this pull request Aug 19, 2026
Quality / CI contracts and consistency failed on this branch: the
console-print ratchet still approved four eprintln! occurrences that
this PR converted to tracing —
crates/mesh-llm-host-runtime/src/runtime/tracing_writer.rs:321 and
crates/skippy-server/src/kv_integration/config.rs:110/120/152 — so the
checker reported them as "approved occurrence is missing or was
replaced".

Regenerated with `cargo run -p xtask -- repo-consistency
no-console-print --regen`. The diff is deletions only: 1044 legacy hits
across 115 files becomes 1040 across 113. No entry was added and no line
number moved, so the ratchet strictly tightened.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
ndizazzo added a commit that referenced this pull request Aug 19, 2026
`spawn_reader` discarded the `thread::Builder::spawn` result, so a failed
spawn still left fd 1 and fd 2 pointing at a pipe with nothing draining
it. The failure mode is the worst kind: everything works until the 64 KiB
pipe buffer fills, and then every write to stdout or stderr — in this
process and in every child that inherited those descriptors — blocks
forever, while the dashboard keeps painting as if nothing is wrong.

The spawn result is now propagated and `install` puts the saved
descriptors back before returning the error. Capture is optional at the
call site (`enter_terminal` treats a failure as "no capture"), so the
dashboard still comes up — just without interception.

Also stop dropping tabs from captured lines. `char::is_control` counts
`\t`, and llama.cpp's loader lines are tab-separated, so stripping it ran
two columns together; it degrades to a space instead.

Raised by CodeRabbit on #1382.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
ndizazzo added a commit that referenced this pull request Aug 19, 2026
Two ways the repair could fail to repair.

The handler matched only `Char('r')`, but the status bar reads
`R Refresh` and Shift+R arrives as `Char('R')` — pressing the advertised
key did nothing. It now accepts either, with the existing guard that
keeps both as filter text while the events filter is being edited.

`render_if_dirty` also cleared `pending_full_repaint` with `mem::take`
before the fallible repair ran. If the erase failed, the request was gone
and the next dirty render was an ordinary diff against a screen ratatui
still believed was intact — so the damage survived a key press the
operator had already made. The flag is now cleared only after
`repair_tui_terminal` succeeds, and the propagated error leaves `dirty`
set so the next render retries.

Raised by CodeRabbit on #1382.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
The dashboard rendered to `io::stderr()` — the same descriptor used by
`eprintln!`, tracing's default writer, inherited plugin child stderr, and
every noisy C library in the process. Sharing that descriptor is why a
stray write lands *on top of* the dashboard and never heals: ratatui
diffs against its own idea of the screen, so damaged cells already match
the buffer it believes is displayed.

Converting individual call sites cannot close this. `plugin/runtime.rs`
hands spawned plugins `Stdio::inherit()`, the staged llama.cpp runtime is
C, and third-party crates print whatever they like. So the fix is at the
descriptor layer:

- Render to the controlling terminal (`/dev/tty`, `CONOUT$`) instead of
  fd 2, giving the dashboard a channel nothing else holds. This is why
  `less`, `fzf`, and `vim` open the tty directly.
- With that in place, redirect fd 1 and fd 2 into the dashboard while it
  owns the screen. A reader thread turns each line into an `OutputEvent`,
  so stray output becomes a dashboard row instead of screen damage. The
  original descriptors are restored on exit and on the panic path.
- Wire `R` to a one-shot physical clear plus diff invalidation. The
  status bar has advertised `R Refresh` since the dashboard shipped with
  nothing behind it; it is the repair for damage capture cannot intercept,
  such as another process writing straight to the tty.

Also converts the three `skippy-server` KV-tier `eprintln!` calls and the
embedded-runtime-tracing `eprintln!` to `tracing`, and adds the
`skippy_server=warn` directive without which `EnvFilter::from_default_env`
(which defaults to ERROR) drops those warnings before the writer sees
them — converting them alone would have silenced the diagnostics rather
than routed them.

`mesh-llm-tui` keeps `#![forbid(unsafe_code)]`: the descriptor plumbing
uses `std::io::pipe` and rustix's safe `dup2`/`fcntl` wrappers.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
…ing them

The MODEL/PROCESSES column was laid out with `Constraint::Fill(1)` while its
cell text was truncated to a separately computed width that had a minimum of
8. The two never had to agree, and at narrow panel widths they did not: the
text was fitted to 8 characters and then rendered into whatever `Fill(1)` had
left over, which at a 100-column terminal was a single character. The table
showed a `M` header over an `l` cell.

Raising the dashboard's minimum width does not fix this — the column is still
one character at 100 columns and still truncated at 120 — it only hides the
narrow cases while costing every 80-column user the dashboard entirely.

Columns are now solved explicitly and rendered with exact `Length`
constraints, so the layout is what the text was fitted to. When the panel
cannot afford every column it surrenders them from the right (STATE, then
PORT) rather than crushing the column that identifies the row. Truncating a
header to `STA` is not an improvement over dropping it.

Measured across 80/100/120/160/200 columns: every width now renders whole,
legible columns, and the model name stays recognizable at all of them.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
Live PTY testing found two gaps in the capture reader that the unit tests
could not: a stray write with no trailing newline was intercepted correctly
(the frame stayed clean) but then sat in the reader's buffer forever, and
raw escape bytes were forwarded into the dashboard verbatim.

Both matter. `print!` without a newline and `\r` progress counters are
ordinary output, and holding them until the next newline means the operator
never sees them. Rendering an unfiltered escape sequence into a dashboard
cell would move the cursor and corrupt the very frame capture exists to
protect.

The reader now polls with a 150 ms idle timeout and flushes whatever partial
line is pending, treats `\r` as a line end so progress counters surface, and
strips control characters before the text reaches a cell.

Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
Refs #1340

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
Quality / CI contracts and consistency failed on this branch: the
console-print ratchet still approved four eprintln! occurrences that
this PR converted to tracing —
crates/mesh-llm-host-runtime/src/runtime/tracing_writer.rs:321 and
crates/skippy-server/src/kv_integration/config.rs:110/120/152 — so the
checker reported them as "approved occurrence is missing or was
replaced".

Regenerated with `cargo run -p xtask -- repo-consistency
no-console-print --regen`. The diff is deletions only: 1044 legacy hits
across 115 files becomes 1040 across 113. No entry was added and no line
number moved, so the ratchet strictly tightened.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
`spawn_reader` discarded the `thread::Builder::spawn` result, so a failed
spawn still left fd 1 and fd 2 pointing at a pipe with nothing draining
it. The failure mode is the worst kind: everything works until the 64 KiB
pipe buffer fills, and then every write to stdout or stderr — in this
process and in every child that inherited those descriptors — blocks
forever, while the dashboard keeps painting as if nothing is wrong.

The spawn result is now propagated and `install` puts the saved
descriptors back before returning the error. Capture is optional at the
call site (`enter_terminal` treats a failure as "no capture"), so the
dashboard still comes up — just without interception.

Also stop dropping tabs from captured lines. `char::is_control` counts
`\t`, and llama.cpp's loader lines are tab-separated, so stripping it ran
two columns together; it degrades to a space instead.

Raised by CodeRabbit on #1382.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
ndizazzo added a commit that referenced this pull request Aug 20, 2026
Two ways the repair could fail to repair.

The handler matched only `Char('r')`, but the status bar reads
`R Refresh` and Shift+R arrives as `Char('R')` — pressing the advertised
key did nothing. It now accepts either, with the existing guard that
keeps both as filter text while the events filter is being edited.

`render_if_dirty` also cleared `pending_full_repaint` with `mem::take`
before the fallible repair ran. If the erase failed, the request was gone
and the next dirty render was an ordinary diff against a screen ratatui
still believed was intact — so the damage survived a key press the
operator had already made. The flag is now cleared only after
`repair_tui_terminal` succeeds, and the propagated error leaves `dirty`
set so the next render retries.

Raised by CodeRabbit on #1382.

Refs #1340

Co-authored-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Signed-off-by: Nick DiZazzo <nick.dizazzo@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant