Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
### Changed

- 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] <command>` or `[errors]` / `[errors N] <command>`. 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.
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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.
Expand Down
42 changes: 41 additions & 1 deletion packages/coding-agent/src/modes/components/composer-chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {}
}
19 changes: 7 additions & 12 deletions packages/coding-agent/src/modes/first-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,13 @@ 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";
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. 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.
*/
const COMPOSER_RESERVE_ROWS = 8;

/** Inputs used to decide whether the launch card may be painted this early. */
export interface FirstFrameDecisionOptions {
readonly isInteractive: boolean;
Expand Down Expand Up @@ -116,7 +106,12 @@ export function paintFirstFrame(version: string): FirstFrame {
hero,
new Spacer(1),
layout.bottomFill,
new Spacer(COMPOSER_RESERVE_ROWS),
// 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);
// No frame has been composed, so this measures the children directly.
Expand Down
4 changes: 1 addition & 3 deletions packages/coding-agent/src/modes/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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,
* 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.
*
* 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.
*
* 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 () => {
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);
}
});
});

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);
}
});
});
97 changes: 97 additions & 0 deletions proof/scenes/first-frame.sh
Original file line number Diff line number Diff line change
@@ -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: `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
# 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
Loading