@three333/termbuddy 0.1.1 → 0.1.2

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.
@@ -60,6 +60,12 @@ export function Session(
60
60
  direction: ProjectileDirection;
61
61
  }>
62
62
  >([]);
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 });
63
69
 
64
70
  const tcpOptions = useMemo(() => {
65
71
  return props.role === "host"
@@ -96,7 +102,20 @@ export function Session(
96
102
 
97
103
  const localActivity = useActivityMonitor();
98
104
 
99
- const remoteActivity: ActivityState = tcp.remoteState ?? "OFFLINE";
105
+ // Fix: Derive peers from single peerName until multi-peer is supported
106
+ const peers = useMemo(() => {
107
+ return tcp.peerName
108
+ ? [
109
+ {
110
+ id: "remote",
111
+ name: tcp.peerName,
112
+ state: tcp.remoteState ?? ("OFFLINE" as const),
113
+ },
114
+ ]
115
+ : [];
116
+ }, [tcp.peerName, tcp.remoteState]);
117
+
118
+ const firstPeerName = tcp.peerName;
100
119
 
101
120
  const onToggleAi = useCallback(() => setShowAi((v) => !v), []);
102
121
  const onCloseAi = useCallback(() => setShowAi(false), []);
@@ -170,10 +189,10 @@ export function Session(
170
189
  connectedDurationMs,
171
190
  startedAt: sessionStartAtRef.current,
172
191
  endedAt,
173
- peerName: tcp.peerName,
192
+ peerName: firstPeerName,
174
193
  };
175
194
  props.onLeave(stats);
176
- }, [props, tcp.peerName]);
195
+ }, [props, firstPeerName]);
177
196
 
178
197
  const startCountdown = useCallback((minutes: number) => {
179
198
  const totalSeconds = Math.max(1, Math.floor(minutes * 60));
@@ -189,10 +208,57 @@ export function Session(
189
208
  });
190
209
  }, []);
191
210
 
211
+ const nextShotIdRef = useRef<number>(1);
212
+ const shotQueueRef = useRef<
213
+ Array<{ kind: ProjectileKind; direction: ProjectileDirection }>
214
+ >([]);
215
+
216
+ const pumpShotQueue = useCallback(() => {
217
+ setShots((prev) => {
218
+ if (prev.length >= 1) return prev;
219
+ const next = shotQueueRef.current.shift();
220
+ if (!next) return prev;
221
+ const id = nextShotIdRef.current++;
222
+ return [...prev, { id, kind: next.kind, direction: next.direction }];
223
+ });
224
+ }, []);
225
+
226
+ useEffect(() => {
227
+ if (shots.length === 0) pumpShotQueue();
228
+ }, [shots.length, pumpShotQueue]);
229
+
192
230
  const throwProjectile = useCallback(
193
231
  (kind: ProjectileKind, direction: ProjectileDirection) => {
194
- const id = Date.now() + Math.floor(Math.random() * 1000);
195
- setShots((prev) => [...prev, { id, kind, direction }]);
232
+ shotQueueRef.current.push({ kind, direction });
233
+ pumpShotQueue();
234
+ if (tcp.status === "connected") tcp.sendProjectile(kind, direction);
235
+ },
236
+ [pumpShotQueue, tcp]
237
+ );
238
+
239
+ const showBubble = useCallback(
240
+ (args: { text: string; target: "local" | "buddy"; durationMs: number }) => {
241
+ const text = args.text.trim();
242
+ if (!text) return;
243
+ const durationMs = Math.max(300, Math.min(15_000, Math.floor(args.durationMs)));
244
+
245
+ const setBubble = args.target === "buddy" ? setBuddyBubble : setLocalBubble;
246
+ setBubble(text);
247
+
248
+ const prevTimer =
249
+ args.target === "buddy"
250
+ ? bubbleTimersRef.current.buddy
251
+ : bubbleTimersRef.current.local;
252
+ if (prevTimer) clearTimeout(prevTimer);
253
+
254
+ const handle = setTimeout(() => {
255
+ setBubble(null);
256
+ if (args.target === "buddy") bubbleTimersRef.current.buddy = null;
257
+ else bubbleTimersRef.current.local = null;
258
+ }, durationMs);
259
+
260
+ if (args.target === "buddy") bubbleTimersRef.current.buddy = handle;
261
+ else bubbleTimersRef.current.local = handle;
196
262
  },
197
263
  []
198
264
  );
@@ -212,11 +278,6 @@ export function Session(
212
278
  { isActive: !showAi && countdown !== null }
213
279
  );
214
280
 
215
- const buddyName =
216
- props.role === "host"
217
- ? tcp.peerName ?? "Waiting..."
218
- : props.hostName ?? "Host";
219
-
220
281
  const localState = localActivity.state;
221
282
  const localLabel =
222
283
  props.role === "host"
@@ -229,6 +290,18 @@ export function Session(
229
290
  tcp.sendStatus(localState);
230
291
  }, [localState, tcp.status, tcp.sendStatus]);
231
292
 
293
+ // Handle incoming projectiles from peer (flip direction)
294
+ useEffect(() => {
295
+ const handleRemoteProjectile = (kind: ProjectileKind, direction: ProjectileDirection, _senderName?: string) => {
296
+ const flippedDirection: ProjectileDirection =
297
+ direction === "LEFT_TO_RIGHT" ? "RIGHT_TO_LEFT" : "LEFT_TO_RIGHT";
298
+ shotQueueRef.current.push({ kind, direction: flippedDirection });
299
+ pumpShotQueue();
300
+ };
301
+
302
+ tcp.setOnRemoteProjectile(handleRemoteProjectile);
303
+ }, [tcp, pumpShotQueue]);
304
+
232
305
  useEffect(() => {
233
306
  if (!countdown) return;
234
307
  const endsAt = countdown.endsAt;
@@ -245,6 +318,13 @@ export function Session(
245
318
  return () => clearInterval(handle);
246
319
  }, [countdown?.endsAt]);
247
320
 
321
+ useEffect(() => {
322
+ return () => {
323
+ if (bubbleTimersRef.current.local) clearTimeout(bubbleTimersRef.current.local);
324
+ if (bubbleTimersRef.current.buddy) clearTimeout(bubbleTimersRef.current.buddy);
325
+ };
326
+ }, []);
327
+
248
328
  return (
249
329
  <Box flexDirection="column" padding={1}>
250
330
  <StatusHeader
@@ -252,6 +332,7 @@ export function Session(
252
332
  status={tcp.status}
253
333
  hostIp={props.role === "client" ? props.hostIp : undefined}
254
334
  tcpPort={props.role === "client" ? props.tcpPort : tcp.listenPort}
335
+ peerCount={peers.length}
255
336
  />
256
337
 
257
338
  {/* Main Stage: 3 Columns */}
@@ -279,7 +360,7 @@ export function Session(
279
360
  ) : (
280
361
  <Box height={4} />
281
362
  )}
282
- <BuddyAvatar state={localState} marginTop={0} />
363
+ <BuddyAvatar state={localState} marginTop={0} bubbleText={localBubble} />
283
364
  <Text color="cyan">{localLabel}</Text>
284
365
  </Box>
285
366
 
@@ -309,12 +390,27 @@ export function Session(
309
390
  </Box>
310
391
  </Box>
311
392
 
312
- {/* Right: Remote User */}
393
+ {/* Right: Remote Users */}
313
394
  <Box flexDirection="column" alignItems="center" minWidth={20}>
314
- {/* Placeholder for remote clock to keep alignment with local user */}
315
- <Box height={4} />
316
- <BuddyAvatar state={remoteActivity} marginTop={0} />
317
- <Text color="magenta">{buddyName}</Text>
395
+ {peers.length === 0 ? (
396
+ <>
397
+ <Box height={4} />
398
+ <BuddyAvatar state="OFFLINE" marginTop={0} bubbleText={buddyBubble} />
399
+ <Text color="gray">Waiting...</Text>
400
+ </>
401
+ ) : (
402
+ <Box flexDirection="row" gap={2} flexWrap="wrap" justifyContent="center">
403
+ {peers.slice(0, 4).map((peer) => (
404
+ <Box key={peer.id} flexDirection="column" alignItems="center">
405
+ <BuddyAvatar state={peer.state} marginTop={0} bubbleText={buddyBubble} />
406
+ <Text color="magenta">{peer.name}</Text>
407
+ </Box>
408
+ ))}
409
+ {peers.length > 4 && (
410
+ <Text color="gray">+{peers.length - 4} more</Text>
411
+ )}
412
+ </Box>
413
+ )}
318
414
  </Box>
319
415
  </Box>
320
416
 
@@ -348,10 +444,11 @@ export function Session(
348
444
  <AiConsole
349
445
  onClose={onCloseAi}
350
446
  onStartCountdown={startCountdown}
447
+ onShowBubble={showBubble}
351
448
  onThrowProjectile={throwProjectile}
352
449
  localName={props.localName}
353
450
  peerName={
354
- tcp.peerName ??
451
+ firstPeerName ??
355
452
  (props.role === "client" ? props.hostName : undefined) ??
356
453
  "Buddy"
357
454
  }
package/src/protocol.ts CHANGED
@@ -9,10 +9,22 @@ export type DiscoveryPacket = {
9
9
  sentAt: number;
10
10
  };
11
11
 
12
+ export type ProjectileKind = "ROSE" | "POOP" | "HAMMER";
13
+ export type ProjectileDirection = "LEFT_TO_RIGHT" | "RIGHT_TO_LEFT";
14
+
12
15
  export type TcpPacket =
13
16
  | {type: 'hello'; hostName: string; clientName: string; sentAt: number}
14
- | {type: 'status'; state: ActivityState; sentAt: number}
17
+ | {type: 'status'; state: ActivityState; senderName?: string; sentAt: number}
15
18
  | {type: 'ping'; sentAt: number}
16
- | {type: 'pong'; sentAt: number};
19
+ | {type: 'pong'; sentAt: number}
20
+ | {type: 'projectile'; kind: ProjectileKind; direction: ProjectileDirection; senderName?: string; sentAt: number}
21
+ | {type: 'peer_joined'; peerName: string; sentAt: number}
22
+ | {type: 'peer_left'; peerName: string; sentAt: number};
23
+
24
+ export type Peer = {
25
+ id: string;
26
+ name: string;
27
+ state: ActivityState;
28
+ };
17
29
 
18
30
  export type ConnectionStatus = 'waiting' | 'connecting' | 'connected' | 'disconnected';
@@ -28,9 +28,13 @@ export async function loadStoredApiKey(): Promise<string | null> {
28
28
  }
29
29
 
30
30
  export async function saveStoredApiKey(apiKey: string): Promise<void> {
31
+ const trimmed = apiKey.trim();
32
+ if (trimmed.length === 0) {
33
+ throw new Error('API key cannot be empty');
34
+ }
31
35
  const absolute = path.resolve(process.cwd(), KEY_RELATIVE_PATH);
32
36
  await ensureDirForFile(absolute);
33
- const payload: KeyFile = {apiKey};
37
+ const payload: KeyFile = {apiKey: trimmed};
34
38
  await fs.writeFile(absolute, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
35
39
  }
36
40