@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.
- package/.claude/settings.local.json +3 -1
- package/dist/cli.js +327 -132
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AiConsole.tsx +3 -1
- package/src/components/InfoPanel.tsx +67 -0
- package/src/components/index.ts +2 -0
- package/src/components/tool/createBubbleTool.ts +31 -11
- package/src/hooks/useAiAgent.ts +22 -10
- package/src/hooks/useTcpSync.ts +42 -1
- package/src/page/Session.tsx +187 -90
- package/src/protocol.ts +1 -0
package/src/page/Session.tsx
CHANGED
|
@@ -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
|
-
|
|
64
|
-
const [
|
|
65
|
-
const bubbleTimersRef = useRef<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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:
|
|
328
|
-
<Box
|
|
329
|
-
|
|
330
|
-
alignItems="center"
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
<BuddyAvatar state=
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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={
|
|
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
|
|