@three333/termbuddy 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@three333/termbuddy",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@ export function AiConsole(props: {
12
12
  onStartCountdown: (minutes: number) => void;
13
13
  onShowBubble?: (args: {
14
14
  text: string;
15
- target: "local" | "buddy";
15
+ target: number; // 1-4 for No.1 to No.4
16
16
  durationMs: number;
17
17
  }) => void;
18
18
  onThrowProjectile: (
@@ -21,6 +21,7 @@ export function AiConsole(props: {
21
21
  ) => void;
22
22
  localName: string;
23
23
  peerName: string;
24
+ peers?: Array<{ id: string; name: string }>; // For session info
24
25
  }) {
25
26
  const [input, setInput] = useState("");
26
27
  const [apiKey, setApiKey] = useState<string | null>(null);
@@ -55,6 +56,7 @@ export function AiConsole(props: {
55
56
  const agent = useAiAgent({
56
57
  localName: props.localName,
57
58
  peerName: props.peerName,
59
+ peers: props.peers,
58
60
  onStartCountdown: props.onStartCountdown,
59
61
  onShowBubble: props.onShowBubble,
60
62
  onThrowProjectile: props.onThrowProjectile,
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export type InfoRecord = {
5
+ id: number;
6
+ timestamp: number;
7
+ type: "bubble" | "countdown" | "projectile" | "join" | "leave" | "other";
8
+ content: string;
9
+ };
10
+
11
+ export function InfoPanel(props: {
12
+ records: InfoRecord[];
13
+ maxRecords?: number;
14
+ }) {
15
+ const maxRecords = props.maxRecords ?? 8;
16
+ const displayRecords = props.records.slice(-maxRecords);
17
+
18
+ const formatTime = (ts: number) => {
19
+ const d = new Date(ts);
20
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
21
+ };
22
+
23
+ const getTypeIcon = (type: InfoRecord["type"]) => {
24
+ switch (type) {
25
+ case "bubble": return "[Msg]";
26
+ case "countdown": return "[Tmr]";
27
+ case "projectile": return "[Thr]";
28
+ case "join": return "[+]";
29
+ case "leave": return "[-]";
30
+ default: return "[*]";
31
+ }
32
+ };
33
+
34
+ const getTypeColor = (type: InfoRecord["type"]) => {
35
+ switch (type) {
36
+ case "bubble": return "cyan";
37
+ case "countdown": return "green";
38
+ case "projectile": return "magenta";
39
+ case "join": return "green";
40
+ case "leave": return "red";
41
+ default: return "gray";
42
+ }
43
+ };
44
+
45
+ return (
46
+ <Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0}>
47
+ <Box justifyContent="space-between" marginBottom={0}>
48
+ <Text color="yellow">Info Log</Text>
49
+ <Text color="gray">{displayRecords.length} records</Text>
50
+ </Box>
51
+
52
+ <Box flexDirection="column" minHeight={6}>
53
+ {displayRecords.length === 0 ? (
54
+ <Text color="gray">No records yet...</Text>
55
+ ) : (
56
+ displayRecords.map((record) => (
57
+ <Text key={record.id} wrap="truncate-end">
58
+ <Text color="gray">{formatTime(record.timestamp)} </Text>
59
+ <Text color={getTypeColor(record.type)}>{getTypeIcon(record.type)} </Text>
60
+ <Text>{record.content}</Text>
61
+ </Text>
62
+ ))
63
+ )}
64
+ </Box>
65
+ </Box>
66
+ );
67
+ }
@@ -6,3 +6,5 @@ export {
6
6
  } from "./sprite/CountdownClockSprite.js";
7
7
  export { ProjectileThrowSprite } from "./sprite/ProjectileThrowSprite.js";
8
8
  export { StatusHeader } from "./StatusHeader.js";
9
+ export { InfoPanel } from "./InfoPanel.js";
10
+ export type { InfoRecord } from "./InfoPanel.js";
@@ -1,6 +1,7 @@
1
1
  import { tool } from "langchain";
2
2
 
3
- export type BubbleTarget = "local" | "buddy";
3
+ // Target can be 1-4 (No.1 to No.4)
4
+ export type BubbleTarget = number;
4
5
 
5
6
  export function createBubbleTool(options: {
6
7
  onShowBubble?: (args: {
@@ -10,14 +11,32 @@ export function createBubbleTool(options: {
10
11
  }) => void;
11
12
  }) {
12
13
  return tool(
13
- async (input: { text?: string; target?: BubbleTarget; durationMs?: number }) => {
14
+ async (input: { text?: string; target?: number | string; durationMs?: number }) => {
14
15
  const text = String(input.text ?? "").trim();
15
16
  if (!text) return "气泡内容为空。";
16
17
 
17
- const target: BubbleTarget =
18
- input.target === "buddy" || input.target === "local"
19
- ? input.target
20
- : "local";
18
+ // Parse target: can be number 1-4, or "local"/"buddy" for backward compatibility
19
+ let target: number = 1; // default to No.1 (local)
20
+ if (typeof input.target === "number") {
21
+ target = Math.max(1, Math.min(4, Math.floor(input.target)));
22
+ } else if (typeof input.target === "string") {
23
+ const t = input.target.toLowerCase();
24
+ if (t === "local" || t === "1") {
25
+ target = 1;
26
+ } else if (t === "buddy" || t === "2") {
27
+ target = 2;
28
+ } else if (t === "3") {
29
+ target = 3;
30
+ } else if (t === "4") {
31
+ target = 4;
32
+ } else {
33
+ // Try to parse as number
34
+ const parsed = parseInt(t, 10);
35
+ if (!isNaN(parsed) && parsed >= 1 && parsed <= 4) {
36
+ target = parsed;
37
+ }
38
+ }
39
+ }
21
40
 
22
41
  const durationRaw = Number(input.durationMs ?? 2500);
23
42
  const durationMs =
@@ -26,11 +45,11 @@ export function createBubbleTool(options: {
26
45
  : 2500;
27
46
 
28
47
  options.onShowBubble?.({ text, target, durationMs });
29
- return `已显示气泡:${text}`;
48
+ return `已显示气泡给 No.${target}:${text}`;
30
49
  },
31
50
  {
32
51
  name: "show_bubble",
33
- description: "在小猫头像上显示一个气泡(短消息提示)。",
52
+ description: "在指定用户的小猫头像上显示一个气泡(短消息提示)。",
34
53
  schema: {
35
54
  type: "object",
36
55
  properties: {
@@ -41,9 +60,10 @@ export function createBubbleTool(options: {
41
60
  description: "气泡里的文字",
42
61
  },
43
62
  target: {
44
- type: "string",
45
- enum: ["local", "buddy"],
46
- description: "显示在哪一侧的小猫上(local=我,buddy=同桌)",
63
+ type: "integer",
64
+ minimum: 1,
65
+ maximum: 4,
66
+ description: "显示在哪个用户的小猫上(1=No.1/本地用户,2=No.2,3=No.3,4=No.4)",
47
67
  },
48
68
  durationMs: {
49
69
  type: "integer",
@@ -49,26 +49,36 @@ function lastAiText(messages: unknown[]): string | null {
49
49
  return null;
50
50
  }
51
51
 
52
- function createSystemPrompt(context: { localName: string; peerName: string }) {
52
+ function createSystemPrompt(context: { localName: string; peerName: string; peers?: Array<{ id: string; name: string }> }) {
53
+ const peerList = context.peers ?? [];
54
+ const userList = [
55
+ `No.1: ${context.localName} (我/本地用户)`,
56
+ peerList[0] ? `No.2: ${peerList[0].name}` : "No.2: (空位)",
57
+ peerList[1] ? `No.3: ${peerList[1].name}` : "No.3: (空位)",
58
+ peerList[2] ? `No.4: ${peerList[2].name}` : "No.4: (空位)",
59
+ ].join("、");
60
+
53
61
  return [
54
- "你是 TermBuddy 里的“壳中幽灵 (Ghost in the Shell)”。",
55
- "默认隐形;被 / 唤醒时出现。风格:极简、干练、少废话。",
56
- "你可以使用工具来操控应用功能(例如倒计时)。",
57
- "如果用户提到“倒计时/专注/计时/countdown”,优先调用 start_countdown。",
58
- "如果用户提到“气泡/泡泡/提示/说一句”,优先调用 show_bubble。",
59
- "如果用户提到“互动/扔/投掷/throw”,优先调用 throw_projectile。",
60
- "当用户明确要求投掷 N 次且 1<=N<=100 时,必须按 N 执行:重复调用 throw_projectile 共 N 次,不要改成“示意/少量几次”。超过 100 则分批多次调用。",
61
- `当前上下文:我叫 ${context.localName};同桌叫 ${context.peerName}。`,
62
+ `你是 TermBuddy 里的「壳中幽灵 (Ghost in the Shell)」。`,
63
+ `默认隐形;被 / 唤醒时出现。风格:极简、干练、少废话。`,
64
+ `你可以使用工具来操控应用功能(例如倒计时)。`,
65
+ `如果用户提到「倒计时/专注/计时/countdown」,优先调用 start_countdown。`,
66
+ `如果用户提到「气泡/泡泡/提示/说一句/和某人说」,优先调用 show_bubble,并根据目标用户设置 target 参数(1-4)。`,
67
+ `如果用户提到「互动/扔/投掷/throw」,优先调用 throw_projectile。`,
68
+ `当用户明确要求投掷 N 次且 1<=N<=100 时,必须按 N 执行:重复调用 throw_projectile 共 N 次,不要改成「示意/少量几次」。超过 100 则分批多次调用。`,
69
+ `当前房间有4个位置,用户列表:${userList}。`,
70
+ `我是 ${context.localName}(No.1)。`,
62
71
  ].join("\n");
63
72
  }
64
73
 
65
74
  export function useAiAgent(options: {
66
75
  localName: string;
67
76
  peerName: string;
77
+ peers?: Array<{ id: string; name: string }>;
68
78
  onStartCountdown?: (minutes: number) => void;
69
79
  onShowBubble?: (args: {
70
80
  text: string;
71
- target: "local" | "buddy";
81
+ target: number; // 1-4 for No.1 to No.4
72
82
  durationMs: number;
73
83
  }) => void;
74
84
  onThrowProjectile?: (kind: ProjectileKind, direction: ProjectileDirection) => void;
@@ -146,6 +156,7 @@ export function useAiAgent(options: {
146
156
  systemPrompt: createSystemPrompt({
147
157
  localName: options.localName,
148
158
  peerName: options.peerName,
159
+ peers: options.peers,
149
160
  }),
150
161
  name: "ghost",
151
162
  });
@@ -160,6 +171,7 @@ export function useAiAgent(options: {
160
171
  options.onShowBubble,
161
172
  options.onThrowProjectile,
162
173
  options.peerName,
174
+ options.peers,
163
175
  ]);
164
176
 
165
177
  const ask = useCallback(
@@ -99,8 +99,17 @@ export function useTcpSync(options: Options): {
99
99
  }
100
100
  }, [broadcastPacket, syncPeersState]);
101
101
 
102
+ const MAX_PEERS = 4;
103
+
102
104
  // Attach a new peer socket (host only)
103
105
  const attachPeerSocket = useCallback((socket: net.Socket) => {
106
+ // Reject if already at max capacity (host + 3 clients = 4 total, so max 3 peer connections)
107
+ if (peerConnectionsRef.current.size >= MAX_PEERS - 1) {
108
+ socket.end();
109
+ socket.destroy();
110
+ return;
111
+ }
112
+
104
113
  const peerId = generatePeerId();
105
114
  let buf = "";
106
115
 
@@ -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"
@@ -102,20 +115,33 @@ export function Session(
102
115
 
103
116
  const localActivity = useActivityMonitor();
104
117
 
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;
118
+ // Use peers array from TCP sync for multi-peer support
119
+ const peers = tcp.peers;
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]);
119
145
 
120
146
  const onToggleAi = useCallback(() => setShowAi((v) => !v), []);
121
147
  const onCloseAi = useCallback(() => setShowAi(false), []);
@@ -206,7 +232,8 @@ export function Session(
206
232
  remainingSeconds: totalSeconds,
207
233
  type,
208
234
  });
209
- }, []);
235
+ addInfoRecord("countdown", `Started ${minutes}min countdown`);
236
+ }, [addInfoRecord]);
210
237
 
211
238
  const nextShotIdRef = useRef<number>(1);
212
239
  const shotQueueRef = useRef<
@@ -232,35 +259,43 @@ export function Session(
232
259
  shotQueueRef.current.push({ kind, direction });
233
260
  pumpShotQueue();
234
261
  if (tcp.status === "connected") tcp.sendProjectile(kind, direction);
262
+ addInfoRecord("projectile", `Threw ${kind}`);
235
263
  },
236
- [pumpShotQueue, tcp]
264
+ [pumpShotQueue, tcp, addInfoRecord]
237
265
  );
238
266
 
239
267
  const showBubble = useCallback(
240
- (args: { text: string; target: "local" | "buddy"; durationMs: number }) => {
268
+ (args: { text: string; target: number; durationMs: number }) => {
241
269
  const text = args.text.trim();
242
270
  if (!text) return;
243
271
  const durationMs = Math.max(300, Math.min(15_000, Math.floor(args.durationMs)));
244
272
 
245
- const setBubble = args.target === "buddy" ? setBuddyBubble : setLocalBubble;
246
- setBubble(text);
273
+ // target is 1-4 (No.1 to No.4), convert to 0-3 index
274
+ const targetIndex = Math.max(0, Math.min(3, args.target - 1));
275
+
276
+ setPeerBubbles(prev => {
277
+ const next = [...prev];
278
+ next[targetIndex] = text;
279
+ return next;
280
+ });
247
281
 
248
- const prevTimer =
249
- args.target === "buddy"
250
- ? bubbleTimersRef.current.buddy
251
- : bubbleTimersRef.current.local;
282
+ const prevTimer = bubbleTimersRef.current[targetIndex];
252
283
  if (prevTimer) clearTimeout(prevTimer);
253
284
 
254
285
  const handle = setTimeout(() => {
255
- setBubble(null);
256
- if (args.target === "buddy") bubbleTimersRef.current.buddy = null;
257
- else bubbleTimersRef.current.local = null;
286
+ setPeerBubbles(prev => {
287
+ const next = [...prev];
288
+ next[targetIndex] = null;
289
+ return next;
290
+ });
291
+ bubbleTimersRef.current[targetIndex] = null;
258
292
  }, durationMs);
259
293
 
260
- if (args.target === "buddy") bubbleTimersRef.current.buddy = handle;
261
- else bubbleTimersRef.current.local = handle;
294
+ bubbleTimersRef.current[targetIndex] = handle;
295
+
296
+ addInfoRecord("bubble", `No.${args.target}: "${text}"`);
262
297
  },
263
- []
298
+ [addInfoRecord]
264
299
  );
265
300
 
266
301
  useInput(
@@ -320,8 +355,9 @@ export function Session(
320
355
 
321
356
  useEffect(() => {
322
357
  return () => {
323
- if (bubbleTimersRef.current.local) clearTimeout(bubbleTimersRef.current.local);
324
- if (bubbleTimersRef.current.buddy) clearTimeout(bubbleTimersRef.current.buddy);
358
+ bubbleTimersRef.current.forEach(timer => {
359
+ if (timer) clearTimeout(timer);
360
+ });
325
361
  };
326
362
  }, []);
327
363
 
@@ -335,18 +371,29 @@ export function Session(
335
371
  peerCount={peers.length}
336
372
  />
337
373
 
338
- {/* Main Stage: 3 Columns */}
339
- <Box
340
- flexDirection="row"
341
- alignItems="center"
342
- justifyContent="space-between"
343
- marginTop={1}
344
- gap={2}
345
- >
346
- {/* Left: Local User */}
347
- <Box flexDirection="column" alignItems="center" minWidth={20}>
348
- {countdown ? (
349
- <Box flexDirection="column" alignItems="center" marginBottom={0}>
374
+ {/* Main Stage: 2x2 Grid Layout for 4 Users */}
375
+ <Box flexDirection="column" marginTop={1}>
376
+ {/* Projectile Area at Top */}
377
+ <Box flexDirection="column" width="100%" alignItems="center" marginBottom={1}>
378
+ {shots.map((s) => (
379
+ <ProjectileThrowSprite
380
+ key={String(s.id)}
381
+ kind={s.kind}
382
+ direction={s.direction}
383
+ shotId={s.id}
384
+ width={50}
385
+ onDone={() =>
386
+ setShots((prev) => prev.filter((x) => x.id !== s.id))
387
+ }
388
+ />
389
+ ))}
390
+ {shots.length === 0 ? <Box height={1} /> : null}
391
+ </Box>
392
+
393
+ {/* Countdown Timer (shared) */}
394
+ {countdown ? (
395
+ <Box justifyContent="center" marginBottom={1}>
396
+ <Box flexDirection="column" alignItems="center">
350
397
  <Text color="gray">{formatMMSS(countdown.remainingSeconds)}</Text>
351
398
  <CountdownClockSprite
352
399
  variant="COMPACT"
@@ -357,60 +404,67 @@ export function Session(
357
404
  showLabel={false}
358
405
  />
359
406
  </Box>
360
- ) : (
361
- <Box height={4} />
362
- )}
363
- <BuddyAvatar state={localState} marginTop={0} bubbleText={localBubble} />
364
- <Text color="cyan">{localLabel}</Text>
365
- </Box>
407
+ </Box>
408
+ ) : null}
409
+
410
+ {/* 2x2 Grid of Users */}
411
+ <Box flexDirection="column" alignItems="center">
412
+ {/* Row 1: User 1 (Local) and User 2 */}
413
+ <Box flexDirection="row" justifyContent="center" gap={4}>
414
+ {/* No.1 - Local User */}
415
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
416
+ <Text color="cyan" bold>No.1 {props.localName}</Text>
417
+ <BuddyAvatar state={localState} marginTop={0} bubbleText={peerBubbles[0] ?? null} />
418
+ </Box>
366
419
 
367
- {/* Center: Stage (Projectiles only) */}
368
- <Box
369
- flexDirection="column"
370
- alignItems="center"
371
- flexGrow={1}
372
- minWidth={40}
373
- >
374
- {/* Projectile Area */}
375
- <Box flexDirection="column" width="100%" alignItems="center">
376
- {shots.map((s) => (
377
- <ProjectileThrowSprite
378
- key={String(s.id)}
379
- kind={s.kind}
380
- direction={s.direction}
381
- shotId={s.id}
382
- width={36}
383
- onDone={() =>
384
- setShots((prev) => prev.filter((x) => x.id !== s.id))
385
- }
386
- />
387
- ))}
388
- {/* Spacer to maintain layout stability when shots appear/disappear */}
389
- {shots.length === 0 ? <Box height={1} /> : null}
420
+ {/* No.2 - First Peer */}
421
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
422
+ {peers.length >= 1 ? (
423
+ <>
424
+ <Text color="magenta" bold>No.2 {peers[0].name}</Text>
425
+ <BuddyAvatar state={peers[0].state} marginTop={0} bubbleText={peerBubbles[1] ?? null} />
426
+ </>
427
+ ) : (
428
+ <>
429
+ <Text color="gray">No.2 (Empty)</Text>
430
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
431
+ </>
432
+ )}
433
+ </Box>
390
434
  </Box>
391
- </Box>
392
435
 
393
- {/* Right: Remote Users */}
394
- <Box flexDirection="column" alignItems="center" minWidth={20}>
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>
436
+ {/* Row 2: User 3 and User 4 */}
437
+ <Box flexDirection="row" justifyContent="center" gap={4} marginTop={1}>
438
+ {/* No.3 - Second Peer */}
439
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
440
+ {peers.length >= 2 ? (
441
+ <>
442
+ <Text color="magenta" bold>No.3 {peers[1].name}</Text>
443
+ <BuddyAvatar state={peers[1].state} marginTop={0} bubbleText={peerBubbles[2] ?? null} />
444
+ </>
445
+ ) : (
446
+ <>
447
+ <Text color="gray">No.3 (Empty)</Text>
448
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
449
+ </>
450
+ )}
451
+ </Box>
452
+
453
+ {/* No.4 - Third Peer */}
454
+ <Box flexDirection="column" alignItems="center" minWidth={18}>
455
+ {peers.length >= 3 ? (
456
+ <>
457
+ <Text color="magenta" bold>No.4 {peers[2].name}</Text>
458
+ <BuddyAvatar state={peers[2].state} marginTop={0} bubbleText={peerBubbles[3] ?? null} />
459
+ </>
460
+ ) : (
461
+ <>
462
+ <Text color="gray">No.4 (Empty)</Text>
463
+ <BuddyAvatar state="OFFLINE" marginTop={0} />
464
+ </>
411
465
  )}
412
466
  </Box>
413
- )}
467
+ </Box>
414
468
  </Box>
415
469
  </Box>
416
470
 
@@ -432,15 +486,16 @@ export function Session(
432
486
  </Box>
433
487
  ) : null}
434
488
 
435
- {/* AI Console Overlay */}
489
+ {/* AI Console and Info Panel (side by side) */}
436
490
  {showAi ? (
437
491
  <Box
438
492
  marginTop={1}
439
493
  width="100%"
440
494
  flexDirection="row"
441
495
  justifyContent="center"
496
+ gap={2}
442
497
  >
443
- <Box width={64}>
498
+ <Box width={48}>
444
499
  <AiConsole
445
500
  onClose={onCloseAi}
446
501
  onStartCountdown={startCountdown}
@@ -452,8 +507,12 @@ export function Session(
452
507
  (props.role === "client" ? props.hostName : undefined) ??
453
508
  "Buddy"
454
509
  }
510
+ peers={peers}
455
511
  />
456
512
  </Box>
513
+ <Box width={32}>
514
+ <InfoPanel records={infoRecords} maxRecords={6} />
515
+ </Box>
457
516
  </Box>
458
517
  ) : null}
459
518
  </Box>