@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
@@ -1,77 +1,175 @@
1
- import React, {useMemo, useState} from 'react';
2
- import {Box, Text, useInput} from 'ink';
3
- import {useAiAgent} from '../hooks/index.js';
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { useAiAgent } from "../hooks/index.js";
4
+ import { loadStoredApiKey, saveStoredApiKey } from "../storage/apiKey.js";
5
+ import type {
6
+ ProjectileDirection,
7
+ ProjectileKind,
8
+ } from "./sprite/ProjectileThrowSprite.js";
4
9
 
5
10
  export function AiConsole(props: {
6
- onClose: () => void;
7
- onStartCountdown: (minutes: number) => void;
8
- localName: string;
9
- peerName: string;
11
+ onClose: () => void;
12
+ onStartCountdown: (minutes: number) => void;
13
+ onThrowProjectile: (
14
+ kind: ProjectileKind,
15
+ direction: ProjectileDirection
16
+ ) => void;
17
+ localName: string;
18
+ peerName: string;
10
19
  }) {
11
- const [input, setInput] = useState('');
12
- const agent = useAiAgent({
13
- localName: props.localName,
14
- peerName: props.peerName,
15
- onStartCountdown: props.onStartCountdown
16
- });
17
-
18
- const helpLine = useMemo(
19
- () => '示例:倒计时20分钟 / countdown 20 / 问个技术问题',
20
- []
21
- );
22
-
23
- useInput(
24
- (ch, key) => {
25
- if (key.escape) {
26
- props.onClose();
27
- return;
28
- }
29
-
30
- if (key.return) {
31
- const line = input.trim();
32
- setInput('');
33
- if (!line) return;
34
- void agent.ask(line);
35
- return;
36
- }
37
-
38
- if (key.backspace || key.delete) {
39
- setInput((s) => s.slice(0, -1));
40
- return;
41
- }
42
-
43
- if (key.ctrl || key.meta) return;
44
- if (ch) setInput((s) => s + ch);
45
- },
46
- {isActive: true}
47
- );
48
-
49
- const lines = agent.lines.slice(-12);
50
-
51
- return (
52
- <Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0}>
53
- <Box justifyContent="space-between">
54
- <Text color="cyan">AI Console</Text>
55
- <Text color="gray">{agent.busy ? 'Thinking…' : 'Esc 关闭'}</Text>
56
- </Box>
57
-
58
- <Box flexDirection="column" marginTop={1}>
59
- <Text color="gray">{helpLine}</Text>
60
- </Box>
61
-
62
- <Box flexDirection="column" marginTop={1}>
63
- {lines.length === 0 ? <Text color="gray">(幽灵还在壳里…)</Text> : null}
64
- {lines.map((l, i) => (
65
- <Text key={`${l.kind}:${l.at}:${i}`} color={l.kind === 'user' ? 'yellow' : 'white'}>
66
- {l.text}
67
- </Text>
68
- ))}
69
- </Box>
70
-
71
- <Box marginTop={1}>
72
- <Text color="green">{'>'} </Text>
73
- <Text>{input}</Text>
74
- </Box>
75
- </Box>
76
- );
20
+ const [input, setInput] = useState("");
21
+ const [apiKey, setApiKey] = useState<string | null>(null);
22
+ const [keyDraft, setKeyDraft] = useState("");
23
+ const [keyStatus, setKeyStatus] = useState<
24
+ "loading" | "missing" | "ready" | "saving"
25
+ >("loading");
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ void (async () => {
30
+ const stored = await loadStoredApiKey();
31
+ if (cancelled) return;
32
+ if (stored) {
33
+ setApiKey(stored);
34
+ setKeyStatus("ready");
35
+ } else {
36
+ setKeyStatus("missing");
37
+ }
38
+ })();
39
+ return () => {
40
+ cancelled = true;
41
+ };
42
+ }, []);
43
+
44
+ const agent = useAiAgent({
45
+ localName: props.localName,
46
+ peerName: props.peerName,
47
+ onStartCountdown: props.onStartCountdown,
48
+ onThrowProjectile: props.onThrowProjectile,
49
+ apiKey: apiKey ?? undefined,
50
+ });
51
+
52
+ const helpLine = useMemo(
53
+ () => "示例:倒计时20分钟 / 聊会天 / 和别人互动一下",
54
+ []
55
+ );
56
+
57
+ useInput(
58
+ (ch, key) => {
59
+ if (key.escape) {
60
+ props.onClose();
61
+ return;
62
+ }
63
+
64
+ if (keyStatus !== "ready") {
65
+ if (key.return) {
66
+ const draft = keyDraft.trim();
67
+ if (!draft) return;
68
+ setKeyStatus("saving");
69
+ void (async () => {
70
+ await saveStoredApiKey(draft);
71
+ setApiKey(draft);
72
+ setKeyDraft("");
73
+ setKeyStatus("ready");
74
+ })();
75
+ return;
76
+ }
77
+
78
+ if (key.backspace || key.delete) {
79
+ setKeyDraft((s) => s.slice(0, -1));
80
+ return;
81
+ }
82
+
83
+ if (key.ctrl || key.meta) return;
84
+ if (ch) setKeyDraft((s) => s + ch);
85
+ return;
86
+ }
87
+
88
+ if (key.return) {
89
+ const line = input.trim();
90
+ setInput("");
91
+ if (!line) return;
92
+ void agent.ask(line);
93
+ return;
94
+ }
95
+
96
+ if (key.backspace || key.delete) {
97
+ setInput((s) => s.slice(0, -1));
98
+ return;
99
+ }
100
+
101
+ if (key.ctrl || key.meta) return;
102
+ if (ch) setInput((s) => s + ch);
103
+ },
104
+ { isActive: true }
105
+ );
106
+
107
+ const lines = agent.lines.filter((l) => l.text.trim().length > 0).slice(-6);
108
+
109
+ return (
110
+ <Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0}>
111
+ <Box justifyContent="space-between" marginBottom={0}>
112
+ <Text color="cyan">AI Console</Text>
113
+ <Text color="gray">
114
+ {keyStatus === "saving"
115
+ ? "Saving…"
116
+ : agent.busy
117
+ ? "Thinking…"
118
+ : "Esc Close"}
119
+ </Text>
120
+ </Box>
121
+
122
+ <Box flexDirection="column">
123
+ {keyStatus === "loading" ? (
124
+ <Text color="gray">Checking API Key...</Text>
125
+ ) : keyStatus === "missing" || keyStatus === "saving" ? (
126
+ <Text color="yellow">
127
+ Setup: Enter DeepSeek API Key (saves to{" "}
128
+ <Text color="cyan">src/assets/key.json</Text>)
129
+ </Text>
130
+ ) : lines.length === 0 ? (
131
+ <Text color="gray">{helpLine}</Text>
132
+ ) : null}
133
+ </Box>
134
+
135
+ <Box flexDirection="column" marginTop={0} minHeight={6}>
136
+ {keyStatus === "ready" ? (
137
+ <>
138
+ {lines.map((l, i) => (
139
+ <Text
140
+ key={`${l.kind}:${l.at}:${i}`}
141
+ color={l.kind === "user" ? "yellow" : "white"}
142
+ wrap="truncate-end"
143
+ >
144
+ {l.kind === "user" ? "> " : ""}
145
+ {l.text}
146
+ </Text>
147
+ ))}
148
+ </>
149
+ ) : (
150
+ <Text color="gray">Please enter API Key to proceed.</Text>
151
+ )}
152
+ </Box>
153
+
154
+ <Box
155
+ marginTop={0}
156
+ borderStyle="single"
157
+ borderTop={true}
158
+ borderBottom={false}
159
+ borderLeft={false}
160
+ borderRight={false}
161
+ >
162
+ <Text color="green">{">"} </Text>
163
+ {keyStatus === "ready" ? (
164
+ <Text>{input}</Text>
165
+ ) : (
166
+ <Text>
167
+ {keyDraft.length === 0
168
+ ? ""
169
+ : "*".repeat(Math.min(64, keyDraft.length))}
170
+ </Text>
171
+ )}
172
+ </Box>
173
+ </Box>
174
+ );
77
175
  }
@@ -1,43 +1,43 @@
1
- import React from 'react';
2
- import {Box, Text} from 'ink';
3
- import type {ConnectionStatus} from '../protocol.js';
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { ConnectionStatus } from "../protocol.js";
4
4
 
5
5
  function statusText(status: ConnectionStatus) {
6
- switch (status) {
7
- case 'waiting':
8
- return {label: 'Waiting', color: 'yellow'};
9
- case 'connecting':
10
- return {label: 'Connecting', color: 'yellow'};
11
- case 'connected':
12
- return {label: 'Connected via TCP', color: 'green'};
13
- case 'disconnected':
14
- return {label: 'Disconnected', color: 'red'};
15
- }
6
+ switch (status) {
7
+ case "waiting":
8
+ return { label: "Waiting", color: "yellow" };
9
+ case "connecting":
10
+ return { label: "Connecting", color: "yellow" };
11
+ case "connected":
12
+ return { label: "Connected via TCP", color: "green" };
13
+ case "disconnected":
14
+ return { label: "Disconnected", color: "red" };
15
+ }
16
16
  }
17
17
 
18
18
  export function StatusHeader(props: {
19
- role: 'host' | 'client';
20
- status: ConnectionStatus;
21
- hostIp?: string;
22
- tcpPort?: number;
23
- countdownLabel?: string | null;
19
+ role: "host" | "client";
20
+ status: ConnectionStatus;
21
+ hostIp?: string;
22
+ tcpPort?: number;
24
23
  }) {
25
- const st = statusText(props.status);
26
- return (
27
- <Box justifyContent="space-between">
28
- <Box>
29
- <Text color={st.color}>{st.label}</Text>
30
- {props.role === 'host' ? (
31
- <Text color="gray">{props.tcpPort ? ` — TCP :${props.tcpPort}` : ''}</Text>
32
- ) : (
33
- <Text color="gray">
34
- {props.hostIp && props.tcpPort ? ` — ${props.hostIp}:${props.tcpPort}` : ''}
35
- </Text>
36
- )}
37
- </Box>
38
- <Box>
39
- {props.countdownLabel ? <Text color="cyan">Focus {props.countdownLabel}</Text> : <Text> </Text>}
40
- </Box>
41
- </Box>
42
- );
24
+ const st = statusText(props.status);
25
+ return (
26
+ <Box>
27
+ <Box>
28
+ <Text color={st.color}>{st.label}</Text>
29
+ {props.role === "host" ? (
30
+ <Text color="gray">
31
+ {props.tcpPort ? ` — TCP :${props.tcpPort}` : ""}
32
+ </Text>
33
+ ) : (
34
+ <Text color="gray">
35
+ {props.hostIp && props.tcpPort
36
+ ? ` — ${props.hostIp}:${props.tcpPort}`
37
+ : ""}
38
+ </Text>
39
+ )}
40
+ </Box>
41
+ </Box>
42
+ );
43
43
  }
@@ -1,4 +1,8 @@
1
- export {AiConsole} from './AiConsole.js';
2
- export {AvatarDisplay} from './AvatarDisplay.js';
3
- export {BuddyAvatar} from './BuddyAvatar.js';
4
- export {StatusHeader} from './StatusHeader.js';
1
+ export { AiConsole } from "./AiConsole.js";
2
+ export { BuddyAvatar } from "./sprite/BuddyAvatar.js";
3
+ export {
4
+ CountdownClockSprite,
5
+ countdownClockTypeFromMinutes,
6
+ } from "./sprite/CountdownClockSprite.js";
7
+ export { ProjectileThrowSprite } from "./sprite/ProjectileThrowSprite.js";
8
+ export { StatusHeader } from "./StatusHeader.js";
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { ActivityState } from "../../protocol.js";
4
+
5
+ const SPRITES: Record<
6
+ ActivityState,
7
+ { color?: string; compact: string; frames: string[] }
8
+ > = {
9
+ TYPING: {
10
+ color: "green",
11
+ compact: "( >_<)===3",
12
+ frames: [" /\\_/\\ ", "( >_<) ", " /|_|\\\\ ", " / \\\\ "],
13
+ },
14
+ IDLE: {
15
+ color: "yellow",
16
+ compact: "( -.-)Zzz",
17
+ frames: [" /\\_/\\ ", "( -.-) ", " /|_|\\\\ ", " / \\\\ "],
18
+ },
19
+ OFFLINE: {
20
+ color: "gray",
21
+ compact: "( x_x)",
22
+ frames: [" /\\_/\\ ", "( x_x) ", " /|_|\\\\ ", " / \\\\ "],
23
+ },
24
+ };
25
+
26
+ export function BuddyAvatar(props: {
27
+ state: ActivityState;
28
+ variant?: "frames" | "compact";
29
+ marginTop?: number;
30
+ }) {
31
+ const sprite = SPRITES[props.state];
32
+ if (props.variant === "compact") {
33
+ return (
34
+ <Box marginTop={props.marginTop ?? 1}>
35
+ <Text color={sprite.color}>{sprite.compact}</Text>
36
+ </Box>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <Box flexDirection="column" marginTop={props.marginTop ?? 1}>
42
+ {sprite.frames.map((line, i) => (
43
+ <Text key={`${props.state}:${i}`} color={sprite.color}>
44
+ {line}
45
+ </Text>
46
+ ))}
47
+ </Box>
48
+ );
49
+ }
@@ -0,0 +1,146 @@
1
+ import React, { useMemo } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export type CountdownClockType = "SHORT" | "MEDIUM" | "LONG";
5
+ export type CountdownClockVariant = "FULL" | "COMPACT";
6
+
7
+ export function countdownClockTypeFromMinutes(
8
+ minutes: number
9
+ ): CountdownClockType {
10
+ if (minutes <= 10) return "SHORT";
11
+ if (minutes <= 30) return "MEDIUM";
12
+ return "LONG";
13
+ }
14
+
15
+ function clamp01(n: number) {
16
+ if (n <= 0) return 0;
17
+ if (n >= 1) return 1;
18
+ return n;
19
+ }
20
+
21
+ const TYPE_STYLE: Record<CountdownClockType, { color: string; label: string }> =
22
+ {
23
+ SHORT: { color: "green", label: "Sprint" },
24
+ MEDIUM: { color: "cyan", label: "Focus" },
25
+ LONG: { color: "magenta", label: "Deep" },
26
+ };
27
+
28
+ type HandDir = "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW";
29
+
30
+ function handFromProgress(progress01: number): HandDir {
31
+ const idx = Math.round(clamp01(progress01) * 7);
32
+ const dirs: HandDir[] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
33
+ return dirs[idx]!;
34
+ }
35
+
36
+ function renderClockFace(hand: HandDir) {
37
+ const lines = [
38
+ " .---. ",
39
+ " / \\ ",
40
+ "| • |",
41
+ " \\ / ",
42
+ " '---' ",
43
+ ].map((s) => s.split(""));
44
+
45
+ const center = { r: 2, c: 4 };
46
+ const handMap: Record<HandDir, { r: number; c: number; ch: string }> = {
47
+ N: { r: 1, c: 4, ch: "|" },
48
+ NE: { r: 1, c: 5, ch: "/" },
49
+ E: { r: 2, c: 5, ch: "-" },
50
+ SE: { r: 3, c: 5, ch: "\\" },
51
+ S: { r: 3, c: 4, ch: "|" },
52
+ SW: { r: 3, c: 3, ch: "/" },
53
+ W: { r: 2, c: 3, ch: "-" },
54
+ NW: { r: 1, c: 3, ch: "\\" },
55
+ };
56
+
57
+ const tip = handMap[hand];
58
+ lines[center.r][center.c] = "•";
59
+ lines[tip.r][tip.c] = tip.ch;
60
+
61
+ return lines.map((row) => row.join(""));
62
+ }
63
+
64
+ function renderCompactClockFace(hand: HandDir) {
65
+ const lines = [" .---. ", "| • |", " '---' "].map((s) => s.split(""));
66
+ const center = { r: 1, c: 3 };
67
+ const handMap: Record<HandDir, { r: number; c: number; ch: string }> = {
68
+ N: { r: 0, c: 3, ch: "|" },
69
+ NE: { r: 0, c: 4, ch: "/" },
70
+ E: { r: 1, c: 5, ch: "-" },
71
+ SE: { r: 2, c: 4, ch: "\\" },
72
+ S: { r: 2, c: 3, ch: "|" },
73
+ SW: { r: 2, c: 2, ch: "/" },
74
+ W: { r: 1, c: 1, ch: "-" },
75
+ NW: { r: 0, c: 2, ch: "\\" },
76
+ };
77
+
78
+ const tip = handMap[hand];
79
+ lines[center.r][center.c] = "•";
80
+ lines[tip.r][tip.c] = tip.ch;
81
+
82
+ return lines.map((row) => row.join(""));
83
+ }
84
+
85
+ export function CountdownClockSprite(props: {
86
+ type?: CountdownClockType;
87
+ variant?: CountdownClockVariant;
88
+ minutes?: number;
89
+ label?: string | null;
90
+ showLabel?: boolean;
91
+ totalSeconds?: number;
92
+ remainingSeconds?: number | null;
93
+ }) {
94
+ const type =
95
+ props.type ??
96
+ (typeof props.minutes === "number"
97
+ ? countdownClockTypeFromMinutes(props.minutes)
98
+ : "MEDIUM");
99
+
100
+ const progress01 = useMemo(() => {
101
+ if (
102
+ typeof props.totalSeconds !== "number" ||
103
+ props.totalSeconds <= 0 ||
104
+ props.remainingSeconds === null ||
105
+ typeof props.remainingSeconds !== "number"
106
+ ) {
107
+ return null;
108
+ }
109
+ return clamp01(props.remainingSeconds / props.totalSeconds);
110
+ }, [props.remainingSeconds, props.totalSeconds]);
111
+
112
+ const style = TYPE_STYLE[type];
113
+ const hand = handFromProgress(progress01 ?? 1);
114
+ const caption = props.label ?? style.label;
115
+
116
+ if (props.variant === "COMPACT") {
117
+ const face = renderCompactClockFace(hand);
118
+ return (
119
+ <Box flexDirection="column">
120
+ {face.map((line, i) => (
121
+ <Text key={`clock:compact:${type}:${hand}:${i}`} color={style.color}>
122
+ {line}
123
+ </Text>
124
+ ))}
125
+ {props.showLabel === false ? null : (
126
+ <Text color="gray">{caption ?? " "}</Text>
127
+ )}
128
+ </Box>
129
+ );
130
+ }
131
+
132
+ const face = renderClockFace(hand);
133
+
134
+ return (
135
+ <Box flexDirection="column">
136
+ {face.map((line, i) => (
137
+ <Text key={`clock:${type}:${hand}:${i}`} color={style.color}>
138
+ {line}
139
+ </Text>
140
+ ))}
141
+ {props.showLabel === false ? null : (
142
+ <Text color="gray">{caption ?? " "}</Text>
143
+ )}
144
+ </Box>
145
+ );
146
+ }
@@ -0,0 +1,86 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export type ProjectileKind = "ROSE" | "POOP" | "HAMMER";
5
+ export type ProjectileDirection = "LEFT_TO_RIGHT" | "RIGHT_TO_LEFT";
6
+
7
+ const PROJECTILES: Record<ProjectileKind, { glyph: string; color: string }> = {
8
+ ROSE: { glyph: "🌹", color: "magenta" },
9
+ POOP: { glyph: "💩", color: "yellow" },
10
+ HAMMER: { glyph: "🔨", color: "cyan" },
11
+ };
12
+
13
+ function clamp01(n: number) {
14
+ if (n <= 0) return 0;
15
+ if (n >= 1) return 1;
16
+ return n;
17
+ }
18
+
19
+ function renderTrack(width: number, pos: number, glyph: string) {
20
+ const w = Math.max(8, Math.floor(width));
21
+ const innerWidth = w - 2;
22
+ if (pos < 0) return `|${new Array(innerWidth).fill("·").join("")}|`;
23
+ const clampedPos = Math.max(0, Math.min(innerWidth - 1, Math.floor(pos)));
24
+
25
+ const track = new Array(innerWidth).fill("·");
26
+ track[clampedPos] = glyph;
27
+ return `|${track.join("")}|`;
28
+ }
29
+
30
+ export function ProjectileThrowSprite(props: {
31
+ kind: ProjectileKind;
32
+ direction?: ProjectileDirection;
33
+ width?: number;
34
+ progress?: number;
35
+ shotId?: string | number;
36
+ durationMs?: number;
37
+ leftLabel?: string;
38
+ rightLabel?: string;
39
+ onDone?: () => void;
40
+ }) {
41
+ const direction = props.direction ?? "LEFT_TO_RIGHT";
42
+ const width = props.width ?? 28;
43
+ const durationMs = props.durationMs ?? 700;
44
+
45
+ const [autoProgress, setAutoProgress] = useState<number | null>(null);
46
+ const progress = typeof props.progress === "number" ? props.progress : autoProgress;
47
+
48
+ useEffect(() => {
49
+ if (props.shotId === undefined) return;
50
+ const startedAt = Date.now();
51
+ setAutoProgress(0);
52
+
53
+ const handle = setInterval(() => {
54
+ const elapsed = Date.now() - startedAt;
55
+ const next = clamp01(elapsed / Math.max(1, durationMs));
56
+ setAutoProgress(next);
57
+ if (next >= 1) {
58
+ clearInterval(handle);
59
+ props.onDone?.();
60
+ }
61
+ }, 33);
62
+
63
+ return () => clearInterval(handle);
64
+ }, [durationMs, props.onDone, props.shotId]);
65
+
66
+ const projectile = PROJECTILES[props.kind];
67
+
68
+ const track = useMemo(() => {
69
+ if (progress === null || !Number.isFinite(progress)) {
70
+ return renderTrack(width, -1, " ");
71
+ }
72
+ const innerWidth = Math.max(8, Math.floor(width)) - 2;
73
+ const rawPos = clamp01(progress) * (innerWidth - 1);
74
+ const pos =
75
+ direction === "LEFT_TO_RIGHT" ? rawPos : (innerWidth - 1 - rawPos);
76
+ return renderTrack(width, pos, projectile.glyph);
77
+ }, [direction, progress, projectile.glyph, width]);
78
+
79
+ return (
80
+ <Box>
81
+ {props.leftLabel ? <Text color="gray">{props.leftLabel} </Text> : null}
82
+ <Text color={projectile.color}>{track}</Text>
83
+ {props.rightLabel ? <Text color="gray"> {props.rightLabel}</Text> : null}
84
+ </Box>
85
+ );
86
+ }
@@ -0,0 +1,32 @@
1
+ import { tool } from "langchain";
2
+
3
+ export function createCountdownTool(options: {
4
+ onStartCountdown?: (minutes: number) => void;
5
+ }) {
6
+ return tool(
7
+ async (input: { minutes: number }) => {
8
+ const minutes = Number(input.minutes);
9
+ if (!Number.isFinite(minutes) || minutes <= 0) return "倒计时分钟数无效。";
10
+ options.onStartCountdown?.(minutes);
11
+ return `已开始倒计时 ${minutes} 分钟。`;
12
+ },
13
+ {
14
+ name: "start_countdown",
15
+ description: "开始一个专注倒计时(分钟)。",
16
+ schema: {
17
+ type: "object",
18
+ properties: {
19
+ minutes: {
20
+ type: "integer",
21
+ minimum: 1,
22
+ maximum: 180,
23
+ description: "倒计时分钟数",
24
+ },
25
+ },
26
+ required: ["minutes"],
27
+ additionalProperties: false,
28
+ },
29
+ }
30
+ );
31
+ }
32
+