anvilsign in

collin/strudel-claude

pre-demo / tui / strudel-tui.py
1#!/usr/bin/env python3
2"""strudel-tui — watch a Strudel×Claude pattern play, and talk to it, from a terminal.
3
4 curl -s http://localhost:8787/tui.py | python3 - localhost:8787
5 # or, to keep a copy you can read and edit:
6 curl -sO http://localhost:8787/tui.py && python3 tui.py localhost:8787
7
8Connects to the app's /ws timing hub and draws three things: the pattern's
9source code with the characters that produced each event flashing as it sounds,
10the chat transcript, and a prompt line. A prompt typed here runs through the
11same code path as the composer in the page, so the browser and the terminal
12share one conversation.
13
14Python 3.8+, standard library only. No pip install, one file, meant to be read:
15the WebSocket client is ~120 lines at the top, the drawing is the rest.
16
17Keys
18 NORMAL q quit · i or Enter to write · j/k scroll code · J/K scroll chat
19 f follow the sounding code · r redraw
20 WRITING Enter send · Esc back to NORMAL · ^U clear · ^W delete word
21"""
22
23import base64
24import hashlib
25import json
26import os
27import queue
28import re
29import select
30import signal
31import socket
32import ssl
33import sys
34import termios
35import textwrap
36import threading
37import time
38import tty
39from urllib.parse import urlparse
40
41# ---------------------------------------------------------------- websocket --
42
43GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
44
45OP_CONT, OP_TEXT, OP_BIN, OP_CLOSE, OP_PING, OP_PONG = 0x0, 0x1, 0x2, 0x8, 0x9, 0xA
46
47
48class WSError(Exception):
49 pass
50
51
52class WebSocket:
53 """A minimal RFC 6455 client: connect, receive text frames, send, close.
54
55 Enough of the protocol to talk to the hub and no more. Pings are answered,
56 because a server that pings will hang up on a client that doesn't. Writes
57 are serialised: the reader thread sends pongs while the input loop sends
58 prompts, and two interleaved frames on one socket is a protocol error.
59 """
60
61 def __init__(self, url, timeout=10):
62 u = urlparse(url)
63 secure = u.scheme == "wss"
64 host = u.hostname or "localhost"
65 port = u.port or (443 if secure else 80)
66 path = u.path or "/"
67 if u.query:
68 path += "?" + u.query
69
70 self._wlock = threading.Lock()
71 self.sock = socket.create_connection((host, port), timeout=timeout)
72 if secure:
73 ctx = ssl.create_default_context()
74 self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
75
76 key = base64.b64encode(os.urandom(16)).decode()
77 hosthdr = host if port in (80, 443) else f"{host}:{port}"
78 req = (
79 f"GET {path} HTTP/1.1\r\n"
80 f"Host: {hosthdr}\r\n"
81 "Upgrade: websocket\r\n"
82 "Connection: Upgrade\r\n"
83 f"Sec-WebSocket-Key: {key}\r\n"
84 "Sec-WebSocket-Version: 13\r\n"
85 "\r\n"
86 )
87 self.sock.sendall(req.encode())
88
89 head = b""
90 while b"\r\n\r\n" not in head:
91 chunk = self.sock.recv(4096)
92 if not chunk:
93 raise WSError("connection closed during handshake")
94 head += chunk
95 if len(head) > 65536:
96 raise WSError("handshake response too large")
97 header_blob, _, rest = head.partition(b"\r\n\r\n")
98 lines = header_blob.decode("latin-1").split("\r\n")
99 if "101" not in lines[0]:
100 raise WSError(f"server refused the upgrade: {lines[0]}")
101 accept = ""
102 for line in lines[1:]:
103 k, _, v = line.partition(":")
104 if k.strip().lower() == "sec-websocket-accept":
105 accept = v.strip()
106 expect = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
107 if accept != expect:
108 raise WSError("bad Sec-WebSocket-Accept")
109
110 self._buf = rest # bytes that arrived glued to the handshake response
111 self.sock.settimeout(None)
112
113 def _read(self, n):
114 while len(self._buf) < n:
115 chunk = self.sock.recv(65536)
116 if not chunk:
117 raise WSError("connection closed")
118 self._buf += chunk
119 out, self._buf = self._buf[:n], self._buf[n:]
120 return out
121
122 def recv(self):
123 """Return the next complete text message, or None for a close frame."""
124 payload = b""
125 opcode = None
126 while True:
127 b0, b1 = self._read(2)
128 fin = b0 & 0x80
129 op = b0 & 0x0F
130 masked = b1 & 0x80
131 length = b1 & 0x7F
132 if length == 126:
133 length = int.from_bytes(self._read(2), "big")
134 elif length == 127:
135 length = int.from_bytes(self._read(8), "big")
136 mask = self._read(4) if masked else None
137 data = self._read(length) if length else b""
138 if mask:
139 data = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
140
141 if op == OP_PING:
142 self._send_frame(OP_PONG, data)
143 continue
144 if op == OP_PONG:
145 continue
146 if op == OP_CLOSE:
147 try:
148 self._send_frame(OP_CLOSE, data[:2])
149 except OSError:
150 pass
151 return None
152
153 if op != OP_CONT:
154 opcode = op
155 payload += data
156 if fin:
157 if opcode == OP_TEXT:
158 return payload.decode("utf-8", "replace")
159 payload, opcode = b"", None # ignore binary, keep reading
160
161 def send_text(self, text):
162 self._send_frame(OP_TEXT, text.encode("utf-8"))
163
164 def _send_frame(self, opcode, data=b""):
165 mask = os.urandom(4)
166 masked = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
167 head = bytes([0x80 | opcode])
168 n = len(data)
169 if n < 126:
170 head += bytes([0x80 | n])
171 elif n < 65536:
172 head += bytes([0x80 | 126]) + n.to_bytes(2, "big")
173 else:
174 head += bytes([0x80 | 127]) + n.to_bytes(8, "big")
175 with self._wlock:
176 self.sock.sendall(head + mask + masked)
177
178 def close(self):
179 try:
180 self._send_frame(OP_CLOSE, b"\x03\xe8")
181 except OSError:
182 pass
183 try:
184 self.sock.close()
185 except OSError:
186 pass
187
188
189# -------------------------------------------------------------------- state --
190
191
192class State:
193 """What we know about the pattern, and where the playhead is *now*.
194
195 The hub sends a window of upcoming events twice a second, not a frame per
196 redraw. So the clock is ours: `cycle()` extrapolates from the last reported
197 position at the reported cycles-per-second, which keeps the animation
198 smooth at whatever framerate we draw at, over any link.
199 """
200
201 def __init__(self):
202 self.lock = threading.Lock()
203 self.code = ""
204 self.lines = [""]
205 self.line_starts = [0]
206 self.haps = [] # [{b, e, l: [[start, end]], s, c}]
207 self.turns = [] # [{role, content}]
208 self.got_chat = False # has the producer ever sent a transcript at all?
209 # Prompts we have sent that the browser hasn't echoed back yet, as
210 # {text, at}. A prompt the page never picks up — an old tab with no
211 # handler for it, most often — would otherwise sit here saying
212 # "sending…" forever, which is the least useful thing it could say.
213 self.pending = []
214 self.notice = None
215 self.playing = False
216 self.cps = 0.5
217 self.connected = False
218 self.source = False # is a browser actually feeding the hub?
219 self.standby = 0 # other tabs open but not the live source
220 self.status = "connecting…"
221 self._base_cycle = 0.0 # cycle position at _base_mono
222 self._base_mono = time.monotonic()
223 self._slots = {} # source range -> colour slot, by first sighting
224
225 def slot(self, rng):
226 """A stable colour slot for a source range, in order of first sighting.
227
228 Stable so a voice keeps its colour cycle to cycle; ordinal so the
229 voices on screen come out a golden angle apart rather than wherever a
230 hash of their offsets happened to land.
231 """
232 key = (rng[0], rng[1])
233 with self.lock: # set_code clears this from the reader thread
234 s = self._slots.get(key)
235 if s is None:
236 s = self._slots[key] = len(self._slots)
237 return s
238
239 def set_code(self, code):
240 with self.lock:
241 if code != self.code:
242 self._slots.clear() # offsets mean something else now
243 self.code = code
244 self.lines = code.split("\n")
245 starts, pos = [], 0
246 for line in self.lines:
247 starts.append(pos)
248 pos += len(line) + 1
249 self.line_starts = starts
250
251 def set_turns(self, turns):
252 with self.lock:
253 self.turns = turns
254 self.got_chat = True
255 # Drop the local echo of anything the browser has now taken up, so
256 # a prompt doesn't appear twice for the half-second in between.
257 said = {t.get("content") for t in turns if t.get("role") == "user"}
258 self.pending = [p for p in self.pending if p["text"] not in said]
259
260 def set_haps(self, now, cps, haps):
261 with self.lock:
262 self.haps = haps
263 self.cps = cps or self.cps
264 mono = time.monotonic()
265 here = self._base_cycle + (mono - self._base_mono) * self.cps
266 drift = now - here
267 if abs(drift) > 0.25:
268 self._base_cycle = now # a jump: eval, restart, seek
269 else:
270 # Nudge toward the truth instead of snapping to it, so ordinary
271 # network jitter doesn't make the playhead stutter backwards.
272 self._base_cycle = here + drift * 0.3
273 self._base_mono = mono
274
275 def cycle(self):
276 with self.lock:
277 if not self.playing:
278 return self._base_cycle
279 return self._base_cycle + (time.monotonic() - self._base_mono) * self.cps
280
281 def snapshot(self):
282 with self.lock:
283 return self.lines, list(self.haps)
284
285 def chat(self):
286 with self.lock:
287 return list(self.turns), list(self.pending), self.got_chat
288
289 def thinking(self):
290 """A user turn with no reply yet means the request is still out."""
291 with self.lock:
292 return bool(self.turns) and self.turns[-1].get("role") == "user"
293
294 def offset_to_rowcol(self, offset):
295 """Character offset in `code` -> (row, col), both 0-based."""
296 lo, hi = 0, len(self.line_starts) - 1
297 while lo < hi:
298 mid = (lo + hi + 1) // 2
299 if self.line_starts[mid] <= offset:
300 lo = mid
301 else:
302 hi = mid - 1
303 return lo, offset - self.line_starts[lo]
304
305
306# -------------------------------------------------------------------- color --
307
308CSS_COLORS = {
309 "white": (255, 255, 255), "black": (0, 0, 0), "red": (255, 0, 0),
310 "green": (0, 128, 0), "lime": (0, 255, 0), "blue": (0, 0, 255),
311 "yellow": (255, 255, 0), "cyan": (0, 255, 255), "aqua": (0, 255, 255),
312 "magenta": (255, 0, 255), "fuchsia": (255, 0, 255), "orange": (255, 165, 0),
313 "purple": (128, 0, 128), "pink": (255, 192, 203), "gray": (128, 128, 128),
314 "grey": (128, 128, 128), "salmon": (250, 128, 114), "gold": (255, 215, 0),
315 "steelblue": (70, 130, 180), "tomato": (255, 99, 71),
316 "turquoise": (64, 224, 208), "violet": (238, 130, 238),
317}
318
319# Hues are handed out by order of appearance, one golden angle apart, which
320# keeps whatever voices are actually on screen as far apart on the wheel as
321# they can be. Hashing the character offset instead looks like it would work
322# and doesn't: source lines land at evenly-spaced offsets, so any hash can
323# happen to drop three voices into the same 20° of the wheel, and a stride
324# whose product with the multiplier is ≈0 mod 360 makes every voice identical.
325GOLDEN_ANGLE = 137.508
326
327# How long a sent prompt may go unacknowledged before the terminal stops
328# claiming it is on its way.
329PROMPT_TIMEOUT_S = 8.0
330
331
332def hsl_to_rgb(h, s, ll):
333 def f(n):
334 k = (n + h / 30.0) % 12
335 a = s * min(ll, 1 - ll)
336 return round(255 * (ll - a * max(-1, min(k - 3, 9 - k, 1))))
337 return f(0), f(8), f(4)
338
339
340def parse_color(spec, fallback_seed=0):
341 """A CSS colour from the pattern if it named one, else a stable hue."""
342 if isinstance(spec, str):
343 s = spec.strip().lower()
344 if s in CSS_COLORS:
345 return CSS_COLORS[s]
346 m = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", s)
347 if m:
348 h = m.group(1)
349 if len(h) == 3:
350 h = "".join(c * 2 for c in h)
351 return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
352 m = re.fullmatch(r"rgba?\(([^)]+)\)", s)
353 if m:
354 parts = [p.strip() for p in m.group(1).replace("/", ",").split(",")]
355 try:
356 return tuple(int(float(p)) for p in parts[:3])
357 except ValueError:
358 pass
359 return hsl_to_rgb(GOLDEN_ANGLE * fallback_seed % 360, 0.72, 0.62)
360
361
362def blend(fg, bg, alpha):
363 return tuple(round(f * alpha + b * (1 - alpha)) for f, b in zip(fg, bg))
364
365
366def luminance(c):
367 return (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255
368
369
370# ------------------------------------------------------------------ drawing --
371
372ESC = "\x1b"
373RESET = f"{ESC}[0m"
374
375
376def fg(c):
377 return f"{ESC}[38;2;{c[0]};{c[1]};{c[2]}m"
378
379
380def bg(c):
381 return f"{ESC}[48;2;{c[0]};{c[1]};{c[2]}m"
382
383
384class Screen:
385 def __init__(self, light=False):
386 self.light = light
387 self.base = (245, 245, 245) if light else (12, 14, 20)
388 self.dim = (120, 124, 138) if light else (108, 114, 132)
389 self.text = (40, 42, 50) if light else (196, 202, 216)
390 self.accent = (0, 150, 125) if light else (120, 230, 200)
391 self.you = (200, 120, 30) if light else (240, 190, 120)
392 self.warn = (190, 90, 40) if light else (235, 150, 100)
393 self.claude = (140, 90, 200) if light else (190, 160, 245)
394 self.rows, self.cols = 24, 80
395 self.measure()
396
397 def measure(self):
398 try:
399 self.cols, self.rows = os.get_terminal_size()
400 except OSError:
401 pass
402
403
404# A row is a list of (text, fg, bg) cells; None means "the screen default".
405# Panes build rows independently and get stitched side by side, so nothing may
406# emit raw escapes until the whole row is known.
407
408def fit(cells, width):
409 """Truncate or pad a row of cells to exactly `width` visible columns."""
410 out, used = [], 0
411 for text, f, b in cells:
412 if used >= width:
413 break
414 t = text[: width - used]
415 if t:
416 out.append((t, f, b))
417 used += len(t)
418 if used < width:
419 out.append((" " * (width - used), None, None))
420 return out
421
422
423def paint(cells, screen):
424 parts, cur_f, cur_b = [], None, None
425 for text, f, b in cells:
426 f = f or screen.text
427 b = b or screen.base
428 if b != cur_b:
429 parts.append(bg(b))
430 cur_b = b
431 if f != cur_f:
432 parts.append(fg(f))
433 cur_f = f
434 parts.append(text)
435 return "".join(parts) + f"{ESC}[K" + RESET
436
437
438def hap_style(hap, now, screen, seed):
439 """Colour + intensity for an event at the current instant.
440
441 Brightest on the attack and fading across the event's length, so a long
442 note reads as decaying rather than as a static block — the same cue the
443 browser gives, and the reason a screenshot of this looks like music.
444 """
445 b, e = hap["b"], hap["e"]
446 dur = max(e - b, 1e-6)
447 p = min(max((now - b) / dur, 0.0), 1.0)
448 color = parse_color(hap.get("c"), seed)
449 return blend(color, screen.base, 0.92 - 0.62 * p), color
450
451
452def code_pane(state, screen, lines, spans, scroll, width, height):
453 gutter = min(6, max(3, len(str(len(lines))) + 1))
454 rows = []
455 for i in range(height):
456 row = scroll + i
457 if row >= len(lines):
458 rows.append(fit([], width))
459 continue
460 lit = spans.get(row, [])
461 num = str(row + 1).rjust(gutter - 1)
462 cells = [(num + " ", screen.accent if lit else screen.dim, None)]
463 text = lines[row][: max(0, width - gutter)]
464 if not lit:
465 cells.append((text, screen.text, None))
466 else:
467 # Paint left to right, switching background at each span boundary.
468 col = 0
469 for c0, c1, bgc in sorted(lit, key=lambda s: s[0]):
470 c0, c1 = max(c0, col), min(c1, len(text))
471 if c1 <= c0:
472 continue
473 cells.append((text[col:c0], screen.text, None))
474 ink = (18, 18, 22) if luminance(bgc) > 0.55 else (250, 250, 252)
475 cells.append((text[c0:c1], ink, bgc))
476 col = c1
477 cells.append((text[col:], screen.text, None))
478 rows.append(fit(cells, width))
479 return rows
480
481
482def chat_lines(state, screen, width):
483 """The transcript as flat rows, wrapped to the pane."""
484 turns, pending, got_chat = state.chat()
485 body_w = max(8, width - 2)
486 out = []
487
488 # An empty pane is ambiguous — a conversation that hasn't started looks
489 # exactly like a page too old to send one. Say which. Falls through so a
490 # prompt sent before the first transcript arrives still shows as pending.
491 if not got_chat:
492 if state.source:
493 msg = ["the browser hasn't sent a transcript.",
494 "if the page is open, reload it — a tab",
495 "loaded before this feature existed only",
496 "sends the pattern, not the chat."]
497 else:
498 msg = ["no browser connected."]
499 out.append([("✦ no transcript", screen.dim, None)])
500 out.extend([(" " + line, screen.dim, None)] for line in msg)
501 out.append([])
502 elif not turns:
503 out.append([("✦ no messages yet — press i to write one", screen.dim, None)])
504 out.append([])
505 for turn in turns:
506 role = turn.get("role")
507 who, color = ("you", screen.you) if role == "user" else ("claude", screen.claude)
508 out.append([(f"{'›' if role == 'user' else '✦'} {who}", color, None)])
509 for para in (turn.get("content") or "").split("\n"):
510 for line in textwrap.wrap(para, body_w) or [""]:
511 out.append([(" " + line, screen.text, None)])
512 out.append([])
513 for item in pending:
514 # The browser echoes the transcript back within ~500ms of accepting a
515 # prompt, so silence well past that means it never arrived.
516 stale = time.monotonic() - item["at"] > PROMPT_TIMEOUT_S
517 color = screen.warn if stale else screen.dim
518 out.append([("› you", color, None)])
519 for line in textwrap.wrap(item["text"], body_w) or [""]:
520 out.append([(" " + line, color, None)])
521 if stale:
522 out.append([(" not picked up by the browser — reload", screen.warn, None)])
523 out.append([(" the page if it was open before you", screen.warn, None)])
524 out.append([(" updated the app.", screen.warn, None)])
525 else:
526 out.append([(" sending…", screen.dim, None)])
527 if state.thinking():
528 # A user turn with no reply yet: the request is still in flight.
529 dots = "." * (1 + int(time.monotonic() * 2) % 3)
530 out.append([("✦ claude is thinking" + dots, screen.claude, None)])
531 return out
532
533
534def render(state, screen, ui):
535 lines, haps = state.snapshot()
536 now = state.cycle()
537 width, height = screen.cols, screen.rows
538
539 # Which characters are sounding, and in what colour.
540 spans, active = {}, []
541 for hap in haps:
542 if not (hap["b"] <= now < hap["e"]):
543 continue
544 for rng in hap.get("l", []):
545 bgc, pure = hap_style(hap, now, screen, state.slot(rng))
546 r0, c0 = state.offset_to_rowcol(rng[0])
547 r1, c1 = state.offset_to_rowcol(rng[1])
548 if r0 != r1: # a range spanning lines: clip to its first line
549 c1 = len(lines[r0]) if r0 < len(lines) else c0
550 spans.setdefault(r0, []).append((c0, c1, bgc))
551 if hap.get("s"):
552 active.append((pure, hap["s"]))
553
554 # Layout. Two panes side by side when there is room for both to be
555 # readable, stacked when there isn't; the chat is never dropped, because
556 # you cannot answer a question you cannot see.
557 body_h = max(1, height - 4)
558 side_by_side = width >= 100
559 if side_by_side:
560 code_w = max(46, int(width * 0.58))
561 chat_w = max(20, width - 1 - code_w - 1)
562 code_h = chat_h = body_h
563 else:
564 code_w = chat_w = width - 1
565 chat_h = max(3, min(8, body_h // 3))
566 code_h = max(1, body_h - chat_h - 1)
567
568 if ui.follow and spans:
569 # Scroll only when nothing that is sounding is on screen at all.
570 #
571 # The obvious rule — keep the whole active range in view — thrashes: a
572 # pattern whose voices span more lines than the pane cannot satisfy it,
573 # so scrolling to the top pushes the bottom out, which scrolls back,
574 # thirty times a second. Whether *something* audible is visible is a
575 # condition the view can actually settle on.
576 if not any(ui.scroll <= r < ui.scroll + code_h for r in spans):
577 ui.scroll = max(0, min(min(spans) - 1, max(0, len(lines) - code_h)))
578 ui.scroll = max(0, min(ui.scroll, max(0, len(lines) - 1)))
579
580 code_rows = code_pane(state, screen, lines, spans, ui.scroll, code_w, code_h)
581
582 chat_all = chat_lines(state, screen, chat_w)
583 # Anchored to the bottom: the newest turn is the one you want, and
584 # chat_scroll counts lines back from there.
585 ui.chat_scroll = max(0, min(ui.chat_scroll, max(0, len(chat_all) - chat_h)))
586 end = len(chat_all) - ui.chat_scroll
587 window = chat_all[max(0, end - chat_h):end]
588 chat_rows = [fit(r, chat_w) for r in window]
589 chat_rows += [fit([], chat_w)] * (chat_h - len(chat_rows))
590
591 out = []
592
593 # --- header
594 if not state.connected:
595 dot, word, dotc = "●", state.status, (220, 90, 90)
596 elif not state.source:
597 dot, word, dotc = "○", "no browser connected", (200, 170, 80)
598 elif state.playing:
599 dot, word, dotc = "●", "playing", (110, 220, 140)
600 else:
601 dot, word, dotc = "○", "stopped", (150, 155, 170)
602 # Another tab being open explains why the terminal isn't following the one
603 # you're looking at, so say so rather than leaving it a mystery.
604 if state.standby:
605 word += f" (+{state.standby} idle tab{'s' if state.standby > 1 else ''})"
606 bpm = state.cps * 60 * 4 # a cycle is conventionally a bar of 4 beats
607 right = f"cycle {now:8.3f} {state.cps:.3f} cps {bpm:.0f} bpm "
608 head = [(f" {dot} {word}", dotc, None)]
609 pad = max(1, width - 1 - len(f" {dot} {word}") - len(right))
610 head.append((" " * pad + right, screen.dim, None))
611 # Every row is positioned explicitly. Letting a full-width line wrap on its
612 # own is not portable — terminals defer the wrap, so the next write lands
613 # in the wrong place on some of them.
614 out.append(f"{ESC}[1;1H" + paint(fit(head, width - 1), screen))
615 out.append(f"{ESC}[2;1H" + paint(fit([("─" * (width - 1), screen.dim, None)], width - 1), screen))
616
617 # --- body
618 for i in range(body_h):
619 row = f"{ESC}[{i + 3};1H"
620 if side_by_side:
621 cells = code_rows[i] + [("│", screen.dim, None)] + chat_rows[i]
622 elif i < code_h:
623 cells = code_rows[i]
624 elif i == code_h:
625 cells = [("─" * (width - 1), screen.dim, None)]
626 else:
627 cells = chat_rows[i - code_h - 1]
628 out.append(row + paint(fit(cells, width - 1), screen))
629
630 # --- timeline: one cycle wide, with a marker for the playhead and ticks
631 # where the upcoming events land. This is the "moving cursor".
632 bar_w = max(10, width - 2)
633 cycle_start = int(now // 1)
634 ticks = [None] * bar_w
635 for hap in haps:
636 if cycle_start <= hap["b"] < cycle_start + 1:
637 idx = int((hap["b"] - cycle_start) * bar_w)
638 if 0 <= idx < bar_w and ticks[idx] is None:
639 ticks[idx] = parse_color(hap.get("c"), state.slot(hap.get("l", [[0, 0]])[0]))
640 head_i = int((now - cycle_start) * bar_w)
641 bar = [(" ", None, None)]
642 for i, c in enumerate(ticks):
643 if i == head_i:
644 bar.append(("┃", (255, 255, 255), None))
645 elif c:
646 bar.append(("╷", blend(c, screen.base, 0.75), None))
647 else:
648 bar.append(("·", blend(screen.dim, screen.base, 0.35), None))
649 out.append(f"{ESC}[{height - 1};1H" + paint(fit(bar, width - 1), screen))
650
651 # --- last row: the prompt when writing, otherwise what is sounding
652 if ui.mode == "input":
653 # Scroll the field so the caret stays visible on a long prompt.
654 field_w = max(10, width - 4)
655 shown = ui.buf[-field_w:]
656 line = [("› ", screen.accent, None), (shown, screen.text, None), (" ", screen.base, screen.text)]
657 else:
658 seen, line, used = set(), [(" ", None, None)], 1
659 for color, label in active:
660 if label in seen:
661 continue
662 seen.add(label)
663 if used + len(label) + 1 > width - 34:
664 break
665 line.append((label + " ", blend(color, screen.base, 0.95), None))
666 used += len(label) + 1
667 hint = ui.note or "q quit · i write · j/k scroll · f follow"
668 line.append((" " * max(1, width - 1 - used - len(hint)) + hint, screen.dim, None))
669 out.append(f"{ESC}[{height};1H" + paint(fit(line, width - 1), screen))
670
671 sys.stdout.write("".join(out))
672 sys.stdout.flush()
673
674
675# ----------------------------------------------------------------- plumbing --
676
677
678class UI:
679 """Everything the drawing needs that isn't the pattern itself."""
680
681 def __init__(self):
682 self.scroll = 0
683 self.chat_scroll = 0
684 self.follow = True
685 self.mode = "normal" # or "input"
686 self.buf = ""
687 self.note = None
688
689
690def reader_thread(url, state, conn):
691 """Own the socket: connect, parse, and keep reconnecting if the app goes away."""
692 backoff = 0.5
693 while True:
694 ws = None
695 try:
696 ws = WebSocket(url)
697 conn["ws"] = ws
698 with state.lock:
699 state.connected = True
700 state.status = "connected"
701 backoff = 0.5
702 while True:
703 msg = ws.recv()
704 if msg is None:
705 break
706 try:
707 data = json.loads(msg)
708 except ValueError:
709 continue
710 kind = data.get("type")
711 if kind == "code":
712 state.set_code(data.get("code", ""))
713 elif kind == "haps":
714 state.set_haps(data.get("now", 0.0), data.get("cps", 0.5), data.get("haps", []))
715 elif kind == "chat":
716 state.set_turns(data.get("turns", []))
717 elif kind == "transport":
718 with state.lock:
719 state.playing = bool(data.get("playing"))
720 state.cps = data.get("cps") or state.cps
721 elif kind == "notice":
722 with state.lock:
723 state.notice = data.get("text")
724 elif kind == "hello":
725 with state.lock:
726 state.source = bool(data.get("source"))
727 state.standby = int(data.get("standby") or 0)
728 elif kind == "source":
729 with state.lock:
730 state.source = bool(data.get("connected"))
731 state.standby = int(data.get("standby") or 0)
732 if not state.source:
733 state.playing = False
734 except (WSError, OSError, ssl.SSLError) as err:
735 with state.lock:
736 state.connected = False
737 state.source = False
738 state.playing = False
739 state.status = f"reconnecting… ({err.__class__.__name__})"
740 finally:
741 conn["ws"] = None
742 if ws:
743 ws.close()
744 time.sleep(backoff)
745 backoff = min(backoff * 2, 5.0)
746
747
748def send_prompt(state, conn, text):
749 ws = conn.get("ws")
750 if not ws:
751 return "not connected — prompt not sent"
752 try:
753 ws.send_text(json.dumps({"type": "prompt", "text": text}))
754 except OSError:
755 return "send failed — prompt not sent"
756 with state.lock:
757 state.pending.append({"text": text, "at": time.monotonic()})
758 return None
759
760
761def handle_key(ch, state, conn, ui, screen):
762 """Returns False to quit."""
763 ui.note = None
764 if ch in ("\x03",): # Ctrl+C, in either mode
765 return False
766
767 if ui.mode == "input":
768 if ch == ESC:
769 ui.mode, ui.buf = "normal", ""
770 elif ch in ("\r", "\n"):
771 text = ui.buf.strip()
772 ui.buf = ""
773 ui.mode = "normal"
774 if text:
775 ui.note = send_prompt(state, conn, text) or "sent"
776 elif ch in ("\x7f", "\b"):
777 ui.buf = ui.buf[:-1]
778 elif ch == "\x15": # Ctrl+U
779 ui.buf = ""
780 elif ch == "\x17": # Ctrl+W — delete the word before the caret
781 stripped = ui.buf.rstrip()
782 cut = stripped.rfind(" ")
783 ui.buf = stripped[: cut + 1] if cut >= 0 else ""
784 elif not ch.startswith(ESC):
785 ui.buf += "".join(c for c in ch if c.isprintable())
786 return True
787
788 body_h = max(1, screen.rows - 4)
789 if ch == "q":
790 return False
791 elif ch in ("i", "\r", "\n"):
792 ui.mode = "input"
793 elif ch in ("j", f"{ESC}[B"):
794 ui.scroll += 1
795 ui.follow = False
796 elif ch in ("k", f"{ESC}[A"):
797 ui.scroll -= 1
798 ui.follow = False
799 elif ch == " ":
800 ui.scroll += body_h
801 ui.follow = False
802 elif ch == "J":
803 ui.chat_scroll = max(0, ui.chat_scroll - 1)
804 elif ch == "K":
805 ui.chat_scroll += 1
806 elif ch == "f":
807 ui.follow = not ui.follow
808 ui.note = "follow on" if ui.follow else "follow off"
809 elif ch == "r":
810 screen.measure()
811 sys.stdout.write(f"{ESC}[2J")
812 return True
813
814
815def normalize_url(arg):
816 """Accept localhost:8787, http(s)://host, ws(s)://host/ws — all mean the hub."""
817 if not arg:
818 arg = "localhost:8787"
819 if "://" not in arg:
820 arg = "ws://" + arg
821 u = urlparse(arg)
822 scheme = {"http": "ws", "https": "wss"}.get(u.scheme, u.scheme)
823 if scheme not in ("ws", "wss"):
824 raise SystemExit(f"don't know how to connect to {arg!r}")
825 path = u.path if u.path not in ("", "/") else "/ws"
826 netloc = u.netloc or "localhost:8787"
827 return f"{scheme}://{netloc}{path}"
828
829
830def main(argv):
831 args = [a for a in argv[1:] if not a.startswith("-")]
832 flags = {a for a in argv[1:] if a.startswith("-")}
833 if flags & {"-h", "--help"}:
834 print(__doc__)
835 return 0
836 url = normalize_url(args[0] if args else os.environ.get("STRUDEL_WS", ""))
837 fps = 30.0
838
839 state = State()
840 conn = {"ws": None}
841 threading.Thread(target=reader_thread, args=(url, state, conn), daemon=True).start()
842
843 screen = Screen(light="--light" in flags)
844 ui = UI()
845
846 resized = threading.Event()
847 signal.signal(signal.SIGWINCH, lambda *_: resized.set())
848
849 # `curl … | python3 -` feeds the *script* in on stdin, so the keyboard has
850 # to come from the terminal directly or nothing would be typeable in the
851 # one command this file advertises.
852 keys = sys.stdin
853 if not keys.isatty():
854 try:
855 keys = open("/dev/tty")
856 except OSError:
857 pass
858
859 fd = keys.fileno()
860 isatty = keys.isatty()
861 old = termios.tcgetattr(fd) if isatty else None
862 try:
863 if isatty:
864 tty.setcbreak(fd)
865 sys.stdout.write(f"{ESC}[?1049h{ESC}[?25l{ESC}[2J") # alt screen, no cursor
866 while True:
867 if resized.is_set():
868 resized.clear()
869 screen.measure()
870 sys.stdout.write(f"{ESC}[2J")
871
872 with state.lock:
873 if state.notice:
874 ui.note, state.notice = state.notice, None
875
876 render(state, screen, ui)
877
878 if isatty and select.select([fd], [], [], 1.0 / fps)[0]:
879 ch = os.read(fd, 64).decode("utf-8", "replace")
880 if not handle_key(ch, state, conn, ui, screen):
881 break
882 elif not isatty:
883 # Piped rather than interactive (a recording, a demo): there is
884 # no keyboard to wait on, so pace the loop ourselves.
885 time.sleep(1.0 / fps)
886 except KeyboardInterrupt:
887 pass
888 finally:
889 if isatty and old:
890 termios.tcsetattr(fd, termios.TCSADRAIN, old)
891 sys.stdout.write(f"{ESC}[?25h{ESC}[?1049l")
892 sys.stdout.flush()
893 return 0
894
895
896if __name__ == "__main__":
897 sys.exit(main(sys.argv))