Show what a coder turn actually contains, and paint it without wrecking scrollback

f54ff659b945 · AtlantisPleb · · parent fc5688427600

Show what a coder turn actually contains, and paint it without wrecking scrollback

Six defects the owner reported from a live session against Ox Alpha.

The turn was rendered as if it were one string of plain text. It is not.
`coder-ox.ts` yielded only `text_delta`, so `tool_call_started`,
`tool_call_completed`, `tool_call_failed`, and `reasoning_delta` were read and
dropped. A tool call therefore left no entry at all and the sentences either
side of it were appended to the same entry, which is why the owner saw
"Let me check what's currently connected:Here's the rundown of what I can do".
A reply source now yields a tagged union — text, reasoning, tool call, tool
result — and `CoderSession` builds one transcript entry per run of each, in
arrival order, so the two sentences are two entries with a tool call and a
blank line between them. Tool calls are read from the `tool_call` projection
the server attaches to every `tool_call_*` event, the same projection the web
surface renders, so the CLI cannot disagree with it about a call. Reasoning
streams in place, dim and italic, where it happened.

Markdown arrived as source, so `**ox-alpha**` was printed with its asterisks.
`coder-markdown.ts` renders bold, italic, inline code, fenced blocks,
headings, bullet and numbered lists, and blockquotes as ANSI, wrapped on
visible width with a list item's continuation aligned under its text. It has
no dependency because the text arrives in chunks: every construct is matched
by finding its terminator first, so a half-arrived `**` renders as the two
characters that arrived rather than swallowing the rest of the reply, and no
character is lost at any prefix of the stream.

The interface cleared the whole screen and repainted it several times a second
while a reply streamed, which left a stack of half-drawn frames wherever the
terminal keeps scrolled-off alternate screen rows. Painting is now
differential: each row is positioned absolutely, erased with `\x1b[K`, and
written only when it changed. Nothing emits a newline or clears the screen, so
the terminal has no reason to scroll and its scrollback is never written to. A
streamed reply costs about a fifth of the bytes it did.

Scrolling held a distance from the bottom, so new content moved the viewport
under a reader who had scrolled up. It now holds the absolute first visible
line and only follows the newest content when the reader is at the bottom or
submits. PageUp and PageDown move a page, the arrow keys move a line, and
alternate scroll mode turns the wheel into those arrows without taking the
terminal's own text selection away. The counter says how many lines are above.

`ctrl+o` expands and collapses the newest tool call, which is the one the
reader just watched happen. Older calls have no focus of their own yet.

The bottom bar advertised "esc esc to interrupt" while idle, where there is
nothing to interrupt. Every key it names now does something in the state it is
named in. A single escape interrupts: a lone `\x1b` is held for 40ms and
treated as a bare escape only if nothing follows it, which is below the
threshold where an arrow key feels delayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
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

  • added packages/openagents-cli/src/coder-markdown.ts
  • modified packages/openagents-cli/src/coder-ox.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-markdown.test.ts
  • modified packages/openagents-cli/test/coder-ox.test.ts
  • modified packages/openagents-cli/test/coder-session.test.ts
  • added packages/openagents-cli/test/coder-ui.test.ts

Diff

9 files changed, +1406 -171

packages/openagents-cli/src/coder-markdown.ts added +328

@@ -0,0 +1,328 @@

1
/**
2
 * Markdown to ANSI for the coder transcript.
3
 *
4
 * Replies arrive as Markdown and used to be printed as source, so the reader
5
 * saw `**ox-alpha**` rather than a bold name. This turns the source into
6
 * styled, width-wrapped rows.
7
 *
8
 * Two properties of the caller shape every decision here:
9
 *
10
 * - The text arrives in chunks. Every frame re-renders the whole entry from
11
 *   whatever has arrived, so a half-arrived `**` or an unclosed fence is the
12
 *   normal case rather than an error. Unterminated markup renders as the
13
 *   literal characters that arrived, which means the row never loses a
14
 *   character and never flickers between two different readings of the same
15
 *   prefix.
16
 * - Rows are laid out by the interface, which owns the gutter. Wrapping
17
 *   therefore works on visible width with ANSI ignored, and a wrapped list
18
 *   item continues under its text rather than under its bullet.
19
 *
20
 * There is no Markdown dependency. The subset below is what a reply uses, and
21
 * a parser small enough to test directly is worth more here than a general one
22
 * that would still need a streaming and a wrapping layer on top.
23
 */
24
25
const RESET = "\x1b[0m";
26
const BOLD = "\x1b[1m";
27
const DIM = "\x1b[2m";
28
const ITALIC = "\x1b[3m";
29
const CODE = "\x1b[36m";
30
const HEADING = "\x1b[1m\x1b[36m";
31
32
/** A run of text that shares one ANSI prefix. */
33
export interface StyledSpan {
34
  readonly text: string;
35
  readonly style: string;
36
}
37
38
/** Visible width, ignoring ANSI styling. */
39
export function visibleWidth(text: string): number {
40
  return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length;
41
}
42
43
/** Render one text block with a single style, wrapped like a paragraph. */
44
export function wrapStyled(text: string, width: number, style: string): ReadonlyArray<string> {
45
  const rows: string[] = [];
46
  for (const paragraph of text.split("\n")) {
47
    if (paragraph.length === 0) {
48
      rows.push("");
49
      continue;
50
    }
51
    rows.push(...wrapSpans([{ text: paragraph, style }], width, "", ""));
52
  }
53
  return rows;
54
}
55
56
/** Render Markdown source as styled rows no wider than `width`. */
57
export function renderMarkdown(text: string, width: number): ReadonlyArray<string> {
58
  const rows: string[] = [];
59
  /** The fence marker that opened the current code block, if one is open. */
60
  let fence: string | undefined;
61
62
  for (const line of text.split("\n")) {
63
    const fenced = /^\s*(```+|~~~+)/.exec(line);
64
65
    if (fence !== undefined) {
66
      // A fence closes only on its own marker, so a ``` inside a ~~~ block is
67
      // content rather than a terminator.
68
      if (fenced !== null && line.trim().startsWith(fence)) {
69
        fence = undefined;
70
        continue;
71
      }
72
      rows.push(...codeRows(line, width));
73
      continue;
74
    }
75
76
    if (fenced !== null) {
77
      fence = fenced[1] ?? "```";
78
      continue;
79
    }
80
81
    rows.push(...blockRows(line, width));
82
  }
83
84
  return rows;
85
}
86
87
/** One non-fenced source line as one or more rendered rows. */
88
function blockRows(line: string, width: number): ReadonlyArray<string> {
89
  if (line.trim().length === 0) return [""];
90
91
  if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) {
92
    return [`${DIM}${"─".repeat(Math.max(1, Math.min(width, 24)))}${RESET}`];
93
  }
94
95
  const heading = /^\s*(#{1,6})\s+(.*)$/.exec(line);
96
  if (heading !== null) {
97
    return wrapSpans(scan(heading[2] ?? "", HEADING), width, "", "");
98
  }
99
100
  const quote = /^\s*>\s?(.*)$/.exec(line);
101
  if (quote !== null) {
102
    const bar = `${DIM}│${RESET} `;
103
    return wrapSpans(scan(quote[1] ?? "", DIM), width, bar, bar);
104
  }
105
106
  const bullet = /^(\s*)([-*+])\s+(.*)$/.exec(line);
107
  if (bullet !== null) {
108
    const indent = " ".repeat(bullet[1]?.length ?? 0);
109
    return wrapSpans(scan(bullet[3] ?? "", ""), width, `${indent}${DIM}•${RESET} `, `${indent}  `);
110
  }
111
112
  const numbered = /^(\s*)(\d{1,9}[.)])\s+(.*)$/.exec(line);
113
  if (numbered !== null) {
114
    const indent = " ".repeat(numbered[1]?.length ?? 0);
115
    const marker = numbered[2] ?? "1.";
116
    return wrapSpans(
117
      scan(numbered[3] ?? "", ""),
118
      width,
119
      `${indent}${DIM}${marker}${RESET} `,
120
      `${indent}${" ".repeat(marker.length + 1)}`,
121
    );
122
  }
123
124
  return wrapSpans(scan(line, ""), width, "", "");
125
}
126
127
/** A line inside a fenced block: never wrapped by word, only hard-split. */
128
function codeRows(line: string, width: number): ReadonlyArray<string> {
129
  const body = Math.max(4, width - 2);
130
  const expanded = line.replace(/\t/g, "  ");
131
  const rows: string[] = [];
132
  let rest = [...expanded];
133
  do {
134
    const piece = rest.slice(0, body).join("");
135
    rows.push(`${DIM}│${RESET} ${CODE}${piece}${RESET}`);
136
    rest = rest.slice(body);
137
  } while (rest.length > 0);
138
  return rows;
139
}
140
141
/**
142
 * Split inline Markdown into styled spans.
143
 *
144
 * Every construct is matched by finding its terminator first. When the
145
 * terminator has not arrived the opening characters are kept as literal text,
146
 * which is what makes a half-streamed `**bold` read as `**bold` rather than
147
 * swallowing the rest of the reply.
148
 */
149
function scan(text: string, style: string): ReadonlyArray<StyledSpan> {
150
  const spans: StyledSpan[] = [];
151
  let buffer = "";
152
153
  const flush = () => {
154
    if (buffer.length === 0) return;
155
    spans.push({ text: buffer, style });
156
    buffer = "";
157
  };
158
159
  let index = 0;
160
  while (index < text.length) {
161
    const char = text[index] ?? "";
162
    const next = text[index + 1];
163
164
    if (char === "\\" && next !== undefined && /[\\`*_~[\]()#+\-.!>]/.test(next)) {
165
      buffer += next;
166
      index += 2;
167
      continue;
168
    }
169
170
    if (char === "`") {
171
      const end = text.indexOf("`", index + 1);
172
      if (end > index + 1) {
173
        flush();
174
        spans.push({ text: text.slice(index + 1, end), style: `${style}${CODE}` });
175
        index = end + 1;
176
        continue;
177
      }
178
      buffer += char;
179
      index += 1;
180
      continue;
181
    }
182
183
    const strong = text.startsWith("**", index)
184
      ? "**"
185
      : text.startsWith("__", index)
186
        ? "__"
187
        : undefined;
188
    if (strong !== undefined) {
189
      const end = text.indexOf(strong, index + 2);
190
      if (end > index + 2) {
191
        flush();
192
        spans.push(...scan(text.slice(index + 2, end), `${style}${BOLD}`));
193
        index = end + 2;
194
        continue;
195
      }
196
      buffer += strong;
197
      index += 2;
198
      continue;
199
    }
200
201
    if ((char === "*" || char === "_") && opensEmphasis(text, index, char)) {
202
      const end = closesEmphasis(text, index + 1, char);
203
      if (end !== undefined) {
204
        flush();
205
        spans.push(...scan(text.slice(index + 1, end), `${style}${ITALIC}`));
206
        index = end + 1;
207
        continue;
208
      }
209
    }
210
211
    buffer += char;
212
    index += 1;
213
  }
214
215
  flush();
216
  return spans;
217
}
218
219
const WORD = /[\p{L}\p{N}]/u;
220
221
/** An opener needs text after it, and `_` also needs a boundary before it. */
222
function opensEmphasis(text: string, index: number, marker: string): boolean {
223
  const after = text[index + 1];
224
  if (after === undefined || /\s/.test(after)) return false;
225
  if (marker !== "_") return true;
226
  const before = text[index - 1];
227
  return before === undefined || !WORD.test(before);
228
}
229
230
/** The matching terminator, or undefined when it has not arrived yet. */
231
function closesEmphasis(text: string, from: number, marker: string): number | undefined {
232
  for (let index = from; index < text.length; index += 1) {
233
    if (text[index] !== marker) continue;
234
    const before = text[index - 1];
235
    if (before === undefined || /\s/.test(before)) continue;
236
    if (marker === "_") {
237
      const after = text[index + 1];
238
      if (after !== undefined && WORD.test(after)) continue;
239
    }
240
    return index;
241
  }
242
  return undefined;
243
}
244
245
/**
246
 * Greedy word wrap over styled spans.
247
 *
248
 * Wrapping happens on visible width so styling never shifts the right edge,
249
 * and the continuation prefix is separate from the first one so a wrapped list
250
 * item lines up under its own text.
251
 */
252
export function wrapSpans(
253
  spans: ReadonlyArray<StyledSpan>,
254
  width: number,
255
  first: string,
256
  continuation: string,
257
): ReadonlyArray<string> {
258
  const rows: string[] = [];
259
  let prefix = first;
260
  let line: StyledSpan[] = [];
261
  let used = 0;
262
  /** A space seen between two words, carrying the style of the span it came from. */
263
  let pendingSpace: string | undefined;
264
265
  const room = () => Math.max(4, width - visibleWidth(prefix));
266
267
  const emit = () => {
268
    rows.push(prefix + merge(line).map(paint).join(""));
269
    prefix = continuation;
270
    line = [];
271
    used = 0;
272
    pendingSpace = undefined;
273
  };
274
275
  for (const span of spans) {
276
    for (const piece of span.text.split(/(\s+)/)) {
277
      if (piece.length === 0) continue;
278
      if (/^\s+$/.test(piece)) {
279
        if (used > 0) pendingSpace = span.style;
280
        continue;
281
      }
282
283
      let word = [...piece];
284
      while (word.length > 0) {
285
        const gap = pendingSpace !== undefined && used > 0 ? 1 : 0;
286
        const available = room() - used - gap;
287
288
        if (word.length > available && used > 0) {
289
          emit();
290
          continue;
291
        }
292
293
        // A word wider than a whole row is split rather than dropped.
294
        const take = word.length > room() ? room() : word.length;
295
        if (pendingSpace !== undefined && used > 0) {
296
          // The space keeps the style of the span it came from, so a styled
297
          // run stays one escape sequence and a boundary space stays plain.
298
          line.push({ text: " ", style: pendingSpace });
299
          used += 1;
300
        }
301
        pendingSpace = undefined;
302
        line.push({ text: word.slice(0, take).join(""), style: span.style });
303
        used += take;
304
        word = word.slice(take);
305
        if (word.length > 0) emit();
306
      }
307
    }
308
  }
309
310
  emit();
311
  return rows;
312
}
313
314
/** Join neighbours that share a style, so a styled run is one escape sequence. */
315
function merge(spans: ReadonlyArray<StyledSpan>): ReadonlyArray<StyledSpan> {
316
  const out: StyledSpan[] = [];
317
  for (const span of spans) {
318
    const last = out.at(-1);
319
    if (last !== undefined && last.style === span.style)
320
      out[out.length - 1] = { text: last.text + span.text, style: last.style };
321
    else out.push(span);
322
  }
323
  return out;
324
}
325
326
function paint(span: StyledSpan): string {
327
  return span.style.length === 0 ? span.text : `${span.style}${span.text}${RESET}`;
328
}
packages/openagents-cli/src/coder-ox.ts modified +69 -6

@@ -18,6 +18,8 @@

18 18
 *   appears in pieces rather than at once.
19 19
 */
20 20
21
import type { ReplyChunk } from "./coder-session.js";
22
21 23
const SUBMIT_PATH = "/api/v3/chat/turns";
22 24
const EVENTS_PATH = "/api/v3/chat/events";
23 25

@@ -39,6 +41,23 @@ interface ChatEvent {

39 41
  readonly sequence?: number;
40 42
  readonly type?: string;
41 43
  readonly payload?: Record<string, unknown>;
44
  /**
45
   * The server's own projection of the tool call this event belongs to, which
46
   * every `tool_call_*` event carries. It already holds pretty-printed
47
   * arguments, the extracted result, and a structured error, so reading it
48
   * rather than the raw payload keeps the CLI and the web surface showing the
49
   * same tool call.
50
   */
51
  readonly tool_call?: ToolCallView;
52
}
53
54
interface ToolCallView {
55
  readonly call_id?: string;
56
  readonly name?: string;
57
  readonly arguments?: string;
58
  readonly output?: string | null;
59
  readonly error?: { readonly code?: string | null; readonly message?: string | null } | null;
60
  readonly status?: string;
42 61
}
43 62
44 63
export class OxAlphaUnavailable extends Error {

@@ -52,11 +71,12 @@ export class OxAlphaUnavailable extends Error {

52 71
}
53 72
54 73
/**
55
 * Submit a turn and yield the assistant text as the server records it.
74
 * Submit a turn and yield what the server records, in the order it records it.
56 75
 *
57
 * Reasoning deltas are read but not yielded: the transcript shows what the
58
 * assistant said, and the runtime already treats a thought as something a
59
 * client may drop.
76
 * A turn interleaves reasoning, tool calls, and assistant text. Every one of
77
 * those becomes a chunk here. An earlier version yielded only `text_delta`,
78
 * which made a tool call invisible and joined the sentence before it to the
79
 * sentence after it.
60 80
 */
61 81
export class OxAlphaReplySource {
62 82
  readonly model: string;

@@ -65,7 +85,7 @@ export class OxAlphaReplySource {

65 85
    this.model = options.model ?? "stealth/ox-alpha";
66 86
  }
67 87
68
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<string> {
88
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
69 89
    const seen = await this.latestSequence();
70 90
    const runId = await this.submit(prompt, signal);
71 91
    const startedAt = Date.now();

@@ -103,7 +123,16 @@ export class OxAlphaReplySource {

103 123
104 124
        if (event.type === "text_delta") {
105 125
          const value = event.payload?.["value"];
106
          if (typeof value === "string" && value.length > 0) yield value;
126
          if (typeof value === "string" && value.length > 0) yield { type: "text", value };
127
        } else if (event.type === "reasoning_delta") {
128
          const value = event.payload?.["value"];
129
          if (typeof value === "string" && value.length > 0) yield { type: "reasoning", value };
130
        } else if (event.type === "tool_call_started") {
131
          const call = toolCall(event);
132
          if (call !== undefined) yield call;
133
        } else if (event.type === "tool_call_completed" || event.type === "tool_call_failed") {
134
          const result = toolResult(event);
135
          if (result !== undefined) yield result;
107 136
        } else if (event.type === "response_completed") {
108 137
          finished = true;
109 138
        } else if (event.type === "response_failed") {

@@ -204,6 +233,40 @@ export class OxAlphaReplySource {

204 233
  }
205 234
}
206 235
236
/** The start of a tool call, read from the server's projection of it. */
237
function toolCall(event: ChatEvent): Extract<ReplyChunk, { type: "tool_call" }> | undefined {
238
  const view = event.tool_call;
239
  const callId = view?.call_id ?? stringField(event.payload, "call_id");
240
  if (callId === undefined) return undefined;
241
  return {
242
    type: "tool_call",
243
    callId,
244
    name: view?.name ?? stringField(event.payload, "name") ?? "tool",
245
    arguments: view?.arguments ?? stringField(event.payload, "arguments") ?? "",
246
  };
247
}
248
249
/** The outcome of a tool call. `error` decides whether it succeeded. */
250
function toolResult(event: ChatEvent): Extract<ReplyChunk, { type: "tool_result" }> | undefined {
251
  const view = event.tool_call;
252
  const callId = view?.call_id ?? stringField(event.payload, "call_id");
253
  if (callId === undefined) return undefined;
254
255
  const message = view?.error?.message ?? stringField(event.payload, "error");
256
  const error = message ?? (event.type === "tool_call_failed" ? "The tool failed." : undefined);
257
  const output = view?.output ?? stringField(event.payload, "output");
258
259
  return { type: "tool_result", callId, output: output ?? undefined, error };
260
}
261
262
function stringField(
263
  payload: Record<string, unknown> | undefined,
264
  key: string,
265
): string | undefined {
266
  const value = payload?.[key];
267
  return typeof value === "string" && value.length > 0 ? value : undefined;
268
}
269
207 270
/** The newest run in the log that the pre-submit snapshot did not know about. */
208 271
function newestRun(
209 272
  events: ReadonlyArray<ChatEvent>,
packages/openagents-cli/src/coder-plain.ts modified +39 -9

@@ -8,11 +8,17 @@

8 8
 *
9 9
 * `docs/2026-08-23-openagents-coder-cli-spec.md` section 3.6 makes this the
10 10
 * contract: the interface is optional, the agent is not.
11
 *
12
 * It renders a subset on purpose. Assistant text is written as the Markdown
13
 * source the model produced, with no ANSI, because this is the path a script
14
 * reads. Tool calls are written as one line each, so a reader can still see
15
 * that a call happened between two sentences. Reasoning is not written: it is
16
 * a thought, and a pipe wants the answer.
11 17
 */
12 18
13 19
import { createInterface } from "node:readline";
14 20
15
import type { CoderSession } from "./coder-session.js";
21
import type { CoderEntry, CoderSession } from "./coder-session.js";
16 22
17 23
export interface CoderPlainOptions {
18 24
  readonly stdin: NodeJS.ReadableStream;

@@ -28,19 +34,27 @@ export async function runCoderPlain(

28 34
  const { stdin, stdout, prompt } = options;
29 35
30 36
  let written = 0;
31
  // Notices are tracked by their text rather than by position: a failed turn
32
  // removes the empty assistant entry, so an index into the transcript can move
33
  // backwards and skip the very notice that explains the failure.
37
  // Notices and tool lines are tracked by their text rather than by position: a
38
  // failed turn removes the empty assistant entry, so an index into the
39
  // transcript can move backwards and skip the very notice that explains the
40
  // failure.
34 41
  const reported = new Set<string>();
42
43
  const announce = (line: string) => {
44
    if (reported.has(line)) return;
45
    reported.add(line);
46
    stdout.write(`${line}\n`);
47
  };
48
35 49
  const flush = () => {
36 50
    const entries = session.snapshot().entries;
37 51
38
    // Notices carry refusals and failures. Dropping them here is how a failed
39
    // turn becomes a silent empty reply, so they are written as they arrive.
52
    // Notices carry refusals and failures, and a tool call is the only sign
53
    // that the text either side of it came from two different places. Dropping
54
    // either here is how a turn becomes a silent or run-on reply.
40 55
    for (const entry of entries) {
41
      if (entry.role !== "notice" || reported.has(entry.text)) continue;
42
      reported.add(entry.text);
43
      stdout.write(`${entry.text}\n`);
56
      if (entry.role === "notice") announce(entry.text);
57
      else if (entry.role === "tool") announce(toolLine(entry));
44 58
    }
45 59
46 60
    const last = entries.at(-1);

@@ -77,3 +91,19 @@ export async function runCoderPlain(

77 91
    unsubscribe();
78 92
  }
79 93
}
94
95
/**
96
 * One line when the call starts and one when it ends.
97
 *
98
 * Both are keyed by their own text, so the start line is written once while
99
 * the call runs and the outcome line replaces nothing: a reader of a pipe sees
100
 * the call begin and sees how it ended.
101
 */
102
function toolLine(entry: CoderEntry): string {
103
  const tool = entry.tool;
104
  if (tool === undefined) return `[tool] ${entry.text}`;
105
  if (tool.status === "running") {
106
    return `\n[tool] ${tool.name} ${tool.arguments.replace(/\s+/g, " ").trim()}`;
107
  }
108
  return `[tool] ${tool.name} → ${tool.error === undefined ? "ok" : `failed: ${tool.error}`}`;
109
}
packages/openagents-cli/src/coder-session.ts modified +173 -24

@@ -6,20 +6,50 @@

6 6
 * line-oriented fallback in `coder-plain.ts` both render the same snapshot, so
7 7
 * the two cannot disagree about what a session contains.
8 8
 *
9
 * The reply source is a stand-in. The delivered first stage replaces
10
 * `DummyReplySource` with an ACP client that spawns the agent runtime and
11
 * streams `session/update` notifications; nothing outside this file changes
12
 * when it does, because both produce the same chunks through the same
13
 * interface.
9
 * A reply is not one string. A turn interleaves reasoning, tool calls, and
10
 * assistant text, and the transcript keeps them as separate entries in the
11
 * order the source produced them. An earlier version yielded only text, so a
12
 * tool call left no entry at all and the sentences on either side of it were
13
 * appended to the same entry and read as one run-on sentence.
14 14
 */
15 15
16
/** What a reply source produces. One entry kind per member. */
17
export type ReplyChunk =
18
  | { readonly type: "text"; readonly value: string }
19
  | { readonly type: "reasoning"; readonly value: string }
20
  | {
21
      readonly type: "tool_call";
22
      readonly callId: string;
23
      readonly name: string;
24
      /** Arguments as JSON source, pretty-printed when the server printed it. */
25
      readonly arguments: string;
26
    }
27
  | {
28
      readonly type: "tool_result";
29
      readonly callId: string;
30
      readonly output: string | undefined;
31
      readonly error: string | undefined;
32
    };
33
34
/** The tool half of a `tool` entry. Grows when the outcome arrives. */
35
export interface CoderToolCall {
36
  readonly callId: string;
37
  readonly name: string;
38
  readonly arguments: string;
39
  output: string | undefined;
40
  error: string | undefined;
41
  status: "running" | "succeeded" | "failed";
42
}
43
16 44
/** One entry in the transcript. */
17 45
export interface CoderEntry {
18
  readonly role: "you" | "assistant" | "notice";
19
  /** Rendered text. Assistant entries grow while a reply streams. */
46
  readonly role: "you" | "assistant" | "notice" | "tool" | "reasoning";
47
  /** Rendered text. Streaming entries grow while chunks arrive. */
20 48
  text: string;
21 49
  /** False while chunks are still arriving, so a renderer can show a caret. */
22 50
  settled: boolean;
51
  /** Present on a `tool` entry only. */
52
  readonly tool?: CoderToolCall;
23 53
}
24 54
25 55
/** Everything a renderer needs. No renderer reads anything else. */

@@ -42,7 +72,7 @@ export interface ReplySource {

42 72
   * Yield the reply to `prompt` in chunks. Rendering appends each chunk as it
43 73
   * arrives, so a slow source shows partial text rather than nothing.
44 74
   */
45
  reply(prompt: string, signal: AbortSignal): AsyncIterable<string>;
75
  reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk>;
46 76
}
47 77
48 78
const DUMMY_PREAMBLE =

@@ -56,31 +86,69 @@ const DUMMY_PREAMBLE =

56 86
 * It streams word by word with a small delay because a reply that appears all
57 87
 * at once would not exercise the incremental rendering the real source needs,
58 88
 * and a rendering bug that only shows up mid-stream would stay hidden until
59
 * the runtime landed.
89
 * the runtime landed. It emits reasoning, a tool call, and Markdown for the
90
 * same reason: `--offline` has to exercise every entry kind the interface
91
 * draws, or a rendering defect only appears against the live model.
60 92
 */
61 93
export class DummyReplySource implements ReplySource {
62 94
  readonly model = "dummy (no agent attached)";
63 95
64 96
  constructor(private readonly delayMs = 18) {}
65 97
66
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<string> {
98
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
99
    const thought =
100
      "The prompt asks about this repository. I should check what is connected " +
101
      "before answering, then describe what the session can do.";
102
103
    for (const token of tokenize(thought)) {
104
      if (signal.aborted) return;
105
      await sleep(this.delayMs, signal);
106
      if (signal.aborted) return;
107
      yield { type: "reasoning", value: token };
108
    }
109
110
    if (signal.aborted) return;
111
    const callId = "dummy-call-1";
112
    yield {
113
      type: "tool_call",
114
      callId,
115
      name: "repo_grep",
116
      arguments: `{\n  "max_results": 30,\n  "pattern": ${JSON.stringify(prompt.trim())}\n}`,
117
    };
118
    await sleep(this.delayMs * 6, signal);
119
    if (signal.aborted) return;
120
    yield {
121
      type: "tool_result",
122
      callId,
123
      output: `{\n  "matches": [],\n  "status": "empty"\n}`,
124
      error: undefined,
125
    };
126
67 127
    const body = [
68
      DUMMY_PREAMBLE,
128
      `## ${DUMMY_PREAMBLE}`,
129
      "",
130
      `You said: **${prompt.trim()}**`,
69 131
      "",
70
      `You said: ${prompt.trim()}`,
132
      "What works right now:",
71 133
      "",
72
      "What works right now: the transcript, the composer, streaming, " +
73
        "interruption, and the status line.",
134
      "- the transcript, the composer, and *streaming*",
135
      "- interruption, the status line, and `--plain`",
136
      "- Markdown, including a wrapped list item whose continuation lines line " +
137
        "up under the item text rather than under its bullet",
74 138
      "",
75 139
      "What does not: reading files, running commands, and answering the " +
76 140
        "question you actually asked.",
141
      "",
142
      "```elixir",
143
      'def hello, do: "world"',
144
      "```",
77 145
    ].join("\n");
78 146
79 147
    for (const token of tokenize(body)) {
80 148
      if (signal.aborted) return;
81 149
      await sleep(this.delayMs, signal);
82 150
      if (signal.aborted) return;
83
      yield token;
151
      yield { type: "text", value: token };
84 152
    }
85 153
  }
86 154
}

@@ -123,7 +191,7 @@ export class CoderSession {

123 191
124 192
  snapshot(): CoderSnapshot {
125 193
    return {
126
      entries: this.entries.map((entry) => ({ ...entry })),
194
      entries: this.entries.map(copyEntry),
127 195
      running: this.controller !== undefined,
128 196
      repository: this.repository,
129 197
      branch: this.branch,

@@ -158,8 +226,23 @@ export class CoderSession {

158 226
    if (prompt.trim().length === 0) return;
159 227
160 228
    this.entries.push({ role: "you", text: prompt, settled: true });
161
    const reply: CoderEntry = { role: "assistant", text: "", settled: false };
162
    this.entries.push(reply);
229
    // An empty assistant entry from the start, so the interface shows a caret
230
    // rather than nothing while the first chunk is in flight. It is withdrawn
231
    // if the turn opens with reasoning or a tool call instead of text.
232
    const opening: CoderEntry = { role: "assistant", text: "", settled: false };
233
    this.entries.push(opening);
234
235
    /** The entry each streaming chunk kind is currently appending to. */
236
    let text: CoderEntry | undefined = opening;
237
    let reasoning: CoderEntry | undefined;
238
239
    const settle = (entry: CoderEntry | undefined) => {
240
      if (entry !== undefined) entry.settled = true;
241
    };
242
    const withdrawOpening = () => {
243
      const at = this.entries.indexOf(opening);
244
      if (at >= 0 && opening.text.length === 0) this.entries.splice(at, 1);
245
    };
163 246
164 247
    const controller = new AbortController();
165 248
    this.controller = controller;

@@ -168,25 +251,73 @@ export class CoderSession {

168 251
    try {
169 252
      for await (const chunk of this.source.reply(prompt, controller.signal)) {
170 253
        if (controller.signal.aborted) break;
171
        reply.text += chunk;
254
255
        if (chunk.type === "text") {
256
          if (text === undefined) {
257
            settle(reasoning);
258
            reasoning = undefined;
259
            text = { role: "assistant", text: "", settled: false };
260
            this.entries.push(text);
261
          }
262
          text.text += chunk.value;
263
        } else if (chunk.type === "reasoning") {
264
          if (reasoning === undefined) {
265
            if (text === opening) withdrawOpening();
266
            settle(text);
267
            text = undefined;
268
            reasoning = { role: "reasoning", text: "", settled: false };
269
            this.entries.push(reasoning);
270
          }
271
          reasoning.text += chunk.value;
272
        } else if (chunk.type === "tool_call") {
273
          if (text === opening) withdrawOpening();
274
          settle(text);
275
          settle(reasoning);
276
          text = undefined;
277
          reasoning = undefined;
278
          this.entries.push({
279
            role: "tool",
280
            text: chunk.name,
281
            settled: false,
282
            tool: {
283
              callId: chunk.callId,
284
              name: chunk.name,
285
              arguments: chunk.arguments,
286
              output: undefined,
287
              error: undefined,
288
              status: "running",
289
            },
290
          });
291
        } else {
292
          this.applyToolResult(chunk);
293
        }
294
172 295
        this.emit();
173 296
      }
174
      if (controller.signal.aborted && reply.text.length > 0) {
297
298
      if (controller.signal.aborted) {
175 299
        // Keep the partial text. Cancellation is a state transition, not a
176 300
        // failure, so what the agent already said stays on the transcript.
177
        reply.text += "\n\n[interrupted]";
301
        const last = text ?? reasoning;
302
        if (last !== undefined && last.text.length > 0) last.text += "\n\n[interrupted]";
178 303
      }
179 304
    } catch (cause) {
180 305
      // A failed turn ends the turn, not the session. The reason belongs on the
181 306
      // transcript where the prompt that caused it is still visible, and any
182 307
      // text the source produced before failing is kept.
183 308
      const message = cause instanceof Error ? cause.message : String(cause);
184
      if (reply.text.length === 0) {
185
        this.entries.splice(this.entries.indexOf(reply), 1);
309
      if (text !== undefined && text.text.length === 0) {
310
        this.entries.splice(this.entries.indexOf(text), 1);
311
        text = undefined;
186 312
      }
187 313
      this.entries.push({ role: "notice", text: message, settled: true });
188 314
    } finally {
189
      reply.settled = true;
315
      for (const entry of this.entries) {
316
        if (entry.settled) continue;
317
        entry.settled = true;
318
        // A tool call the turn never resolved has no outcome to report.
319
        if (entry.tool?.status === "running") entry.tool.status = "failed";
320
      }
190 321
      this.controller = undefined;
191 322
      this.turnCount += 1;
192 323
      this.emit();

@@ -200,7 +331,25 @@ export class CoderSession {

200 331
    return true;
201 332
  }
202 333
334
  private applyToolResult(chunk: Extract<ReplyChunk, { type: "tool_result" }>): void {
335
    for (let index = this.entries.length - 1; index >= 0; index -= 1) {
336
      const tool = this.entries[index]?.tool;
337
      if (tool === undefined || tool.callId !== chunk.callId) continue;
338
      tool.output = chunk.output;
339
      tool.error = chunk.error;
340
      tool.status = chunk.error === undefined ? "succeeded" : "failed";
341
      const entry = this.entries[index];
342
      if (entry !== undefined) entry.settled = true;
343
      return;
344
    }
345
  }
346
203 347
  private emit(): void {
204 348
    for (const listener of this.listeners) listener();
205 349
  }
206 350
}
351
352
/** A renderer must not be able to mutate the transcript through its snapshot. */
353
function copyEntry(entry: CoderEntry): CoderEntry {
354
  return entry.tool === undefined ? { ...entry } : { ...entry, tool: { ...entry.tool } };
355
}
packages/openagents-cli/src/coder-ui.ts modified +284 -124

@@ -19,38 +19,62 @@

19 19
 *     ├──────────────────────────────┤
20 20
 *     │ composer                     │
21 21
 *     └──────────────────────────────┘
22
 *
23
 * Painting is differential. An earlier version cleared the whole screen and
24
 * repainted it several times a second while a reply streamed, which left a
25
 * stack of half-drawn frames wherever the terminal kept scrolled-off alternate
26
 * screen rows. Nothing here clears the screen, writes a newline, or moves the
27
 * cursor past the last row, so the terminal never scrolls and its own
28
 * scrollback is never written to. Scrolling the transcript is the interface's
29
 * own job instead.
22 30
 */
23 31
24
import type { CoderEntry, CoderSession, CoderSnapshot } from "./coder-session.js";
32
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
33
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
25 34
26 35
const ALT_SCREEN_ON = "\x1b[?1049h";
27 36
const ALT_SCREEN_OFF = "\x1b[?1049l";
28 37
const CURSOR_HIDE = "\x1b[?25l";
29 38
const CURSOR_SHOW = "\x1b[?25h";
30
const CLEAR = "\x1b[2J";
39
const ERASE_LINE = "\x1b[K";
40
/**
41
 * Alternate scroll: the terminal turns the wheel into arrow keys while the
42
 * alternate screen is up. It costs one escape sequence and, unlike mouse
43
 * reporting, leaves the terminal's own text selection alone.
44
 */
45
const ALT_SCROLL_ON = "\x1b[?1007h";
46
const ALT_SCROLL_OFF = "\x1b[?1007l";
31 47
32 48
const DIM = "\x1b[2m";
33 49
const BOLD = "\x1b[1m";
50
const ITALIC = "\x1b[3m";
34 51
const RESET = "\x1b[0m";
35 52
const CYAN = "\x1b[36m";
36 53
const GREEN = "\x1b[32m";
37 54
const YELLOW = "\x1b[33m";
55
const MAGENTA = "\x1b[35m";
56
const RED = "\x1b[31m";
38 57
39 58
const STATUS_ROWS = 1;
40 59
const COMPOSER_ROWS = 3;
41
/** Escape arms interruption for this long, so an arrow key is not an escape. */
42
const INTERRUPT_WINDOW_MS = 5000;
60
/** Width of the role gutter, so every entry's text starts in one column. */
61
const GUTTER = 9;
62
/**
63
 * How long a lone escape byte waits for the rest of a sequence.
64
 *
65
 * A bare `\x1b` and the first byte of an arrow key are the same byte, so the
66
 * only way to tell them apart is to wait. Terminals deliver the rest of an
67
 * arrow key in the same read or the one straight after, so this is below the
68
 * threshold where a keypress feels delayed, and it lets a single escape
69
 * interrupt rather than requiring two.
70
 */
71
const ESCAPE_WINDOW_MS = 40;
43 72
44 73
export interface CoderUiOptions {
45 74
  readonly stdin: NodeJS.ReadStream;
46 75
  readonly stdout: NodeJS.WriteStream;
47 76
}
48 77
49
/** Visible width, ignoring ANSI styling. */
50
function visibleWidth(text: string): number {
51
  return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length;
52
}
53
54 78
/**
55 79
 * Put `left` at the start of a row and `right` at the end.
56 80
 *

@@ -73,34 +97,22 @@ function elapsed(sinceMs: number, nowMs: number): string {

73 97
  return `${minutes}m ${seconds % 60}s`;
74 98
}
75 99
76
/** Wrap one paragraph to the available width, preserving blank lines. */
77
function wrap(text: string, width: number): ReadonlyArray<string> {
78
  const lines: string[] = [];
79
  for (const paragraph of text.split("\n")) {
80
    if (paragraph.length === 0) {
81
      lines.push("");
82
      continue;
83
    }
84
    let current = "";
85
    for (const word of paragraph.split(" ")) {
86
      if (current.length === 0) {
87
        current = word;
88
      } else if (current.length + 1 + word.length <= width) {
89
        current += ` ${word}`;
90
      } else {
91
        lines.push(current);
92
        current = word;
93
      }
94
    }
95
    lines.push(current);
96
  }
97
  return lines;
100
/** Collapse to one line and cut to a visible width, marking what was cut. */
101
function clip(text: string, width: number): string {
102
  return truncate(text.replace(/\s+/g, " ").trim(), width);
103
}
104
105
/** Cut to a visible width, keeping the leading indentation intact. */
106
function truncate(text: string, width: number): string {
107
  const glyphs = [...text.replace(/\t/g, "  ")];
108
  if (glyphs.length <= width) return glyphs.join("");
109
  return `${glyphs.slice(0, Math.max(1, width - 1)).join("")}…`;
98 110
}
99 111
100 112
/**
101
 * Match a complete escape sequence at `index`, or return undefined for a bare
102
 * escape. Covers CSI (`\x1b[…final`), SS3 (`\x1bO…`), and the `\x1b[…~` forms
103
 * that carry PageUp and PageDown.
113
 * Match a complete escape sequence at `index`, or return undefined when the
114
 * bytes so far could still be the start of one. Covers CSI (`\x1b[…final`),
115
 * SS3 (`\x1bO…`), and the `\x1b[…~` forms that carry PageUp and PageDown.
104 116
 */
105 117
function matchEscapeSequence(text: string, index: number): string | undefined {
106 118
  if (text[index] !== "\x1b") return undefined;

@@ -125,7 +137,9 @@ function matchEscapeSequence(text: string, index: number): string | undefined {

125 137
    return index + 2 < text.length ? text.slice(index, index + 3) : undefined;
126 138
  }
127 139
128
  return undefined;
140
  // Anything else after the escape is not a sequence this interface reads, so
141
  // the escape stands on its own and the next byte is an ordinary key.
142
  return "\x1b";
129 143
}
130 144
131 145
/**

@@ -135,14 +149,30 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

135 149
  const { stdin, stdout } = options;
136 150
137 151
  let composer = "";
138
  let scrollOffset = 0;
139
  let escapeArmedAt = 0;
140
  let armedNotice = false;
152
  /**
153
   * The first transcript line the viewport shows, or undefined while the
154
   * viewport follows the newest content.
155
   *
156
   * Holding an absolute line rather than a distance from the bottom is what
157
   * makes a scrolled-up reader stay put while a reply keeps arriving: the
158
   * bottom moves, the anchor does not.
159
   */
160
  let anchor: number | undefined;
161
  /** Tool calls the reader expanded with ctrl+o. */
162
  const expanded = new Set<string>();
141 163
  let exitCode = 0;
142 164
  let closed = false;
143 165
  let runningSince = Date.now();
144 166
  /** Redraws the status line once a second so the elapsed time advances. */
145 167
  let ticker: NodeJS.Timeout | undefined;
168
  /** Rows as last painted, so only what changed is written. */
169
  let painted: string[] = [];
170
  /** Geometry from the last paint, which is what the scroll keys act on. */
171
  let lineCount = 0;
172
  let viewport = 1;
173
  /** Bytes held back because they may be the start of an escape sequence. */
174
  let pendingEscape = "";
175
  let escapeTimer: NodeJS.Timeout | undefined;
146 176
147 177
  const write = (text: string) => {
148 178
    stdout.write(text);

@@ -157,20 +187,26 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

157 187
        clearInterval(ticker);
158 188
        ticker = undefined;
159 189
      }
190
      if (escapeTimer !== undefined) {
191
        clearTimeout(escapeTimer);
192
        escapeTimer = undefined;
193
      }
160 194
      unsubscribe();
161 195
      stdin.off("data", onData);
162
      stdout.off("resize", render);
196
      stdout.off("resize", onResize);
163 197
      if (stdin.isTTY) stdin.setRawMode(false);
164 198
      stdin.pause();
165
      write(CURSOR_SHOW + ALT_SCREEN_OFF);
199
      write(CURSOR_SHOW + ALT_SCROLL_OFF + ALT_SCREEN_OFF);
166 200
      resolve(exitCode);
167 201
    };
168 202
169
    /** Turn the transcript into printable lines, newest last. */
203
    /** Turn the transcript into printable rows, newest last. */
170 204
    const transcriptLines = (snapshot: CoderSnapshot, width: number): ReadonlyArray<string> => {
171 205
      const out: string[] = [];
172
      const body = Math.max(20, width - 12);
206
      const body = Math.max(20, width - GUTTER - 1);
173 207
208
      // One blank row between entries. It is what keeps a tool call from
209
      // reading as part of the sentence before it.
174 210
      for (const entry of snapshot.entries) {
175 211
        if (out.length > 0) out.push("");
176 212
        out.push(...renderEntry(entry, body));

@@ -179,25 +215,91 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

179 215
    };
180 216
181 217
    const renderEntry = (entry: CoderEntry, width: number): ReadonlyArray<string> => {
182
      const label =
218
      const [label, color] =
183 219
        entry.role === "you"
184
          ? `${CYAN}${BOLD}you${RESET}`
220
          ? ["you", CYAN]
185 221
          : entry.role === "assistant"
186
            ? `${GREEN}${BOLD}coder${RESET}`
187
            : `${YELLOW}${BOLD}note${RESET}`;
188
189
      const text = entry.text.length === 0 && !entry.settled ? "…" : entry.text;
190
      const wrapped = wrap(text, width);
222
            ? ["coder", GREEN]
223
            : entry.role === "tool"
224
              ? ["tool", MAGENTA]
225
              : entry.role === "reasoning"
226
                ? ["think", DIM]
227
                : ["note", YELLOW];
228
229
      const head = `  ${color}${BOLD}${label}${RESET}${" ".repeat(GUTTER - 2 - label.length)}`;
230
      const continuation = " ".repeat(GUTTER);
231
      const rows = entryRows(entry, width);
191 232
      const caret = entry.settled ? "" : `${DIM}▌${RESET}`;
192 233
193
      return wrapped.map((line, index) => {
194
        const gutter = index === 0 ? label : "     ";
195
        const tail = index === wrapped.length - 1 ? caret : "";
196
        const pad = index === 0 ? "  " : "";
197
        return `  ${gutter}${pad} ${line}${tail}`;
234
      return rows.map((row, index) => {
235
        const tail = index === rows.length - 1 ? caret : "";
236
        return `${index === 0 ? head : continuation}${row}${tail}`;
198 237
      });
199 238
    };
200 239
240
    const entryRows = (entry: CoderEntry, width: number): ReadonlyArray<string> => {
241
      if (entry.role === "tool" && entry.tool !== undefined) {
242
        return toolRows(entry.tool, width, expanded.has(entry.tool.callId));
243
      }
244
      if (entry.text.length === 0 && !entry.settled) return ["…"];
245
      // Reasoning is dim italic rather than Markdown. The styling already says
246
      // what the text is, and emphasis nested inside italic reads worse than
247
      // the source it came from.
248
      if (entry.role === "reasoning") return wrapStyled(entry.text, width, `${DIM}${ITALIC}`);
249
      if (entry.role === "assistant") return renderMarkdown(entry.text, width);
250
      return wrapStyled(entry.text, width, entry.role === "notice" ? DIM : "");
251
    };
252
253
    const toolRows = (tool: CoderToolCall, width: number, open: boolean): ReadonlyArray<string> => {
254
      const mark =
255
        tool.status === "running"
256
          ? `${YELLOW}◐${RESET}`
257
          : tool.status === "failed"
258
            ? `${RED}✗${RESET}`
259
            : `${GREEN}✓${RESET}`;
260
      const rows = [`${mark} ${BOLD}${tool.name}${RESET}`];
261
262
      if (!open) {
263
        const args = clip(tool.arguments, Math.max(8, width - 4));
264
        if (args.length > 0) rows.push(`${DIM}${args}${RESET}`);
265
        const outcome =
266
          tool.error !== undefined
267
            ? `${RED}${clip(tool.error, Math.max(8, width - 4))}${RESET}`
268
            : tool.output !== undefined
269
              ? `${DIM}→ ${clip(tool.output, Math.max(8, width - 6))}${RESET}`
270
              : tool.status === "running"
271
                ? `${DIM}→ running…${RESET}`
272
                : "";
273
        if (outcome.length > 0) rows.push(outcome);
274
        return rows;
275
      }
276
277
      for (const line of tool.arguments.split("\n")) {
278
        rows.push(`${DIM}${truncate(line, width)}${RESET}`);
279
      }
280
      if (tool.error !== undefined) {
281
        rows.push(...wrapStyled(tool.error, width, RED));
282
      } else if (tool.output !== undefined) {
283
        // The arrow separates the call from its result, which otherwise read
284
        // as one JSON document split over a blank line.
285
        const lines = tool.output.split("\n");
286
        for (const [index, line] of lines.entries()) {
287
          const marker = index === 0 ? `${DIM}→${RESET} ` : "  ";
288
          rows.push(`${marker}${DIM}${truncate(line, Math.max(4, width - 2))}${RESET}`);
289
        }
290
      }
291
      return rows;
292
    };
293
294
    /** The newest tool call, which is the one ctrl+o expands. */
295
    const focusedTool = (snapshot: CoderSnapshot): string | undefined => {
296
      for (let index = snapshot.entries.length - 1; index >= 0; index -= 1) {
297
        const callId = snapshot.entries[index]?.tool?.callId;
298
        if (callId !== undefined) return callId;
299
      }
300
      return undefined;
301
    };
302
201 303
    const render = () => {
202 304
      if (closed) return;
203 305
      const snapshot = session.snapshot();

@@ -206,59 +308,94 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

206 308
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - 1);
207 309
208 310
      const lines = transcriptLines(snapshot, width);
209
      const maxOffset = Math.max(0, lines.length - transcriptHeight);
210
      if (scrollOffset > maxOffset) scrollOffset = maxOffset;
211
      const start = Math.max(0, maxOffset - scrollOffset);
212
      const visible = lines.slice(start, start + transcriptHeight);
311
      lineCount = lines.length;
312
      viewport = transcriptHeight;
213 313
214
      const frame: string[] = [];
215
      frame.push(CURSOR_HIDE, CLEAR, "\x1b[H");
314
      const maxStart = Math.max(0, lines.length - transcriptHeight);
315
      const start = anchor === undefined ? maxStart : Math.min(anchor, maxStart);
316
      const above = start;
317
      const below = Math.max(0, lines.length - start - transcriptHeight);
216 318
217
      for (let row = 0; row < transcriptHeight; row += 1) {
218
        frame.push(`\x1b[${row + 1};1H`, visible[row] ?? "");
219
      }
319
      const rows: string[] = [];
320
      for (let row = 0; row < transcriptHeight; row += 1) rows.push(lines[start + row] ?? "");
220 321
221 322
      // Bottom chrome, in the order a reader scans it: what the session is
222 323
      // doing now, then where the typing goes, then what the keys do. The
223 324
      // composer sits between two rules so it reads as its own region rather
224 325
      // than as the last line of the transcript.
225
      const rule = "─".repeat(Math.max(0, width));
326
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
226 327
      const inner = Math.max(10, width - 4);
227 328
228 329
      const activity = snapshot.running
229 330
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())} · streaming)${RESET}`
230
        : armedNotice
231
          ? `${YELLOW}●${RESET} ${YELLOW}again to interrupt${RESET}`
232
          : `${DIM}○ ready${RESET}`;
331
        : `${DIM}○ ready${RESET}`;
233 332
      const where = `${DIM}${snapshot.repository} · ${snapshot.branch} · ${snapshot.model}${RESET}`;
234
      frame.push(`\x1b[${transcriptHeight + 1};1H`, `  ${justify(activity, where, inner)}`);
333
      rows.push(`  ${justify(activity, where, inner)}`);
334
      rows.push(rule);
335
      rows.push(`  › ${composer}`);
336
      rows.push(rule);
337
338
      // Every key named here does something in the state it is named in. An
339
      // earlier version offered "esc esc to interrupt" while idle, where there
340
      // was nothing to interrupt.
341
      const keys: string[] = [];
342
      if (snapshot.running) {
343
        keys.push("esc to interrupt", "ctrl+c to stop");
344
      } else {
345
        keys.push("enter to send");
346
        if (composer.length > 0) keys.push("esc to clear");
347
        else keys.push("ctrl+d to quit");
348
      }
349
      if (lines.length > transcriptHeight) keys.push("pgup/pgdn to scroll");
350
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
235 351
236
      frame.push(`\x1b[${transcriptHeight + 2};1H`, `${DIM}${rule}${RESET}`);
352
      const counter =
353
        anchor !== undefined
354
          ? `${YELLOW}scrolled${RESET}${DIM} · ↑${above} · ↓${below}${RESET}`
355
          : above > 0
356
            ? `${DIM}↑${above} above · ${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`
357
            : `${DIM}${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`;
358
      rows.push(`  ${justify(`${DIM}${keys.join(" · ")}${RESET}`, counter, inner)}`);
359
360
      paint(rows, transcriptHeight + 3, 4 + composer.length + 1);
361
    };
237 362
238
      const promptPrefix = "  › ";
239
      frame.push(`\x1b[${transcriptHeight + 3};1H`, `${promptPrefix}${composer}`);
363
    /**
364
     * Write only the rows that changed, erasing each to the end of the line.
365
     *
366
     * Nothing here emits a newline or writes past the last column, so the
367
     * terminal has no reason to scroll and no frame can reach its scrollback.
368
     */
369
    const paint = (rows: ReadonlyArray<string>, cursorRow: number, cursorColumn: number) => {
370
      const frame: string[] = [CURSOR_HIDE];
371
      for (let index = 0; index < rows.length; index += 1) {
372
        const next = rows[index] ?? "";
373
        if (painted[index] === next) continue;
374
        frame.push(`\x1b[${index + 1};1H`, ERASE_LINE, next);
375
      }
376
      painted = [...rows];
377
      frame.push(`\x1b[${cursorRow};${cursorColumn}H`, CURSOR_SHOW);
378
      write(frame.join(""));
379
    };
240 380
241
      frame.push(`\x1b[${transcriptHeight + 4};1H`, `${DIM}${rule}${RESET}`);
381
    const onResize = () => {
382
      // Every row is laid out for the old width, so none of it can be reused.
383
      painted = [];
384
      render();
385
    };
242 386
243
      const keys = snapshot.running
244
        ? `${DIM}esc esc to interrupt · ctrl+c to stop${RESET}`
245
        : `${DIM}enter to send · esc esc to interrupt · ctrl+d to quit${RESET}`;
246
      const counter =
247
        scrollOffset > 0
248
          ? `${DIM}scrolled ${scrollOffset}${RESET}`
249
          : `${DIM}${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`;
250
      frame.push(`\x1b[${transcriptHeight + 5};1H`, `  ${justify(keys, counter, inner)}`);
251
252
      // Park the cursor at the composer so typing looks right.
253
      frame.push(`\x1b[${transcriptHeight + 3};${promptPrefix.length + composer.length + 1}H`);
254
      frame.push(CURSOR_SHOW);
255
      write(frame.join(""));
387
    /** Move the viewport by whole lines, snapping back to follow at the end. */
388
    const scrollBy = (delta: number) => {
389
      const maxStart = Math.max(0, lineCount - viewport);
390
      const current = anchor ?? maxStart;
391
      const next = Math.max(0, Math.min(maxStart, current + delta));
392
      anchor = next >= maxStart ? undefined : next;
256 393
    };
257 394
258 395
    const submit = () => {
259 396
      const prompt = composer;
260 397
      composer = "";
261
      scrollOffset = 0;
398
      anchor = undefined;
262 399
      runningSince = Date.now();
263 400
      render();
264 401

@@ -277,6 +414,20 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

277 414
      });
278 415
    };
279 416
417
    /** A lone escape: interrupt if there is something to interrupt, else clear. */
418
    const onEscape = () => {
419
      if (!session.interrupt()) composer = "";
420
      render();
421
    };
422
423
    const toggleFocusedTool = () => {
424
      const callId = focusedTool(session.snapshot());
425
      if (callId === undefined) return;
426
      if (expanded.has(callId)) expanded.delete(callId);
427
      else expanded.add(callId);
428
      render();
429
    };
430
280 431
    /**
281 432
     * Handle one chunk of terminal input.
282 433
     *

@@ -286,55 +437,44 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

286 437
     * text never submits, which is exactly what the first version did.
287 438
     */
288 439
    const onData = (chunk: string | Buffer) => {
289
      const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
440
      if (escapeTimer !== undefined) {
441
        clearTimeout(escapeTimer);
442
        escapeTimer = undefined;
443
      }
444
      const text = pendingEscape + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
445
      pendingEscape = "";
290 446
      let index = 0;
291 447
      let dirty = false;
292 448
293
      const disarm = () => {
294
        if (armedNotice) {
295
          armedNotice = false;
296
          dirty = true;
297
        }
298
      };
299
300 449
      while (index < text.length) {
301 450
        const char = text[index] ?? "";
302 451
303 452
        if (char === "\x1b") {
304 453
          const sequence = matchEscapeSequence(text, index);
305
          if (sequence !== undefined) {
306
            disarm();
307
            index += sequence.length;
308
            if (sequence === "\x1b[5~") {
309
              scrollOffset += 5;
310
              dirty = true;
311
            } else if (sequence === "\x1b[6~") {
312
              scrollOffset = Math.max(0, scrollOffset - 5);
313
              dirty = true;
314
            }
315
            // Every other sequence (arrows, home, end) is ignored rather than
316
            // typed into the composer.
317
            continue;
454
          if (sequence === undefined) {
455
            // The rest of the sequence has not arrived. Hold it: if nothing
456
            // follows within the window it was a bare escape after all.
457
            pendingEscape = text.slice(index);
458
            break;
318 459
          }
319
320
          // A bare escape. The first arms interruption, the second inside the
321
          // window performs it. Without the window an arrow key's leading byte
322
          // is indistinguishable from a deliberate escape.
323
          const now = Date.now();
324
          if (armedNotice && now - escapeArmedAt <= INTERRUPT_WINDOW_MS) {
325
            armedNotice = false;
326
            if (!session.interrupt()) composer = "";
327
          } else {
328
            escapeArmedAt = now;
329
            armedNotice = true;
460
          index += sequence.length;
461
          if (sequence === "\x1b") {
462
            onEscape();
463
            dirty = false;
464
            continue;
330 465
          }
466
          const page = Math.max(1, viewport - 1);
467
          if (sequence === "\x1b[5~") scrollBy(-page);
468
          else if (sequence === "\x1b[6~") scrollBy(page);
469
          else if (sequence === "\x1b[A" || sequence === "\x1bOA") scrollBy(-1);
470
          else if (sequence === "\x1b[B" || sequence === "\x1bOB") scrollBy(1);
471
          else if (sequence === "\x1b[1~" || sequence === "\x1b[H") scrollBy(-lineCount);
472
          else if (sequence === "\x1b[4~" || sequence === "\x1b[F") scrollBy(lineCount);
473
          else continue;
331 474
          dirty = true;
332
          index += 1;
333 475
          continue;
334 476
        }
335 477
336
        disarm();
337
338 478
        if (char === "\r" || char === "\n") {
339 479
          index += 1;
340 480
          // Swallow a CRLF pair so a paste does not submit twice.

@@ -364,6 +504,13 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

364 504
          continue;
365 505
        }
366 506
507
        if (char === "\x0f") {
508
          toggleFocusedTool();
509
          dirty = false;
510
          index += 1;
511
          continue;
512
        }
513
367 514
        if (char === "\x7f" || char === "\b") {
368 515
          composer = composer.slice(0, -1);
369 516
          dirty = true;

@@ -394,25 +541,38 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

394 541
          end += 1;
395 542
        }
396 543
        composer += text.slice(index, end);
544
        // Typing means the reader wants to see what they are answering.
545
        anchor = undefined;
397 546
        dirty = true;
398 547
        index = end;
399 548
      }
400 549
550
      if (pendingEscape.length > 0) {
551
        escapeTimer = setTimeout(() => {
552
          escapeTimer = undefined;
553
          const held = pendingEscape;
554
          pendingEscape = "";
555
          // A lone escape byte and nothing after it. Anything longer was the
556
          // start of a sequence the terminal never finished, and is dropped.
557
          if (held === "\x1b") onEscape();
558
        }, ESCAPE_WINDOW_MS);
559
      }
560
401 561
      if (dirty) render();
402 562
    };
403 563
404 564
    const unsubscribe = session.onChange(render);
405 565
406
    write(ALT_SCREEN_ON);
566
    write(ALT_SCREEN_ON + ALT_SCROLL_ON);
407 567
    if (stdin.isTTY) stdin.setRawMode(true);
408 568
    stdin.resume();
409 569
    stdin.setEncoding("utf8");
410 570
    stdin.on("data", onData);
411
    stdout.on("resize", render);
571
    stdout.on("resize", onResize);
412 572
413 573
    session.notice(
414 574
      "openagents coder — development build. Type a message and press enter. " +
415
        "Ctrl+D quits, Esc Esc interrupts a reply.",
575
        "Ctrl+D quits, Esc interrupts a reply.",
416 576
    );
417 577
    render();
418 578
  });
packages/openagents-cli/test/coder-markdown.test.ts added +105

@@ -0,0 +1,105 @@

1
import { describe, expect, it } from "vitest";
2
3
import { renderMarkdown, visibleWidth, wrapStyled } from "../src/coder-markdown.js";
4
5
const SGR = new RegExp("\\u001b\\[[0-9;]*m", "g");
6
const plain = (rows: ReadonlyArray<string>) => rows.map((row) => row.replace(SGR, ""));
7
8
const BOLD = "\x1b[1m";
9
const ITALIC = "\x1b[3m";
10
const DIM = "\x1b[2m";
11
const CODE = "\x1b[36m";
12
13
describe("renderMarkdown", () => {
14
  it("renders bold as ANSI rather than as asterisks", () => {
15
    const rows = renderMarkdown("hello **ox-alpha** there", 60);
16
    expect(rows[0]).toContain(`${BOLD}ox-alpha\x1b[0m`);
17
    expect(plain(rows)[0]).toBe("hello ox-alpha there");
18
  });
19
20
  it("renders italic and inline code", () => {
21
    const rows = renderMarkdown("a *slanted* word and `some_code()`", 60);
22
    expect(rows[0]).toContain(`${ITALIC}slanted\x1b[0m`);
23
    expect(rows[0]).toContain(`${CODE}some_code()\x1b[0m`);
24
  });
25
26
  it("renders a fenced block as styled rows without the fence markers", () => {
27
    const rows = renderMarkdown(
28
      ["before", "```elixir", 'def hello, do: "world"', "```", "after"].join("\n"),
29
      60,
30
    );
31
    expect(plain(rows)).toEqual(["before", '│ def hello, do: "world"', "after"]);
32
    expect(rows[1]).toContain(CODE);
33
  });
34
35
  it("keeps a fenced block styled while its closing fence has not arrived", () => {
36
    const rows = renderMarkdown(["```", "line one", "line two"].join("\n"), 60);
37
    expect(plain(rows)).toEqual(["│ line one", "│ line two"]);
38
  });
39
40
  it("renders headings, bullets, numbers, and quotes", () => {
41
    const rows = renderMarkdown(
42
      ["# Title", "- first", "- second", "1. one", "> quoted"].join("\n"),
43
      60,
44
    );
45
    expect(plain(rows)).toEqual(["Title", "• first", "• second", "1. one", "│ quoted"]);
46
    expect(rows[0]).toContain(BOLD);
47
  });
48
49
  it("wraps a list item under its text rather than under its bullet", () => {
50
    const rows = plain(renderMarkdown("- alpha beta gamma delta epsilon zeta", 16));
51
    expect(rows[0]).toBe("• alpha beta");
52
    for (const row of rows.slice(1)) expect(row.startsWith("  ")).toBe(true);
53
    expect(rows.join(" ").replace(/\s+/g, " ")).toContain("alpha beta gamma delta epsilon zeta");
54
  });
55
56
  it("never renders a row wider than the width it was given", () => {
57
    const source = "a-very-long-unbroken-token-that-cannot-be-split-on-spaces and some words";
58
    for (const row of renderMarkdown(source, 20)) expect(visibleWidth(row)).toBeLessThanOrEqual(20);
59
  });
60
61
  it("renders unterminated markup as the characters that arrived", () => {
62
    expect(plain(renderMarkdown("half a **bold run", 60))[0]).toBe("half a **bold run");
63
    expect(plain(renderMarkdown("half a `code run", 60))[0]).toBe("half a `code run");
64
  });
65
66
  it("loses no characters at any point in a streamed reply", () => {
67
    const source = [
68
      "## Connected **repositories**",
69
      "",
70
      "- 📓 `openagents.com` — *the forge*",
71
      "",
72
      "```",
73
      "git push openagents HEAD:main",
74
      "```",
75
    ].join("\n");
76
77
    for (let length = 1; length <= source.length; length += 1) {
78
      const arrived = source.slice(0, length);
79
      const rendered = plain(renderMarkdown(arrived, 60)).join("\n");
80
      // Every non-markup character that arrived is still on screen.
81
      const wanted = arrived.replace(/[`*#>\-\s]/gu, "");
82
      const shown = rendered.replace(/[`*#>\-\s│•]/gu, "");
83
      expect(shown).toContain(wanted);
84
    }
85
  });
86
87
  it("drops only the fence info string, which names a language rather than saying anything", () => {
88
    expect(plain(renderMarkdown("```elixir\nx\n```", 60))).toEqual(["│ x"]);
89
  });
90
91
  it("keeps a mid-token split from mangling a run that later completes", () => {
92
    // `**ox` then `-alpha**` is how a bold name arrives from the server.
93
    expect(plain(renderMarkdown("**ox", 60))[0]).toBe("**ox");
94
    expect(plain(renderMarkdown("**ox-alpha**", 60))[0]).toBe("ox-alpha");
95
    expect(renderMarkdown("**ox-alpha**", 60)[0]).toContain(BOLD);
96
  });
97
});
98
99
describe("wrapStyled", () => {
100
  it("applies one style to the whole block and preserves blank lines", () => {
101
    const rows = wrapStyled("one\n\ntwo", 40, DIM);
102
    expect(plain(rows)).toEqual(["one", "", "two"]);
103
    expect(rows[0]).toBe(`${DIM}one\x1b[0m`);
104
  });
105
});
packages/openagents-cli/test/coder-ox.test.ts modified +125 -6

@@ -1,16 +1,23 @@

1 1
import { afterEach, describe, expect, it, vi } from "vitest";
2 2
3 3
import { OxAlphaReplySource, OxAlphaUnavailable } from "../src/coder-ox.js";
4
import type { ReplyChunk } from "../src/coder-session.js";
4 5
5 6
const json = (status: number, body: unknown) =>
6 7
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
7 8
8
const collect = async (source: OxAlphaReplySource, prompt = "hello") => {
9
  const chunks: string[] = [];
10
  for await (const chunk of source.reply(prompt, new AbortController().signal)) chunks.push(chunk);
11
  return chunks.join("");
9
const chunks = async (source: OxAlphaReplySource, prompt = "hello") => {
10
  const out: ReplyChunk[] = [];
11
  for await (const chunk of source.reply(prompt, new AbortController().signal)) out.push(chunk);
12
  return out;
12 13
};
13 14
15
/** The assistant text a turn produced, which most of these tests assert on. */
16
const collect = async (source: OxAlphaReplySource, prompt = "hello") =>
17
  (await chunks(source, prompt))
18
    .map((chunk) => (chunk.type === "text" ? chunk.value : ""))
19
    .join("");
20
14 21
const source = () =>
15 22
  new OxAlphaReplySource({ origin: "https://openagents.test", token: "test-token" });
16 23

@@ -86,7 +93,7 @@ describe("OxAlphaReplySource", () => {

86 93
    expect(await collect(source())).toBe("one two");
87 94
  });
88 95
89
  it("ignores reasoning deltas, which the transcript does not show", async () => {
96
  it("yields reasoning deltas beside the text, in the order they were recorded", async () => {
90 97
    stubFetch(
91 98
      [
92 99
        [],

@@ -99,7 +106,119 @@ describe("OxAlphaReplySource", () => {

99 106
      json(202, { turn: { id: "run-1" } }),
100 107
    );
101 108
102
    expect(await collect(source())).toBe("said");
109
    expect(await chunks(source())).toEqual([
110
      { type: "reasoning", value: "thinking" },
111
      { type: "text", value: "said" },
112
    ]);
113
  });
114
115
  it("surfaces a tool call from the projection the event carries", async () => {
116
    stubFetch(
117
      [
118
        [],
119
        [
120
          {
121
            run_id: "run-1",
122
            sequence: 1,
123
            type: "tool_call_started",
124
            payload: { call_id: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
125
            tool_call: {
126
              call_id: "c1",
127
              name: "repo_grep",
128
              arguments: '{\n  "pattern": "x"\n}',
129
              output: null,
130
              error: null,
131
              status: "running",
132
            },
133
          },
134
          {
135
            run_id: "run-1",
136
            sequence: 2,
137
            type: "tool_call_completed",
138
            payload: { call_id: "c1", output: "{}" },
139
            tool_call: {
140
              call_id: "c1",
141
              name: "repo_grep",
142
              arguments: '{\n  "pattern": "x"\n}',
143
              output: '{\n  "matches": []\n}',
144
              error: null,
145
              status: "succeeded",
146
            },
147
          },
148
          { run_id: "run-1", sequence: 3, type: "response_completed", payload: {} },
149
        ],
150
      ],
151
      json(202, { turn: { id: "run-1" } }),
152
    );
153
154
    // The pretty-printed projection is what the browser shows, so the CLI
155
    // reads it rather than re-deriving the call from the raw payload.
156
    expect(await chunks(source())).toEqual([
157
      {
158
        type: "tool_call",
159
        callId: "c1",
160
        name: "repo_grep",
161
        arguments: '{\n  "pattern": "x"\n}',
162
      },
163
      { type: "tool_result", callId: "c1", output: '{\n  "matches": []\n}', error: undefined },
164
    ]);
165
  });
166
167
  it("reports a failed tool call with the server's message", async () => {
168
    stubFetch(
169
      [
170
        [],
171
        [
172
          {
173
            run_id: "run-1",
174
            sequence: 1,
175
            type: "tool_call_failed",
176
            payload: { call_id: "c1", error: "The tool is not authorized for this data scope." },
177
            tool_call: {
178
              call_id: "c1",
179
              name: "conversation_search",
180
              arguments: "{}",
181
              output: null,
182
              error: { code: null, message: "The tool is not authorized for this data scope." },
183
              status: "failed",
184
            },
185
          },
186
          { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
187
        ],
188
      ],
189
      json(202, { turn: { id: "run-1" } }),
190
    );
191
192
    expect(await chunks(source())).toEqual([
193
      {
194
        type: "tool_result",
195
        callId: "c1",
196
        output: undefined,
197
        error: "The tool is not authorized for this data scope.",
198
      },
199
    ]);
200
  });
201
202
  it("reads a tool call from the raw payload when no projection is attached", async () => {
203
    stubFetch(
204
      [
205
        [],
206
        [
207
          {
208
            run_id: "run-1",
209
            sequence: 1,
210
            type: "tool_call_started",
211
            payload: { call_id: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
212
          },
213
          { run_id: "run-1", sequence: 2, type: "response_completed", payload: {} },
214
        ],
215
      ],
216
      json(202, { turn: { id: "run-1" } }),
217
    );
218
219
    expect(await chunks(source())).toEqual([
220
      { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
221
    ]);
103 222
  });
104 223
105 224
  it("reports a missing scope rather than an empty reply", async () => {
packages/openagents-cli/test/coder-session.test.ts modified +125 -2

@@ -1,9 +1,14 @@

1 1
import { describe, expect, it } from "vitest";
2 2
3
import { CoderSession, DummyReplySource, type ReplySource } from "../src/coder-session.js";
3
import {
4
  CoderSession,
5
  DummyReplySource,
6
  type ReplyChunk,
7
  type ReplySource,
8
} from "../src/coder-session.js";
4 9
5 10
/** A source whose chunks are controlled by the test. */
6
const scripted = (chunks: ReadonlyArray<string>, delayMs = 0): ReplySource => ({
11
const source = (chunks: ReadonlyArray<ReplyChunk>, delayMs = 0): ReplySource => ({
7 12
  model: "scripted",
8 13
  async *reply(_prompt, signal) {
9 14
    for (const chunk of chunks) {

@@ -15,6 +20,13 @@ const scripted = (chunks: ReadonlyArray<string>, delayMs = 0): ReplySource => ({

15 20
  },
16 21
});
17 22
23
/** The common case: a source that only produces assistant text. */
24
const scripted = (chunks: ReadonlyArray<string>, delayMs = 0): ReplySource =>
25
  source(
26
    chunks.map((value) => ({ type: "text", value }) as const),
27
    delayMs,
28
  );
29
18 30
describe("CoderSession", () => {
19 31
  it("appends the prompt and streams the reply into one entry", async () => {
20 32
    const session = new CoderSession(scripted(["one ", "two ", "three"]), "repo", "main");

@@ -82,6 +94,109 @@ describe("CoderSession", () => {

82 94
    expect(session.interrupt()).toBe(false);
83 95
  });
84 96
97
  it("makes a tool call its own entry with the name and arguments the source gave", async () => {
98
    const session = new CoderSession(
99
      source([
100
        { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
101
        { type: "tool_result", callId: "c1", output: '{"matches":[]}', error: undefined },
102
      ]),
103
      "repo",
104
      "main",
105
    );
106
    await session.submit("look");
107
108
    const tool = session.snapshot().entries.find((entry) => entry.role === "tool");
109
    expect(tool?.tool?.name).toBe("repo_grep");
110
    expect(tool?.tool?.arguments).toBe('{"pattern":"x"}');
111
    expect(tool?.tool?.output).toBe('{"matches":[]}');
112
    expect(tool?.tool?.status).toBe("succeeded");
113
    expect(tool?.settled).toBe(true);
114
  });
115
116
  it("records a failed tool call with the reason rather than an outcome", async () => {
117
    const session = new CoderSession(
118
      source([
119
        { type: "tool_call", callId: "c1", name: "conversation_search", arguments: "{}" },
120
        { type: "tool_result", callId: "c1", output: undefined, error: "not authorized" },
121
      ]),
122
      "repo",
123
      "main",
124
    );
125
    await session.submit("look");
126
127
    const tool = session.snapshot().entries.find((entry) => entry.role === "tool");
128
    expect(tool?.tool?.status).toBe("failed");
129
    expect(tool?.tool?.error).toBe("not authorized");
130
  });
131
132
  it("splits the text either side of a tool call rather than joining it", async () => {
133
    const session = new CoderSession(
134
      source([
135
        { type: "text", value: "Let me check what is connected:" },
136
        { type: "tool_call", callId: "c1", name: "repo_list", arguments: "{}" },
137
        { type: "tool_result", callId: "c1", output: "[]", error: undefined },
138
        { type: "text", value: "Here is the rundown" },
139
      ]),
140
      "repo",
141
      "main",
142
    );
143
    await session.submit("what can you do");
144
145
    const entries = session.snapshot().entries;
146
    expect(entries.map((entry) => entry.role)).toEqual(["you", "assistant", "tool", "assistant"]);
147
    expect(entries[1]?.text).toBe("Let me check what is connected:");
148
    expect(entries[3]?.text).toBe("Here is the rundown");
149
  });
150
151
  it("keeps reasoning, a tool call, and text as three entries in arrival order", async () => {
152
    const session = new CoderSession(
153
      source([
154
        { type: "reasoning", value: "I should " },
155
        { type: "reasoning", value: "check first." },
156
        { type: "tool_call", callId: "c1", name: "repo_grep", arguments: "{}" },
157
        { type: "tool_result", callId: "c1", output: "[]", error: undefined },
158
        { type: "text", value: "Done." },
159
      ]),
160
      "repo",
161
      "main",
162
    );
163
    await session.submit("go");
164
165
    const entries = session.snapshot().entries;
166
    expect(entries.map((entry) => entry.role)).toEqual(["you", "reasoning", "tool", "assistant"]);
167
    expect(entries[1]?.text).toBe("I should check first.");
168
    // The opening placeholder is withdrawn when the turn starts with a thought,
169
    // so an empty assistant entry never sits above the reasoning.
170
    expect(entries.filter((entry) => entry.text.length === 0)).toEqual([]);
171
  });
172
173
  it("marks a tool call the turn never resolved as failed", async () => {
174
    const session = new CoderSession(
175
      source([{ type: "tool_call", callId: "c1", name: "repo_grep", arguments: "{}" }]),
176
      "repo",
177
      "main",
178
    );
179
    await session.submit("go");
180
181
    const tool = session.snapshot().entries.find((entry) => entry.role === "tool");
182
    expect(tool?.tool?.status).toBe("failed");
183
    expect(tool?.settled).toBe(true);
184
  });
185
186
  it("does not let a renderer mutate the transcript through its snapshot", async () => {
187
    const session = new CoderSession(
188
      source([{ type: "tool_call", callId: "c1", name: "repo_grep", arguments: "{}" }]),
189
      "repo",
190
      "main",
191
    );
192
    await session.submit("go");
193
194
    const taken = session.snapshot().entries.find((entry) => entry.role === "tool");
195
    if (taken?.tool !== undefined) taken.tool.output = "changed";
196
    const again = session.snapshot().entries.find((entry) => entry.role === "tool");
197
    expect(again?.tool?.output).toBeUndefined();
198
  });
199
85 200
  it("carries workspace and model into the snapshot for the status line", () => {
86 201
    const session = new CoderSession(new DummyReplySource(), "openagents", "main");
87 202
    const snapshot = session.snapshot();

@@ -98,4 +213,12 @@ describe("CoderSession", () => {

98 213
    expect(reply).toContain("dummy reply");
99 214
    expect(reply).toContain("what does this repository do");
100 215
  });
216
217
  it("exercises every entry kind offline, so --offline can prove the rendering", async () => {
218
    const session = new CoderSession(new DummyReplySource(0), "repo", "main");
219
    await session.submit("what can you do");
220
221
    const roles = new Set(session.snapshot().entries.map((entry) => entry.role));
222
    expect(roles).toEqual(new Set(["you", "reasoning", "tool", "assistant"]));
223
  });
101 224
});
packages/openagents-cli/test/coder-ui.test.ts added +158

@@ -0,0 +1,158 @@

1
import { EventEmitter } from "node:events";
2
import { describe, expect, it } from "vitest";
3
4
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
5
import { runCoderUi } from "../src/coder-ui.js";
6
7
/** A writable that records what the interface painted. */
8
class FakeOut extends EventEmitter {
9
  columns = 100;
10
  rows = 24;
11
  written = "";
12
  write(text: string): boolean {
13
    this.written += text;
14
    return true;
15
  }
16
}
17
18
/** A readable TTY the test can push keystrokes into. */
19
class FakeIn extends EventEmitter {
20
  isTTY = true;
21
  setRawMode(): this {
22
    return this;
23
  }
24
  resume(): this {
25
    return this;
26
  }
27
  pause(): this {
28
    return this;
29
  }
30
  setEncoding(): this {
31
    return this;
32
  }
33
}
34
35
const source = (chunks: ReadonlyArray<ReplyChunk>): ReplySource => ({
36
  model: "scripted",
37
  async *reply() {
38
    for (const chunk of chunks) yield chunk;
39
  },
40
});
41
42
/**
43
 * Replay the painted rows.
44
 *
45
 * Every row the interface writes is positioned absolutely and erased to the
46
 * end of the line first, so the last write to a row is what that row shows.
47
 */
48
function screen(written: string): ReadonlyArray<string> {
49
  const rows: string[] = [];
50
  const positions = /\x1b\[(\d+);(\d+)H(\x1b\[K)?/g;
51
  let row: number | undefined;
52
  let from = 0;
53
  const flush = (end: number) => {
54
    if (row === undefined) return;
55
    rows[row - 1] = written
56
      .slice(from, end)
57
      .replace(/\x1b\[[0-9;]*m/g, "")
58
      .trimEnd();
59
  };
60
  for (let match = positions.exec(written); match !== null; match = positions.exec(written)) {
61
    flush(match.index);
62
    row = match[3] === undefined ? undefined : Number(match[1]);
63
    from = positions.lastIndex;
64
  }
65
  flush(written.length);
66
  return rows;
67
}
68
69
const drive = async (chunks: ReadonlyArray<ReplyChunk>, prompt = "go") => {
70
  const stdin = new FakeIn();
71
  const stdout = new FakeOut();
72
  const session = new CoderSession(source(chunks), "repo", "main");
73
  const running = runCoderUi(session, {
74
    stdin: stdin as unknown as NodeJS.ReadStream,
75
    stdout: stdout as unknown as NodeJS.WriteStream,
76
  });
77
78
  await session.submit(prompt);
79
  const painted = stdout.written;
80
  stdin.emit("data", "\x04");
81
  await running;
82
  return { painted, rows: screen(painted) };
83
};
84
85
describe("runCoderUi", () => {
86
  it("never clears the screen and never writes a newline", async () => {
87
    const { painted } = await drive([{ type: "text", value: "hello" }]);
88
    // Both are how a frame reaches the terminal's own scrollback.
89
    expect(painted).not.toContain("\x1b[2J");
90
    expect(painted).not.toContain("\n");
91
    expect(painted).toContain("\x1b[?1049h");
92
  });
93
94
  it("erases each row it repaints rather than clearing the screen", async () => {
95
    const { painted } = await drive([{ type: "text", value: "hello" }]);
96
    expect(/\x1b\[\d+;1H\x1b\[K/.test(painted)).toBe(true);
97
  });
98
99
  it("puts a blank row on both sides of a tool call", async () => {
100
    const { rows } = await drive([
101
      { type: "text", value: "Let me check what is connected:" },
102
      { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
103
      { type: "tool_result", callId: "c1", output: "{}", error: undefined },
104
      { type: "text", value: "Here is the rundown" },
105
    ]);
106
107
    const tool = rows.findIndex((row) => row.includes("repo_grep"));
108
    expect(tool).toBeGreaterThan(0);
109
    expect(rows[tool - 1]).toBe("");
110
    const after = rows.findIndex((row) => row.includes("Here is the rundown"));
111
    expect(rows[after - 1]).toBe("");
112
    // The sentences either side of the call are on different rows, which is
113
    // the defect: they used to be appended to one another.
114
    expect(rows.join("\n")).not.toContain("connected:Here");
115
  });
116
117
  it("shows the tool name, its arguments, and its outcome", async () => {
118
    const { rows } = await drive([
119
      { type: "tool_call", callId: "c1", name: "repo_grep", arguments: '{"pattern":"x"}' },
120
      { type: "tool_result", callId: "c1", output: '{"matches":[]}', error: undefined },
121
    ]);
122
    const text = rows.join("\n");
123
    expect(text).toContain("repo_grep");
124
    expect(text).toContain('{"pattern":"x"}');
125
    expect(text).toContain('{"matches":[]}');
126
  });
127
128
  it("renders assistant Markdown rather than its source", async () => {
129
    const { painted, rows } = await drive([
130
      { type: "text", value: "hello **ox-alpha** and `code`" },
131
    ]);
132
    expect(rows.join("\n")).toContain("hello ox-alpha and code");
133
    expect(painted).toContain("\x1b[1mox-alpha\x1b[0m");
134
  });
135
136
  it("streams reasoning dim and italic, above the text of the same turn", async () => {
137
    const { painted, rows } = await drive([
138
      { type: "reasoning", value: "I should check first." },
139
      { type: "text", value: "Done." },
140
    ]);
141
142
    const thought = rows.findIndex((row) => row.includes("I should check first."));
143
    const answer = rows.findIndex((row) => row.includes("Done."));
144
    expect(thought).toBeGreaterThanOrEqual(0);
145
    expect(answer).toBeGreaterThan(thought);
146
    expect(rows[answer - 1]).toBe("");
147
    expect(painted).toContain("\x1b[2m\x1b[3mI should check first.\x1b[0m");
148
  });
149
150
  it("offers no key in the bottom bar that does nothing in that state", async () => {
151
    const { rows } = await drive([{ type: "text", value: "hello" }]);
152
    const bar = rows.at(-1) ?? "";
153
    expect(bar).toContain("enter to send");
154
    expect(bar).toContain("ctrl+d to quit");
155
    // There is nothing to interrupt while the session is idle.
156
    expect(bar).not.toContain("interrupt");
157
  });
158
});

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