@wizardingcode/shibaox-tui 0.1.2 → 0.1.4

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 (64) hide show
  1. package/dist/main.js +8188 -0
  2. package/dist/main.js.map +67 -0
  3. package/package.json +9 -13
  4. package/src/app.tsx +0 -300
  5. package/src/component/dialogs/confirm.tsx +0 -29
  6. package/src/component/dialogs/diff.tsx +0 -143
  7. package/src/component/dialogs/help.tsx +0 -99
  8. package/src/component/dialogs/keys.tsx +0 -201
  9. package/src/component/dialogs/palette.tsx +0 -95
  10. package/src/component/dialogs/runs.tsx +0 -115
  11. package/src/component/dialogs/tiers.tsx +0 -240
  12. package/src/component/footer.tsx +0 -66
  13. package/src/component/logo.tsx +0 -29
  14. package/src/component/prompt/autocomplete.tsx +0 -44
  15. package/src/component/prompt/index.tsx +0 -213
  16. package/src/component/reconnecting.tsx +0 -44
  17. package/src/component/sidebar.tsx +0 -235
  18. package/src/component/tabs.tsx +0 -116
  19. package/src/component/too-small.tsx +0 -15
  20. package/src/context/client.tsx +0 -54
  21. package/src/context/commands.tsx +0 -44
  22. package/src/context/config.tsx +0 -22
  23. package/src/context/data-state.ts +0 -41
  24. package/src/context/data.tsx +0 -308
  25. package/src/context/exit.tsx +0 -13
  26. package/src/context/keys.tsx +0 -70
  27. package/src/context/poller.ts +0 -352
  28. package/src/context/prefs.tsx +0 -54
  29. package/src/context/route.tsx +0 -23
  30. package/src/main.tsx +0 -55
  31. package/src/model/format.ts +0 -38
  32. package/src/model/prompt-commands.ts +0 -315
  33. package/src/model/status.ts +0 -55
  34. package/src/model/stream.ts +0 -464
  35. package/src/motion/animation.ts +0 -235
  36. package/src/motion/config.tsx +0 -15
  37. package/src/motion/fade-in-text.tsx +0 -93
  38. package/src/motion/masked-text.ts +0 -62
  39. package/src/motion/one-cell-motion.ts +0 -100
  40. package/src/motion/pulse.ts +0 -38
  41. package/src/motion/shimmer-text.tsx +0 -78
  42. package/src/motion/spinner-renderable.ts +0 -171
  43. package/src/motion/spinner.tsx +0 -100
  44. package/src/motion/subcell.ts +0 -40
  45. package/src/routes/home.tsx +0 -365
  46. package/src/routes/session/approval-bar.tsx +0 -136
  47. package/src/routes/session/cards.tsx +0 -495
  48. package/src/routes/session/context-line.tsx +0 -129
  49. package/src/routes/session/continue.tsx +0 -104
  50. package/src/routes/session/index.tsx +0 -340
  51. package/src/routes/session/request.tsx +0 -87
  52. package/src/routes/session/timeline.tsx +0 -313
  53. package/src/testing/fake-client.ts +0 -249
  54. package/src/theme/context.tsx +0 -44
  55. package/src/theme/resolve.ts +0 -196
  56. package/src/theme/shibaox.json +0 -159
  57. package/src/ui/border.ts +0 -20
  58. package/src/ui/delayed-presence.ts +0 -23
  59. package/src/ui/dialog.tsx +0 -123
  60. package/src/ui/layout.ts +0 -23
  61. package/src/ui/marquee.ts +0 -75
  62. package/src/ui/pane-resize.ts +0 -77
  63. package/src/ui/toast.tsx +0 -203
  64. package/tsconfig.json +0 -19
@@ -1,365 +0,0 @@
1
- import { existsSync, mkdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { useTerminalDimensions } from '@opentui/solid';
5
- import { loadOrg, OrgLoadError } from '@wizardingcode/shibaox-schemas';
6
- import {
7
- createEffect,
8
- createMemo,
9
- createResource,
10
- createSignal,
11
- type JSX,
12
- on,
13
- Show,
14
- } from 'solid-js';
15
- import { HelpDialog } from '../component/dialogs/help.js';
16
- import { KeysDialog } from '../component/dialogs/keys.js';
17
- import { RunsDialog } from '../component/dialogs/runs.js';
18
- import { TiersDialog } from '../component/dialogs/tiers.js';
19
- import { KeyHints } from '../component/footer.js';
20
- import { Logo } from '../component/logo.js';
21
- import { Prompt, type PromptRef } from '../component/prompt/index.js';
22
- import { useClient } from '../context/client.js';
23
- import { useConfig } from '../context/config.js';
24
- import { useData } from '../context/data.js';
25
- import { usePrefs } from '../context/prefs.js';
26
- import { useRoute } from '../context/route.js';
27
- import { money, tilde } from '../model/format.js';
28
- import {
29
- ADAPTERS,
30
- type Adapter,
31
- adapterForModel,
32
- applyPromptCommand,
33
- COMMAND_HINT,
34
- type CommandName,
35
- homeCommands,
36
- type PromptCommand,
37
- type PromptContext,
38
- toSubmitRequest,
39
- } from '../model/prompt-commands.js';
40
- import { useTheme } from '../theme/context.js';
41
- import { useDialog } from '../ui/dialog.js';
42
- import { useToast } from '../ui/toast.js';
43
-
44
- export const PLACEHOLDERS = [
45
- 'Ask the team anything… "Add a /health endpoint"',
46
- 'Ask the team anything… "Fix the failing tests"',
47
- 'Ask the team anything… "What does this repo do?"',
48
- ];
49
- export const HOME_HINTS = [
50
- { key: '/', label: 'commands' },
51
- { key: 'ctrl+o', label: 'runs' },
52
- { key: 'ctrl+k', label: 'palette' },
53
- { key: '?', label: 'help' },
54
- ];
55
-
56
- interface OrgInfo {
57
- workflows: string[];
58
- /** Workflows marked `conversation: true` (the org's `chat`): they run in place. */
59
- single: string[];
60
- subscription: boolean;
61
- /** `org.yaml adapter`: the org's default runtime. */
62
- adapter?: 'mock' | 'direct' | 'claude-code';
63
- error?: string;
64
- }
65
-
66
- /** The org's workflows and whether its strong tier runs on a subscription runtime, or why it could not be loaded. */
67
- function orgInfo(root: string): OrgInfo {
68
- try {
69
- const org = loadOrg(root);
70
- const strong = String(org.models.tiers?.strong ?? '');
71
- return {
72
- workflows: Object.keys(org.workflows),
73
- single: Object.values(org.workflows)
74
- .filter((w) => w.conversation)
75
- .map((w) => w.workflow),
76
- subscription: /-subscription\//.test(strong),
77
- adapter: org.org.adapter,
78
- };
79
- } catch (e) {
80
- if (e instanceof OrgLoadError && /not found/i.test(e.message))
81
- return { workflows: [], single: [], subscription: false, error: `Org not found: ${root}` };
82
- return {
83
- workflows: [],
84
- single: [],
85
- subscription: false,
86
- error: e instanceof Error ? e.message : String(e),
87
- };
88
- }
89
- }
90
-
91
- /**
92
- * The project the dashboard works on: the current directory, except the home directory itself,
93
- * where the orchestrator gets a workspace of its own (`~/.shibaox/workspace`).
94
- */
95
- export function defaultProject(cwd: string, home: string | undefined): string {
96
- const h = home || homedir();
97
- return h && resolve(cwd) === resolve(h) ? join(h, '.shibaox', 'workspace') : cwd;
98
- }
99
-
100
- /** `daemon 0.0.1 · 2 running · 1 queued · ▲ 1 needs you`, or the unreachable notice. */
101
- export function daemonLine(d: ReturnType<typeof useData>['state'], version: string): string {
102
- if (!d.reachable) return 'Daemon unreachable';
103
- const running = d.runs.filter((r) => r.status === 'running').length;
104
- const queued = d.runs.filter((r) => r.status === 'queued').length;
105
- const needs = d.inbox.length;
106
- return `daemon ${version} · ${running} running · ${queued} queued${needs ? ` · ▲ ${needs} needs you` : ''}`;
107
- }
108
-
109
- export const MOCK_NOTICE =
110
- 'mock runs no model: the nodes complete at once. /adapter claude-code uses your Claude login';
111
-
112
- export { tilde } from '../model/format.js';
113
-
114
- export function Home(): JSX.Element {
115
- const theme = useTheme();
116
- const config = useConfig();
117
- const client = useClient();
118
- const data = useData();
119
- const prefs = usePrefs();
120
- const route = useRoute();
121
- const toast = useToast();
122
- const dialog = useDialog();
123
- const dimensions = useTerminalDimensions();
124
- let prompt: PromptRef | undefined;
125
- // the org: the remembered one, else `./org` next to the project, else the daemon's default
126
- const localOrg = join(config.cwd, 'org');
127
- const [ctx, setCtx] = createSignal<PromptContext>({
128
- org: prefs.data.lastOrg ?? (existsSync(join(localOrg, 'org.yaml')) ? localOrg : ''),
129
- project: defaultProject(config.cwd, config.env.HOME),
130
- // the org's models decide the runtime: a subscription tier runs through claude-code, an API
131
- // or local tier through our own agent loop (direct); mock is never the remembered choice
132
- adapter: ADAPTERS.find((a) => a === prefs.data.lastAdapter && a !== 'mock') ?? 'direct',
133
- model: prefs.data.lastModel,
134
- });
135
- // the models a run can be pointed at (`/model`), from the daemon
136
- const [models] = createResource(() => client.models().catch(() => []));
137
- // the daemon's default org, asked with a few retries: the daemon may still be settling
138
- // (a fresh install starts it on demand) and a transient failure must not leave the home
139
- // without an org
140
- const [defaultOrg] = createResource<{ root: string; created: boolean; error?: string }>(
141
- async () => {
142
- for (const wait of [0, 250, 1000, 2000]) {
143
- if (wait) await new Promise((r) => setTimeout(r, wait));
144
- try {
145
- return await client.defaultOrg();
146
- } catch {
147
- // try again
148
- }
149
- }
150
- return { root: '', created: false, error: 'the daemon did not answer for the default org' };
151
- },
152
- );
153
- createEffect(() => {
154
- const d = defaultOrg();
155
- if (d?.root && !ctx().org) setCtx((c) => ({ ...c, org: d.root }));
156
- });
157
- const orgRoot = createMemo(() => ctx().org);
158
- // bumped when the org's files change under us (a /tiers save) so the memo reads them again
159
- const [orgVersion, setOrgVersion] = createSignal(0);
160
- const org = createMemo(() => {
161
- orgVersion();
162
- return orgInfo(orgRoot());
163
- });
164
- // what the project is (stack, tests, size), from the daemon; nothing when it cannot say
165
- const [profile] = createResource(
166
- () => ({ project: ctx().project, org: orgRoot() }),
167
- (k) => client.projectProfile(k.project, k.org).catch(() => undefined),
168
- );
169
- // only the chosen model (not every context change) may move the adapter
170
- const chosenModel = createMemo(() => ctx().model);
171
- createEffect(() => {
172
- // a chosen model decides the adapter; otherwise the org's tiers do (unless remembered)
173
- const ref = chosenModel();
174
- const chosen = ref ? adapterForModel(ref, models() ?? []) : undefined;
175
- if (!chosen && prefs.data.lastAdapter && prefs.data.lastAdapter !== 'mock') return;
176
- const adapter = chosen ?? org().adapter ?? (org().subscription ? 'claude-code' : 'direct');
177
- setCtx((c) => (c.adapter === adapter ? c : { ...c, adapter }));
178
- });
179
- // a closed dialog gives the keyboard back to the prompt
180
- createEffect(
181
- on(
182
- dialog.depth,
183
- (d) => {
184
- if (d === 0) setTimeout(() => prompt?.focus(), 0);
185
- },
186
- { defer: true },
187
- ),
188
- );
189
- const [error, setError] = createSignal<string | undefined>();
190
- const [hint, setHint] = createSignal<string | undefined>();
191
- // the first workflow of the org when none was picked yet
192
- // chat is the entry of the dashboard when the org has it; other workflows are one `/workflow` away
193
- const workflow = () =>
194
- ctx().workflow ?? (org().workflows.includes('chat') ? 'chat' : org().workflows[0]);
195
-
196
- const onCommand = (cmd: PromptCommand) => {
197
- setError(undefined);
198
- setHint(undefined);
199
- if (cmd.command === 'help') return dialog.open(() => <HelpDialog />);
200
- if (cmd.command === 'runs') return dialog.open(() => <RunsDialog />);
201
- if (cmd.command === 'keys') return dialog.open(() => <KeysDialog />);
202
- if (cmd.command === 'tiers')
203
- return dialog.open(() => (
204
- <TiersDialog orgRoot={orgRoot()} onSaved={() => setOrgVersion((v) => v + 1)} />
205
- ));
206
- if (cmd.command === 'key') {
207
- const [name, ...rest] = cmd.arg.split(/\s+/);
208
- const value = rest.join(' ').trim();
209
- if (!name || !value) return setError('Usage: /key NAME VALUE');
210
- void client
211
- .setKey(name, value)
212
- .then(() => toast.show({ message: `${name} saved in the vault`, variant: 'success' }))
213
- .catch((e: unknown) =>
214
- setError(`Could not save ${name}: ${e instanceof Error ? e.message : String(e)}`),
215
- );
216
- return;
217
- }
218
- const next = applyPromptCommand(ctx(), cmd, { cwd: config.cwd });
219
- if ('error' in next) return setError(next.error);
220
- setCtx(next);
221
- };
222
- const onSubmit = async (text: string) => {
223
- setError(undefined);
224
- setHint(undefined);
225
- if (org().error) return setError(org().error);
226
- const wf = workflow();
227
- if (!wf) return setError('Pick a workflow first: /workflow <name>');
228
- const req = toSubmitRequest({ ...ctx(), workflow: wf }, text);
229
- // a conversation acts on the checkout itself; teams get the org default (a worktree)
230
- if (req.workspace === undefined && org().single.includes(wf)) req.workspace = 'inplace';
231
- if (req.project === join(config.env.HOME || homedir(), '.shibaox', 'workspace'))
232
- mkdirSync(req.project, { recursive: true });
233
- const runId = await data.actions.submit(req);
234
- if (!runId) return;
235
- prefs.update({
236
- lastOrg: req.orgRoot,
237
- lastAdapter: req.adapter === 'mock' ? undefined : (req.adapter as Adapter),
238
- lastWorkflow: wf,
239
- lastModel: req.model,
240
- });
241
- prompt?.clear();
242
- data.openRun(runId);
243
- route.navigate({ type: 'session', runId });
244
- };
245
-
246
- // no org known yet: while the daemon is still being asked, say so instead of an error
247
- const orgPending = () => !orgRoot() && (defaultOrg.loading || defaultOrg() === undefined);
248
- const notice = () =>
249
- error() ??
250
- (orgPending()
251
- ? 'finding the org…'
252
- : !orgRoot() && defaultOrg()?.error
253
- ? defaultOrg()?.error
254
- : org().error);
255
- const mockNotice = () => (ctx().adapter === 'mock' ? MOCK_NOTICE : undefined);
256
- const contextFooter = () => {
257
- const c = ctx();
258
- const muted = theme.text.muted;
259
- const accent = theme.text.action.primary.selected;
260
- return (
261
- <text fg={theme.text.base} wrapMode="none">
262
- <span style={{ fg: muted }}>workflow </span>
263
- <span style={{ fg: accent }}>{workflow() ?? '—'}</span>
264
- {c.model ? (
265
- // a chosen model implies its adapter
266
- <>
267
- <span style={{ fg: muted }}> model </span>
268
- {c.model}
269
- </>
270
- ) : (
271
- <>
272
- <span style={{ fg: muted }}> adapter </span>
273
- {c.adapter}
274
- </>
275
- )}
276
- <span style={{ fg: muted }}> project </span>
277
- {tilde(c.project, config.env.HOME)}
278
- {c.workspace ? <span style={{ fg: muted }}>{` workspace ${c.workspace}`}</span> : ''}
279
- {c.budgetUsd !== undefined ? (
280
- <span style={{ fg: muted }}>{` budget ${money(c.budgetUsd)}`}</span>
281
- ) : (
282
- ''
283
- )}
284
- </text>
285
- );
286
- };
287
-
288
- return (
289
- <box flexDirection="column" width="100%" height="100%">
290
- <box
291
- flexGrow={1}
292
- alignItems="center"
293
- paddingLeft={dimensions().width < 44 ? 1 : 2}
294
- paddingRight={dimensions().width < 44 ? 1 : 2}
295
- >
296
- <box flexGrow={1} minHeight={0} />
297
- <Logo />
298
- <box height={1} flexShrink={0} />
299
- <box width="100%" maxWidth={76} flexShrink={0} flexDirection="column">
300
- <Prompt
301
- ref={(r) => {
302
- prompt = r;
303
- }}
304
- placeholders={PLACEHOLDERS}
305
- commands={homeCommands(org().workflows, models() ?? [])}
306
- disabled={dialog.depth() > 0}
307
- onSubmit={(t) => void onSubmit(t)}
308
- onCommand={onCommand}
309
- onInput={() => {
310
- setError(undefined);
311
- setHint(undefined);
312
- }}
313
- onNeedValue={(c) => setHint(COMMAND_HINT[c as CommandName])}
314
- footer={contextFooter()}
315
- />
316
- <Show when={profile()?.summary}>
317
- <box height={1} flexShrink={0} width="100%" paddingLeft={1}>
318
- <text fg={theme.text.muted} wrapMode="none">
319
- {profile()?.summary ?? ''}
320
- </text>
321
- </box>
322
- </Show>
323
- <box height={1} flexShrink={0} flexDirection="row" width="100%" paddingLeft={1}>
324
- <Show when={notice() ?? hint() ?? mockNotice()}>
325
- <text
326
- fg={
327
- notice()
328
- ? theme.text.feedback.error
329
- : hint()
330
- ? theme.text.muted
331
- : theme.text.feedback.warning
332
- }
333
- wrapMode="none"
334
- >
335
- {notice() ?? hint() ?? mockNotice() ?? ''}
336
- </text>
337
- </Show>
338
- <box flexGrow={1} />
339
- <KeyHints hints={HOME_HINTS} />
340
- </box>
341
- </box>
342
- <box flexGrow={1} minHeight={0} />
343
- </box>
344
- <box
345
- height={1}
346
- flexShrink={0}
347
- flexDirection="row"
348
- paddingLeft={2}
349
- paddingRight={2}
350
- width="100%"
351
- >
352
- <text fg={theme.text.muted} flexShrink={1} wrapMode="none">
353
- {tilde(config.cwd, config.env.HOME)}
354
- </text>
355
- <text fg={theme.text.muted} flexShrink={0} wrapMode="none">
356
- {` · ${daemonLine(data.state, config.version)}`}
357
- </text>
358
- <box flexGrow={1} flexShrink={0} width={2} />
359
- <text fg={theme.text.muted} flexShrink={0} wrapMode="none">
360
- {`shibaox ${config.version}`}
361
- </text>
362
- </box>
363
- </box>
364
- );
365
- }
@@ -1,136 +0,0 @@
1
- import type { InputRenderable } from '@opentui/core';
2
- import type { InboxItem } from '@wizardingcode/shibaox-daemon';
3
- import { createMemo, createSignal, type JSX, Show } from 'solid-js';
4
- import { useData } from '../../context/data.js';
5
- import { useKeys } from '../../context/keys.js';
6
- import { useTheme } from '../../theme/context.js';
7
-
8
- export const APPROVAL_KEYS = '[a]pprove [d]eny [n]ote';
9
-
10
- /** The pending items of a run (approvals first): the one the keys act on is the first. */
11
- export function pendingFor(inbox: InboxItem[], runId: string): InboxItem[] {
12
- return inbox
13
- .filter((i) => i.runId === runId)
14
- .sort((a, b) => (a.kind === b.kind ? 0 : a.kind === 'approval' ? -1 : 1));
15
- }
16
-
17
- /**
18
- * Replaces the key hints while the run waits for the user: a approves (an approval of a command
19
- * asks y first), d denies, n asks for a note and approves with it; esc backs out.
20
- */
21
- export function ApprovalBar(props: { runId: string }): JSX.Element {
22
- const data = useData();
23
- const theme = useTheme();
24
- const items = createMemo(() => pendingFor(data.state.inbox, props.runId));
25
- const item = () => items()[0];
26
- const [mode, setMode] = createSignal<'idle' | 'confirm' | 'note'>('idle');
27
- const [note, setNote] = createSignal('');
28
-
29
- const answer = (approved: boolean, text?: string) => {
30
- const i = item();
31
- if (!i) return;
32
- setMode('idle');
33
- setNote('');
34
- void data.actions.answer(i.id, approved, text || undefined);
35
- };
36
-
37
- useKeys('pane', (key) => {
38
- const i = item();
39
- if (!i || mode() === 'note' || key.ctrl || key.meta) return false;
40
- if (mode() === 'confirm') {
41
- if (key.name === 'y') answer(true);
42
- else if (key.name === 'n' || key.name === 'escape') setMode('idle');
43
- return true;
44
- }
45
- if (key.name === 'a') {
46
- if (i.kind === 'approval') setMode('confirm');
47
- else answer(true);
48
- return true;
49
- }
50
- if (key.name === 'd') {
51
- answer(false);
52
- return true;
53
- }
54
- if (key.name === 'n') {
55
- // after this key event: the focused renderable receives the key after the listeners,
56
- // so a synchronously mounted input would get the `n` typed into it
57
- setTimeout(() => setMode('note'), 0);
58
- return true;
59
- }
60
- return false;
61
- });
62
- useKeys('prompt', (key) => {
63
- if (mode() !== 'note') return false;
64
- if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
65
- setMode('idle');
66
- setNote('');
67
- return true;
68
- }
69
- return false;
70
- });
71
-
72
- const who = () => {
73
- const i = item();
74
- if (!i) return '';
75
- return i.kind === 'approval'
76
- ? (i.detail.role ?? i.detail.program ?? '')
77
- : (i.detail.action ?? i.nodeId);
78
- };
79
-
80
- return (
81
- <box height={2} flexShrink={0} flexDirection="column" width="100%">
82
- <Show
83
- when={mode() === 'note'}
84
- fallback={
85
- <Show
86
- when={mode() === 'confirm'}
87
- fallback={
88
- <box height={1} flexDirection="row">
89
- <text
90
- fg={theme.text.feedback.warning}
91
- wrapMode="none"
92
- >{`▲ ${item()?.prompt ?? ''} · ${who()} · `}</text>
93
- <text fg={theme.text.base} wrapMode="none">
94
- {APPROVAL_KEYS}
95
- </text>
96
- </box>
97
- }
98
- >
99
- <box height={1}>
100
- <text
101
- fg={theme.text.feedback.warning}
102
- wrapMode="none"
103
- >{`Approve ${item()?.prompt ?? ''}? (y/n)`}</text>
104
- </box>
105
- </Show>
106
- }
107
- >
108
- <box height={1} flexDirection="row">
109
- <text fg={theme.text.feedback.warning} flexShrink={0}>
110
- {'Note · '}
111
- </text>
112
- <input
113
- ref={(r: InputRenderable) => r.focus()}
114
- focused
115
- onInput={setNote}
116
- onSubmit={() => answer(true, note())}
117
- flexGrow={1}
118
- backgroundColor={theme.background.raised.base}
119
- focusedBackgroundColor={theme.background.raised.base}
120
- textColor={theme.text.base}
121
- cursorColor={theme.text.feedback.warning}
122
- />
123
- </box>
124
- </Show>
125
- <box height={1}>
126
- <text fg={theme.text.muted} wrapMode="none">
127
- {items().length > 1
128
- ? `+${items().length - 1} more waiting`
129
- : item()?.kind === 'approval'
130
- ? 'a command needs your approval before it runs'
131
- : 'the workflow waits for your answer'}
132
- </text>
133
- </box>
134
- </box>
135
- );
136
- }