@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 +12 -0
- package/bin/agentx +2 -0
- package/bunfig.toml +4 -0
- package/package.json +32 -0
- package/src/app.tsx +180 -0
- package/src/component/dialog-new-session.tsx +90 -0
- package/src/component/dialog-session-list.tsx +101 -0
- package/src/context/agentx.tsx +70 -0
- package/src/context/dialog.tsx +109 -0
- package/src/context/exit.tsx +21 -0
- package/src/context/route.tsx +50 -0
- package/src/context/theme.tsx +134 -0
- package/src/context/toast.tsx +66 -0
- package/src/index.ts +147 -0
- package/src/routes/home.tsx +53 -0
- package/src/routes/session.tsx +171 -0
- package/tsconfig.json +15 -0
package/CHANGELOG.md
ADDED
package/bin/agentx
ADDED
package/bunfig.toml
ADDED
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
|
+
}
|