@wizardingcode/shibaox-tui 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/bunfig.toml +5 -0
- package/package.json +42 -0
- package/src/app.tsx +300 -0
- package/src/component/dialogs/confirm.tsx +29 -0
- package/src/component/dialogs/diff.tsx +143 -0
- package/src/component/dialogs/help.tsx +99 -0
- package/src/component/dialogs/keys.tsx +201 -0
- package/src/component/dialogs/palette.tsx +95 -0
- package/src/component/dialogs/runs.tsx +115 -0
- package/src/component/dialogs/tiers.tsx +240 -0
- package/src/component/footer.tsx +66 -0
- package/src/component/logo.tsx +29 -0
- package/src/component/prompt/autocomplete.tsx +44 -0
- package/src/component/prompt/index.tsx +213 -0
- package/src/component/reconnecting.tsx +44 -0
- package/src/component/sidebar.tsx +235 -0
- package/src/component/tabs.tsx +116 -0
- package/src/component/too-small.tsx +15 -0
- package/src/context/client.tsx +54 -0
- package/src/context/commands.tsx +44 -0
- package/src/context/config.tsx +22 -0
- package/src/context/data-state.ts +41 -0
- package/src/context/data.tsx +308 -0
- package/src/context/exit.tsx +13 -0
- package/src/context/keys.tsx +70 -0
- package/src/context/poller.ts +352 -0
- package/src/context/prefs.tsx +54 -0
- package/src/context/route.tsx +23 -0
- package/src/main.tsx +55 -0
- package/src/model/format.ts +38 -0
- package/src/model/prompt-commands.ts +315 -0
- package/src/model/status.ts +55 -0
- package/src/model/stream.ts +464 -0
- package/src/motion/animation.ts +235 -0
- package/src/motion/config.tsx +15 -0
- package/src/motion/fade-in-text.tsx +93 -0
- package/src/motion/masked-text.ts +62 -0
- package/src/motion/one-cell-motion.ts +100 -0
- package/src/motion/pulse.ts +38 -0
- package/src/motion/shimmer-text.tsx +78 -0
- package/src/motion/spinner.tsx +101 -0
- package/src/motion/subcell.ts +40 -0
- package/src/routes/home.tsx +365 -0
- package/src/routes/session/approval-bar.tsx +136 -0
- package/src/routes/session/cards.tsx +495 -0
- package/src/routes/session/context-line.tsx +129 -0
- package/src/routes/session/continue.tsx +104 -0
- package/src/routes/session/index.tsx +340 -0
- package/src/routes/session/request.tsx +87 -0
- package/src/routes/session/timeline.tsx +313 -0
- package/src/testing/fake-client.ts +249 -0
- package/src/theme/context.tsx +44 -0
- package/src/theme/resolve.ts +196 -0
- package/src/theme/shibaox.json +159 -0
- package/src/ui/border.ts +20 -0
- package/src/ui/delayed-presence.ts +23 -0
- package/src/ui/dialog.tsx +123 -0
- package/src/ui/layout.ts +23 -0
- package/src/ui/marquee.ts +75 -0
- package/src/ui/pane-resize.ts +77 -0
- package/src/ui/toast.tsx +203 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { RunState } from '@wizardingcode/shibaox-core';
|
|
2
|
+
import type { Envelope, InboxId, SubmitRequest } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
import { DaemonHttpError } from '@wizardingcode/shibaox-daemon/client';
|
|
4
|
+
import type { SetStoreFunction } from 'solid-js/store';
|
|
5
|
+
import { RUN_EVENT_REFRESH } from '../model/stream.js';
|
|
6
|
+
import type { Feedback } from '../theme/resolve.js';
|
|
7
|
+
import type { DaemonClientLike } from './client.js';
|
|
8
|
+
import type { DataState } from './data-state.js';
|
|
9
|
+
|
|
10
|
+
export interface PollerIntervals {
|
|
11
|
+
fast: number;
|
|
12
|
+
normal: number;
|
|
13
|
+
slow: number;
|
|
14
|
+
health: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PollerToast {
|
|
18
|
+
message: string;
|
|
19
|
+
variant: Feedback;
|
|
20
|
+
action?: { label: string; run: () => void };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where the frames of a run go: a plain array per run, outside the reactive store. */
|
|
24
|
+
export interface FrameSink {
|
|
25
|
+
reset(runId: string): void;
|
|
26
|
+
append(runId: string, batch: Envelope[]): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PollerOptions {
|
|
30
|
+
client: DaemonClientLike;
|
|
31
|
+
set: SetStoreFunction<DataState>;
|
|
32
|
+
get: () => DataState;
|
|
33
|
+
frames: FrameSink;
|
|
34
|
+
toast: (t: PollerToast) => void;
|
|
35
|
+
/** Opens a run's tab (the toast's Open action). */
|
|
36
|
+
open?: (runId: string) => void;
|
|
37
|
+
/** Where stream failures and malformed frames are noted (`~/.shibaox/tui.log`). */
|
|
38
|
+
log?: (line: string) => void;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
intervals?: Partial<PollerIntervals>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Sub {
|
|
44
|
+
runId: string;
|
|
45
|
+
controller: AbortController;
|
|
46
|
+
cursor?: string;
|
|
47
|
+
/** Frames received but not yet written to the store (flushed every FLUSH_MS). */
|
|
48
|
+
pending: Envelope[];
|
|
49
|
+
flushTimer?: NodeJS.Timeout;
|
|
50
|
+
/** The stream closed without an `end` frame (daemon restart, socket error): reopen on the next tick. */
|
|
51
|
+
broken?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULTS: PollerIntervals = { fast: 500, normal: 1000, slow: 5000, health: 5000 };
|
|
55
|
+
const FAST_WINDOW_MS = 3000;
|
|
56
|
+
const REFRESH_THROTTLE_MS = 200;
|
|
57
|
+
/** Frames are written to the store in batches: one store update per burst, not per frame. */
|
|
58
|
+
const FLUSH_MS = 50;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Feeds the data store: `listRuns` + `inbox` on a timer (faster after an action, slower while
|
|
62
|
+
* the daemon is unreachable), `health` on its own timer, and one SSE subscription per open tab
|
|
63
|
+
* (reopened with its cursor when the daemon comes back). Actions call the client and toast.
|
|
64
|
+
*/
|
|
65
|
+
export class Poller {
|
|
66
|
+
private readonly client: DaemonClientLike;
|
|
67
|
+
private readonly set: SetStoreFunction<DataState>;
|
|
68
|
+
private readonly get: () => DataState;
|
|
69
|
+
private readonly frames: FrameSink;
|
|
70
|
+
private readonly toast: (t: PollerToast) => void;
|
|
71
|
+
private readonly open: (runId: string) => void;
|
|
72
|
+
private readonly log: (line: string) => void;
|
|
73
|
+
private readonly now: () => number;
|
|
74
|
+
private readonly intervals: PollerIntervals;
|
|
75
|
+
private tickTimer: NodeJS.Timeout | undefined;
|
|
76
|
+
private healthTimer: NodeJS.Timeout | undefined;
|
|
77
|
+
private readonly subs = new Map<string, Sub>();
|
|
78
|
+
private readonly refreshTimers = new Map<string, NodeJS.Timeout>();
|
|
79
|
+
private fastUntil = 0;
|
|
80
|
+
private running = false;
|
|
81
|
+
private ticking = false;
|
|
82
|
+
|
|
83
|
+
constructor(o: PollerOptions) {
|
|
84
|
+
this.client = o.client;
|
|
85
|
+
this.set = o.set;
|
|
86
|
+
this.get = o.get;
|
|
87
|
+
this.frames = o.frames;
|
|
88
|
+
this.toast = o.toast;
|
|
89
|
+
this.open = o.open ?? (() => undefined);
|
|
90
|
+
this.log = o.log ?? (() => undefined);
|
|
91
|
+
this.now = o.now ?? (() => Date.now());
|
|
92
|
+
this.intervals = { ...DEFAULTS, ...o.intervals };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
start(): void {
|
|
96
|
+
if (this.running) return;
|
|
97
|
+
this.running = true;
|
|
98
|
+
this.schedule(0);
|
|
99
|
+
this.healthTimer = setInterval(() => void this.healthTick(), this.intervals.health);
|
|
100
|
+
void this.healthTick();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop(): void {
|
|
104
|
+
this.running = false;
|
|
105
|
+
if (this.tickTimer) clearTimeout(this.tickTimer);
|
|
106
|
+
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
107
|
+
this.tickTimer = undefined;
|
|
108
|
+
this.healthTimer = undefined;
|
|
109
|
+
for (const t of this.refreshTimers.values()) clearTimeout(t);
|
|
110
|
+
this.refreshTimers.clear();
|
|
111
|
+
for (const runId of [...this.subs.keys()]) this.close(runId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private schedule(delay: number): void {
|
|
115
|
+
if (!this.running) return;
|
|
116
|
+
if (this.tickTimer) clearTimeout(this.tickTimer);
|
|
117
|
+
this.tickTimer = setTimeout(() => void this.tick(), delay);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private nextDelay(): number {
|
|
121
|
+
if (!this.get().reachable) return this.intervals.slow;
|
|
122
|
+
return this.now() < this.fastUntil ? this.intervals.fast : this.intervals.normal;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One `listRuns` + `inbox` round; reschedules itself while started. */
|
|
126
|
+
async tick(): Promise<void> {
|
|
127
|
+
if (this.ticking) return;
|
|
128
|
+
this.ticking = true;
|
|
129
|
+
try {
|
|
130
|
+
const [runs, inbox] = await Promise.all([this.client.listRuns(), this.client.inbox()]);
|
|
131
|
+
const wasDown = !this.get().reachable;
|
|
132
|
+
this.set('reachable', true);
|
|
133
|
+
this.set('unreachableSince', undefined);
|
|
134
|
+
this.set('runs', runs);
|
|
135
|
+
this.set('inbox', inbox);
|
|
136
|
+
for (const sub of [...this.subs.values()]) if (wasDown || sub.broken) this.reopen(sub);
|
|
137
|
+
} catch {
|
|
138
|
+
if (this.get().reachable) {
|
|
139
|
+
this.set('reachable', false);
|
|
140
|
+
this.set('unreachableSince', this.now());
|
|
141
|
+
}
|
|
142
|
+
for (const sub of this.subs.values()) sub.controller.abort();
|
|
143
|
+
} finally {
|
|
144
|
+
this.ticking = false;
|
|
145
|
+
this.schedule(this.nextDelay());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async healthTick(): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
this.set('health', await this.client.health());
|
|
152
|
+
} catch {
|
|
153
|
+
this.set('health', undefined);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Opens the run's SSE stream (history first); a no-op when already subscribed. */
|
|
158
|
+
subscribe(runId: string): void {
|
|
159
|
+
if (this.subs.has(runId)) return;
|
|
160
|
+
const sub: Sub = { runId, controller: new AbortController(), pending: [] };
|
|
161
|
+
this.subs.set(runId, sub);
|
|
162
|
+
if (this.get().reachable) void this.consume(sub);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
unsubscribe(runId: string): void {
|
|
166
|
+
this.close(runId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private close(runId: string): void {
|
|
170
|
+
const sub = this.subs.get(runId);
|
|
171
|
+
if (!sub) return;
|
|
172
|
+
this.flush(sub);
|
|
173
|
+
sub.controller.abort();
|
|
174
|
+
this.subs.delete(runId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private flush(sub: Sub): void {
|
|
178
|
+
if (sub.flushTimer) clearTimeout(sub.flushTimer);
|
|
179
|
+
sub.flushTimer = undefined;
|
|
180
|
+
if (sub.pending.length === 0) return;
|
|
181
|
+
const batch = sub.pending;
|
|
182
|
+
sub.pending = [];
|
|
183
|
+
this.frames.append(sub.runId, batch);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private queue(sub: Sub, env: Envelope): void {
|
|
187
|
+
sub.pending.push(env);
|
|
188
|
+
if (!sub.flushTimer) sub.flushTimer = setTimeout(() => this.flush(sub), FLUSH_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private reopen(old: Sub): void {
|
|
192
|
+
this.flush(old);
|
|
193
|
+
old.controller.abort();
|
|
194
|
+
const sub: Sub = {
|
|
195
|
+
runId: old.runId,
|
|
196
|
+
controller: new AbortController(),
|
|
197
|
+
cursor: old.cursor,
|
|
198
|
+
pending: [],
|
|
199
|
+
};
|
|
200
|
+
this.subs.set(old.runId, sub);
|
|
201
|
+
void this.consume(sub);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async consume(sub: Sub): Promise<void> {
|
|
205
|
+
const { runId, controller } = sub;
|
|
206
|
+
const mine = () => this.subs.get(runId) === sub && !controller.signal.aborted;
|
|
207
|
+
let ended = false;
|
|
208
|
+
// a fresh subscription replays the history: start the run's frames over, and show the
|
|
209
|
+
// run's nodes right away instead of waiting for its first event
|
|
210
|
+
if (!sub.cursor) this.frames.reset(runId);
|
|
211
|
+
await this.refreshRun(runId);
|
|
212
|
+
if (!mine()) return;
|
|
213
|
+
try {
|
|
214
|
+
for await (const env of this.client.events(runId, {
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
since: sub.cursor,
|
|
217
|
+
})) {
|
|
218
|
+
if (!mine()) return;
|
|
219
|
+
if (env.kind !== 'end') {
|
|
220
|
+
sub.cursor = env.cursor;
|
|
221
|
+
this.queue(sub, env);
|
|
222
|
+
}
|
|
223
|
+
if (env.kind === 'run' && RUN_EVENT_REFRESH.has(env.event.type)) this.refreshSoon(runId);
|
|
224
|
+
if (env.kind === 'end') {
|
|
225
|
+
ended = true;
|
|
226
|
+
this.flush(sub);
|
|
227
|
+
await this.refreshRun(runId);
|
|
228
|
+
this.set('ended', runId, env.status);
|
|
229
|
+
this.subs.delete(runId);
|
|
230
|
+
if (this.get().active !== runId) {
|
|
231
|
+
this.set('unread', runId, 'done');
|
|
232
|
+
const failed = env.status !== 'completed';
|
|
233
|
+
this.toast({
|
|
234
|
+
message: `Run ${runId.slice(0, 8)} ${failed ? env.status : 'is done'}`,
|
|
235
|
+
variant: failed ? 'error' : 'success',
|
|
236
|
+
action: { label: 'Open', run: () => this.open(runId) },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// aborted, or the daemon went away: the poll loop notices and reopens
|
|
244
|
+
if (!controller.signal.aborted)
|
|
245
|
+
this.log(`stream ${runId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
246
|
+
} finally {
|
|
247
|
+
// closed without an end frame while still ours: the next successful tick reopens it
|
|
248
|
+
if (!ended && mine()) sub.broken = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** While the daemon is known as down, actions only say so (no call, no error toast). */
|
|
253
|
+
private down(): boolean {
|
|
254
|
+
if (this.get().reachable) return false;
|
|
255
|
+
this.toast({ message: 'Daemon unreachable', variant: 'info' });
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private refreshSoon(runId: string): void {
|
|
260
|
+
if (this.refreshTimers.has(runId)) return;
|
|
261
|
+
this.refreshTimers.set(
|
|
262
|
+
runId,
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
this.refreshTimers.delete(runId);
|
|
265
|
+
void this.refreshRun(runId);
|
|
266
|
+
}, REFRESH_THROTTLE_MS),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Fetches the run's state; marks it unread as "needs" when it waits and is not active. */
|
|
271
|
+
async refreshRun(runId: string): Promise<void> {
|
|
272
|
+
try {
|
|
273
|
+
const state = await this.client.getRun(runId);
|
|
274
|
+
this.setState(state);
|
|
275
|
+
} catch {
|
|
276
|
+
// gone (404) or unreachable: the list refresh handles it
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private setState(state: RunState): void {
|
|
281
|
+
const before = this.get().states[state.runId];
|
|
282
|
+
// read through the store proxy before writing: it reflects the write immediately
|
|
283
|
+
const waited = (before?.pendingApprovals.length ?? 0) + (before?.pendingHumans.length ?? 0) > 0;
|
|
284
|
+
this.set('states', state.runId, state);
|
|
285
|
+
const waits = state.pendingApprovals.length + state.pendingHumans.length > 0;
|
|
286
|
+
if (waits && !waited && this.get().active !== state.runId)
|
|
287
|
+
this.set('unread', state.runId, 'needs');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private toastError(e: unknown, fallback: string): void {
|
|
291
|
+
if (e instanceof DaemonHttpError && e.status === 409 && /already answered/i.test(e.message)) {
|
|
292
|
+
this.toast({ message: 'Already answered elsewhere', variant: 'info' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
296
|
+
this.toast({ message: message || fallback, variant: 'error' });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private afterAction(): void {
|
|
300
|
+
this.fastUntil = this.now() + FAST_WINDOW_MS;
|
|
301
|
+
this.schedule(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async answer(id: InboxId, approved: boolean, note?: string): Promise<void> {
|
|
305
|
+
if (this.down()) return;
|
|
306
|
+
try {
|
|
307
|
+
await this.client.answer(id, { approved, note, via: 'cli' });
|
|
308
|
+
this.toast({ message: approved ? 'Approved' : 'Denied', variant: 'success' });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
this.toastError(e, 'Could not record the answer');
|
|
311
|
+
}
|
|
312
|
+
this.afterAction();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async cancel(runId: string): Promise<void> {
|
|
316
|
+
if (this.down()) return;
|
|
317
|
+
try {
|
|
318
|
+
this.setState(await this.client.cancel(runId));
|
|
319
|
+
this.toast({ message: `Run ${runId.slice(0, 8)} cancelled`, variant: 'success' });
|
|
320
|
+
} catch (e) {
|
|
321
|
+
this.toastError(e, 'Could not cancel the run');
|
|
322
|
+
}
|
|
323
|
+
this.afterAction();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async resume(runId: string, budgetUsd?: number): Promise<void> {
|
|
327
|
+
if (this.down()) return;
|
|
328
|
+
try {
|
|
329
|
+
this.setState(await this.client.resume(runId, { budgetUsd }));
|
|
330
|
+
this.toast({ message: `Run ${runId.slice(0, 8)} resumed`, variant: 'success' });
|
|
331
|
+
} catch (e) {
|
|
332
|
+
this.toastError(e, 'Could not resume the run');
|
|
333
|
+
}
|
|
334
|
+
this.afterAction();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Submits a run; returns its id, or undefined on failure (toast shown). */
|
|
338
|
+
async submit(req: SubmitRequest): Promise<string | undefined> {
|
|
339
|
+
if (this.down()) return undefined;
|
|
340
|
+
try {
|
|
341
|
+
const { runId, warnings } = await this.client.submitRun(req);
|
|
342
|
+
const note = warnings.length ? ` (${warnings.join('; ')})` : '';
|
|
343
|
+
this.toast({ message: `Run ${runId.slice(0, 8)} queued${note}`, variant: 'success' });
|
|
344
|
+
this.afterAction();
|
|
345
|
+
return runId;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
this.toastError(e, 'Could not submit the run');
|
|
348
|
+
this.afterAction();
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createContext, type JSX, type ParentProps, useContext } from 'solid-js';
|
|
4
|
+
import { createStore } from 'solid-js/store';
|
|
5
|
+
|
|
6
|
+
export interface Prefs {
|
|
7
|
+
lastOrg?: string;
|
|
8
|
+
lastAdapter?: string;
|
|
9
|
+
lastWorkflow?: string;
|
|
10
|
+
/** The model chosen with `/model` (`provider/model`). */
|
|
11
|
+
lastModel?: string;
|
|
12
|
+
animations?: boolean;
|
|
13
|
+
sidebarWidth?: number;
|
|
14
|
+
sidebar?: 'auto' | 'hide';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FILE = 'ui.json';
|
|
18
|
+
|
|
19
|
+
/** `~/.shibaox/ui.json`; unreadable or malformed means empty. */
|
|
20
|
+
export function loadPrefs(home: string): Prefs {
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(readFileSync(join(home, FILE), 'utf8')) as unknown;
|
|
23
|
+
return raw && typeof raw === 'object' ? (raw as Prefs) : {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function savePrefs(home: string, p: Prefs): void {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(home, { recursive: true });
|
|
32
|
+
writeFileSync(join(home, FILE), `${JSON.stringify(p, null, 2)}\n`);
|
|
33
|
+
} catch {
|
|
34
|
+
// preferences are a convenience; never fail the UI over them
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Context = createContext<{ data: Prefs; update: (patch: Partial<Prefs>) => void }>();
|
|
39
|
+
|
|
40
|
+
export function PrefsProvider(props: ParentProps<{ home: string }>): JSX.Element {
|
|
41
|
+
const [data, set] = createStore<Prefs>(loadPrefs(props.home));
|
|
42
|
+
const update = (patch: Partial<Prefs>) => {
|
|
43
|
+
set(patch);
|
|
44
|
+
const next = Object.fromEntries(Object.entries({ ...data }).filter(([, v]) => v !== undefined));
|
|
45
|
+
savePrefs(props.home, next);
|
|
46
|
+
};
|
|
47
|
+
return <Context.Provider value={{ data, update }}>{props.children}</Context.Provider>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function usePrefs(): { data: Prefs; update: (patch: Partial<Prefs>) => void } {
|
|
51
|
+
const c = useContext(Context);
|
|
52
|
+
if (!c) throw new Error('usePrefs outside PrefsProvider');
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Accessor,
|
|
3
|
+
createContext,
|
|
4
|
+
createSignal,
|
|
5
|
+
type JSX,
|
|
6
|
+
type ParentProps,
|
|
7
|
+
useContext,
|
|
8
|
+
} from 'solid-js';
|
|
9
|
+
|
|
10
|
+
export type Route = { type: 'home' } | { type: 'session'; runId: string };
|
|
11
|
+
|
|
12
|
+
const Context = createContext<{ data: Accessor<Route>; navigate: (r: Route) => void }>();
|
|
13
|
+
|
|
14
|
+
export function RouteProvider(props: ParentProps<{ initial?: Route }>): JSX.Element {
|
|
15
|
+
const [data, setData] = createSignal<Route>(props.initial ?? { type: 'home' });
|
|
16
|
+
return <Context.Provider value={{ data, navigate: setData }}>{props.children}</Context.Provider>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useRoute(): { data: Accessor<Route>; navigate: (r: Route) => void } {
|
|
20
|
+
const c = useContext(Context);
|
|
21
|
+
if (!c) throw new Error('useRoute outside RouteProvider');
|
|
22
|
+
return c;
|
|
23
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bun entry the CLI spawns (from the apps/tui root, with @opentui/solid/preload):
|
|
3
|
+
* bun --preload @opentui/solid/preload src/main.tsx dashboard --socket <path> --home <dir> --version <v> [--cwd <dir>]
|
|
4
|
+
* bun --preload @opentui/solid/preload src/main.tsx stream <runId> --socket <path> --home <dir>
|
|
5
|
+
*/
|
|
6
|
+
import { appendFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { DaemonClient } from '@wizardingcode/shibaox-daemon/client';
|
|
9
|
+
import { runDashboard, runStream } from './app.js';
|
|
10
|
+
|
|
11
|
+
function arg(name: string): string | undefined {
|
|
12
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
13
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mode = process.argv[2];
|
|
17
|
+
const socket = arg('socket');
|
|
18
|
+
const home = arg('home');
|
|
19
|
+
if (!socket || !home || (mode !== 'dashboard' && mode !== 'stream')) {
|
|
20
|
+
console.error(
|
|
21
|
+
'usage: main.tsx dashboard|stream [runId] --socket <path> --home <dir> [--version <v>] [--cwd <dir>]',
|
|
22
|
+
);
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const client = new DaemonClient(socket);
|
|
26
|
+
const log = (line: string) => {
|
|
27
|
+
try {
|
|
28
|
+
appendFileSync(join(home, 'tui.log'), `${new Date().toISOString()} ${line}\n`);
|
|
29
|
+
} catch {
|
|
30
|
+
// the log is a convenience; never fail the UI over it
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
if (mode === 'dashboard') {
|
|
34
|
+
const code = await runDashboard(client, {
|
|
35
|
+
version: arg('version') ?? '0.0.0',
|
|
36
|
+
home,
|
|
37
|
+
cwd: arg('cwd'),
|
|
38
|
+
log,
|
|
39
|
+
});
|
|
40
|
+
process.exit(code);
|
|
41
|
+
} else {
|
|
42
|
+
const runId = process.argv[3];
|
|
43
|
+
if (!runId) {
|
|
44
|
+
console.error('usage: main.tsx stream <runId> ...');
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
const r = await runStream(client, runId, {
|
|
48
|
+
version: arg('version') ?? '0.0.0',
|
|
49
|
+
home,
|
|
50
|
+
cwd: arg('cwd'),
|
|
51
|
+
log,
|
|
52
|
+
});
|
|
53
|
+
if (r.message) console.log(r.message);
|
|
54
|
+
process.exit(r.code);
|
|
55
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** `$0.0020` under a dollar, `$1.50` from a dollar up. */
|
|
2
|
+
export const money = (usd: number): string =>
|
|
3
|
+
usd < 1 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
|
|
4
|
+
|
|
5
|
+
/** `41 s`, `2m 13s`, `1h 04m`. */
|
|
6
|
+
export const duration = (ms: number): string => {
|
|
7
|
+
const s = Math.floor(ms / 1000);
|
|
8
|
+
if (s < 60) return `${s} s`;
|
|
9
|
+
const m = Math.floor(s / 60);
|
|
10
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
11
|
+
const h = Math.floor(m / 60);
|
|
12
|
+
return `${h}h ${String(m % 60).padStart(2, '0')}m`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** How long ago an ISO timestamp was: `12 s`, `3 m`, `2 h`, `4 d`. */
|
|
16
|
+
export const age = (iso: string, now = Date.now()): string => {
|
|
17
|
+
const s = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000));
|
|
18
|
+
if (s < 60) return `${s} s`;
|
|
19
|
+
const m = Math.floor(s / 60);
|
|
20
|
+
if (m < 60) return `${m} m`;
|
|
21
|
+
const h = Math.floor(m / 60);
|
|
22
|
+
if (h < 24) return `${h} h`;
|
|
23
|
+
return `${Math.floor(h / 24)} d`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const shortId = (id: string): string => id.slice(0, 8);
|
|
27
|
+
|
|
28
|
+
/** `~/dir` for paths under the home directory. */
|
|
29
|
+
export function tilde(p: string, home: string | undefined = process.env.HOME): string {
|
|
30
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `24.0k` / `1.2M` for token counts. */
|
|
34
|
+
export function tokens(n: number): string {
|
|
35
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
36
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
37
|
+
return String(n);
|
|
38
|
+
}
|