@three333/termbuddy 0.1.3 → 0.1.5

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.
@@ -7,7 +7,9 @@ import {
7
7
  CountdownClockSprite,
8
8
  ProjectileThrowSprite,
9
9
  StatusHeader,
10
+ InfoPanel,
10
11
  } from "../components/index.js";
12
+ import type { InfoRecord } from "../components/index.js";
11
13
  import type { CountdownClockType } from "../components/sprite/CountdownClockSprite.js";
12
14
  import type {
13
15
  ProjectileDirection,
@@ -60,12 +62,23 @@ export function Session(
60
62
  direction: ProjectileDirection;
61
63
  }>
62
64
  >([]);
63
- const [localBubble, setLocalBubble] = useState<string | null>(null);
64
- const [buddyBubble, setBuddyBubble] = useState<string | null>(null);
65
- const bubbleTimersRef = useRef<{
66
- local: NodeJS.Timeout | null;
67
- buddy: NodeJS.Timeout | null;
68
- }>({ local: null, buddy: null });
65
+ // Bubbles for all 4 users: index 0 = local (No.1), 1-3 = peers (No.2-4)
66
+ const [peerBubbles, setPeerBubbles] = useState<(string | null)[]>([null, null, null, null]);
67
+ const bubbleTimersRef = useRef<(NodeJS.Timeout | null)[]>([null, null, null, null]);
68
+
69
+ // Info records for logging events
70
+ const [infoRecords, setInfoRecords] = useState<InfoRecord[]>([]);
71
+ const nextRecordIdRef = useRef<number>(1);
72
+
73
+ const addInfoRecord = useCallback((type: InfoRecord["type"], content: string) => {
74
+ const record: InfoRecord = {
75
+ id: nextRecordIdRef.current++,
76
+ timestamp: Date.now(),
77
+ type,
78
+ content,
79
+ };
80
+ setInfoRecords(prev => [...prev, record]);
81
+ }, []);
69
82
 
70
83
  const tcpOptions = useMemo(() => {
71
84
  return props.role === "host"
@@ -105,6 +118,30 @@ export function Session(
105
118
  // Use peers array from TCP sync for multi-peer support
106
119
  const peers = tcp.peers;
107
120
  const firstPeerName = peers.length > 0 ? peers[0].name : undefined;
121
+ const prevPeersRef = useRef<typeof peers>([]);
122
+
123
+ // Track peer joins and leaves
124
+ useEffect(() => {
125
+ const prevPeers = prevPeersRef.current;
126
+ const prevNames = new Set(prevPeers.map(p => p.name));
127
+ const currentNames = new Set(peers.map(p => p.name));
128
+
129
+ // Check for new peers
130
+ peers.forEach(p => {
131
+ if (!prevNames.has(p.name)) {
132
+ addInfoRecord("join", `${p.name} joined`);
133
+ }
134
+ });
135
+
136
+ // Check for left peers
137
+ prevPeers.forEach(p => {
138
+ if (!currentNames.has(p.name)) {
139
+ addInfoRecord("leave", `${p.name} left`);
140
+ }
141
+ });
142
+
143
+ prevPeersRef.current = [...peers];
144
+ }, [peers, addInfoRecord]);
108
145
 
109
146
  const onToggleAi = useCallback(() => setShowAi((v) => !v), []);
110
147
  const onCloseAi = useCallback(() => setShowAi(false), []);
@@ -195,7 +232,8 @@ export function Session(
195
232
  remainingSeconds: totalSeconds,
196
233
  type,
197
234
  });
198
- }, []);
235
+ addInfoRecord("countdown", `Started ${minutes}min countdown`);
236
+ }, [addInfoRecord]);
199
237
 
200
238
  const nextShotIdRef = useRef<number>(1);
201
239
  const shotQueueRef = useRef<
@@ -221,37 +259,72 @@ export function Session(
221
259
  shotQueueRef.current.push({ kind, direction });
222
260
  pumpShotQueue();
223
261
  if (tcp.status === "connected") tcp.sendProjectile(kind, direction);
262
+ addInfoRecord("projectile", `Threw ${kind}`);
224
263
  },
225
- [pumpShotQueue, tcp]
264
+ [pumpShotQueue, tcp, addInfoRecord]
226
265
  );
227
266
 
228
- const showBubble = useCallback(
229
- (args: { text: string; target: "local" | "buddy"; durationMs: number }) => {
230
- const text = args.text.trim();
231
- if (!text) return;
232
- const durationMs = Math.max(300, Math.min(15_000, Math.floor(args.durationMs)));
233
-
234
- const setBubble = args.target === "buddy" ? setBuddyBubble : setLocalBubble;
235
- setBubble(text);
267
+ // Helper to show bubble at specific index
268
+ const showBubbleAtIndex = useCallback(
269
+ (index: number, text: string, durationMs: number) => {
270
+ setPeerBubbles(prev => {
271
+ const next = [...prev];
272
+ next[index] = text;
273
+ return next;
274
+ });
236
275
 
237
- const prevTimer =
238
- args.target === "buddy"
239
- ? bubbleTimersRef.current.buddy
240
- : bubbleTimersRef.current.local;
276
+ const prevTimer = bubbleTimersRef.current[index];
241
277
  if (prevTimer) clearTimeout(prevTimer);
242
278
 
243
279
  const handle = setTimeout(() => {
244
- setBubble(null);
245
- if (args.target === "buddy") bubbleTimersRef.current.buddy = null;
246
- else bubbleTimersRef.current.local = null;
280
+ setPeerBubbles(prev => {
281
+ const next = [...prev];
282
+ next[index] = null;
283
+ return next;
284
+ });
285
+ bubbleTimersRef.current[index] = null;
247
286
  }, durationMs);
248
287
 
249
- if (args.target === "buddy") bubbleTimersRef.current.buddy = handle;
250
- else bubbleTimersRef.current.local = handle;
288
+ bubbleTimersRef.current[index] = handle;
251
289
  },
252
290
  []
253
291
  );
254
292
 
293
+ // Called by AI tool - bubble appears on LOCAL user (speaker), not target
294
+ const showBubble = useCallback(
295
+ (args: { text: string; target: number; durationMs: number }) => {
296
+ const text = args.text.trim();
297
+ if (!text) return;
298
+ const durationMs = Math.max(300, Math.min(15_000, Math.floor(args.durationMs)));
299
+
300
+ // Bubble appears on LOCAL user (index 0) - they are the one speaking
301
+ showBubbleAtIndex(0, text, durationMs);
302
+
303
+ // Send bubble to other peers via TCP
304
+ if (tcp.status === "connected") {
305
+ tcp.sendBubble(text, durationMs);
306
+ }
307
+
308
+ addInfoRecord("bubble", `To No.${args.target}: "${text}"`);
309
+ },
310
+ [showBubbleAtIndex, tcp, addInfoRecord]
311
+ );
312
+
313
+ // Handle remote bubbles - find sender's position and show bubble there
314
+ useEffect(() => {
315
+ const handleRemoteBubble = (text: string, durationMs: number, senderName: string) => {
316
+ // Find the sender's index in peers array (peers are at index 1-3)
317
+ const peerIndex = peers.findIndex(p => p.name === senderName);
318
+ if (peerIndex !== -1) {
319
+ // peerIndex 0 -> bubbleIndex 1, peerIndex 1 -> bubbleIndex 2, etc.
320
+ showBubbleAtIndex(peerIndex + 1, text, durationMs);
321
+ addInfoRecord("bubble", `From ${senderName}: "${text}"`);
322
+ }
323
+ };
324
+
325
+ tcp.setOnRemoteBubble(handleRemoteBubble);
326
+ }, [tcp, peers, showBubbleAtIndex, addInfoRecord]);
327
+
255
328
  useInput(
256
329
  (input, key) => {
257
330
  if (input === "q") finishAndLeave();
@@ -309,8 +382,9 @@ export function Session(
309
382
 
310
383
  useEffect(() => {
311
384
  return () => {
312
- if (bubbleTimersRef.current.local) clearTimeout(bubbleTimersRef.current.local);
313
- if (bubbleTimersRef.current.buddy) clearTimeout(bubbleTimersRef.current.buddy);
385
+ bubbleTimersRef.current.forEach(timer => {
386
+ if (timer) clearTimeout(timer);
387
+ });
314
388
  };
315
389
  }, []);
316
390
 
@@ -324,18 +398,29 @@ export function Session(
324
398
  peerCount={peers.length}
325
399
  />
326
400
 
327
- {/* Main Stage: 3 Columns */}
328
- <Box
329
- flexDirection="row"
330
- alignItems="center"
331
- justifyContent="space-between"
332
- marginTop={1}
333
- gap={2}
334
- >
335
- {/* Left: Local User */}
336
- <Box flexDirection="column" alignItems="center" minWidth={20}>
337
- {countdown ? (
338
- <Box flexDirection="column" alignItems="center" marginBottom={0}>
401
+ {/* Main Stage: 2x2 Grid Layout for 4 Users */}
402
+ <Box flexDirection="column" marginTop={1}>
403
+ {/* Projectile Area at Top */}
404
+ <Box flexDirection="column" width="100%" alignItems="center" marginBottom={1}>
405
+ {shots.map((s) => (
406
+ <ProjectileThrowSprite
407
+ key={String(s.id)}
408
+ kind={s.kind}
409
+ direction={s.direction}
410
+ shotId={s.id}
411
+ width={50}
412
+ onDone={() =>
413
+ setShots((prev) => prev.filter((x) => x.id !== s.id))
414
+ }
415
+ />
416
+ ))}
417
+ {shots.length === 0 ? <Box height={1} /> : null}
418
+ </Box>
419
+
420
+ {/* Countdown Timer (shared) */}
421
+ {countdown ? (
422
+ <Box justifyContent="center" marginBottom={1}>
423
+ <Box flexDirection="column" alignItems="center">
339
424
  <Text color="gray">{formatMMSS(countdown.remainingSeconds)}</Text>
340
425
  <CountdownClockSprite
341
426
  variant="COMPACT"
@@ -346,60 +431,67 @@ export function Session(
346
431
  showLabel={false}
347
432
  />
348
433
  </Box>
349
- ) : (
350
- <Box height={4} />
351
- )}
352
- <BuddyAvatar state={localState} marginTop={0} bubbleText={localBubble} />
353
- <Text color="cyan">{localLabel}</Text>
354
- </Box>
434
+ </Box>
435
+ ) : null}
436
+
437
+ {/* 2x2 Grid of Users */}
438
+ <Box flexDirection="column" alignItems="center">
439
+ {/* Row 1: User 1 (Local) and User 2 */}
440
+ <Box flexDirection="row" justifyContent="center" gap={4}>
441
+ {/* No.1 - Local User */}
442
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
443
+ <Text color="cyan" bold>No.1 {props.localName}</Text>
444
+ <BuddyAvatar state={localState} marginTop={0} bubbleText={peerBubbles[0] ?? null} />
445
+ </Box>
355
446
 
356
- {/* Center: Stage (Projectiles only) */}
357
- <Box
358
- flexDirection="column"
359
- alignItems="center"
360
- flexGrow={1}
361
- minWidth={40}
362
- >
363
- {/* Projectile Area */}
364
- <Box flexDirection="column" width="100%" alignItems="center">
365
- {shots.map((s) => (
366
- <ProjectileThrowSprite
367
- key={String(s.id)}
368
- kind={s.kind}
369
- direction={s.direction}
370
- shotId={s.id}
371
- width={36}
372
- onDone={() =>
373
- setShots((prev) => prev.filter((x) => x.id !== s.id))
374
- }
375
- />
376
- ))}
377
- {/* Spacer to maintain layout stability when shots appear/disappear */}
378
- {shots.length === 0 ? <Box height={1} /> : null}
447
+ {/* No.2 - First Peer */}
448
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
449
+ {peers.length >= 1 ? (
450
+ <>
451
+ <Text color="magenta" bold>No.2 {peers[0].name}</Text>
452
+ <BuddyAvatar state={peers[0].state} marginTop={0} bubbleText={peerBubbles[1] ?? null} />
453
+ </>
454
+ ) : (
455
+ <>
456
+ <Text color="gray">No.2 (Empty)</Text>
457
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
458
+ </>
459
+ )}
460
+ </Box>
379
461
  </Box>
380
- </Box>
381
462
 
382
- {/* Right: Remote Users */}
383
- <Box flexDirection="column" alignItems="center" minWidth={20}>
384
- {peers.length === 0 ? (
385
- <>
386
- <Box height={4} />
387
- <BuddyAvatar state="OFFLINE" marginTop={0} bubbleText={buddyBubble} />
388
- <Text color="gray">Waiting...</Text>
389
- </>
390
- ) : (
391
- <Box flexDirection="row" gap={2} flexWrap="wrap" justifyContent="center">
392
- {peers.slice(0, 4).map((peer) => (
393
- <Box key={peer.id} flexDirection="column" alignItems="center">
394
- <BuddyAvatar state={peer.state} marginTop={0} bubbleText={buddyBubble} />
395
- <Text color="magenta">{peer.name}</Text>
396
- </Box>
397
- ))}
398
- {peers.length > 4 && (
399
- <Text color="gray">+{peers.length - 4} more</Text>
463
+ {/* Row 2: User 3 and User 4 */}
464
+ <Box flexDirection="row" justifyContent="center" gap={4} marginTop={1}>
465
+ {/* No.3 - Second Peer */}
466
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
467
+ {peers.length >= 2 ? (
468
+ <>
469
+ <Text color="magenta" bold>No.3 {peers[1].name}</Text>
470
+ <BuddyAvatar state={peers[1].state} marginTop={0} bubbleText={peerBubbles[2] ?? null} />
471
+ </>
472
+ ) : (
473
+ <>
474
+ <Text color="gray">No.3 (Empty)</Text>
475
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
476
+ </>
477
+ )}
478
+ </Box>
479
+
480
+ {/* No.4 - Third Peer */}
481
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
482
+ {peers.length >= 3 ? (
483
+ <>
484
+ <Text color="magenta" bold>No.4 {peers[2].name}</Text>
485
+ <BuddyAvatar state={peers[2].state} marginTop={0} bubbleText={peerBubbles[3] ?? null} />
486
+ </>
487
+ ) : (
488
+ <>
489
+ <Text color="gray">No.4 (Empty)</Text>
490
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
491
+ </>
400
492
  )}
401
493
  </Box>
402
- )}
494
+ </Box>
403
495
  </Box>
404
496
  </Box>
405
497
 
@@ -421,15 +513,16 @@ export function Session(
421
513
  </Box>
422
514
  ) : null}
423
515
 
424
- {/* AI Console Overlay */}
516
+ {/* AI Console and Info Panel (side by side) */}
425
517
  {showAi ? (
426
518
  <Box
427
519
  marginTop={1}
428
520
  width="100%"
429
521
  flexDirection="row"
430
522
  justifyContent="center"
523
+ gap={2}
431
524
  >
432
- <Box width={64}>
525
+ <Box width={48}>
433
526
  <AiConsole
434
527
  onClose={onCloseAi}
435
528
  onStartCountdown={startCountdown}
@@ -441,8 +534,12 @@ export function Session(
441
534
  (props.role === "client" ? props.hostName : undefined) ??
442
535
  "Buddy"
443
536
  }
537
+ peers={peers}
444
538
  />
445
539
  </Box>
540
+ <Box width={32}>
541
+ <InfoPanel records={infoRecords} maxRecords={6} />
542
+ </Box>
446
543
  </Box>
447
544
  ) : null}
448
545
  </Box>
package/src/protocol.ts CHANGED
@@ -18,6 +18,7 @@ export type TcpPacket =
18
18
  | {type: 'ping'; sentAt: number}
19
19
  | {type: 'pong'; sentAt: number}
20
20
  | {type: 'projectile'; kind: ProjectileKind; direction: ProjectileDirection; senderName?: string; sentAt: number}
21
+ | {type: 'bubble'; text: string; durationMs: number; senderName: string; sentAt: number}
21
22
  | {type: 'peer_joined'; peerName: string; sentAt: number}
22
23
  | {type: 'peer_left'; peerName: string; sentAt: number};
23
24