Pin the stop key to the hint row while children are running

11bbcd4fe926 · Devin AI · · parent 815018943809

Pin the stop key to the hint row while children are running

At eighty columns the counter on the right grows as the transcript
scrolls, and the hint row gave up every key at once — including the one
that stops a fleet from spending. The row now drops the conveniences,
then the counter, and keeps a pinned hint.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.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, +95 -16

packages/openagents-cli/src/coder-ui.ts modified +42 -16

@@ -99,6 +99,12 @@ function justify(left: string, right: string, width: number): string {

99 99
  return left + " ".repeat(width - used) + right;
100 100
}
101 101
102
/** A key hint. A pinned one is kept even when the row has to give something up. */
103
interface Hint {
104
  readonly text: string;
105
  readonly pinned?: boolean;
106
}
107
102 108
/**
103 109
 * Lay out the key hints against the counter, dropping hints from the end until
104 110
 * the row fits.

@@ -107,13 +113,33 @@ function justify(left: string, right: string, width: number): string {

107 113
 * of keys that work whether or not they are printed. So the hints are what
108 114
 * gives way. Padding the two apart and dropping the counter instead is how a
109 115
 * wide-enough terminal still managed to hide the reply count.
116
 *
117
 * A pinned hint outranks the counter, because a key that stops fifteen agents
118
 * from spending is not a reminder. Ordering the hints so the stop came before
119
 * the conveniences was not enough: at eighty columns the counter grows as the
120
 * transcript scrolls, and the row dropped every hint at once.
110 121
 */
111
function hints(keys: ReadonlyArray<string>, right: string, width: number): string {
112
  for (let count = keys.length; count > 0; count -= 1) {
113
    const left = `${DIM}${keys.slice(0, count).join(" · ")}${RESET}`;
114
    if (visibleWidth(left) + visibleWidth(right) + 2 <= width) return justify(left, right, width);
122
function hints(keys: ReadonlyArray<Hint>, right: string, width: number): string {
123
  const shown = [...keys];
124
  for (;;) {
125
    const left = `${DIM}${shown.map((key) => key.text).join(" · ")}${RESET}`;
126
    if (shown.length > 0 && visibleWidth(left) + visibleWidth(right) + 2 <= width) {
127
      return justify(left, right, width);
128
    }
129
    const droppable = shown.reduce<number>(
130
      (last, key, index) => (key.pinned === true ? last : index),
131
      -1,
132
    );
133
    if (droppable < 0) break;
134
    shown.splice(droppable, 1);
115 135
  }
116
  return right;
136
137
  const plain = shown.map((key) => key.text).join(" · ");
138
  if (plain.length === 0) return right;
139
  const letters = [...plain];
140
  const clipped =
141
    letters.length > width ? `${letters.slice(0, Math.max(0, width - 1)).join("")}…` : plain;
142
  return `${DIM}${clipped}${RESET}`;
117 143
}
118 144
119 145
/**

@@ -453,25 +479,25 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

453 479
      // they are listed in the order a reader needs them, because a narrow row
454 480
      // drops them from the end. An earlier version offered "esc esc to
455 481
      // interrupt" while idle, where there was nothing to interrupt.
456
      const keys: string[] = [];
482
      const keys: Hint[] = [];
457 483
      if (snapshot.running) {
458
        keys.push("esc to interrupt", "ctrl+c to stop");
484
        keys.push({ text: "esc to interrupt" }, { text: "ctrl+c to stop" });
459 485
      } else {
460
        keys.push("enter to send");
461
        if (composer.length > 0) keys.push("esc to clear");
462
        else keys.push("ctrl+d to quit");
486
        keys.push({ text: "enter to send" });
487
        if (composer.length > 0) keys.push({ text: "esc to clear" });
488
        else keys.push({ text: "ctrl+d to quit" });
463 489
      }
464
      // Stopping the fleet comes before the conveniences, because the row is
490
      // Stopping the fleet is pinned rather than merely early: the row is
465 491
      // clipped from the end and this hint only appears while children are
466
      // spending. Offered last, it was dropped exactly when it applied.
492
      // spending, so an unpinned one went exactly when it applied.
467 493
      if (snapshot.tasks.some((task) => task.status === "running")) {
468
        keys.push("ctrl+x to stop agents");
494
        keys.push({ text: "ctrl+x to stop agents", pinned: true });
469 495
      }
470 496
      // Only when there is another model to switch to, and only while nothing
471 497
      // is running: a turn already accepted keeps the backend it named.
472
      if (session.canCycleBackend && !snapshot.running) keys.push("tab to switch model");
473
      if (lines.length > transcriptRows) keys.push("pgup/pgdn to scroll");
474
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
498
      if (session.canCycleBackend && !snapshot.running) keys.push({ text: "tab to switch model" });
499
      if (lines.length > transcriptRows) keys.push({ text: "pgup/pgdn to scroll" });
500
      if (focusedTool(snapshot) !== undefined) keys.push({ text: "ctrl+o to expand" });
475 501
476 502
      // `this run` is not decoration. The count is this process's, and a
477 503
      // source that is not the thread — the stand-in behind `--offline` — has
packages/openagents-cli/test/coder-ui.test.ts modified +53

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";

3 3
4 4
import { CODER_BACKENDS, defaultBackend } from "../src/coder-backends.js";
5 5
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
6
import { CoderTaskRegistry } from "../src/coder-tasks.js";
6 7
import { runCoderUi } from "../src/coder-ui.js";
7 8
8 9
/** A writable that records what the interface painted. */

@@ -311,6 +312,58 @@ describe("runCoderUi", () => {

311 312
    expect(status).toContain("$2.00");
312 313
  });
313 314
315
  it("keeps the stop key on a narrow row that has run out of room", async () => {
316
    const stdin = new FakeIn();
317
    const stdout = new FakeOut();
318
    stdout.columns = 80;
319
    const registry = new CoderTaskRegistry();
320
    const session = new CoderSession(
321
      source([
322
        {
323
          type: "text",
324
          value: Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\n"),
325
        },
326
      ]),
327
      "repo",
328
      "main",
329
      {
330
        registry,
331
        fleet: {
332
          submit: async () => ({ status: "refused", code: "empty_prompt", reason: "not used" }),
333
        },
334
        label: "fake (fake/model)",
335
      },
336
    );
337
    const running = runCoderUi(session, {
338
      stdin: stdin as unknown as NodeJS.ReadStream,
339
      stdout: stdout as unknown as NodeJS.WriteStream,
340
    });
341
342
    // A transcript long enough to scroll grows the counter on the right, which
343
    // is what used to push every hint off an eighty-column row.
344
    await session.submit("go");
345
    const task = registry.register(
346
      {
347
        id: "d1",
348
        description: "run the long build",
349
        prompt: "build",
350
        agent: "opencode",
351
        model: "fake/model",
352
        cwd: "/tmp",
353
        background: true,
354
      },
355
      0,
356
    );
357
    registry.start(task.id, new AbortController());
358
    await new Promise((resolve) => setTimeout(resolve, 20));
359
    const rows = screen(stdout.written);
360
    stdin.emit("data", "\x04");
361
    await running;
362
    session.close();
363
364
    expect(rows.at(-1) ?? "").toContain("ctrl+x to stop agents");
365
  });
366
314 367
  it("keeps a long typed line inside the row, showing its tail", async () => {
315 368
    const stdin = new FakeIn();
316 369
    const stdout = new FakeOut();

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