Skip to content

perf(codemode): unchanged Code cards repeatedly wrap and repaint output #2017

Closed

Description

@NotMxx

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() invokes floatRows and the body callback on every render, with no per-component cache. invalidate() is empty.
  • cardBodyRows() in lib/shell-card.ts:193-201 wraps 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

  1. 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.
  2. Through the public decorateCodemodeTool renderer, 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.

  1. 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.
  2. 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.
  3. 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 in invalidate(). 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

  1. eduardocortezpena commented on Oct 10, 2026

    @eduardocortezpena

    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 /proc sampling 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%
    • the separate Agent-result card renderer: 28.4%

    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:

    Measure Baseline Candidate
    Median frame time 1942 ms 0.01 ms
    Maximum frame time 2058 ms 29.7 ms

    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 getPromptGuidelines mock failure occurs on both sides.

    Live follow-up and limitations

    Selected patched-session profile windows reduced cardBodyRows to 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.monitorEventLoopDelay at 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.

  2. added
    bugSomething isn't working
    status:approvedIssue approved by maintainer; PR may be opened
    on Oct 10, 2026
  3. dnlrsls commented on Oct 10, 2026

    @dnlrsls
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstatus:approvedIssue approved by maintainer; PR may be openedtype:bugBug fix

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions