@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.
- package/.claude/settings.local.json +8 -0
- package/dist/cli.js +568 -176
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AiConsole.tsx +31 -1
- package/src/components/StatusHeader.tsx +4 -0
- package/src/components/sprite/BubbleSprite.tsx +60 -0
- package/src/components/sprite/BuddyAvatar.tsx +14 -2
- package/src/components/tool/createBubbleTool.ts +61 -0
- package/src/components/tool/createInteractionTool.ts +0 -1
- package/src/components/tool/index.ts +1 -1
- package/src/hooks/useAiAgent.ts +33 -5
- package/src/hooks/useTcpSync.ts +328 -94
- package/src/page/Session.tsx +114 -17
- package/src/protocol.ts +14 -2
- package/src/storage/apiKey.ts +5 -1
package/src/page/Session.tsx
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
192
|
+
peerName: firstPeerName,
|
|
174
193
|
};
|
|
175
194
|
props.onLeave(stats);
|
|
176
|
-
}, [props,
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
393
|
+
{/* Right: Remote Users */}
|
|
313
394
|
<Box flexDirection="column" alignItems="center" minWidth={20}>
|
|
314
|
-
{
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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';
|
package/src/storage/apiKey.ts
CHANGED
|
@@ -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
|
|