keystream-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +457 -0
- package/keystream.js +881 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lihao Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# Keystream
|
|
2
|
+
|
|
3
|
+
Keystream is a real-time terminal chat app built with Bun. It is designed to do two jobs at once:
|
|
4
|
+
|
|
5
|
+
- let multiple users chat live from the CLI
|
|
6
|
+
- turn normal chat usage into Vim-style keyboard practice
|
|
7
|
+
|
|
8
|
+
The app has a standalone Bun WebSocket server, a full-screen terminal client, persistent SQLite history, room-based chat, and a modal UI with `NORMAL`, `INSERT`, `COMMAND`, and `SEARCH` modes.
|
|
9
|
+
|
|
10
|
+
## Key Features
|
|
11
|
+
|
|
12
|
+
- Real-time multi-user chat over WebSockets
|
|
13
|
+
- Persistent message history stored in SQLite
|
|
14
|
+
- Room-based messaging with live room switching
|
|
15
|
+
- Full-screen terminal UI built for keyboard-first use
|
|
16
|
+
- Vim-style modal interaction
|
|
17
|
+
- Motion counts like `5j`
|
|
18
|
+
- Feed jumps with `gg` and `G`
|
|
19
|
+
- Search mode with `/`
|
|
20
|
+
- Command mode with `:`
|
|
21
|
+
- Strict Vim mode that blocks arrow keys and tracks violations
|
|
22
|
+
- Lightweight training stats for mode time, streaks, and arrow-key usage
|
|
23
|
+
- Auto-reconnect behavior in the CLI client
|
|
24
|
+
- Local multi-client practice launcher with `tmux`
|
|
25
|
+
|
|
26
|
+
## Current Scope
|
|
27
|
+
|
|
28
|
+
This version is intentionally narrow:
|
|
29
|
+
|
|
30
|
+
- authentication is username-only
|
|
31
|
+
- chat is room-first
|
|
32
|
+
- direct messages are not implemented
|
|
33
|
+
- file upload, reactions, and message editing are not implemented
|
|
34
|
+
- this is best suited for trusted users or local/LAN usage unless you add TLS and stronger access control around it
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- [Bun](https://bun.sh/) 1.3+
|
|
39
|
+
- macOS, Linux, or another environment where Bun and terminal WebSocket clients work
|
|
40
|
+
- `tmux` only if you want to use `bun run dev:stack`
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bun install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### Option 1: Run the full local practice stack
|
|
51
|
+
|
|
52
|
+
This is the fastest way to try Keystream as a Vim practice tool.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bun run dev:stack
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
What it does:
|
|
59
|
+
|
|
60
|
+
- starts one server
|
|
61
|
+
- opens three CLI clients in a tiled `tmux` session
|
|
62
|
+
- connects them to the same `general` room
|
|
63
|
+
- enables `--strict-vim` in each client
|
|
64
|
+
|
|
65
|
+
If `tmux` is not installed, use the manual setup below.
|
|
66
|
+
|
|
67
|
+
### Option 2: Run server and clients manually
|
|
68
|
+
|
|
69
|
+
Start the server:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun --cwd apps/server src/index.ts --host 0.0.0.0 --port 3088 --db ./data/keystream.sqlite
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Start a client:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Start another client in a second terminal:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username bob --room general --strict-vim
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can omit `--strict-vim` if you want arrow keys to translate to motions instead of being counted as violations.
|
|
88
|
+
|
|
89
|
+
## Public Install And Distribution
|
|
90
|
+
|
|
91
|
+
### Install the public npm CLI
|
|
92
|
+
|
|
93
|
+
Once published, the CLI can be installed globally with npm:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm install -g keystream-cli
|
|
97
|
+
keystream --help
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The installed command is `keystream`, while the package name is `keystream-cli`.
|
|
101
|
+
|
|
102
|
+
### Run the published CLI
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
keystream --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Pull the backend Docker image
|
|
109
|
+
|
|
110
|
+
Once a tagged GitHub release has published the backend image:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
docker pull ghcr.io/lihaowang/keystream-server:v0.0.1
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Run the backend Docker image with persistent storage
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
mkdir -p ./keystream-data
|
|
120
|
+
|
|
121
|
+
docker run --rm \
|
|
122
|
+
-p 3088:3088 \
|
|
123
|
+
-e KEYSTREAM_HOST=0.0.0.0 \
|
|
124
|
+
-e KEYSTREAM_PORT=3088 \
|
|
125
|
+
-e KEYSTREAM_DB=/data/keystream.sqlite \
|
|
126
|
+
-v "$(pwd)/keystream-data:/data" \
|
|
127
|
+
ghcr.io/lihaowang/keystream-server:v0.0.1
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This mounted `/data` path is the persistent SQLite store. If you do not mount it, history will not survive container replacement.
|
|
131
|
+
|
|
132
|
+
## CLI Flags And Env Vars
|
|
133
|
+
|
|
134
|
+
### Server
|
|
135
|
+
|
|
136
|
+
Command:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bun --cwd apps/server src/index.ts --host 0.0.0.0 --port 3088 --db ./data/keystream.sqlite
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Flags:
|
|
143
|
+
|
|
144
|
+
- `--host` bind address, default `0.0.0.0`
|
|
145
|
+
- `--port` listening port, default `3088`
|
|
146
|
+
- `--db` SQLite database path, default `./data/keystream.sqlite`
|
|
147
|
+
|
|
148
|
+
Environment variables:
|
|
149
|
+
|
|
150
|
+
- `KEYSTREAM_HOST`
|
|
151
|
+
- `KEYSTREAM_PORT`
|
|
152
|
+
- `KEYSTREAM_DB`
|
|
153
|
+
|
|
154
|
+
### Client
|
|
155
|
+
|
|
156
|
+
Command:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
bun --cwd apps/cli src/index.ts --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Flags:
|
|
163
|
+
|
|
164
|
+
- `--url` WebSocket server URL, default `ws://127.0.0.1:3088`
|
|
165
|
+
- `--username` username to claim on connect
|
|
166
|
+
- `--room` initial room name, default `general`
|
|
167
|
+
- `--strict-vim` block arrow keys and record violations
|
|
168
|
+
|
|
169
|
+
Environment variables:
|
|
170
|
+
|
|
171
|
+
- `KEYSTREAM_URL`
|
|
172
|
+
- `KEYSTREAM_USERNAME`
|
|
173
|
+
- `KEYSTREAM_ROOM`
|
|
174
|
+
- `KEYSTREAM_STRICT_VIM=1`
|
|
175
|
+
|
|
176
|
+
Flag precedence is higher than environment variables.
|
|
177
|
+
|
|
178
|
+
Published npm usage:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
keystream --url ws://127.0.0.1:3088 --username alice --room general --strict-vim
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## How To Use Keystream
|
|
185
|
+
|
|
186
|
+
### Screen layout
|
|
187
|
+
|
|
188
|
+
The client screen has four regions:
|
|
189
|
+
|
|
190
|
+
- left pane: room list
|
|
191
|
+
- main pane: message feed for the current room
|
|
192
|
+
- bottom input pane: mode-specific input area
|
|
193
|
+
- status line: mode, username, room, training stats, and status messages
|
|
194
|
+
|
|
195
|
+
### Modes
|
|
196
|
+
|
|
197
|
+
Keystream is modal. Most interaction starts in `NORMAL` mode.
|
|
198
|
+
|
|
199
|
+
#### `NORMAL`
|
|
200
|
+
|
|
201
|
+
Use this mode to navigate rooms and messages.
|
|
202
|
+
|
|
203
|
+
- `i`, `a`, `o`: enter `INSERT` mode
|
|
204
|
+
- `:`: enter `COMMAND` mode
|
|
205
|
+
- `/`: enter `SEARCH` mode
|
|
206
|
+
- `h` and `l`: move between rooms
|
|
207
|
+
- `j` and `k`: move through messages in the current feed
|
|
208
|
+
- `5j`, `10k`, etc.: counted motions
|
|
209
|
+
- `gg`: jump to the top of the current feed
|
|
210
|
+
- `G`: jump to the bottom of the current feed
|
|
211
|
+
- `Ctrl-u`: move up 10 messages
|
|
212
|
+
- `Ctrl-d`: move down 10 messages
|
|
213
|
+
- `Ctrl-c`: quit immediately
|
|
214
|
+
|
|
215
|
+
#### `INSERT`
|
|
216
|
+
|
|
217
|
+
Use this mode to compose and send a message.
|
|
218
|
+
|
|
219
|
+
- type to edit the current message buffer
|
|
220
|
+
- `Backspace`: delete one character
|
|
221
|
+
- `Enter`: send the message to the active room
|
|
222
|
+
- `Esc`: return to `NORMAL`
|
|
223
|
+
|
|
224
|
+
#### `COMMAND`
|
|
225
|
+
|
|
226
|
+
Use this mode for room and app commands.
|
|
227
|
+
|
|
228
|
+
- starts with `:`
|
|
229
|
+
- type a command, then press `Enter`
|
|
230
|
+
- `Esc` cancels command mode
|
|
231
|
+
|
|
232
|
+
#### `SEARCH`
|
|
233
|
+
|
|
234
|
+
Use this mode to filter the current room feed.
|
|
235
|
+
|
|
236
|
+
- starts with `/`
|
|
237
|
+
- type a search term, then press `Enter`
|
|
238
|
+
- search is case-insensitive
|
|
239
|
+
- only the current room feed is filtered
|
|
240
|
+
- empty search clears the filter
|
|
241
|
+
- `Esc` cancels search mode
|
|
242
|
+
|
|
243
|
+
### Built-in commands
|
|
244
|
+
|
|
245
|
+
- `:join <room>`: join or create a room and switch to it
|
|
246
|
+
- `:leave`: leave the current room on the server
|
|
247
|
+
- `:rooms`: show known rooms in the status line
|
|
248
|
+
- `:users`: show active occupants in the current room
|
|
249
|
+
- `:history [limit]`: load older messages before the earliest loaded message
|
|
250
|
+
- `:stats`: show mode timing, streak, and arrow-key stats
|
|
251
|
+
- `:help`: print the command list
|
|
252
|
+
- `:commands`: alias for `:help`
|
|
253
|
+
- `:quit`: exit the client
|
|
254
|
+
- `:q`: alias for `:quit`
|
|
255
|
+
|
|
256
|
+
### Vim training behavior
|
|
257
|
+
|
|
258
|
+
Keystream is not just a terminal chat client with some Vim keys added. The app is intentionally structured to train habits through repetition.
|
|
259
|
+
|
|
260
|
+
- Navigation is concentrated in `NORMAL` mode.
|
|
261
|
+
- Writing happens in `INSERT` mode.
|
|
262
|
+
- Commands happen in `COMMAND` mode.
|
|
263
|
+
- Search happens in `SEARCH` mode.
|
|
264
|
+
- The status line tracks clean-motion streaks and arrow-key violations.
|
|
265
|
+
- In strict mode, arrow keys are blocked and replaced with a hint to use `h`, `j`, `k`, `l`.
|
|
266
|
+
|
|
267
|
+
### Typical workflow
|
|
268
|
+
|
|
269
|
+
1. Start the server.
|
|
270
|
+
2. Open one or more clients.
|
|
271
|
+
3. Land in `NORMAL` mode.
|
|
272
|
+
4. Press `i` to enter `INSERT` mode and type a message.
|
|
273
|
+
5. Press `Enter` to send it.
|
|
274
|
+
6. Press `Esc` to go back to `NORMAL`.
|
|
275
|
+
7. Use `j`, `k`, `gg`, `G`, and search to move through the feed.
|
|
276
|
+
8. Use `:join dev` to switch into another room.
|
|
277
|
+
9. Use `:stats` to check your mode time and arrow-key violations.
|
|
278
|
+
|
|
279
|
+
## Development Guide
|
|
280
|
+
|
|
281
|
+
### Repository layout
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
apps/
|
|
285
|
+
cli/ Terminal client
|
|
286
|
+
server/ Bun WebSocket server + SQLite persistence
|
|
287
|
+
packages/
|
|
288
|
+
shared/ Shared protocol, types, and validation
|
|
289
|
+
scripts/
|
|
290
|
+
dev-stack.ts Local tmux-based practice launcher
|
|
291
|
+
test/
|
|
292
|
+
integration/ End-to-end server integration tests
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Main modules
|
|
296
|
+
|
|
297
|
+
- `packages/shared/src/protocol.ts`: typed wire protocol and runtime validation
|
|
298
|
+
- `apps/server/src/chat-server.ts`: WebSocket lifecycle and room/message coordination
|
|
299
|
+
- `apps/server/src/store.ts`: SQLite schema and persistence logic
|
|
300
|
+
- `apps/cli/src/app.ts`: modal terminal UI and client-side state machine
|
|
301
|
+
- `apps/cli/src/client.ts`: reconnecting WebSocket transport
|
|
302
|
+
- `apps/cli/src/modal-state.ts`: Vim training state and motion logic
|
|
303
|
+
- `apps/cli/src/commands.ts`: command parsing
|
|
304
|
+
|
|
305
|
+
### Install dependencies
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
bun install
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Run tests
|
|
312
|
+
|
|
313
|
+
Run all tests:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
bun test
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Run the TypeScript checks:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
bunx tsc -p packages/shared/tsconfig.json --noEmit
|
|
323
|
+
bunx tsc -p apps/server/tsconfig.json --noEmit
|
|
324
|
+
bunx tsc -p apps/cli/tsconfig.json --noEmit
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Build release artifacts locally
|
|
328
|
+
|
|
329
|
+
Build the Node-compatible npm CLI bundle:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
bun run build:cli
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Dry-run the npm package tarball:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
bun run pack:cli --dry-run
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Build the backend Docker image:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
bun run docker:build:server
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Run the backend Docker smoke test:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
bun run docker:smoke:server
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Recommended development loop
|
|
354
|
+
|
|
355
|
+
1. Start the server in one terminal.
|
|
356
|
+
2. Start one or more clients in separate terminals.
|
|
357
|
+
3. Use `bun test` after behavior changes.
|
|
358
|
+
4. Re-run the TypeScript checks before finishing a change.
|
|
359
|
+
5. Use `bun run dev:stack` when you want rapid multi-user keyboard practice.
|
|
360
|
+
|
|
361
|
+
### Manual smoke test
|
|
362
|
+
|
|
363
|
+
Use this after touching the server-client interaction path:
|
|
364
|
+
|
|
365
|
+
1. Start the server on a local port.
|
|
366
|
+
2. Open two clients with different usernames.
|
|
367
|
+
3. Confirm both receive `welcome` and `room_snapshot` behavior.
|
|
368
|
+
4. Send messages from one client and confirm the other updates live.
|
|
369
|
+
5. Join a new room with `:join`.
|
|
370
|
+
6. Use `/` search and `:history`.
|
|
371
|
+
7. Run `:stats` and confirm Vim training counters update.
|
|
372
|
+
|
|
373
|
+
### Persistence notes
|
|
374
|
+
|
|
375
|
+
- The server uses SQLite and creates the database directory automatically.
|
|
376
|
+
- The default database path is `./data/keystream.sqlite`.
|
|
377
|
+
- The local dev stack uses `./data/dev-stack.sqlite`.
|
|
378
|
+
- Message history survives server restarts when you reuse the same database path.
|
|
379
|
+
|
|
380
|
+
### Networking notes
|
|
381
|
+
|
|
382
|
+
- The server speaks WebSocket only.
|
|
383
|
+
- The client reconnects automatically if the connection drops.
|
|
384
|
+
- Duplicate active usernames are rejected.
|
|
385
|
+
- Room message ordering is assigned by the server per room using monotonically increasing sequence numbers.
|
|
386
|
+
|
|
387
|
+
## Scripts
|
|
388
|
+
|
|
389
|
+
- `bun run build`: workspace build/typecheck script entrypoint
|
|
390
|
+
- `bun run check`: alias for `bun test`
|
|
391
|
+
- `bun run build:cli`: build the public Node-compatible CLI package into `apps/cli/dist`
|
|
392
|
+
- `bun run pack:cli --dry-run`: generate and inspect the npm tarball from `apps/cli/dist`
|
|
393
|
+
- `bun run docker:build:server`: build the local backend container image
|
|
394
|
+
- `bun run docker:smoke:server`: build, run, restart, and verify the Dockerized backend with persisted SQLite data
|
|
395
|
+
- `bun run release:check`: run the release validation path used before tagging
|
|
396
|
+
- `bun run dev:server`: run the server package script
|
|
397
|
+
- `bun run dev:cli`: run the CLI package script
|
|
398
|
+
- `bun run dev:stack`: launch the tmux multi-client practice environment
|
|
399
|
+
|
|
400
|
+
## Deployment Guide
|
|
401
|
+
|
|
402
|
+
### Backend container
|
|
403
|
+
|
|
404
|
+
The Docker image targets the backend only. It does not package the CLI.
|
|
405
|
+
|
|
406
|
+
- Registry: `ghcr.io/lihaowang/keystream-server`
|
|
407
|
+
- Default internal port: `3088`
|
|
408
|
+
- Default container DB path: `/data/keystream.sqlite`
|
|
409
|
+
- Supported runtime configuration: `KEYSTREAM_HOST`, `KEYSTREAM_PORT`, `KEYSTREAM_DB`
|
|
410
|
+
|
|
411
|
+
Recommended deployment pattern:
|
|
412
|
+
|
|
413
|
+
1. Mount a persistent volume to `/data`
|
|
414
|
+
2. Expose port `3088` or reverse-proxy it
|
|
415
|
+
3. Front the service with TLS if it is reachable outside a trusted LAN
|
|
416
|
+
4. Reuse the same mounted database volume across restarts
|
|
417
|
+
|
|
418
|
+
### GitHub Container Registry release path
|
|
419
|
+
|
|
420
|
+
The backend image is published by GitHub Actions on version tags:
|
|
421
|
+
|
|
422
|
+
- workflow: `.github/workflows/release-server-image.yml`
|
|
423
|
+
- trigger: `v*` tags
|
|
424
|
+
- image tags:
|
|
425
|
+
- `ghcr.io/lihaowang/keystream-server:<tag>`
|
|
426
|
+
- `ghcr.io/lihaowang/keystream-server:latest`
|
|
427
|
+
|
|
428
|
+
### npm CLI release path
|
|
429
|
+
|
|
430
|
+
The CLI release path is manual for final publish, but validated in CI:
|
|
431
|
+
|
|
432
|
+
1. Build the CLI package with `bun run build:cli`
|
|
433
|
+
2. Validate the package with `bun run pack:cli --dry-run`
|
|
434
|
+
3. Log in to npm with `npm login`
|
|
435
|
+
4. Publish from `apps/cli/dist` with:
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
npm publish ./apps/cli/dist --access public
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
See [docs/releasing.md](/Users/leowang1/Documents/keystream/docs/releasing.md) for the maintainer flow.
|
|
442
|
+
|
|
443
|
+
## Known Limitations
|
|
444
|
+
|
|
445
|
+
- `:leave` acknowledges on the server but the UI does not implement a separate “roomless” screen state.
|
|
446
|
+
- Presence updates are modeled in the protocol but not yet rendered as live status changes in the UI.
|
|
447
|
+
- The README examples use direct Bun entrypoints because they are the most explicit and reliable during development.
|
|
448
|
+
- Public internet deployment should be fronted by TLS and stronger access control if you plan to expose it beyond a trusted environment.
|
|
449
|
+
|
|
450
|
+
## Future Improvements
|
|
451
|
+
|
|
452
|
+
- Direct messages
|
|
453
|
+
- Better presence rendering
|
|
454
|
+
- Message editing and reactions
|
|
455
|
+
- Room privacy and invitations
|
|
456
|
+
- Richer training metrics and drills
|
|
457
|
+
- PTY-level end-to-end tests for the full terminal UI
|
package/keystream.js
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// apps/cli/src/app.ts
|
|
4
|
+
import blessed from "blessed";
|
|
5
|
+
|
|
6
|
+
// apps/cli/src/commands.ts
|
|
7
|
+
function parseCommand(input) {
|
|
8
|
+
const trimmed = input.trim();
|
|
9
|
+
if (!trimmed.startsWith(":")) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const tokens = trimmed.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
13
|
+
if (tokens.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
name: tokens[0].toLowerCase(),
|
|
18
|
+
args: tokens.slice(1)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// apps/cli/src/modal-state.ts
|
|
23
|
+
var arrowKeys = new Set(["up", "down", "left", "right"]);
|
|
24
|
+
function transitionMode(currentMode, key) {
|
|
25
|
+
if (currentMode === "INSERT" && key === "escape") {
|
|
26
|
+
return "NORMAL";
|
|
27
|
+
}
|
|
28
|
+
if (currentMode === "NORMAL") {
|
|
29
|
+
if (key === "i" || key === "a" || key === "o") {
|
|
30
|
+
return "INSERT";
|
|
31
|
+
}
|
|
32
|
+
if (key === ":") {
|
|
33
|
+
return "COMMAND";
|
|
34
|
+
}
|
|
35
|
+
if (key === "/") {
|
|
36
|
+
return "SEARCH";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if ((currentMode === "COMMAND" || currentMode === "SEARCH") && key === "escape") {
|
|
40
|
+
return "NORMAL";
|
|
41
|
+
}
|
|
42
|
+
return currentMode;
|
|
43
|
+
}
|
|
44
|
+
function createTrainingStats(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
strictVim: false,
|
|
47
|
+
pendingCount: "",
|
|
48
|
+
pendingOperator: null,
|
|
49
|
+
arrowViolations: 0,
|
|
50
|
+
cleanMotionStreak: 0,
|
|
51
|
+
lastMotion: null,
|
|
52
|
+
lastHint: null,
|
|
53
|
+
...overrides
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function handleNormalModeKey(state, key) {
|
|
57
|
+
if (arrowKeys.has(key)) {
|
|
58
|
+
return {
|
|
59
|
+
...state,
|
|
60
|
+
arrowViolations: state.strictVim ? state.arrowViolations + 1 : state.arrowViolations,
|
|
61
|
+
lastHint: state.strictVim ? "Use h j k l instead of arrow keys." : null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (/^[1-9]$/.test(key) || state.pendingCount !== "" && /^[0-9]$/.test(key)) {
|
|
65
|
+
return {
|
|
66
|
+
...state,
|
|
67
|
+
pendingCount: `${state.pendingCount}${key}`,
|
|
68
|
+
lastHint: null
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (key === "g") {
|
|
72
|
+
if (state.pendingOperator === "g") {
|
|
73
|
+
return finalizeMotion(state, "gg");
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...state,
|
|
77
|
+
pendingOperator: "g",
|
|
78
|
+
lastHint: null
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (["j", "k", "h", "l", "G"].includes(key)) {
|
|
82
|
+
return finalizeMotion(state, key);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
...state,
|
|
86
|
+
pendingCount: "",
|
|
87
|
+
pendingOperator: null,
|
|
88
|
+
lastHint: null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function finalizeMotion(state, key) {
|
|
92
|
+
const count = state.pendingCount === "" ? 1 : Number(state.pendingCount);
|
|
93
|
+
return {
|
|
94
|
+
...state,
|
|
95
|
+
pendingCount: "",
|
|
96
|
+
pendingOperator: null,
|
|
97
|
+
cleanMotionStreak: state.cleanMotionStreak + 1,
|
|
98
|
+
lastMotion: {
|
|
99
|
+
key,
|
|
100
|
+
count
|
|
101
|
+
},
|
|
102
|
+
lastHint: null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// packages/shared/src/protocol.ts
|
|
107
|
+
import { z } from "zod";
|
|
108
|
+
var isoDateString = z.string().datetime({ offset: true });
|
|
109
|
+
var clientPayloadSchemas = {
|
|
110
|
+
connect: z.object({
|
|
111
|
+
username: z.string().min(1),
|
|
112
|
+
room: z.string().min(1),
|
|
113
|
+
sessionId: z.string().min(1).nullable()
|
|
114
|
+
}),
|
|
115
|
+
join_room: z.object({
|
|
116
|
+
room: z.string().min(1)
|
|
117
|
+
}),
|
|
118
|
+
leave_room: z.object({
|
|
119
|
+
roomId: z.string().min(1)
|
|
120
|
+
}),
|
|
121
|
+
send_message: z.object({
|
|
122
|
+
roomId: z.string().min(1),
|
|
123
|
+
body: z.string().min(1)
|
|
124
|
+
}),
|
|
125
|
+
load_history: z.object({
|
|
126
|
+
roomId: z.string().min(1),
|
|
127
|
+
beforeSeq: z.number().int().positive().nullable(),
|
|
128
|
+
limit: z.number().int().positive().max(100).default(50)
|
|
129
|
+
}),
|
|
130
|
+
set_presence: z.object({
|
|
131
|
+
state: z.enum(["active", "idle"])
|
|
132
|
+
}),
|
|
133
|
+
ping: z.object({
|
|
134
|
+
at: isoDateString
|
|
135
|
+
})
|
|
136
|
+
};
|
|
137
|
+
var messageSchema = z.object({
|
|
138
|
+
id: z.string().min(1),
|
|
139
|
+
roomId: z.string().min(1),
|
|
140
|
+
sender: z.string().min(1),
|
|
141
|
+
body: z.string().min(1),
|
|
142
|
+
seq: z.number().int().positive(),
|
|
143
|
+
createdAt: isoDateString
|
|
144
|
+
});
|
|
145
|
+
var roomSchema = z.object({
|
|
146
|
+
id: z.string().min(1),
|
|
147
|
+
name: z.string().min(1),
|
|
148
|
+
topic: z.string().default(""),
|
|
149
|
+
isPrivate: z.boolean().default(false)
|
|
150
|
+
});
|
|
151
|
+
var presenceSchema = z.object({
|
|
152
|
+
username: z.string().min(1),
|
|
153
|
+
state: z.enum(["active", "idle", "offline"]),
|
|
154
|
+
lastSeenAt: isoDateString
|
|
155
|
+
});
|
|
156
|
+
var serverPayloadSchemas = {
|
|
157
|
+
welcome: z.object({
|
|
158
|
+
username: z.string().min(1),
|
|
159
|
+
rooms: z.array(roomSchema),
|
|
160
|
+
joinedRoomId: z.string().min(1),
|
|
161
|
+
sessionId: z.string().min(1)
|
|
162
|
+
}),
|
|
163
|
+
room_snapshot: z.object({
|
|
164
|
+
room: roomSchema,
|
|
165
|
+
occupants: z.array(z.string().min(1)),
|
|
166
|
+
messages: z.array(messageSchema),
|
|
167
|
+
unreadCount: z.number().int().nonnegative()
|
|
168
|
+
}),
|
|
169
|
+
message_created: messageSchema,
|
|
170
|
+
presence_changed: presenceSchema,
|
|
171
|
+
history_page: z.object({
|
|
172
|
+
roomId: z.string().min(1),
|
|
173
|
+
messages: z.array(messageSchema),
|
|
174
|
+
hasMore: z.boolean(),
|
|
175
|
+
nextBeforeSeq: z.number().int().positive().nullable()
|
|
176
|
+
}),
|
|
177
|
+
ack: z.object({
|
|
178
|
+
action: z.string().min(1),
|
|
179
|
+
ok: z.literal(true)
|
|
180
|
+
}),
|
|
181
|
+
error: z.object({
|
|
182
|
+
action: z.string().min(1),
|
|
183
|
+
message: z.string().min(1)
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
var clientEventTypes = Object.keys(clientPayloadSchemas);
|
|
187
|
+
var serverEventTypes = Object.keys(serverPayloadSchemas);
|
|
188
|
+
function createProtocolEnvelope(direction, type, payload) {
|
|
189
|
+
return {
|
|
190
|
+
direction,
|
|
191
|
+
type,
|
|
192
|
+
payload
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function makeClientEvent(type, payload) {
|
|
196
|
+
return createProtocolEnvelope("client", type, payload);
|
|
197
|
+
}
|
|
198
|
+
function makeServerEvent(type, payload) {
|
|
199
|
+
return createProtocolEnvelope("server", type, payload);
|
|
200
|
+
}
|
|
201
|
+
function parseProtocolEnvelope(input) {
|
|
202
|
+
const baseEnvelope = z.object({
|
|
203
|
+
direction: z.enum(["client", "server"]),
|
|
204
|
+
type: z.string().min(1),
|
|
205
|
+
payload: z.unknown()
|
|
206
|
+
}).parse(input);
|
|
207
|
+
if (baseEnvelope.direction === "client") {
|
|
208
|
+
const schema2 = clientPayloadSchemas[baseEnvelope.type];
|
|
209
|
+
if (!schema2) {
|
|
210
|
+
throw new Error(`Unsupported client event type: ${baseEnvelope.type}`);
|
|
211
|
+
}
|
|
212
|
+
return makeClientEvent(baseEnvelope.type, schema2.parse(baseEnvelope.payload));
|
|
213
|
+
}
|
|
214
|
+
const schema = serverPayloadSchemas[baseEnvelope.type];
|
|
215
|
+
if (!schema) {
|
|
216
|
+
throw new Error(`Unsupported server event type: ${baseEnvelope.type}`);
|
|
217
|
+
}
|
|
218
|
+
return makeServerEvent(baseEnvelope.type, schema.parse(baseEnvelope.payload));
|
|
219
|
+
}
|
|
220
|
+
// apps/cli/src/client.ts
|
|
221
|
+
class ChatTransport {
|
|
222
|
+
options;
|
|
223
|
+
ws = null;
|
|
224
|
+
closedByClient = false;
|
|
225
|
+
reconnectTimer = null;
|
|
226
|
+
sessionId;
|
|
227
|
+
constructor(options) {
|
|
228
|
+
this.options = options;
|
|
229
|
+
this.sessionId = options.sessionId;
|
|
230
|
+
}
|
|
231
|
+
connect() {
|
|
232
|
+
this.closedByClient = false;
|
|
233
|
+
this.openSocket();
|
|
234
|
+
}
|
|
235
|
+
updateRoom(room) {
|
|
236
|
+
this.options.room = room;
|
|
237
|
+
}
|
|
238
|
+
updateSessionId(sessionId) {
|
|
239
|
+
this.sessionId = sessionId;
|
|
240
|
+
}
|
|
241
|
+
send(type, payload) {
|
|
242
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
243
|
+
this.options.onStatus("Socket is not connected yet.");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.ws.send(JSON.stringify(makeClientEvent(type, payload)));
|
|
247
|
+
}
|
|
248
|
+
close() {
|
|
249
|
+
this.closedByClient = true;
|
|
250
|
+
if (this.reconnectTimer) {
|
|
251
|
+
clearTimeout(this.reconnectTimer);
|
|
252
|
+
this.reconnectTimer = null;
|
|
253
|
+
}
|
|
254
|
+
this.ws?.close();
|
|
255
|
+
}
|
|
256
|
+
openSocket() {
|
|
257
|
+
this.options.onStatus(`Connecting to ${this.options.url}...`);
|
|
258
|
+
const ws = new WebSocket(this.options.url);
|
|
259
|
+
this.ws = ws;
|
|
260
|
+
ws.addEventListener("open", () => {
|
|
261
|
+
this.options.onStatus(`Connected as ${this.options.username}`);
|
|
262
|
+
this.send("connect", {
|
|
263
|
+
username: this.options.username,
|
|
264
|
+
room: this.options.room,
|
|
265
|
+
sessionId: this.sessionId
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
ws.addEventListener("message", (event) => {
|
|
269
|
+
const parsed = parseProtocolEnvelope(JSON.parse(String(event.data)));
|
|
270
|
+
this.options.onEvent(parsed);
|
|
271
|
+
});
|
|
272
|
+
ws.addEventListener("close", () => {
|
|
273
|
+
if (this.closedByClient) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.options.onStatus("Disconnected. Reconnecting in 1.5s...");
|
|
277
|
+
this.reconnectTimer = setTimeout(() => this.openSocket(), 1500);
|
|
278
|
+
});
|
|
279
|
+
ws.addEventListener("error", () => {
|
|
280
|
+
this.options.onStatus("Socket error.");
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// apps/cli/src/app.ts
|
|
286
|
+
function startCliApp(options) {
|
|
287
|
+
const screen = blessed.screen({
|
|
288
|
+
smartCSR: true,
|
|
289
|
+
title: "Keystream",
|
|
290
|
+
fullUnicode: true
|
|
291
|
+
});
|
|
292
|
+
const roomsBox = blessed.box({
|
|
293
|
+
parent: screen,
|
|
294
|
+
top: 0,
|
|
295
|
+
left: 0,
|
|
296
|
+
width: "24%",
|
|
297
|
+
height: "100%-4",
|
|
298
|
+
border: "line",
|
|
299
|
+
label: " Rooms ",
|
|
300
|
+
tags: false
|
|
301
|
+
});
|
|
302
|
+
const messagesBox = blessed.box({
|
|
303
|
+
parent: screen,
|
|
304
|
+
top: 0,
|
|
305
|
+
left: "24%",
|
|
306
|
+
width: "76%",
|
|
307
|
+
height: "100%-4",
|
|
308
|
+
border: "line",
|
|
309
|
+
label: " Feed ",
|
|
310
|
+
tags: false,
|
|
311
|
+
scrollable: true,
|
|
312
|
+
alwaysScroll: true
|
|
313
|
+
});
|
|
314
|
+
const inputBox = blessed.box({
|
|
315
|
+
parent: screen,
|
|
316
|
+
bottom: 1,
|
|
317
|
+
left: 0,
|
|
318
|
+
width: "100%",
|
|
319
|
+
height: 3,
|
|
320
|
+
border: "line",
|
|
321
|
+
label: " Input ",
|
|
322
|
+
tags: false
|
|
323
|
+
});
|
|
324
|
+
const statusBox = blessed.box({
|
|
325
|
+
parent: screen,
|
|
326
|
+
bottom: 0,
|
|
327
|
+
left: 0,
|
|
328
|
+
width: "100%",
|
|
329
|
+
height: 1,
|
|
330
|
+
tags: false
|
|
331
|
+
});
|
|
332
|
+
let state = {
|
|
333
|
+
mode: "NORMAL",
|
|
334
|
+
rooms: [],
|
|
335
|
+
currentRoomId: null,
|
|
336
|
+
messagesByRoom: new Map,
|
|
337
|
+
occupantsByRoom: new Map,
|
|
338
|
+
unreadByRoom: new Map,
|
|
339
|
+
selectedMessageIndex: 0,
|
|
340
|
+
composeBuffer: "",
|
|
341
|
+
commandBuffer: "",
|
|
342
|
+
searchBuffer: "",
|
|
343
|
+
searchTerm: "",
|
|
344
|
+
status: "Starting Keystream...",
|
|
345
|
+
sessionId: null,
|
|
346
|
+
training: createTrainingStats({ strictVim: options.strictVim }),
|
|
347
|
+
modeDurations: {
|
|
348
|
+
NORMAL: 0,
|
|
349
|
+
INSERT: 0,
|
|
350
|
+
COMMAND: 0,
|
|
351
|
+
SEARCH: 0
|
|
352
|
+
},
|
|
353
|
+
modeStartedAt: Date.now()
|
|
354
|
+
};
|
|
355
|
+
const transport = new ChatTransport({
|
|
356
|
+
url: options.url,
|
|
357
|
+
username: options.username,
|
|
358
|
+
room: options.room,
|
|
359
|
+
sessionId: state.sessionId,
|
|
360
|
+
onEvent: (event) => {
|
|
361
|
+
state = reduceEvent(state, event);
|
|
362
|
+
if (event.type === "welcome") {
|
|
363
|
+
transport.updateSessionId(event.payload.sessionId);
|
|
364
|
+
}
|
|
365
|
+
render();
|
|
366
|
+
},
|
|
367
|
+
onStatus: (message) => {
|
|
368
|
+
state = {
|
|
369
|
+
...state,
|
|
370
|
+
status: message
|
|
371
|
+
};
|
|
372
|
+
render();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
const setMode = (nextMode) => {
|
|
376
|
+
if (nextMode === state.mode) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
const elapsed = now - state.modeStartedAt;
|
|
381
|
+
state = {
|
|
382
|
+
...state,
|
|
383
|
+
mode: nextMode,
|
|
384
|
+
modeStartedAt: now,
|
|
385
|
+
modeDurations: {
|
|
386
|
+
...state.modeDurations,
|
|
387
|
+
[state.mode]: state.modeDurations[state.mode] + elapsed
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
const render = () => {
|
|
392
|
+
const roomLines = state.rooms.map((room) => {
|
|
393
|
+
const currentMarker = room.id === state.currentRoomId ? ">" : " ";
|
|
394
|
+
const unread = state.unreadByRoom.get(room.id) ?? 0;
|
|
395
|
+
const unreadLabel = unread > 0 ? ` (${unread})` : "";
|
|
396
|
+
return `${currentMarker} ${room.name}${unreadLabel}`;
|
|
397
|
+
});
|
|
398
|
+
const visibleMessages = applySearch(getCurrentMessages(state), state.searchTerm);
|
|
399
|
+
const selected = clamp(state.selectedMessageIndex, 0, Math.max(visibleMessages.length - 1, 0));
|
|
400
|
+
state.selectedMessageIndex = selected;
|
|
401
|
+
const messageLines = visibleMessages.map((message, index) => {
|
|
402
|
+
const marker = index === selected ? ">" : " ";
|
|
403
|
+
return `${marker} [${String(message.seq).padStart(3, " ")}] ${message.sender}: ${message.body}`;
|
|
404
|
+
});
|
|
405
|
+
roomsBox.setContent(roomLines.join(`
|
|
406
|
+
`) || "No rooms");
|
|
407
|
+
messagesBox.setLabel(` Feed ${state.searchTerm ? `(search: ${state.searchTerm})` : ""} `);
|
|
408
|
+
messagesBox.setContent(messageLines.join(`
|
|
409
|
+
`) || "No messages yet");
|
|
410
|
+
messagesBox.setScrollPerc(100);
|
|
411
|
+
inputBox.setLabel(` ${state.mode} `);
|
|
412
|
+
inputBox.setContent(currentInputValue(state));
|
|
413
|
+
statusBox.setContent(buildStatusLine(state, options.username));
|
|
414
|
+
screen.render();
|
|
415
|
+
};
|
|
416
|
+
screen.on("keypress", (ch, key) => {
|
|
417
|
+
const rawKey = normalizeKey(ch, key);
|
|
418
|
+
if (rawKey === "C-c") {
|
|
419
|
+
shutdown();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (state.mode === "NORMAL") {
|
|
423
|
+
if (handleNormalBindings(rawKey)) {
|
|
424
|
+
render();
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (state.mode === "INSERT") {
|
|
429
|
+
handleInsertMode(rawKey, ch);
|
|
430
|
+
render();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (state.mode === "COMMAND") {
|
|
434
|
+
handleCommandMode(rawKey, ch);
|
|
435
|
+
render();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
handleSearchMode(rawKey, ch);
|
|
439
|
+
render();
|
|
440
|
+
});
|
|
441
|
+
transport.connect();
|
|
442
|
+
render();
|
|
443
|
+
function handleNormalBindings(rawKey) {
|
|
444
|
+
const nextMode = transitionMode(state.mode, rawKey);
|
|
445
|
+
if (nextMode !== state.mode) {
|
|
446
|
+
if (nextMode === "COMMAND") {
|
|
447
|
+
state.commandBuffer = ":";
|
|
448
|
+
}
|
|
449
|
+
if (nextMode === "SEARCH") {
|
|
450
|
+
state.searchBuffer = "/";
|
|
451
|
+
}
|
|
452
|
+
setMode(nextMode);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
const previousTraining = state.training;
|
|
456
|
+
state = {
|
|
457
|
+
...state,
|
|
458
|
+
training: handleNormalModeKey(state.training, rawKey)
|
|
459
|
+
};
|
|
460
|
+
if (state.training.lastHint) {
|
|
461
|
+
state.status = state.training.lastHint;
|
|
462
|
+
}
|
|
463
|
+
if (state.training.lastMotion !== previousTraining.lastMotion && state.training.lastMotion) {
|
|
464
|
+
applyMotion(state.training.lastMotion.key, state.training.lastMotion.count);
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
if (rawKey === "C-u") {
|
|
468
|
+
moveSelection(-10);
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
if (rawKey === "C-d") {
|
|
472
|
+
moveSelection(10);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
if (!state.training.strictVim && arrowToMotion(rawKey)) {
|
|
476
|
+
applyMotion(arrowToMotion(rawKey), 1);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
function handleInsertMode(rawKey, ch) {
|
|
482
|
+
if (rawKey === "escape") {
|
|
483
|
+
setMode("NORMAL");
|
|
484
|
+
state.status = "Returned to NORMAL mode";
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (rawKey === "backspace") {
|
|
488
|
+
state.composeBuffer = state.composeBuffer.slice(0, -1);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (rawKey === "enter") {
|
|
492
|
+
const roomId = state.currentRoomId;
|
|
493
|
+
const message = state.composeBuffer.trim();
|
|
494
|
+
if (!roomId || !message) {
|
|
495
|
+
state.status = "Nothing to send";
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
transport.send("send_message", {
|
|
499
|
+
roomId,
|
|
500
|
+
body: message
|
|
501
|
+
});
|
|
502
|
+
state.composeBuffer = "";
|
|
503
|
+
state.status = "Message sent";
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (isPrintable(ch)) {
|
|
507
|
+
state.composeBuffer += ch;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function handleCommandMode(rawKey, ch) {
|
|
511
|
+
if (rawKey === "escape") {
|
|
512
|
+
state.commandBuffer = "";
|
|
513
|
+
setMode("NORMAL");
|
|
514
|
+
state.status = "Command canceled";
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (rawKey === "backspace") {
|
|
518
|
+
state.commandBuffer = state.commandBuffer.slice(0, -1);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (rawKey === "enter") {
|
|
522
|
+
executeCommand(state.commandBuffer);
|
|
523
|
+
state.commandBuffer = "";
|
|
524
|
+
setMode("NORMAL");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (isPrintable(ch)) {
|
|
528
|
+
state.commandBuffer += ch;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function handleSearchMode(rawKey, ch) {
|
|
532
|
+
if (rawKey === "escape") {
|
|
533
|
+
state.searchBuffer = "";
|
|
534
|
+
setMode("NORMAL");
|
|
535
|
+
state.status = "Search canceled";
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (rawKey === "backspace") {
|
|
539
|
+
state.searchBuffer = state.searchBuffer.slice(0, -1);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (rawKey === "enter") {
|
|
543
|
+
state.searchTerm = state.searchBuffer.slice(1).trim().toLowerCase();
|
|
544
|
+
state.searchBuffer = "";
|
|
545
|
+
setMode("NORMAL");
|
|
546
|
+
const firstMatch = applySearch(getCurrentMessages(state), state.searchTerm).findIndex(() => true);
|
|
547
|
+
state.selectedMessageIndex = firstMatch >= 0 ? firstMatch : 0;
|
|
548
|
+
state.status = state.searchTerm ? `Search applied for "${state.searchTerm}"` : "Search cleared";
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (isPrintable(ch)) {
|
|
552
|
+
state.searchBuffer += ch;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function executeCommand(input) {
|
|
556
|
+
const parsed = parseCommand(input);
|
|
557
|
+
if (!parsed) {
|
|
558
|
+
state.status = "Not a command";
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
switch (parsed.name) {
|
|
562
|
+
case "join": {
|
|
563
|
+
const roomName = parsed.args[0];
|
|
564
|
+
if (!roomName) {
|
|
565
|
+
state.status = "Usage: :join <room>";
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
transport.updateRoom(roomName);
|
|
569
|
+
transport.send("join_room", { room: roomName });
|
|
570
|
+
state.status = `Joining ${roomName}...`;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
case "leave":
|
|
574
|
+
if (!state.currentRoomId) {
|
|
575
|
+
state.status = "No active room";
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
transport.send("leave_room", { roomId: state.currentRoomId });
|
|
579
|
+
state.status = "Left current room";
|
|
580
|
+
return;
|
|
581
|
+
case "rooms":
|
|
582
|
+
state.status = state.rooms.map((room) => room.name).join(", ") || "No rooms";
|
|
583
|
+
return;
|
|
584
|
+
case "users":
|
|
585
|
+
state.status = (state.currentRoomId ? state.occupantsByRoom.get(state.currentRoomId)?.join(", ") : "") || "No active occupants";
|
|
586
|
+
return;
|
|
587
|
+
case "history": {
|
|
588
|
+
if (!state.currentRoomId) {
|
|
589
|
+
state.status = "No active room";
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const currentMessages = getCurrentMessages(state);
|
|
593
|
+
const earliestSeq = currentMessages[0]?.seq ?? null;
|
|
594
|
+
const limit = Number(parsed.args[0] ?? "20");
|
|
595
|
+
transport.send("load_history", {
|
|
596
|
+
roomId: state.currentRoomId,
|
|
597
|
+
beforeSeq: earliestSeq,
|
|
598
|
+
limit: Number.isFinite(limit) && limit > 0 ? limit : 20
|
|
599
|
+
});
|
|
600
|
+
state.status = "Loading older messages...";
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
case "stats":
|
|
604
|
+
state.status = formatStats(state);
|
|
605
|
+
return;
|
|
606
|
+
case "help":
|
|
607
|
+
case "commands":
|
|
608
|
+
state.status = ":join :leave :rooms :users :history :stats :help :quit";
|
|
609
|
+
return;
|
|
610
|
+
case "quit":
|
|
611
|
+
case "q":
|
|
612
|
+
shutdown();
|
|
613
|
+
return;
|
|
614
|
+
default:
|
|
615
|
+
state.status = `Unknown command: ${parsed.name}`;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function applyMotion(key, count) {
|
|
619
|
+
switch (key) {
|
|
620
|
+
case "j":
|
|
621
|
+
moveSelection(count);
|
|
622
|
+
return;
|
|
623
|
+
case "k":
|
|
624
|
+
moveSelection(-count);
|
|
625
|
+
return;
|
|
626
|
+
case "h":
|
|
627
|
+
moveRoom(-count);
|
|
628
|
+
return;
|
|
629
|
+
case "l":
|
|
630
|
+
moveRoom(count);
|
|
631
|
+
return;
|
|
632
|
+
case "gg":
|
|
633
|
+
state.selectedMessageIndex = 0;
|
|
634
|
+
state.status = "Top of feed";
|
|
635
|
+
return;
|
|
636
|
+
case "G":
|
|
637
|
+
state.selectedMessageIndex = Math.max(applySearch(getCurrentMessages(state), state.searchTerm).length - 1, 0);
|
|
638
|
+
state.status = "Bottom of feed";
|
|
639
|
+
return;
|
|
640
|
+
default:
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function moveSelection(delta) {
|
|
645
|
+
const visibleMessages = applySearch(getCurrentMessages(state), state.searchTerm);
|
|
646
|
+
const nextIndex = clamp(state.selectedMessageIndex + delta, 0, Math.max(visibleMessages.length - 1, 0));
|
|
647
|
+
state.selectedMessageIndex = nextIndex;
|
|
648
|
+
state.status = `Selected message ${nextIndex + 1}/${Math.max(visibleMessages.length, 1)}`;
|
|
649
|
+
}
|
|
650
|
+
function moveRoom(delta) {
|
|
651
|
+
if (state.rooms.length === 0) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const currentIndex = state.rooms.findIndex((room) => room.id === state.currentRoomId);
|
|
655
|
+
const safeCurrent = currentIndex >= 0 ? currentIndex : 0;
|
|
656
|
+
const nextIndex = clamp(safeCurrent + delta, 0, state.rooms.length - 1);
|
|
657
|
+
const nextRoom = state.rooms[nextIndex];
|
|
658
|
+
if (!nextRoom) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
transport.updateRoom(nextRoom.name);
|
|
662
|
+
transport.send("join_room", { room: nextRoom.name });
|
|
663
|
+
state.status = `Switching to #${nextRoom.name}`;
|
|
664
|
+
}
|
|
665
|
+
function shutdown() {
|
|
666
|
+
transport.close();
|
|
667
|
+
screen.destroy();
|
|
668
|
+
process.exit(0);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function reduceEvent(state, event) {
|
|
672
|
+
if (event.direction !== "server") {
|
|
673
|
+
return state;
|
|
674
|
+
}
|
|
675
|
+
switch (event.type) {
|
|
676
|
+
case "welcome": {
|
|
677
|
+
const unreadByRoom = new Map(state.unreadByRoom);
|
|
678
|
+
const rooms = upsertRooms(state.rooms, event.payload.rooms);
|
|
679
|
+
event.payload.rooms.forEach((room) => unreadByRoom.set(room.id, unreadByRoom.get(room.id) ?? 0));
|
|
680
|
+
return {
|
|
681
|
+
...state,
|
|
682
|
+
rooms,
|
|
683
|
+
currentRoomId: event.payload.joinedRoomId,
|
|
684
|
+
sessionId: event.payload.sessionId,
|
|
685
|
+
status: `Connected to ${roomNameForId(rooms, event.payload.joinedRoomId)}`
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
case "room_snapshot": {
|
|
689
|
+
const rooms = upsertRooms(state.rooms, [event.payload.room]);
|
|
690
|
+
const messagesByRoom = new Map(state.messagesByRoom);
|
|
691
|
+
const occupantsByRoom = new Map(state.occupantsByRoom);
|
|
692
|
+
const unreadByRoom = new Map(state.unreadByRoom);
|
|
693
|
+
messagesByRoom.set(event.payload.room.id, event.payload.messages);
|
|
694
|
+
occupantsByRoom.set(event.payload.room.id, event.payload.occupants);
|
|
695
|
+
unreadByRoom.set(event.payload.room.id, 0);
|
|
696
|
+
return {
|
|
697
|
+
...state,
|
|
698
|
+
rooms,
|
|
699
|
+
currentRoomId: event.payload.room.id,
|
|
700
|
+
messagesByRoom,
|
|
701
|
+
occupantsByRoom,
|
|
702
|
+
unreadByRoom,
|
|
703
|
+
selectedMessageIndex: Math.max(event.payload.messages.length - 1, 0),
|
|
704
|
+
status: `Loaded #${event.payload.room.name}`
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
case "message_created": {
|
|
708
|
+
const messagesByRoom = new Map(state.messagesByRoom);
|
|
709
|
+
const existing = messagesByRoom.get(event.payload.roomId) ?? [];
|
|
710
|
+
if (!existing.some((message) => message.id === event.payload.id)) {
|
|
711
|
+
messagesByRoom.set(event.payload.roomId, [...existing, event.payload]);
|
|
712
|
+
}
|
|
713
|
+
const unreadByRoom = new Map(state.unreadByRoom);
|
|
714
|
+
if (event.payload.roomId !== state.currentRoomId) {
|
|
715
|
+
unreadByRoom.set(event.payload.roomId, (unreadByRoom.get(event.payload.roomId) ?? 0) + 1);
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
...state,
|
|
719
|
+
messagesByRoom,
|
|
720
|
+
unreadByRoom,
|
|
721
|
+
selectedMessageIndex: event.payload.roomId === state.currentRoomId ? Math.max((messagesByRoom.get(event.payload.roomId) ?? []).length - 1, 0) : state.selectedMessageIndex,
|
|
722
|
+
status: `New message from ${event.payload.sender}`
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
case "history_page": {
|
|
726
|
+
const messagesByRoom = new Map(state.messagesByRoom);
|
|
727
|
+
const current = messagesByRoom.get(event.payload.roomId) ?? [];
|
|
728
|
+
const merged = dedupeMessages([...event.payload.messages, ...current]);
|
|
729
|
+
messagesByRoom.set(event.payload.roomId, merged);
|
|
730
|
+
return {
|
|
731
|
+
...state,
|
|
732
|
+
messagesByRoom,
|
|
733
|
+
status: event.payload.hasMore ? "Loaded older history" : "Reached start of history"
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
case "ack":
|
|
737
|
+
return {
|
|
738
|
+
...state,
|
|
739
|
+
status: `${event.payload.action} ok`
|
|
740
|
+
};
|
|
741
|
+
case "error":
|
|
742
|
+
return {
|
|
743
|
+
...state,
|
|
744
|
+
status: `Error: ${event.payload.message}`
|
|
745
|
+
};
|
|
746
|
+
case "presence_changed":
|
|
747
|
+
return state;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function buildStatusLine(state, username) {
|
|
751
|
+
const currentRoomName = state.currentRoomId ? roomNameForId(state.rooms, state.currentRoomId) : "none";
|
|
752
|
+
return `${state.mode} | ${username} | room:${currentRoomName} | streak:${state.training.cleanMotionStreak} | arrows:${state.training.arrowViolations} | ${state.status}`;
|
|
753
|
+
}
|
|
754
|
+
function currentInputValue(state) {
|
|
755
|
+
switch (state.mode) {
|
|
756
|
+
case "INSERT":
|
|
757
|
+
return state.composeBuffer || "Compose a message...";
|
|
758
|
+
case "COMMAND":
|
|
759
|
+
return state.commandBuffer || ":";
|
|
760
|
+
case "SEARCH":
|
|
761
|
+
return state.searchBuffer || "/";
|
|
762
|
+
default:
|
|
763
|
+
return "NORMAL mode: i insert, : command, / search, h/j/k/l move";
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function normalizeKey(ch, key) {
|
|
767
|
+
if (key.full) {
|
|
768
|
+
return key.full;
|
|
769
|
+
}
|
|
770
|
+
if (key.ctrl && key.name) {
|
|
771
|
+
return `C-${key.name}`;
|
|
772
|
+
}
|
|
773
|
+
return key.name ?? ch;
|
|
774
|
+
}
|
|
775
|
+
function isPrintable(ch) {
|
|
776
|
+
return Boolean(ch && ch >= " " && ch !== "");
|
|
777
|
+
}
|
|
778
|
+
function getCurrentMessages(state) {
|
|
779
|
+
return state.currentRoomId ? state.messagesByRoom.get(state.currentRoomId) ?? [] : [];
|
|
780
|
+
}
|
|
781
|
+
function dedupeMessages(messages) {
|
|
782
|
+
return [...new Map(messages.map((message) => [message.id, message])).values()].sort((left, right) => left.seq - right.seq);
|
|
783
|
+
}
|
|
784
|
+
function applySearch(messages, searchTerm) {
|
|
785
|
+
if (!searchTerm) {
|
|
786
|
+
return messages;
|
|
787
|
+
}
|
|
788
|
+
return messages.filter((message) => `${message.sender} ${message.body}`.toLowerCase().includes(searchTerm));
|
|
789
|
+
}
|
|
790
|
+
function upsertRooms(existing, incoming) {
|
|
791
|
+
const merged = new Map(existing.map((room) => [room.id, room]));
|
|
792
|
+
incoming.forEach((room) => merged.set(room.id, room));
|
|
793
|
+
return [...merged.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
794
|
+
}
|
|
795
|
+
function roomNameForId(rooms, roomId) {
|
|
796
|
+
return rooms.find((room) => room.id === roomId)?.name ?? roomId;
|
|
797
|
+
}
|
|
798
|
+
function formatStats(state) {
|
|
799
|
+
const now = Date.now();
|
|
800
|
+
const liveDurations = {
|
|
801
|
+
...state.modeDurations,
|
|
802
|
+
[state.mode]: state.modeDurations[state.mode] + (now - state.modeStartedAt)
|
|
803
|
+
};
|
|
804
|
+
const formatMs = (value) => `${Math.round(value / 1000)}s`;
|
|
805
|
+
return `normal:${formatMs(liveDurations.NORMAL)} insert:${formatMs(liveDurations.INSERT)} command:${formatMs(liveDurations.COMMAND)} search:${formatMs(liveDurations.SEARCH)} streak:${state.training.cleanMotionStreak} arrows:${state.training.arrowViolations}`;
|
|
806
|
+
}
|
|
807
|
+
function arrowToMotion(key) {
|
|
808
|
+
switch (key) {
|
|
809
|
+
case "up":
|
|
810
|
+
return "k";
|
|
811
|
+
case "down":
|
|
812
|
+
return "j";
|
|
813
|
+
case "left":
|
|
814
|
+
return "h";
|
|
815
|
+
case "right":
|
|
816
|
+
return "l";
|
|
817
|
+
default:
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function clamp(value, min, max) {
|
|
822
|
+
return Math.min(Math.max(value, min), max);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// apps/cli/src/args.ts
|
|
826
|
+
function parseCliArgs(argv, env = process.env, randomSuffix = () => Math.floor(Math.random() * 1000)) {
|
|
827
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
828
|
+
return { command: "help" };
|
|
829
|
+
}
|
|
830
|
+
if (argv.includes("--version")) {
|
|
831
|
+
return { command: "version" };
|
|
832
|
+
}
|
|
833
|
+
const url = readFlag(argv, "--url") ?? env.KEYSTREAM_URL ?? "ws://127.0.0.1:3088";
|
|
834
|
+
const username = readFlag(argv, "--username") ?? env.KEYSTREAM_USERNAME ?? `guest-${randomSuffix()}`;
|
|
835
|
+
const room = readFlag(argv, "--room") ?? env.KEYSTREAM_ROOM ?? "general";
|
|
836
|
+
const strictVim = argv.includes("--strict-vim") || env.KEYSTREAM_STRICT_VIM === "1";
|
|
837
|
+
return {
|
|
838
|
+
command: "run",
|
|
839
|
+
url,
|
|
840
|
+
username,
|
|
841
|
+
room,
|
|
842
|
+
strictVim
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function formatCliHelp() {
|
|
846
|
+
return [
|
|
847
|
+
"keystream - real-time terminal chat with Vim-style navigation",
|
|
848
|
+
"",
|
|
849
|
+
"Usage:",
|
|
850
|
+
" keystream [--url ws://host:port] [--username name] [--room room] [--strict-vim]",
|
|
851
|
+
" keystream --help",
|
|
852
|
+
" keystream --version",
|
|
853
|
+
"",
|
|
854
|
+
"Options:",
|
|
855
|
+
" --url WebSocket server URL",
|
|
856
|
+
" --username Username to claim on connect",
|
|
857
|
+
" --room Initial room name",
|
|
858
|
+
" --strict-vim Block arrow keys and track violations",
|
|
859
|
+
" -h, --help Show this help",
|
|
860
|
+
" --version Show package version"
|
|
861
|
+
].join(`
|
|
862
|
+
`);
|
|
863
|
+
}
|
|
864
|
+
function readFlag(args, name) {
|
|
865
|
+
const index = args.indexOf(name);
|
|
866
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// apps/cli/src/bin.ts
|
|
870
|
+
var options = parseCliArgs(process.argv.slice(2));
|
|
871
|
+
if (options.command === "help") {
|
|
872
|
+
console.log(formatCliHelp());
|
|
873
|
+
process.exit(0);
|
|
874
|
+
}
|
|
875
|
+
if (options.command === "version") {
|
|
876
|
+
console.log("0.0.1");
|
|
877
|
+
process.exit(0);
|
|
878
|
+
}
|
|
879
|
+
if (options.command === "run") {
|
|
880
|
+
startCliApp(options);
|
|
881
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "keystream-cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Real-time terminal chat with Vim-style navigation training.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./keystream.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"keystream": "./keystream.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"keystream.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"chat",
|
|
17
|
+
"cli",
|
|
18
|
+
"terminal",
|
|
19
|
+
"vim",
|
|
20
|
+
"websocket"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/LihaoWang/keystream.git"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"blessed": "^0.1.81",
|
|
35
|
+
"pino": "^9.9.0",
|
|
36
|
+
"zod": "^4.1.8"
|
|
37
|
+
}
|
|
38
|
+
}
|