@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.
Files changed (61) hide show
  1. package/bunfig.toml +5 -0
  2. package/package.json +42 -0
  3. package/src/app.tsx +300 -0
  4. package/src/component/dialogs/confirm.tsx +29 -0
  5. package/src/component/dialogs/diff.tsx +143 -0
  6. package/src/component/dialogs/help.tsx +99 -0
  7. package/src/component/dialogs/keys.tsx +201 -0
  8. package/src/component/dialogs/palette.tsx +95 -0
  9. package/src/component/dialogs/runs.tsx +115 -0
  10. package/src/component/dialogs/tiers.tsx +240 -0
  11. package/src/component/footer.tsx +66 -0
  12. package/src/component/logo.tsx +29 -0
  13. package/src/component/prompt/autocomplete.tsx +44 -0
  14. package/src/component/prompt/index.tsx +213 -0
  15. package/src/component/reconnecting.tsx +44 -0
  16. package/src/component/sidebar.tsx +235 -0
  17. package/src/component/tabs.tsx +116 -0
  18. package/src/component/too-small.tsx +15 -0
  19. package/src/context/client.tsx +54 -0
  20. package/src/context/commands.tsx +44 -0
  21. package/src/context/config.tsx +22 -0
  22. package/src/context/data-state.ts +41 -0
  23. package/src/context/data.tsx +308 -0
  24. package/src/context/exit.tsx +13 -0
  25. package/src/context/keys.tsx +70 -0
  26. package/src/context/poller.ts +352 -0
  27. package/src/context/prefs.tsx +54 -0
  28. package/src/context/route.tsx +23 -0
  29. package/src/main.tsx +55 -0
  30. package/src/model/format.ts +38 -0
  31. package/src/model/prompt-commands.ts +315 -0
  32. package/src/model/status.ts +55 -0
  33. package/src/model/stream.ts +464 -0
  34. package/src/motion/animation.ts +235 -0
  35. package/src/motion/config.tsx +15 -0
  36. package/src/motion/fade-in-text.tsx +93 -0
  37. package/src/motion/masked-text.ts +62 -0
  38. package/src/motion/one-cell-motion.ts +100 -0
  39. package/src/motion/pulse.ts +38 -0
  40. package/src/motion/shimmer-text.tsx +78 -0
  41. package/src/motion/spinner.tsx +101 -0
  42. package/src/motion/subcell.ts +40 -0
  43. package/src/routes/home.tsx +365 -0
  44. package/src/routes/session/approval-bar.tsx +136 -0
  45. package/src/routes/session/cards.tsx +495 -0
  46. package/src/routes/session/context-line.tsx +129 -0
  47. package/src/routes/session/continue.tsx +104 -0
  48. package/src/routes/session/index.tsx +340 -0
  49. package/src/routes/session/request.tsx +87 -0
  50. package/src/routes/session/timeline.tsx +313 -0
  51. package/src/testing/fake-client.ts +249 -0
  52. package/src/theme/context.tsx +44 -0
  53. package/src/theme/resolve.ts +196 -0
  54. package/src/theme/shibaox.json +159 -0
  55. package/src/ui/border.ts +20 -0
  56. package/src/ui/delayed-presence.ts +23 -0
  57. package/src/ui/dialog.tsx +123 -0
  58. package/src/ui/layout.ts +23 -0
  59. package/src/ui/marquee.ts +75 -0
  60. package/src/ui/pane-resize.ts +77 -0
  61. package/src/ui/toast.tsx +203 -0
@@ -0,0 +1,104 @@
1
+ import { createEffect, createMemo, createResource, type JSX, on } from 'solid-js';
2
+ import { Prompt, type PromptRef } from '../../component/prompt/index.js';
3
+ import { useClient } from '../../context/client.js';
4
+ import { useCommands } from '../../context/commands.js';
5
+ import { useData } from '../../context/data.js';
6
+ import { useKeys } from '../../context/keys.js';
7
+ import {
8
+ DEFAULT_MODEL,
9
+ isModelRef,
10
+ modelCommand,
11
+ type PromptCommandSpec,
12
+ } from '../../model/prompt-commands.js';
13
+ import { useDialog } from '../../ui/dialog.js';
14
+ import { useToast } from '../../ui/toast.js';
15
+
16
+ const PLACEHOLDERS = ['Continue… "Now add tests for it"', 'Continue… "/diff shows the changes"'];
17
+
18
+ /**
19
+ * The prompt at the bottom of a finished run: the next request runs in the same tab. `/`
20
+ * offers the commands of this screen (the same ones as the palette): `/diff`, `/cancel`,
21
+ * `/help`, `/home`…
22
+ */
23
+ export function ContinuePrompt(props: {
24
+ /** The tab's root run: `/model` applies to its next turns. */
25
+ runId: string;
26
+ onSubmit(text: string): void;
27
+ onScroll?(lines: number): void;
28
+ footer?: JSX.Element;
29
+ }): JSX.Element {
30
+ const registry = useCommands();
31
+ const dialog = useDialog();
32
+ const client = useClient();
33
+ const data = useData();
34
+ const toast = useToast();
35
+ const [models] = createResource(() => client.models().catch(() => []));
36
+ let prompt: PromptRef | undefined;
37
+ // a dialog opened from here (diff, help, runs) takes the keys; the prompt gets them back after
38
+ createEffect(
39
+ on(
40
+ dialog.depth,
41
+ (d) => {
42
+ if (d === 0) setTimeout(() => prompt?.focus(), 0);
43
+ },
44
+ { defer: true },
45
+ ),
46
+ );
47
+ // the screen's own commands first, then the shell's
48
+ const ORDER = ['diff', 'cancel', 'resume', 'sidebar', 'home', 'runs', 'help', 'close', 'quit'];
49
+ const rank = (id: string) => {
50
+ const i = ORDER.indexOf(id);
51
+ return i < 0 ? ORDER.length : i;
52
+ };
53
+ const commands = createMemo<PromptCommandSpec[]>(() => [
54
+ modelCommand(() => models() ?? []),
55
+ ...registry
56
+ .commands()
57
+ .filter((c) => c.when?.() ?? true)
58
+ .sort((a, b) => rank(a.id) - rank(b.id))
59
+ .map((c) => ({ name: c.id, kind: 'action' as const, hint: c.label })),
60
+ ]);
61
+ // registered before the prompt mounts: its handler runs after the prompt's, so the arrows
62
+ // scroll the conversation only while no suggestion list is showing
63
+ useKeys('prompt', (key) => {
64
+ const scroll: Record<string, number> = { up: -2, down: 2, pageup: -20, pagedown: 20 };
65
+ const lines = scroll[key.name];
66
+ if (lines === undefined) return false;
67
+ props.onScroll?.(lines);
68
+ return true;
69
+ });
70
+ return (
71
+ <Prompt
72
+ ref={(r) => {
73
+ prompt = r;
74
+ }}
75
+ bare
76
+ disabled={dialog.depth() > 0}
77
+ placeholders={PLACEHOLDERS}
78
+ commands={commands()}
79
+ onSubmit={props.onSubmit}
80
+ onCommand={(cmd) => {
81
+ if (cmd.command === 'model') {
82
+ if (cmd.arg === DEFAULT_MODEL) {
83
+ data.setModel(props.runId, undefined);
84
+ toast.show({ message: 'Next turns follow the org routing', variant: 'info' });
85
+ } else if (!isModelRef(cmd.arg)) {
86
+ toast.show({
87
+ message: 'Model must look like provider/model (see /model for the list)',
88
+ variant: 'error',
89
+ });
90
+ } else {
91
+ data.setModel(props.runId, cmd.arg);
92
+ toast.show({ message: `Next turns run on ${cmd.arg}`, variant: 'info' });
93
+ }
94
+ return;
95
+ }
96
+ registry
97
+ .commands()
98
+ .find((c) => c.id === cmd.command)
99
+ ?.run();
100
+ }}
101
+ footer={props.footer}
102
+ />
103
+ );
104
+ }
@@ -0,0 +1,340 @@
1
+ import { type MouseEvent, TextAttributes } from '@opentui/core';
2
+ import { useTerminalDimensions } from '@opentui/solid';
3
+ import { createEffect, createMemo, createSignal, type JSX, onCleanup, Show } from 'solid-js';
4
+ import { Confirm } from '../../component/dialogs/confirm.js';
5
+ import { DiffDialog } from '../../component/dialogs/diff.js';
6
+ import { type KeyHint, KeyHints } from '../../component/footer.js';
7
+ import { Sidebar } from '../../component/sidebar.js';
8
+ import { useCommands } from '../../context/commands.js';
9
+ import { useData } from '../../context/data.js';
10
+ import { useKeys } from '../../context/keys.js';
11
+ import { usePrefs } from '../../context/prefs.js';
12
+ import { duration, shortId } from '../../model/format.js';
13
+ import { statusOf } from '../../model/status.js';
14
+ import { createAnimatable, spring } from '../../motion/animation.js';
15
+ import { useMotion } from '../../motion/config.js';
16
+ import { ShimmerText } from '../../motion/shimmer-text.js';
17
+ import { useTheme } from '../../theme/context.js';
18
+ import { useDialog } from '../../ui/dialog.js';
19
+ import { clampSidebarWidth, SIDEBAR_WIDTH, sidebarAuto } from '../../ui/layout.js';
20
+ import { createPaneResize } from '../../ui/pane-resize.js';
21
+ import { useToast } from '../../ui/toast.js';
22
+ import { ApprovalBar, pendingFor } from './approval-bar.js';
23
+ import { ContextLine } from './context-line.js';
24
+ import { ContinuePrompt } from './continue.js';
25
+ import { Timeline, type TimelineApi } from './timeline.js';
26
+
27
+ export const SESSION_HINTS: KeyHint[] = [
28
+ { key: 'enter', label: 'expand' },
29
+ { key: 'd', label: 'diff' },
30
+ { key: 'tab', label: 'sidebar' },
31
+ { key: 'ctrl+n', label: 'home' },
32
+ { key: '?', label: 'help' },
33
+ ];
34
+ export const STREAM_HINTS: KeyHint[] = [
35
+ { key: 'enter', label: 'expand' },
36
+ { key: 'd', label: 'diff' },
37
+ { key: 'q', label: 'quit' },
38
+ ];
39
+
40
+ const DOTS = ['·', '·', '·', '·', '·', '·'];
41
+ const DOT_MS = 120;
42
+
43
+ /** The dotted progress of a working run (the reference's `······` under the prompt). */
44
+ function Dots(props: { active: boolean }): JSX.Element {
45
+ const theme = useTheme();
46
+ const motion = useMotion();
47
+ const [head, setHead] = createSignal(0);
48
+ createEffect(() => {
49
+ if (!props.active || !motion()) return;
50
+ const t = setInterval(() => setHead((h) => (h + 1) % (DOTS.length + 2)), DOT_MS);
51
+ onCleanup(() => clearInterval(t));
52
+ });
53
+ const glyphs = () =>
54
+ DOTS.map((_, i) => (props.active && motion() && Math.abs(i - head()) <= 1 ? '▪' : '·')).join(
55
+ '',
56
+ );
57
+ return (
58
+ <text fg={props.active ? theme.text.action.primary.selected : theme.text.muted} wrapMode="none">
59
+ {glyphs()}
60
+ </text>
61
+ );
62
+ }
63
+
64
+ /** One run as a conversation: request, cards, the status box (or the approval bar), and the sidebar. */
65
+ export function SessionFrame(props: { runId: string; single?: boolean }): JSX.Element {
66
+ const data = useData();
67
+ const theme = useTheme();
68
+ const dialog = useDialog();
69
+ const toast = useToast();
70
+ const prefs = usePrefs();
71
+ const motion = useMotion();
72
+ const dimensions = useTerminalDimensions();
73
+ const [now, setNow] = createSignal(Date.now());
74
+ const tick = setInterval(() => setNow(Date.now()), 1000);
75
+ onCleanup(() => clearInterval(tick));
76
+
77
+ // the tab is a thread: the status, the pending items and the prompt belong to its latest run
78
+ const latest = () => {
79
+ const runs = data.threadOf(props.runId);
80
+ return runs[runs.length - 1] ?? props.runId;
81
+ };
82
+ const summary = () => data.state.runs.find((r) => r.runId === latest());
83
+ const state = () => data.state.states[latest()];
84
+ const status = () =>
85
+ data.state.ended[latest()] ?? state()?.status ?? summary()?.status ?? 'queued';
86
+ const look = () => statusOf({ status: status() });
87
+ const working = () => status() === 'running';
88
+ const elapsed = () => {
89
+ const created = summary()?.createdAt;
90
+ const updated = summary()?.updatedAt;
91
+ if (!created) return undefined;
92
+ const from = Date.parse(created);
93
+ const terminal = status() === 'completed' || status() === 'failed' || status() === 'cancelled';
94
+ const to = terminal && updated ? Date.parse(updated) : now();
95
+ return Number.isFinite(from) && Number.isFinite(to)
96
+ ? duration(Math.max(0, to - from))
97
+ : undefined;
98
+ };
99
+ const activeNode = () => {
100
+ const nodes = state()?.nodes ?? {};
101
+ return Object.entries(nodes).find(
102
+ ([, n]) => n.status === 'running' || n.status === 'waiting',
103
+ )?.[0];
104
+ };
105
+ const color = () => {
106
+ const f = look().feedback;
107
+ return f === 'muted' ? theme.text.muted : theme.text.feedback[f];
108
+ };
109
+ const pending = createMemo(() => pendingFor(data.state.inbox, latest()));
110
+ const finished = () => ['completed', 'failed', 'cancelled'].includes(status());
111
+ let timeline: TimelineApi | undefined;
112
+
113
+ // sidebar: automatic on wide terminals, toggled with ctrl+b, resizable with the mouse
114
+ const area = () => dimensions().width;
115
+ const [openedByKey, setOpenedByKey] = createSignal<boolean | undefined>(undefined);
116
+ const sidebarVisible = createMemo(() => {
117
+ if (props.single) return false;
118
+ if (openedByKey() !== undefined) return openedByKey() as boolean;
119
+ return prefs.data.sidebar !== 'hide' && sidebarAuto(dimensions().width, 0);
120
+ });
121
+ const resize = createPaneResize({
122
+ value: () => prefs.data.sidebarWidth ?? SIDEBAR_WIDTH,
123
+ defaultValue: () => SIDEBAR_WIDTH,
124
+ clamp: (w) => clampSidebarWidth(w, area()),
125
+ fromMouse: (e: MouseEvent) => dimensions().width - e.x - 1,
126
+ contains: (e: MouseEvent, w) =>
127
+ e.x >= dimensions().width - w - 1 && e.x <= dimensions().width - w,
128
+ onCommit: (w) => prefs.update({ sidebarWidth: w }),
129
+ });
130
+ const anim = createAnimatable(
131
+ { w: sidebarVisible() ? resize.size() : 0 },
132
+ { transition: spring({ visualDuration: 0.25 }), enabled: motion },
133
+ );
134
+ createEffect(() => anim.animate({ w: sidebarVisible() ? resize.size() : 0 }));
135
+ const sidebarWidth = () => Math.round(anim.value().w);
136
+ const [focus, setFocus] = createSignal<'conversation' | 'sidebar'>('conversation');
137
+ createEffect(() => {
138
+ if (!sidebarVisible()) setFocus('conversation');
139
+ });
140
+
141
+ const toggleSidebar = () => {
142
+ const next = !sidebarVisible();
143
+ setOpenedByKey(next);
144
+ prefs.update({ sidebar: next ? 'auto' : 'hide' });
145
+ };
146
+ const cancel = () =>
147
+ dialog.open(() => (
148
+ <Confirm
149
+ message={`Cancel run ${shortId(latest())}?`}
150
+ onYes={() => {
151
+ dialog.close();
152
+ void data.actions.cancel(latest());
153
+ }}
154
+ onNo={() => dialog.close()}
155
+ />
156
+ ));
157
+ const resume = () => {
158
+ if (status() === 'paused_budget') void data.actions.resume(latest());
159
+ else toast.show({ message: 'Nothing to resume', variant: 'info' });
160
+ };
161
+ const diff = () => dialog.open(() => <DiffDialog runId={latest()} />);
162
+ const unregister = useCommands().register([
163
+ { id: 'diff', label: 'Diff of the run', keys: 'd', run: diff },
164
+ {
165
+ id: 'sidebar',
166
+ label: 'Toggle sidebar',
167
+ keys: 'ctrl+b',
168
+ run: toggleSidebar,
169
+ when: () => !props.single,
170
+ },
171
+ { id: 'cancel', label: 'Cancel run', keys: 'c', run: cancel, when: () => !finished() },
172
+ {
173
+ id: 'resume',
174
+ label: 'Resume run',
175
+ keys: 'r',
176
+ run: resume,
177
+ when: () => status() === 'paused_budget',
178
+ },
179
+ ]);
180
+ onCleanup(unregister);
181
+ useKeys('global', (key) => {
182
+ if (props.single || !(key.ctrl && key.name === 'b')) return false;
183
+ toggleSidebar();
184
+ return true;
185
+ });
186
+ useKeys('pane', (key) => {
187
+ if (key.ctrl || key.meta) return false;
188
+ if (key.name === 'tab' && sidebarVisible()) {
189
+ setFocus((f) => (f === 'sidebar' ? 'conversation' : 'sidebar'));
190
+ return true;
191
+ }
192
+ if (focus() !== 'conversation') return false;
193
+ if (key.name === 'c') {
194
+ cancel();
195
+ return true;
196
+ }
197
+ if (key.name === 'r') {
198
+ resume();
199
+ return true;
200
+ }
201
+ if (key.name === 'd' && pending().length === 0) {
202
+ diff();
203
+ return true;
204
+ }
205
+ return false;
206
+ });
207
+ const hints = () => {
208
+ const base = props.single ? STREAM_HINTS : SESSION_HINTS;
209
+ if (status() === 'paused_budget') return [{ key: 'r', label: 'resume' }, ...base];
210
+ return base;
211
+ };
212
+
213
+ return (
214
+ <box
215
+ flexDirection="row"
216
+ width="100%"
217
+ height="100%"
218
+ onMouseDrag={resize.onMouseDrag}
219
+ onMouseDragEnd={resize.onMouseDragEnd}
220
+ onMouseUp={resize.onMouseUp}
221
+ >
222
+ <box flexDirection="column" flexGrow={1} height="100%">
223
+ <Timeline
224
+ runId={props.runId}
225
+ focused={focus() === 'conversation'}
226
+ api={(a) => {
227
+ timeline = a;
228
+ }}
229
+ />
230
+ <box flexDirection="row" width="100%" flexShrink={0} paddingLeft={2} paddingRight={2}>
231
+ <box
232
+ width={1}
233
+ flexShrink={0}
234
+ backgroundColor={
235
+ pending().length > 0
236
+ ? theme.text.feedback.warning
237
+ : theme.text.action.primary.selected
238
+ }
239
+ />
240
+ <box
241
+ flexGrow={1}
242
+ flexDirection="column"
243
+ paddingLeft={2}
244
+ paddingRight={2}
245
+ paddingTop={1}
246
+ paddingBottom={1}
247
+ backgroundColor={theme.background.raised.base}
248
+ >
249
+ <Show
250
+ when={pending().length > 0}
251
+ fallback={
252
+ <Show
253
+ when={!finished() || props.single}
254
+ fallback={
255
+ <ContinuePrompt
256
+ runId={props.runId}
257
+ onSubmit={(t) => void data.continueRun(props.runId, t)}
258
+ onScroll={(n) => timeline?.scrollBy(n)}
259
+ footer={<ContextLine runId={props.runId} elapsed={elapsed()} />}
260
+ />
261
+ }
262
+ >
263
+ <box height={1} flexShrink={0} flexDirection="row">
264
+ <Show
265
+ when={working()}
266
+ fallback={
267
+ <text
268
+ fg={color()}
269
+ attributes={TextAttributes.BOLD}
270
+ wrapMode="none"
271
+ >{`${look().symbol} ${look().word}`}</text>
272
+ }
273
+ >
274
+ <ShimmerText
275
+ fg={color()}
276
+ shimmer={theme.text.base}
277
+ attributes={TextAttributes.BOLD}
278
+ wrapMode="none"
279
+ >
280
+ {`${look().symbol} ${look().word}`}
281
+ </ShimmerText>
282
+ </Show>
283
+ <text fg={theme.text.muted} wrapMode="none">
284
+ {activeNode() ? ` · ${activeNode()}` : ''}
285
+ </text>
286
+ </box>
287
+ <box height={1} flexShrink={0} flexDirection="row">
288
+ <ContextLine runId={props.runId} elapsed={elapsed()} />
289
+ </box>
290
+ </Show>
291
+ }
292
+ >
293
+ <ApprovalBar runId={latest()} />
294
+ </Show>
295
+ </box>
296
+ </box>
297
+ <box
298
+ height={1}
299
+ flexShrink={0}
300
+ flexDirection="row"
301
+ paddingLeft={2}
302
+ paddingRight={2}
303
+ width="100%"
304
+ >
305
+ <Dots active={working()} />
306
+ <text fg={theme.text.muted} wrapMode="none">
307
+ {' '}
308
+ </text>
309
+ <Show when={working() || status() === 'waiting_human' || status() === 'waiting_approval'}>
310
+ <KeyHints hints={[{ key: 'c', label: 'cancel' }]} />
311
+ </Show>
312
+ <box flexGrow={1} flexShrink={0} width={2} />
313
+ <KeyHints hints={hints()} />
314
+ </box>
315
+ </box>
316
+ <Show when={sidebarWidth() > 0}>
317
+ <box
318
+ width={1}
319
+ flexShrink={0}
320
+ height="100%"
321
+ backgroundColor={
322
+ resize.hovered() || resize.resizing()
323
+ ? theme.background.raised.high
324
+ : theme.background.base
325
+ }
326
+ onMouseOver={resize.onMouseOver}
327
+ onMouseOut={resize.onMouseOut}
328
+ onMouseDown={resize.onMouseDown}
329
+ />
330
+ <Sidebar
331
+ runId={latest()}
332
+ rootId={props.runId}
333
+ width={sidebarWidth()}
334
+ focused={focus() === 'sidebar'}
335
+ onFocusBack={() => setFocus('conversation')}
336
+ />
337
+ </Show>
338
+ </box>
339
+ );
340
+ }
@@ -0,0 +1,87 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { type JSX, Show } from 'solid-js';
3
+ import { useData } from '../../context/data.js';
4
+ import { shortId } from '../../model/format.js';
5
+ import { isEventRun } from '../../model/stream.js';
6
+ import { useTheme } from '../../theme/context.js';
7
+
8
+ /** The text a run was asked for, from its state (`input.spec` for daemon and inline runs). */
9
+ export function requestText(input: Record<string, unknown> | undefined): string {
10
+ if (!input) return '';
11
+ for (const key of ['spec', 'input', 'text', 'prompt']) {
12
+ const v = input[key];
13
+ if (typeof v === 'string' && v.trim()) return v.trim();
14
+ }
15
+ try {
16
+ return JSON.stringify(input);
17
+ } catch {
18
+ return '';
19
+ }
20
+ }
21
+
22
+ /** The run's request at the top of the conversation: a block with the accent bar on the left. */
23
+ export function RequestBlock(props: { runId: string }): JSX.Element {
24
+ const data = useData();
25
+ const theme = useTheme();
26
+ const state = () => data.state.states[props.runId];
27
+ const summary = () => data.state.runs.find((r) => r.runId === props.runId);
28
+ const meta = () => {
29
+ const parts = [
30
+ summary()?.workflow ?? state()?.workflow ?? '',
31
+ state()?.adapter ?? '',
32
+ shortId(props.runId),
33
+ ];
34
+ return parts.filter(Boolean).join(' · ');
35
+ };
36
+ const text = () => requestText(state()?.input);
37
+ const event = () => isEventRun(state());
38
+ const child = () => state()?.parentRunId !== undefined;
39
+ const workflow = () => summary()?.workflow ?? state()?.workflow ?? '';
40
+ return (
41
+ <Show
42
+ when={!event()}
43
+ fallback={
44
+ // a message from shibaox itself (a dispatched run ended): one quiet line, no block
45
+ <box width="100%" flexShrink={0} marginBottom={1} paddingLeft={1}>
46
+ <text fg={theme.text.muted} wrapMode="word">
47
+ <span style={{ fg: theme.text.action.primary.selected }}>{'↳ '}</span>
48
+ {text()}
49
+ </text>
50
+ </box>
51
+ }
52
+ >
53
+ <box flexDirection="row" width="100%" flexShrink={0} marginBottom={1}>
54
+ <box width={1} flexShrink={0} backgroundColor={theme.text.action.primary.selected} />
55
+ <box
56
+ flexGrow={1}
57
+ flexDirection="column"
58
+ paddingLeft={2}
59
+ paddingRight={2}
60
+ paddingTop={1}
61
+ paddingBottom={1}
62
+ backgroundColor={theme.background.raised.base}
63
+ >
64
+ <Show when={child()}>
65
+ <box height={1} flexShrink={0}>
66
+ <text
67
+ fg={theme.text.action.primary.selected}
68
+ attributes={TextAttributes.BOLD}
69
+ wrapMode="none"
70
+ >
71
+ {`→ ${workflow()}`}
72
+ </text>
73
+ </box>
74
+ </Show>
75
+ <text fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="word">
76
+ {text() || '…'}
77
+ </text>
78
+ <box height={1} flexShrink={0}>
79
+ <text fg={theme.text.muted} wrapMode="none">
80
+ {meta()}
81
+ </text>
82
+ </box>
83
+ </box>
84
+ </box>
85
+ </Show>
86
+ );
87
+ }