@tsconfigdotjson/vibe-os 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsconfigdotjson/vibe-os",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A coding desktop in the browser, on a box you own. Git worktrees per piece of work, terminals that survive a reload, and SSH that runs in the tab.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server/api.ts CHANGED
@@ -25,7 +25,10 @@ import type { Db } from "./db.ts";
25
25
  import { ID_PATTERN } from "./db.ts";
26
26
  import { discover, specs } from "./harness.ts";
27
27
  import {
28
+ KEY_NAMES,
29
+ keyBytes,
28
30
  paste,
31
+ press,
29
32
  submit as pressEnter,
30
33
  startSession,
31
34
  waitForInput,
@@ -134,6 +137,9 @@ async function refreshMcpMirrors(db: Db, stateDir: string): Promise<void> {
134
137
  /** Ceiling on text sent to a window, matching a profile prompt's. */
135
138
  const MAX_SEND_CHARS = 16_000;
136
139
 
140
+ /** Most keys one send can press. */
141
+ const MAX_KEYS = 32;
142
+
137
143
  /** Longest label a window can be tagged with. */
138
144
  const MAX_LABEL_CHARS = 200;
139
145
  /**
@@ -162,7 +168,9 @@ function textToSend(
162
168
  ): string {
163
169
  if (body.text !== undefined) {
164
170
  if (typeof body.text !== "string" || body.text === "")
165
- throw new Error("text must be a non-empty string");
171
+ throw new Error(
172
+ 'text must be a non-empty string. To press Enter alone, send keys: ["Enter"]',
173
+ );
166
174
  if (body.blanks !== undefined)
167
175
  throw new Error(
168
176
  "blanks fill the profile prompt; leave out text to use it",
@@ -192,6 +200,31 @@ function textToSend(
192
200
  return filled.text;
193
201
  }
194
202
 
203
+ /**
204
+ * The keys a send presses instead of pasting. Throws with a message for the
205
+ * caller when they are not keys, or come with something a key send ignores.
206
+ */
207
+ function keysToPress(body: SendInput): string[] {
208
+ const { keys } = body;
209
+ if (
210
+ !Array.isArray(keys) ||
211
+ keys.length === 0 ||
212
+ keys.length > MAX_KEYS ||
213
+ keys.some((k) => typeof k !== "string")
214
+ )
215
+ throw new Error(`keys must be a list of 1 to ${MAX_KEYS} key names`);
216
+ const unknown = keys.find((k) => keyBytes(k) === null);
217
+ if (unknown !== undefined)
218
+ throw new Error(
219
+ `unknown key ${JSON.stringify(unknown)}. A key is one character or one of ${KEY_NAMES.join(", ")}`,
220
+ );
221
+ if (body.text !== undefined || body.blanks !== undefined)
222
+ throw new Error("send text or keys, not both");
223
+ if (body.submit !== undefined)
224
+ throw new Error('submit is for text; put "Enter" in keys instead');
225
+ return keys;
226
+ }
227
+
195
228
  export interface ApiDeps {
196
229
  config: Config;
197
230
  ca: SshCa;
@@ -293,6 +326,25 @@ export function createApi(deps: ApiDeps) {
293
326
  return { started, delivered: true, state };
294
327
  }
295
328
 
329
+ /**
330
+ * Starts a window if need be, and presses keys in it once it reads them.
331
+ *
332
+ * No wait for quiet and no refusal on a question, unlike `deliver`: keys are
333
+ * how a question is answered, and an Escape is how a busy agent is stopped.
334
+ */
335
+ async function pressKeys(
336
+ target: AttachTarget,
337
+ profile: Profile | undefined,
338
+ keys: string[],
339
+ ): Promise<SendResult> {
340
+ const { session } = target;
341
+ const started = await start(target, profile);
342
+ await waitForInput(config, session);
343
+ const state = screens.get(session)?.activity() ?? "idle";
344
+ await press(config, session, keys);
345
+ return { started, delivered: true, state };
346
+ }
347
+
296
348
  return async function handleApi(
297
349
  req: Request,
298
350
  url: URL,
@@ -701,6 +753,7 @@ export function createApi(deps: ApiDeps) {
701
753
  : undefined;
702
754
 
703
755
  let text: string | undefined;
756
+ let keys: string[] | undefined;
704
757
  let submit = false;
705
758
  let force = false;
706
759
  if (startMatch[2] === "send") {
@@ -712,7 +765,8 @@ export function createApi(deps: ApiDeps) {
712
765
  submit = body.submit === true;
713
766
  force = body.force === true;
714
767
  try {
715
- text = textToSend(body, profile);
768
+ if (body.keys !== undefined) keys = keysToPress(body);
769
+ else text = textToSend(body, profile);
716
770
  } catch (err) {
717
771
  return json({ error: describeError(err) }, 400);
718
772
  }
@@ -720,9 +774,11 @@ export function createApi(deps: ApiDeps) {
720
774
 
721
775
  try {
722
776
  const result =
723
- text !== undefined
724
- ? await deliver(target, profile, text, { submit, force })
725
- : { started: await start(target, profile) };
777
+ keys !== undefined
778
+ ? await pressKeys(target, profile, keys)
779
+ : text !== undefined
780
+ ? await deliver(target, profile, text, { submit, force })
781
+ : { started: await start(target, profile) };
726
782
  return json({
727
783
  ...result,
728
784
  window: summarise(target, new Set([target.session])),
@@ -48,6 +48,8 @@ const SETTLE_MS = 1_000;
48
48
  const POLL_MS = 250;
49
49
  /** Between the paste and the Enter that submits it, so they are two events. */
50
50
  const SUBMIT_DELAY_MS = 300;
51
+ /** Between keys pressed in one send, so each arrives as its own read. */
52
+ const KEY_GAP_MS = 100;
51
53
 
52
54
  const PASTE_START = "\x1b[200~";
53
55
  const PASTE_END = "\x1b[201~";
@@ -356,3 +358,60 @@ export async function submit(config: Config, session: string): Promise<void> {
356
358
  await sleep(SUBMIT_DELAY_MS);
357
359
  await push(socketPath(config, session), "\r");
358
360
  }
361
+
362
+ /** Keys a send can press by name, as the bytes a terminal sends for them. */
363
+ const NAMED_KEYS: Record<string, string> = {
364
+ Enter: "\r",
365
+ Escape: "\x1b",
366
+ Tab: "\t",
367
+ BackTab: "\x1b[Z",
368
+ Backspace: "\x7f",
369
+ Delete: "\x1b[3~",
370
+ Space: " ",
371
+ Up: "\x1b[A",
372
+ Down: "\x1b[B",
373
+ Right: "\x1b[C",
374
+ Left: "\x1b[D",
375
+ Home: "\x1b[H",
376
+ End: "\x1b[F",
377
+ PageUp: "\x1b[5~",
378
+ PageDown: "\x1b[6~",
379
+ };
380
+
381
+ /** Every name `keyBytes` takes, for the caller's error message. */
382
+ export const KEY_NAMES = [...Object.keys(NAMED_KEYS), "Ctrl+<letter>"];
383
+
384
+ /**
385
+ * The bytes for one key: a name from `NAMED_KEYS`, `Ctrl+` and a letter, or a
386
+ * single character, which is typed as it is. Null for anything else.
387
+ */
388
+ export function keyBytes(key: string): string | null {
389
+ if (Object.hasOwn(NAMED_KEYS, key)) return NAMED_KEYS[key];
390
+ const ctrl = /^Ctrl\+([A-Za-z])$/.exec(key);
391
+ if (ctrl)
392
+ return String.fromCharCode(ctrl[1].toUpperCase().charCodeAt(0) - 64);
393
+ // One character, counted by code point so an emoji is one too.
394
+ if ([...key].length === 1 && key >= " " && key !== "\x7f") return key;
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * Presses keys in a session, one at a time.
400
+ *
401
+ * Each is its own write, a moment after the last, so an Escape followed by a
402
+ * letter is read as two keys rather than as Alt and the letter.
403
+ */
404
+ export async function press(
405
+ config: Config,
406
+ session: string,
407
+ keys: string[],
408
+ ): Promise<void> {
409
+ const sock = socketPath(config, session);
410
+ for (const [i, key] of keys.entries()) {
411
+ const bytes = keyBytes(key);
412
+ if (bytes === null) throw new Error(`unknown key ${key}`);
413
+ if (i > 0) await sleep(KEY_GAP_MS);
414
+ await push(sock, bytes);
415
+ }
416
+ log.info(`pressed ${keys.join(" ")} in ${session}`);
417
+ }
package/server/screens.ts CHANGED
@@ -18,7 +18,7 @@ import { readdir } from "node:fs/promises";
18
18
  import net from "node:net";
19
19
  import os from "node:os";
20
20
  import { promisify } from "node:util";
21
- import { Terminal } from "@xterm/headless";
21
+ import { type IBufferLine, Terminal } from "@xterm/headless";
22
22
  import type { Activity } from "../shared/wire.ts";
23
23
  import type { Config } from "./config.ts";
24
24
  import { sessionTty } from "./headless.ts";
@@ -129,15 +129,23 @@ async function ttySize(
129
129
  * long line reads as it was written rather than as the width it was drawn at.
130
130
  * Blank lines at the bottom, which are only the unused part of the screen, are
131
131
  * dropped.
132
+ *
133
+ * Faint text on the cursor's line is left out. That is where a harness draws a
134
+ * placeholder or a suggested prompt, greyed out with SGR 2 (Claude's `Try
135
+ * "…"`, Codex's hint), and as plain text it reads the same as something typed
136
+ * and not yet sent. The cursor is the input caret in both, so its line is the
137
+ * input line.
132
138
  */
133
139
  export function bufferText(term: Terminal, count: number): string {
134
140
  const buf = term.buffer.active;
141
+ const cursorRow = buf.baseY + buf.cursorY;
135
142
  const lines: string[] = [];
136
143
  for (let i = 0; i < buf.length; i++) {
137
144
  const line = buf.getLine(i);
138
145
  if (!line) continue;
139
146
  // Untrimmed until joined, or a space at the wrap point would be lost.
140
- const text = line.translateToString(true);
147
+ const text =
148
+ i === cursorRow ? withoutFaint(line) : line.translateToString(true);
141
149
  if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text;
142
150
  else lines.push(text);
143
151
  }
@@ -147,6 +155,18 @@ export function bufferText(term: Terminal, count: number): string {
147
155
  return trimmed.slice(-count).join("\n");
148
156
  }
149
157
 
158
+ /** A line's text with every faint cell read as a blank. */
159
+ function withoutFaint(line: IBufferLine): string {
160
+ let text = "";
161
+ for (let x = 0; x < line.length; x++) {
162
+ const cell = line.getCell(x);
163
+ // The second half of a wide character has width 0 and no text of its own.
164
+ if (!cell || cell.getWidth() === 0) continue;
165
+ text += cell.isDim() ? " ".repeat(cell.getWidth()) : cell.getChars() || " ";
166
+ }
167
+ return text.trimEnd();
168
+ }
169
+
150
170
  /** One session's screen, kept up to date by its connection. */
151
171
  export class Screen {
152
172
  readonly term = new Terminal({
package/shared/wire.ts CHANGED
@@ -222,13 +222,15 @@ export interface SendInput {
222
222
  submit?: boolean;
223
223
  /** Paste even when the window is stopped on a question. */
224
224
  force?: boolean;
225
+ /** Instead of text: keys to press, like `["Enter"]` or `["Escape"]`. */
226
+ keys?: string[];
225
227
  }
226
228
 
227
229
  /** What a send did. */
228
230
  export interface SendResult {
229
231
  /** The session was started for this send. */
230
232
  started: boolean;
231
- /** The text went in and the window drew it. */
233
+ /** The text went in and the window drew it, or the keys were pressed. */
232
234
  delivered: boolean;
233
235
  /** What the window was doing when the text went in, or why it did not. */
234
236
  state: Activity;