Skip to content
18 changes: 14 additions & 4 deletions packages/core/scripts/analyze-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { join } from "node:path";
import { isRecord } from "../src/lib/guards";

const evalsRoot = join(import.meta.dir, "..", "..", "..", "evals");
// Sweep writes each run to evals/runs/<runId> (see sweep.ts). Run dirs are
// resolved from there, not the evals/ root (which only holds runs/, corpus/, …).
const runsRoot = join(evalsRoot, "runs");

interface IRunMetrics {
runId: string;
Expand Down Expand Up @@ -40,7 +43,10 @@ interface IRunMetrics {
quality: number | undefined;
}

const TIMING = /⏱ turn (\d+) took ([\d.]+)(s|ms) \(total ([\d.]+)(s|ms)\)/;
// The turn-timing line (turn.ts reportTurnTiming). The `⏱` prefix was dropped
// from the emitter, so it's optional here to stay compatible with both formats.
const TIMING =
/(?:⏱ )?turn (\d+) took ([\d.]+)(s|ms) \(total ([\d.]+)(s|ms)\)/u;
const RED = /turn \d+: red \((\d+) error/;
const ASKING = /turn (\d+): asking model/;
// Hand-counting = the model re-typing the file with SEQUENTIAL line numbers
Expand Down Expand Up @@ -199,16 +205,20 @@ async function resolveDirs(): Promise<string[]> {
if (args.length === 2 && /^\d+$/.test(args[1] ?? "")) {
const prefix = args[0] ?? "";
const count = Number(args[1]);
const all = await readdir(evalsRoot, { withFileTypes: true });
// evals/runs/ may not exist yet on a clean checkout (no sweep has run) —
// treat that as "no runs" instead of crashing with ENOENT.
const all = await readdir(runsRoot, { withFileTypes: true }).catch(
() => []
);
const dirs = all
.filter((d) => d.isDirectory() && d.name.startsWith(prefix))
.map((d) => d.name)
.sort();

return dirs.slice(-count).map((name) => join(evalsRoot, name));
return dirs.slice(-count).map((name) => join(runsRoot, name));
}

return args.map((a) => (a.startsWith("/") ? a : join(evalsRoot, a)));
return args.map((a) => (a.startsWith("/") ? a : join(runsRoot, a)));
}

function median(values: number[]): number {
Expand Down
107 changes: 104 additions & 3 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { formatHelp, takesArg } from "./cli/commands";
import { pickCommand } from "./render/command-menu";
import {
pickFileInline,
filterFiles,
formatCompletionRows,
shouldOpenAtPicker,
type IPickerView,
Expand Down Expand Up @@ -75,6 +76,9 @@ import {
renderEvent,
renderMessage,
renderStatus,
speakerLabel,
indentBlock,
BLOCK_INDENT,
StatusBar,
MIN_ROWS,
welcomeBanner,
Expand Down Expand Up @@ -633,7 +637,9 @@ function printHeader(info: {
process.stdout.write("\n── resuming conversation ──\n");

for (const message of resumed.messages) {
process.stdout.write(renderMessage(message, { color: true }));
process.stdout.write(
renderMessage(message, { color: true, speaker: model.model })
);
}

process.stdout.write("\n──────────────────────────\n");
Expand Down Expand Up @@ -1490,14 +1496,55 @@ async function repl(args: ICliArgs): Promise<number> {
// size instead of clipping the current line at its pre-resize dimensions.
let resizeEditor: ((columns: number, rows: number) => void) | null = null;

// Each agent turn renders as a "▌ <model>" block with its body indented under the
// label (mirrors the user block). The label is emitted once, on the turn's first
// streamed output; `agentTurnOpen` is reset at the start of every runLine.
let agentTurnOpen = false;
let agentAtLineStart = true;

// Indent each streamed line under the agent label. Stateful so indentation is
// correct even when a line is split across chunks (tokens). ANSI codes carry no
// newlines, so they're treated as ordinary characters and never mis-indented.
const indentAgentChunk = (text: string): string => {
let out = "";

for (const ch of text) {
if (agentAtLineStart && ch !== "\n") {
out += BLOCK_INDENT;
agentAtLineStart = false;
}

out += ch;

if (ch === "\n") {
agentAtLineStart = true;
}
}

return out;
};

// Route streamed agent output through the bar so it scrolls above the pinned
// input row; cleared on loop exit so later/headless writes go straight to stdout.
if (useInputRow) {
interactiveStream = (text): void => {
statusBar.writeStream(text);
if (!agentTurnOpen) {
agentTurnOpen = true;
agentAtLineStart = true;
statusBar.writeStream(
`\n${speakerLabel(statusInfo().model, false, true)}\n`
);
}

statusBar.writeStream(indentAgentChunk(text));
};
}

// Start a fresh agent block for each turn (the label re-emits on its first output).
const beginAgentTurn = (): void => {
agentTurnOpen = false;
};

// Mirror readline's buffer onto the input row after each keypress. setImmediate
// lets readline update rl.line/rl.cursor first (it processes the key async).
const syncInput = (): void => {
Expand Down Expand Up @@ -1626,7 +1673,10 @@ async function repl(args: ICliArgs): Promise<number> {
// never echoed to scrollback — record it ourselves so the transcript reads
// naturally above the (now-cleared) input row.
if (useInputRow) {
echo(`${STYLE.dim}›${RESET} ${line}\n`);
echo(
`\n${speakerLabel("you", true, true)}\n` +
`${STYLE.brand}${indentBlock(line)}${RESET}\n`
);
}

if (busy) {
Expand Down Expand Up @@ -1654,6 +1704,7 @@ async function repl(args: ICliArgs): Promise<number> {
// Handle one idle line (slash command or a message), then any queued follow-up.
const runLine = async (line: string): Promise<void> => {
busy = true;
beginAgentTurn(); // the agent's response opens a fresh "▌ <model>" block

try {
if (line.startsWith("/")) {
Expand Down Expand Up @@ -1720,6 +1771,9 @@ async function repl(args: ICliArgs): Promise<number> {
// argument. Cancel ⇒ back to a clean prompt. Only meaningful on a TTY.
const openPalette = async (): Promise<void> => {
paletteOpen = true;
// Suspend the editor's stdin ownership so the palette's keypress loop owns
// input (see openFilePicker). Resumed in finally.
editorHandle?.suspend();

try {
const picked = await pickCommand(process.stdout.isTTY);
Expand Down Expand Up @@ -1749,6 +1803,13 @@ async function repl(args: ICliArgs): Promise<number> {
} finally {
paletteOpen = false;

// Hand stdin back to the editor and repaint its input row (the overlay
// cleared it). No-op in readline mode (editorHandle is null).
if (editorHandle !== null) {
editorHandle.resume();
repaintEditor(editorHandle);
}

if (useInputRow) {
statusBar.update(statusInfo());

Expand All @@ -1767,6 +1828,10 @@ async function repl(args: ICliArgs): Promise<number> {
// the `@`; at send time `@path` expands to the file's contents (see runSend).
const openFilePicker = async (): Promise<void> => {
paletteOpen = true;
// In editor mode the editor owns stdin via a `data` listener; suspend it so
// the inline picker's own `keypress` loop isn't fighting the editor for every
// keystroke (both would otherwise consume the same input). Resumed in finally.
editorHandle?.suspend();

const base =
editorHandle !== null
Expand Down Expand Up @@ -1807,6 +1872,13 @@ async function repl(args: ICliArgs): Promise<number> {
} finally {
paletteOpen = false;

// Hand stdin back to the editor and repaint its input row (the overlay
// cleared it). No-op in readline mode (editorHandle is null).
if (editorHandle !== null) {
editorHandle.resume();
repaintEditor(editorHandle);
}

if (useInputRow) {
statusBar.update(statusInfo());

Expand Down Expand Up @@ -1862,6 +1934,34 @@ async function repl(args: ICliArgs): Promise<number> {
// called here from readline. Crucially: the editor owns stdin exclusively in
// editor mode, and readline is NOT created in that case.
if (useEditor) {
// Editor-native `@`-completion: preload the workspace file list once, then
// filter it synchronously as the user types. The dropdown is painted ABOVE
// the editor block (not the readline input row), so it can't fight the editor
// for the cursor — the cause of the earlier display corruption.
let completionFiles: readonly string[] = [];

void listWorkspaceFiles(args.dir).then((files) => {
completionFiles = files;
});

const editorCompletion = {
items: (query: string): readonly string[] =>
filterFiles(completionFiles, query),
render: (items: readonly string[], selected: number): void => {
statusBar.setEditorOverlay(
formatCompletionRows(
items,
selected,
process.stdout.columns,
process.stdout.isTTY
)
);
},
clear: (): void => {
statusBar.clearEditorOverlay();
},
};

editorHandle = startEditor({
stdin: {
on: (event: string, cb: (data: string) => void) => {
Expand Down Expand Up @@ -1897,6 +1997,7 @@ async function repl(args: ICliArgs): Promise<number> {
rows: process.stdout.rows,
openPalette,
openFilePicker,
completion: editorCompletion,
});

resizeEditor = (columns, rows): void => {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/editor/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,16 @@ export class EditorBuffer {
}
}

/** Wipe the whole buffer in one keystroke. Snapshots first, so Ctrl-Z restores
* what was typed — a clear is never a destructive dead-end. */
clear(): void {
this.clearSticky();
this.maybeSnapshot("clear");
this.lines = [""];
this.cursorLine = 0;
this.cursorCol = 0;
}

expand(): string {
return this.getText().replace(
/\[paste #(\d+) \+\d+ lines\]/g,
Expand Down
Loading