@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,201 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import type { KeyRow } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
import { createMemo, createResource, createSignal, For, type JSX, Show } from 'solid-js';
|
|
4
|
+
import { useClient } from '../../context/client.js';
|
|
5
|
+
import { useKeys } from '../../context/keys.js';
|
|
6
|
+
import { useTheme } from '../../theme/context.js';
|
|
7
|
+
import { Dialog, useDialog } from '../../ui/dialog.js';
|
|
8
|
+
import { useToast } from '../../ui/toast.js';
|
|
9
|
+
|
|
10
|
+
const VISIBLE = 16;
|
|
11
|
+
/** The keys most people set first, ahead of the rest of the catalog. */
|
|
12
|
+
const PRIORITY = [
|
|
13
|
+
'ANTHROPIC_API_KEY',
|
|
14
|
+
'OPENROUTER_API_KEY',
|
|
15
|
+
'OPENAI_API_KEY',
|
|
16
|
+
'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
17
|
+
'TYPESAFE_API_KEY',
|
|
18
|
+
'SHIBAOX_TELEGRAM_TOKEN',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Set keys first, then the usual ones, then the rest by name. */
|
|
22
|
+
export function orderKeys(rows: readonly KeyRow[]): KeyRow[] {
|
|
23
|
+
const rank = (r: KeyRow) => {
|
|
24
|
+
const p = PRIORITY.indexOf(r.name);
|
|
25
|
+
return (r.set ? 0 : 1000) + (p < 0 ? PRIORITY.length : p);
|
|
26
|
+
};
|
|
27
|
+
return [...rows].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The key vault: ↑/↓ pick a key, enter types its value (masked) and enter again saves it,
|
|
32
|
+
* `x` removes the selected one. Values never show on screen.
|
|
33
|
+
*/
|
|
34
|
+
export function KeysDialog(): JSX.Element {
|
|
35
|
+
const client = useClient();
|
|
36
|
+
const dialog = useDialog();
|
|
37
|
+
const toast = useToast();
|
|
38
|
+
const theme = useTheme().surface('dialog');
|
|
39
|
+
const [rows, { refetch }] = createResource<KeyRow[]>(() => client.keys().catch(() => []));
|
|
40
|
+
const sorted = createMemo(() => orderKeys(rows() ?? []));
|
|
41
|
+
const [cursor, setCursor] = createSignal(0);
|
|
42
|
+
/** After a save the list reorders (set keys first): the cursor stays on the same key. */
|
|
43
|
+
const reload = async () => {
|
|
44
|
+
const name = selected()?.name;
|
|
45
|
+
await refetch();
|
|
46
|
+
if (name) {
|
|
47
|
+
const i = sorted().findIndex((r) => r.name === name);
|
|
48
|
+
if (i >= 0) setCursor(i);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const [editing, setEditing] = createSignal<string | undefined>();
|
|
52
|
+
const [value, setValue] = createSignal('');
|
|
53
|
+
const selected = () => sorted()[Math.min(cursor(), Math.max(0, sorted().length - 1))];
|
|
54
|
+
const save = async () => {
|
|
55
|
+
const name = editing();
|
|
56
|
+
const v = value().trim();
|
|
57
|
+
if (!name) return;
|
|
58
|
+
if (!v) {
|
|
59
|
+
setEditing(undefined);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await client.setKey(name, v);
|
|
64
|
+
toast.show({ message: `${name} saved in the vault`, variant: 'success' });
|
|
65
|
+
await reload();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
toast.show({
|
|
68
|
+
message: `Could not save ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
69
|
+
variant: 'error',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
setValue('');
|
|
73
|
+
setEditing(undefined);
|
|
74
|
+
};
|
|
75
|
+
const remove = async () => {
|
|
76
|
+
const r = selected();
|
|
77
|
+
if (!r?.set || r.source !== 'vault') {
|
|
78
|
+
if (r?.source === 'env')
|
|
79
|
+
toast.show({ message: `${r.name} comes from the shell, not the vault`, variant: 'info' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await client.unsetKey(r.name).catch(() => undefined);
|
|
83
|
+
toast.show({ message: `${r.name} removed from the vault`, variant: 'info' });
|
|
84
|
+
await reload();
|
|
85
|
+
};
|
|
86
|
+
useKeys('dialog', (key) => {
|
|
87
|
+
if (editing()) return false; // the input owns the keys while a value is typed
|
|
88
|
+
if (key.name === 'up') {
|
|
89
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (key.name === 'down') {
|
|
93
|
+
setCursor((c) => Math.min(sorted().length - 1, c + 1));
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (key.name === 'return') {
|
|
97
|
+
const r = selected();
|
|
98
|
+
// the input mounts on the next tick: a focused input would receive this same enter
|
|
99
|
+
if (r)
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
setValue('');
|
|
102
|
+
setEditing(r.name);
|
|
103
|
+
}, 0);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (key.name === 'x' && !key.ctrl) {
|
|
107
|
+
void remove();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
const start = () =>
|
|
113
|
+
Math.max(0, Math.min(cursor() - Math.floor(VISIBLE / 2), sorted().length - VISIBLE));
|
|
114
|
+
const window = () => sorted().slice(start(), start() + VISIBLE);
|
|
115
|
+
return (
|
|
116
|
+
<Dialog size="large" title="Keys" onClose={() => dialog.close()}>
|
|
117
|
+
<box flexDirection="column" width="100%">
|
|
118
|
+
<Show when={rows.loading}>
|
|
119
|
+
<text fg={theme.text.muted}>Reading the vault…</text>
|
|
120
|
+
</Show>
|
|
121
|
+
<For each={window()}>
|
|
122
|
+
{(r, i) => {
|
|
123
|
+
const active = () => start() + i() === cursor();
|
|
124
|
+
return (
|
|
125
|
+
<box
|
|
126
|
+
height={1}
|
|
127
|
+
flexShrink={0}
|
|
128
|
+
flexDirection="row"
|
|
129
|
+
backgroundColor={active() ? theme.background.action.primary.selected : undefined}
|
|
130
|
+
>
|
|
131
|
+
<text
|
|
132
|
+
fg={r.set ? theme.text.feedback.success : theme.text.muted}
|
|
133
|
+
wrapMode="none"
|
|
134
|
+
flexShrink={0}
|
|
135
|
+
>
|
|
136
|
+
{r.set ? ' ● ' : ' ○ '}
|
|
137
|
+
</text>
|
|
138
|
+
<text
|
|
139
|
+
fg={active() ? theme.text.action.primary.selected : theme.text.base}
|
|
140
|
+
attributes={TextAttributes.BOLD}
|
|
141
|
+
wrapMode="none"
|
|
142
|
+
flexShrink={0}
|
|
143
|
+
>
|
|
144
|
+
{r.name.padEnd(28)}
|
|
145
|
+
</text>
|
|
146
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={0}>
|
|
147
|
+
{(r.set ? `${r.masked ?? ''} ${r.source ?? ''}` : 'missing').padEnd(22)}
|
|
148
|
+
</text>
|
|
149
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={1}>
|
|
150
|
+
{r.description}
|
|
151
|
+
</text>
|
|
152
|
+
</box>
|
|
153
|
+
);
|
|
154
|
+
}}
|
|
155
|
+
</For>
|
|
156
|
+
<Show when={sorted().length > VISIBLE}>
|
|
157
|
+
<box height={1} flexShrink={0}>
|
|
158
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
159
|
+
{`${cursor() + 1} of ${sorted().length}`}
|
|
160
|
+
</text>
|
|
161
|
+
</box>
|
|
162
|
+
</Show>
|
|
163
|
+
<box height={1} flexShrink={0} marginTop={1} flexDirection="row">
|
|
164
|
+
<Show
|
|
165
|
+
when={editing()}
|
|
166
|
+
fallback={
|
|
167
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
168
|
+
{'↑/↓ choose · enter set a value · x remove · esc close'}
|
|
169
|
+
</text>
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
<text fg={theme.text.base} wrapMode="none" flexShrink={0}>
|
|
173
|
+
{`value for ${editing() ?? ''}: `}
|
|
174
|
+
</text>
|
|
175
|
+
<text fg={theme.text.action.primary.selected} wrapMode="none" flexShrink={1}>
|
|
176
|
+
{'•'.repeat(Math.min(value().length, 40))}
|
|
177
|
+
</text>
|
|
178
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={0}>
|
|
179
|
+
{value().length > 40 ? ` (${value().length} chars)` : ''}
|
|
180
|
+
</text>
|
|
181
|
+
{/* the real input carries the keys; its text is hidden behind the dots */}
|
|
182
|
+
<input
|
|
183
|
+
focused
|
|
184
|
+
width={1}
|
|
185
|
+
onInput={setValue}
|
|
186
|
+
onSubmit={() => void save()}
|
|
187
|
+
backgroundColor={theme.background.base}
|
|
188
|
+
focusedBackgroundColor={theme.background.base}
|
|
189
|
+
textColor={theme.background.base}
|
|
190
|
+
placeholderColor={theme.background.base}
|
|
191
|
+
cursorColor={theme.text.action.primary.selected}
|
|
192
|
+
/>
|
|
193
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={0}>
|
|
194
|
+
{' enter saves'}
|
|
195
|
+
</text>
|
|
196
|
+
</Show>
|
|
197
|
+
</box>
|
|
198
|
+
</box>
|
|
199
|
+
</Dialog>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import fuzzysort from 'fuzzysort';
|
|
3
|
+
import { createMemo, createSignal, For, type JSX } from 'solid-js';
|
|
4
|
+
import { useCommands } from '../../context/commands.js';
|
|
5
|
+
import { useKeys } from '../../context/keys.js';
|
|
6
|
+
import { useTheme } from '../../theme/context.js';
|
|
7
|
+
import { Dialog, useDialog } from '../../ui/dialog.js';
|
|
8
|
+
|
|
9
|
+
/** Every available command with its keys; enter runs the selected one. */
|
|
10
|
+
export function PaletteDialog(): JSX.Element {
|
|
11
|
+
const registry = useCommands();
|
|
12
|
+
const dialog = useDialog();
|
|
13
|
+
const theme = useTheme().surface('dialog');
|
|
14
|
+
const [query, setQuery] = createSignal('');
|
|
15
|
+
const [cursor, setCursor] = createSignal(0);
|
|
16
|
+
const rows = createMemo(() => {
|
|
17
|
+
const all = registry.commands().filter((c) => c.when?.() ?? true);
|
|
18
|
+
const q = query().trim();
|
|
19
|
+
if (!q) return all;
|
|
20
|
+
return fuzzysort.go(q, all, { key: 'label' }).map((r) => r.obj);
|
|
21
|
+
});
|
|
22
|
+
const run = () => {
|
|
23
|
+
const c = rows()[Math.min(cursor(), rows().length - 1)];
|
|
24
|
+
if (!c) return;
|
|
25
|
+
dialog.close();
|
|
26
|
+
c.run();
|
|
27
|
+
};
|
|
28
|
+
useKeys('dialog', (key) => {
|
|
29
|
+
if (key.name === 'up') {
|
|
30
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (key.name === 'down') {
|
|
34
|
+
setCursor((c) => Math.min(rows().length - 1, c + 1));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (key.name === 'return') {
|
|
38
|
+
run();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
return (
|
|
44
|
+
<Dialog title="Commands" onClose={() => dialog.close()}>
|
|
45
|
+
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1}>
|
|
46
|
+
<text fg={theme.text.action.primary.selected} flexShrink={0}>
|
|
47
|
+
{'› '}
|
|
48
|
+
</text>
|
|
49
|
+
<input
|
|
50
|
+
focused
|
|
51
|
+
placeholder="type a command"
|
|
52
|
+
onInput={(v: string) => {
|
|
53
|
+
setQuery(v);
|
|
54
|
+
setCursor(0);
|
|
55
|
+
}}
|
|
56
|
+
flexGrow={1}
|
|
57
|
+
backgroundColor={theme.background.base}
|
|
58
|
+
focusedBackgroundColor={theme.background.base}
|
|
59
|
+
textColor={theme.text.base}
|
|
60
|
+
placeholderColor={theme.text.muted}
|
|
61
|
+
cursorColor={theme.text.action.primary.selected}
|
|
62
|
+
/>
|
|
63
|
+
</box>
|
|
64
|
+
<For each={rows().slice(0, 12)}>
|
|
65
|
+
{(c, i) => {
|
|
66
|
+
const selected = () => i() === Math.min(cursor(), rows().length - 1);
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
height={1}
|
|
70
|
+
flexShrink={0}
|
|
71
|
+
flexDirection="row"
|
|
72
|
+
backgroundColor={selected() ? theme.background.action.primary.selected : undefined}
|
|
73
|
+
onMouseUp={() => {
|
|
74
|
+
setCursor(i());
|
|
75
|
+
run();
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<text
|
|
79
|
+
fg={theme.text.base}
|
|
80
|
+
attributes={selected() ? TextAttributes.BOLD : undefined}
|
|
81
|
+
wrapMode="none"
|
|
82
|
+
>
|
|
83
|
+
{c.label}
|
|
84
|
+
</text>
|
|
85
|
+
<box flexGrow={1} />
|
|
86
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
87
|
+
{c.keys}
|
|
88
|
+
</text>
|
|
89
|
+
</box>
|
|
90
|
+
);
|
|
91
|
+
}}
|
|
92
|
+
</For>
|
|
93
|
+
</Dialog>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import fuzzysort from 'fuzzysort';
|
|
3
|
+
import { createMemo, createSignal, For, type JSX } from 'solid-js';
|
|
4
|
+
import { useData } from '../../context/data.js';
|
|
5
|
+
import { useKeys } from '../../context/keys.js';
|
|
6
|
+
import { age, money, shortId } from '../../model/format.js';
|
|
7
|
+
import { statusOf } from '../../model/status.js';
|
|
8
|
+
import { useTheme } from '../../theme/context.js';
|
|
9
|
+
import { Dialog, useDialog } from '../../ui/dialog.js';
|
|
10
|
+
|
|
11
|
+
const VISIBLE = 20;
|
|
12
|
+
|
|
13
|
+
/** Every run the daemon knows, filtered as you type; enter opens the selected one in a tab. */
|
|
14
|
+
export function RunsDialog(): JSX.Element {
|
|
15
|
+
const data = useData();
|
|
16
|
+
const dialog = useDialog();
|
|
17
|
+
const theme = useTheme().surface('dialog');
|
|
18
|
+
const [query, setQuery] = createSignal('');
|
|
19
|
+
const [cursor, setCursor] = createSignal(0);
|
|
20
|
+
const rows = createMemo(() => {
|
|
21
|
+
const runs = [...data.state.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
22
|
+
const q = query().trim();
|
|
23
|
+
if (!q) return runs;
|
|
24
|
+
return fuzzysort
|
|
25
|
+
.go(q, runs, { key: (r) => `${r.runId} ${r.workflow} ${r.status}` })
|
|
26
|
+
.map((r) => r.obj);
|
|
27
|
+
});
|
|
28
|
+
const open = () => {
|
|
29
|
+
const r = rows()[Math.min(cursor(), rows().length - 1)];
|
|
30
|
+
if (!r) return;
|
|
31
|
+
dialog.close();
|
|
32
|
+
data.openRun(r.runId);
|
|
33
|
+
};
|
|
34
|
+
useKeys('dialog', (key) => {
|
|
35
|
+
if (key.name === 'up') {
|
|
36
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (key.name === 'down') {
|
|
40
|
+
setCursor((c) => Math.min(rows().length - 1, c + 1));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (key.name === 'return') {
|
|
44
|
+
open();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
});
|
|
49
|
+
const start = () =>
|
|
50
|
+
Math.max(0, Math.min(cursor() - Math.floor(VISIBLE / 2), rows().length - VISIBLE));
|
|
51
|
+
return (
|
|
52
|
+
<Dialog size="large" title="Runs" onClose={() => dialog.close()}>
|
|
53
|
+
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1}>
|
|
54
|
+
<text fg={theme.text.action.primary.selected} flexShrink={0}>
|
|
55
|
+
{'› '}
|
|
56
|
+
</text>
|
|
57
|
+
<input
|
|
58
|
+
focused
|
|
59
|
+
placeholder="type to filter"
|
|
60
|
+
onInput={(v: string) => {
|
|
61
|
+
setQuery(v);
|
|
62
|
+
setCursor(0);
|
|
63
|
+
}}
|
|
64
|
+
flexGrow={1}
|
|
65
|
+
backgroundColor={theme.background.base}
|
|
66
|
+
focusedBackgroundColor={theme.background.base}
|
|
67
|
+
textColor={theme.text.base}
|
|
68
|
+
placeholderColor={theme.text.muted}
|
|
69
|
+
cursorColor={theme.text.action.primary.selected}
|
|
70
|
+
/>
|
|
71
|
+
</box>
|
|
72
|
+
<For each={rows().slice(start(), start() + VISIBLE)}>
|
|
73
|
+
{(r, i) => {
|
|
74
|
+
const selected = () => start() + i() === Math.min(cursor(), rows().length - 1);
|
|
75
|
+
const look = () => statusOf({ status: r.status });
|
|
76
|
+
const color = () => {
|
|
77
|
+
const f = look().feedback;
|
|
78
|
+
return f === 'muted' ? theme.text.muted : theme.text.feedback[f];
|
|
79
|
+
};
|
|
80
|
+
return (
|
|
81
|
+
<box
|
|
82
|
+
height={1}
|
|
83
|
+
flexShrink={0}
|
|
84
|
+
flexDirection="row"
|
|
85
|
+
backgroundColor={selected() ? theme.background.action.primary.selected : undefined}
|
|
86
|
+
onMouseUp={() => {
|
|
87
|
+
setCursor(start() + i());
|
|
88
|
+
open();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<text fg={color()} wrapMode="none">{`${look().symbol} `}</text>
|
|
92
|
+
<text
|
|
93
|
+
fg={theme.text.base}
|
|
94
|
+
attributes={selected() ? TextAttributes.BOLD : undefined}
|
|
95
|
+
wrapMode="none"
|
|
96
|
+
>
|
|
97
|
+
{`${shortId(r.runId)} ${r.workflow}`}
|
|
98
|
+
</text>
|
|
99
|
+
<box flexGrow={1} />
|
|
100
|
+
<text
|
|
101
|
+
fg={theme.text.muted}
|
|
102
|
+
wrapMode="none"
|
|
103
|
+
>{`${look().word} · ${age(r.createdAt)} · ${money(r.spentUsd)}`}</text>
|
|
104
|
+
</box>
|
|
105
|
+
);
|
|
106
|
+
}}
|
|
107
|
+
</For>
|
|
108
|
+
<box height={1} flexShrink={0} marginTop={1}>
|
|
109
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
110
|
+
{rows().length === 0 ? 'No runs match' : '↑/↓ select · enter open · esc close'}
|
|
111
|
+
</text>
|
|
112
|
+
</box>
|
|
113
|
+
</Dialog>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import type { ModelChoice, OrgConfig, OrgConfigPatch } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
import fuzzysort from 'fuzzysort';
|
|
4
|
+
import { createMemo, createResource, createSignal, For, type JSX, Show } from 'solid-js';
|
|
5
|
+
import { useClient } from '../../context/client.js';
|
|
6
|
+
import { useKeys } from '../../context/keys.js';
|
|
7
|
+
import { useTheme } from '../../theme/context.js';
|
|
8
|
+
import { Dialog, useDialog } from '../../ui/dialog.js';
|
|
9
|
+
import { useToast } from '../../ui/toast.js';
|
|
10
|
+
|
|
11
|
+
const VISIBLE = 12;
|
|
12
|
+
type RowName = 'strong' | 'cheap' | 'decision' | 'judge' | 'adapter';
|
|
13
|
+
interface Row {
|
|
14
|
+
name: RowName;
|
|
15
|
+
value?: string;
|
|
16
|
+
note: string;
|
|
17
|
+
}
|
|
18
|
+
const ADAPTERS = ['direct', 'claude-code', 'mock'];
|
|
19
|
+
|
|
20
|
+
/** What the dialog lists for an org config, in the order people read it. */
|
|
21
|
+
export function tierRows(c: OrgConfig | undefined): Row[] {
|
|
22
|
+
return [
|
|
23
|
+
{ name: 'strong', value: c?.tiers.strong, note: 'tasks that need the best model' },
|
|
24
|
+
{ name: 'cheap', value: c?.tiers.cheap, note: 'the orchestrator and light roles' },
|
|
25
|
+
{ name: 'decision', value: c?.tiers.decision, note: 'decide nodes (model ref or jev-latest)' },
|
|
26
|
+
{ name: 'judge', value: c?.judge, note: 'judge checks (default: decision, then strong)' },
|
|
27
|
+
{ name: 'adapter', value: c?.adapter, note: 'default runtime; a chosen model overrides it' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The choices for a row: the daemon's models (usable first) for a tier, the adapters otherwise. */
|
|
32
|
+
export function tierChoices(name: RowName, models: readonly ModelChoice[]): string[] {
|
|
33
|
+
if (name === 'adapter') return ADAPTERS;
|
|
34
|
+
const refs = [...models]
|
|
35
|
+
.sort((a, b) => Number(b.configured) - Number(a.configured))
|
|
36
|
+
.map((m) => m.ref);
|
|
37
|
+
return name === 'decision' ? ['jev-latest', ...refs] : refs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The org's tiers, judge and adapter: ↑/↓ pick a row, enter opens the list of models (typed
|
|
42
|
+
* text filters it), enter again saves through the daemon; `x` clears judge/adapter.
|
|
43
|
+
*/
|
|
44
|
+
export function TiersDialog(props: { orgRoot: string; onSaved?: () => void }): JSX.Element {
|
|
45
|
+
const client = useClient();
|
|
46
|
+
const dialog = useDialog();
|
|
47
|
+
const toast = useToast();
|
|
48
|
+
const theme = useTheme().surface('dialog');
|
|
49
|
+
const [config, { refetch }] = createResource(() => client.orgConfig(props.orgRoot));
|
|
50
|
+
const [models] = createResource(() => client.models().catch(() => []));
|
|
51
|
+
const rows = createMemo(() => tierRows(config()));
|
|
52
|
+
const [cursor, setCursor] = createSignal(0);
|
|
53
|
+
const [editing, setEditing] = createSignal<RowName | undefined>();
|
|
54
|
+
const [query, setQuery] = createSignal('');
|
|
55
|
+
const [pick, setPick] = createSignal(0);
|
|
56
|
+
const choices = createMemo(() => {
|
|
57
|
+
const name = editing();
|
|
58
|
+
if (!name) return [];
|
|
59
|
+
const all = tierChoices(name, models() ?? []);
|
|
60
|
+
const q = query().trim();
|
|
61
|
+
if (!q) return all;
|
|
62
|
+
return fuzzysort.go(q, all).map((r) => r.target);
|
|
63
|
+
});
|
|
64
|
+
const selected = () => rows()[Math.min(cursor(), rows().length - 1)];
|
|
65
|
+
const patchFor = (name: RowName, value: string | null): OrgConfigPatch =>
|
|
66
|
+
name === 'judge'
|
|
67
|
+
? { judge: value }
|
|
68
|
+
: name === 'adapter'
|
|
69
|
+
? { adapter: value as OrgConfigPatch['adapter'] }
|
|
70
|
+
: { tiers: { [name]: value } };
|
|
71
|
+
const save = async (name: RowName, value: string | null) => {
|
|
72
|
+
try {
|
|
73
|
+
await client.setOrgConfig(props.orgRoot, patchFor(name, value));
|
|
74
|
+
toast.show({ message: value ? `${name} → ${value}` : `${name} cleared`, variant: 'success' });
|
|
75
|
+
await refetch();
|
|
76
|
+
props.onSaved?.();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
toast.show({
|
|
79
|
+
message: `Could not set ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
80
|
+
variant: 'error',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const choose = () => {
|
|
85
|
+
const name = editing();
|
|
86
|
+
const value = choices()[Math.min(pick(), choices().length - 1)];
|
|
87
|
+
setEditing(undefined);
|
|
88
|
+
setQuery('');
|
|
89
|
+
if (name && value) void save(name, value);
|
|
90
|
+
};
|
|
91
|
+
useKeys('dialog', (key) => {
|
|
92
|
+
if (editing()) {
|
|
93
|
+
// the input has the text; the list keys are ours
|
|
94
|
+
if (key.name === 'up') {
|
|
95
|
+
setPick((c) => Math.max(0, c - 1));
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (key.name === 'down') {
|
|
99
|
+
setPick((c) => Math.min(choices().length - 1, c + 1));
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (key.name === 'up') {
|
|
105
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (key.name === 'down') {
|
|
109
|
+
setCursor((c) => Math.min(rows().length - 1, c + 1));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if (key.name === 'return') {
|
|
113
|
+
const r = selected();
|
|
114
|
+
// the input mounts on the next tick: a focused input would receive this same enter
|
|
115
|
+
if (r)
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
setQuery('');
|
|
118
|
+
setPick(0);
|
|
119
|
+
setEditing(r.name);
|
|
120
|
+
}, 0);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (key.name === 'x' && !key.ctrl) {
|
|
124
|
+
const r = selected();
|
|
125
|
+
if (r && (r.name === 'judge' || r.name === 'adapter')) void save(r.name, null);
|
|
126
|
+
else toast.show({ message: 'Tiers cannot be cleared, pick another model', variant: 'info' });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
});
|
|
131
|
+
const start = () =>
|
|
132
|
+
Math.max(0, Math.min(pick() - Math.floor(VISIBLE / 2), choices().length - VISIBLE));
|
|
133
|
+
const title = () => `Tiers · ${config()?.organization ?? ''}`;
|
|
134
|
+
// the Dialog sees escape first (its handler is the newest): back out of the list, else close
|
|
135
|
+
const back = () => {
|
|
136
|
+
if (!editing()) return dialog.close();
|
|
137
|
+
setEditing(undefined);
|
|
138
|
+
setQuery('');
|
|
139
|
+
};
|
|
140
|
+
return (
|
|
141
|
+
<Dialog size="large" title={title()} onClose={back}>
|
|
142
|
+
<box flexDirection="column" width="100%">
|
|
143
|
+
<Show when={config.error}>
|
|
144
|
+
<text fg={theme.text.feedback.error}>
|
|
145
|
+
{`Could not read the org: ${String((config.error as Error)?.message ?? config.error)}`}
|
|
146
|
+
</text>
|
|
147
|
+
</Show>
|
|
148
|
+
<For each={rows()}>
|
|
149
|
+
{(r, i) => {
|
|
150
|
+
const active = () => i() === Math.min(cursor(), rows().length - 1);
|
|
151
|
+
return (
|
|
152
|
+
<box
|
|
153
|
+
height={1}
|
|
154
|
+
flexShrink={0}
|
|
155
|
+
flexDirection="row"
|
|
156
|
+
backgroundColor={active() ? theme.background.action.primary.selected : undefined}
|
|
157
|
+
>
|
|
158
|
+
<text
|
|
159
|
+
fg={active() ? theme.text.action.primary.selected : theme.text.base}
|
|
160
|
+
attributes={TextAttributes.BOLD}
|
|
161
|
+
wrapMode="none"
|
|
162
|
+
flexShrink={0}
|
|
163
|
+
>
|
|
164
|
+
{` ${r.name.padEnd(10)}`}
|
|
165
|
+
</text>
|
|
166
|
+
<text
|
|
167
|
+
fg={r.value ? theme.text.base : theme.text.muted}
|
|
168
|
+
wrapMode="none"
|
|
169
|
+
flexShrink={0}
|
|
170
|
+
>
|
|
171
|
+
{(r.value ?? '—').padEnd(44)}
|
|
172
|
+
</text>
|
|
173
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={1}>
|
|
174
|
+
{r.note}
|
|
175
|
+
</text>
|
|
176
|
+
</box>
|
|
177
|
+
);
|
|
178
|
+
}}
|
|
179
|
+
</For>
|
|
180
|
+
<Show
|
|
181
|
+
when={editing()}
|
|
182
|
+
fallback={
|
|
183
|
+
<box height={1} flexShrink={0} marginTop={1}>
|
|
184
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
185
|
+
{`${config()?.per_run_usd !== undefined ? `budget $${config()?.per_run_usd} per run (/budget for this run) · ` : ''}↑/↓ choose · enter change · x clear judge/adapter · esc close`}
|
|
186
|
+
</text>
|
|
187
|
+
</box>
|
|
188
|
+
}
|
|
189
|
+
>
|
|
190
|
+
<box height={1} flexShrink={0} marginTop={1} flexDirection="row">
|
|
191
|
+
<text fg={theme.text.base} wrapMode="none" flexShrink={0}>
|
|
192
|
+
{`${editing() ?? ''} › `}
|
|
193
|
+
</text>
|
|
194
|
+
<input
|
|
195
|
+
focused
|
|
196
|
+
placeholder="type to filter"
|
|
197
|
+
onInput={(v: string) => {
|
|
198
|
+
setQuery(v);
|
|
199
|
+
setPick(0);
|
|
200
|
+
}}
|
|
201
|
+
onSubmit={choose}
|
|
202
|
+
flexGrow={1}
|
|
203
|
+
backgroundColor={theme.background.base}
|
|
204
|
+
focusedBackgroundColor={theme.background.base}
|
|
205
|
+
textColor={theme.text.base}
|
|
206
|
+
placeholderColor={theme.text.muted}
|
|
207
|
+
cursorColor={theme.text.action.primary.selected}
|
|
208
|
+
/>
|
|
209
|
+
</box>
|
|
210
|
+
<For each={choices().slice(start(), start() + VISIBLE)}>
|
|
211
|
+
{(c, i) => {
|
|
212
|
+
const active = () => start() + i() === Math.min(pick(), choices().length - 1);
|
|
213
|
+
return (
|
|
214
|
+
<box
|
|
215
|
+
height={1}
|
|
216
|
+
flexShrink={0}
|
|
217
|
+
backgroundColor={active() ? theme.background.action.primary.selected : undefined}
|
|
218
|
+
>
|
|
219
|
+
<text
|
|
220
|
+
fg={active() ? theme.text.action.primary.selected : theme.text.base}
|
|
221
|
+
wrapMode="none"
|
|
222
|
+
>
|
|
223
|
+
{` ${c}`}
|
|
224
|
+
</text>
|
|
225
|
+
</box>
|
|
226
|
+
);
|
|
227
|
+
}}
|
|
228
|
+
</For>
|
|
229
|
+
<box height={1} flexShrink={0}>
|
|
230
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
231
|
+
{choices().length === 0
|
|
232
|
+
? 'No model matches'
|
|
233
|
+
: `${choices().length} choices · ↑/↓ pick · enter save · esc back`}
|
|
234
|
+
</text>
|
|
235
|
+
</box>
|
|
236
|
+
</Show>
|
|
237
|
+
</box>
|
|
238
|
+
</Dialog>
|
|
239
|
+
);
|
|
240
|
+
}
|