@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,116 @@
|
|
|
1
|
+
import { TextAttributes } from '@opentui/core';
|
|
2
|
+
import { createEffect, createSignal, For, type JSX, onCleanup } from 'solid-js';
|
|
3
|
+
import { useData } from '../context/data.js';
|
|
4
|
+
import { shortId } from '../model/format.js';
|
|
5
|
+
import { statusOf } from '../model/status.js';
|
|
6
|
+
import { useMotion } from '../motion/config.js';
|
|
7
|
+
import { completionPulseOpacity } from '../motion/pulse.js';
|
|
8
|
+
import { useTheme } from '../theme/context.js';
|
|
9
|
+
import { tint } from './logo.js';
|
|
10
|
+
|
|
11
|
+
const PULSE_MS = 1200;
|
|
12
|
+
const PULSES = 3;
|
|
13
|
+
|
|
14
|
+
/** 0..1 pulse level for an unread tab: three attack/decay beats, then rest (motion only). */
|
|
15
|
+
function usePulse(unread: () => boolean) {
|
|
16
|
+
const motion = useMotion();
|
|
17
|
+
const [level, setLevel] = createSignal(0);
|
|
18
|
+
createEffect(() => {
|
|
19
|
+
if (!unread() || !motion()) {
|
|
20
|
+
setLevel(0);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const started = performance.now();
|
|
24
|
+
const t = setInterval(() => {
|
|
25
|
+
const elapsed = performance.now() - started;
|
|
26
|
+
if (elapsed >= PULSE_MS * PULSES) {
|
|
27
|
+
setLevel(0);
|
|
28
|
+
clearInterval(t);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setLevel(completionPulseOpacity((elapsed % PULSE_MS) / PULSE_MS));
|
|
32
|
+
}, 50);
|
|
33
|
+
onCleanup(() => clearInterval(t));
|
|
34
|
+
});
|
|
35
|
+
return level;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Tab(props: { runId?: string }): JSX.Element {
|
|
39
|
+
const data = useData();
|
|
40
|
+
const theme = useTheme();
|
|
41
|
+
const active = () => data.state.active === props.runId;
|
|
42
|
+
const unread = () => (props.runId ? data.state.unread[props.runId] : undefined);
|
|
43
|
+
const pulse = usePulse(() => !!unread());
|
|
44
|
+
const summary = () =>
|
|
45
|
+
props.runId ? data.state.runs.find((r) => r.runId === props.runId) : undefined;
|
|
46
|
+
const status = () =>
|
|
47
|
+
(props.runId
|
|
48
|
+
? (data.state.ended[props.runId] ??
|
|
49
|
+
data.state.states[props.runId]?.status ??
|
|
50
|
+
summary()?.status)
|
|
51
|
+
: undefined) ?? 'queued';
|
|
52
|
+
const look = () => statusOf({ status: status() });
|
|
53
|
+
const feedback = () => {
|
|
54
|
+
const f = look().feedback;
|
|
55
|
+
return f === 'muted' ? theme.text.muted : theme.text.feedback[f];
|
|
56
|
+
};
|
|
57
|
+
const pulseColor = () =>
|
|
58
|
+
unread() === 'needs' ? theme.text.feedback.warning : theme.text.feedback.success;
|
|
59
|
+
const bg = () => {
|
|
60
|
+
const base = active() ? theme.background.raised.high : theme.background.raised.base;
|
|
61
|
+
return pulse() > 0 ? tint(base, pulseColor(), pulse() * 0.5) : base;
|
|
62
|
+
};
|
|
63
|
+
const label = () =>
|
|
64
|
+
props.runId
|
|
65
|
+
? `${look().symbol} ${shortId(props.runId)} ${summary()?.workflow ?? ''}${unread() ? ' •' : ''}`
|
|
66
|
+
: '⌂';
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
flexDirection="row"
|
|
70
|
+
flexShrink={0}
|
|
71
|
+
height={1}
|
|
72
|
+
paddingLeft={2}
|
|
73
|
+
paddingRight={2}
|
|
74
|
+
backgroundColor={bg()}
|
|
75
|
+
onMouseUp={() => data.activate(props.runId)}
|
|
76
|
+
>
|
|
77
|
+
<text
|
|
78
|
+
fg={props.runId ? (active() ? theme.text.base : theme.text.muted) : theme.text.base}
|
|
79
|
+
attributes={active() ? TextAttributes.BOLD : undefined}
|
|
80
|
+
wrapMode="none"
|
|
81
|
+
>
|
|
82
|
+
{props.runId ? '' : label()}
|
|
83
|
+
</text>
|
|
84
|
+
{props.runId ? (
|
|
85
|
+
<>
|
|
86
|
+
<text fg={feedback()} wrapMode="none">{`${look().symbol} `}</text>
|
|
87
|
+
<text
|
|
88
|
+
fg={active() ? theme.text.base : theme.text.muted}
|
|
89
|
+
attributes={active() ? TextAttributes.BOLD : undefined}
|
|
90
|
+
wrapMode="none"
|
|
91
|
+
>
|
|
92
|
+
{`${shortId(props.runId)} ${summary()?.workflow ?? ''}${unread() ? ' •' : ''}`}
|
|
93
|
+
</text>
|
|
94
|
+
</>
|
|
95
|
+
) : null}
|
|
96
|
+
</box>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One line of tabs on top: home, then every open run. Only shown while runs are open. */
|
|
101
|
+
export function Tabs(): JSX.Element {
|
|
102
|
+
const data = useData();
|
|
103
|
+
const theme = useTheme();
|
|
104
|
+
return (
|
|
105
|
+
<box
|
|
106
|
+
height={1}
|
|
107
|
+
flexShrink={0}
|
|
108
|
+
flexDirection="row"
|
|
109
|
+
width="100%"
|
|
110
|
+
backgroundColor={theme.background.raised.base}
|
|
111
|
+
>
|
|
112
|
+
<Tab />
|
|
113
|
+
<For each={data.state.open}>{(id) => <Tab runId={id} />}</For>
|
|
114
|
+
</box>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js';
|
|
2
|
+
import { useTheme } from '../theme/context.js';
|
|
3
|
+
import { MIN_COLS, MIN_ROWS } from '../ui/layout.js';
|
|
4
|
+
|
|
5
|
+
/** Replaces the page when the terminal is under 60×15. */
|
|
6
|
+
export function TooSmall(): JSX.Element {
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
return (
|
|
9
|
+
<box width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
10
|
+
<text
|
|
11
|
+
fg={theme.text.feedback.warning}
|
|
12
|
+
>{`Terminal too small (${MIN_COLS}×${MIN_ROWS} minimum)`}</text>
|
|
13
|
+
</box>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ProjectProfile, RunState } from '@wizardingcode/shibaox-core';
|
|
2
|
+
import type {
|
|
3
|
+
DiffResult,
|
|
4
|
+
Envelope,
|
|
5
|
+
Health,
|
|
6
|
+
InboxItem,
|
|
7
|
+
KeyRow,
|
|
8
|
+
ModelChoice,
|
|
9
|
+
OrgConfig,
|
|
10
|
+
OrgConfigPatch,
|
|
11
|
+
RunSummaryPlus,
|
|
12
|
+
SubmitRequest,
|
|
13
|
+
} from '@wizardingcode/shibaox-daemon';
|
|
14
|
+
import { createContext, type JSX, type ParentProps, useContext } from 'solid-js';
|
|
15
|
+
|
|
16
|
+
/** The subset of the daemon client the dashboard uses; the fake implements it in tests. */
|
|
17
|
+
export interface DaemonClientLike {
|
|
18
|
+
health(): Promise<Health>;
|
|
19
|
+
listRuns(q?: { status?: string; org?: string }): Promise<RunSummaryPlus[]>;
|
|
20
|
+
getRun(id: string): Promise<RunState>;
|
|
21
|
+
events(
|
|
22
|
+
id: string,
|
|
23
|
+
o?: { since?: string; signal?: AbortSignal; historyOnly?: boolean },
|
|
24
|
+
): AsyncIterable<Envelope>;
|
|
25
|
+
inbox(): Promise<InboxItem[]>;
|
|
26
|
+
answer(
|
|
27
|
+
id: string,
|
|
28
|
+
a: { approved: boolean; note?: string; via?: 'cli' | 'api' },
|
|
29
|
+
): Promise<{ runId: string; kind: 'human' | 'approval' }>;
|
|
30
|
+
cancel(id: string): Promise<RunState>;
|
|
31
|
+
resume(id: string, o?: { budgetUsd?: number }): Promise<RunState>;
|
|
32
|
+
submitRun(req: SubmitRequest): Promise<{ runId: string; warnings: string[] }>;
|
|
33
|
+
diff(id: string): Promise<DiffResult>;
|
|
34
|
+
projectProfile(path: string, orgRoot?: string): Promise<ProjectProfile>;
|
|
35
|
+
models(): Promise<ModelChoice[]>;
|
|
36
|
+
orgConfig(root: string): Promise<OrgConfig>;
|
|
37
|
+
setOrgConfig(root: string, patch: OrgConfigPatch): Promise<OrgConfig>;
|
|
38
|
+
defaultOrg(): Promise<{ root: string; created: boolean }>;
|
|
39
|
+
keys(): Promise<KeyRow[]>;
|
|
40
|
+
setKey(name: string, value: string): Promise<{ name: string; set: true }>;
|
|
41
|
+
unsetKey(name: string): Promise<{ name: string; removed: boolean }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Context = createContext<DaemonClientLike>();
|
|
45
|
+
|
|
46
|
+
export function ClientProvider(props: ParentProps<{ client: DaemonClientLike }>): JSX.Element {
|
|
47
|
+
return <Context.Provider value={props.client}>{props.children}</Context.Provider>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useClient(): DaemonClientLike {
|
|
51
|
+
const c = useContext(Context);
|
|
52
|
+
if (!c) throw new Error('useClient outside ClientProvider');
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Accessor,
|
|
3
|
+
createContext,
|
|
4
|
+
createSignal,
|
|
5
|
+
type JSX,
|
|
6
|
+
type ParentProps,
|
|
7
|
+
useContext,
|
|
8
|
+
} from 'solid-js';
|
|
9
|
+
|
|
10
|
+
/** An action the palette can run; `when` hides it while false. */
|
|
11
|
+
export interface Command {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
keys: string;
|
|
15
|
+
run: () => void;
|
|
16
|
+
when?: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Registry {
|
|
20
|
+
commands: Accessor<Command[]>;
|
|
21
|
+
register(cmds: Command[]): () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const Context = createContext<Registry>();
|
|
25
|
+
|
|
26
|
+
/** Screens register their commands here so the palette and help can list them. */
|
|
27
|
+
export function CommandsProvider(props: ParentProps): JSX.Element {
|
|
28
|
+
const [all, setAll] = createSignal<Command[][]>([]);
|
|
29
|
+
const register = (cmds: Command[]) => {
|
|
30
|
+
setAll((a) => [...a, cmds]);
|
|
31
|
+
return () => setAll((a) => a.filter((c) => c !== cmds));
|
|
32
|
+
};
|
|
33
|
+
return (
|
|
34
|
+
<Context.Provider value={{ commands: () => all().flat(), register }}>
|
|
35
|
+
{props.children}
|
|
36
|
+
</Context.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useCommands(): Registry {
|
|
41
|
+
const c = useContext(Context);
|
|
42
|
+
if (!c) throw new Error('useCommands outside CommandsProvider');
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext, type JSX, type ParentProps, useContext } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
/** Where this instance runs: the CLI version, the shibaox home and the directory it was launched from. */
|
|
4
|
+
export interface AppConfig {
|
|
5
|
+
version: string;
|
|
6
|
+
home: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
/** The environment the dashboard was launched with (PATH decides the default adapter). */
|
|
9
|
+
env: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const Context = createContext<AppConfig>();
|
|
13
|
+
|
|
14
|
+
export function ConfigProvider(props: ParentProps<{ config: AppConfig }>): JSX.Element {
|
|
15
|
+
return <Context.Provider value={props.config}>{props.children}</Context.Provider>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useConfig(): AppConfig {
|
|
19
|
+
const c = useContext(Context);
|
|
20
|
+
if (!c) throw new Error('useConfig outside ConfigProvider');
|
|
21
|
+
return c;
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RunState, RunStatus } from '@wizardingcode/shibaox-core';
|
|
2
|
+
import type { Health, InboxItem, RunSummaryPlus } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
|
|
4
|
+
/** Everything the screens read; fed by the Poller and by user actions. */
|
|
5
|
+
export interface DataState {
|
|
6
|
+
health?: Health;
|
|
7
|
+
reachable: boolean;
|
|
8
|
+
/** When the daemon stopped answering (ms epoch), for the delayed "reconnecting" overlay. */
|
|
9
|
+
unreachableSince?: number;
|
|
10
|
+
runs: RunSummaryPlus[];
|
|
11
|
+
inbox: InboxItem[];
|
|
12
|
+
states: Record<string, RunState>;
|
|
13
|
+
/** Bumped whenever a run's frames change (the frames themselves live outside the store). */
|
|
14
|
+
versions: Record<string, number>;
|
|
15
|
+
ended: Record<string, RunStatus>;
|
|
16
|
+
/** Open tabs (root run ids) in order; `active` undefined means the home tab. */
|
|
17
|
+
open: string[];
|
|
18
|
+
/** The runs of each tab, oldest first: a tab continues into follow-up runs. */
|
|
19
|
+
threads: Record<string, string[]>;
|
|
20
|
+
/** The model chosen with `/model` in a tab, for its next turns. */
|
|
21
|
+
models: Record<string, string>;
|
|
22
|
+
active?: string;
|
|
23
|
+
/** Runs with something the user has not looked at yet (tab pulse). */
|
|
24
|
+
unread: Record<string, 'done' | 'needs'>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const initialData = (): DataState => ({
|
|
28
|
+
reachable: true,
|
|
29
|
+
runs: [],
|
|
30
|
+
inbox: [],
|
|
31
|
+
states: {},
|
|
32
|
+
versions: {},
|
|
33
|
+
ended: {},
|
|
34
|
+
open: [],
|
|
35
|
+
threads: {},
|
|
36
|
+
models: {},
|
|
37
|
+
unread: {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Frames kept per run (the cards cap at 5000; frames are a few per card). */
|
|
41
|
+
export const FRAME_LIMIT = 20_000;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { conversationOf, type RunState, type RunStatus } from '@wizardingcode/shibaox-core';
|
|
2
|
+
import type { Envelope, InboxId, SubmitRequest } from '@wizardingcode/shibaox-daemon';
|
|
3
|
+
import type { ChatMessage } from '@wizardingcode/shibaox-schemas';
|
|
4
|
+
import {
|
|
5
|
+
type Accessor,
|
|
6
|
+
createContext,
|
|
7
|
+
createEffect,
|
|
8
|
+
createRoot,
|
|
9
|
+
type JSX,
|
|
10
|
+
onCleanup,
|
|
11
|
+
type ParentProps,
|
|
12
|
+
useContext,
|
|
13
|
+
} from 'solid-js';
|
|
14
|
+
import { createStore, reconcile } from 'solid-js/store';
|
|
15
|
+
import { money } from '../model/format.js';
|
|
16
|
+
import { type Card, reduceTimeline } from '../model/stream.js';
|
|
17
|
+
import { requestText } from '../routes/session/request.js';
|
|
18
|
+
import type { DaemonClientLike } from './client.js';
|
|
19
|
+
import { useConfig } from './config.js';
|
|
20
|
+
import { type DataState, FRAME_LIMIT, initialData } from './data-state.js';
|
|
21
|
+
import { Poller, type PollerIntervals, type PollerToast } from './poller.js';
|
|
22
|
+
|
|
23
|
+
export type { DataState } from './data-state.js';
|
|
24
|
+
|
|
25
|
+
export interface Data {
|
|
26
|
+
state: DataState;
|
|
27
|
+
/**
|
|
28
|
+
* The session cards of a run: reconciled by key on every change, so unchanged cards and
|
|
29
|
+
* blocks keep their identity and the screen updates in place instead of remounting.
|
|
30
|
+
*/
|
|
31
|
+
timeline(runId: string): Accessor<Card[]>;
|
|
32
|
+
/** How many frames of the run are held; undefined once its tab is closed. */
|
|
33
|
+
frameCount(runId: string): number | undefined;
|
|
34
|
+
openRun(runId: string): void;
|
|
35
|
+
closeRun(runId: string): void;
|
|
36
|
+
/** The runs of the tab that holds `runId`, oldest first. */
|
|
37
|
+
threadOf(runId: string): string[];
|
|
38
|
+
/** Submits the next turn in the tab of `rootId` (same org, project, workflow and adapter; the conversation so far travels as `messages`). */
|
|
39
|
+
continueRun(rootId: string, text: string, o?: { event?: boolean }): Promise<string | undefined>;
|
|
40
|
+
/** The model the next turns of the tab run on (`/model`). */
|
|
41
|
+
setModel(rootId: string, ref: string | undefined): void;
|
|
42
|
+
activate(runId?: string): void;
|
|
43
|
+
nextTab(direction: 1 | -1): void;
|
|
44
|
+
markRead(runId: string): void;
|
|
45
|
+
actions: {
|
|
46
|
+
answer(id: InboxId, approved: boolean, note?: string): Promise<void>;
|
|
47
|
+
cancel(runId: string): Promise<void>;
|
|
48
|
+
resume(runId: string, budgetUsd?: number): Promise<void>;
|
|
49
|
+
submit(req: SubmitRequest): Promise<string | undefined>;
|
|
50
|
+
};
|
|
51
|
+
poller: Poller;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const Context = createContext<Data>();
|
|
55
|
+
|
|
56
|
+
export function DataProvider(
|
|
57
|
+
props: ParentProps<{
|
|
58
|
+
client: DaemonClientLike;
|
|
59
|
+
intervals?: Partial<PollerIntervals>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** Stream mode: subscribe to this run only and never open tabs. */
|
|
62
|
+
single?: string;
|
|
63
|
+
/** Where the poller's toasts go (the shell passes `useToast().show`). */
|
|
64
|
+
toast?: (t: PollerToast) => void;
|
|
65
|
+
/** Where stream failures and malformed frames are noted. */
|
|
66
|
+
log?: (line: string) => void;
|
|
67
|
+
}>,
|
|
68
|
+
): JSX.Element {
|
|
69
|
+
const [state, set] = createStore<DataState>(initialData());
|
|
70
|
+
const TERMINAL = new Set<string>(['completed', 'failed', 'cancelled']);
|
|
71
|
+
// how long a follow-up waits for the previous turn to end (tests shorten it)
|
|
72
|
+
const turnWaitMs = Number(useConfig().env.SHIBAOX_TURN_WAIT_MS) || 60_000;
|
|
73
|
+
// frames stay out of the reactive store: a reduce over 20 000 proxied envelopes would
|
|
74
|
+
// subscribe to every property it reads; `versions` is the only signal
|
|
75
|
+
const frames = new Map<string, Envelope[]>();
|
|
76
|
+
const bump = (runId: string) => set('versions', runId, (v = 0) => v + 1);
|
|
77
|
+
const sink = {
|
|
78
|
+
reset: (runId: string) => {
|
|
79
|
+
frames.set(runId, []);
|
|
80
|
+
bump(runId);
|
|
81
|
+
},
|
|
82
|
+
append: (runId: string, batch: Envelope[]) => {
|
|
83
|
+
const all = [...(frames.get(runId) ?? []), ...batch];
|
|
84
|
+
frames.set(runId, all.length > FRAME_LIMIT ? all.slice(-FRAME_LIMIT) : all);
|
|
85
|
+
bump(runId);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const timelines = new Map<string, { cards: Accessor<Card[]>; dispose: () => void }>();
|
|
89
|
+
|
|
90
|
+
const activate = (runId?: string) => {
|
|
91
|
+
set('active', runId);
|
|
92
|
+
if (runId) set('unread', runId, undefined as never);
|
|
93
|
+
};
|
|
94
|
+
const openRun = (runId: string) => {
|
|
95
|
+
if (!state.open.includes(runId)) set('open', (o) => [...o, runId]);
|
|
96
|
+
if (!state.threads[runId]) set('threads', runId, [runId]);
|
|
97
|
+
for (const id of state.threads[runId] ?? [runId]) poller.subscribe(id);
|
|
98
|
+
activate(runId);
|
|
99
|
+
};
|
|
100
|
+
const threadOf = (runId: string) => state.threads[runId] ?? [runId];
|
|
101
|
+
/** The conversation turns of a tab: the runs the user talked to (dispatched runs are not turns). */
|
|
102
|
+
const turnsOf = (rootId: string) =>
|
|
103
|
+
threadOf(rootId).filter((id) => !state.states[id]?.parentRunId);
|
|
104
|
+
const replyOf = (id: string) =>
|
|
105
|
+
timeline(id)()
|
|
106
|
+
.flatMap((c) => (c.kind === 'node' ? c.blocks : []))
|
|
107
|
+
.flatMap((b) => (b.kind === 'text' && !b.parentId ? [b.text.trim()] : []))
|
|
108
|
+
.join('\n')
|
|
109
|
+
.trim();
|
|
110
|
+
// turns of a tab go one at a time: the next one builds on the previous one's reply
|
|
111
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
112
|
+
const continueRun = (rootId: string, text: string, o: { event?: boolean } = {}) => {
|
|
113
|
+
const next = (chains.get(rootId) ?? Promise.resolve()).then(
|
|
114
|
+
() => submitTurn(rootId, text, o),
|
|
115
|
+
() => submitTurn(rootId, text, o),
|
|
116
|
+
);
|
|
117
|
+
chains.set(rootId, next);
|
|
118
|
+
return next;
|
|
119
|
+
};
|
|
120
|
+
/** Waits (bounded) until `runId` is no longer working, so its reply is whole. */
|
|
121
|
+
const settled = async (runId: string) => {
|
|
122
|
+
const until = Date.now() + turnWaitMs;
|
|
123
|
+
while (Date.now() < until) {
|
|
124
|
+
const st = state.states[runId];
|
|
125
|
+
if (st && TERMINAL.has(st.status)) return;
|
|
126
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const submitTurn = async (rootId: string, text: string, o: { event?: boolean } = {}) => {
|
|
130
|
+
const turns = turnsOf(rootId);
|
|
131
|
+
const previousId = turns[turns.length - 1] ?? rootId;
|
|
132
|
+
await settled(previousId);
|
|
133
|
+
const previous = state.states[previousId];
|
|
134
|
+
if (!previous) return undefined;
|
|
135
|
+
// the conversation so far travels as structured messages; the input is the new turn only.
|
|
136
|
+
// It builds on the thread the daemon kept for the previous run (it may have compacted
|
|
137
|
+
// the oldest turns into a summary) plus that run's own request and reply.
|
|
138
|
+
const reply = replyOf(previousId);
|
|
139
|
+
const messages: ChatMessage[] = [
|
|
140
|
+
...conversationOf(previous.input),
|
|
141
|
+
{ role: 'user' as const, content: requestText(previous.input) },
|
|
142
|
+
...(reply ? [{ role: 'assistant' as const, content: reply }] : []),
|
|
143
|
+
];
|
|
144
|
+
const model = state.models[rootId] ?? previous.model;
|
|
145
|
+
const runId = await poller.submit({
|
|
146
|
+
orgRoot: previous.orgRoot ?? '',
|
|
147
|
+
project: previous.project ?? previous.workspace,
|
|
148
|
+
workflow: previous.workflow,
|
|
149
|
+
input: text,
|
|
150
|
+
messages,
|
|
151
|
+
...(o.event ? { event: true } : {}),
|
|
152
|
+
// with a model the daemon derives the adapter from it
|
|
153
|
+
...(model ? { model } : {}),
|
|
154
|
+
adapter: previous.adapter as SubmitRequest['adapter'],
|
|
155
|
+
workspace: previous.workspaceMode,
|
|
156
|
+
budgetUsd: previous.budgetUsd,
|
|
157
|
+
});
|
|
158
|
+
if (!runId) return undefined;
|
|
159
|
+
set('threads', rootId, (t = [rootId]) => [...t, runId]);
|
|
160
|
+
poller.subscribe(runId);
|
|
161
|
+
return runId;
|
|
162
|
+
};
|
|
163
|
+
/** The tab whose thread holds `runId`, when one does. */
|
|
164
|
+
const rootOf = (runId: string) =>
|
|
165
|
+
state.open.find((root) => (state.threads[root] ?? [root]).includes(runId));
|
|
166
|
+
/** What the orchestrator is told when a run it dispatched ends. */
|
|
167
|
+
const eventText = (st: RunState, status: RunStatus) => {
|
|
168
|
+
const nodes = Object.entries(st.nodes);
|
|
169
|
+
const summaries = nodes
|
|
170
|
+
.filter(([, n]) => n.summary)
|
|
171
|
+
.map(([id, n]) => `${id}: ${String(n.summary).replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
172
|
+
const files = timeline(st.runId)().find((c) => c.kind === 'summary');
|
|
173
|
+
const parts = [
|
|
174
|
+
`workflow ${st.workflow} finished: ${status}`,
|
|
175
|
+
`${nodes.length} node${nodes.length === 1 ? '' : 's'}`,
|
|
176
|
+
money(st.spentUsd),
|
|
177
|
+
...(files?.kind === 'summary' && files.files.length > 0
|
|
178
|
+
? [`files: ${files.files.slice(0, 20).join(', ')}`]
|
|
179
|
+
: []),
|
|
180
|
+
...(st.branch ? [`branch ${st.branch}`] : []),
|
|
181
|
+
...(st.error ? [`error: ${st.error}`] : []),
|
|
182
|
+
...summaries,
|
|
183
|
+
];
|
|
184
|
+
return parts.join(' · ').slice(0, 2000);
|
|
185
|
+
};
|
|
186
|
+
const poller = new Poller({
|
|
187
|
+
client: props.client,
|
|
188
|
+
set,
|
|
189
|
+
get: () => state,
|
|
190
|
+
frames: sink,
|
|
191
|
+
toast: (t) => props.toast?.(t),
|
|
192
|
+
log: props.log,
|
|
193
|
+
open: openRun,
|
|
194
|
+
now: props.now,
|
|
195
|
+
intervals: props.intervals,
|
|
196
|
+
});
|
|
197
|
+
const release = (runId: string) => {
|
|
198
|
+
timelines.get(runId)?.dispose();
|
|
199
|
+
timelines.delete(runId);
|
|
200
|
+
frames.delete(runId);
|
|
201
|
+
set('versions', runId, undefined as never);
|
|
202
|
+
};
|
|
203
|
+
const closeRun = (runId: string) => {
|
|
204
|
+
const i = state.open.indexOf(runId);
|
|
205
|
+
if (i < 0) return;
|
|
206
|
+
set('open', (o) => o.filter((id) => id !== runId));
|
|
207
|
+
for (const id of threadOf(runId)) {
|
|
208
|
+
poller.unsubscribe(id);
|
|
209
|
+
release(id);
|
|
210
|
+
}
|
|
211
|
+
set('threads', runId, undefined as never);
|
|
212
|
+
set('models', runId, undefined as never);
|
|
213
|
+
if (state.active === runId) activate(state.open[Math.min(i, state.open.length - 1)]);
|
|
214
|
+
};
|
|
215
|
+
const nextTab = (direction: 1 | -1) => {
|
|
216
|
+
const tabs = [undefined, ...state.open];
|
|
217
|
+
const i = tabs.indexOf(state.active);
|
|
218
|
+
activate(tabs[(i + direction + tabs.length) % tabs.length]);
|
|
219
|
+
};
|
|
220
|
+
const timeline = (runId: string): Accessor<Card[]> => {
|
|
221
|
+
let entry = timelines.get(runId);
|
|
222
|
+
if (!entry) {
|
|
223
|
+
entry = createRoot((dispose) => {
|
|
224
|
+
const [cards, setCards] = createStore<{ list: Card[] }>({ list: [] });
|
|
225
|
+
createEffect(() => {
|
|
226
|
+
state.versions[runId];
|
|
227
|
+
const run = state.states[runId];
|
|
228
|
+
// a malformed frame must never take the screen down: keep the last good cards and log
|
|
229
|
+
try {
|
|
230
|
+
const next = reduceTimeline(run, frames.get(runId) ?? []);
|
|
231
|
+
setCards('list', reconcile(next, { key: 'key' }));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
props.log?.(`timeline ${runId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
return { cards: () => cards.list, dispose };
|
|
237
|
+
});
|
|
238
|
+
timelines.set(runId, entry);
|
|
239
|
+
}
|
|
240
|
+
return entry.cards;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// children whose end was already history when they joined a tab are never reported again
|
|
244
|
+
const reported = new Set<string>();
|
|
245
|
+
// a run dispatched from a conversation (`parentRunId`) joins the tab of its parent
|
|
246
|
+
createEffect(() => {
|
|
247
|
+
if (props.single) return;
|
|
248
|
+
for (const r of state.runs) {
|
|
249
|
+
if (!r.parentRunId) continue;
|
|
250
|
+
const root = rootOf(r.parentRunId);
|
|
251
|
+
if (!root || threadOf(root).includes(r.runId)) continue;
|
|
252
|
+
if (TERMINAL.has(r.status)) reported.add(r.runId);
|
|
253
|
+
set('threads', root, (t = [root]) => [...t, r.runId]);
|
|
254
|
+
poller.subscribe(r.runId);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// when a dispatched run ends, the conversation continues with an event for the orchestrator
|
|
258
|
+
createEffect(() => {
|
|
259
|
+
if (props.single) return;
|
|
260
|
+
for (const root of state.open)
|
|
261
|
+
for (const id of state.threads[root] ?? []) {
|
|
262
|
+
const st = state.states[id];
|
|
263
|
+
const status = state.ended[id];
|
|
264
|
+
if (!st?.parentRunId || !status || reported.has(id)) continue;
|
|
265
|
+
reported.add(id);
|
|
266
|
+
void continueRun(root, eventText(st, status), { event: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
poller.start();
|
|
271
|
+
if (props.single) {
|
|
272
|
+
set('open', [props.single]);
|
|
273
|
+
set('active', props.single);
|
|
274
|
+
poller.subscribe(props.single);
|
|
275
|
+
}
|
|
276
|
+
onCleanup(() => {
|
|
277
|
+
poller.stop();
|
|
278
|
+
for (const id of [...timelines.keys()]) release(id);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const value: Data = {
|
|
282
|
+
state,
|
|
283
|
+
timeline,
|
|
284
|
+
frameCount: (runId) => frames.get(runId)?.length,
|
|
285
|
+
openRun,
|
|
286
|
+
closeRun,
|
|
287
|
+
threadOf,
|
|
288
|
+
continueRun,
|
|
289
|
+
setModel: (rootId, ref) => set('models', rootId, ref as never),
|
|
290
|
+
activate,
|
|
291
|
+
nextTab,
|
|
292
|
+
markRead: (runId) => set('unread', runId, undefined as never),
|
|
293
|
+
actions: {
|
|
294
|
+
answer: (id, approved, note) => poller.answer(id, approved, note),
|
|
295
|
+
cancel: (runId) => poller.cancel(runId),
|
|
296
|
+
resume: (runId, budgetUsd) => poller.resume(runId, budgetUsd),
|
|
297
|
+
submit: (req) => poller.submit(req),
|
|
298
|
+
},
|
|
299
|
+
poller,
|
|
300
|
+
};
|
|
301
|
+
return <Context.Provider value={value}>{props.children}</Context.Provider>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function useData(): Data {
|
|
305
|
+
const c = useContext(Context);
|
|
306
|
+
if (!c) throw new Error('useData outside DataProvider');
|
|
307
|
+
return c;
|
|
308
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext, type JSX, type ParentProps, useContext } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
const Context = createContext<(code: number) => void>();
|
|
4
|
+
|
|
5
|
+
export function ExitProvider(props: ParentProps<{ onExit: (code: number) => void }>): JSX.Element {
|
|
6
|
+
return <Context.Provider value={props.onExit}>{props.children}</Context.Provider>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useExit(): (code: number) => void {
|
|
10
|
+
const c = useContext(Context);
|
|
11
|
+
if (!c) throw new Error('useExit outside ExitProvider');
|
|
12
|
+
return c;
|
|
13
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type KeyEvent, TextareaRenderable } from '@opentui/core';
|
|
2
|
+
import { useKeyboard, useRenderer } from '@opentui/solid';
|
|
3
|
+
import { createContext, type JSX, onCleanup, type ParentProps, useContext } from 'solid-js';
|
|
4
|
+
|
|
5
|
+
export type Scope = 'global' | 'pane' | 'prompt' | 'dialog';
|
|
6
|
+
/** Returns true when the key was consumed. */
|
|
7
|
+
export type KeyHandler = (key: KeyEvent) => boolean | undefined;
|
|
8
|
+
|
|
9
|
+
interface Entry {
|
|
10
|
+
scope: Scope;
|
|
11
|
+
handler: KeyHandler;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Context = createContext<{ register(e: Entry): () => void }>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One keyboard listener for the whole app, dispatched by scope: ctrl+q exits always; an open
|
|
18
|
+
* dialog is modal (only dialog handlers see keys, newest first); a focused text input keeps every
|
|
19
|
+
* plain key for prompt handlers; otherwise prompt handlers, then pane handlers, then global
|
|
20
|
+
* handlers, each newest first, until one consumes the key.
|
|
21
|
+
* ctrl+c exits unless a prompt handler consumes it (clearing its text) or a dialog is open.
|
|
22
|
+
*/
|
|
23
|
+
export function KeysProvider(props: ParentProps<{ onExit: (code: number) => void }>): JSX.Element {
|
|
24
|
+
const entries: Entry[] = [];
|
|
25
|
+
const renderer = useRenderer();
|
|
26
|
+
useKeyboard((key) => {
|
|
27
|
+
if (key.ctrl && key.name === 'q') return props.onExit(0);
|
|
28
|
+
// a focused text input owns every plain key: only prompt handlers may look at it
|
|
29
|
+
// (they still see ctrl/meta chords and escape through the normal dispatch)
|
|
30
|
+
const typing =
|
|
31
|
+
renderer.currentFocusedRenderable instanceof TextareaRenderable &&
|
|
32
|
+
!key.ctrl &&
|
|
33
|
+
!key.meta &&
|
|
34
|
+
key.name !== 'escape';
|
|
35
|
+
if (entries.some((e) => e.scope === 'dialog')) {
|
|
36
|
+
// modal: every key (ctrl+c included) belongs to the open dialogs, newest handler first
|
|
37
|
+
for (const e of [...entries].reverse()) if (e.scope === 'dialog' && e.handler(key)) return;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const ordered = [...entries].reverse();
|
|
41
|
+
const dispatch = (scope: Scope) => ordered.some((e) => e.scope === scope && e.handler(key));
|
|
42
|
+
if (typing) {
|
|
43
|
+
dispatch('prompt');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (key.ctrl && key.name === 'c') {
|
|
47
|
+
if (dispatch('prompt')) return;
|
|
48
|
+
return props.onExit(0);
|
|
49
|
+
}
|
|
50
|
+
if (dispatch('prompt')) return;
|
|
51
|
+
if (dispatch('pane')) return;
|
|
52
|
+
dispatch('global');
|
|
53
|
+
});
|
|
54
|
+
const register = (e: Entry) => {
|
|
55
|
+
entries.push(e);
|
|
56
|
+
return () => {
|
|
57
|
+
const i = entries.indexOf(e);
|
|
58
|
+
if (i >= 0) entries.splice(i, 1);
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return <Context.Provider value={{ register }}>{props.children}</Context.Provider>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Registers a key handler for this component's lifetime. */
|
|
65
|
+
export function useKeys(scope: Scope, handler: KeyHandler): void {
|
|
66
|
+
const c = useContext(Context);
|
|
67
|
+
if (!c) throw new Error('useKeys outside KeysProvider');
|
|
68
|
+
const off = c.register({ scope, handler });
|
|
69
|
+
onCleanup(off);
|
|
70
|
+
}
|