@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.
- package/dist/cli.js +1097 -260
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/app/App.tsx +94 -53
- package/src/app/index.ts +1 -2
- package/src/components/AiConsole.tsx +171 -73
- package/src/components/StatusHeader.tsx +36 -36
- package/src/components/index.ts +8 -4
- package/src/components/sprite/BuddyAvatar.tsx +49 -0
- package/src/components/sprite/CountdownClockSprite.tsx +146 -0
- package/src/components/sprite/ProjectileThrowSprite.tsx +86 -0
- package/src/components/tool/createCountdownTool.ts +32 -0
- package/src/components/tool/createInteractionTool.ts +67 -0
- package/src/components/tool/createSessionInfoTool.ts +29 -0
- package/src/components/tool/index.ts +4 -0
- package/src/hooks/globalKeyboard.ts +146 -0
- package/src/hooks/index.ts +5 -7
- package/src/hooks/useActivityMonitor.ts +61 -24
- package/src/hooks/useAiAgent.ts +200 -165
- package/src/hooks/useBroadcaster.ts +55 -47
- package/src/hooks/useScanner.ts +59 -55
- package/src/hooks/useTcpSync.ts +166 -145
- package/src/net/broadcast.ts +21 -21
- package/src/net/index.ts +1 -2
- package/src/page/LeavePage.tsx +85 -0
- package/src/{views → page}/MainMenu.tsx +32 -28
- package/src/page/NicknamePrompt.tsx +62 -0
- package/src/{views → page}/RoomScanner.tsx +4 -1
- package/src/page/Session.tsx +364 -0
- package/src/page/index.ts +5 -0
- package/src/storage/apiKey.ts +36 -0
- package/src/types.ts +8 -0
- package/src/components/AvatarDisplay.tsx +0 -18
- package/src/components/BuddyAvatar.tsx +0 -32
- package/src/hooks/useCountdown.ts +0 -42
- package/src/views/Session.tsx +0 -127
- package/src/views/index.ts +0 -4
|
@@ -1,77 +1,175 @@
|
|
|
1
|
-
import React, {useMemo, useState} from
|
|
2
|
-
import {Box, Text, useInput} from
|
|
3
|
-
import {useAiAgent} from
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
2
|
-
import {Box, Text} from
|
|
3
|
-
import type {ConnectionStatus} from
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
countdownLabel?: string | null;
|
|
19
|
+
role: "host" | "client";
|
|
20
|
+
status: ConnectionStatus;
|
|
21
|
+
hostIp?: string;
|
|
22
|
+
tcpPort?: number;
|
|
24
23
|
}) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {AiConsole} from
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
|
|
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
|
+
|