Greet an empty coder session with a CODER splash screen

b157b67139c5 · AtlantisPleb · · parent 569bd0bf9ba6

Greet an empty coder session with a CODER splash screen

Before the first prompt is sent the transcript viewport was nineteen
blank rows, which reads as a program that has not started. It now shows
a splash: the word CODER in hand-drawn block glyphs inside a frame, over
a field of 1s and 0s, with a one-line hint underneath.

The splash is not state and not a lifecycle. It is only what an empty
transcript looks like — the render path substitutes it when the snapshot
has no entries — so the first entry replaces it like any frame replaces
the one before, and it never comes back. The binary texture is seeded
from each cell's position rather than from a random source, so the same
viewport always paints the same field and the differential paint loop
never rewrites it. A terminal too narrow for the wordmark gets the word
spelled out in the same frame, and one with no room for a frame at all
gets the bare word; no variant ever emits a row wider than the viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

2 files changed, +192 -1

packages/openagents-cli/src/coder-ui.ts modified +126 -1

@@ -285,6 +285,123 @@ function truncate(text: string, width: number): string {

285 285
  return `${glyphs.slice(0, Math.max(1, width - 1)).join("")}…`;
286 286
}
287 287
288
/**
289
 * The face the splash writes CODER in, drawn by hand in block glyphs.
290
 *
291
 * Hand-drawn because this interface renders with no dependencies, and a figlet
292
 * package to say one word would be the first. Six rows, because shorter art
293
 * stops reading as a wordmark and taller art crowds a small terminal.
294
 */
295
const SPLASH_WORD = [
296
  " ██████╗ ██████╗ ██████╗ ███████╗██████╗ ",
297
  "██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗",
298
  "██║     ██║   ██║██║  ██║█████╗  ██████╔╝",
299
  "██║     ██║   ██║██║  ██║██╔══╝  ██╔══██╗",
300
  "╚██████╗╚██████╔╝██████╔╝███████╗██║  ██║",
301
  " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝  ╚═╝",
302
];
303
304
/** What the splash asks for. The status line already names the workspace. */
305
const SPLASH_HINT = "type a prompt below to begin";
306
307
/**
308
 * One cell of the splash's binary texture.
309
 *
310
 * Seeded from the cell's own position rather than from a random source, so the
311
 * same viewport always shows the same field. The paint loop diffs rows against
312
 * the last frame, and texture that shifted on every repaint would rewrite the
313
 * whole block each time and read as static rather than as a backdrop.
314
 */
315
function splashBit(row: number, column: number): string {
316
  // Two multiply-xor rounds, because one leaves the low bits of neighbouring
317
  // columns correlated and the field visibly striped.
318
  let hash = (Math.imul(row + 1, 2654435761) ^ Math.imul(column + 1, 0x9e3779b9)) >>> 0;
319
  hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b) >>> 0;
320
  return ((hash ^ (hash >>> 16)) & 1) === 0 ? "0" : "1";
321
}
322
323
/** A full row of that texture, one digit per column. */
324
function splashTexture(row: number, width: number): string {
325
  let cells = "";
326
  for (let column = 0; column < width; column += 1) cells += splashBit(row, column);
327
  return cells;
328
}
329
330
/**
331
 * What the transcript's viewport shows before the first entry exists.
332
 *
333
 * A session opens onto nothing — no banner, by the note at the bottom of this
334
 * file — and nineteen blank rows read as a program that has not started. The
335
 * splash is not state and not a lifecycle: it is only what an empty transcript
336
 * looks like, so the first entry replaces it the way any frame replaces the
337
 * one before, and it never comes back. Same size in, same rows out, because
338
 * the renderer repaints on state changes and resize and nothing else.
339
 *
340
 * Each size gets the largest rendering that fits whole: the wordmark in a
341
 * frame with the binary field, a one-line box, or the bare word. Nothing here
342
 * may emit a row wider than the viewport — the terminal would wrap it, which
343
 * shifts every row below and leaves text nothing will erase.
344
 */
345
function splashLines(width: number, height: number): ReadonlyArray<string> {
346
  const wordWidth = [...(SPLASH_WORD[0] ?? "")].length;
347
348
  /** Wrap inner rows in the frame and center the block in the viewport. */
349
  const framed = (inner: ReadonlyArray<string>, innerWidth: number): ReadonlyArray<string> => {
350
    const rows = [
351
      `${DIM}┌${"─".repeat(innerWidth)}┐${RESET}`,
352
      ...inner,
353
      `${DIM}└${"─".repeat(innerWidth)}┘${RESET}`,
354
    ];
355
    const left = " ".repeat(Math.max(0, Math.floor((width - innerWidth - 2) / 2)));
356
    const above = Math.max(0, Math.floor((height - rows.length) / 2));
357
    return [...Array.from({ length: above }, () => ""), ...rows.map((row) => left + row)];
358
  };
359
360
  /** A framed row holding centered content, given the content's visible width. */
361
  const padded = (innerWidth: number, visible: number, styled: string): string => {
362
    const leftPad = Math.max(0, Math.floor((innerWidth - visible) / 2));
363
    const rightPad = Math.max(0, innerWidth - visible - leftPad);
364
    return `${DIM}│${RESET}${" ".repeat(leftPad)}${styled}${" ".repeat(rightPad)}${DIM}│${RESET}`;
365
  };
366
367
  /** A framed row of the binary field, edge to edge. */
368
  const texture = (row: number, innerWidth: number): string =>
369
    `${DIM}│${splashTexture(row, innerWidth)}│${RESET}`;
370
371
  // Four columns of air either side of the wordmark, so the letters do not
372
  // touch the field above them. Height needs the six rows of the word plus the
373
  // frame, the texture, the air, and the hint.
374
  const fullInner = wordWidth + 8;
375
  if (width >= fullInner + 2 && height >= SPLASH_WORD.length + 7) {
376
    const inner: string[] = [texture(0, fullInner), padded(fullInner, 0, "")];
377
    for (const line of SPLASH_WORD) {
378
      inner.push(padded(fullInner, wordWidth, `${CYAN}${line}${RESET}`));
379
    }
380
    inner.push(padded(fullInner, 0, ""));
381
    inner.push(padded(fullInner, SPLASH_HINT.length, `${DIM}${SPLASH_HINT}${RESET}`));
382
    inner.push(texture(1, fullInner));
383
    return framed(inner, fullInner);
384
  }
385
386
  // Too narrow or too short for the wordmark: the word spelled out in the same
387
  // frame, three rows tall, which fits down to a seventeen-column terminal.
388
  const compactInner = 15;
389
  if (width >= compactInner + 2 && height >= 5) {
390
    const word = "C O D E R";
391
    return framed(
392
      [
393
        texture(0, compactInner),
394
        padded(compactInner, [...word].length, `${CYAN}${BOLD}${word}${RESET}`),
395
        texture(1, compactInner),
396
      ],
397
      compactInner,
398
    );
399
  }
400
401
  // A viewport with no room for a frame at all still says whose screen it is.
402
  return [truncate("CODER", Math.max(1, width))];
403
}
404
288 405
/**
289 406
 * Match a complete escape sequence at `index`, or return undefined when the
290 407
 * bytes so far could still be the start of one. Covers CSI (`\x1b[…final`),

@@ -928,7 +1045,15 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

928 1045
      const sidebar = snapshot.tasks.length > 0 && width >= SIDEBAR_MINIMUM_TERMINAL;
929 1046
      const transcriptWidth = sidebar ? width - SIDEBAR_WIDTH - 1 : width;
930 1047
931
      const lines = transcriptLines(snapshot, transcriptWidth, sidebar);
1048
      // Before the first entry exists there is no conversation to draw, so
1049
      // the viewport shows the splash instead of nothing. Gated on the entries
1050
      // rather than on what they render to: a transcript whose entries all
1051
      // settled empty is a conversation that happened, and greeting it again
1052
      // would say that it had not.
1053
      const lines =
1054
        snapshot.entries.length === 0
1055
          ? splashLines(transcriptWidth, transcriptHeight)
1056
          : transcriptLines(snapshot, transcriptWidth, sidebar);
932 1057
      lineCount = lines.length;
933 1058
      viewport = transcriptHeight;
934 1059
packages/openagents-cli/test/coder-ui.test.ts modified +66

@@ -958,6 +958,72 @@ describe("the chrome under the composer", () => {

958 958
  });
959 959
});
960 960
961
describe("the splash before the first message", () => {
962
  /** Open a fresh session, send nothing, and read what it painted. */
963
  const openFresh = async (columns = 100, rows = 24) => {
964
    const stdin = new FakeIn();
965
    const stdout = new FakeOut();
966
    stdout.columns = columns;
967
    stdout.rows = rows;
968
    const session = new CoderSession(source([]), "repo", "main");
969
    const running = runCoderUi(session, {
970
      stdin: stdin as unknown as NodeJS.ReadStream,
971
      stdout: stdout as unknown as NodeJS.WriteStream,
972
    });
973
974
    const painted = screen(stdout.written);
975
    stdin.emit("data", "\x04");
976
    await running;
977
    return painted;
978
  };
979
980
  it("greets an empty session with the wordmark, a frame, and the binary field", async () => {
981
    const rows = await openFresh();
982
    const joined = rows.join("\n");
983
984
    // The wordmark, its frame, and the field of digits around it. The hint
985
    // says what to do next without repeating what the status line says.
986
    expect(joined).toContain("██████╗");
987
    expect(joined).toContain("┌");
988
    expect(joined).toContain("└");
989
    expect(joined).toMatch(/[01]{20}/);
990
    expect(joined).toContain("type a prompt below to begin");
991
  });
992
993
  it("is gone the moment the first entry exists, and stays gone", async () => {
994
    const { rows } = await drive([{ type: "text", value: "hello" }]);
995
    const joined = rows.join("\n");
996
997
    // The splash is only what an empty transcript looks like, so a transcript
998
    // with anything in it must show no row of it.
999
    expect(joined).toContain("hello");
1000
    expect(joined).not.toContain("██");
1001
    expect(joined).not.toContain("type a prompt below to begin");
1002
    expect(joined).not.toMatch(/[01]{20}/);
1003
  });
1004
1005
  it("paints the same frame every time the same empty state renders", async () => {
1006
    // Same snapshot and same size mean the same rows: the texture is seeded
1007
    // from cell positions, so nothing in the frame depends on time or chance.
1008
    const first = await openFresh();
1009
    const second = await openFresh();
1010
    expect(second).toEqual(first);
1011
  });
1012
1013
  it("falls back on a narrow terminal and never emits a row past the edge", async () => {
1014
    const rows = await openFresh(40);
1015
    const joined = rows.join("\n");
1016
1017
    // Too narrow for the wordmark, so the word is spelled out in the same
1018
    // frame — and no row may be wider than the terminal, which would wrap it.
1019
    expect(joined).not.toContain("██████╗");
1020
    expect(joined).toContain("C O D E R");
1021
    for (const row of rows) {
1022
      expect([...row].length).toBeLessThanOrEqual(40);
1023
    }
1024
  });
1025
});
1026
961 1027
describe("where a running child is shown", () => {
962 1028
  /** A session with a running delegate tool call, at a given terminal width. */
963 1029
  const driveDelegated = async (

This page updates live while a promote is in flight · changelog