Public: anyone can read this; the crew can change it.

The terminal, under test: src/tty.ts split so every screen is a pure function of state #57

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/41-the-terminal-under-test-src-tty-ts-split → main 1 commit fork

  • Merged. mason/41-the-terminal-under-test-src-tty-ts-split was merged into main as dd58653.

Review

Sign in to review this change.
4 files changed +484 −105
AGENTS.md +5 −1
@@ -82,7 +82,11 @@ To add a concept:
8282 - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence,
8383 a JSON schema, the action.
8484 - **A terminal command** — a key in `src/tty.ts` that calls the action and
85 reports on the status line.
85 reports on the status line. The screen there is a pure function of state
86 (`screen(state)`: the loop's state, the actions' data and the forge's
87 environment in, the lines of a frame out) and has a test in
88 `src/tty.test.ts`; only the loop touches the database, the keyboard and
89 `process.env`.
8690 - **Something live** — `emit(type, data)` in the action, and
8791 `data-live="<types>"` on the region of the page that shows it.
8892
README.md +6 −1
@@ -53,7 +53,7 @@ renders the same functions from real state.
5353 | `src/page.ts` | the lobby's body, poured into the layout |
5454 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
5555 | `src/server.ts` | boot: open the database, serve |
56 | `src/tty.ts` | the terminal, over the same actions |
56 | `src/tty.ts` | the terminal: a pure screen, and the loop that prints it |
5757 | `src/env.ts` | what the forge told this process |
5858 | `static/app.css`, `static/live.js` | the look, and the list that stays current |
5959 | `static/when.js` | the words for a time, run by the server and the browser both |
@@ -163,6 +163,11 @@ runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
163163 the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
164164 a real pty over the same data, as the account at the keyboard: j/k move
165165 between tables, o opens one, s sits down, x gives the seat up, q quits.
166 That file is two halves: `screen(state)` draws a frame as a pure function —
167 state in, lines out, no database and no environment, so every screen it can
168 show has a test in `src/tty.test.ts` — and the loop around it reads keys,
169 calls the actions and prints what comes back. When `FORGE_APP_SSH` is unset
170 the header says the terminal is off rather than inventing a command.
166171
167172 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
168173 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
src/tty.test.ts added +229
@@ -0,0 +1,229 @@
1 /**
2 * The terminal, screen by screen. Every test here is `screen(state)` — plain
3 * data in, the lines of one frame out — so the loop, the database and the
4 * environment are nowhere near it, and a screen is asserted as the words a
5 * person reads rather than as a soup of escape sequences (`plain`).
6 */
7
8 import { expect, test } from "bun:test";
9 import type { Seat, Table } from "./actions.ts";
10 import { frame, keys, plain, screen, type State } from "./tty.ts";
11 import type { Who } from "./who.ts";
12
13 const anon: Who = { role: "anon" };
14 const alice: Who = { role: "user", id: "u1", name: "alice" };
15 const bob: Who = { role: "user", id: "u2", name: "bob" };
16 const root: Who = { role: "admin", id: "u0", name: "root" };
17
18 const NOW = Date.parse("2026-09-14T12:00:00.000Z");
19 const OPENED = "2026-09-14T11:40:00.000Z";
20 const SSH = "ssh -p 2222 parley@localhost";
21
22 const seat = (ordinal: number, who: Who, charge: unknown = null): Seat => ({
23 id: 100 + ordinal,
24 ordinal,
25 player_id: who.role === "anon" ? "" : who.id,
26 player_name: who.role === "anon" ? "" : who.name,
27 joined_at: OPENED,
28 score: null,
29 charge,
30 });
31
32 /** A table as `listTables` hands one to a door: already redacted for its viewer. */
33 const table = (over: Partial<Table> = {}): Table => {
34 const seats = over.seats ?? [seat(0, alice)];
35 return {
36 id: 1,
37 title: "The Wreck",
38 status: "open",
39 host_id: "u1",
40 host_name: "alice",
41 seats_max: 4,
42 quorum: 3,
43 turn_seconds: 21_600,
44 deadline_seconds: 86_400,
45 created_at: OPENED,
46 started_at: null,
47 settled_at: null,
48 seated: seats.length,
49 your_seat: null,
50 ...over,
51 seats,
52 };
53 };
54
55 const state = (over: Partial<State> = {}): State => ({
56 who: anon,
57 tables: [],
58 cursor: 0,
59 title: "parley",
60 where: "local",
61 ssh: SSH,
62 now: NOW,
63 ...over,
64 });
65
66 /** The screen as it reads: the same lines, with the styling taken off. */
67 const shown = (over: Partial<State> = {}): string[] =>
68 screen(state(over)).map(plain);
69
70 /** The last line: the legend, what an action said, or what is being typed. */
71 const lastLine = (over: Partial<State> = {}): string =>
72 shown(over).at(-1) ?? "";
73
74 test("the empty lobby: a player is told the key, a guest nothing to press", () => {
75 expect(shown({ who: alice })).toContain(
76 "No tables yet. Press o to open one.",
77 );
78 expect(lastLine({ who: alice })).toBe("j/k move · o open · q quit");
79
80 expect(shown({ who: anon })).toContain("No tables yet.");
81 expect(lastLine({ who: anon })).toBe(
82 "j/k move · q quit · sign in with a key the forge knows to play",
83 );
84 });
85
86 test("one open table: the row says what it is, and the seats are under it", () => {
87 const lines = shown({ who: bob, tables: [table()] });
88 expect(lines).toContain(
89 "▸ The Wreck open · 1/4 seated · host alice · opened 20m ago",
90 );
91 expect(lines).toContain(" 1. alice");
92 expect(lastLine({ who: bob, tables: [table()] })).toBe(
93 "j/k move · o open · s sit · q quit",
94 );
95 });
96
97 test("a table you are seated at says so, and offers x instead of s", () => {
98 const mine = table({ seats: [seat(0, alice), seat(1, bob)], your_seat: 101 });
99 const lines = shown({ who: bob, tables: [mine] });
100 expect(lines).toContain(
101 "▸ The Wreck open · 2/4 seated · host alice · opened 20m ago · you are seated",
102 );
103 expect(lines).toContain(" 1. alice 2. bob");
104 expect(lastLine({ who: bob, tables: [mine] })).toBe(
105 "j/k move · o open · x leave · q quit",
106 );
107 });
108
109 test("a full table offers neither: the legend is the actions' own rules", () => {
110 const full = table({
111 seats: [seat(0, alice), seat(1, bob), seat(2, root), seat(3, alice)],
112 seats_max: 4,
113 });
114 expect(lastLine({ who: bob, tables: [full] })).toBe(
115 "j/k move · o open · q quit",
116 );
117 });
118
119 test("a guest is offered no key that writes", () => {
120 const open = table();
121 expect(keys(anon, open).some((key) => key.writes)).toBe(false);
122 // The same table, to a player, does offer one — so the assertion above is
123 // about the guest and not about the fixture.
124 expect(keys(bob, open).some((key) => key.writes)).toBe(true);
125
126 const legend = lastLine({ who: anon, tables: [open] });
127 for (const key of ["o open", "s sit", "x leave"])
128 expect(legend).not.toContain(key);
129 });
130
131 test("your own charge is shown at your seat, and nobody else's is there to show", () => {
132 const dealt = table({
133 seats: [seat(0, alice, { want: "the anchor" }), seat(1, bob)],
134 your_seat: 100,
135 });
136 const lines = shown({ who: alice, tables: [dealt] });
137 expect(lines).toContain(
138 ' 1. alice (your charge: {"want":"the anchor"}) 2. bob',
139 );
140 });
141
142 test("a table with nobody at it says so", () => {
143 const empty = table({ seats: [], seated: 0 });
144 expect(shown({ who: bob, tables: [empty] })).toContain(" nobody seated yet");
145 });
146
147 test("the header names the app, the environment and who is at the keyboard", () => {
148 expect(shown({ who: anon })[0]).toBe("parley local guest");
149 expect(shown({ who: alice })[0]).toBe("parley local alice");
150 expect(shown({ who: root })[0]).toBe("parley local root (admin)");
151 });
152
153 test("the header prints FORGE_APP_SSH verbatim, and says so when there is none", () => {
154 expect(shown({ ssh: SSH })[1]).toBe(`connect: ${SSH}`);
155 expect(shown({ ssh: undefined })[1]).toBe(
156 "the terminal is off: FORGE_APP_SSH is not set",
157 );
158 expect(shown({ ssh: " " })[1]).toBe(
159 "the terminal is off: FORGE_APP_SSH is not set",
160 );
161 });
162
163 test("the environment reaches the screen only through its state", () => {
164 process.env.FORGE_APP_SSH = "ssh -p 2222 somewhere@else";
165 try {
166 // Unset in the state is unset on the screen, whatever the process holds.
167 expect(shown({ ssh: undefined })[1]).toBe(
168 "the terminal is off: FORGE_APP_SSH is not set",
169 );
170 expect(shown({ ssh: SSH })[1]).toBe(`connect: ${SSH}`);
171 } finally {
172 delete process.env.FORGE_APP_SSH;
173 }
174 });
175
176 test("a refusal is the status line, in the action's own words", () => {
177 const said = "This table is already playing.";
178 expect(lastLine({ who: bob, tables: [table()], said })).toBe(said);
179 // And the legend is what comes back once it is cleared.
180 expect(lastLine({ who: bob, tables: [table()], said: "" })).toBe(
181 "j/k move · o open · s sit · q quit",
182 );
183 });
184
185 test("a table being named is the status line, with a block for a cursor", () => {
186 const typing = lastLine({ who: alice, typing: "The Wr" });
187 expect(typing).toBe("Table name: The Wr ");
188 expect(lastLine({ who: alice, typing: "" })).toBe("Table name: ");
189 });
190
191 test("the cursor is clamped to the rows there are", () => {
192 const two = [table(), table({ id: 2, title: "The Ledger" })];
193 const far = shown({ who: bob, tables: two, cursor: 9 });
194 expect(far.some((line) => line.startsWith("▸ The Ledger"))).toBe(true);
195 const before = shown({ who: bob, tables: two, cursor: -3 });
196 expect(before.some((line) => line.startsWith("▸ The Wreck"))).toBe(true);
197 });
198
199 test("a control character in a title, a name or the command never reaches the terminal", () => {
200 const nasty = table({
201 title: "The\x1b[2JWreck",
202 host_name: "al\x07ice",
203 seats: [seat(0, { role: "user", id: "u1", name: "al\rice" })],
204 });
205 const lines = screen(
206 state({ who: bob, tables: [nasty], ssh: "ssh\x1b[2J -p 2222" }),
207 );
208 expect(lines.map(plain)).toContain(
209 "▸ The[2JWreck open · 1/4 seated · host alice · opened 20m ago",
210 );
211 expect(lines.map(plain)).toContain(" 1. alice");
212 expect(lines.map(plain)[1]).toBe("connect: ssh[2J -p 2222");
213 // Nothing drawn from data carries an escape of its own: strip this file's
214 // own sequences and no escape byte is left anywhere on the screen.
215 expect(lines.map(plain).join("\n")).not.toContain("\x1b");
216 });
217
218 test("a frame is the screen cleared, one line to a line", () => {
219 const lines = screen(state({ who: bob, tables: [table()] }));
220 const drawn = frame(lines);
221 expect(drawn.startsWith("\x1b[2J\x1b[H")).toBe(true);
222 expect(drawn.split("\r\n").length).toBe(lines.length);
223 expect(plain(drawn).split("\r\n").at(0)).toBe("parley local bob");
224 });
225
226 test("the same state draws the same screen, every time", () => {
227 const same = state({ who: bob, tables: [table()] });
228 expect(screen(same)).toEqual(screen(same));
229 });
src/tty.ts +244 −103
@@ -5,6 +5,16 @@
55 * one, s sits down, x gives the seat up, q leaves. Plain ANSI, no
66 * dependencies. What a person can do here is what they can do on the page —
77 * the actions say so, not this file.
8 *
9 * Two halves, along the line that was always here:
10 *
11 * - `screen(state)` is the terminal drawn. A pure function: the loop's
12 * state, the actions' data and the forge's environment in, the lines of
13 * one frame out. No database, no `process.stdout`, no `process.env`, no
14 * clock — so every screen has a test.
15 * - `run()` is the loop. It opens the database, asks `whoAtTty()` who is
16 * there, reads keys, calls actions and prints what the builder returns.
17 * It decides no rule: a refusal is printed in the action's own words.
818 */
919
1020 import {
@@ -14,55 +24,122 @@ import {
1424 leaveTable,
1525 listTables,
1626 openTable,
17 tableState,
18 TITLE_MAX,
27 type Seat,
1928 type Table,
29 TITLE_MAX,
2030 } from "./actions.ts";
2131 import { openDb } from "./db.ts";
2232 import { ssh, title, where } from "./env.ts";
2333 import { relative } from "./when.ts";
24 import { whoAtTty } from "./who.ts";
34 import { type Who, whoAtTty } from "./who.ts";
2535
26 const db = openDb();
27 const me = whoAtTty();
28 const esc = "\x1b[";
29 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
30 const bold = (s: string): string => `${esc}1m${s}${esc}0m`;
31 const out = (s: string): void => {
32 process.stdout.write(s);
33 };
36 // --------------------------------------------------------------- the ANSI
37 // Every escape sequence this file knows, named once, so a screen is read as
38 // text and a test asserts words rather than a soup of `\x1b[`.
3439
35 let cursor = 0;
36 let typing: string | undefined;
37 let said = "";
40 /** Control Sequence Introducer: the two bytes every sequence below starts with. */
41 const CSI = "\x1b[";
3842
39 const tables = (): Table[] => {
40 const listed = listTables(db, me);
41 return listed.ok ? listed.value : [];
42 };
43 const dim = (s: string): string => `${CSI}2m${s}${CSI}0m`;
44 const bold = (s: string): string => `${CSI}1m${s}${CSI}0m`;
45 /** Reverse video — the block that stands in for a cursor while you type. */
46 const reverse = (s: string): string => `${CSI}7m${s}${CSI}0m`;
47 /** Wipe the screen and put the cursor home: the start of every frame. */
48 const CLEAR = `${CSI}2J${CSI}H`;
49 /** A terminal ends a line with both, because the pty is in raw mode. */
50 const CRLF = "\r\n";
4351
44 function draw(): void {
45 const rows = tables();
46 cursor = Math.min(cursor, Math.max(0, rows.length - 1));
47 const lines = [
48 `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`,
49 ssh === undefined ? "" : dim(`connect: ${ssh}`),
50 "",
52 /** The keys the loop knows by name, rather than by their bytes. */
53 const KEY = {
54 enter: "\r",
55 escape: "\x1b",
56 backspace: "\x7f",
57 interrupt: "\x03",
58 up: `${CSI}A`,
59 down: `${CSI}B`,
60 } as const;
61
62 /** A screen with its styling taken off: what it says, which is what a test reads. */
63 export const plain = (s: string): string =>
64 s.replaceAll(/\x1b\[[0-9;]*[A-Za-z]/g, "");
65
66 /**
67 * A value on its way onto the screen: one line of it, control characters
68 * removed. A title or a name out of the database never moves the cursor,
69 * whoever wrote it and whenever it was written.
70 */
71 const text = (value: string): string =>
72 value.replaceAll(/[\u0000-\u001f\u007f]/g, "");
73
74 // ------------------------------------------------------------- the screen
75
76 /** Everything one frame is drawn from. Plain data: no database, no environment. */
77 export interface State {
78 /** Who is at the keyboard, as `whoAtTty()` read them. */
79 readonly who: Who;
80 /** The lobby, as `listTables` returned it — already redacted for `who`. */
81 readonly tables: readonly Table[];
82 /** Which row is selected. Out of range is clamped, never thrown. */
83 readonly cursor: number;
84 /** The table name being typed, or `undefined` when nobody is typing one. */
85 readonly typing?: string | undefined;
86 /** The status line: an action's refusal, in the action's own words. */
87 readonly said?: string;
88 /** The app's name, `FORGE_APP_ENV` in words, and `FORGE_APP_SSH` verbatim. */
89 readonly title: string;
90 readonly where: string;
91 readonly ssh?: string | undefined;
92 /** Now, for "opened 20m ago". Handed in, so a screen is the same every time. */
93 readonly now: number;
94 }
95
96 /** A key the legend offers. `writes` is true when pressing it changes something. */
97 export interface Key {
98 readonly key: string;
99 readonly what: string;
100 readonly writes: boolean;
101 }
102
103 const MOVE: Key = { key: "j/k", what: "move", writes: false };
104 const OPEN: Key = { key: "o", what: "open", writes: true };
105 const SIT: Key = { key: "s", what: "sit", writes: true };
106 const LEAVE: Key = { key: "x", what: "leave", writes: true };
107 const QUIT: Key = { key: "q", what: "quit", writes: false };
108
109 /**
110 * What this viewer may press, at this table. The writing keys are exactly
111 * the actions' own `canJoin` / `canLeave`, so the legend and the refusal
112 * can never disagree — and a guest is offered nothing that writes.
113 */
114 export function keys(who: Who, table: Table | undefined): readonly Key[] {
115 if (who.role === "anon") return [MOVE, QUIT];
116 return [
117 MOVE,
118 OPEN,
119 ...(table !== undefined && canJoin(who, table) ? [SIT] : []),
120 ...(table !== undefined && canLeave(who, table) ? [LEAVE] : []),
121 QUIT,
51122 ];
123 }
124
125 /** The lines of one frame. */
126 export function screen(state: State): string[] {
127 const rows = state.tables;
128 const last = Math.max(0, rows.length - 1);
129 const cursor = Math.min(Math.max(state.cursor, 0), last);
130 const lines = [header(state), connection(state.ssh), ""];
52131 if (rows.length === 0)
53132 lines.push(
54133 dim(
55 me.role === "anon"
134 state.who.role === "anon"
56135 ? "No tables yet."
57136 : "No tables yet. Press o to open one.",
58137 ),
59138 );
60139 rows.forEach((table, i) => {
61140 const here = i === cursor;
62 const name = here ? bold(table.title) : table.title;
63 lines.push(
64 `${here ? "▸" : " "} ${name} ${dim(`${table.status} · ${table.seated}/${table.seats_max} seated · host ${table.host_name} · opened ${relative(table.created_at, Date.now())}${table.your_seat === null ? "" : " · you are seated"}`)}`,
65 );
141 const name = here ? bold(text(table.title)) : text(table.title);
142 lines.push(`${here ? "▸" : " "} ${name} ${dim(about(table, state.now))}`);
66143 });
67144 const here = rows[cursor];
68145 if (here !== undefined) {
@@ -70,85 +147,149 @@ function draw(): void {
70147 lines.push(dim(` ${seatsOf(here)}`));
71148 }
72149 lines.push("");
73 if (typing !== undefined)
74 lines.push(`Table name: ${typing}${esc}7m ${esc}0m`);
75 else if (said !== "") lines.push(said);
76 else lines.push(dim(keys(here)));
77 out(`${esc}2J${esc}H${lines.join("\r\n")}`);
150 lines.push(status(state, here));
151 return lines;
78152 }
79153
154 /** One frame as the bytes a terminal is sent: cleared, and CR LF between lines. */
155 export const frame = (lines: readonly string[]): string =>
156 `${CLEAR}${lines.join(CRLF)}`;
157
158 const header = (state: State): string =>
159 `${bold(text(state.title))} ${dim(text(state.where))} ${dim(whose(state.who))}`;
160
161 const whose = (who: Who): string =>
162 who.role === "anon"
163 ? "guest"
164 : `${text(who.name)}${who.role === "admin" ? " (admin)" : ""}`;
165
166 /**
167 * How to reach this terminal: the forge's own command, printed exactly as
168 * it was given and never a guess at one. Unset, the door is not open.
169 */
170 const connection = (ssh: string | undefined): string =>
171 ssh === undefined || ssh.trim() === ""
172 ? dim("the terminal is off: FORGE_APP_SSH is not set")
173 : dim(`connect: ${text(ssh)}`);
174
175 const about = (table: Table, now: number): string =>
176 `${table.status} · ${table.seated}/${table.seats_max} seated · host ${text(table.host_name)} · opened ${relative(table.created_at, now)}${table.your_seat === null ? "" : " · you are seated"}`;
177
80178 /** The selected table's seats, and your own charge when there is one to see. */
81179 function seatsOf(table: Table): string {
82 const state = tableState(db, me, { id: table.id });
83 if (!state.ok) return state.message;
84 const seats = state.value.seats;
85 if (seats.length === 0) return "nobody seated yet";
86 return seats
87 .map(
88 (seat) =>
89 `${seat.ordinal + 1}. ${seat.player_name}${seat.charge === null ? "" : ` (your charge: ${JSON.stringify(seat.charge)})`}`,
90 )
91 .join(" ");
180 if (table.seats.length === 0) return "nobody seated yet";
181 return table.seats.map(seatLine).join(" ");
92182 }
93183
94 const keys = (table: Table | undefined): string => {
95 if (me.role === "anon")
96 return "j/k move · q quit · sign in with a key the forge knows to play";
97 const can = [
98 "j/k move",
99 "o open",
100 ...(table !== undefined && canJoin(me, table) ? ["s sit"] : []),
101 ...(table !== undefined && canLeave(me, table) ? ["x leave"] : []),
102 "q quit",
103 ];
104 return can.join(" · ");
184 const seatLine = (seat: Seat): string =>
185 `${seat.ordinal + 1}. ${text(seat.player_name)}${seat.charge === null ? "" : ` (your charge: ${JSON.stringify(seat.charge)})`}`;
186
187 /** The last line: what you are typing, what an action said, or what you may press. */
188 function status(state: State, here: Table | undefined): string {
189 if (state.typing !== undefined)
190 return `Table name: ${text(state.typing)}${reverse(" ")}`;
191 if (state.said !== undefined && state.said !== "") return state.said;
192 return dim(legend(state.who, here));
193 }
194
195 /** The key legend, as one line. */
196 const legend = (who: Who, table: Table | undefined): string => {
197 const offered = keys(who, table)
198 .map((key) => `${key.key} ${key.what}`)
199 .join(" · ");
200 return who.role === "anon"
201 ? `${offered} · sign in with a key the forge knows to play`
202 : offered;
105203 };
106204
107 process.stdin.setRawMode?.(true);
108 process.stdin.resume();
109 process.stdin.setEncoding("utf8");
110 draw();
111 process.stdin.on("data", (key: string) => {
112 said = "";
113 if (typing !== undefined) {
114 if (key === "\r") {
115 const opened = openTable(db, me, { title: typing });
116 if (!opened.ok) said = opened.message;
117 typing = undefined;
118 cursor = 0;
119 } else if (key === "\x1b") typing = undefined;
120 else if (key === "\x7f") typing = typing.slice(0, -1);
121 else if (key >= " " && typing.length < TITLE_MAX) typing += key;
122 draw();
123 return;
124 }
125 if (key === "q" || key === "\x03") {
126 out(`${esc}2J${esc}H`);
127 process.exit(0);
128 }
129 const rows = tables();
130 if (key === "j" || key === "\x1b[B")
131 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
132 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
133 if (key === "o") {
134 if (me.role === "anon") said = "Sign in to open a table.";
135 else typing = "";
136 }
137 const here = rows[cursor];
138 if (key === "s") {
139 if (here === undefined) said = "No table to sit at.";
140 else {
141 const joined = joinTable(db, me, { id: here.id });
142 if (!joined.ok) said = joined.message;
143 }
144 }
145 if (key === "x") {
146 if (here === undefined) said = "No table to leave.";
147 else {
148 const left = leaveTable(db, me, { id: here.id });
149 if (!left.ok) said = left.message;
150 }
151 }
205 // --------------------------------------------------------------- the loop
206
207 /** The terminal itself: the database, the keyboard, the environment, stdout. */
208 export function run(): void {
209 const db = openDb();
210 const me = whoAtTty();
211 let cursor = 0;
212 let typing: string | undefined;
213 let said = "";
214
215 const tables = (): Table[] => {
216 const listed = listTables(db, me);
217 return listed.ok ? listed.value : [];
218 };
219
220 const draw = (): void => {
221 const rows = tables();
222 cursor = Math.min(cursor, Math.max(0, rows.length - 1));
223 process.stdout.write(
224 frame(
225 screen({
226 who: me,
227 tables: rows,
228 cursor,
229 typing,
230 said,
231 title,
232 where,
233 ssh,
234 now: Date.now(),
235 }),
236 ),
237 );
238 };
239
240 process.stdin.setRawMode?.(true);
241 process.stdin.resume();
242 process.stdin.setEncoding("utf8");
152243 draw();
153 });
154 process.stdout.on("resize", draw);
244 process.stdin.on("data", (key: string) => {
245 said = "";
246 if (typing !== undefined) {
247 if (key === KEY.enter) {
248 const opened = openTable(db, me, { title: typing });
249 if (!opened.ok) said = opened.message;
250 typing = undefined;
251 cursor = 0;
252 } else if (key === KEY.escape) typing = undefined;
253 else if (key === KEY.backspace) typing = typing.slice(0, -1);
254 else if (key >= " " && typing.length < TITLE_MAX) typing += key;
255 draw();
256 return;
257 }
258 if (key === "q" || key === KEY.interrupt) {
259 process.stdout.write(CLEAR);
260 process.exit(0);
261 }
262 const rows = tables();
263 if (key === "j" || key === KEY.down)
264 cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1));
265 if (key === "k" || key === KEY.up) cursor = Math.max(cursor - 1, 0);
266 if (key === "o") {
267 // Whether a guest may open one is the action's to say, and so are the
268 // words it says it in: ask it with nothing, and print what comes back.
269 if (me.role !== "anon") typing = "";
270 else {
271 const refused = openTable(db, me, { title: "" });
272 if (!refused.ok) said = refused.message;
273 }
274 }
275 const here = rows[cursor];
276 if (key === "s") {
277 if (here === undefined) said = "No table to sit at.";
278 else {
279 const joined = joinTable(db, me, { id: here.id });
280 if (!joined.ok) said = joined.message;
281 }
282 }
283 if (key === "x") {
284 if (here === undefined) said = "No table to leave.";
285 else {
286 const left = leaveTable(db, me, { id: here.id });
287 if (!left.ok) said = left.message;
288 }
289 }
290 draw();
291 });
292 process.stdout.on("resize", draw);
293 }
294
295 if (import.meta.main) run();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs