| 1 | // The shell-integration command model: marks in, a list of commands out. |
| 2 | // |
| 3 | // This is the layer that makes a terminal more than a screen — command |
| 4 | // boundaries, exit codes, cwd, and durations are exactly the "what did the |
| 5 | // agent run, and did it fail" question, and they are emulator-independent. |
| 6 | // |
| 7 | // Pure by construction: screen anchoring arrives through the `anchor` seam |
| 8 | // (the wasm layer hands back tracked grid refs; tests hand back counters), and |
| 9 | // time through `now`. Nothing here touches wasm or the DOM. |
| 10 | |
| 11 | import type { ShellMark } from "./marks.ts"; |
| 12 | |
| 13 | /** |
| 14 | * A position on the screen that survives scrolling, scrollback pruning and |
| 15 | * reflow. `point()` returns null once the anchored row is gone. |
| 16 | */ |
| 17 | export interface CommandAnchor { |
| 18 | /** Viewport coordinates for painting decorations; null when scrolled out. */ |
| 19 | point(): { x: number; y: number } | null; |
| 20 | /** Screen coordinates (scrollback included) — stable, for ordering and reads. */ |
| 21 | screenPoint(): { x: number; y: number } | null; |
| 22 | free(): void; |
| 23 | } |
| 24 | |
| 25 | export type CommandState = "prompt" | "editing" | "running" | "finished"; |
| 26 | |
| 27 | export interface ShellCommand { |
| 28 | readonly id: number; |
| 29 | /** From `633;E`, or read off the screen at execute time when the seam is wired. */ |
| 30 | commandLine: string | null; |
| 31 | cwd: string | null; |
| 32 | /** Null while running, and after a `D` that carried no status. */ |
| 33 | exitCode: number | null; |
| 34 | state: CommandState; |
| 35 | /** Milliseconds from the `now` seam; null until the phase is reached. */ |
| 36 | executedAt: number | null; |
| 37 | finishedAt: number | null; |
| 38 | promptStart: CommandAnchor | null; |
| 39 | commandStart: CommandAnchor | null; |
| 40 | outputStart: CommandAnchor | null; |
| 41 | outputEnd: CommandAnchor | null; |
| 42 | } |
| 43 | |
| 44 | export interface ShellCommandsOptions { |
| 45 | /** |
| 46 | * Anchor the current cursor position. Returning null (unanchorable point, |
| 47 | * alternate screen) degrades the command to a text-only record. |
| 48 | */ |
| 49 | anchor?: () => CommandAnchor | null; |
| 50 | now?: () => number; |
| 51 | /** |
| 52 | * Read the command line off the screen between two anchors, for shells that |
| 53 | * emit OSC 133 without VS Code's `633;E`. Called once, at execute time. |
| 54 | */ |
| 55 | readCommandLine?: (from: CommandAnchor, to: CommandAnchor) => string | null; |
| 56 | /** |
| 57 | * Retained command records. Anchors are freed on eviction — each tracked |
| 58 | * anchor costs bookkeeping on every terminal mutation, so this cap is a |
| 59 | * performance contract, not just memory hygiene. |
| 60 | */ |
| 61 | limit?: number; |
| 62 | onCommandStart?: (command: ShellCommand) => void; |
| 63 | onCommandEnd?: (command: ShellCommand) => void; |
| 64 | /** Any change to the list, including cwd/command-line fills. */ |
| 65 | onChange?: () => void; |
| 66 | } |
| 67 | |
| 68 | const DEFAULT_LIMIT = 512; |
| 69 | |
| 70 | export class ShellCommands { |
| 71 | private options: ShellCommandsOptions; |
| 72 | private list: ShellCommand[] = []; |
| 73 | private open: ShellCommand | null = null; |
| 74 | private nextId = 1; |
| 75 | private cwdNow: string | null = null; |
| 76 | |
| 77 | constructor(options: ShellCommandsOptions = {}) { |
| 78 | this.options = options; |
| 79 | } |
| 80 | |
| 81 | /** Commands oldest-first. The last entry may still be running. */ |
| 82 | get commands(): readonly ShellCommand[] { |
| 83 | return this.list; |
| 84 | } |
| 85 | |
| 86 | /** The command currently at the prompt or running, if any. */ |
| 87 | get current(): ShellCommand | null { |
| 88 | return this.open; |
| 89 | } |
| 90 | |
| 91 | /** Last known cwd, tracked independently of command boundaries. */ |
| 92 | get cwd(): string | null { |
| 93 | return this.cwdNow; |
| 94 | } |
| 95 | |
| 96 | /** True once any recognized mark has arrived — i.e. the shell is instrumented. */ |
| 97 | get active(): boolean { |
| 98 | return this.list.length > 0; |
| 99 | } |
| 100 | |
| 101 | handle(mark: ShellMark): void { |
| 102 | switch (mark.kind) { |
| 103 | case "prompt-start": { |
| 104 | // A new prompt implies the previous command ended, even if its `D` |
| 105 | // never arrived (SIGKILL, a shell that lost the status, a torn replay). |
| 106 | this.closeOpen(null); |
| 107 | const command = this.begin(); |
| 108 | command.promptStart = this.anchor(); |
| 109 | this.changed(); |
| 110 | break; |
| 111 | } |
| 112 | |
| 113 | case "command-start": { |
| 114 | const command = this.open ?? this.begin(); |
| 115 | command.commandStart = this.anchor(); |
| 116 | command.state = "editing"; |
| 117 | this.changed(); |
| 118 | break; |
| 119 | } |
| 120 | |
| 121 | case "command-executed": { |
| 122 | const command = this.open ?? this.begin(); |
| 123 | // A command joins the list when it actually runs. Before `C` it is |
| 124 | // just a prompt the user may never submit. |
| 125 | if (!this.list.includes(command)) { |
| 126 | this.list.push(command); |
| 127 | this.evict(); |
| 128 | } |
| 129 | command.outputStart = this.anchor(); |
| 130 | command.state = "running"; |
| 131 | command.executedAt = this.now(); |
| 132 | if (command.commandLine === null && command.commandStart && command.outputStart) { |
| 133 | command.commandLine = |
| 134 | this.options.readCommandLine?.(command.commandStart, command.outputStart) ?? null; |
| 135 | } |
| 136 | this.options.onCommandStart?.(command); |
| 137 | this.changed(); |
| 138 | break; |
| 139 | } |
| 140 | |
| 141 | case "command-end": |
| 142 | this.closeOpen(mark.exitCode); |
| 143 | this.changed(); |
| 144 | break; |
| 145 | |
| 146 | case "command-line": { |
| 147 | const command = this.open ?? this.begin(); |
| 148 | command.commandLine = mark.text; |
| 149 | this.changed(); |
| 150 | break; |
| 151 | } |
| 152 | |
| 153 | case "property": |
| 154 | if (mark.key === "Cwd") { |
| 155 | this.cwdNow = mark.value; |
| 156 | if (this.open) this.open.cwd = mark.value; |
| 157 | this.changed(); |
| 158 | } |
| 159 | break; |
| 160 | |
| 161 | case "set-mark": |
| 162 | break; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | /** Free every anchor. Call before freeing the terminal. */ |
| 167 | dispose(): void { |
| 168 | for (const command of this.list) freeAnchors(command); |
| 169 | this.list = []; |
| 170 | this.open = null; |
| 171 | } |
| 172 | |
| 173 | private begin(): ShellCommand { |
| 174 | const command: ShellCommand = { |
| 175 | id: this.nextId++, |
| 176 | commandLine: null, |
| 177 | cwd: this.cwdNow, |
| 178 | exitCode: null, |
| 179 | state: "prompt", |
| 180 | executedAt: null, |
| 181 | finishedAt: null, |
| 182 | promptStart: null, |
| 183 | commandStart: null, |
| 184 | outputStart: null, |
| 185 | outputEnd: null, |
| 186 | }; |
| 187 | this.open = command; |
| 188 | return command; |
| 189 | } |
| 190 | |
| 191 | private closeOpen(exitCode: number | null): void { |
| 192 | const command = this.open; |
| 193 | if (command === null) return; |
| 194 | this.open = null; |
| 195 | // A command that never reached `C` never ran (an empty prompt, ^C at the |
| 196 | // prompt): it was never listed, so just release its anchors. |
| 197 | if (command.state === "prompt" || command.state === "editing") { |
| 198 | freeAnchors(command); |
| 199 | return; |
| 200 | } |
| 201 | command.state = "finished"; |
| 202 | command.exitCode = exitCode; |
| 203 | command.finishedAt = this.now(); |
| 204 | command.outputEnd = this.anchor(); |
| 205 | this.options.onCommandEnd?.(command); |
| 206 | } |
| 207 | |
| 208 | private evict(): void { |
| 209 | const limit = this.options.limit ?? DEFAULT_LIMIT; |
| 210 | // Never evict the open command — it is still accumulating. |
| 211 | while (this.list.length > limit && this.list[0] !== this.open) { |
| 212 | const dropped = this.list.shift(); |
| 213 | if (dropped) freeAnchors(dropped); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | private anchor(): CommandAnchor | null { |
| 218 | return this.options.anchor?.() ?? null; |
| 219 | } |
| 220 | |
| 221 | private now(): number { |
| 222 | return this.options.now?.() ?? Date.now(); |
| 223 | } |
| 224 | |
| 225 | private changed(): void { |
| 226 | this.options.onChange?.(); |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | function freeAnchors(command: ShellCommand): void { |
| 231 | for (const anchor of [ |
| 232 | command.promptStart, |
| 233 | command.commandStart, |
| 234 | command.outputStart, |
| 235 | command.outputEnd, |
| 236 | ]) { |
| 237 | anchor?.free(); |
| 238 | } |
| 239 | command.promptStart = null; |
| 240 | command.commandStart = null; |
| 241 | command.outputStart = null; |
| 242 | command.outputEnd = null; |
| 243 | } |