@wizardingcode/shibaox-tui 0.1.3 → 0.1.5
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/LICENSE +216 -0
- package/dist/main.js +8188 -0
- package/dist/main.js.map +67 -0
- package/package.json +9 -12
- package/src/app.tsx +0 -300
- package/src/component/dialogs/confirm.tsx +0 -29
- package/src/component/dialogs/diff.tsx +0 -143
- package/src/component/dialogs/help.tsx +0 -99
- package/src/component/dialogs/keys.tsx +0 -201
- package/src/component/dialogs/palette.tsx +0 -95
- package/src/component/dialogs/runs.tsx +0 -115
- package/src/component/dialogs/tiers.tsx +0 -240
- package/src/component/footer.tsx +0 -66
- package/src/component/logo.tsx +0 -29
- package/src/component/prompt/autocomplete.tsx +0 -44
- package/src/component/prompt/index.tsx +0 -213
- package/src/component/reconnecting.tsx +0 -44
- package/src/component/sidebar.tsx +0 -235
- package/src/component/tabs.tsx +0 -116
- package/src/component/too-small.tsx +0 -15
- package/src/context/client.tsx +0 -54
- package/src/context/commands.tsx +0 -44
- package/src/context/config.tsx +0 -22
- package/src/context/data-state.ts +0 -41
- package/src/context/data.tsx +0 -308
- package/src/context/exit.tsx +0 -13
- package/src/context/keys.tsx +0 -70
- package/src/context/poller.ts +0 -352
- package/src/context/prefs.tsx +0 -54
- package/src/context/route.tsx +0 -23
- package/src/main.tsx +0 -55
- package/src/model/format.ts +0 -38
- package/src/model/prompt-commands.ts +0 -315
- package/src/model/status.ts +0 -55
- package/src/model/stream.ts +0 -464
- package/src/motion/animation.ts +0 -235
- package/src/motion/config.tsx +0 -15
- package/src/motion/fade-in-text.tsx +0 -93
- package/src/motion/masked-text.ts +0 -62
- package/src/motion/one-cell-motion.ts +0 -100
- package/src/motion/pulse.ts +0 -38
- package/src/motion/shimmer-text.tsx +0 -78
- package/src/motion/spinner-renderable.ts +0 -171
- package/src/motion/spinner.tsx +0 -100
- package/src/motion/subcell.ts +0 -40
- package/src/routes/home.tsx +0 -365
- package/src/routes/session/approval-bar.tsx +0 -136
- package/src/routes/session/cards.tsx +0 -495
- package/src/routes/session/context-line.tsx +0 -129
- package/src/routes/session/continue.tsx +0 -104
- package/src/routes/session/index.tsx +0 -340
- package/src/routes/session/request.tsx +0 -87
- package/src/routes/session/timeline.tsx +0 -313
- package/src/testing/fake-client.ts +0 -249
- package/src/theme/context.tsx +0 -44
- package/src/theme/resolve.ts +0 -196
- package/src/theme/shibaox.json +0 -159
- package/src/ui/border.ts +0 -20
- package/src/ui/delayed-presence.ts +0 -23
- package/src/ui/dialog.tsx +0 -123
- package/src/ui/layout.ts +0 -23
- package/src/ui/marquee.ts +0 -75
- package/src/ui/pane-resize.ts +0 -77
- package/src/ui/toast.tsx +0 -203
- package/tsconfig.json +0 -19
package/src/routes/home.tsx
DELETED
|
@@ -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
|
-
}
|