From 71675ab55d4b209eadc089e13d4a6d05175686dc Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:52:41 -0700 Subject: [PATCH 1/6] =?UTF-8?q?perf(coding-agent):=20paint=20the=20compose?= =?UTF-8?q?r=20in=20the=20first=20frame=20=E2=80=94=20no=20slide-up=20on?= =?UTF-8?q?=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first frame reserved eight blank rows where the composer would live and left them empty until InteractiveMode.init finished (slash command discovery, recent-session reads), so the prompt arrived late and read as sliding up into place. The first frame now paints the resting composer through a static component shared with the mounted zone: real hairline bytes, the same ghost placeholder, the exact resting row count. The handover swaps text, never position. --- CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 2 + .../src/modes/components/composer-chrome.ts | 42 +++++++++++- .../coding-agent/src/modes/first-frame.ts | 12 ++-- .../src/modes/interactive-mode.ts | 4 +- ...rame-paints-the-composer-instantly.test.ts | 68 +++++++++++++++++++ 6 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 50190f6ea5..4bb0e1db55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Changed +- Startup paints the composer itself instead of an empty reservation. The first frame used to reserve eight blank rows where the prompt would live and left them empty until the mode finished initializing — slash-command discovery, recent-session reads — so on a cold launch the composer arrived seconds late and read as it sliding up into place. The first frame now paints the resting composer (real hairline, ghost prompt, exact row count) from one static component shared with the mounted zone: the prompt is on screen from the first paint, and the handover swaps text, never position. - Multi-target `ast_grep` searches now execute concurrently while preserving globally ordered paging, totals, parse errors, cancellation, and target-order failures. - The vibe screens, the image-inspection call and an LSP hover code block draw no border of their own inside a tool block, so a block keeps one left edge; a tree connector remains only where a row belongs to the row above it, in the eval value tree, the grep line gutter, the job tree and the LSP reference tree. - A picture a terminal will not draw now leaves a row naming the file, the media type, the pixel size and the cause, in place of `[Image: image/png]`, including when a Kitty session cannot convert it to PNG. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bf178493eb..4fbfbaccce 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Changed +- Startup paints the composer itself instead of an empty reservation. The first frame used to reserve eight blank rows where the prompt would live and left them empty until the mode finished initializing — slash-command discovery, recent-session reads — so on a cold launch the composer arrived seconds late and read as it sliding up into place. The first frame now paints the resting composer (real hairline, ghost prompt, exact row count) from one static component shared with the mounted zone: the prompt is on screen from the first paint, and the handover swaps text, never position. + - Multi-target `ast_grep` searches now execute concurrently while preserving globally ordered paging, totals, parse errors, cancellation, and target-order failures. - The vibe screens, the image-inspection call and an LSP hover code block draw no border of their own inside a tool block, so a block keeps one left edge; a tree connector remains only where a row belongs to the row above it, in the eval value tree, the grep line gutter, the job tree and the LSP reference tree. - A picture a terminal will not draw now leaves a row naming the file, the media type, the pixel size and the cause, in place of `[Image: image/png]`, including when a Kitty session cannot convert it to PNG. diff --git a/packages/coding-agent/src/modes/components/composer-chrome.ts b/packages/coding-agent/src/modes/components/composer-chrome.ts index 6c9558d4d1..96ed6231ed 100644 --- a/packages/coding-agent/src/modes/components/composer-chrome.ts +++ b/packages/coding-agent/src/modes/components/composer-chrome.ts @@ -9,7 +9,7 @@ import type { ThinkingLevel } from "@veyyon/agent-core"; import type { Component, MouseRoutable, SgrMouseEvent } from "@veyyon/tui"; -import { Spacer, TERMINAL } from "@veyyon/tui"; +import { Spacer, TERMINAL, truncateToWidth } from "@veyyon/tui"; import { groundHairlineHex, groundTintFgAnsi } from "../theme/ground-tints"; import { theme } from "../theme/theme"; import { EMBER } from "./sun"; @@ -255,3 +255,43 @@ export class ComposerHairline implements Component { invalidate(): void {} } + +/** Rows the home screen reserves for the composer zone while the mode's init + * finishes (the real zone mounts into exactly this height). */ +export const COMPOSER_RESTING_ROWS = 8; + +/** The ghost prompt the real composer shows when its draft is empty. Owned + * here so the first frame's static composer and the live editor show the same + * sentence — the swap between them must be invisible. */ +export const COMPOSER_PLACEHOLDER = "ask anything · / for commands"; + +/** + * The composer at rest, painted by the FIRST frame so the prompt is on screen + * from the first paint instead of arriving when the mode's init finishes. + * It mirrors mountComposerZone's resting shape with static bytes — empty + * status row, hairline, pad, one ghost input row, pad, footline row, + * shortcuts row — no state, no animation, nothing to settle. The real zone + * mounts into the same rows, so the handover changes text, not position: + * nothing slides. + */ +export class StaticComposerFrame implements Component { + render(width: number): string[] { + const w = Math.max(1, width); + const clip = (row: string): string => truncateToWidth(row, w); + const hairline = new ComposerHairline().render(w)[0] ?? ""; + const inset = " ".repeat(COMPOSER_INSET_COLS); + const gutter = `${theme.getFgAnsi("borderAccent")}›\x1b[39m`; + return [ + "", + clip(hairline), + "", + clip(`${inset}${gutter} ${theme.fg("dim", COMPOSER_PLACEHOLDER)}`), + "", + "", + "", + "", + ]; + } + + invalidate(): void {} +} diff --git a/packages/coding-agent/src/modes/first-frame.ts b/packages/coding-agent/src/modes/first-frame.ts index e83cea00b9..64009f795e 100644 --- a/packages/coding-agent/src/modes/first-frame.ts +++ b/packages/coding-agent/src/modes/first-frame.ts @@ -36,6 +36,7 @@ import { } from "@veyyon/tui"; import { logger } from "@veyyon/utils"; import { settings } from "../config/settings-instance"; +import { StaticComposerFrame } from "./components/composer-chrome"; import { WelcomeComponent } from "./components/welcome"; import { HomeAnchorLayout } from "./controllers/home-anchor-layout"; import { applyGroundPaint, setDetectedTerminalGround } from "./theme/ground-tints"; @@ -48,10 +49,10 @@ import { flushPendingTtyInput } from "./tty-input-flush"; * and the bottom margin. The zone does not exist yet, and the centring is a * share of the slack below the card ({@link HomeAnchorLayout}), so a stand-in * of the right height is what puts the card where the mounted home screen puts - * it. An estimate off by a row moves the card by at most one row when the real - * composer mounts, and `HomeAnchorLayout.sync` corrects it on that frame. + * it. The stand-in is the composer itself — {@link StaticComposerFrame} paints + * the resting zone's exact row count with its real chrome, so the prompt is on + * screen from the first paint and the mounted zone swaps text, not position. */ -const COMPOSER_RESERVE_ROWS = 8; /** Inputs used to decide whether the launch card may be painted this early. */ export interface FirstFrameDecisionOptions { @@ -116,7 +117,10 @@ export function paintFirstFrame(version: string): FirstFrame { hero, new Spacer(1), layout.bottomFill, - new Spacer(COMPOSER_RESERVE_ROWS), + // The composer at rest, painted NOW: the prompt is on screen from the + // first paint, and the real zone mounts into the same rows when init + // finishes — a text handover, not a slide. + new StaticComposerFrame(), ]; for (const child of children) ui.addChild(child); // No frame has been composed, so this measures the children directly. diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 8ecc81e231..7a4965a92e 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -151,6 +151,7 @@ import type { BashExecutionComponent } from "./components/bash-execution"; import { ChatBlock, type ChatBlockHost } from "./components/chat-block"; import { COMPOSER_INSET_COLS, + COMPOSER_PLACEHOLDER, ComposerHairline, mountComposerZone, QuietZoneLine, @@ -312,9 +313,6 @@ const EDITOR_RESERVED_ROWS = 12; const EDITOR_FALLBACK_ROWS = 24; const EDITOR_MIN_CHROME_ROWS = 4; // rows reserved for transcript + status on small terms const EDITOR_MIN_RENDERED_ROWS = 3; // bordered editor floor: top+bottom border + 1 content row -/** The idle composer's ghost text. Single spaces around the interpunct — the - * double-spaced version read as uneven gaps. */ -const COMPOSER_PLACEHOLDER = "ask anything · / for commands"; /** * Consecutive provider-killed goal turns tolerated before goal mode stops diff --git a/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts new file mode 100644 index 0000000000..d215432740 --- /dev/null +++ b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it, setSystemTime } from "bun:test"; +import { + COMPOSER_PLACEHOLDER, + COMPOSER_RESTING_ROWS, + ComposerHairline, + StaticComposerFrame, +} from "@veyyon/coding-agent/modes/components/composer-chrome"; +import { initTheme } from "@veyyon/coding-agent/modes/theme/theme"; +import { visibleWidth } from "@veyyon/tui/utils"; + +/** + * WHY: startup used to paint eight BLANK rows where the composer would live, + * so the prompt appeared only when InteractiveMode.init finished — reading as + * the composer "sliding up" seconds after launch. The first frame now paints + * a static resting composer into those rows, and the real zone mounts into + * the same height, so the handover changes text and never position. + * + * These tests close the class "the first-frame composer drifts from the + * mounted one": the static frame must render exactly the reserved row count, + * must carry the real hairline bytes (same owner), must show the shared ghost + * placeholder, and must be time-invariant — nothing on it may animate. + */ + +beforeAll(async () => { + await initTheme(false); +}); + +describe("static first-frame composer", () => { + it("renders exactly the resting zone's row count", () => { + const frame = new StaticComposerFrame(); + expect(frame.render(100)).toHaveLength(COMPOSER_RESTING_ROWS); + }); + + it("shows the hairline with its real bytes", () => { + const frame = new StaticComposerFrame(); + const rows = frame.render(100); + const hairline = new ComposerHairline().render(100)[0]; + expect(rows).toContain(hairline); + }); + + it("shows the shared ghost placeholder inset by the composer margin", () => { + const frame = new StaticComposerFrame(); + const inputRow = frame.render(100).find(row => row.includes(COMPOSER_PLACEHOLDER)); + expect(inputRow).toBeDefined(); + expect(visibleWidth(inputRow as string)).toBeLessThanOrEqual(100); + }); + + it("never animates: identical bytes at different wall-clock times", async () => { + const frame = new StaticComposerFrame(); + const first = frame.render(100); + await Bun.sleep(30); + setSystemTime(new Date(Date.now() + 5_000)); + try { + expect(frame.render(100)).toEqual(first); + } finally { + setSystemTime(); + } + }); + + it("clips to narrow widths without throwing or wrapping", () => { + const frame = new StaticComposerFrame(); + for (const width of [1, 10, 40]) { + const rows = frame.render(width); + expect(rows).toHaveLength(COMPOSER_RESTING_ROWS); + for (const row of rows) expect(visibleWidth(row)).toBeLessThanOrEqual(width); + } + }); +}); From 51d6f6cd5bc3523e68baf70db8f740f58c3bea76 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:06:00 -0700 Subject: [PATCH 2/6] chore(changelog): render the root changelog after the merge --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27903cd868..f75dda6f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ ### Changed -- Startup paints the composer itself instead of an empty reservation. The first frame used to reserve eight blank rows where the prompt would live and left them empty until the mode finished initializing — slash-command discovery, recent-session reads — so on a cold launch the composer arrived seconds late and read as it sliding up into place. The first frame now paints the resting composer (real hairline, ghost prompt, exact row count) from one static component shared with the mounted zone: the prompt is on screen from the first paint, and the handover swaps text, never position. - Classified runner output (cargo, bun, Go, ctest, dotnet, clippy, golangci-lint, Gradle lint, pytest, and tsc/eslint-family) now opens with a result-contract header: `[clean] ` or `[errors]` / `[errors N] `. The header is the verdict and the body contains retained diagnostics. +- Startup paints the composer itself instead of an empty reservation. The first frame used to reserve eight blank rows where the prompt would live and left them empty until the mode finished initializing — slash-command discovery, recent-session reads — so on a cold launch the composer arrived seconds late and read as it sliding up into place. The first frame now paints the resting composer (real hairline, ghost prompt, exact row count) from one static component shared with the mounted zone: the prompt is on screen from the first paint, and the handover swaps text, never position. - Multi-target `ast_grep` searches now execute concurrently while preserving globally ordered paging, totals, parse errors, cancellation, and target-order failures. - The vibe screens, the image-inspection call and an LSP hover code block draw no border of their own inside a tool block, so a block keeps one left edge; a tree connector remains only where a row belongs to the row above it, in the eval value tree, the grep line gutter, the job tree and the LSP reference tree. - A picture a terminal will not draw now leaves a row naming the file, the media type, the pixel size and the cause, in place of `[Image: image/png]`, including when a Kitty session cannot convert it to PNG. From 41112ad7d06ad403e937d1267063140acde12e2d Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:13:16 -0700 Subject: [PATCH 3/6] test(composer): state what the first-frame suite does not catch The suite claimed to close "the first-frame composer drifts from the mounted one". It compares the static frame against COMPOSER_RESTING_ROWS, which is a hand-maintained claim about the resting zone rather than a measurement of it, so a change to what the zone renders leaves the constant, the frame and every assertion agreeing while the handover moves the card. --- ...rame-paints-the-composer-instantly.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts index d215432740..18b9325a9b 100644 --- a/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts +++ b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts @@ -15,10 +15,21 @@ import { visibleWidth } from "@veyyon/tui/utils"; * a static resting composer into those rows, and the real zone mounts into * the same height, so the handover changes text and never position. * - * These tests close the class "the first-frame composer drifts from the - * mounted one": the static frame must render exactly the reserved row count, - * must carry the real hairline bytes (same owner), must show the shared ghost - * placeholder, and must be time-invariant — nothing on it may animate. + * What these tests close: the static frame must render exactly + * COMPOSER_RESTING_ROWS, must carry the real hairline bytes from the same + * owner the mounted zone uses, must show the shared ghost placeholder, and + * must be time-invariant — nothing on it may animate. + * + * WHAT THEY DO NOT CATCH, stated plainly: they do not compare the static + * frame against the MOUNTED zone's rendered height. COMPOSER_RESTING_ROWS is + * a hand-maintained claim about what the real zone occupies at rest, and + * deriving the true height needs the live status, editor, footline and + * shortcut components, which this suite does not construct. Change what the + * resting zone renders — a footline that gains a row, a status line that + * stops collapsing — and the constant, the static frame and these assertions + * all still agree with each other while the handover moves the card by a row. + * `composer-zone-mount.test.ts` pins the zone's composition; that pairing is + * the current guard, not a derivation. */ beforeAll(async () => { From 0412a13e385eb990c2e1d2c99b2b206ec823f1f0 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:04:28 -0700 Subject: [PATCH 4/6] test(modes): derive the composer's resting height from the mounted zone COMPOSER_RESTING_ROWS and StaticComposerFrame's row literal were two hand-maintained statements of the same number, and nothing compared either against what the real zone renders. A changed bottom margin or an added pad row inside mountComposerZone moved the mounted height while the constant, the static frame and the suite kept agreeing with each other. The new suite constructs a real InteractiveMode, runs the real init, slices the zone as the root children from statusContainer onward, and sums what each one renders at 40, 100 and 200 columns. Mutation-gated: COMPOSER_RESTING_ROWS 8 to 9, COMPOSER_BOTTOM_MARGIN_ROWS 1 to 2, an extra CardPadRow in mountComposerZone, and a dropped row in StaticComposerFrame each turn it red. Refs #901 --- ...rame-paints-the-composer-instantly.test.ts | 113 ++++++++++++++++-- 1 file changed, 102 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts index 18b9325a9b..84ffe09ddb 100644 --- a/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts +++ b/packages/coding-agent/test/the-first-frame-paints-the-composer-instantly.test.ts @@ -1,12 +1,22 @@ -import { beforeAll, describe, expect, it, setSystemTime } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@veyyon/agent-core"; +import { ModelRegistry } from "@veyyon/coding-agent/config/model-registry"; +import { resetSettingsForTest, Settings } from "@veyyon/coding-agent/config/settings"; import { COMPOSER_PLACEHOLDER, COMPOSER_RESTING_ROWS, ComposerHairline, StaticComposerFrame, } from "@veyyon/coding-agent/modes/components/composer-chrome"; +import { InteractiveMode } from "@veyyon/coding-agent/modes/interactive-mode"; import { initTheme } from "@veyyon/coding-agent/modes/theme/theme"; +import { AgentSession } from "@veyyon/coding-agent/session/agent-session"; +import { AuthStorage } from "@veyyon/coding-agent/session/auth-storage"; +import { SessionManager } from "@veyyon/coding-agent/session/session-manager"; +import type { Component } from "@veyyon/tui"; import { visibleWidth } from "@veyyon/tui/utils"; +import { TempDir } from "@veyyon/utils"; /** * WHY: startup used to paint eight BLANK rows where the composer would live, @@ -20,16 +30,18 @@ import { visibleWidth } from "@veyyon/tui/utils"; * owner the mounted zone uses, must show the shared ghost placeholder, and * must be time-invariant — nothing on it may animate. * - * WHAT THEY DO NOT CATCH, stated plainly: they do not compare the static - * frame against the MOUNTED zone's rendered height. COMPOSER_RESTING_ROWS is - * a hand-maintained claim about what the real zone occupies at rest, and - * deriving the true height needs the live status, editor, footline and - * shortcut components, which this suite does not construct. Change what the - * resting zone renders — a footline that gains a row, a status line that - * stops collapsing — and the constant, the static frame and these assertions - * all still agree with each other while the handover moves the card by a row. - * `composer-zone-mount.test.ts` pins the zone's composition; that pairing is - * the current guard, not a derivation. + * The last suite closes the drift: it constructs a real InteractiveMode, + * runs the real init, and sums what the MOUNTED zone renders at rest, so the + * static frame is compared against the live components rather than against a + * second copy of the same number. A footline that gains a row, a status line + * that stops collapsing, an extra pad row inside mountComposerZone or a + * changed bottom margin all move that sum and fail here. + * + * WHAT IT DOES NOT CATCH, stated plainly: it measures the resting state of a + * fresh session on the home screen at three widths. A zone height that only + * diverges under state the resting session never reaches — a live status + * message, a multi-line draft, a mounted hook widget — is outside it, and so + * is a divergence that appears only at a width not in the list. */ beforeAll(async () => { @@ -77,3 +89,82 @@ describe("static first-frame composer", () => { } }); }); + +describe("the mounted composer zone occupies the static frame's rows", () => { + let authStorage: AuthStorage; + let mode: InteractiveMode; + let session: AgentSession; + let tempDir: TempDir; + + beforeEach(async () => { + // Keep ProcessTerminal.start() from probing the real terminal during init(). + vi.spyOn(process.stdout, "write").mockReturnValue(true); + vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); + vi.spyOn(process.stdin, "pause").mockReturnValue(process.stdin); + vi.spyOn(process.stdin, "setEncoding").mockReturnValue(process.stdin); + if (typeof process.stdin.setRawMode === "function") { + vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); + } + + resetSettingsForTest(); + tempDir = TempDir.createSync("@pi-first-frame-resting-height-"); + await Settings.init({ inMemory: true, cwd: tempDir.path() }); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); + + session = new AgentSession({ + agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated(), + modelRegistry, + }); + mode = new InteractiveMode(session, "test"); + vi.spyOn(mode.statusLine, "watchBranch").mockImplementation(() => {}); + vi.spyOn(mode, "ensureLoadingAnimation").mockImplementation(() => {}); + await mode.init(); + }); + + afterEach(async () => { + mode?.stop(); + vi.restoreAllMocks(); + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + resetSettingsForTest(); + }); + + /** + * The zone is the tail of the root children starting at the first part + * mountComposerZone adds. Deriving the slice this way rather than from a + * child count means a row added inside mountComposerZone, or anything + * mounted after the zone, lands in the measurement instead of escaping it. + */ + function mountedZone(): Component[] { + const children = mode.ui.children; + const start = children.indexOf(mode.statusContainer); + expect(start, "statusContainer must be mounted as a root child").toBeGreaterThanOrEqual(0); + return children.slice(start); + } + + function restingRows(width: number): number { + return mountedZone().reduce((rows, child) => rows + child.render(width).length, 0); + } + + it("renders the same number of rows the first frame reserved", () => { + expect(restingRows(100)).toBe(COMPOSER_RESTING_ROWS); + }); + + it("renders the same number of rows the static frame paints", () => { + const width = 100; + expect(restingRows(width)).toBe(new StaticComposerFrame().render(width).length); + }); + + it("holds that height across the widths the static frame clips to", () => { + const frame = new StaticComposerFrame(); + for (const width of [40, 100, 200]) { + expect(restingRows(width), `width ${width}`).toBe(frame.render(width).length); + } + }); +}); From addfd9971387187d76dbe63462f76f83fb3702ed Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:24:04 -0700 Subject: [PATCH 5/6] proof(scenes): record the composer at a cold launch Every other scene starts the app as SCENE_COMMAND, so kitty is already running it by the time the window is placed and ffmpeg attaches, and the startup frames were the one surface no scene could reach. This scene runs a login shell and types the launch line after the recording has started, so the window between the command and the first prompt is in the clip. Recorded at the repository's capture config on both arms. The published pair holds the composer band, because the welcome hero draws a tip picked at random on every launch and two full-screen arms would differ by that tip as well as by the change. Refs #901 --- proof/scenes/first-frame.sh | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100755 proof/scenes/first-frame.sh diff --git a/proof/scenes/first-frame.sh b/proof/scenes/first-frame.sh new file mode 100755 index 0000000000..3606232bb3 --- /dev/null +++ b/proof/scenes/first-frame.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# The first frame of a cold launch: what the terminal shows between the shell +# handing off and InteractiveMode.init finishing. +# +# WHY THIS SCENE EXISTS. Every other scene starts the app as SCENE_COMMAND, so +# kitty is already running it by the time the window is placed and ffmpeg +# attaches. The launch is over before the recorder sees anything, which makes +# the startup frames the one surface no scene could reach. This scene runs a +# shell instead and types the launch line after the recording has started, so +# the frames between the command and the first prompt are in the clip. +# +# WHAT IT SHOWS. The composer zone at rest. Before the change the startup frame +# reserved those rows blank and the prompt appeared only when init finished, +# which reads as the composer sliding up out of the floor. After it the resting +# composer is painted by the first frame and init replaces text in rows that +# never move. +# +# HOW TO RECORD THE PAIR. The subject is motion, so the artifact is the pair of +# clips, not a pair of stills: +# +# SCENE_COMMAND='bash -l' SCENE_MOTION_FLOOR=0 \ +# proof/docker/record-x11.sh proof/scenes/first-frame.sh +# SCENE_COMMAND='bash -l' SCENE_MOTION_FLOOR=0 \ +# proof/docker/record-x11-before.sh proof/scenes/first-frame.sh +# +# Then hold both arms on the composer band, at the same crop, so the pair +# regenerates from one command: +# +# for arm in proof/captures/x11 proof/captures/x11/before; do +# ffmpeg -y -i "$arm/first-frame.mp4" -vf crop=1600:300:0:700 \ +# "$arm/first-frame-composer-band.mp4" +# done +# +# The band is the crop and not a preference. The welcome hero draws a tip +# picked at random on every launch, so two full-screen arms differ by the +# change and by whichever tip each one drew, and a pair that differs for an +# unrelated reason is not a pair. The band holds the rows the change is about +# and nothing that varies between launches. +# +# `startup.quiet` would remove the hero and the tip with it, and it is the +# wrong instrument: quiet startup skips the work whose duration the blank +# window is made of, and measured here it shrank the window from about a third +# of a second to two frames. An arm that no longer contains the transition is +# not an arm of this pair. +# +# The motion floor is zero here and nowhere else. The gate measures unique +# frames per second across the whole take and exists to catch a capture that +# stuttered; this take is a one-second transition inside a session that is +# still by design, so its rate of change is legitimately near zero and the +# default floor of 12 rejects a correct recording. The take still has to show +# the transition, which is what the two bracketing stills check. +# +# The before arm holds the changed files at the base commit for the length of +# the run. Both arms type the same line at the same rate into the same window, +# so the only difference between them is the change. +# +# WHAT IT DOES NOT SHOW. One launch on one machine at one window size. A first +# frame that only regresses under a slow disk, a cold module cache or a profile +# with more startup work than the seeded one is outside it, and so is any part +# of startup that finishes before the shell hands off. + +# The launch line is typed, and a doubled character in it starts nothing at all: +# a shell scene has no model latency to hide behind, so it types at the slower +# rate the install row settled on rather than the default. +TYPE_DELAY="${TYPE_DELAY:-70}" + +# The shell's own prompt has to be on screen before anything is typed, or the +# launch line lands in a terminal that is still starting and the clip opens +# mid-command. +settle 4 +shot shell + +# The subject starts here. Everything from this point to the prompt is the +# handover the pair is about. +submit "bun /repo/packages/coding-agent/src/cli.ts --model local/qwen2.5-1.5b" + +# The still that matters is taken in the window between the command and the +# prompt, so it is a short pause rather than a settle: `settle` waits for the +# screen to stop changing, which is the end of the very transition this shot +# is of. `shot` aborts the take when a frame is byte-identical to the one +# before it, so a launch that drew nothing cannot be published as a recording +# of one. +pause 0.8 +shot launching + +# No still is taken here. On the after arm the composer is already on screen at +# the shot above, so a second frame of it is byte-identical and `shot` ends the +# take; that identity is the change, not a fault in the scene. The guard still +# runs, because an arm where the prompt never arrives is not an arm. +expect_screen "ask anything" 60 + +# The composer has to be reachable, not merely painted: a static frame that the +# real zone never took over would look identical in a still and accept nothing. +# Typed, not sent, so the scene needs no model. +t "hello" +settle 3 +shot accepted From c88da0b39db0370b2e347386fed3046782470983 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:39:39 -0700 Subject: [PATCH 6/6] polish(first-frame): drop a doc block left behind by the reserve constant Replacing COMPOSER_RESERVE_ROWS with StaticComposerFrame removed the constant and left its documentation floating between the imports and the first interface, describing a declaration that no longer exists. The height contract it stated belongs at the call site that now depends on it. The scene note said quiet startup only skips startup work. It also turns off the first frame outright, since shouldPaintFirstFrame returns false under it, which is the real reason the two arms converge. Refs #901 --- .../coding-agent/src/modes/first-frame.ts | 19 +++++-------------- proof/scenes/first-frame.sh | 8 ++++---- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/modes/first-frame.ts b/packages/coding-agent/src/modes/first-frame.ts index 64009f795e..0a8f210238 100644 --- a/packages/coding-agent/src/modes/first-frame.ts +++ b/packages/coding-agent/src/modes/first-frame.ts @@ -43,17 +43,6 @@ import { applyGroundPaint, setDetectedTerminalGround } from "./theme/ground-tint import { theme } from "./theme/theme"; import { flushPendingTtyInput } from "./tty-input-flush"; -/** - * Rows the composer zone occupies at rest: the status line, the hairline, the - * three rows of the bordered editor card, the capability line, the shortcut bar - * and the bottom margin. The zone does not exist yet, and the centring is a - * share of the slack below the card ({@link HomeAnchorLayout}), so a stand-in - * of the right height is what puts the card where the mounted home screen puts - * it. The stand-in is the composer itself — {@link StaticComposerFrame} paints - * the resting zone's exact row count with its real chrome, so the prompt is on - * screen from the first paint and the mounted zone swaps text, not position. - */ - /** Inputs used to decide whether the launch card may be painted this early. */ export interface FirstFrameDecisionOptions { readonly isInteractive: boolean; @@ -117,9 +106,11 @@ export function paintFirstFrame(version: string): FirstFrame { hero, new Spacer(1), layout.bottomFill, - // The composer at rest, painted NOW: the prompt is on screen from the - // first paint, and the real zone mounts into the same rows when init - // finishes — a text handover, not a slide. + // The composer at rest, painted NOW. Centring is a share of the slack + // below the card (HomeAnchorLayout), so the zone's height has to be on + // screen before the zone exists: this paints the resting zone's exact + // row count with its real chrome, and the mounted zone swaps text into + // those rows rather than arriving under them. new StaticComposerFrame(), ]; for (const child of children) ui.addChild(child); diff --git a/proof/scenes/first-frame.sh b/proof/scenes/first-frame.sh index 3606232bb3..646bfe204c 100755 --- a/proof/scenes/first-frame.sh +++ b/proof/scenes/first-frame.sh @@ -38,10 +38,10 @@ # and nothing that varies between launches. # # `startup.quiet` would remove the hero and the tip with it, and it is the -# wrong instrument: quiet startup skips the work whose duration the blank -# window is made of, and measured here it shrank the window from about a third -# of a second to two frames. An arm that no longer contains the transition is -# not an arm of this pair. +# wrong instrument: `shouldPaintFirstFrame` returns false under it, so the +# after arm paints no first frame either and there is nothing left to compare. +# Measured that way the two arms converged to a two-frame difference. An arm +# that does not contain the subject is not an arm of this pair. # # The motion floor is zero here and nowhere else. The gate measures unique # frames per second across the whole take and exists to catch a capture that