@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/.claude/settings.local.json +3 -1
- package/README.md +60 -0
- package/dist/cli.js +283 -137
- 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 +9 -0
- package/src/page/Session.tsx +159 -100
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ export function AiConsole(props: {
|
|
|
12
12
|
onStartCountdown: (minutes: number) => void;
|
|
13
13
|
onShowBubble?: (args: {
|
|
14
14
|
text: string;
|
|
15
|
-
target:
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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: "
|
|
45
|
-
|
|
46
|
-
|
|
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",
|
package/src/hooks/useAiAgent.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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(
|
package/src/hooks/useTcpSync.ts
CHANGED
|
@@ -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
|
|
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"
|
|
@@ -102,20 +115,33 @@ export function Session(
|
|
|
102
115
|
|
|
103
116
|
const localActivity = useActivityMonitor();
|
|
104
117
|
|
|
105
|
-
//
|
|
106
|
-
const peers =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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:
|
|
339
|
-
<Box
|
|
340
|
-
|
|
341
|
-
alignItems="center"
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
<BuddyAvatar state=
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
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={
|
|
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>
|