@codeoid/core 0.1.0

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/src/slash.ts ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Slash-command parsing + dispatch. Mirrors the TUI verbs:
3
+ * /new <name> [workdir] — create a session (workdir defaults to cwd)
4
+ * /rename <name> — rename focused session
5
+ * /destroy — destroy focused session
6
+ * /interrupt — interrupt current turn
7
+ * /rotate — rotate context (refresh skills/settings)
8
+ * /mode <i|g|x> — interactive | guarded | autonomous
9
+ * /model <id|alias> — switch model (opus / sonnet / haiku / full id)
10
+ * /help — surface help modal (TODO P6)
11
+ * /clear — clear prompt (handled by caller before dispatch)
12
+ *
13
+ * Pure functions — easy to unit-test. The `dispatch` helper takes a
14
+ * thin context object so we can mock send/newRequestId in tests.
15
+ */
16
+
17
+ import type {
18
+ ClientMessage,
19
+ SessionMode,
20
+ } from "@codeoid/protocol";
21
+
22
+ export type SlashCommand =
23
+ | { kind: "new"; name: string; workdir?: string }
24
+ | { kind: "rename"; name: string }
25
+ | { kind: "destroy" }
26
+ | { kind: "interrupt" }
27
+ | { kind: "rotate" }
28
+ | { kind: "mode"; mode: SessionMode; maxTurns?: number }
29
+ | { kind: "model"; model: string; fallback?: string | null }
30
+ | { kind: "model-picker" }
31
+ | { kind: "help" }
32
+ | { kind: "clear" }
33
+ | { kind: "who" }
34
+ | { kind: "capabilities"; tab: "agents" | "skills" | "mcp" | "hooks" }
35
+ | { kind: "export" }
36
+ | { kind: "import" }
37
+ | { kind: "fork" };
38
+
39
+ export function parseSlash(raw: string): SlashCommand | null {
40
+ const trimmed = raw.trim();
41
+ if (!trimmed.startsWith("/")) return null;
42
+ const head = trimmed.slice(1);
43
+ const [verb, ...rest] = head.split(/\s+/);
44
+ if (!verb) return null;
45
+
46
+ switch (verb.toLowerCase()) {
47
+ case "new": {
48
+ if (rest.length === 0) {
49
+ throw new Error("/new <name> [workdir]");
50
+ }
51
+ const [name, ...wd] = rest;
52
+ const workdir = wd.length > 0 ? wd.join(" ") : undefined;
53
+ return { kind: "new", name: name!, workdir };
54
+ }
55
+ case "rename": {
56
+ if (rest.length === 0) throw new Error("/rename <name>");
57
+ return { kind: "rename", name: rest.join(" ") };
58
+ }
59
+ case "destroy":
60
+ return { kind: "destroy" };
61
+ case "interrupt":
62
+ return { kind: "interrupt" };
63
+ case "rotate":
64
+ return { kind: "rotate" };
65
+ case "mode": {
66
+ const m = rest[0]?.toLowerCase();
67
+ if (!m) throw new Error("/mode <guarded|interactive|autonomous>");
68
+ const mode: SessionMode | undefined =
69
+ m === "i" || m === "interactive"
70
+ ? "interactive"
71
+ : // `a`/`auto`/`auto-allow` kept as backward-compat aliases for `guarded`.
72
+ m === "g" || m === "guarded" || m === "a" || m === "auto-allow" || m === "auto"
73
+ ? "guarded"
74
+ : m === "x" || m === "autonomous"
75
+ ? "autonomous"
76
+ : undefined;
77
+ if (!mode) throw new Error(`unknown mode: ${m}`);
78
+ const maxTurnsArg = rest[1];
79
+ const maxTurns = maxTurnsArg ? Number.parseInt(maxTurnsArg, 10) : undefined;
80
+ return {
81
+ kind: "mode",
82
+ mode,
83
+ ...(maxTurns !== undefined && Number.isFinite(maxTurns)
84
+ ? { maxTurns }
85
+ : {}),
86
+ };
87
+ }
88
+ case "model": {
89
+ // Bare `/model` opens the model picker (list + current). With an
90
+ // argument it switches directly.
91
+ if (rest.length === 0) return { kind: "model-picker" };
92
+ const [model, fallback] = rest;
93
+ return {
94
+ kind: "model",
95
+ model: model!,
96
+ ...(fallback !== undefined ? { fallback } : {}),
97
+ };
98
+ }
99
+ case "help":
100
+ return { kind: "help" };
101
+ case "clear":
102
+ return { kind: "clear" };
103
+ case "who":
104
+ case "whoami":
105
+ return { kind: "who" };
106
+ case "agents":
107
+ case "agent":
108
+ return { kind: "capabilities", tab: "agents" };
109
+ case "skills":
110
+ case "skill":
111
+ return { kind: "capabilities", tab: "skills" };
112
+ case "mcp":
113
+ return { kind: "capabilities", tab: "mcp" };
114
+ case "hooks":
115
+ case "hook":
116
+ return { kind: "capabilities", tab: "hooks" };
117
+ case "export":
118
+ case "share":
119
+ return { kind: "export" };
120
+ case "import":
121
+ return { kind: "import" };
122
+ case "fork":
123
+ return { kind: "fork" };
124
+ default:
125
+ throw new Error(`unknown slash command: /${verb}`);
126
+ }
127
+ }
128
+
129
+ export interface SlashContext {
130
+ sessionId: string;
131
+ send: (msg: ClientMessage) => void;
132
+ newRequestId: () => string;
133
+ removeSession: (id: string) => void;
134
+ /**
135
+ * Send a command and await the daemon's response, rejecting on
136
+ * `response.error`. When provided, fallible commands route through this so
137
+ * the daemon's error (e.g. "Unknown model") surfaces via `report`. Falls
138
+ * back to fire-and-forget `send` when absent (e.g. in tests).
139
+ */
140
+ request?: (msg: ClientMessage) => Promise<unknown>;
141
+ /** Surface a command error to the user (wired to the prompt's error line). */
142
+ report?: (message: string) => void;
143
+ /** Optional UI hooks — caller provides as needed. */
144
+ showHelp?: () => void;
145
+ showModelPicker?: () => void;
146
+ showIdentity?: () => void;
147
+ showCapabilities?: (tab: "agents" | "skills" | "mcp" | "hooks") => void;
148
+ showExport?: () => void;
149
+ showImport?: () => void;
150
+ }
151
+
152
+ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
153
+ // Send a daemon command, surfacing any `response.error` via `report` so
154
+ // the user gets feedback (e.g. `/model o` → "Unknown model"). Falls back
155
+ // to fire-and-forget when no request channel is wired.
156
+ const fire = (msg: ClientMessage): void => {
157
+ if (ctx.request) {
158
+ ctx.request(msg).catch((e) =>
159
+ ctx.report?.(e instanceof Error ? e.message : String(e)),
160
+ );
161
+ } else {
162
+ ctx.send(msg);
163
+ }
164
+ };
165
+
166
+ switch (cmd.kind) {
167
+ case "new":
168
+ fire({
169
+ type: "session.create",
170
+ id: ctx.newRequestId(),
171
+ name: cmd.name,
172
+ workdir: cmd.workdir ?? ".",
173
+ });
174
+ return;
175
+ case "rename":
176
+ fire({
177
+ type: "session.rename",
178
+ id: ctx.newRequestId(),
179
+ sessionId: ctx.sessionId,
180
+ name: cmd.name,
181
+ });
182
+ return;
183
+ case "destroy":
184
+ fire({
185
+ type: "session.destroy",
186
+ id: ctx.newRequestId(),
187
+ sessionId: ctx.sessionId,
188
+ });
189
+ // Optimistically drop locally; the daemon's broadcast will confirm.
190
+ ctx.removeSession(ctx.sessionId);
191
+ return;
192
+ case "interrupt":
193
+ fire({
194
+ type: "session.interrupt",
195
+ id: ctx.newRequestId(),
196
+ sessionId: ctx.sessionId,
197
+ });
198
+ return;
199
+ case "rotate":
200
+ fire({
201
+ type: "session.rotate",
202
+ id: ctx.newRequestId(),
203
+ sessionId: ctx.sessionId,
204
+ });
205
+ return;
206
+ case "mode":
207
+ fire({
208
+ type: "session.set_mode",
209
+ id: ctx.newRequestId(),
210
+ sessionId: ctx.sessionId,
211
+ mode: cmd.mode,
212
+ ...(cmd.maxTurns !== undefined ? { maxTurns: cmd.maxTurns } : {}),
213
+ });
214
+ return;
215
+ case "model":
216
+ fire({
217
+ type: "session.set_model",
218
+ id: ctx.newRequestId(),
219
+ sessionId: ctx.sessionId,
220
+ model: cmd.model,
221
+ ...(cmd.fallback !== undefined ? { fallbackModel: cmd.fallback } : {}),
222
+ });
223
+ return;
224
+ case "model-picker":
225
+ ctx.showModelPicker?.();
226
+ return;
227
+ case "help":
228
+ ctx.showHelp?.();
229
+ return;
230
+ case "clear":
231
+ // Pure UI side-effect handled by the caller.
232
+ return;
233
+ case "who":
234
+ ctx.showIdentity?.();
235
+ return;
236
+ case "capabilities":
237
+ ctx.showCapabilities?.(cmd.tab);
238
+ return;
239
+ case "export":
240
+ ctx.showExport?.();
241
+ return;
242
+ case "import":
243
+ case "fork":
244
+ ctx.showImport?.();
245
+ return;
246
+ }
247
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * UTC day-bucket helpers for the analytics panel.
3
+ *
4
+ * The daemon buckets usage by SQLite `date(created_at/1000, 'unixepoch')`,
5
+ * which is UTC. The client must derive its bucket keys and "today" in UTC
6
+ * too — the previous implementation stepped days with local-time
7
+ * `setDate()/getDate()` and then read the key via `toISOString()` (UTC),
8
+ * which mismatches daemon buckets for any user not on UTC and can
9
+ * skip/duplicate a day around DST transitions.
10
+ */
11
+
12
+ export interface DayCostBucket {
13
+ day: string;
14
+ costUsd: number;
15
+ }
16
+
17
+ const DAY_MS = 86_400_000; // UTC days have no DST; fixed stepping is exact.
18
+
19
+ /** `YYYY-MM-DD` in UTC for an epoch-ms timestamp. */
20
+ export function utcDayKey(ms: number): string {
21
+ return new Date(ms).toISOString().slice(0, 10);
22
+ }
23
+
24
+ /**
25
+ * Expand sparse daemon buckets into a dense `days`-long window ending at
26
+ * the UTC day containing `nowMs`, filling gaps with zero cost.
27
+ */
28
+ export function padDays(
29
+ data: ReadonlyArray<{ day: string; costUsd: number }>,
30
+ days: number,
31
+ nowMs: number = Date.now(),
32
+ ): DayCostBucket[] {
33
+ const map = new Map(data.map((d) => [d.day, d.costUsd]));
34
+ const result: DayCostBucket[] = [];
35
+ for (let i = days - 1; i >= 0; i--) {
36
+ const key = utcDayKey(nowMs - i * DAY_MS);
37
+ result.push({ day: key, costUsd: map.get(key) ?? 0 });
38
+ }
39
+ return result;
40
+ }