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
11import 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 */
17export 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
25export type CommandState = "prompt" | "editing" | "running" | "finished";
26
27export 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
44export 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
68const DEFAULT_LIMIT = 512;
69
70export 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
230function 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}