@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 +1 -1
- package/server/api.ts +61 -5
- package/server/headless.ts +59 -0
- package/server/screens.ts +22 -2
- package/shared/wire.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsconfigdotjson/vibe-os",
|
|
3
|
-
"version": "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(
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
? await
|
|
725
|
-
:
|
|
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])),
|
package/server/headless.ts
CHANGED
|
@@ -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 =
|
|
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;
|