@yahaha-studio/kichi-forwarder 0.0.1-alpha.44 → 0.0.1-alpha.46
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/index.ts +284 -2
- package/package.json +1 -1
- package/src/service.ts +15 -2
- package/src/types.ts +8 -0
package/index.ts
CHANGED
|
@@ -71,6 +71,21 @@ const MAX_NOTEBOARD_TEXT_LENGTH = 200;
|
|
|
71
71
|
const MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH = 20;
|
|
72
72
|
const MESSAGE_RECEIVED_ELLIPSIS = "...";
|
|
73
73
|
const BUNDLED_ALBUM_CONFIG_PATH = new URL("./config/album-config.json", import.meta.url);
|
|
74
|
+
const ANSI = {
|
|
75
|
+
reset: "\u001b[0m",
|
|
76
|
+
bold: "\u001b[1m",
|
|
77
|
+
dim: "\u001b[2m",
|
|
78
|
+
cyan: "\u001b[36m",
|
|
79
|
+
green: "\u001b[32m",
|
|
80
|
+
yellow: "\u001b[33m",
|
|
81
|
+
magenta: "\u001b[35m",
|
|
82
|
+
blue: "\u001b[34m",
|
|
83
|
+
gray: "\u001b[90m",
|
|
84
|
+
white: "\u001b[37m",
|
|
85
|
+
};
|
|
86
|
+
const WORKSPACE_SCREEN_WIDTH = 109;
|
|
87
|
+
const WORKSPACE_ACTIVITY_LIMIT = 8;
|
|
88
|
+
const WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS = 150;
|
|
74
89
|
let cachedConfig: KichiRuntimeConfig | null = null;
|
|
75
90
|
let cachedConfigMtime = 0;
|
|
76
91
|
let cachedConfigPath = "";
|
|
@@ -78,6 +93,173 @@ let cachedAlbumConfig: Album | null = null;
|
|
|
78
93
|
let cachedAlbumConfigMtime = 0;
|
|
79
94
|
let service: KichiForwarderService | null = null;
|
|
80
95
|
let pluginApi: OpenClawPluginApi | null = null;
|
|
96
|
+
let workspaceState: WorkspaceScreenState = createWorkspaceScreenState();
|
|
97
|
+
let workspacePushTimer: NodeJS.Timeout | null = null;
|
|
98
|
+
|
|
99
|
+
type WorkspaceScreenState = {
|
|
100
|
+
sessionLabel: string;
|
|
101
|
+
mode: string;
|
|
102
|
+
phase: string;
|
|
103
|
+
channel: string;
|
|
104
|
+
updatedAtLabel: string;
|
|
105
|
+
recentActivity: string[];
|
|
106
|
+
currentFocus: string;
|
|
107
|
+
hint: string;
|
|
108
|
+
prompt: string;
|
|
109
|
+
title: string;
|
|
110
|
+
shellName: string;
|
|
111
|
+
cwdLabel: string;
|
|
112
|
+
modelLabel: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function createWorkspaceScreenState(): WorkspaceScreenState {
|
|
116
|
+
return {
|
|
117
|
+
sessionLabel: "main",
|
|
118
|
+
mode: "idle",
|
|
119
|
+
phase: "waiting",
|
|
120
|
+
channel: "unknown",
|
|
121
|
+
updatedAtLabel: "just now",
|
|
122
|
+
recentActivity: [],
|
|
123
|
+
currentFocus: "Waiting for the next thread to pick up.",
|
|
124
|
+
hint: "Low-noise live workspace view.",
|
|
125
|
+
prompt: "$ _",
|
|
126
|
+
title: "Workspace",
|
|
127
|
+
shellName: "agent",
|
|
128
|
+
cwdLabel: process.cwd(),
|
|
129
|
+
modelLabel: "model: unknown",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeShellToken(value: string): string {
|
|
134
|
+
const normalized = value
|
|
135
|
+
.trim()
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
138
|
+
.replace(/^-+|-+$/g, "");
|
|
139
|
+
return normalized || "agent";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseIdentityNameFromWorkspace(workspaceRoot: string): string | null {
|
|
143
|
+
try {
|
|
144
|
+
const identityPath = path.join(workspaceRoot, "IDENTITY.md");
|
|
145
|
+
if (!fs.existsSync(identityPath)) return null;
|
|
146
|
+
const raw = fs.readFileSync(identityPath, "utf-8");
|
|
147
|
+
const match = raw.match(/^-\s*\*\*Name:\*\*\s*(.+)$/m);
|
|
148
|
+
const value = match?.[1]?.trim();
|
|
149
|
+
return value || null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deriveWorkspaceIdentity(workspaceRoot: string): Pick<WorkspaceScreenState, "title" | "shellName" | "cwdLabel"> {
|
|
156
|
+
const identityName = parseIdentityNameFromWorkspace(workspaceRoot);
|
|
157
|
+
const titleBase = identityName || path.basename(workspaceRoot) || "workspace";
|
|
158
|
+
return {
|
|
159
|
+
title: `${titleBase} Workspace`,
|
|
160
|
+
shellName: normalizeShellToken(identityName || titleBase),
|
|
161
|
+
cwdLabel: workspaceRoot,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function nowLabel(): string {
|
|
166
|
+
const date = new Date();
|
|
167
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stripAnsi(text: string): string {
|
|
171
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function visibleLength(text: string): number {
|
|
175
|
+
return Array.from(stripAnsi(text)).length;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function padVisible(text: string, width: number): string {
|
|
179
|
+
const pad = Math.max(0, width - visibleLength(text));
|
|
180
|
+
return text + " ".repeat(pad);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function truncatePlain(text: string, width: number): string {
|
|
184
|
+
const chars = Array.from(text);
|
|
185
|
+
if (chars.length <= width) return text;
|
|
186
|
+
if (width <= 1) return chars.slice(0, width).join("");
|
|
187
|
+
return chars.slice(0, width - 1).join("") + "…";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function color(text: string, tone: keyof typeof ANSI): string {
|
|
191
|
+
return `${ANSI[tone]}${text}${ANSI.reset}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function pushActivity(line: string): void {
|
|
195
|
+
if (!line.trim()) return;
|
|
196
|
+
const stamped = `[${nowLabel()}] ${line.trim()}`;
|
|
197
|
+
workspaceState.recentActivity.unshift(stamped);
|
|
198
|
+
workspaceState.recentActivity = workspaceState.recentActivity.slice(0, WORKSPACE_ACTIVITY_LIMIT);
|
|
199
|
+
workspaceState.updatedAtLabel = "just now";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderWorkspaceScreen(): string {
|
|
203
|
+
const innerWidth = WORKSPACE_SCREEN_WIDTH - 2;
|
|
204
|
+
const topTitle = `${color(` ${workspaceState.title} `, "bold")}${color("live session", "gray")}`;
|
|
205
|
+
const topLine = `╭─── ${topTitle}${"─".repeat(Math.max(0, innerWidth - 4 - visibleLength(topTitle)))}╮`;
|
|
206
|
+
const bottomLine = `╰${"─".repeat(innerWidth)}╯`;
|
|
207
|
+
|
|
208
|
+
const body: string[] = [];
|
|
209
|
+
const pushRow = (text = "") => {
|
|
210
|
+
body.push(`│${padVisible(text, innerWidth)}│`);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
pushRow("");
|
|
214
|
+
pushRow(`${color(" Welcome back.", "white")}`);
|
|
215
|
+
pushRow(`${color(" Current session is active and mirrored as a terminal-style workspace.", "gray")}`);
|
|
216
|
+
pushRow("");
|
|
217
|
+
pushRow(`${color(" Recent activity", "cyan")}`);
|
|
218
|
+
for (const item of workspaceState.recentActivity.length ? workspaceState.recentActivity : ["[--:--:--] idle"] ) {
|
|
219
|
+
pushRow(` ${color("•", "green")} ${truncatePlain(item, innerWidth - 4)}`);
|
|
220
|
+
}
|
|
221
|
+
pushRow("");
|
|
222
|
+
pushRow(`${color(" Current focus", "cyan")}`);
|
|
223
|
+
pushRow(` ${truncatePlain(workspaceState.currentFocus, innerWidth - 2)}`);
|
|
224
|
+
pushRow("");
|
|
225
|
+
pushRow(`${color(" Status", "cyan")}`);
|
|
226
|
+
pushRow(` ${color("mode", "gray")} ${workspaceState.mode}`);
|
|
227
|
+
pushRow(` ${color("phase", "gray")} ${workspaceState.phase}`);
|
|
228
|
+
pushRow(` ${color("channel", "gray")} ${workspaceState.channel}`);
|
|
229
|
+
pushRow(` ${color("session", "gray")} ${workspaceState.sessionLabel}`);
|
|
230
|
+
pushRow("");
|
|
231
|
+
pushRow(`${color(" Hint", "cyan")}`);
|
|
232
|
+
pushRow(` ${truncatePlain(workspaceState.hint, innerWidth - 2)}`);
|
|
233
|
+
pushRow("");
|
|
234
|
+
pushRow(` ${color(workspaceState.modelLabel, "magenta")}`);
|
|
235
|
+
pushRow(` ${color(workspaceState.cwdLabel, "blue")}`);
|
|
236
|
+
pushRow(` ${color(`updated: ${workspaceState.updatedAtLabel}`, "gray")}`);
|
|
237
|
+
pushRow("");
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
topLine,
|
|
241
|
+
...body,
|
|
242
|
+
bottomLine,
|
|
243
|
+
"",
|
|
244
|
+
color("─".repeat(WORKSPACE_SCREEN_WIDTH), "gray"),
|
|
245
|
+
`${color(workspaceState.shellName, "green")}:${color("~", "blue")} ${workspaceState.prompt}`,
|
|
246
|
+
].join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function scheduleWorkspacePush(): void {
|
|
250
|
+
if (!service?.hasValidIdentity() || !service?.isConnected()) return;
|
|
251
|
+
if (workspacePushTimer) clearTimeout(workspacePushTimer);
|
|
252
|
+
workspacePushTimer = setTimeout(() => {
|
|
253
|
+
workspacePushTimer = null;
|
|
254
|
+
service?.sendWorkspaceScreen(renderWorkspaceScreen(), true);
|
|
255
|
+
}, WORKSPACE_SCREEN_PUSH_DEBOUNCE_MS);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function updateWorkspace(partial: Partial<WorkspaceScreenState>, activity?: string): void {
|
|
259
|
+
workspaceState = { ...workspaceState, ...partial, updatedAtLabel: "just now" };
|
|
260
|
+
if (activity) pushActivity(activity);
|
|
261
|
+
scheduleWorkspacePush();
|
|
262
|
+
}
|
|
81
263
|
|
|
82
264
|
function isAlbumConfig(value: unknown): value is Album {
|
|
83
265
|
if (!value || typeof value !== "object") {
|
|
@@ -297,6 +479,16 @@ async function handleMessageReceivedHook(content: string): Promise<void> {
|
|
|
297
479
|
}
|
|
298
480
|
const trimmed = truncateByDisplayWidth(content, MAX_MESSAGE_RECEIVED_PREVIEW_WIDTH);
|
|
299
481
|
service.sendHookNotify("message_received", `"${trimmed}"`);
|
|
482
|
+
updateWorkspace(
|
|
483
|
+
{
|
|
484
|
+
mode: "reading",
|
|
485
|
+
phase: "processing inbound message",
|
|
486
|
+
currentFocus: trimmed ? `User message: ${trimmed}` : "Reading the latest message.",
|
|
487
|
+
hint: "Inbound message updated the live workspace.",
|
|
488
|
+
prompt: "$ reading-message",
|
|
489
|
+
},
|
|
490
|
+
`received message: ${trimmed || "(empty)"}`,
|
|
491
|
+
);
|
|
300
492
|
}
|
|
301
493
|
|
|
302
494
|
function handleMessageSentHook(): void {
|
|
@@ -304,10 +496,30 @@ function handleMessageSentHook(): void {
|
|
|
304
496
|
return;
|
|
305
497
|
}
|
|
306
498
|
service.sendHookNotify("before_send_message", pickRandomAction(MESSAGE_SENT_BUBBLES));
|
|
499
|
+
updateWorkspace(
|
|
500
|
+
{
|
|
501
|
+
mode: "sent",
|
|
502
|
+
phase: "message delivered",
|
|
503
|
+
currentFocus: "Latest reply has been sent to the active chat.",
|
|
504
|
+
hint: "Workspace settles after delivery.",
|
|
505
|
+
prompt: "$ idle",
|
|
506
|
+
},
|
|
507
|
+
"sent assistant reply",
|
|
508
|
+
);
|
|
307
509
|
}
|
|
308
510
|
|
|
309
511
|
function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
310
512
|
api.on("before_prompt_build", () => {
|
|
513
|
+
updateWorkspace(
|
|
514
|
+
{
|
|
515
|
+
mode: "thinking",
|
|
516
|
+
phase: "building prompt",
|
|
517
|
+
currentFocus: "Preparing the next response from current session context.",
|
|
518
|
+
hint: "Prompt assembly is in progress.",
|
|
519
|
+
prompt: "$ build-prompt",
|
|
520
|
+
},
|
|
521
|
+
"building prompt context",
|
|
522
|
+
);
|
|
311
523
|
if (!service?.hasValidIdentity() || !service?.isConnected()) {
|
|
312
524
|
return;
|
|
313
525
|
}
|
|
@@ -320,13 +532,65 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
|
320
532
|
};
|
|
321
533
|
});
|
|
322
534
|
|
|
323
|
-
api.on("
|
|
535
|
+
api.on("llm_input", (event) => {
|
|
536
|
+
updateWorkspace(
|
|
537
|
+
{
|
|
538
|
+
mode: "thinking",
|
|
539
|
+
phase: `llm input · ${event.model}`,
|
|
540
|
+
currentFocus: "Feeding the model the current thread and constraints.",
|
|
541
|
+
hint: `provider: ${event.provider} · images: ${event.imagesCount}`,
|
|
542
|
+
prompt: "$ llm-input",
|
|
543
|
+
modelLabel: `${event.provider}/${event.model}`,
|
|
544
|
+
},
|
|
545
|
+
`entered llm input: ${event.model}`,
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
api.on("llm_output", (event) => {
|
|
550
|
+
updateWorkspace(
|
|
551
|
+
{
|
|
552
|
+
mode: "writing",
|
|
553
|
+
phase: `llm output · ${event.model}`,
|
|
554
|
+
currentFocus: "Shaping model output into the visible reply.",
|
|
555
|
+
hint: `assistant chunks: ${event.assistantTexts.length}`,
|
|
556
|
+
prompt: "$ draft-reply",
|
|
557
|
+
modelLabel: `${event.provider}/${event.model}`,
|
|
558
|
+
},
|
|
559
|
+
`received llm output: ${event.model}`,
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
564
|
+
updateWorkspace(
|
|
565
|
+
{
|
|
566
|
+
mode: "tool",
|
|
567
|
+
phase: `running ${event.toolName}`,
|
|
568
|
+
currentFocus: `Tool call in flight: ${event.toolName}`,
|
|
569
|
+
hint: `tool context: ${ctx.toolName}`,
|
|
570
|
+
prompt: `$ tool ${event.toolName}`,
|
|
571
|
+
},
|
|
572
|
+
`tool start: ${event.toolName}`,
|
|
573
|
+
);
|
|
324
574
|
if (!isLlmRuntimeEnabled()) {
|
|
325
575
|
syncFixedStatus(FIXED_HOOK_STATUSES.beforeToolCall);
|
|
326
576
|
}
|
|
327
577
|
});
|
|
328
578
|
|
|
329
|
-
api.on("
|
|
579
|
+
api.on("after_tool_call", (event) => {
|
|
580
|
+
updateWorkspace(
|
|
581
|
+
{
|
|
582
|
+
mode: "thinking",
|
|
583
|
+
phase: `tool finished ${event.toolName}`,
|
|
584
|
+
currentFocus: `Tool result returned from ${event.toolName}.`,
|
|
585
|
+
hint: event.error ? `tool error: ${event.error}` : `tool completed in ${event.durationMs ?? 0}ms`,
|
|
586
|
+
prompt: `$ continue ${event.toolName}`,
|
|
587
|
+
},
|
|
588
|
+
event.error ? `tool error: ${event.toolName}` : `tool done: ${event.toolName}`,
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
api.on("message_received", async (event, ctx) => {
|
|
593
|
+
workspaceState.channel = ctx.channelId || workspaceState.channel;
|
|
330
594
|
await handleMessageReceivedHook(event.content);
|
|
331
595
|
});
|
|
332
596
|
|
|
@@ -335,6 +599,16 @@ function registerPluginHooks(api: OpenClawPluginApi): void {
|
|
|
335
599
|
});
|
|
336
600
|
|
|
337
601
|
api.on("agent_end", (event) => {
|
|
602
|
+
updateWorkspace(
|
|
603
|
+
{
|
|
604
|
+
mode: event.success ? "idle" : "error",
|
|
605
|
+
phase: event.success ? "run complete" : "run failed",
|
|
606
|
+
currentFocus: event.success ? "Run complete. Waiting for the next thread." : `Run failed: ${event.error ?? "unknown error"}`,
|
|
607
|
+
hint: `duration: ${event.durationMs ?? 0}ms`,
|
|
608
|
+
prompt: event.success ? "$ _" : "$ recover",
|
|
609
|
+
},
|
|
610
|
+
event.success ? "agent run complete" : "agent run failed",
|
|
611
|
+
);
|
|
338
612
|
if (isLlmRuntimeEnabled()) {
|
|
339
613
|
return;
|
|
340
614
|
}
|
|
@@ -596,6 +870,13 @@ const plugin = {
|
|
|
596
870
|
ctx.config.plugins?.entries?.["kichi-forwarder"]?.config,
|
|
597
871
|
) as KichiForwarderConfig;
|
|
598
872
|
service = new KichiForwarderService(cfg, api.logger);
|
|
873
|
+
const workspaceRoot = ctx.repoPath ?? "/Users/xiaoxinshi/.openclaw/workspace";
|
|
874
|
+
workspaceState = {
|
|
875
|
+
...createWorkspaceScreenState(),
|
|
876
|
+
...deriveWorkspaceIdentity(workspaceRoot),
|
|
877
|
+
};
|
|
878
|
+
workspaceState.channel = ctx.channelId ?? "unknown";
|
|
879
|
+
scheduleWorkspacePush();
|
|
599
880
|
return service.start();
|
|
600
881
|
},
|
|
601
882
|
stop: () => service?.stop(),
|
|
@@ -656,6 +937,7 @@ const plugin = {
|
|
|
656
937
|
return { success: false, error: "Kichi service is not initialized" };
|
|
657
938
|
}
|
|
658
939
|
if (result.success) {
|
|
940
|
+
scheduleWorkspacePush();
|
|
659
941
|
return { success: true, authKey: result.authKey };
|
|
660
942
|
}
|
|
661
943
|
return {
|
package/package.json
CHANGED
package/src/service.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
QueryStatusPayload,
|
|
23
23
|
QueryStatusResultPayload,
|
|
24
24
|
StatusPayload,
|
|
25
|
+
WorkspaceScreenPayload,
|
|
25
26
|
} from "./types.js";
|
|
26
27
|
|
|
27
28
|
const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "kichi-world");
|
|
@@ -110,7 +111,6 @@ export class KichiForwarderService {
|
|
|
110
111
|
this.ws = new WebSocket(this.config.wsUrl);
|
|
111
112
|
this.ws.on("open", () => {
|
|
112
113
|
this.logger.info(`Connected to ${this.config.wsUrl}`);
|
|
113
|
-
// Automatically send rejoin when a valid identity is available.
|
|
114
114
|
this.sendRejoinPayload();
|
|
115
115
|
});
|
|
116
116
|
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
@@ -150,7 +150,6 @@ export class KichiForwarderService {
|
|
|
150
150
|
this.joinResolve?.({ success: true, authKey: joinAck.authKey });
|
|
151
151
|
this.joinResolve = null;
|
|
152
152
|
} else if (msg.type === "rejoin_failed" || msg.type === "auth_error") {
|
|
153
|
-
// AuthKey invalid/expired, clear it
|
|
154
153
|
this.logger.warn(`Auth failed: ${msg.reason || "unknown"}`);
|
|
155
154
|
this.clearAuthKey();
|
|
156
155
|
} else if (msg.type === "leave_ack") {
|
|
@@ -335,6 +334,20 @@ export class KichiForwarderService {
|
|
|
335
334
|
this.ws.send(JSON.stringify(payload));
|
|
336
335
|
}
|
|
337
336
|
|
|
337
|
+
sendWorkspaceScreen(text: string, ansi = true): boolean {
|
|
338
|
+
const identity = this.requireIdentity();
|
|
339
|
+
if (!identity || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
340
|
+
const payload: WorkspaceScreenPayload = {
|
|
341
|
+
type: "workspace_screen",
|
|
342
|
+
avatarId: identity.avatarId,
|
|
343
|
+
authKey: identity.authKey,
|
|
344
|
+
text,
|
|
345
|
+
ansi,
|
|
346
|
+
};
|
|
347
|
+
this.ws.send(JSON.stringify(payload));
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
338
351
|
sendClock(action: ClockAction, clock?: ClockConfig, requestId?: string): boolean {
|
|
339
352
|
if (!this.identity?.authKey || this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
340
353
|
if (action === "set" && !clock) return false;
|
package/src/types.ts
CHANGED
|
@@ -105,6 +105,14 @@ export type HookNotifyPayload = {
|
|
|
105
105
|
bubble: string;
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
+
export type WorkspaceScreenPayload = {
|
|
109
|
+
type: "workspace_screen";
|
|
110
|
+
avatarId: string;
|
|
111
|
+
authKey: string;
|
|
112
|
+
text: string;
|
|
113
|
+
ansi?: boolean;
|
|
114
|
+
};
|
|
115
|
+
|
|
108
116
|
export type ClockAction = "set" | "stop";
|
|
109
117
|
|
|
110
118
|
export type ClockMode = "pomodoro" | "countDown" | "countUp";
|