@agentxjs/cli 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @agentxjs/cli
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [3b764d8]
8
+ - agentxjs@2.0.0
9
+ - @agentxjs/core@2.0.0
10
+ - @agentxjs/node-platform@2.0.0
11
+ - @agentxjs/mono-driver@2.0.0
12
+ - @agentxjs/server@2.0.0
package/bin/agentx ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.ts";
package/bunfig.toml ADDED
@@ -0,0 +1,4 @@
1
+ preload = ["@opentui/solid/preload"]
2
+
3
+ [test]
4
+ preload = ["@opentui/solid/preload"]
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@agentxjs/cli",
3
+ "version": "0.0.2",
4
+ "description": "AgentX Terminal UI Client",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "agentx": "./bin/agentx"
9
+ },
10
+ "scripts": {
11
+ "dev": "bun run --conditions=browser src/index.ts",
12
+ "build": "bun build src/index.ts --outdir dist --target node",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@opentui/core": "^0.1.77",
17
+ "@opentui/solid": "^0.1.77",
18
+ "solid-js": "^1.9.10",
19
+ "agentxjs": "^2.0.0",
20
+ "@agentxjs/server": "^2.0.0",
21
+ "@agentxjs/node-platform": "^2.0.0",
22
+ "@agentxjs/mono-driver": "^2.0.0",
23
+ "@agentxjs/core": "^2.0.0",
24
+ "yargs": "^18.0.0",
25
+ "clipboardy": "^4.0.0",
26
+ "dotenv": "^16.6.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/yargs": "^17.0.33",
30
+ "typescript": "^5.3.3"
31
+ }
32
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * AgentX TUI Application
3
+ *
4
+ * Main entry point for the terminal UI.
5
+ */
6
+
7
+ import { render, useTerminalDimensions, useKeyboard } from "@opentui/solid";
8
+ import { ErrorBoundary, Switch, Match, createSignal } from "solid-js";
9
+ import { ThemeProvider, useTheme } from "./context/theme";
10
+ import { AgentXProvider, useAgentX } from "./context/agentx";
11
+ import { DialogProvider, useDialog } from "./context/dialog";
12
+ import { ToastProvider } from "./context/toast";
13
+ import { RouteProvider, useRoute } from "./context/route";
14
+ import { ExitProvider, useExit } from "./context/exit";
15
+ import { Home } from "./routes/home";
16
+ import { SessionView } from "./routes/session";
17
+ import { DialogNewSession } from "./component/dialog-new-session";
18
+ import { DialogSessionList } from "./component/dialog-session-list";
19
+ import { createLogger } from "commonxjs/logger";
20
+
21
+ const logger = createLogger("cli/app");
22
+
23
+ export interface TuiOptions {
24
+ serverUrl: string;
25
+ theme?: string;
26
+ }
27
+
28
+ /**
29
+ * Start the TUI application
30
+ */
31
+ export function tui(options: TuiOptions): Promise<void> {
32
+ return new Promise<void>((resolve) => {
33
+ const onExit = () => {
34
+ logger.info("Exiting TUI");
35
+ // Restore terminal state before exit
36
+ process.stdout.write("\x1b[?1000l"); // Disable mouse tracking
37
+ process.stdout.write("\x1b[?1002l"); // Disable mouse button tracking
38
+ process.stdout.write("\x1b[?1003l"); // Disable all mouse tracking
39
+ process.stdout.write("\x1b[?1006l"); // Disable SGR mouse mode
40
+ process.stdout.write("\x1b[?25h"); // Show cursor
41
+ process.stdout.write("\x1b[?1049l"); // Restore main screen buffer
42
+ resolve();
43
+ process.exit(0);
44
+ };
45
+
46
+ render(
47
+ () => (
48
+ <ErrorBoundary fallback={(error) => <ErrorScreen error={error} />}>
49
+ <ExitProvider onExit={onExit}>
50
+ <ToastProvider>
51
+ <RouteProvider>
52
+ <AgentXProvider serverUrl={options.serverUrl}>
53
+ <ThemeProvider initialTheme={options.theme}>
54
+ <DialogProvider>
55
+ <App />
56
+ </DialogProvider>
57
+ </ThemeProvider>
58
+ </AgentXProvider>
59
+ </RouteProvider>
60
+ </ToastProvider>
61
+ </ExitProvider>
62
+ </ErrorBoundary>
63
+ ),
64
+ {
65
+ targetFps: 60,
66
+ exitOnCtrlC: false,
67
+ }
68
+ );
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Main App component
74
+ */
75
+ function App() {
76
+ const dimensions = useTerminalDimensions();
77
+ const { theme } = useTheme();
78
+ const exit = useExit();
79
+ const route = useRoute();
80
+ const dialog = useDialog();
81
+ const agentx = useAgentX();
82
+ const [lastKey, setLastKey] = createSignal("");
83
+
84
+ useKeyboard((evt) => {
85
+ // Show last key for debugging
86
+ const keyStr = `${evt.ctrl ? "Ctrl+" : ""}${evt.meta ? "Meta+" : ""}${evt.name}`;
87
+ setLastKey(keyStr);
88
+ logger.debug("Key pressed", {
89
+ key: keyStr,
90
+ connected: agentx.connected(),
91
+ dialogCount: dialog.stack.length,
92
+ });
93
+
94
+ // Ctrl+C to exit
95
+ if (evt.ctrl && evt.name === "c") {
96
+ logger.info("Exit requested");
97
+ exit();
98
+ return;
99
+ }
100
+
101
+ // Only handle shortcuts when connected
102
+ if (!agentx.connected()) {
103
+ logger.debug("Ignoring key - not connected");
104
+ return;
105
+ }
106
+
107
+ // Skip if dialog is open
108
+ if (dialog.stack.length > 0) {
109
+ logger.debug("Ignoring key - dialog open");
110
+ return;
111
+ }
112
+
113
+ // Ctrl+N - New session
114
+ if (evt.ctrl && (evt.name === "n" || evt.name === "N")) {
115
+ logger.info("Opening new session dialog");
116
+ dialog.show(() => <DialogNewSession />);
117
+ logger.info("dialog.show called", { stackLength: dialog.stack.length });
118
+ evt.preventDefault();
119
+ return;
120
+ }
121
+
122
+ // Ctrl+L - List sessions
123
+ if (evt.ctrl && (evt.name === "l" || evt.name === "L")) {
124
+ logger.info("Opening session list dialog");
125
+ dialog.show(() => <DialogSessionList />);
126
+ logger.info("dialog.show called", { stackLength: dialog.stack.length });
127
+ evt.preventDefault();
128
+ return;
129
+ }
130
+
131
+ // Escape - Go back
132
+ if (evt.name === "escape" && route.data.type !== "home") {
133
+ route.back();
134
+ evt.preventDefault();
135
+ return;
136
+ }
137
+ });
138
+
139
+ return (
140
+ <box
141
+ width={dimensions().width}
142
+ height={dimensions().height}
143
+ backgroundColor={theme().background}
144
+ flexDirection="column"
145
+ >
146
+ <box flexGrow={1}>
147
+ <Switch>
148
+ <Match when={route.data.type === "home"}>
149
+ <Home />
150
+ </Match>
151
+ <Match when={route.data.type === "session"}>
152
+ <SessionView sessionId={(route.data as { sessionId: string }).sessionId} />
153
+ </Match>
154
+ </Switch>
155
+ </box>
156
+ {/* Debug: show status */}
157
+ <box position="absolute" bottom={0} right={2}>
158
+ <text fg={theme().textMuted}>
159
+ Key: {lastKey()} | Connected: {agentx.connected() ? "Y" : "N"} | Dialogs:{" "}
160
+ {dialog.stack.length}
161
+ </text>
162
+ </box>
163
+ </box>
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Error screen
169
+ */
170
+ function ErrorScreen(props: { error: Error }) {
171
+ const dimensions = useTerminalDimensions();
172
+
173
+ return (
174
+ <box width={dimensions().width} height={dimensions().height} flexDirection="column" padding={2}>
175
+ <text fg="#ff0000">Fatal Error:</text>
176
+ <text fg="#ffffff">{props.error.message}</text>
177
+ <text fg="#808080">{props.error.stack}</text>
178
+ </box>
179
+ );
180
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * New Session Dialog - Create a new chat session
3
+ */
4
+
5
+ import { useKeyboard } from "@opentui/solid";
6
+ import { createSignal } from "solid-js";
7
+ import { useTheme } from "../context/theme";
8
+ import { useAgentX } from "../context/agentx";
9
+ import { useDialog } from "../context/dialog";
10
+ import { useRoute } from "../context/route";
11
+ import { useToast } from "../context/toast";
12
+ import { createLogger } from "commonxjs/logger";
13
+
14
+ const logger = createLogger("cli/dialog-new-session");
15
+
16
+ export function DialogNewSession() {
17
+ const { theme } = useTheme();
18
+ const agentx = useAgentX();
19
+ const dialog = useDialog();
20
+ const route = useRoute();
21
+ const toast = useToast();
22
+
23
+ const [creating, setCreating] = createSignal(false);
24
+ const [error, setError] = createSignal<string | null>(null);
25
+
26
+ async function createSession() {
27
+ logger.info("createSession called", { hasClient: !!agentx.client, creating: creating() });
28
+ if (!agentx.client || creating()) return;
29
+
30
+ setCreating(true);
31
+ setError(null);
32
+
33
+ try {
34
+ logger.info("Creating container...");
35
+ await agentx.client.containers.create("default");
36
+
37
+ logger.info("Creating image...");
38
+ const result = await agentx.client.images.create({
39
+ containerId: "default",
40
+ name: `Chat ${new Date().toLocaleString()}`,
41
+ systemPrompt: "You are a helpful assistant.",
42
+ });
43
+
44
+ logger.info("createImage response", { result: JSON.stringify(result) });
45
+
46
+ if (!result.record?.sessionId) {
47
+ throw new Error("Invalid response: missing sessionId");
48
+ }
49
+
50
+ logger.info("Session created", { sessionId: result.record.sessionId });
51
+ dialog.clear();
52
+ toast.show({ message: "Session created", variant: "success" });
53
+ route.navigate({ type: "session", sessionId: result.record.sessionId });
54
+ } catch (err) {
55
+ logger.error("Failed to create session", {
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ setError(err instanceof Error ? err.message : String(err));
59
+ setCreating(false);
60
+ }
61
+ }
62
+
63
+ // Auto-create on mount
64
+ createSession();
65
+
66
+ useKeyboard((evt) => {
67
+ if (evt.name === "return" && error()) {
68
+ // Retry
69
+ createSession();
70
+ evt.preventDefault();
71
+ }
72
+ });
73
+
74
+ return (
75
+ <box flexDirection="column" gap={1}>
76
+ <text fg={theme().primary}>
77
+ <strong>New Session</strong>
78
+ </text>
79
+
80
+ {creating() && !error() ? (
81
+ <text fg={theme().textMuted}>Creating session...</text>
82
+ ) : error() ? (
83
+ <box flexDirection="column" gap={1}>
84
+ <text fg={theme().error}>Error: {error()}</text>
85
+ <text fg={theme().textMuted}>Press Enter to retry • Esc to cancel</text>
86
+ </box>
87
+ ) : null}
88
+ </box>
89
+ );
90
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Session List Dialog - Shows available sessions
3
+ */
4
+
5
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
6
+ import { createSignal, For, onMount, Show, createEffect } from "solid-js";
7
+ import { useTheme } from "../context/theme";
8
+ import { useAgentX } from "../context/agentx";
9
+ import { useDialog } from "../context/dialog";
10
+ import { useRoute } from "../context/route";
11
+ import type { ImageRecord } from "agentxjs";
12
+
13
+ export function DialogSessionList() {
14
+ const { theme } = useTheme();
15
+ const agentx = useAgentX();
16
+ const dialog = useDialog();
17
+ const route = useRoute();
18
+ const dimensions = useTerminalDimensions();
19
+
20
+ const [sessions, setSessions] = createSignal<ImageRecord[]>([]);
21
+ const [selected, setSelected] = createSignal(0);
22
+ const [loading, setLoading] = createSignal(true);
23
+ const [error, setError] = createSignal<string | null>(null);
24
+
25
+ // Load sessions
26
+ onMount(async () => {
27
+ if (!agentx.client) return;
28
+
29
+ try {
30
+ const result = await agentx.client.images.list();
31
+ setSessions(result.records);
32
+ } catch (err) {
33
+ setError(err instanceof Error ? err.message : String(err));
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ });
38
+
39
+ // Keyboard navigation
40
+ useKeyboard((evt) => {
41
+ const list = sessions();
42
+ if (list.length === 0) return;
43
+
44
+ if (evt.name === "up" || evt.name === "k") {
45
+ setSelected((s) => Math.max(0, s - 1));
46
+ evt.preventDefault();
47
+ } else if (evt.name === "down" || evt.name === "j") {
48
+ setSelected((s) => Math.min(list.length - 1, s + 1));
49
+ evt.preventDefault();
50
+ } else if (evt.name === "return") {
51
+ // Select session
52
+ const session = list[selected()];
53
+ if (session) {
54
+ dialog.clear();
55
+ route.navigate({ type: "session", sessionId: session.sessionId });
56
+ }
57
+ evt.preventDefault();
58
+ }
59
+ });
60
+
61
+ const maxItems = Math.min(10, dimensions().height - 10);
62
+
63
+ return (
64
+ <box flexDirection="column" gap={1}>
65
+ <text fg={theme().primary}>
66
+ <strong>Sessions</strong>
67
+ </text>
68
+
69
+ <Show when={loading()}>
70
+ <text fg={theme().textMuted}>Loading...</text>
71
+ </Show>
72
+
73
+ <Show when={error()}>
74
+ <text fg={theme().error}>Error: {error()}</text>
75
+ </Show>
76
+
77
+ <Show when={!loading() && !error() && sessions().length === 0}>
78
+ <text fg={theme().textMuted}>No sessions yet. Press Ctrl+N to create one.</text>
79
+ </Show>
80
+
81
+ <Show when={!loading() && sessions().length > 0}>
82
+ <box flexDirection="column">
83
+ <For each={sessions().slice(0, maxItems)}>
84
+ {(session, index) => (
85
+ <box flexDirection="row">
86
+ <text fg={index() === selected() ? theme().primary : theme().text}>
87
+ {index() === selected() ? "› " : " "}
88
+ {session.name || `Session ${session.imageId.slice(0, 8)}`}
89
+ </text>
90
+ </box>
91
+ )}
92
+ </For>
93
+ </box>
94
+
95
+ <box marginTop={1}>
96
+ <text fg={theme().textMuted}>↑/↓ navigate • Enter select • Esc close</text>
97
+ </box>
98
+ </Show>
99
+ </box>
100
+ );
101
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * AgentX Provider - Connect to AgentX server
3
+ */
4
+
5
+ import {
6
+ createContext,
7
+ useContext,
8
+ onMount,
9
+ onCleanup,
10
+ createSignal,
11
+ type ParentProps,
12
+ } from "solid-js";
13
+ import { createAgentX, type AgentX } from "agentxjs";
14
+
15
+ interface AgentXContext {
16
+ client: AgentX | null;
17
+ connected: () => boolean;
18
+ error: () => string | null;
19
+ reconnect: () => Promise<void>;
20
+ }
21
+
22
+ const AgentXCtx = createContext<AgentXContext>();
23
+
24
+ export function AgentXProvider(props: ParentProps<{ serverUrl: string }>) {
25
+ const [client, setClient] = createSignal<AgentX | null>(null);
26
+ const [connected, setConnected] = createSignal(false);
27
+ const [error, setError] = createSignal<string | null>(null);
28
+
29
+ async function connect() {
30
+ try {
31
+ setError(null);
32
+ const agentx = await createAgentX({
33
+ serverUrl: props.serverUrl,
34
+ autoReconnect: true,
35
+ });
36
+ setClient(agentx);
37
+ setConnected(true);
38
+ } catch (err) {
39
+ setError(err instanceof Error ? err.message : String(err));
40
+ setConnected(false);
41
+ }
42
+ }
43
+
44
+ onMount(() => {
45
+ connect();
46
+ });
47
+
48
+ onCleanup(() => {
49
+ client()?.dispose();
50
+ });
51
+
52
+ const ctx: AgentXContext = {
53
+ get client() {
54
+ return client();
55
+ },
56
+ connected,
57
+ error,
58
+ reconnect: connect,
59
+ };
60
+
61
+ return <AgentXCtx.Provider value={ctx}>{props.children}</AgentXCtx.Provider>;
62
+ }
63
+
64
+ export function useAgentX(): AgentXContext {
65
+ const ctx = useContext(AgentXCtx);
66
+ if (!ctx) {
67
+ throw new Error("useAgentX must be used within AgentXProvider");
68
+ }
69
+ return ctx;
70
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Dialog Provider - Modal stack management
3
+ */
4
+
5
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
6
+ import { createContext, useContext, Show, type ParentProps, type JSX } from "solid-js";
7
+ import { createStore } from "solid-js/store";
8
+ import { useTheme } from "./theme";
9
+ import { createLogger } from "commonxjs/logger";
10
+
11
+ const logger = createLogger("cli/dialog");
12
+
13
+ interface DialogEntry {
14
+ element: () => JSX.Element;
15
+ onClose?: () => void;
16
+ }
17
+
18
+ interface DialogContext {
19
+ stack: DialogEntry[];
20
+ show: (element: () => JSX.Element, onClose?: () => void) => void;
21
+ replace: (element: () => JSX.Element, onClose?: () => void) => void;
22
+ clear: () => void;
23
+ pop: () => void;
24
+ }
25
+
26
+ const DialogCtx = createContext<DialogContext>();
27
+
28
+ export function DialogProvider(props: ParentProps) {
29
+ const [store, setStore] = createStore<{ stack: DialogEntry[] }>({ stack: [] });
30
+ const { theme } = useTheme();
31
+ const dimensions = useTerminalDimensions();
32
+
33
+ useKeyboard((evt) => {
34
+ if (evt.name === "escape" && store.stack.length > 0) {
35
+ ctx.pop();
36
+ evt.preventDefault();
37
+ evt.stopPropagation();
38
+ }
39
+ });
40
+
41
+ const ctx: DialogContext = {
42
+ get stack() {
43
+ return store.stack;
44
+ },
45
+ show(element, onClose) {
46
+ logger.debug("dialog.show called", { currentStackSize: store.stack.length });
47
+ setStore("stack", [...store.stack, { element, onClose }]);
48
+ logger.debug("dialog.show completed", { newStackSize: store.stack.length });
49
+ },
50
+ replace(element, onClose) {
51
+ logger.debug("dialog.replace called");
52
+ for (const entry of store.stack) {
53
+ entry.onClose?.();
54
+ }
55
+ setStore("stack", [{ element, onClose }]);
56
+ },
57
+ clear() {
58
+ logger.debug("dialog.clear called", { stackSize: store.stack.length });
59
+ for (const entry of store.stack) {
60
+ entry.onClose?.();
61
+ }
62
+ setStore("stack", []);
63
+ },
64
+ pop() {
65
+ logger.debug("dialog.pop called", { stackSize: store.stack.length });
66
+ const current = store.stack.at(-1);
67
+ current?.onClose?.();
68
+ setStore("stack", store.stack.slice(0, -1));
69
+ },
70
+ };
71
+
72
+ return (
73
+ <DialogCtx.Provider value={ctx}>
74
+ {props.children}
75
+ <Show when={store.stack.length > 0}>
76
+ <box
77
+ position="absolute"
78
+ left={0}
79
+ top={0}
80
+ width={dimensions().width}
81
+ height={dimensions().height}
82
+ alignItems="center"
83
+ paddingTop={Math.floor(dimensions().height / 4)}
84
+ backgroundColor="rgba(0,0,0,0.6)"
85
+ >
86
+ <box
87
+ width={60}
88
+ maxWidth={dimensions().width - 4}
89
+ backgroundColor={theme().backgroundPanel}
90
+ paddingTop={1}
91
+ paddingBottom={1}
92
+ paddingLeft={2}
93
+ paddingRight={2}
94
+ >
95
+ {store.stack.at(-1)?.element()}
96
+ </box>
97
+ </box>
98
+ </Show>
99
+ </DialogCtx.Provider>
100
+ );
101
+ }
102
+
103
+ export function useDialog(): DialogContext {
104
+ const ctx = useContext(DialogCtx);
105
+ if (!ctx) {
106
+ throw new Error("useDialog must be used within DialogProvider");
107
+ }
108
+ return ctx;
109
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Exit Provider - Handle application exit
3
+ */
4
+
5
+ import { createContext, useContext, type ParentProps } from "solid-js";
6
+
7
+ type ExitFn = () => void;
8
+
9
+ const ExitContext = createContext<ExitFn>();
10
+
11
+ export function ExitProvider(props: ParentProps<{ onExit: () => void }>) {
12
+ return <ExitContext.Provider value={props.onExit}>{props.children}</ExitContext.Provider>;
13
+ }
14
+
15
+ export function useExit(): ExitFn {
16
+ const exit = useContext(ExitContext);
17
+ if (!exit) {
18
+ throw new Error("useExit must be used within ExitProvider");
19
+ }
20
+ return exit;
21
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Route Provider - Simple screen navigation
3
+ */
4
+
5
+ import { createContext, useContext, type ParentProps } from "solid-js";
6
+ import { createStore } from "solid-js/store";
7
+
8
+ export type RouteData = { type: "home" } | { type: "session"; sessionId: string };
9
+
10
+ interface RouteContext {
11
+ data: RouteData;
12
+ navigate: (route: RouteData) => void;
13
+ back: () => void;
14
+ }
15
+
16
+ const RouteCtx = createContext<RouteContext>();
17
+
18
+ export function RouteProvider(props: ParentProps) {
19
+ const [store, setStore] = createStore<{ data: RouteData; history: RouteData[] }>({
20
+ data: { type: "home" },
21
+ history: [],
22
+ });
23
+
24
+ const ctx: RouteContext = {
25
+ get data() {
26
+ return store.data;
27
+ },
28
+ navigate(route: RouteData) {
29
+ setStore("history", [...store.history, store.data]);
30
+ setStore("data", route);
31
+ },
32
+ back() {
33
+ const prev = store.history.at(-1);
34
+ if (prev) {
35
+ setStore("data", prev);
36
+ setStore("history", store.history.slice(0, -1));
37
+ }
38
+ },
39
+ };
40
+
41
+ return <RouteCtx.Provider value={ctx}>{props.children}</RouteCtx.Provider>;
42
+ }
43
+
44
+ export function useRoute(): RouteContext {
45
+ const ctx = useContext(RouteCtx);
46
+ if (!ctx) {
47
+ throw new Error("useRoute must be used within RouteProvider");
48
+ }
49
+ return ctx;
50
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Theme Provider - Terminal color theming
3
+ */
4
+
5
+ import { createContext, useContext, createMemo, type ParentProps } from "solid-js";
6
+ import { createStore } from "solid-js/store";
7
+
8
+ /**
9
+ * Theme colors
10
+ */
11
+ export interface ThemeColors {
12
+ primary: string;
13
+ secondary: string;
14
+ accent: string;
15
+ error: string;
16
+ warning: string;
17
+ success: string;
18
+ info: string;
19
+ text: string;
20
+ textMuted: string;
21
+ background: string;
22
+ backgroundPanel: string;
23
+ backgroundElement: string;
24
+ border: string;
25
+ borderActive: string;
26
+ }
27
+
28
+ /**
29
+ * Built-in themes
30
+ */
31
+ const THEMES: Record<string, ThemeColors> = {
32
+ opencode: {
33
+ primary: "#fab283",
34
+ secondary: "#c4a7e7",
35
+ accent: "#fab283",
36
+ error: "#eb6f92",
37
+ warning: "#f6c177",
38
+ success: "#9ccfd8",
39
+ info: "#31748f",
40
+ text: "#e0def4",
41
+ textMuted: "#6e6a86",
42
+ background: "#0a0a0a",
43
+ backgroundPanel: "#1a1a1a",
44
+ backgroundElement: "#2a2a2a",
45
+ border: "#3a3a3a",
46
+ borderActive: "#fab283",
47
+ },
48
+ dracula: {
49
+ primary: "#bd93f9",
50
+ secondary: "#ff79c6",
51
+ accent: "#8be9fd",
52
+ error: "#ff5555",
53
+ warning: "#ffb86c",
54
+ success: "#50fa7b",
55
+ info: "#8be9fd",
56
+ text: "#f8f8f2",
57
+ textMuted: "#6272a4",
58
+ background: "#282a36",
59
+ backgroundPanel: "#1e1f29",
60
+ backgroundElement: "#44475a",
61
+ border: "#44475a",
62
+ borderActive: "#bd93f9",
63
+ },
64
+ nord: {
65
+ primary: "#88c0d0",
66
+ secondary: "#81a1c1",
67
+ accent: "#8fbcbb",
68
+ error: "#bf616a",
69
+ warning: "#ebcb8b",
70
+ success: "#a3be8c",
71
+ info: "#5e81ac",
72
+ text: "#eceff4",
73
+ textMuted: "#4c566a",
74
+ background: "#2e3440",
75
+ backgroundPanel: "#3b4252",
76
+ backgroundElement: "#434c5e",
77
+ border: "#4c566a",
78
+ borderActive: "#88c0d0",
79
+ },
80
+ tokyonight: {
81
+ primary: "#7aa2f7",
82
+ secondary: "#bb9af7",
83
+ accent: "#7dcfff",
84
+ error: "#f7768e",
85
+ warning: "#e0af68",
86
+ success: "#9ece6a",
87
+ info: "#2ac3de",
88
+ text: "#c0caf5",
89
+ textMuted: "#565f89",
90
+ background: "#1a1b26",
91
+ backgroundPanel: "#24283b",
92
+ backgroundElement: "#414868",
93
+ border: "#414868",
94
+ borderActive: "#7aa2f7",
95
+ },
96
+ };
97
+
98
+ interface ThemeContext {
99
+ theme: () => ThemeColors;
100
+ themeName: () => string;
101
+ setTheme: (name: string) => void;
102
+ availableThemes: () => string[];
103
+ }
104
+
105
+ const ThemeCtx = createContext<ThemeContext>();
106
+
107
+ export function ThemeProvider(props: ParentProps<{ initialTheme?: string }>) {
108
+ const [store, setStore] = createStore({
109
+ active: props.initialTheme ?? "opencode",
110
+ });
111
+
112
+ const theme = createMemo(() => THEMES[store.active] ?? THEMES.opencode);
113
+
114
+ const ctx: ThemeContext = {
115
+ theme,
116
+ themeName: () => store.active,
117
+ setTheme: (name: string) => {
118
+ if (THEMES[name]) {
119
+ setStore("active", name);
120
+ }
121
+ },
122
+ availableThemes: () => Object.keys(THEMES),
123
+ };
124
+
125
+ return <ThemeCtx.Provider value={ctx}>{props.children}</ThemeCtx.Provider>;
126
+ }
127
+
128
+ export function useTheme(): ThemeContext {
129
+ const ctx = useContext(ThemeCtx);
130
+ if (!ctx) {
131
+ throw new Error("useTheme must be used within ThemeProvider");
132
+ }
133
+ return ctx;
134
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Toast Provider - Notification system
3
+ */
4
+
5
+ import { createContext, useContext, type ParentProps } from "solid-js";
6
+ import { createStore } from "solid-js/store";
7
+
8
+ export type ToastVariant = "info" | "success" | "warning" | "error";
9
+
10
+ export interface Toast {
11
+ id: string;
12
+ message: string;
13
+ variant: ToastVariant;
14
+ duration?: number;
15
+ }
16
+
17
+ interface ToastContext {
18
+ toasts: Toast[];
19
+ show: (options: { message: string; variant?: ToastVariant; duration?: number }) => void;
20
+ dismiss: (id: string) => void;
21
+ error: (err: unknown) => void;
22
+ }
23
+
24
+ const ToastCtx = createContext<ToastContext>();
25
+
26
+ let toastId = 0;
27
+
28
+ export function ToastProvider(props: ParentProps) {
29
+ const [store, setStore] = createStore<{ toasts: Toast[] }>({ toasts: [] });
30
+
31
+ const ctx: ToastContext = {
32
+ get toasts() {
33
+ return store.toasts;
34
+ },
35
+ show({ message, variant = "info", duration = 3000 }) {
36
+ const id = `toast-${++toastId}`;
37
+ const toast: Toast = { id, message, variant, duration };
38
+
39
+ setStore("toasts", [...store.toasts, toast]);
40
+
41
+ if (duration > 0) {
42
+ setTimeout(() => ctx.dismiss(id), duration);
43
+ }
44
+ },
45
+ dismiss(id: string) {
46
+ setStore(
47
+ "toasts",
48
+ store.toasts.filter((t) => t.id !== id)
49
+ );
50
+ },
51
+ error(err: unknown) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ ctx.show({ message, variant: "error", duration: 5000 });
54
+ },
55
+ };
56
+
57
+ return <ToastCtx.Provider value={ctx}>{props.children}</ToastCtx.Provider>;
58
+ }
59
+
60
+ export function useToast(): ToastContext {
61
+ const ctx = useContext(ToastCtx);
62
+ if (!ctx) {
63
+ throw new Error("useToast must be used within ToastProvider");
64
+ }
65
+ return ctx;
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * AgentX CLI - Terminal UI Client
3
+ *
4
+ * Starts an embedded server and connects to it automatically.
5
+ * If a server is already running on the port, connects to it instead.
6
+ * Use --server to connect to an external server.
7
+ */
8
+
9
+ import "dotenv/config";
10
+ import yargs from "yargs";
11
+ import { hideBin } from "yargs/helpers";
12
+ import { tui } from "./app";
13
+ import { createServer } from "@agentxjs/server";
14
+ import { nodePlatform, FileLoggerFactory } from "@agentxjs/node-platform";
15
+ import { createMonoDriver } from "@agentxjs/mono-driver";
16
+ import type { CreateDriver } from "@agentxjs/core/driver";
17
+ import { createLogger, setLoggerFactory } from "commonxjs/logger";
18
+ import { connect } from "net";
19
+
20
+ // Configure file logging early (before any logger is used)
21
+ const dataPath = process.env.DATA_PATH ?? "./.agentx";
22
+ const logDir = `${dataPath}/logs`;
23
+ setLoggerFactory(new FileLoggerFactory({ logDir, level: "debug" }));
24
+
25
+ const logger = createLogger("cli");
26
+
27
+ /**
28
+ * Check if a port is in use
29
+ */
30
+ async function isPortInUse(port: number): Promise<boolean> {
31
+ return new Promise((resolve) => {
32
+ const socket = connect(port, "127.0.0.1");
33
+ socket.on("connect", () => {
34
+ socket.destroy();
35
+ resolve(true);
36
+ });
37
+ socket.on("error", () => {
38
+ resolve(false);
39
+ });
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Start embedded server or connect to existing
45
+ */
46
+ async function getServerUrl(port: number): Promise<{ url: string; cleanup?: () => Promise<void> }> {
47
+ const url = `ws://127.0.0.1:${port}`;
48
+
49
+ // Check if server already running
50
+ if (await isPortInUse(port)) {
51
+ logger.info("Found existing server", { port });
52
+ return { url };
53
+ }
54
+
55
+ // Validate API key for starting new server
56
+ const apiKey = process.env.DEEPRACTICE_API_KEY;
57
+ if (!apiKey) {
58
+ console.error("Error: DEEPRACTICE_API_KEY environment variable is required");
59
+ console.error("");
60
+ console.error("Create a .env.local file with:");
61
+ console.error(" DEEPRACTICE_API_KEY=sk-xxx");
62
+ process.exit(1);
63
+ }
64
+
65
+ // Create driver factory that injects apiKey/baseUrl
66
+ const baseUrl = process.env.DEEPRACTICE_BASE_URL;
67
+ const wrappedCreateDriver: CreateDriver = (config) => {
68
+ return createMonoDriver({
69
+ ...config,
70
+ apiKey,
71
+ baseUrl,
72
+ options: { provider: "anthropic" },
73
+ });
74
+ };
75
+
76
+ const server = await createServer({
77
+ platform: nodePlatform({
78
+ dataPath,
79
+ // logDir already configured at startup
80
+ }),
81
+ createDriver: wrappedCreateDriver,
82
+ port,
83
+ host: "127.0.0.1",
84
+ });
85
+
86
+ await server.listen();
87
+
88
+ logger.info("Embedded server started", { port });
89
+
90
+ return {
91
+ url,
92
+ cleanup: async () => {
93
+ await server.dispose();
94
+ },
95
+ };
96
+ }
97
+
98
+ // Parse args and run
99
+ yargs(hideBin(process.argv))
100
+ .scriptName("agentx")
101
+ .usage("$0 [command] [options]")
102
+ .command(
103
+ ["$0", "chat"],
104
+ "Start interactive chat session",
105
+ (yargs) =>
106
+ yargs
107
+ .option("server", {
108
+ alias: "s",
109
+ type: "string",
110
+ description: "Connect to external AgentX server URL (default: start embedded server)",
111
+ })
112
+ .option("port", {
113
+ alias: "p",
114
+ type: "number",
115
+ default: 5200,
116
+ description: "Port for embedded server",
117
+ })
118
+ .option("theme", {
119
+ alias: "t",
120
+ type: "string",
121
+ description: "Theme name",
122
+ }),
123
+ async (args) => {
124
+ let serverUrl = args.server;
125
+ let cleanup: (() => Promise<void>) | undefined;
126
+
127
+ // If no external server specified, use embedded or existing server
128
+ if (!serverUrl) {
129
+ const result = await getServerUrl(args.port);
130
+ serverUrl = result.url;
131
+ cleanup = result.cleanup;
132
+ }
133
+
134
+ try {
135
+ await tui({
136
+ serverUrl,
137
+ theme: args.theme,
138
+ });
139
+ } finally {
140
+ if (cleanup) {
141
+ await cleanup();
142
+ }
143
+ }
144
+ }
145
+ )
146
+ .help()
147
+ .parseAsync();
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Home Screen - Welcome and connection status
3
+ */
4
+
5
+ import { useTerminalDimensions } from "@opentui/solid";
6
+ import { Show } from "solid-js";
7
+ import { useTheme } from "../context/theme";
8
+ import { useAgentX } from "../context/agentx";
9
+
10
+ export function Home() {
11
+ const dimensions = useTerminalDimensions();
12
+ const { theme } = useTheme();
13
+ const agentx = useAgentX();
14
+
15
+ return (
16
+ <box width={dimensions().width} height={dimensions().height} flexDirection="column" padding={2}>
17
+ {/* Header */}
18
+ <box flexDirection="row" gap={1}>
19
+ <text fg={theme().primary}>
20
+ <strong>AgentX</strong>
21
+ <span style={{ fg: theme().textMuted }}> Terminal UI</span>
22
+ </text>
23
+ </box>
24
+
25
+ {/* Connection Status */}
26
+ <box marginTop={1}>
27
+ <Show
28
+ when={agentx.connected()}
29
+ fallback={
30
+ <Show when={agentx.error()} fallback={<text fg={theme().warning}>Connecting...</text>}>
31
+ <text fg={theme().error}>Error: {agentx.error()}</text>
32
+ </Show>
33
+ }
34
+ >
35
+ <text fg={theme().success}>Connected to server</text>
36
+ </Show>
37
+ </box>
38
+
39
+ {/* Instructions */}
40
+ <box marginTop={2} flexDirection="column" gap={1}>
41
+ <text fg={theme().textMuted}>Keyboard shortcuts:</text>
42
+ <text fg={theme().text}> Ctrl+C Exit</text>
43
+ <text fg={theme().text}> Ctrl+N New session</text>
44
+ <text fg={theme().text}> Ctrl+L List sessions</text>
45
+ </box>
46
+
47
+ {/* Footer */}
48
+ <box position="absolute" bottom={1} left={2}>
49
+ <text fg={theme().textMuted}>Press Ctrl+C to exit</text>
50
+ </box>
51
+ </box>
52
+ );
53
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Session View - Chat session interface
3
+ */
4
+
5
+ import { useTerminalDimensions, useKeyboard } from "@opentui/solid";
6
+ import { createSignal, onMount, Show, For, createEffect } from "solid-js";
7
+ import { useTheme } from "../context/theme";
8
+ import { useAgentX } from "../context/agentx";
9
+ import { useRoute } from "../context/route";
10
+ import { useToast } from "../context/toast";
11
+ import { createLogger } from "commonxjs/logger";
12
+
13
+ const logger = createLogger("cli/session");
14
+
15
+ export interface SessionViewProps {
16
+ sessionId: string;
17
+ }
18
+
19
+ export function SessionView(props: SessionViewProps) {
20
+ const dimensions = useTerminalDimensions();
21
+ const { theme } = useTheme();
22
+ const agentx = useAgentX();
23
+ const route = useRoute();
24
+ const toast = useToast();
25
+
26
+ const [input, setInput] = createSignal("");
27
+ const [messages, setMessages] = createSignal<Array<{ role: string; content: string }>>([]);
28
+ const [loading, setLoading] = createSignal(false);
29
+ const [agentId, setAgentId] = createSignal<string | null>(null);
30
+ const [streamingText, setStreamingText] = createSignal("");
31
+
32
+ // Create agent on mount
33
+ onMount(async () => {
34
+ logger.info("SessionView mounted", { sessionId: props.sessionId });
35
+ if (!agentx.client) {
36
+ logger.warn("No agentx client");
37
+ return;
38
+ }
39
+
40
+ try {
41
+ // Get image by session ID
42
+ logger.info("Fetching images...");
43
+ const images = await agentx.client.images.list();
44
+ const image = images.records.find((r) => r.sessionId === props.sessionId);
45
+
46
+ if (!image) {
47
+ logger.error("Session not found", { sessionId: props.sessionId });
48
+ toast.error(new Error("Session not found"));
49
+ route.back();
50
+ return;
51
+ }
52
+
53
+ // Create agent
54
+ logger.info("Creating agent...", { imageId: image.imageId });
55
+ const result = await agentx.client.agents.create({ imageId: image.imageId });
56
+ setAgentId(result.agentId);
57
+ logger.info("Agent created", { agentId: result.agentId });
58
+
59
+ // Subscribe to events
60
+ agentx.client.on("text_delta", (event) => {
61
+ const data = event.data as { text: string };
62
+ setStreamingText((s) => s + data.text);
63
+ });
64
+
65
+ agentx.client.on("assistant_message", (event) => {
66
+ const data = event.data as { content: string };
67
+ logger.info("Assistant message received", { contentLength: data.content.length });
68
+ setMessages((m) => [...m, { role: "assistant", content: data.content }]);
69
+ setStreamingText("");
70
+ setLoading(false);
71
+ });
72
+ } catch (err) {
73
+ logger.error("Failed to initialize session", {
74
+ error: err instanceof Error ? err.message : String(err),
75
+ });
76
+ toast.error(err);
77
+ }
78
+ });
79
+
80
+ // Handle input
81
+ useKeyboard((evt) => {
82
+ if (loading()) return;
83
+
84
+ if (evt.name === "return" && input().trim()) {
85
+ sendMessage();
86
+ evt.preventDefault();
87
+ } else if (evt.name === "backspace") {
88
+ setInput((s) => s.slice(0, -1));
89
+ evt.preventDefault();
90
+ } else if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
91
+ // Single character input
92
+ setInput((s) => s + evt.name);
93
+ evt.preventDefault();
94
+ } else if (evt.name === "space") {
95
+ setInput((s) => s + " ");
96
+ evt.preventDefault();
97
+ }
98
+ });
99
+
100
+ async function sendMessage() {
101
+ const content = input().trim();
102
+ logger.info("sendMessage called", { content, hasClient: !!agentx.client, agentId: agentId() });
103
+ if (!content || !agentx.client || !agentId()) return;
104
+
105
+ setInput("");
106
+ setMessages((m) => [...m, { role: "user", content }]);
107
+ setLoading(true);
108
+ setStreamingText("");
109
+
110
+ try {
111
+ logger.info("Sending message to agent...");
112
+ await agentx.client.sessions.send(agentId()!, content);
113
+ logger.info("Message sent");
114
+ } catch (err) {
115
+ logger.error("Failed to send message", {
116
+ error: err instanceof Error ? err.message : String(err),
117
+ });
118
+ toast.error(err);
119
+ setLoading(false);
120
+ }
121
+ }
122
+
123
+ const visibleHeight = dimensions().height - 6; // Header + input area
124
+
125
+ return (
126
+ <box width={dimensions().width} height={dimensions().height} flexDirection="column">
127
+ {/* Header */}
128
+ <box padding={1} flexDirection="row" gap={2}>
129
+ <text fg={theme().primary}>
130
+ <strong>Chat</strong>
131
+ </text>
132
+ <text fg={theme().textMuted}>Session: {props.sessionId.slice(0, 8)}...</text>
133
+ <text fg={theme().textMuted}>(Esc to go back)</text>
134
+ </box>
135
+
136
+ {/* Messages */}
137
+ <box flexGrow={1} flexDirection="column" padding={1}>
138
+ <For each={messages().slice(-Math.floor(visibleHeight / 2))}>
139
+ {(msg) => (
140
+ <box marginBottom={1}>
141
+ <text fg={msg.role === "user" ? theme().primary : theme().text}>
142
+ <strong>{msg.role === "user" ? "You" : "AI"}:</strong> {msg.content}
143
+ </text>
144
+ </box>
145
+ )}
146
+ </For>
147
+
148
+ <Show when={streamingText()}>
149
+ <box marginBottom={1}>
150
+ <text fg={theme().text}>
151
+ <strong>AI:</strong> {streamingText()}
152
+ <span style={{ fg: theme().textMuted }}>▊</span>
153
+ </text>
154
+ </box>
155
+ </Show>
156
+
157
+ <Show when={loading() && !streamingText()}>
158
+ <text fg={theme().textMuted}>Thinking...</text>
159
+ </Show>
160
+ </box>
161
+
162
+ {/* Input */}
163
+ <box padding={1} border borderColor={theme().border}>
164
+ <text fg={theme().text}>
165
+ <span style={{ fg: theme().primary }}>›</span> {input()}
166
+ <span style={{ fg: theme().textMuted }}>▊</span>
167
+ </text>
168
+ </box>
169
+ </box>
170
+ );
171
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "jsx": "preserve",
7
+ "jsxImportSource": "@opentui/solid",
8
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
9
+ "types": ["bun-types"],
10
+ "noUnusedLocals": false,
11
+ "noUnusedParameters": false
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }