@three333/termbuddy 0.1.0 → 0.1.1

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.
Files changed (38) hide show
  1. package/dist/cli.js +1097 -260
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +3 -2
  4. package/pnpm-workspace.yaml +2 -0
  5. package/src/app/App.tsx +94 -53
  6. package/src/app/index.ts +1 -2
  7. package/src/components/AiConsole.tsx +171 -73
  8. package/src/components/StatusHeader.tsx +36 -36
  9. package/src/components/index.ts +8 -4
  10. package/src/components/sprite/BuddyAvatar.tsx +49 -0
  11. package/src/components/sprite/CountdownClockSprite.tsx +146 -0
  12. package/src/components/sprite/ProjectileThrowSprite.tsx +86 -0
  13. package/src/components/tool/createCountdownTool.ts +32 -0
  14. package/src/components/tool/createInteractionTool.ts +67 -0
  15. package/src/components/tool/createSessionInfoTool.ts +29 -0
  16. package/src/components/tool/index.ts +4 -0
  17. package/src/hooks/globalKeyboard.ts +146 -0
  18. package/src/hooks/index.ts +5 -7
  19. package/src/hooks/useActivityMonitor.ts +61 -24
  20. package/src/hooks/useAiAgent.ts +200 -165
  21. package/src/hooks/useBroadcaster.ts +55 -47
  22. package/src/hooks/useScanner.ts +59 -55
  23. package/src/hooks/useTcpSync.ts +166 -145
  24. package/src/net/broadcast.ts +21 -21
  25. package/src/net/index.ts +1 -2
  26. package/src/page/LeavePage.tsx +85 -0
  27. package/src/{views → page}/MainMenu.tsx +32 -28
  28. package/src/page/NicknamePrompt.tsx +62 -0
  29. package/src/{views → page}/RoomScanner.tsx +4 -1
  30. package/src/page/Session.tsx +364 -0
  31. package/src/page/index.ts +5 -0
  32. package/src/storage/apiKey.ts +36 -0
  33. package/src/types.ts +8 -0
  34. package/src/components/AvatarDisplay.tsx +0 -18
  35. package/src/components/BuddyAvatar.tsx +0 -32
  36. package/src/hooks/useCountdown.ts +0 -42
  37. package/src/views/Session.tsx +0 -127
  38. package/src/views/index.ts +0 -4
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
- // src/views/MainMenu.tsx
12
+ // src/page/MainMenu.tsx
13
13
  import { Box, Text, useInput } from "ink";
14
14
  import { jsx, jsxs } from "react/jsx-runtime";
15
15
  function MainMenu(props) {
@@ -46,22 +46,202 @@ function MainMenu(props) {
46
46
  ] });
47
47
  }
48
48
  var init_MainMenu = __esm({
49
- "src/views/MainMenu.tsx"() {
49
+ "src/page/MainMenu.tsx"() {
50
50
  "use strict";
51
51
  }
52
52
  });
53
53
 
54
+ // src/page/NicknamePrompt.tsx
55
+ import { useMemo, useState } from "react";
56
+ import os from "os";
57
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
58
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
59
+ function defaultNick() {
60
+ try {
61
+ return os.userInfo().username || os.hostname();
62
+ } catch {
63
+ return os.hostname();
64
+ }
65
+ }
66
+ function NicknamePrompt(props) {
67
+ const initial = useMemo(() => defaultNick(), []);
68
+ const [nickname, setNickname] = useState(initial);
69
+ const [touched, setTouched] = useState(false);
70
+ useInput2((input, key) => {
71
+ if (key.escape) props.onExit();
72
+ if (key.return) {
73
+ const name = nickname.trim();
74
+ if (!name) return;
75
+ props.onSubmit(name);
76
+ return;
77
+ }
78
+ if (key.backspace || key.delete) {
79
+ setTouched(true);
80
+ setNickname((v) => v.slice(0, -1));
81
+ return;
82
+ }
83
+ if (key.ctrl || key.meta) return;
84
+ if (!input) return;
85
+ if (input === " ") return;
86
+ setTouched(true);
87
+ setNickname((v) => v + input);
88
+ });
89
+ const hint = touched ? "" : " (\u56DE\u8F66\u786E\u8BA4\uFF0C\u53EF\u76F4\u63A5\u7528\u9ED8\u8BA4\u503C)";
90
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
91
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u6B22\u8FCE\u6765\u5230 TermBuddy" }),
92
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { children: [
93
+ "\u8BF7\u8F93\u5165\u4F60\u7684\u6635\u79F0\uFF1A",
94
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: nickname || "" }),
95
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: hint })
96
+ ] }) }),
97
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u6309 Esc \u9000\u51FA\u3002" }) })
98
+ ] });
99
+ }
100
+ var init_NicknamePrompt = __esm({
101
+ "src/page/NicknamePrompt.tsx"() {
102
+ "use strict";
103
+ }
104
+ });
105
+
106
+ // src/hooks/globalKeyboard.ts
107
+ import { spawn } from "child_process";
108
+ function emitKeydown() {
109
+ for (const listener of listeners) {
110
+ try {
111
+ listener();
112
+ } catch {
113
+ }
114
+ }
115
+ }
116
+ async function tryStartUiohook() {
117
+ try {
118
+ const mod = await import("uiohook-napi");
119
+ const uIOhook = mod.uIOhook ?? mod.default?.uIOhook;
120
+ if (!uIOhook) return null;
121
+ const onKeydown = () => emitKeydown();
122
+ uIOhook.on("keydown", onKeydown);
123
+ uIOhook.start();
124
+ stopBackend = () => {
125
+ uIOhook.removeListener?.("keydown", onKeydown);
126
+ uIOhook.stop();
127
+ };
128
+ return "uiohook";
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+ function tryStartXinput() {
134
+ if (process.platform !== "linux") return Promise.resolve(null);
135
+ if (!process.env.DISPLAY) return Promise.resolve(null);
136
+ return new Promise((resolve) => {
137
+ let resolved = false;
138
+ const child = spawn("xinput", ["test-xi2", "--root"], {
139
+ stdio: ["ignore", "pipe", "ignore"]
140
+ });
141
+ const resolveOnce = (value) => {
142
+ if (resolved) return;
143
+ resolved = true;
144
+ resolve(value);
145
+ };
146
+ child.once("error", () => {
147
+ resolveOnce(null);
148
+ });
149
+ resolveOnce("xinput");
150
+ let buf = "";
151
+ child.stdout?.setEncoding("utf8");
152
+ child.stdout?.on("data", (chunk) => {
153
+ buf += chunk;
154
+ while (true) {
155
+ const idx = buf.indexOf("\n");
156
+ if (idx === -1) break;
157
+ const line = buf.slice(0, idx);
158
+ buf = buf.slice(idx + 1);
159
+ if (/KeyPress/.test(line)) emitKeydown();
160
+ }
161
+ });
162
+ stopBackend = () => {
163
+ child.stdout?.removeAllListeners();
164
+ child.removeAllListeners();
165
+ child.kill();
166
+ };
167
+ });
168
+ }
169
+ async function ensureGlobalKeyboard() {
170
+ if (started) return backend;
171
+ if (starting) return starting;
172
+ starting = (async () => {
173
+ const uiohook = await tryStartUiohook();
174
+ if (uiohook) return uiohook;
175
+ return await tryStartXinput();
176
+ })();
177
+ backend = await starting;
178
+ started = backend !== null;
179
+ if (!started) stopBackend = null;
180
+ starting = null;
181
+ return backend;
182
+ }
183
+ function stopIfIdle() {
184
+ if (listeners.size > 0) return;
185
+ if (!started) return;
186
+ started = false;
187
+ backend = null;
188
+ const stop = stopBackend;
189
+ stopBackend = null;
190
+ try {
191
+ stop?.();
192
+ } catch {
193
+ }
194
+ }
195
+ function subscribeGlobalKeydown(listener) {
196
+ listeners.add(listener);
197
+ return () => {
198
+ listeners.delete(listener);
199
+ stopIfIdle();
200
+ };
201
+ }
202
+ var backend, started, starting, stopBackend, listeners;
203
+ var init_globalKeyboard = __esm({
204
+ "src/hooks/globalKeyboard.ts"() {
205
+ "use strict";
206
+ backend = null;
207
+ started = false;
208
+ starting = null;
209
+ stopBackend = null;
210
+ listeners = /* @__PURE__ */ new Set();
211
+ }
212
+ });
213
+
54
214
  // src/hooks/useActivityMonitor.ts
55
- import { useEffect, useRef, useState } from "react";
56
- import { useInput as useInput2 } from "ink";
215
+ import { useCallback, useEffect, useRef, useState as useState2 } from "react";
216
+ import { useInput as useInput3 } from "ink";
57
217
  function useActivityMonitor(options) {
58
218
  const idleAfterMs = options?.idleAfterMs ?? 1500;
59
- const [state, setState] = useState("IDLE");
219
+ const [state, setState] = useState2("IDLE");
60
220
  const lastActivityRef = useRef(Date.now());
61
- useInput2(() => {
221
+ const markActive = useCallback(() => {
62
222
  lastActivityRef.current = Date.now();
63
223
  setState("TYPING");
224
+ }, []);
225
+ useInput3(() => {
226
+ markActive();
64
227
  });
228
+ useEffect(() => {
229
+ const rawSource = options?.source ?? process.env.TERMBUDDY_ACTIVITY_SOURCE ?? "ink";
230
+ const source = rawSource === "xinput" ? "keyboard" : rawSource;
231
+ if (source !== "keyboard") return;
232
+ let cancelled = false;
233
+ let unsub = null;
234
+ void (async () => {
235
+ const ok = await ensureGlobalKeyboard();
236
+ if (cancelled) return;
237
+ if (!ok) return;
238
+ unsub = subscribeGlobalKeydown(markActive);
239
+ })();
240
+ return () => {
241
+ cancelled = true;
242
+ unsub?.();
243
+ };
244
+ }, [markActive, options?.source]);
65
245
  useEffect(() => {
66
246
  const id = setInterval(() => {
67
247
  const delta = Date.now() - lastActivityRef.current;
@@ -74,23 +254,165 @@ function useActivityMonitor(options) {
74
254
  var init_useActivityMonitor = __esm({
75
255
  "src/hooks/useActivityMonitor.ts"() {
76
256
  "use strict";
257
+ init_globalKeyboard();
258
+ }
259
+ });
260
+
261
+ // src/components/tool/createCountdownTool.ts
262
+ import { tool } from "langchain";
263
+ function createCountdownTool(options) {
264
+ return tool(
265
+ async (input) => {
266
+ const minutes = Number(input.minutes);
267
+ if (!Number.isFinite(minutes) || minutes <= 0) return "\u5012\u8BA1\u65F6\u5206\u949F\u6570\u65E0\u6548\u3002";
268
+ options.onStartCountdown?.(minutes);
269
+ return `\u5DF2\u5F00\u59CB\u5012\u8BA1\u65F6 ${minutes} \u5206\u949F\u3002`;
270
+ },
271
+ {
272
+ name: "start_countdown",
273
+ description: "\u5F00\u59CB\u4E00\u4E2A\u4E13\u6CE8\u5012\u8BA1\u65F6\uFF08\u5206\u949F\uFF09\u3002",
274
+ schema: {
275
+ type: "object",
276
+ properties: {
277
+ minutes: {
278
+ type: "integer",
279
+ minimum: 1,
280
+ maximum: 180,
281
+ description: "\u5012\u8BA1\u65F6\u5206\u949F\u6570"
282
+ }
283
+ },
284
+ required: ["minutes"],
285
+ additionalProperties: false
286
+ }
287
+ }
288
+ );
289
+ }
290
+ var init_createCountdownTool = __esm({
291
+ "src/components/tool/createCountdownTool.ts"() {
292
+ "use strict";
293
+ }
294
+ });
295
+
296
+ // src/components/tool/createInteractionTool.ts
297
+ import { tool as tool2 } from "langchain";
298
+ function normalizeKind(raw) {
299
+ if (typeof raw !== "string") return null;
300
+ const upper = raw.toUpperCase().trim();
301
+ if (upper === "ROSE" || upper === "POOP" || upper === "HAMMER") return upper;
302
+ const lower = raw.toLowerCase();
303
+ for (const item of KIND_ALIASES) {
304
+ if (item.keys.some((k) => lower.includes(k))) return item.kind;
305
+ }
306
+ return null;
307
+ }
308
+ function normalizeDirection(raw) {
309
+ if (typeof raw !== "string") return null;
310
+ const upper = raw.toUpperCase().trim();
311
+ if (upper === "LEFT_TO_RIGHT" || upper === "RIGHT_TO_LEFT") return upper;
312
+ return null;
313
+ }
314
+ function createInteractionTool(options) {
315
+ return tool2(
316
+ async (input) => {
317
+ const kind = normalizeKind(input.kind ?? "") ?? "ROSE";
318
+ const direction = normalizeDirection(input.direction) ?? "LEFT_TO_RIGHT";
319
+ options.onThrow?.(kind, direction);
320
+ const msg = (input.message ?? "").trim();
321
+ return msg ? `\u5DF2\u6295\u63B7 ${kind}\uFF1A${msg}` : `\u5DF2\u6295\u63B7 ${kind}\u3002`;
322
+ },
323
+ {
324
+ name: "throw_projectile",
325
+ description: "\u548C\u540C\u684C\u4E92\u52A8\uFF1A\u6295\u63B7\u4E00\u4E2A\u5C0F\u7269\u54C1\uFF08\u{1F339}/\u{1F4A9}/\u{1F528}\uFF09\u3002",
326
+ schema: {
327
+ type: "object",
328
+ properties: {
329
+ kind: {
330
+ type: "string",
331
+ description: "\u6295\u63B7\u7269\u7C7B\u578B\uFF08ROSE/POOP/HAMMER\uFF0C\u6216\u4EFB\u610F\u63CF\u8FF0\u5982\u201C\u73AB\u7470/\u9524\u5B50/\u{1F4A9}\u201D\uFF09"
332
+ },
333
+ direction: {
334
+ type: "string",
335
+ enum: ["LEFT_TO_RIGHT", "RIGHT_TO_LEFT"],
336
+ description: "\u98DE\u884C\u65B9\u5411"
337
+ },
338
+ message: { type: "string", description: "\u9644\u5E26\u4E00\u53E5\u8BDD\uFF08\u53EF\u9009\uFF09" }
339
+ },
340
+ required: [],
341
+ additionalProperties: false
342
+ }
343
+ }
344
+ );
345
+ }
346
+ var KIND_ALIASES;
347
+ var init_createInteractionTool = __esm({
348
+ "src/components/tool/createInteractionTool.ts"() {
349
+ "use strict";
350
+ KIND_ALIASES = [
351
+ { kind: "ROSE", keys: ["rose", "\u82B1", "\u73AB\u7470", "\u{1F339}", "love"] },
352
+ { kind: "POOP", keys: ["poop", "\u5C4E", "\u{1F4A9}", "\u5927\u4FBF"] },
353
+ { kind: "HAMMER", keys: ["hammer", "\u9524", "\u{1F528}", "\u6572", "\u6253"] }
354
+ ];
355
+ }
356
+ });
357
+
358
+ // src/components/tool/createSessionInfoTool.ts
359
+ import { tool as tool3 } from "langchain";
360
+ function createSessionInfoTool(options) {
361
+ return tool3(
362
+ async () => {
363
+ return JSON.stringify(
364
+ {
365
+ localName: options.localName,
366
+ peerName: options.peerName
367
+ },
368
+ null,
369
+ 2
370
+ );
371
+ },
372
+ {
373
+ name: "session_info",
374
+ description: "\u83B7\u53D6\u5F53\u524D\u4F1A\u8BDD\u4E0A\u4E0B\u6587\uFF08\u672C\u5730\u6635\u79F0\u3001\u540C\u684C\u6635\u79F0\uFF09\u3002",
375
+ schema: {
376
+ type: "object",
377
+ properties: {},
378
+ additionalProperties: false
379
+ }
380
+ }
381
+ );
382
+ }
383
+ var init_createSessionInfoTool = __esm({
384
+ "src/components/tool/createSessionInfoTool.ts"() {
385
+ "use strict";
386
+ }
387
+ });
388
+
389
+ // src/components/tool/index.ts
390
+ var init_tool = __esm({
391
+ "src/components/tool/index.ts"() {
392
+ "use strict";
393
+ init_createCountdownTool();
394
+ init_createInteractionTool();
395
+ init_createSessionInfoTool();
77
396
  }
78
397
  });
79
398
 
80
399
  // src/hooks/useAiAgent.ts
81
- import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
82
- import { createAgent, initChatModel, tool } from "langchain";
400
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
401
+ import { createAgent } from "langchain";
402
+ import { ChatOpenAI } from "@langchain/openai";
83
403
  function contentToText(content) {
84
404
  if (typeof content === "string") return content;
85
405
  if (!content) return "";
86
406
  if (Array.isArray(content)) {
87
407
  return content.map((part) => {
88
408
  if (typeof part === "string") return part;
89
- if (typeof part === "object" && part && "text" in part) return String(part.text ?? "");
409
+ if (typeof part === "object" && part && "text" in part)
410
+ return String(part.text ?? "");
90
411
  return "";
91
412
  }).join("");
92
413
  }
93
- if (typeof content === "object" && "text" in content) return String(content.text ?? "");
414
+ if (typeof content === "object" && "text" in content)
415
+ return String(content.text ?? "");
94
416
  return String(content);
95
417
  }
96
418
  function lastAiText(messages) {
@@ -110,20 +432,22 @@ function createSystemPrompt(context) {
110
432
  "\u9ED8\u8BA4\u9690\u5F62\uFF1B\u88AB / \u5524\u9192\u65F6\u51FA\u73B0\u3002\u98CE\u683C\uFF1A\u6781\u7B80\u3001\u5E72\u7EC3\u3001\u5C11\u5E9F\u8BDD\u3002",
111
433
  "\u4F60\u53EF\u4EE5\u4F7F\u7528\u5DE5\u5177\u6765\u64CD\u63A7\u5E94\u7528\u529F\u80FD\uFF08\u4F8B\u5982\u5012\u8BA1\u65F6\uFF09\u3002",
112
434
  "\u5982\u679C\u7528\u6237\u63D0\u5230\u201C\u5012\u8BA1\u65F6/\u4E13\u6CE8/\u8BA1\u65F6/countdown\u201D\uFF0C\u4F18\u5148\u8C03\u7528 start_countdown\u3002",
435
+ "\u5982\u679C\u7528\u6237\u63D0\u5230\u201C\u4E92\u52A8/\u6254/\u6295\u63B7/throw\u201D\uFF0C\u4F18\u5148\u8C03\u7528 throw_projectile\u3002",
113
436
  `\u5F53\u524D\u4E0A\u4E0B\u6587\uFF1A\u6211\u53EB ${context.localName}\uFF1B\u540C\u684C\u53EB ${context.peerName}\u3002`
114
437
  ].join("\n");
115
438
  }
116
439
  function useAiAgent(options) {
117
- const [lines, setLines] = useState2([]);
118
- const [busy, setBusy] = useState2(false);
440
+ const [lines, setLines] = useState3([]);
441
+ const [busy, setBusy] = useState3(false);
119
442
  const agentRef = useRef2(null);
120
443
  const agentInitRef = useRef2(null);
444
+ const agentKeyRef = useRef2(null);
121
445
  const stateRef = useRef2({ messages: [] });
122
446
  const abortRef = useRef2(null);
123
- const append = useCallback((line) => {
447
+ const append = useCallback2((line) => {
124
448
  setLines((prev) => [...prev, line]);
125
449
  }, []);
126
- const updateLine = useCallback((at, text) => {
450
+ const updateLine = useCallback2((at, text) => {
127
451
  setLines((prev) => {
128
452
  const idx = prev.findIndex((l) => l.at === at);
129
453
  if (idx === -1) return prev;
@@ -132,67 +456,65 @@ function useAiAgent(options) {
132
456
  return next;
133
457
  });
134
458
  }, []);
135
- const ensureAgent = useCallback(async () => {
136
- if (agentRef.current) return agentRef.current;
459
+ const ensureAgent = useCallback2(async () => {
460
+ const apiKey = (options.apiKey ?? "").trim();
461
+ if (!apiKey) throw new Error("missing_api_key");
462
+ if (agentRef.current && agentKeyRef.current === apiKey)
463
+ return agentRef.current;
464
+ agentRef.current = null;
465
+ agentInitRef.current = null;
466
+ agentKeyRef.current = apiKey;
467
+ stateRef.current.messages = [];
137
468
  agentInitRef.current ??= (async () => {
138
- const startCountdown = tool(
139
- async (input) => {
140
- const minutes = Number(input.minutes);
141
- if (!Number.isFinite(minutes) || minutes <= 0) return "\u5012\u8BA1\u65F6\u5206\u949F\u6570\u65E0\u6548\u3002";
142
- options.onStartCountdown?.(minutes);
143
- return `\u5DF2\u5F00\u59CB\u5012\u8BA1\u65F6 ${minutes} \u5206\u949F\u3002`;
144
- },
145
- {
146
- name: "start_countdown",
147
- description: "\u5F00\u59CB\u4E00\u4E2A\u4E13\u6CE8\u5012\u8BA1\u65F6\uFF08\u5206\u949F\uFF09\u3002",
148
- schema: {
149
- type: "object",
150
- properties: {
151
- minutes: { type: "integer", minimum: 1, maximum: 180, description: "\u5012\u8BA1\u65F6\u5206\u949F\u6570" }
152
- },
153
- required: ["minutes"],
154
- additionalProperties: false
155
- }
156
- }
157
- );
158
- const sessionInfo = tool(
159
- async () => {
160
- return JSON.stringify(
161
- {
162
- localName: options.localName,
163
- peerName: options.peerName
164
- },
165
- null,
166
- 2
167
- );
469
+ const startCountdown = createCountdownTool({
470
+ onStartCountdown: options.onStartCountdown
471
+ });
472
+ const sessionInfo = createSessionInfoTool({
473
+ localName: options.localName,
474
+ peerName: options.peerName
475
+ });
476
+ const interaction = createInteractionTool({
477
+ onThrow: options.onThrowProjectile
478
+ });
479
+ const llm = new ChatOpenAI({
480
+ model: "deepseek-chat",
481
+ configuration: {
482
+ baseURL: "https://api.deepseek.com"
168
483
  },
169
- {
170
- name: "session_info",
171
- description: "\u83B7\u53D6\u5F53\u524D\u4F1A\u8BDD\u4E0A\u4E0B\u6587\uFF08\u672C\u5730\u6635\u79F0\u3001\u540C\u684C\u6635\u79F0\uFF09\u3002",
172
- schema: { type: "object", properties: {}, additionalProperties: false }
173
- }
174
- );
175
- const modelId = process.env.TERMBUDDY_MODEL ?? "openai:gpt-4o-mini";
176
- const llm = await initChatModel(modelId, {
177
- temperature: 0.2,
178
- maxTokens: 800,
484
+ apiKey,
485
+ temperature: 0.1,
486
+ maxTokens: 1e3,
179
487
  timeout: 3e4
180
488
  });
181
489
  return createAgent({
182
- llm,
183
- tools: [startCountdown, sessionInfo],
184
- prompt: createSystemPrompt({ localName: options.localName, peerName: options.peerName }),
490
+ model: llm,
491
+ tools: [startCountdown, interaction, sessionInfo],
492
+ systemPrompt: createSystemPrompt({
493
+ localName: options.localName,
494
+ peerName: options.peerName
495
+ }),
185
496
  name: "ghost"
186
497
  });
187
498
  })();
188
499
  agentRef.current = await agentInitRef.current;
189
500
  return agentRef.current;
190
- }, [options.localName, options.onStartCountdown, options.peerName]);
191
- const ask = useCallback(
501
+ }, [
502
+ options.apiKey,
503
+ options.localName,
504
+ options.onStartCountdown,
505
+ options.onThrowProjectile,
506
+ options.peerName
507
+ ]);
508
+ const ask = useCallback2(
192
509
  async (text) => {
193
- append({ kind: "user", text: `> ${text}`, at: Date.now() });
194
- const aiAt = Date.now() + 1;
195
- append({ kind: "ai", text: "\u2026", at: aiAt });
510
+ const userAt = Date.now();
511
+ const aiAt = userAt + 1;
512
+ const toolAt = userAt + 2;
513
+ setLines([
514
+ { kind: "user", text: `> ${text}`, at: userAt },
515
+ { kind: "ai", text: "\u2026", at: aiAt },
516
+ { kind: "system", text: "", at: toolAt }
517
+ ]);
196
518
  abortRef.current?.abort();
197
519
  abortRef.current = new AbortController();
198
520
  setBusy(true);
@@ -200,7 +522,7 @@ function useAiAgent(options) {
200
522
  const agent = await ensureAgent();
201
523
  const stream = await agent.stream(
202
524
  {
203
- messages: [...stateRef.current.messages, { role: "user", content: text }]
525
+ messages: [{ role: "user", content: text }]
204
526
  },
205
527
  {
206
528
  streamMode: "values",
@@ -210,12 +532,20 @@ function useAiAgent(options) {
210
532
  for await (const chunk of stream) {
211
533
  const messages = chunk?.messages ?? [];
212
534
  if (messages.length > 0) stateRef.current.messages = messages;
535
+ const latest = messages.at(-1);
536
+ if (latest?.tool_calls?.length) {
537
+ const names = latest.tool_calls.map((tc) => tc?.name).filter(Boolean).join(", ");
538
+ if (names) updateLine(toolAt, `Calling tools: ${names}`);
539
+ continue;
540
+ }
213
541
  const t = lastAiText(messages);
214
542
  if (t !== null) updateLine(aiAt, t);
215
543
  }
216
544
  } catch (e) {
217
545
  const msg = e instanceof Error ? e.message : String(e);
218
- updateLine(aiAt, `\uFF08AI \u51FA\u9519\uFF09${msg}`);
546
+ if (msg === "missing_api_key")
547
+ updateLine(aiAt, "\u8BF7\u5148\u5728 AI Console \u8F93\u5165 DeepSeek API Key\u3002");
548
+ else updateLine(aiAt, `\uFF08AI \u51FA\u9519\uFF09${msg}`);
219
549
  } finally {
220
550
  setBusy(false);
221
551
  }
@@ -230,6 +560,7 @@ function useAiAgent(options) {
230
560
  var init_useAiAgent = __esm({
231
561
  "src/hooks/useAiAgent.ts"() {
232
562
  "use strict";
563
+ init_tool();
233
564
  }
234
565
  });
235
566
 
@@ -245,7 +576,7 @@ var init_constants = __esm({
245
576
  });
246
577
 
247
578
  // src/net/broadcast.ts
248
- import os from "os";
579
+ import os2 from "os";
249
580
  function ipv4ToInt(ip) {
250
581
  return ip.split(".").map((n) => Number.parseInt(n, 10)).reduce((acc, n) => (acc << 8 | n & 255) >>> 0, 0);
251
582
  }
@@ -254,7 +585,7 @@ function intToIpv4(n) {
254
585
  }
255
586
  function getBroadcastTargets() {
256
587
  const out = /* @__PURE__ */ new Set(["255.255.255.255"]);
257
- const ifaces = os.networkInterfaces();
588
+ const ifaces = os2.networkInterfaces();
258
589
  for (const entries of Object.values(ifaces)) {
259
590
  if (!entries) continue;
260
591
  for (const e of entries) {
@@ -328,51 +659,8 @@ var init_useBroadcaster = __esm({
328
659
  }
329
660
  });
330
661
 
331
- // src/hooks/useCountdown.ts
332
- import { useCallback as useCallback2, useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
333
- function formatMMSS(totalSeconds) {
334
- const m = Math.floor(totalSeconds / 60);
335
- const s = totalSeconds % 60;
336
- return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
337
- }
338
- function useCountdown() {
339
- const [remainingSeconds, setRemainingSeconds] = useState3(null);
340
- const timerRef = useRef3(null);
341
- const start = useCallback2((minutes) => {
342
- const seconds = Math.max(1, Math.floor(minutes * 60));
343
- setRemainingSeconds(seconds);
344
- if (timerRef.current) clearInterval(timerRef.current);
345
- timerRef.current = setInterval(() => {
346
- setRemainingSeconds((prev) => {
347
- if (prev === null) return null;
348
- if (prev <= 1) return null;
349
- return prev - 1;
350
- });
351
- }, 1e3);
352
- }, []);
353
- useEffect4(() => {
354
- if (remainingSeconds !== null) return;
355
- if (timerRef.current) clearInterval(timerRef.current);
356
- timerRef.current = null;
357
- }, [remainingSeconds]);
358
- useEffect4(() => {
359
- return () => {
360
- if (timerRef.current) clearInterval(timerRef.current);
361
- };
362
- }, []);
363
- return {
364
- start,
365
- label: remainingSeconds === null ? null : formatMMSS(remainingSeconds)
366
- };
367
- }
368
- var init_useCountdown = __esm({
369
- "src/hooks/useCountdown.ts"() {
370
- "use strict";
371
- }
372
- });
373
-
374
662
  // src/hooks/useScanner.ts
375
- import { useEffect as useEffect5, useState as useState4 } from "react";
663
+ import { useEffect as useEffect4, useState as useState4 } from "react";
376
664
  import dgram2 from "dgram";
377
665
  function safeParse(msg) {
378
666
  try {
@@ -388,7 +676,7 @@ function safeParse(msg) {
388
676
  function useScanner(options) {
389
677
  const staleAfterMs = options?.staleAfterMs ?? 3500;
390
678
  const [rooms, setRooms] = useState4([]);
391
- useEffect5(() => {
679
+ useEffect4(() => {
392
680
  const socket = dgram2.createSocket("udp4");
393
681
  socket.on("error", () => {
394
682
  });
@@ -413,7 +701,9 @@ function useScanner(options) {
413
701
  });
414
702
  const prune = setInterval(() => {
415
703
  const now = Date.now();
416
- setRooms((prev) => prev.filter((r) => now - r.lastSeenAt <= staleAfterMs));
704
+ setRooms(
705
+ (prev) => prev.filter((r) => now - r.lastSeenAt <= staleAfterMs)
706
+ );
417
707
  }, 500);
418
708
  return () => {
419
709
  clearInterval(prune);
@@ -430,20 +720,24 @@ var init_useScanner = __esm({
430
720
  });
431
721
 
432
722
  // src/hooks/useTcpSync.ts
433
- import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef4, useState as useState5 } from "react";
723
+ import { useCallback as useCallback3, useEffect as useEffect5, useRef as useRef3, useState as useState5 } from "react";
434
724
  import net from "net";
435
725
  function writePacket(socket, packet) {
436
726
  socket.write(`${JSON.stringify(packet)}
437
727
  `, "utf8");
438
728
  }
439
729
  function useTcpSync(options) {
440
- const [status, setStatus] = useState5(options.role === "host" ? "waiting" : "connecting");
730
+ const [status, setStatus] = useState5(
731
+ options.role === "host" ? "waiting" : "connecting"
732
+ );
441
733
  const [listenPort, setListenPort] = useState5(void 0);
442
734
  const [peerName, setPeerName] = useState5(void 0);
443
- const [remoteState, setRemoteState] = useState5(void 0);
444
- const socketRef = useRef4(null);
445
- const lastSeenRef = useRef4(Date.now());
446
- const heartbeatRef = useRef4(null);
735
+ const [remoteState, setRemoteState] = useState5(
736
+ void 0
737
+ );
738
+ const socketRef = useRef3(null);
739
+ const lastSeenRef = useRef3(Date.now());
740
+ const heartbeatRef = useRef3(null);
447
741
  const cleanupSocket = useCallback3(() => {
448
742
  if (heartbeatRef.current) clearInterval(heartbeatRef.current);
449
743
  heartbeatRef.current = null;
@@ -477,7 +771,8 @@ function useTcpSync(options) {
477
771
  else setPeerName(packet.hostName);
478
772
  }
479
773
  if (packet.type === "status") setRemoteState(packet.state);
480
- if (packet.type === "ping") writePacket(s, { type: "pong", sentAt: Date.now() });
774
+ if (packet.type === "ping")
775
+ writePacket(s, { type: "pong", sentAt: Date.now() });
481
776
  if (packet.type === "pong") {
482
777
  }
483
778
  } catch {
@@ -514,7 +809,7 @@ function useTcpSync(options) {
514
809
  },
515
810
  [cleanupSocket, options]
516
811
  );
517
- useEffect6(() => {
812
+ useEffect5(() => {
518
813
  if (options.role === "host") {
519
814
  const server = net.createServer((socket2) => {
520
815
  attachSocket(socket2);
@@ -531,9 +826,12 @@ function useTcpSync(options) {
531
826
  };
532
827
  }
533
828
  setStatus("connecting");
534
- const socket = net.createConnection({ host: options.hostIp, port: options.tcpPort }, () => {
535
- attachSocket(socket);
536
- });
829
+ const socket = net.createConnection(
830
+ { host: options.hostIp, port: options.tcpPort },
831
+ () => {
832
+ attachSocket(socket);
833
+ }
834
+ );
537
835
  socket.on("error", () => {
538
836
  setStatus("disconnected");
539
837
  setRemoteState("OFFLINE");
@@ -564,22 +862,21 @@ var init_hooks = __esm({
564
862
  init_useActivityMonitor();
565
863
  init_useAiAgent();
566
864
  init_useBroadcaster();
567
- init_useCountdown();
568
865
  init_useScanner();
569
866
  init_useTcpSync();
570
867
  }
571
868
  });
572
869
 
573
- // src/views/RoomScanner.tsx
574
- import { useMemo } from "react";
575
- import { Box as Box2, Text as Text2, useInput as useInput3 } from "ink";
576
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
870
+ // src/page/RoomScanner.tsx
871
+ import { useMemo as useMemo2 } from "react";
872
+ import { Box as Box3, Text as Text3, useInput as useInput4 } from "ink";
873
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
577
874
  function RoomScanner(props) {
578
875
  const rooms = useScanner();
579
- const sortedRooms = useMemo(() => {
876
+ const sortedRooms = useMemo2(() => {
580
877
  return [...rooms].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
581
878
  }, [rooms]);
582
- useInput3((input, key) => {
879
+ useInput4((input, key) => {
583
880
  if (key.escape || input === "b") props.onBack();
584
881
  if (input === "q") props.onExit();
585
882
  const index = Number.parseInt(input, 10);
@@ -588,61 +885,212 @@ function RoomScanner(props) {
588
885
  if (!room) return;
589
886
  props.onSelectRoom(room);
590
887
  });
591
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", padding: 1, children: [
592
- /* @__PURE__ */ jsxs2(Text2, { children: [
593
- /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "\u6B63\u5728\u626B\u63CF\u5C40\u57DF\u7F51..." }),
888
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", padding: 1, children: [
889
+ /* @__PURE__ */ jsxs3(Text3, { children: [
890
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u6B63\u5728\u626B\u63CF\u5C40\u57DF\u7F51..." }),
594
891
  " (\u6309 ",
595
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "b" }),
892
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "b" }),
596
893
  " \u8FD4\u56DE,",
597
894
  " ",
598
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "q" }),
895
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "q" }),
599
896
  " \u9000\u51FA)"
600
897
  ] }),
601
- /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: sortedRooms.length === 0 ? /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u6682\u65E0\u623F\u95F4\u5E7F\u64AD\u3002" }) : sortedRooms.map((room, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
602
- /* @__PURE__ */ jsxs2(Text2, { color: "cyan", children: [
898
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginTop: 1, children: sortedRooms.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\u6682\u65E0\u623F\u95F4\u5E7F\u64AD\u3002" }) : sortedRooms.map((room, i) => /* @__PURE__ */ jsxs3(Text3, { children: [
899
+ /* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
603
900
  "[",
604
901
  i + 1,
605
902
  "]"
606
903
  ] }),
607
904
  " ",
608
- room.roomName,
609
- " \u2014 ",
610
905
  room.hostName,
611
- " @ ",
612
- room.ip,
613
- ":",
614
- room.tcpPort
906
+ " ",
907
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "@" }),
908
+ " ",
909
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
910
+ room.ip,
911
+ ":",
912
+ room.tcpPort
913
+ ] })
615
914
  ] }, `${room.ip}:${room.tcpPort}`)) })
616
915
  ] });
617
916
  }
618
917
  var init_RoomScanner = __esm({
619
- "src/views/RoomScanner.tsx"() {
918
+ "src/page/RoomScanner.tsx"() {
620
919
  "use strict";
621
920
  init_hooks();
622
921
  }
623
922
  });
624
923
 
924
+ // src/page/LeavePage.tsx
925
+ import { useMemo as useMemo3 } from "react";
926
+ import { Box as Box4, Text as Text4, useInput as useInput5 } from "ink";
927
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
928
+ function formatDuration(ms) {
929
+ const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
930
+ const hours = Math.floor(totalSeconds / 3600);
931
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
932
+ const seconds = totalSeconds % 60;
933
+ if (hours > 0) return `${hours}\u5C0F\u65F6${minutes}\u5206${seconds}\u79D2`;
934
+ if (minutes > 0) return `${minutes}\u5206${seconds}\u79D2`;
935
+ return `${seconds}\u79D2`;
936
+ }
937
+ function LeavePage(props) {
938
+ useInput5((input, key) => {
939
+ if (key.escape || input === "q") props.onExit();
940
+ if (key.return || input === " ") props.onBack();
941
+ });
942
+ const sessionLabel = useMemo3(
943
+ () => formatDuration(props.stats.sessionDurationMs),
944
+ [props.stats.sessionDurationMs]
945
+ );
946
+ const connectedLabel = useMemo3(
947
+ () => formatDuration(props.stats.connectedDurationMs),
948
+ [props.stats.connectedDurationMs]
949
+ );
950
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", padding: 1, alignItems: "center", children: [
951
+ /* @__PURE__ */ jsxs4(
952
+ Box4,
953
+ {
954
+ flexDirection: "column",
955
+ marginTop: 1,
956
+ borderStyle: "round",
957
+ paddingX: 2,
958
+ borderColor: "gray",
959
+ children: [
960
+ /* @__PURE__ */ jsx4(Text4, { color: "white", bold: true, children: props.stats.peerName ? `\u4E0E ${props.stats.peerName} \u7684\u540C\u9891\u8BB0\u5F55` : "\u672C\u6B21\u4E13\u6CE8\u8BB0\u5F55" }),
961
+ /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", gap: 1, children: [
962
+ /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", width: 30, children: [
963
+ /* @__PURE__ */ jsx4(Text4, { children: "\u2328\uFE0F \u952E\u76D8\u6572\u51FB" }),
964
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: props.stats.keyPresses })
965
+ ] }),
966
+ /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", width: 30, children: [
967
+ /* @__PURE__ */ jsx4(Text4, { children: "\u23F1\uFE0F \u603B\u5171\u65F6\u957F" }),
968
+ /* @__PURE__ */ jsx4(Text4, { color: "green", children: sessionLabel })
969
+ ] }),
970
+ /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", width: 30, children: [
971
+ /* @__PURE__ */ jsx4(Text4, { children: "\u{1F517} \u8FDE\u7EBF\u65F6\u957F" }),
972
+ /* @__PURE__ */ jsx4(Text4, { color: "blue", children: connectedLabel })
973
+ ] })
974
+ ] })
975
+ ]
976
+ }
977
+ ),
978
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: "gray", children: [
979
+ "\u6309 ",
980
+ /* @__PURE__ */ jsx4(Text4, { color: "white", children: "Enter" }),
981
+ " \u8FD4\u56DE\u83DC\u5355\uFF0C\u6216",
982
+ " ",
983
+ /* @__PURE__ */ jsx4(Text4, { color: "red", children: "q" }),
984
+ " \u9000\u51FA\u7A0B\u5E8F"
985
+ ] }) })
986
+ ] });
987
+ }
988
+ var init_LeavePage = __esm({
989
+ "src/page/LeavePage.tsx"() {
990
+ "use strict";
991
+ }
992
+ });
993
+
994
+ // src/storage/apiKey.ts
995
+ import fs from "fs/promises";
996
+ import path from "path";
997
+ async function readJsonFile(filePath) {
998
+ try {
999
+ const raw = await fs.readFile(filePath, "utf8");
1000
+ const parsed = JSON.parse(raw);
1001
+ if (!parsed || typeof parsed !== "object") return null;
1002
+ return parsed;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+ async function ensureDirForFile(filePath) {
1008
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1009
+ }
1010
+ async function loadStoredApiKey() {
1011
+ const absolute = path.resolve(process.cwd(), KEY_RELATIVE_PATH);
1012
+ const json = await readJsonFile(absolute);
1013
+ const key = (json?.apiKey ?? "").trim();
1014
+ return key.length > 0 ? key : null;
1015
+ }
1016
+ async function saveStoredApiKey(apiKey) {
1017
+ const absolute = path.resolve(process.cwd(), KEY_RELATIVE_PATH);
1018
+ await ensureDirForFile(absolute);
1019
+ const payload = { apiKey };
1020
+ await fs.writeFile(absolute, `${JSON.stringify(payload, null, 2)}
1021
+ `, "utf8");
1022
+ }
1023
+ var KEY_RELATIVE_PATH;
1024
+ var init_apiKey = __esm({
1025
+ "src/storage/apiKey.ts"() {
1026
+ "use strict";
1027
+ KEY_RELATIVE_PATH = path.join("src", "assets", "key.json");
1028
+ }
1029
+ });
1030
+
625
1031
  // src/components/AiConsole.tsx
626
- import { useMemo as useMemo2, useState as useState6 } from "react";
627
- import { Box as Box3, Text as Text3, useInput as useInput4 } from "ink";
628
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1032
+ import { useEffect as useEffect6, useMemo as useMemo4, useState as useState6 } from "react";
1033
+ import { Box as Box5, Text as Text5, useInput as useInput6 } from "ink";
1034
+ import { Fragment, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
629
1035
  function AiConsole(props) {
630
1036
  const [input, setInput] = useState6("");
1037
+ const [apiKey, setApiKey] = useState6(null);
1038
+ const [keyDraft, setKeyDraft] = useState6("");
1039
+ const [keyStatus, setKeyStatus] = useState6("loading");
1040
+ useEffect6(() => {
1041
+ let cancelled = false;
1042
+ void (async () => {
1043
+ const stored = await loadStoredApiKey();
1044
+ if (cancelled) return;
1045
+ if (stored) {
1046
+ setApiKey(stored);
1047
+ setKeyStatus("ready");
1048
+ } else {
1049
+ setKeyStatus("missing");
1050
+ }
1051
+ })();
1052
+ return () => {
1053
+ cancelled = true;
1054
+ };
1055
+ }, []);
631
1056
  const agent = useAiAgent({
632
1057
  localName: props.localName,
633
1058
  peerName: props.peerName,
634
- onStartCountdown: props.onStartCountdown
1059
+ onStartCountdown: props.onStartCountdown,
1060
+ onThrowProjectile: props.onThrowProjectile,
1061
+ apiKey: apiKey ?? void 0
635
1062
  });
636
- const helpLine = useMemo2(
637
- () => "\u793A\u4F8B\uFF1A\u5012\u8BA1\u65F620\u5206\u949F / countdown 20 / \u95EE\u4E2A\u6280\u672F\u95EE\u9898",
1063
+ const helpLine = useMemo4(
1064
+ () => "\u793A\u4F8B\uFF1A\u5012\u8BA1\u65F620\u5206\u949F / \u804A\u4F1A\u5929 / \u548C\u522B\u4EBA\u4E92\u52A8\u4E00\u4E0B",
638
1065
  []
639
1066
  );
640
- useInput4(
1067
+ useInput6(
641
1068
  (ch, key) => {
642
1069
  if (key.escape) {
643
1070
  props.onClose();
644
1071
  return;
645
1072
  }
1073
+ if (keyStatus !== "ready") {
1074
+ if (key.return) {
1075
+ const draft = keyDraft.trim();
1076
+ if (!draft) return;
1077
+ setKeyStatus("saving");
1078
+ void (async () => {
1079
+ await saveStoredApiKey(draft);
1080
+ setApiKey(draft);
1081
+ setKeyDraft("");
1082
+ setKeyStatus("ready");
1083
+ })();
1084
+ return;
1085
+ }
1086
+ if (key.backspace || key.delete) {
1087
+ setKeyDraft((s) => s.slice(0, -1));
1088
+ return;
1089
+ }
1090
+ if (key.ctrl || key.meta) return;
1091
+ if (ch) setKeyDraft((s) => s + ch);
1092
+ return;
1093
+ }
646
1094
  if (key.return) {
647
1095
  const line = input.trim();
648
1096
  setInput("");
@@ -659,73 +1107,265 @@ function AiConsole(props) {
659
1107
  },
660
1108
  { isActive: true }
661
1109
  );
662
- const lines = agent.lines.slice(-12);
663
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "round", paddingX: 1, paddingY: 0, children: [
664
- /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
665
- /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "AI Console" }),
666
- /* @__PURE__ */ jsx3(Text3, { color: "gray", children: agent.busy ? "Thinking\u2026" : "Esc \u5173\u95ED" })
667
- ] }),
668
- /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: helpLine }) }),
669
- /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
670
- lines.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\uFF08\u5E7D\u7075\u8FD8\u5728\u58F3\u91CC\u2026\uFF09" }) : null,
671
- lines.map((l, i) => /* @__PURE__ */ jsx3(Text3, { color: l.kind === "user" ? "yellow" : "white", children: l.text }, `${l.kind}:${l.at}:${i}`))
1110
+ const lines = agent.lines.filter((l) => l.text.trim().length > 0).slice(-6);
1111
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", paddingX: 1, paddingY: 0, children: [
1112
+ /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", marginBottom: 0, children: [
1113
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "AI Console" }),
1114
+ /* @__PURE__ */ jsx5(Text5, { color: "gray", children: keyStatus === "saving" ? "Saving\u2026" : agent.busy ? "Thinking\u2026" : "Esc Close" })
672
1115
  ] }),
673
- /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, children: [
674
- /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
675
- ">",
676
- " "
677
- ] }),
678
- /* @__PURE__ */ jsx3(Text3, { children: input })
679
- ] })
1116
+ /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: keyStatus === "loading" ? /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "Checking API Key..." }) : keyStatus === "missing" || keyStatus === "saving" ? /* @__PURE__ */ jsxs5(Text5, { color: "yellow", children: [
1117
+ "Setup: Enter DeepSeek API Key (saves to",
1118
+ " ",
1119
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "src/assets/key.json" }),
1120
+ ")"
1121
+ ] }) : lines.length === 0 ? /* @__PURE__ */ jsx5(Text5, { color: "gray", children: helpLine }) : null }),
1122
+ /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", marginTop: 0, minHeight: 6, children: keyStatus === "ready" ? /* @__PURE__ */ jsx5(Fragment, { children: lines.map((l, i) => /* @__PURE__ */ jsxs5(
1123
+ Text5,
1124
+ {
1125
+ color: l.kind === "user" ? "yellow" : "white",
1126
+ wrap: "truncate-end",
1127
+ children: [
1128
+ l.kind === "user" ? "> " : "",
1129
+ l.text
1130
+ ]
1131
+ },
1132
+ `${l.kind}:${l.at}:${i}`
1133
+ )) }) : /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "Please enter API Key to proceed." }) }),
1134
+ /* @__PURE__ */ jsxs5(
1135
+ Box5,
1136
+ {
1137
+ marginTop: 0,
1138
+ borderStyle: "single",
1139
+ borderTop: true,
1140
+ borderBottom: false,
1141
+ borderLeft: false,
1142
+ borderRight: false,
1143
+ children: [
1144
+ /* @__PURE__ */ jsxs5(Text5, { color: "green", children: [
1145
+ ">",
1146
+ " "
1147
+ ] }),
1148
+ keyStatus === "ready" ? /* @__PURE__ */ jsx5(Text5, { children: input }) : /* @__PURE__ */ jsx5(Text5, { children: keyDraft.length === 0 ? "" : "*".repeat(Math.min(64, keyDraft.length)) })
1149
+ ]
1150
+ }
1151
+ )
680
1152
  ] });
681
1153
  }
682
1154
  var init_AiConsole = __esm({
683
1155
  "src/components/AiConsole.tsx"() {
684
1156
  "use strict";
685
1157
  init_hooks();
1158
+ init_apiKey();
686
1159
  }
687
1160
  });
688
1161
 
689
- // src/components/AvatarDisplay.tsx
690
- import { Box as Box4, Text as Text4 } from "ink";
691
- import { jsx as jsx4 } from "react/jsx-runtime";
692
- var init_AvatarDisplay = __esm({
693
- "src/components/AvatarDisplay.tsx"() {
694
- "use strict";
695
- }
696
- });
697
-
698
- // src/components/BuddyAvatar.tsx
699
- import { Box as Box5, Text as Text5 } from "ink";
700
- import { jsx as jsx5 } from "react/jsx-runtime";
1162
+ // src/components/sprite/BuddyAvatar.tsx
1163
+ import { Box as Box6, Text as Text6 } from "ink";
1164
+ import { jsx as jsx6 } from "react/jsx-runtime";
701
1165
  function BuddyAvatar(props) {
702
- const frame = FRAMES[props.state];
703
- return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", marginTop: 1, children: frame.lines.map((line, i) => /* @__PURE__ */ jsx5(Text5, { color: frame.color, children: line }, `${props.state}:${i}`)) });
1166
+ const sprite = SPRITES[props.state];
1167
+ if (props.variant === "compact") {
1168
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: props.marginTop ?? 1, children: /* @__PURE__ */ jsx6(Text6, { color: sprite.color, children: sprite.compact }) });
1169
+ }
1170
+ return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", marginTop: props.marginTop ?? 1, children: sprite.frames.map((line, i) => /* @__PURE__ */ jsx6(Text6, { color: sprite.color, children: line }, `${props.state}:${i}`)) });
704
1171
  }
705
- var FRAMES;
1172
+ var SPRITES;
706
1173
  var init_BuddyAvatar = __esm({
707
- "src/components/BuddyAvatar.tsx"() {
1174
+ "src/components/sprite/BuddyAvatar.tsx"() {
708
1175
  "use strict";
709
- FRAMES = {
1176
+ SPRITES = {
710
1177
  TYPING: {
711
1178
  color: "green",
712
- lines: [" /\\_/\\ ", "( >_<) ", " /|_|\\\\ ", " / \\\\ "]
1179
+ compact: "( >_<)===3",
1180
+ frames: [" /\\_/\\ ", "( >_<) ", " /|_|\\\\ ", " / \\\\ "]
713
1181
  },
714
1182
  IDLE: {
715
1183
  color: "yellow",
716
- lines: [" /\\_/\\ ", "( -.-) ", " /|_|\\\\ ", " / \\\\ "]
1184
+ compact: "( -.-)Zzz",
1185
+ frames: [" /\\_/\\ ", "( -.-) ", " /|_|\\\\ ", " / \\\\ "]
717
1186
  },
718
1187
  OFFLINE: {
719
1188
  color: "gray",
720
- lines: [" /\\_/\\ ", "( x_x) ", " /|_|\\\\ ", " / \\\\ "]
1189
+ compact: "( x_x)",
1190
+ frames: [" /\\_/\\ ", "( x_x) ", " /|_|\\\\ ", " / \\\\ "]
721
1191
  }
722
1192
  };
723
1193
  }
724
1194
  });
725
1195
 
1196
+ // src/components/sprite/CountdownClockSprite.tsx
1197
+ import { useMemo as useMemo5 } from "react";
1198
+ import { Box as Box7, Text as Text7 } from "ink";
1199
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1200
+ function countdownClockTypeFromMinutes(minutes) {
1201
+ if (minutes <= 10) return "SHORT";
1202
+ if (minutes <= 30) return "MEDIUM";
1203
+ return "LONG";
1204
+ }
1205
+ function clamp01(n) {
1206
+ if (n <= 0) return 0;
1207
+ if (n >= 1) return 1;
1208
+ return n;
1209
+ }
1210
+ function handFromProgress(progress01) {
1211
+ const idx = Math.round(clamp01(progress01) * 7);
1212
+ const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
1213
+ return dirs[idx];
1214
+ }
1215
+ function renderClockFace(hand) {
1216
+ const lines = [
1217
+ " .---. ",
1218
+ " / \\ ",
1219
+ "| \u2022 |",
1220
+ " \\ / ",
1221
+ " '---' "
1222
+ ].map((s) => s.split(""));
1223
+ const center = { r: 2, c: 4 };
1224
+ const handMap = {
1225
+ N: { r: 1, c: 4, ch: "|" },
1226
+ NE: { r: 1, c: 5, ch: "/" },
1227
+ E: { r: 2, c: 5, ch: "-" },
1228
+ SE: { r: 3, c: 5, ch: "\\" },
1229
+ S: { r: 3, c: 4, ch: "|" },
1230
+ SW: { r: 3, c: 3, ch: "/" },
1231
+ W: { r: 2, c: 3, ch: "-" },
1232
+ NW: { r: 1, c: 3, ch: "\\" }
1233
+ };
1234
+ const tip = handMap[hand];
1235
+ lines[center.r][center.c] = "\u2022";
1236
+ lines[tip.r][tip.c] = tip.ch;
1237
+ return lines.map((row) => row.join(""));
1238
+ }
1239
+ function renderCompactClockFace(hand) {
1240
+ const lines = [" .---. ", "| \u2022 |", " '---' "].map((s) => s.split(""));
1241
+ const center = { r: 1, c: 3 };
1242
+ const handMap = {
1243
+ N: { r: 0, c: 3, ch: "|" },
1244
+ NE: { r: 0, c: 4, ch: "/" },
1245
+ E: { r: 1, c: 5, ch: "-" },
1246
+ SE: { r: 2, c: 4, ch: "\\" },
1247
+ S: { r: 2, c: 3, ch: "|" },
1248
+ SW: { r: 2, c: 2, ch: "/" },
1249
+ W: { r: 1, c: 1, ch: "-" },
1250
+ NW: { r: 0, c: 2, ch: "\\" }
1251
+ };
1252
+ const tip = handMap[hand];
1253
+ lines[center.r][center.c] = "\u2022";
1254
+ lines[tip.r][tip.c] = tip.ch;
1255
+ return lines.map((row) => row.join(""));
1256
+ }
1257
+ function CountdownClockSprite(props) {
1258
+ const type = props.type ?? (typeof props.minutes === "number" ? countdownClockTypeFromMinutes(props.minutes) : "MEDIUM");
1259
+ const progress01 = useMemo5(() => {
1260
+ if (typeof props.totalSeconds !== "number" || props.totalSeconds <= 0 || props.remainingSeconds === null || typeof props.remainingSeconds !== "number") {
1261
+ return null;
1262
+ }
1263
+ return clamp01(props.remainingSeconds / props.totalSeconds);
1264
+ }, [props.remainingSeconds, props.totalSeconds]);
1265
+ const style = TYPE_STYLE[type];
1266
+ const hand = handFromProgress(progress01 ?? 1);
1267
+ const caption = props.label ?? style.label;
1268
+ if (props.variant === "COMPACT") {
1269
+ const face2 = renderCompactClockFace(hand);
1270
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
1271
+ face2.map((line, i) => /* @__PURE__ */ jsx7(Text7, { color: style.color, children: line }, `clock:compact:${type}:${hand}:${i}`)),
1272
+ props.showLabel === false ? null : /* @__PURE__ */ jsx7(Text7, { color: "gray", children: caption ?? " " })
1273
+ ] });
1274
+ }
1275
+ const face = renderClockFace(hand);
1276
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
1277
+ face.map((line, i) => /* @__PURE__ */ jsx7(Text7, { color: style.color, children: line }, `clock:${type}:${hand}:${i}`)),
1278
+ props.showLabel === false ? null : /* @__PURE__ */ jsx7(Text7, { color: "gray", children: caption ?? " " })
1279
+ ] });
1280
+ }
1281
+ var TYPE_STYLE;
1282
+ var init_CountdownClockSprite = __esm({
1283
+ "src/components/sprite/CountdownClockSprite.tsx"() {
1284
+ "use strict";
1285
+ TYPE_STYLE = {
1286
+ SHORT: { color: "green", label: "Sprint" },
1287
+ MEDIUM: { color: "cyan", label: "Focus" },
1288
+ LONG: { color: "magenta", label: "Deep" }
1289
+ };
1290
+ }
1291
+ });
1292
+
1293
+ // src/components/sprite/ProjectileThrowSprite.tsx
1294
+ import { useEffect as useEffect7, useMemo as useMemo6, useState as useState7 } from "react";
1295
+ import { Box as Box8, Text as Text8 } from "ink";
1296
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1297
+ function clamp012(n) {
1298
+ if (n <= 0) return 0;
1299
+ if (n >= 1) return 1;
1300
+ return n;
1301
+ }
1302
+ function renderTrack(width, pos, glyph) {
1303
+ const w = Math.max(8, Math.floor(width));
1304
+ const innerWidth = w - 2;
1305
+ if (pos < 0) return `|${new Array(innerWidth).fill("\xB7").join("")}|`;
1306
+ const clampedPos = Math.max(0, Math.min(innerWidth - 1, Math.floor(pos)));
1307
+ const track = new Array(innerWidth).fill("\xB7");
1308
+ track[clampedPos] = glyph;
1309
+ return `|${track.join("")}|`;
1310
+ }
1311
+ function ProjectileThrowSprite(props) {
1312
+ const direction = props.direction ?? "LEFT_TO_RIGHT";
1313
+ const width = props.width ?? 28;
1314
+ const durationMs = props.durationMs ?? 700;
1315
+ const [autoProgress, setAutoProgress] = useState7(null);
1316
+ const progress = typeof props.progress === "number" ? props.progress : autoProgress;
1317
+ useEffect7(() => {
1318
+ if (props.shotId === void 0) return;
1319
+ const startedAt = Date.now();
1320
+ setAutoProgress(0);
1321
+ const handle = setInterval(() => {
1322
+ const elapsed = Date.now() - startedAt;
1323
+ const next = clamp012(elapsed / Math.max(1, durationMs));
1324
+ setAutoProgress(next);
1325
+ if (next >= 1) {
1326
+ clearInterval(handle);
1327
+ props.onDone?.();
1328
+ }
1329
+ }, 33);
1330
+ return () => clearInterval(handle);
1331
+ }, [durationMs, props.onDone, props.shotId]);
1332
+ const projectile = PROJECTILES[props.kind];
1333
+ const track = useMemo6(() => {
1334
+ if (progress === null || !Number.isFinite(progress)) {
1335
+ return renderTrack(width, -1, " ");
1336
+ }
1337
+ const innerWidth = Math.max(8, Math.floor(width)) - 2;
1338
+ const rawPos = clamp012(progress) * (innerWidth - 1);
1339
+ const pos = direction === "LEFT_TO_RIGHT" ? rawPos : innerWidth - 1 - rawPos;
1340
+ return renderTrack(width, pos, projectile.glyph);
1341
+ }, [direction, progress, projectile.glyph, width]);
1342
+ return /* @__PURE__ */ jsxs7(Box8, { children: [
1343
+ props.leftLabel ? /* @__PURE__ */ jsxs7(Text8, { color: "gray", children: [
1344
+ props.leftLabel,
1345
+ " "
1346
+ ] }) : null,
1347
+ /* @__PURE__ */ jsx8(Text8, { color: projectile.color, children: track }),
1348
+ props.rightLabel ? /* @__PURE__ */ jsxs7(Text8, { color: "gray", children: [
1349
+ " ",
1350
+ props.rightLabel
1351
+ ] }) : null
1352
+ ] });
1353
+ }
1354
+ var PROJECTILES;
1355
+ var init_ProjectileThrowSprite = __esm({
1356
+ "src/components/sprite/ProjectileThrowSprite.tsx"() {
1357
+ "use strict";
1358
+ PROJECTILES = {
1359
+ ROSE: { glyph: "\u{1F339}", color: "magenta" },
1360
+ POOP: { glyph: "\u{1F4A9}", color: "yellow" },
1361
+ HAMMER: { glyph: "\u{1F528}", color: "cyan" }
1362
+ };
1363
+ }
1364
+ });
1365
+
726
1366
  // src/components/StatusHeader.tsx
727
- import { Box as Box6, Text as Text6 } from "ink";
728
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1367
+ import { Box as Box9, Text as Text9 } from "ink";
1368
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
729
1369
  function statusText(status) {
730
1370
  switch (status) {
731
1371
  case "waiting":
@@ -740,16 +1380,10 @@ function statusText(status) {
740
1380
  }
741
1381
  function StatusHeader(props) {
742
1382
  const st = statusText(props.status);
743
- return /* @__PURE__ */ jsxs4(Box6, { justifyContent: "space-between", children: [
744
- /* @__PURE__ */ jsxs4(Box6, { children: [
745
- /* @__PURE__ */ jsx6(Text6, { color: st.color, children: st.label }),
746
- props.role === "host" ? /* @__PURE__ */ jsx6(Text6, { color: "gray", children: props.tcpPort ? ` \u2014 TCP :${props.tcpPort}` : "" }) : /* @__PURE__ */ jsx6(Text6, { color: "gray", children: props.hostIp && props.tcpPort ? ` \u2014 ${props.hostIp}:${props.tcpPort}` : "" })
747
- ] }),
748
- /* @__PURE__ */ jsx6(Box6, { children: props.countdownLabel ? /* @__PURE__ */ jsxs4(Text6, { color: "cyan", children: [
749
- "Focus ",
750
- props.countdownLabel
751
- ] }) : /* @__PURE__ */ jsx6(Text6, { children: " " }) })
752
- ] });
1383
+ return /* @__PURE__ */ jsx9(Box9, { children: /* @__PURE__ */ jsxs8(Box9, { children: [
1384
+ /* @__PURE__ */ jsx9(Text9, { color: st.color, children: st.label }),
1385
+ props.role === "host" ? /* @__PURE__ */ jsx9(Text9, { color: "gray", children: props.tcpPort ? ` \u2014 TCP :${props.tcpPort}` : "" }) : /* @__PURE__ */ jsx9(Text9, { color: "gray", children: props.hostIp && props.tcpPort ? ` \u2014 ${props.hostIp}:${props.tcpPort}` : "" })
1386
+ ] }) });
753
1387
  }
754
1388
  var init_StatusHeader = __esm({
755
1389
  "src/components/StatusHeader.tsx"() {
@@ -762,21 +1396,31 @@ var init_components = __esm({
762
1396
  "src/components/index.ts"() {
763
1397
  "use strict";
764
1398
  init_AiConsole();
765
- init_AvatarDisplay();
766
1399
  init_BuddyAvatar();
1400
+ init_CountdownClockSprite();
1401
+ init_ProjectileThrowSprite();
767
1402
  init_StatusHeader();
768
1403
  }
769
1404
  });
770
1405
 
771
- // src/views/Session.tsx
772
- import { useCallback as useCallback4, useEffect as useEffect7, useMemo as useMemo3, useState as useState7 } from "react";
773
- import { Box as Box7, Text as Text7, useInput as useInput5 } from "ink";
774
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1406
+ // src/page/Session.tsx
1407
+ import { useCallback as useCallback4, useEffect as useEffect8, useMemo as useMemo7, useRef as useRef4, useState as useState8 } from "react";
1408
+ import { Box as Box10, Text as Text10, useInput as useInput7 } from "ink";
1409
+ import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
1410
+ function formatMMSS(totalSeconds) {
1411
+ const m = Math.floor(totalSeconds / 60);
1412
+ const s = totalSeconds % 60;
1413
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1414
+ }
775
1415
  function Session(props) {
776
- const roomName = useMemo3(() => `${props.localName}'s Room`, [props.localName]);
777
- const [showAi, setShowAi] = useState7(false);
778
- const countdown = useCountdown();
779
- const tcpOptions = useMemo3(() => {
1416
+ const roomName = useMemo7(
1417
+ () => `${props.localName}'s Room`,
1418
+ [props.localName]
1419
+ );
1420
+ const [showAi, setShowAi] = useState8(false);
1421
+ const [countdown, setCountdown] = useState8(null);
1422
+ const [shots, setShots] = useState8([]);
1423
+ const tcpOptions = useMemo7(() => {
780
1424
  return props.role === "host" ? { role: "host", localName: props.localName } : {
781
1425
  role: "client",
782
1426
  localName: props.localName,
@@ -792,7 +1436,7 @@ function Session(props) {
792
1436
  props.role === "client" ? props.hostName : ""
793
1437
  ]);
794
1438
  const tcp = useTcpSync(tcpOptions);
795
- const broadcasterOptions = useMemo3(() => {
1439
+ const broadcasterOptions = useMemo7(() => {
796
1440
  return props.role === "host" ? {
797
1441
  enabled: true,
798
1442
  hostName: props.localName,
@@ -805,89 +1449,272 @@ function Session(props) {
805
1449
  const remoteActivity = tcp.remoteState ?? "OFFLINE";
806
1450
  const onToggleAi = useCallback4(() => setShowAi((v) => !v), []);
807
1451
  const onCloseAi = useCallback4(() => setShowAi(false), []);
808
- useInput5(
1452
+ const sessionStartAtRef = useRef4(Date.now());
1453
+ const connectedStartAtRef = useRef4(null);
1454
+ const connectedTotalMsRef = useRef4(0);
1455
+ const keyPressesRef = useRef4(0);
1456
+ const useGlobalKeyboardRef = useRef4(false);
1457
+ const countKeyPress = useCallback4(() => {
1458
+ keyPressesRef.current += 1;
1459
+ }, []);
1460
+ useInput7(
1461
+ () => {
1462
+ if (!useGlobalKeyboardRef.current) countKeyPress();
1463
+ },
1464
+ { isActive: true }
1465
+ );
1466
+ useEffect8(() => {
1467
+ const raw = process.env.TERMBUDDY_ACTIVITY_SOURCE ?? "ink";
1468
+ const source = raw === "xinput" ? "keyboard" : raw;
1469
+ if (source !== "keyboard") return;
1470
+ let cancelled = false;
1471
+ let unsub = null;
1472
+ void (async () => {
1473
+ const ok = await ensureGlobalKeyboard();
1474
+ if (cancelled) return;
1475
+ if (!ok) return;
1476
+ useGlobalKeyboardRef.current = true;
1477
+ unsub = subscribeGlobalKeydown(countKeyPress);
1478
+ })();
1479
+ return () => {
1480
+ cancelled = true;
1481
+ unsub?.();
1482
+ useGlobalKeyboardRef.current = false;
1483
+ };
1484
+ }, [countKeyPress]);
1485
+ useEffect8(() => {
1486
+ if (tcp.status === "connected") {
1487
+ if (connectedStartAtRef.current === null) {
1488
+ connectedStartAtRef.current = Date.now();
1489
+ }
1490
+ return;
1491
+ }
1492
+ if (connectedStartAtRef.current !== null) {
1493
+ connectedTotalMsRef.current += Date.now() - connectedStartAtRef.current;
1494
+ connectedStartAtRef.current = null;
1495
+ }
1496
+ }, [tcp.status]);
1497
+ const finishAndLeave = useCallback4(() => {
1498
+ const endedAt = Date.now();
1499
+ let connectedDurationMs = connectedTotalMsRef.current;
1500
+ if (connectedStartAtRef.current !== null) {
1501
+ connectedDurationMs += endedAt - connectedStartAtRef.current;
1502
+ }
1503
+ const stats = {
1504
+ keyPresses: keyPressesRef.current,
1505
+ sessionDurationMs: endedAt - sessionStartAtRef.current,
1506
+ connectedDurationMs,
1507
+ startedAt: sessionStartAtRef.current,
1508
+ endedAt,
1509
+ peerName: tcp.peerName
1510
+ };
1511
+ props.onLeave(stats);
1512
+ }, [props, tcp.peerName]);
1513
+ const startCountdown = useCallback4((minutes) => {
1514
+ const totalSeconds = Math.max(1, Math.floor(minutes * 60));
1515
+ const endsAt = Date.now() + totalSeconds * 1e3;
1516
+ const type = minutes <= 10 ? "SHORT" : minutes <= 30 ? "MEDIUM" : "LONG";
1517
+ setCountdown({
1518
+ minutes,
1519
+ totalSeconds,
1520
+ endsAt,
1521
+ remainingSeconds: totalSeconds,
1522
+ type
1523
+ });
1524
+ }, []);
1525
+ const throwProjectile = useCallback4(
1526
+ (kind, direction) => {
1527
+ const id = Date.now() + Math.floor(Math.random() * 1e3);
1528
+ setShots((prev) => [...prev, { id, kind, direction }]);
1529
+ },
1530
+ []
1531
+ );
1532
+ useInput7(
809
1533
  (input, key) => {
810
- if (input === "q") props.onExit();
1534
+ if (input === "q") finishAndLeave();
811
1535
  if (input === "/" && !key.ctrl && !key.meta) onToggleAi();
812
1536
  },
813
1537
  { isActive: !showAi }
814
1538
  );
815
- const buddyName = props.role === "host" ? tcp.peerName ?? "Waiting..." : `${props.hostName ?? "Host"} (${props.roomName ?? "Room"})`;
1539
+ useInput7(
1540
+ (input) => {
1541
+ if (input === "x") setCountdown(null);
1542
+ },
1543
+ { isActive: !showAi && countdown !== null }
1544
+ );
1545
+ const buddyName = props.role === "host" ? tcp.peerName ?? "Waiting..." : props.hostName ?? "Host";
816
1546
  const localState = localActivity.state;
817
1547
  const localLabel = props.role === "host" ? `${props.localName} (Host)` : `${props.localName} (Client)`;
818
- useEffect7(() => {
1548
+ useEffect8(() => {
819
1549
  if (tcp.status !== "connected") return;
820
1550
  tcp.sendStatus(localState);
821
1551
  }, [localState, tcp.status, tcp.sendStatus]);
822
- return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", padding: 1, children: [
823
- /* @__PURE__ */ jsx7(
1552
+ useEffect8(() => {
1553
+ if (!countdown) return;
1554
+ const endsAt = countdown.endsAt;
1555
+ const handle = setInterval(() => {
1556
+ const remaining = Math.max(0, Math.ceil((endsAt - Date.now()) / 1e3));
1557
+ setCountdown((prev) => {
1558
+ if (!prev) return prev;
1559
+ if (prev.endsAt !== endsAt) return prev;
1560
+ if (remaining <= 0) return null;
1561
+ if (prev.remainingSeconds === remaining) return prev;
1562
+ return { ...prev, remainingSeconds: remaining };
1563
+ });
1564
+ }, 250);
1565
+ return () => clearInterval(handle);
1566
+ }, [countdown?.endsAt]);
1567
+ return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", padding: 1, children: [
1568
+ /* @__PURE__ */ jsx10(
824
1569
  StatusHeader,
825
1570
  {
826
1571
  role: props.role,
827
1572
  status: tcp.status,
828
1573
  hostIp: props.role === "client" ? props.hostIp : void 0,
829
- tcpPort: props.role === "client" ? props.tcpPort : tcp.listenPort,
830
- countdownLabel: countdown.label
1574
+ tcpPort: props.role === "client" ? props.tcpPort : tcp.listenPort
831
1575
  }
832
1576
  ),
833
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "row", gap: 4, marginTop: 1, children: [
834
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", width: "50%", children: [
835
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: localLabel }),
836
- /* @__PURE__ */ jsx7(BuddyAvatar, { state: localState })
837
- ] }),
838
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", width: "50%", children: [
839
- /* @__PURE__ */ jsx7(Text7, { color: "magenta", children: buddyName }),
840
- /* @__PURE__ */ jsx7(BuddyAvatar, { state: remoteActivity })
841
- ] })
842
- ] }),
843
- /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text7, { color: "gray", children: [
1577
+ /* @__PURE__ */ jsxs9(
1578
+ Box10,
1579
+ {
1580
+ flexDirection: "row",
1581
+ alignItems: "center",
1582
+ justifyContent: "space-between",
1583
+ marginTop: 1,
1584
+ gap: 2,
1585
+ children: [
1586
+ /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", alignItems: "center", minWidth: 20, children: [
1587
+ countdown ? /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: [
1588
+ /* @__PURE__ */ jsx10(Text10, { color: "gray", children: formatMMSS(countdown.remainingSeconds) }),
1589
+ /* @__PURE__ */ jsx10(
1590
+ CountdownClockSprite,
1591
+ {
1592
+ variant: "COMPACT",
1593
+ type: countdown.type,
1594
+ minutes: countdown.minutes,
1595
+ totalSeconds: countdown.totalSeconds,
1596
+ remainingSeconds: countdown.remainingSeconds,
1597
+ showLabel: false
1598
+ }
1599
+ )
1600
+ ] }) : /* @__PURE__ */ jsx10(Box10, { height: 4 }),
1601
+ /* @__PURE__ */ jsx10(BuddyAvatar, { state: localState, marginTop: 0 }),
1602
+ /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: localLabel })
1603
+ ] }),
1604
+ /* @__PURE__ */ jsx10(
1605
+ Box10,
1606
+ {
1607
+ flexDirection: "column",
1608
+ alignItems: "center",
1609
+ flexGrow: 1,
1610
+ minWidth: 40,
1611
+ children: /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", width: "100%", alignItems: "center", children: [
1612
+ shots.map((s) => /* @__PURE__ */ jsx10(
1613
+ ProjectileThrowSprite,
1614
+ {
1615
+ kind: s.kind,
1616
+ direction: s.direction,
1617
+ shotId: s.id,
1618
+ width: 36,
1619
+ onDone: () => setShots((prev) => prev.filter((x) => x.id !== s.id))
1620
+ },
1621
+ String(s.id)
1622
+ )),
1623
+ shots.length === 0 ? /* @__PURE__ */ jsx10(Box10, { height: 1 }) : null
1624
+ ] })
1625
+ }
1626
+ ),
1627
+ /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", alignItems: "center", minWidth: 20, children: [
1628
+ /* @__PURE__ */ jsx10(Box10, { height: 4 }),
1629
+ /* @__PURE__ */ jsx10(BuddyAvatar, { state: remoteActivity, marginTop: 0 }),
1630
+ /* @__PURE__ */ jsx10(Text10, { color: "magenta", children: buddyName })
1631
+ ] })
1632
+ ]
1633
+ }
1634
+ ),
1635
+ !showAi ? /* @__PURE__ */ jsx10(Box10, { marginTop: 1, justifyContent: "center", children: /* @__PURE__ */ jsxs9(Text10, { color: "gray", children: [
844
1636
  "\u6309 ",
845
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: "/" }),
846
- " \u53EC\u5524 AI Console\uFF0C\u6309 ",
847
- /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: "q" }),
848
- " \u8FD4\u56DE\u83DC\u5355\u3002"
849
- ] }) }),
850
- showAi ? /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
851
- AiConsole,
1637
+ /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: "/" }),
1638
+ " \u53EC\u5524 AI Console\uFF0C\u6309",
1639
+ " ",
1640
+ /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: "q" }),
1641
+ " \u7ED3\u675F\u672C\u6B21\u966A\u4F34\u3002",
1642
+ countdown ? /* @__PURE__ */ jsxs9(Fragment2, { children: [
1643
+ " ",
1644
+ /* @__PURE__ */ jsxs9(Text10, { color: "gray", children: [
1645
+ "(\u5012\u8BA1\u65F6\u4E2D\uFF1A\u6309 ",
1646
+ /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: "x" }),
1647
+ " \u53D6\u6D88)"
1648
+ ] })
1649
+ ] }) : null
1650
+ ] }) }) : null,
1651
+ showAi ? /* @__PURE__ */ jsx10(
1652
+ Box10,
852
1653
  {
853
- onClose: onCloseAi,
854
- onStartCountdown: countdown.start,
855
- localName: props.localName,
856
- peerName: tcp.peerName ?? (props.role === "client" ? props.hostName : void 0) ?? "Buddy"
1654
+ marginTop: 1,
1655
+ width: "100%",
1656
+ flexDirection: "row",
1657
+ justifyContent: "center",
1658
+ children: /* @__PURE__ */ jsx10(Box10, { width: 64, children: /* @__PURE__ */ jsx10(
1659
+ AiConsole,
1660
+ {
1661
+ onClose: onCloseAi,
1662
+ onStartCountdown: startCountdown,
1663
+ onThrowProjectile: throwProjectile,
1664
+ localName: props.localName,
1665
+ peerName: tcp.peerName ?? (props.role === "client" ? props.hostName : void 0) ?? "Buddy"
1666
+ }
1667
+ ) })
857
1668
  }
858
- ) }) : null
1669
+ ) : null
859
1670
  ] });
860
1671
  }
861
1672
  var init_Session = __esm({
862
- "src/views/Session.tsx"() {
1673
+ "src/page/Session.tsx"() {
863
1674
  "use strict";
864
1675
  init_components();
865
1676
  init_hooks();
1677
+ init_globalKeyboard();
866
1678
  }
867
1679
  });
868
1680
 
869
- // src/views/index.ts
870
- var init_views = __esm({
871
- "src/views/index.ts"() {
1681
+ // src/page/index.ts
1682
+ var init_page = __esm({
1683
+ "src/page/index.ts"() {
872
1684
  "use strict";
873
1685
  init_MainMenu();
1686
+ init_NicknamePrompt();
874
1687
  init_RoomScanner();
1688
+ init_LeavePage();
875
1689
  init_Session();
876
1690
  }
877
1691
  });
878
1692
 
879
1693
  // src/app/App.tsx
880
- import { useCallback as useCallback5, useMemo as useMemo4, useState as useState8 } from "react";
881
- import os2 from "os";
1694
+ import { useCallback as useCallback5, useMemo as useMemo8, useState as useState9 } from "react";
1695
+ import os3 from "os";
882
1696
  import { useApp } from "ink";
883
- import { jsx as jsx8 } from "react/jsx-runtime";
1697
+ import { jsx as jsx11 } from "react/jsx-runtime";
884
1698
  function App() {
885
1699
  const { exit } = useApp();
886
- const [view, setView] = useState8({ name: "MENU" });
887
- const localName = useMemo4(() => os2.hostname(), []);
1700
+ const [view, setView] = useState9({ name: "NICKNAME" });
1701
+ const [nickname, setNickname] = useState9(null);
1702
+ const localName = useMemo8(() => nickname ?? os3.hostname(), [nickname]);
888
1703
  const goMenu = useCallback5(() => setView({ name: "MENU" }), []);
1704
+ if (view.name === "NICKNAME") {
1705
+ return /* @__PURE__ */ jsx11(
1706
+ NicknamePrompt,
1707
+ {
1708
+ onExit: () => exit(),
1709
+ onSubmit: (name) => {
1710
+ setNickname(name);
1711
+ setView({ name: "MENU" });
1712
+ }
1713
+ }
1714
+ );
1715
+ }
889
1716
  if (view.name === "MENU") {
890
- return /* @__PURE__ */ jsx8(
1717
+ return /* @__PURE__ */ jsx11(
891
1718
  MainMenu,
892
1719
  {
893
1720
  onHost: () => setView({ name: "SESSION", role: "host" }),
@@ -896,8 +1723,11 @@ function App() {
896
1723
  }
897
1724
  );
898
1725
  }
1726
+ if (view.name === "LEAVE") {
1727
+ return /* @__PURE__ */ jsx11(LeavePage, { stats: view.stats, onBack: goMenu, onExit: () => exit() });
1728
+ }
899
1729
  if (view.name === "SCANNING") {
900
- return /* @__PURE__ */ jsx8(
1730
+ return /* @__PURE__ */ jsx11(
901
1731
  RoomScanner,
902
1732
  {
903
1733
  onBack: goMenu,
@@ -914,14 +1744,21 @@ function App() {
914
1744
  );
915
1745
  }
916
1746
  if (view.name === "SESSION" && view.role === "host") {
917
- return /* @__PURE__ */ jsx8(Session, { localName, role: "host", onExit: goMenu });
1747
+ return /* @__PURE__ */ jsx11(
1748
+ Session,
1749
+ {
1750
+ localName,
1751
+ role: "host",
1752
+ onLeave: (stats) => setView({ name: "LEAVE", stats })
1753
+ }
1754
+ );
918
1755
  }
919
- return /* @__PURE__ */ jsx8(
1756
+ return /* @__PURE__ */ jsx11(
920
1757
  Session,
921
1758
  {
922
1759
  localName,
923
1760
  role: "client",
924
- onExit: goMenu,
1761
+ onLeave: (stats) => setView({ name: "LEAVE", stats }),
925
1762
  hostIp: view.hostIp,
926
1763
  tcpPort: view.tcpPort,
927
1764
  roomName: view.roomName,
@@ -932,7 +1769,7 @@ function App() {
932
1769
  var init_App = __esm({
933
1770
  "src/app/App.tsx"() {
934
1771
  "use strict";
935
- init_views();
1772
+ init_page();
936
1773
  }
937
1774
  });
938
1775
 
@@ -950,8 +1787,8 @@ var init_app = __esm({
950
1787
 
951
1788
  // src/cli.tsx
952
1789
  process.env.NODE_ENV ??= "production";
953
- var React5 = await import("react");
1790
+ var React9 = await import("react");
954
1791
  var { render } = await import("ink");
955
1792
  var { App: App2 } = await Promise.resolve().then(() => (init_app(), app_exports));
956
- render(React5.createElement(App2));
1793
+ render(React9.createElement(App2));
957
1794
  //# sourceMappingURL=cli.js.map