@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,66 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import { useTerminalDimensions } from '@opentui/solid';
|
|
3
|
+
import { For, type JSX } from 'solid-js';
|
|
4
|
+
import { useTheme } from '../theme/context.js';
|
|
5
|
+
import { stringWidth, takeWidth } from '../ui/marquee.js';
|
|
6
|
+
|
|
7
|
+
export interface KeyHint {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `key label · key label` with the keys in bold, as one row of texts. */
|
|
13
|
+
export function KeyHints(props: { hints: KeyHint[] }): JSX.Element {
|
|
14
|
+
const theme = useTheme();
|
|
15
|
+
return (
|
|
16
|
+
<box flexDirection="row" flexShrink={0}>
|
|
17
|
+
<For each={props.hints}>
|
|
18
|
+
{(h, i) => (
|
|
19
|
+
<>
|
|
20
|
+
{i() > 0 ? <text fg={theme.text.muted}>{' '}</text> : null}
|
|
21
|
+
<text fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="none">
|
|
22
|
+
{h.key}
|
|
23
|
+
</text>
|
|
24
|
+
<text fg={theme.text.muted} wrapMode="none">{` ${h.label}`}</text>
|
|
25
|
+
</>
|
|
26
|
+
)}
|
|
27
|
+
</For>
|
|
28
|
+
</box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The last line: status on the left, key hints on the right; the left side yields when space is short. */
|
|
33
|
+
export function Footer(props: { left: string; hints?: KeyHint[]; right?: string }): JSX.Element {
|
|
34
|
+
const theme = useTheme();
|
|
35
|
+
const dimensions = useTerminalDimensions();
|
|
36
|
+
const rightWidth = () =>
|
|
37
|
+
props.right
|
|
38
|
+
? stringWidth(props.right)
|
|
39
|
+
: (props.hints ?? []).reduce((w, h) => w + stringWidth(h.key) + stringWidth(h.label) + 3, 0);
|
|
40
|
+
const left = () => {
|
|
41
|
+
const room = dimensions().width - 4 - rightWidth();
|
|
42
|
+
return stringWidth(props.left) > room ? takeWidth(props.left, Math.max(0, room)) : props.left;
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<box
|
|
46
|
+
height={1}
|
|
47
|
+
flexShrink={0}
|
|
48
|
+
flexDirection="row"
|
|
49
|
+
paddingLeft={2}
|
|
50
|
+
paddingRight={2}
|
|
51
|
+
width="100%"
|
|
52
|
+
>
|
|
53
|
+
<text fg={theme.text.muted} flexShrink={1} wrapMode="none">
|
|
54
|
+
{left()}
|
|
55
|
+
</text>
|
|
56
|
+
<box flexGrow={1} flexShrink={0} width={2} />
|
|
57
|
+
{props.right ? (
|
|
58
|
+
<text fg={theme.text.muted} flexShrink={0} wrapMode="none">
|
|
59
|
+
{props.right}
|
|
60
|
+
</text>
|
|
61
|
+
) : (
|
|
62
|
+
<KeyHints hints={props.hints ?? []} />
|
|
63
|
+
)}
|
|
64
|
+
</box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { RGBA } from '@opentui/core';
|
|
2
|
+
import { useTerminalDimensions } from '@opentui/solid';
|
|
3
|
+
import type { JSX } from 'solid-js';
|
|
4
|
+
import { useTheme } from '../theme/context.js';
|
|
5
|
+
|
|
6
|
+
/** Blends `overlay` over `base` by `alpha`. */
|
|
7
|
+
export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
|
|
8
|
+
const r = base.r + (overlay.r - base.r) * alpha;
|
|
9
|
+
const g = base.g + (overlay.g - base.g) * alpha;
|
|
10
|
+
const b = base.b + (overlay.b - base.b) * alpha;
|
|
11
|
+
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The wordmark in two tones: `shiba` muted, `ox` in the brand orange. Smaller under 60 columns, gone under 20 rows. */
|
|
15
|
+
export function Logo(): JSX.Element {
|
|
16
|
+
const theme = useTheme();
|
|
17
|
+
const dimensions = useTerminalDimensions();
|
|
18
|
+
const font = () => (dimensions().width < 60 ? 'tiny' : 'block');
|
|
19
|
+
return (
|
|
20
|
+
<box flexShrink={0} flexDirection="row">
|
|
21
|
+
{dimensions().height < 20 ? null : (
|
|
22
|
+
<>
|
|
23
|
+
<ascii_font text="shiba" font={font()} color={theme.text.muted} selectable={false} />
|
|
24
|
+
<ascii_font text="ox" font={font()} color={theme.syntax.heading} selectable={false} />
|
|
25
|
+
</>
|
|
26
|
+
)}
|
|
27
|
+
</box>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import { For, type JSX } from 'solid-js';
|
|
3
|
+
import { SUGGESTIONS, type Suggestion } from '../../model/prompt-commands.js';
|
|
4
|
+
import { useTheme } from '../../theme/context.js';
|
|
5
|
+
|
|
6
|
+
export type { Suggestion } from '../../model/prompt-commands.js';
|
|
7
|
+
|
|
8
|
+
/** The suggestion list above the prompt: up to ten rows, the selected one highlighted. */
|
|
9
|
+
export function Autocomplete(props: { items: Suggestion[]; selected: number }): JSX.Element {
|
|
10
|
+
const theme = useTheme();
|
|
11
|
+
return (
|
|
12
|
+
<box
|
|
13
|
+
flexDirection="column"
|
|
14
|
+
backgroundColor={theme.background.raised.base}
|
|
15
|
+
paddingLeft={1}
|
|
16
|
+
paddingRight={1}
|
|
17
|
+
>
|
|
18
|
+
<For each={props.items.slice(0, SUGGESTIONS)}>
|
|
19
|
+
{(item, i) => (
|
|
20
|
+
<box
|
|
21
|
+
height={1}
|
|
22
|
+
flexShrink={0}
|
|
23
|
+
flexDirection="row"
|
|
24
|
+
backgroundColor={
|
|
25
|
+
i() === props.selected ? theme.background.action.primary.selected : undefined
|
|
26
|
+
}
|
|
27
|
+
>
|
|
28
|
+
<text
|
|
29
|
+
fg={i() === props.selected ? theme.text.action.primary.selected : theme.text.base}
|
|
30
|
+
attributes={i() === props.selected ? TextAttributes.BOLD : undefined}
|
|
31
|
+
wrapMode="none"
|
|
32
|
+
flexShrink={0}
|
|
33
|
+
>
|
|
34
|
+
{item.label}
|
|
35
|
+
</text>
|
|
36
|
+
<text fg={theme.text.muted} wrapMode="none" flexShrink={1}>
|
|
37
|
+
{item.hint ? ` ${item.hint}` : ''}
|
|
38
|
+
</text>
|
|
39
|
+
</box>
|
|
40
|
+
)}
|
|
41
|
+
</For>
|
|
42
|
+
</box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { InputRenderable } from '@opentui/core';
|
|
2
|
+
import { createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from 'solid-js';
|
|
3
|
+
import { useKeys } from '../../context/keys.js';
|
|
4
|
+
import {
|
|
5
|
+
completeCommand,
|
|
6
|
+
type PromptCommand,
|
|
7
|
+
type PromptCommandSpec,
|
|
8
|
+
parsePromptCommand,
|
|
9
|
+
} from '../../model/prompt-commands.js';
|
|
10
|
+
import { useMotion } from '../../motion/config.js';
|
|
11
|
+
import { useTheme } from '../../theme/context.js';
|
|
12
|
+
import { Autocomplete } from './autocomplete.js';
|
|
13
|
+
|
|
14
|
+
export interface PromptRef {
|
|
15
|
+
focus(): void;
|
|
16
|
+
set(text: string): void;
|
|
17
|
+
current(): string;
|
|
18
|
+
clear(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PLACEHOLDER_MS = 4000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The input of the home screen: `/` commands autocomplete (↑/↓ choose, tab/enter insert), enter
|
|
25
|
+
* with a command calls onCommand, enter with text calls onSubmit; ctrl+c clears the text.
|
|
26
|
+
*/
|
|
27
|
+
export function Prompt(props: {
|
|
28
|
+
ref?: (r: PromptRef) => void;
|
|
29
|
+
placeholders: string[];
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** The `/` commands this prompt offers. */
|
|
32
|
+
commands: PromptCommandSpec[];
|
|
33
|
+
/** Only the input row and the footer: the caller draws the box around it. */
|
|
34
|
+
bare?: boolean;
|
|
35
|
+
onSubmit(text: string): void;
|
|
36
|
+
onCommand(cmd: PromptCommand): void;
|
|
37
|
+
/** The text changed (the screen clears its notices). */
|
|
38
|
+
onInput?(text: string): void;
|
|
39
|
+
/** A free-text command was entered without its value: the screen shows what to type. */
|
|
40
|
+
onNeedValue?(cmd: string): void;
|
|
41
|
+
/** A line drawn inside the box under the input (the run context). */
|
|
42
|
+
footer?: JSX.Element;
|
|
43
|
+
}): JSX.Element {
|
|
44
|
+
const theme = useTheme();
|
|
45
|
+
const motion = useMotion();
|
|
46
|
+
let input: InputRenderable | undefined;
|
|
47
|
+
const [text, setText] = createSignal('');
|
|
48
|
+
const [selected, setSelected] = createSignal(0);
|
|
49
|
+
const [placeholder, setPlaceholder] = createSignal(0);
|
|
50
|
+
const suggestions = createMemo(() => completeCommand(text(), { commands: props.commands }));
|
|
51
|
+
createEffect(() => {
|
|
52
|
+
suggestions();
|
|
53
|
+
setSelected(0);
|
|
54
|
+
});
|
|
55
|
+
createEffect(() => {
|
|
56
|
+
if (!motion() || props.placeholders.length < 2) return;
|
|
57
|
+
const t = setInterval(
|
|
58
|
+
() => setPlaceholder((i) => (i + 1) % props.placeholders.length),
|
|
59
|
+
PLACEHOLDER_MS,
|
|
60
|
+
);
|
|
61
|
+
onCleanup(() => clearInterval(t));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const set = (value: string) => {
|
|
65
|
+
if (input && !input.isDestroyed) input.value = value;
|
|
66
|
+
setText(value);
|
|
67
|
+
};
|
|
68
|
+
const api: PromptRef = {
|
|
69
|
+
// the home may already be gone when a closed dialog hands focus back
|
|
70
|
+
focus: () => {
|
|
71
|
+
if (input && !input.isDestroyed) input.focus();
|
|
72
|
+
},
|
|
73
|
+
set,
|
|
74
|
+
current: text,
|
|
75
|
+
clear: () => set(''),
|
|
76
|
+
};
|
|
77
|
+
props.ref?.(api);
|
|
78
|
+
|
|
79
|
+
const accept = () => {
|
|
80
|
+
const s = suggestions()[selected()];
|
|
81
|
+
if (!s) return;
|
|
82
|
+
set(s.insert);
|
|
83
|
+
};
|
|
84
|
+
const submit = () => {
|
|
85
|
+
if (props.disabled) return;
|
|
86
|
+
const value = text().trim();
|
|
87
|
+
if (!value) return;
|
|
88
|
+
if (!value.startsWith('/')) return props.onSubmit(value);
|
|
89
|
+
const s = suggestions()[selected()];
|
|
90
|
+
const parsed = parsePromptCommand(value, props.commands);
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
// only the command name so far: enter takes the highlighted command
|
|
93
|
+
if (s) set(s.insert);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const kind = props.commands.find((c) => c.name === parsed.command)?.kind ?? 'action';
|
|
97
|
+
if (kind === 'action') {
|
|
98
|
+
set('');
|
|
99
|
+
props.onCommand(parsed);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!parsed.arg && !value.endsWith(' ') && text() === value) {
|
|
103
|
+
// `/adapter` + enter: step into its values (a list to pick from, or a hint of what to type)
|
|
104
|
+
set(`/${parsed.command} `);
|
|
105
|
+
if (kind === 'text') props.onNeedValue?.(parsed.command);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'choice') {
|
|
109
|
+
// no value or a partial one: the highlighted choice is the value
|
|
110
|
+
const chosen = s?.insert.startsWith(`/${parsed.command} `)
|
|
111
|
+
? parsePromptCommand(s.insert, props.commands)
|
|
112
|
+
: undefined;
|
|
113
|
+
const cmd = chosen?.arg ? chosen : parsed;
|
|
114
|
+
if (!cmd.arg) return;
|
|
115
|
+
set('');
|
|
116
|
+
props.onCommand(cmd);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!parsed.arg) {
|
|
120
|
+
props.onNeedValue?.(parsed.command);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
set('');
|
|
124
|
+
props.onCommand(parsed);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
useKeys('prompt', (key) => {
|
|
128
|
+
if (key.ctrl && key.name === 'c') {
|
|
129
|
+
if (!text()) return false;
|
|
130
|
+
set('');
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (key.name === '?') return true; // typed into the input, never the help shortcut
|
|
134
|
+
if (suggestions().length === 0) return false;
|
|
135
|
+
if (key.name === 'up') {
|
|
136
|
+
setSelected((i) => (i - 1 + suggestions().length) % suggestions().length);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
if (key.name === 'down') {
|
|
140
|
+
setSelected((i) => (i + 1) % suggestions().length);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (key.name === 'tab') {
|
|
144
|
+
accept();
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const inner = () => (
|
|
151
|
+
<>
|
|
152
|
+
<box flexDirection="row" width="100%" height={1}>
|
|
153
|
+
<text fg={theme.text.action.primary.selected} flexShrink={0}>
|
|
154
|
+
{'› '}
|
|
155
|
+
</text>
|
|
156
|
+
<input
|
|
157
|
+
ref={(r: InputRenderable) => {
|
|
158
|
+
input = r;
|
|
159
|
+
}}
|
|
160
|
+
focused={!props.disabled}
|
|
161
|
+
placeholder={props.placeholders[placeholder()] ?? ''}
|
|
162
|
+
onInput={(v: string) => {
|
|
163
|
+
setText(v);
|
|
164
|
+
props.onInput?.(v);
|
|
165
|
+
}}
|
|
166
|
+
onSubmit={submit}
|
|
167
|
+
flexGrow={1}
|
|
168
|
+
backgroundColor={theme.background.raised.base}
|
|
169
|
+
focusedBackgroundColor={theme.background.raised.base}
|
|
170
|
+
textColor={theme.text.base}
|
|
171
|
+
placeholderColor={theme.text.muted}
|
|
172
|
+
cursorColor={theme.text.action.primary.selected}
|
|
173
|
+
/>
|
|
174
|
+
</box>
|
|
175
|
+
<Show when={props.footer}>
|
|
176
|
+
<box height={1} flexShrink={0} />
|
|
177
|
+
<box height={1} flexShrink={0} flexDirection="row">
|
|
178
|
+
{props.footer}
|
|
179
|
+
</box>
|
|
180
|
+
</Show>
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
return (
|
|
184
|
+
<box flexDirection="column" width="100%">
|
|
185
|
+
<Show when={suggestions().length > 0}>
|
|
186
|
+
<Autocomplete items={suggestions()} selected={selected()} />
|
|
187
|
+
</Show>
|
|
188
|
+
<Show
|
|
189
|
+
when={!props.bare}
|
|
190
|
+
fallback={
|
|
191
|
+
<box flexDirection="column" width="100%">
|
|
192
|
+
{inner()}
|
|
193
|
+
</box>
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
<box flexDirection="row" width="100%">
|
|
197
|
+
<box width={1} flexShrink={0} backgroundColor={theme.text.action.primary.selected} />
|
|
198
|
+
<box
|
|
199
|
+
flexGrow={1}
|
|
200
|
+
flexDirection="column"
|
|
201
|
+
paddingLeft={2}
|
|
202
|
+
paddingRight={2}
|
|
203
|
+
paddingTop={1}
|
|
204
|
+
paddingBottom={1}
|
|
205
|
+
backgroundColor={theme.background.raised.base}
|
|
206
|
+
>
|
|
207
|
+
{inner()}
|
|
208
|
+
</box>
|
|
209
|
+
</box>
|
|
210
|
+
</Show>
|
|
211
|
+
</box>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Adapted from opencode (MIT) — https://github.com/anomalyco/opencode
|
|
2
|
+
import { RGBA } from '@opentui/core';
|
|
3
|
+
import { type Accessor, type JSX, Show } from 'solid-js';
|
|
4
|
+
import { Spinner } from '../motion/spinner.js';
|
|
5
|
+
import { useTheme } from '../theme/context.js';
|
|
6
|
+
import { createDelayedPresence } from '../ui/delayed-presence.js';
|
|
7
|
+
|
|
8
|
+
const DELAY_MS = 2000;
|
|
9
|
+
|
|
10
|
+
/** Covers the page while the daemon has been unreachable for more than two seconds. */
|
|
11
|
+
export function Reconnecting(props: { since: Accessor<number | undefined> }): JSX.Element {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
const visible = createDelayedPresence(props.since, DELAY_MS);
|
|
14
|
+
return (
|
|
15
|
+
<Show when={visible()}>
|
|
16
|
+
<box
|
|
17
|
+
position="absolute"
|
|
18
|
+
zIndex={10_000}
|
|
19
|
+
top={0}
|
|
20
|
+
right={0}
|
|
21
|
+
bottom={0}
|
|
22
|
+
left={0}
|
|
23
|
+
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
|
24
|
+
alignItems="center"
|
|
25
|
+
justifyContent="center"
|
|
26
|
+
>
|
|
27
|
+
<box
|
|
28
|
+
width={48}
|
|
29
|
+
maxWidth="90%"
|
|
30
|
+
flexDirection="column"
|
|
31
|
+
backgroundColor={theme.background.raised.base}
|
|
32
|
+
paddingTop={1}
|
|
33
|
+
paddingBottom={1}
|
|
34
|
+
paddingLeft={2}
|
|
35
|
+
paddingRight={2}
|
|
36
|
+
gap={1}
|
|
37
|
+
>
|
|
38
|
+
<Spinner color={theme.text.base}>Connection lost…</Spinner>
|
|
39
|
+
<text fg={theme.text.muted}>Reconnecting to the daemon automatically.</text>
|
|
40
|
+
</box>
|
|
41
|
+
</box>
|
|
42
|
+
</Show>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import type { InboxItem } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
import { createMemo, createSignal, For, type JSX, Show } from 'solid-js';
|
|
4
|
+
import { useConfig } from '../context/config.js';
|
|
5
|
+
import { useData } from '../context/data.js';
|
|
6
|
+
import { useKeys } from '../context/keys.js';
|
|
7
|
+
import { duration, money, shortId } from '../model/format.js';
|
|
8
|
+
import { tilde } from '../routes/home.js';
|
|
9
|
+
import { pendingFor } from '../routes/session/approval-bar.js';
|
|
10
|
+
import { requestText } from '../routes/session/request.js';
|
|
11
|
+
import { useTheme } from '../theme/context.js';
|
|
12
|
+
import { useDialog } from '../ui/dialog.js';
|
|
13
|
+
import { marqueeText } from '../ui/marquee.js';
|
|
14
|
+
import { useToast } from '../ui/toast.js';
|
|
15
|
+
import { Confirm } from './dialogs/confirm.js';
|
|
16
|
+
|
|
17
|
+
function Title(props: { text: string }): JSX.Element {
|
|
18
|
+
const theme = useTheme();
|
|
19
|
+
return (
|
|
20
|
+
<box height={1} flexShrink={0} marginTop={1}>
|
|
21
|
+
<text fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="none">
|
|
22
|
+
{props.text}
|
|
23
|
+
</text>
|
|
24
|
+
</box>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NODE_MARK: Record<string, string> = {
|
|
29
|
+
completed: '✓',
|
|
30
|
+
passed: '✓',
|
|
31
|
+
running: '·',
|
|
32
|
+
waiting: '▲',
|
|
33
|
+
failed: '✗',
|
|
34
|
+
gate_failed: '✗',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The current run at a glance: request, context, nodes as a checklist, files, and what needs you. */
|
|
38
|
+
export function Sidebar(props: {
|
|
39
|
+
/** The latest run of the tab (its nodes and pending items). */
|
|
40
|
+
runId: string;
|
|
41
|
+
/** The first run of the tab (its request titles the sidebar). */
|
|
42
|
+
rootId?: string;
|
|
43
|
+
width: number;
|
|
44
|
+
focused: boolean;
|
|
45
|
+
onFocusBack: () => void;
|
|
46
|
+
}): JSX.Element {
|
|
47
|
+
const data = useData();
|
|
48
|
+
const config = useConfig();
|
|
49
|
+
const theme = useTheme().surface('sidebar');
|
|
50
|
+
const dialog = useDialog();
|
|
51
|
+
const toast = useToast();
|
|
52
|
+
const state = () => data.state.states[props.runId];
|
|
53
|
+
const root = () => data.state.states[props.rootId ?? props.runId];
|
|
54
|
+
const summary = () => data.state.runs.find((r) => r.runId === props.runId);
|
|
55
|
+
const spent = () =>
|
|
56
|
+
data
|
|
57
|
+
.threadOf(props.rootId ?? props.runId)
|
|
58
|
+
.reduce((n, id) => n + (data.state.states[id]?.spentUsd ?? 0), 0);
|
|
59
|
+
const cards = data.timeline(props.runId);
|
|
60
|
+
const inner = () => Math.max(8, props.width - 2);
|
|
61
|
+
|
|
62
|
+
const nodes = createMemo(() => {
|
|
63
|
+
const wf = state()?.workflowSnapshot;
|
|
64
|
+
const st = state()?.nodes ?? {};
|
|
65
|
+
const ids = wf ? Object.keys(wf.nodes) : Object.keys(st);
|
|
66
|
+
return ids.map((id) => ({ id, status: st[id]?.status ?? 'pending' }));
|
|
67
|
+
});
|
|
68
|
+
const done = () =>
|
|
69
|
+
nodes().filter((n) => n.status === 'completed' || n.status === 'passed').length;
|
|
70
|
+
const files = createMemo(() => {
|
|
71
|
+
const out = new Set<string>();
|
|
72
|
+
for (const c of cards()) {
|
|
73
|
+
if (c.kind === 'node') for (const b of c.blocks) if (b.kind === 'file') out.add(b.path);
|
|
74
|
+
if (c.kind === 'summary') for (const f of c.files) out.add(f);
|
|
75
|
+
}
|
|
76
|
+
return [...out];
|
|
77
|
+
});
|
|
78
|
+
const elapsed = () => {
|
|
79
|
+
const created = summary()?.createdAt;
|
|
80
|
+
if (!created) return undefined;
|
|
81
|
+
const from = Date.parse(created);
|
|
82
|
+
const terminal = ['completed', 'failed', 'cancelled'].includes(summary()?.status ?? '');
|
|
83
|
+
const to = terminal ? Date.parse(summary()?.updatedAt ?? created) : Date.now();
|
|
84
|
+
return Number.isFinite(from) && Number.isFinite(to)
|
|
85
|
+
? duration(Math.max(0, to - from))
|
|
86
|
+
: undefined;
|
|
87
|
+
};
|
|
88
|
+
const items = createMemo(() => pendingFor(data.state.inbox, props.runId));
|
|
89
|
+
const [cursor, setCursor] = createSignal(0);
|
|
90
|
+
const selectedItem = () => items()[Math.min(cursor(), Math.max(0, items().length - 1))];
|
|
91
|
+
|
|
92
|
+
/** Answers like the approval bar: a command approval asks y first. */
|
|
93
|
+
const answer = (item: InboxItem, approved: boolean) => {
|
|
94
|
+
if (approved && item.kind === 'approval') {
|
|
95
|
+
dialog.open(() => (
|
|
96
|
+
<Confirm
|
|
97
|
+
message={`Approve ${item.prompt}?`}
|
|
98
|
+
onYes={() => {
|
|
99
|
+
dialog.close();
|
|
100
|
+
void data.actions.answer(item.id, true);
|
|
101
|
+
}}
|
|
102
|
+
onNo={() => dialog.close()}
|
|
103
|
+
/>
|
|
104
|
+
));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
void data.actions.answer(item.id, approved);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
useKeys('pane', (key) => {
|
|
111
|
+
if (!props.focused || key.ctrl || key.meta) return false;
|
|
112
|
+
switch (key.name) {
|
|
113
|
+
case 'j':
|
|
114
|
+
case 'down':
|
|
115
|
+
if (cursor() < items().length - 1) setCursor((c) => c + 1);
|
|
116
|
+
return true;
|
|
117
|
+
case 'k':
|
|
118
|
+
case 'up':
|
|
119
|
+
if (cursor() > 0) setCursor((c) => c - 1);
|
|
120
|
+
return true;
|
|
121
|
+
case 'a':
|
|
122
|
+
case 'd': {
|
|
123
|
+
const item = selectedItem();
|
|
124
|
+
if (item) answer(item, key.name === 'a');
|
|
125
|
+
else toast.show({ message: 'Nothing waiting', variant: 'info' });
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<box
|
|
135
|
+
width={props.width}
|
|
136
|
+
flexShrink={0}
|
|
137
|
+
height="100%"
|
|
138
|
+
flexDirection="column"
|
|
139
|
+
backgroundColor={theme.background.base}
|
|
140
|
+
paddingLeft={2}
|
|
141
|
+
paddingRight={1}
|
|
142
|
+
paddingTop={1}
|
|
143
|
+
>
|
|
144
|
+
<text fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="word">
|
|
145
|
+
{marqueeText(requestText(root()?.input) || (summary()?.workflow ?? ''), inner() * 3, 0)}
|
|
146
|
+
</text>
|
|
147
|
+
<Title text="Context" />
|
|
148
|
+
<text
|
|
149
|
+
fg={theme.text.muted}
|
|
150
|
+
wrapMode="none"
|
|
151
|
+
>{`${money(spent() || (summary()?.spentUsd ?? 0))} spent`}</text>
|
|
152
|
+
<text
|
|
153
|
+
fg={theme.text.muted}
|
|
154
|
+
wrapMode="none"
|
|
155
|
+
>{`${done()} of ${nodes().length} nodes done`}</text>
|
|
156
|
+
<Show when={elapsed()}>
|
|
157
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
158
|
+
{elapsed() ?? ''}
|
|
159
|
+
</text>
|
|
160
|
+
</Show>
|
|
161
|
+
<Title text="Nodes" />
|
|
162
|
+
<For each={nodes()}>
|
|
163
|
+
{(n) => (
|
|
164
|
+
<box height={1} flexShrink={0}>
|
|
165
|
+
<text
|
|
166
|
+
fg={
|
|
167
|
+
n.status === 'running'
|
|
168
|
+
? theme.text.action.primary.selected
|
|
169
|
+
: n.status === 'failed' || n.status === 'gate_failed'
|
|
170
|
+
? theme.text.feedback.error
|
|
171
|
+
: n.status === 'pending'
|
|
172
|
+
? theme.text.muted
|
|
173
|
+
: theme.text.base
|
|
174
|
+
}
|
|
175
|
+
wrapMode="none"
|
|
176
|
+
>
|
|
177
|
+
{`[${NODE_MARK[n.status] ?? ' '}] ${n.id}`}
|
|
178
|
+
</text>
|
|
179
|
+
</box>
|
|
180
|
+
)}
|
|
181
|
+
</For>
|
|
182
|
+
<Show when={files().length > 0}>
|
|
183
|
+
<Title text="Files" />
|
|
184
|
+
<For each={files()}>
|
|
185
|
+
{(f) => (
|
|
186
|
+
<box height={1} flexShrink={0}>
|
|
187
|
+
<text fg={theme.text.base} wrapMode="none">
|
|
188
|
+
{marqueeText(`± ${f}`, inner(), 0)}
|
|
189
|
+
</text>
|
|
190
|
+
</box>
|
|
191
|
+
)}
|
|
192
|
+
</For>
|
|
193
|
+
</Show>
|
|
194
|
+
<Show when={items().length > 0}>
|
|
195
|
+
<Title text={`Needs you (${items().length})`} />
|
|
196
|
+
<For each={items()}>
|
|
197
|
+
{(i) => (
|
|
198
|
+
<box
|
|
199
|
+
height={1}
|
|
200
|
+
flexShrink={0}
|
|
201
|
+
backgroundColor={
|
|
202
|
+
props.focused && selectedItem()?.id === i.id
|
|
203
|
+
? theme.background.action.primary.selected
|
|
204
|
+
: undefined
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
<text fg={theme.text.feedback.warning} wrapMode="none">
|
|
208
|
+
{marqueeText(`▲ ${i.prompt}`, inner(), 0)}
|
|
209
|
+
</text>
|
|
210
|
+
</box>
|
|
211
|
+
)}
|
|
212
|
+
</For>
|
|
213
|
+
</Show>
|
|
214
|
+
<box flexGrow={1} />
|
|
215
|
+
<Show when={props.focused}>
|
|
216
|
+
<box height={1} flexShrink={0}>
|
|
217
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
218
|
+
{items().length > 0 ? 'j/k select · a/d answer · tab back' : 'tab back'}
|
|
219
|
+
</text>
|
|
220
|
+
</box>
|
|
221
|
+
</Show>
|
|
222
|
+
<box height={1} flexShrink={0}>
|
|
223
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
224
|
+
{marqueeText(tilde(state()?.project ?? state()?.workspace ?? config.cwd), inner(), 0)}
|
|
225
|
+
</text>
|
|
226
|
+
</box>
|
|
227
|
+
<box height={1} flexShrink={0} marginBottom={1}>
|
|
228
|
+
<text fg={theme.text.muted} wrapMode="none">
|
|
229
|
+
<span style={{ fg: theme.text.feedback.success }}>● </span>
|
|
230
|
+
{`shibaox ${config.version} · ${shortId(props.runId)}`}
|
|
231
|
+
</text>
|
|
232
|
+
</box>
|
|
233
|
+
</box>
|
|
234
|
+
);
|
|
235
|
+
}
|