Repository navigation
perf(codemode): unchanged Code cards repeatedly wrap and repaint output #2017
Description
Before submitting
- I searched open and closed issues and did not find a report of this problem.
- I reviewed this report and removed credentials, tokens, private paths, hostnames, and other sensitive data.
The duplicate and privacy checks above were performed for this report. Related #994 tracks the broader render-pulse problem; this report isolates the remaining uncached CodemodeCard path. PR #1923 added completed-frame caching to quiet-tools, but not to lib/codemode-renderer.ts. #2011 already tracks the unrelated baseline loadout-test failure described below.
Problem
An active long session was sluggish in terminal interaction and tool/output handling. Repeated process samples showed the Pi main thread at approximately 100% of one logical CPU while background workers were nearly idle. RAM and sustained disk pressure did not explain those samples.
A user-authorized 8.52-second CPU profile (7,208 samples) localized the hot path:
| Function/path | Inclusive sampled time |
|---|---|
doRender |
93.35% |
CodemodeCard.render (lib/codemode-renderer.ts:101) |
85.87% |
These percentages overlap; they must not be added. Self-time hotspots included graphemeSegments (22.42%), visibleWidth (22.31%), splitIntoTokensWithAnsi (16.07%), and wrapSingleLine (8.65%). GC accounted for 2.32%.
Source inspection explains the repeated work:
CodemodeCard.render()invokesfloatRowsand the body callback on every render, with no per-component cache.invalidate()is empty.cardBodyRows()inlib/shell-card.ts:193-201wraps every supplied row before applying the collapsed preview slice. Three visible preview rows therefore do not bound wrapping work.- Differential terminal output still evaluates component layout before comparing output, so unchanged cards can be expensive even when no new terminal text is written.
The same uncached path was present in the reviewed main source at 166ff75e39f3175267a119ba3f18b391b5f03357. This report does not establish a runaway timer or identify which render request source was active during the profile.
Steps to reproduce
- Use gentle-pi 4.0.0 with Pi 1.1.0 and Node 24.18.0. Make baseline and candidate copies without changing the active installation.
- Through the public
decorateCodemodeToolrenderer, construct a completed text-result component. A minimal deterministic reproduction is:
import assert from "node:assert/strict";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { decorateCodemodeTool } from "./lib/codemode-renderer.ts";
import { CARD_STYLE, setCardStyle } from "./lib/shell-card.ts";
initTheme("dark");
setCardStyle(CARD_STYLE.NEON);
let paints = 0;
const theme = {
fg: (_role: string, text: string) => { paints++; return text; },
};
const tool = decorateCodemodeTool({ name: "codemode" } as any);
const context = {
args: { code: "return 1;" }, state: {}, isPartial: false,
expanded: false, showImages: false, isError: false,
};
const component = tool.renderResult!(
{ content: [{ type: "text", text: "hello 界 e\u0301\nsecond" }], details: { calls: [] } },
{ expanded: false, isPartial: false },
theme as any, context as any,
);
const first = component.render(80);
const afterFirst = paints;
assert.deepEqual(component.render(80), first);
assert.equal(paints, afterFirst, "unchanged frame must not repaint its body");The final assertion fails without caching: in the retained initial fixture, paint calls increased from 11 to 22 across two unchanged renders. The casts construct renderer-only test fixtures; no tool is executed.
- For the A/B workload, generate 300 output rows containing CJK, emoji, combining characters, ANSI escapes and repeated words. Render at 80 and 160 columns, collapsed and expanded, in neon and float styles: eight configurations, three paired replicas each, alternating baseline/candidate order between replicas.
- In each variant, construct the component and measure its first render separately, then render the unchanged component 20 times. Check exact ANSI-array equality on every repetition and between baseline and candidate. Call
invalidate()and verify exact output equality again. - Add a single-entry per-card rendered-lines cache to the candidate, keyed by normalized width, current
cardStyle()and computed awaiting-result/running state. Clear it ininvalidate(). No animation, executor, output-budget or shared wrapping changes are needed for this experiment.
Expected and actual behavior
Expected: unchanged Code-card components reuse their rendered frame. Width changes, style changes, theme invalidation and the arrival of a result must recompute correctly. New partial/final result components and expansion must retain fresh content.
Actual: every render recalculates body text and wrapping, including output hidden behind collapsed-preview limits.
The isolated cache prototype removed repeated painting in all eight configurations and preserved exact ANSI output in all 24 paired comparisons, including explicit invalidation. Four new regression tests passed, covering reuse, width/style/theme changes, cached running-call/result transitions and fresh partial/final/expanded content.
Representative medians for 20 unchanged component renders per replica:
| Style / columns / view | Baseline mean per render | Candidate mean per render | Paint callbacks across 20 renders, baseline → candidate |
|---|---|---|---|
| float / 80 / collapsed | 12.13 ms | <0.01 ms | 6,240 → 0 |
| float / 80 / expanded | 49.92 ms | <0.01 ms | 30,120 → 0 |
| float / 160 / expanded | 9.61 ms | <0.01 ms | 18,120 → 0 |
Important measurement boundaries:
- This is a synthetic repeated-component benchmark, not a full-session or real-terminal before/after experiment. Timing includes assertions; returning the same cached array also takes the assertion identity fast path. Do not extrapolate these ratios to whole-application speedups.
- First render and cache misses still require wrapping work. The prototype does not bound the initial cost of a huge first line or optimize collapsed preview construction.
- The active installation and running session were not patched or restarted. Live-session input latency and remote model/provider latency were not measured after the prototype.
- Baseline focused suites: 57 passed, one failed. Candidate focused suites: 61 passed, the same one failed. Both fail at the existing synthetic loadout fixture with
loadout.getPromptGuidelines is not a function, already tracked by test(codemode): loadout fixture omits required Pi 1.1.0 method #2011; no new functional failure was observed in these suites. - The full package suite, long-running retention and native tool-component integration were not tested for this prototype. Two active LSP checks timed out and are inconclusive, not clean results.
gentle-pi version
4.0.0 installed package; reviewed remote main 166ff75e39f3175267a119ba3f18b391b5f03357.
Pi version
@earendil-works/pi-coding-agent 1.1.0; Node 24.18.0.
Operating system
Linux
Relevant logs or error output (optional)
RED: unchanged Code cards do no additional painting on repeated renders
AssertionError: second render must reuse the frame, not repaint the body
22 !== 11
Candidate new regression tests: 4 passed, 0 failed
Baseline focused suites: 57 passed, 1 failed
Candidate focused suites: 61 passed, 1 failed
Same baseline/candidate failure: loadout.getPromptGuidelines is not a function
A/B: 24 paired comparisons, exact ANSI output matched
Repeated candidate paint callbacks: 0 in every configuration
Activity
eduardocortezpena commentedon Oct 10, 2026
Independent confirmation of this exact hot path on Gentle Shell 4.0.0 / Pi 1.1.0 / Node 22.23.1, Linux, fullscreen mode. We also reproduced it and developed a local fix; sharing measurements rather than opening a duplicate issue.
Live diagnosis
Passive approximately 1 Hz
/procsampling of process and main-thread CPU showed one interactive Pi main thread around 99% for extended periods. Subagent processes and the terminal/compositor had substantially lower CPU usage; available RAM was above 5 GiB and Pi processes had no swap in those samples.A user-authorized 20-second V8 profile of the hot session (500 microsecond sampling) showed overlapping inclusive times:
doRender: 96.3%cardBodyRows: 80.0%CodemodeCard.render: 57.0%Inclusive times overlap and are not additive. They identify wrapping/layout work even when terminal output itself is small.
Local candidate and controlled benchmark
The candidate caches Code-card rows by normalized width, running state and card style, clears the cache on
invalidate(), and returns a shallow copy. A separate shared-helper optimization stops wrapping further rows once a collapsed preview has enough physical rows.Using the real decorated codemode result renderer, 30 collapsed cards × 400 non-ASCII output lines, width 120, 20 repeated frames:
Rendered-output SHA-256 matched. These are synthetic render timings, not whole-agent speedup ratios. The maximum is not a separately measured chronological first-frame time.
Regression tests were observed RED before the patch and GREEN after; they also fail when applied to the unpatched upstream base. An independent reviewer reported zero mismatches across 4,560 dense-input old/new wrapping comparisons. The shared-helper optimization has an edge-case difference for sparse arrays, which currently audited callers do not supply; that should be addressed or documented before accepting that part.
Full local-suite comparison against upstream base dfe383f: baseline 5,457 tests / 156 failures; render candidate 5,459 / 155. No new failure names were found, but the suite is not green, and these results do not replace CI. The
getPromptGuidelinesmock failure occurs on both sides.Live follow-up and limitations
Selected patched-session profile windows reduced
cardBodyRowsto 0.5–1.9%. Idle main-thread CPU was around 0.2% with approximately 180k tokens in the last completed request. These were different session histories, not a controlled live A/B comparison. Later larger-transcript windows still showed substantial composition cost and uncached Agent-result work; residual lag remains.Event-loop delay was sampled with
perf_hooks.monitorEventLoopDelayat 10 ms resolution, not key-to-pixel/video latency. We do not claim to have fixed Pi's general long-session rendering issue or provider inference speed.We would like to submit the narrow card-cache fix against this issue after maintainer approval, with tests and reproducible benchmark evidence. The shared collapsed-preview optimization can be kept separate if preferred. No private conversations or raw profiles containing local paths are attached.
perf(codemode): cache unchanged rendered frames (#2061)
dnlrsls commentedon Oct 10, 2026
Fixed by #2061, merged into main as a3f5b91. Thank you for the independent confirmation and measurements.
The narrow fix caches unchanged Codemode frames by normalized width, card style and running state, and clears the cache on invalidate(). Regression tests cover frame reuse, invalidation and fresh result content. The focused checks and independent Codemode verification passed, and all PR CI checks were green.
Closing the uncached Codemode-card issue only. First-render/cache-miss wrapping costs and the sources of render requests remain unchanged; the broader render-pulse problem remains tracked in #994. We have not measured this merged fix in a live session or updated the active installation, so no whole-session speedup or resolution of general long-session lag is claimed. Windows launcher changes were excluded from this PR.