@hoox-sh/hoox-tui 0.2.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/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for AiChatView — export surface, history sanitization, caps.
|
|
8
|
+
* Persistence uses file-backed tui-storage (see tui-storage.test.ts);
|
|
9
|
+
* full render/SSE coverage is out of scope for this unit file.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
AiChatView,
|
|
14
|
+
sanitizeChatMessage,
|
|
15
|
+
MAX_STORED_MESSAGES,
|
|
16
|
+
MAX_MESSAGE_CHARS,
|
|
17
|
+
} from "./ai-chat";
|
|
18
|
+
import { TuiStateFiles } from "../../services/tui-storage";
|
|
19
|
+
|
|
20
|
+
describe("AiChatView", () => {
|
|
21
|
+
it("is a function component", () => {
|
|
22
|
+
expect(AiChatView).toBeInstanceOf(Function);
|
|
23
|
+
expect(AiChatView.name).toBe("AiChatView");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("runs in environments without localStorage", () => {
|
|
27
|
+
// Regression: CLEAR HISTORY used bare localStorage and threw ReferenceError
|
|
28
|
+
expect(typeof globalThis.localStorage).toBe("undefined");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("persists history under the chat-history state file", () => {
|
|
32
|
+
expect(TuiStateFiles.chatHistory).toBe("chat-history.json");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("caps stored messages and message body size", () => {
|
|
36
|
+
expect(MAX_STORED_MESSAGES).toBe(100);
|
|
37
|
+
expect(MAX_MESSAGE_CHARS).toBe(8_000);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("sanitizeChatMessage", () => {
|
|
42
|
+
it("redacts bearer tokens from user content before persist", () => {
|
|
43
|
+
const out = sanitizeChatMessage({
|
|
44
|
+
role: "user",
|
|
45
|
+
content: "Use Authorization: Bearer sk-super-secret-value-here please",
|
|
46
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
47
|
+
});
|
|
48
|
+
expect(out.content).not.toContain("sk-super-secret");
|
|
49
|
+
expect(out.content.toLowerCase()).toContain("redacted");
|
|
50
|
+
expect(out.role).toBe("user");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("redacts HOOX_API_TOKEN-style assignments", () => {
|
|
54
|
+
const out = sanitizeChatMessage({
|
|
55
|
+
role: "user",
|
|
56
|
+
content: "export HOOX_API_TOKEN=my-cleartext-token-12345",
|
|
57
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
58
|
+
});
|
|
59
|
+
expect(out.content).not.toContain("my-cleartext-token");
|
|
60
|
+
expect(out.content).toContain("HOOX_API_TOKEN=[redacted]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("truncates oversized message bodies", () => {
|
|
64
|
+
const out = sanitizeChatMessage({
|
|
65
|
+
role: "assistant",
|
|
66
|
+
content: "x".repeat(MAX_MESSAGE_CHARS + 500),
|
|
67
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
68
|
+
});
|
|
69
|
+
expect(out.content.length).toBeLessThanOrEqual(MAX_MESSAGE_CHARS);
|
|
70
|
+
expect(out.content.endsWith("…")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves short non-secret messages", () => {
|
|
74
|
+
const out = sanitizeChatMessage({
|
|
75
|
+
role: "assistant",
|
|
76
|
+
content: "Hello, how can I help?",
|
|
77
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
78
|
+
});
|
|
79
|
+
expect(out.content).toBe("Hello, how can I help?");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
/**
|
|
8
|
+
* AI Chat View — SSE streaming chat interface with the AI agent.
|
|
9
|
+
*
|
|
10
|
+
* Layout:
|
|
11
|
+
* ┌─────────────────────────────────────────────────┐
|
|
12
|
+
* │ AI CHAT [Model Selector ▼] │
|
|
13
|
+
* │─────────────────────────────────────────────────│
|
|
14
|
+
* │ [user] Hello 10:30 │
|
|
15
|
+
* │ [bot] Hi! How can I help? 10:30 │
|
|
16
|
+
* │ │
|
|
17
|
+
* │ (streaming response appears here token-by-token)│
|
|
18
|
+
* │ │
|
|
19
|
+
* │─────────────────────────────────────────────────│
|
|
20
|
+
* │ [Enter to send · Shift+Enter for newline] │
|
|
21
|
+
* │ ┌───────────────────────────────────────────┐ │
|
|
22
|
+
* │ │ Type your message... │ │
|
|
23
|
+
* │ └───────────────────────────────────────────┘ │
|
|
24
|
+
* └─────────────────────────────────────────────────┘
|
|
25
|
+
*
|
|
26
|
+
* Pattern established for the TUI feature-parity batch:
|
|
27
|
+
* - Pure function component, no props required
|
|
28
|
+
* - Subscribes to useUIStore (so SSE can disconnect when not active)
|
|
29
|
+
* - Uses agentChatStream() for SSE streaming
|
|
30
|
+
* - Wraps content in <ErrorBoundary viewName="AI Chat">
|
|
31
|
+
* - Chat history persisted under $HOME/.hoox/.tui-state/ (last 100 messages)
|
|
32
|
+
* - Loading states and error handling for SSE failures
|
|
33
|
+
* - Keyboard: Enter to send, Shift+Enter for newline
|
|
34
|
+
*/
|
|
35
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
36
|
+
import {
|
|
37
|
+
Colors,
|
|
38
|
+
ConnectionStatusColor,
|
|
39
|
+
useUIStore,
|
|
40
|
+
} from "@hoox-sh/hoox-shared";
|
|
41
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
42
|
+
import { ViewHeader } from "../shared/view-header";
|
|
43
|
+
import {
|
|
44
|
+
agentChatStream,
|
|
45
|
+
type ChatMessage,
|
|
46
|
+
AI_MODEL_OPTIONS,
|
|
47
|
+
type AiModelOption,
|
|
48
|
+
} from "../../services/cli-bridge";
|
|
49
|
+
import {
|
|
50
|
+
readJsonState,
|
|
51
|
+
removeJsonState,
|
|
52
|
+
TuiStateFiles,
|
|
53
|
+
writeJsonState,
|
|
54
|
+
} from "../../services/tui-storage";
|
|
55
|
+
import { redactSecretsInText } from "../../services/dev-log";
|
|
56
|
+
|
|
57
|
+
/** Maximum messages to retain on disk / in memory. */
|
|
58
|
+
export const MAX_STORED_MESSAGES = 100;
|
|
59
|
+
/** Cap single message body to avoid huge history files. */
|
|
60
|
+
export const MAX_MESSAGE_CHARS = 8_000;
|
|
61
|
+
/** Sentinel displayed while awaiting first token. */
|
|
62
|
+
const STREAMING_INDICATOR = "…";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scrub secret-like patterns and cap length before persist/display.
|
|
66
|
+
* Chat history must never store raw API tokens the user pasted.
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeChatMessage(message: ChatMessage): ChatMessage {
|
|
69
|
+
let content =
|
|
70
|
+
typeof message.content === "string"
|
|
71
|
+
? message.content
|
|
72
|
+
: String(message.content ?? "");
|
|
73
|
+
content = redactSecretsInText(content);
|
|
74
|
+
if (content.length > MAX_MESSAGE_CHARS) {
|
|
75
|
+
content = content.slice(0, MAX_MESSAGE_CHARS - 1) + "…";
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
role: message.role,
|
|
79
|
+
content,
|
|
80
|
+
timestamp:
|
|
81
|
+
typeof message.timestamp === "string" && message.timestamp.length > 0
|
|
82
|
+
? message.timestamp
|
|
83
|
+
: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sanitizeChatHistory(messages: ChatMessage[]): ChatMessage[] {
|
|
88
|
+
return messages
|
|
89
|
+
.filter(
|
|
90
|
+
(m) =>
|
|
91
|
+
m &&
|
|
92
|
+
(m.role === "user" || m.role === "assistant") &&
|
|
93
|
+
typeof m.content === "string"
|
|
94
|
+
)
|
|
95
|
+
.map(sanitizeChatMessage)
|
|
96
|
+
.slice(-MAX_STORED_MESSAGES);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Format an ISO timestamp as HH:MM for chat timestamps. */
|
|
100
|
+
function formatTime(iso: string): string {
|
|
101
|
+
const ms = Date.parse(iso);
|
|
102
|
+
if (Number.isNaN(ms)) return "—";
|
|
103
|
+
const d = new Date(ms);
|
|
104
|
+
const hh = d.getHours().toString().padStart(2, "0");
|
|
105
|
+
const mm = d.getMinutes().toString().padStart(2, "0");
|
|
106
|
+
return `${hh}:${mm}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Sub-component: Chat Message Row ─────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
interface MessageRowProps {
|
|
112
|
+
message: ChatMessage;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function MessageRow({ message }: MessageRowProps) {
|
|
116
|
+
const isUser = message.role === "user";
|
|
117
|
+
const roleLabel = isUser ? "USER" : "BOT";
|
|
118
|
+
const roleColor = isUser ? Colors.accent : Colors.info;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<box
|
|
122
|
+
flexDirection="row"
|
|
123
|
+
gap={1}
|
|
124
|
+
paddingLeft={1}
|
|
125
|
+
paddingRight={1}
|
|
126
|
+
paddingTop={0}
|
|
127
|
+
paddingBottom={0}
|
|
128
|
+
>
|
|
129
|
+
<text fg={roleColor} bold dim={isUser}>
|
|
130
|
+
[{roleLabel}]
|
|
131
|
+
</text>
|
|
132
|
+
<text fg={isUser ? Colors.foreground : Colors["muted-foreground"]}>
|
|
133
|
+
{message.content}
|
|
134
|
+
</text>
|
|
135
|
+
<text fg={Colors.muted} dim>
|
|
136
|
+
{formatTime(message.timestamp)}
|
|
137
|
+
</text>
|
|
138
|
+
</box>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Sub-component: Streaming Assistant Row ───────────────────────────────────
|
|
143
|
+
|
|
144
|
+
interface StreamingRowProps {
|
|
145
|
+
content: string;
|
|
146
|
+
active: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function StreamingRow({ content, active }: StreamingRowProps) {
|
|
150
|
+
const displayText = content.length > 0 ? content : STREAMING_INDICATOR;
|
|
151
|
+
return (
|
|
152
|
+
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
|
153
|
+
<text fg={Colors.info} bold dim>
|
|
154
|
+
[BOT]
|
|
155
|
+
</text>
|
|
156
|
+
<text fg={Colors["muted-foreground"]}>{displayText}</text>
|
|
157
|
+
{active && (
|
|
158
|
+
<text fg={Colors.accent} dim>
|
|
159
|
+
▌
|
|
160
|
+
</text>
|
|
161
|
+
)}
|
|
162
|
+
</box>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Sub-component: Model Selector ───────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
interface ModelSelectorProps {
|
|
169
|
+
selected: AiModelOption;
|
|
170
|
+
onChange: (model: AiModelOption) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ModelSelector({ selected, onChange }: ModelSelectorProps) {
|
|
174
|
+
const [open, setOpen] = useState(false);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<box flexDirection="column">
|
|
178
|
+
<text fg={Colors.accent} bold dim onMouseUp={() => setOpen((o) => !o)}>
|
|
179
|
+
[{selected.label.toUpperCase()} ▼]
|
|
180
|
+
</text>
|
|
181
|
+
{open && (
|
|
182
|
+
<box
|
|
183
|
+
flexDirection="column"
|
|
184
|
+
border={true}
|
|
185
|
+
borderStyle="single"
|
|
186
|
+
borderColor={Colors.border}
|
|
187
|
+
backgroundColor={Colors.card}
|
|
188
|
+
>
|
|
189
|
+
{AI_MODEL_OPTIONS.map((opt) => (
|
|
190
|
+
<text
|
|
191
|
+
key={opt.id}
|
|
192
|
+
fg={opt.id === selected.id ? Colors.accent : Colors.foreground}
|
|
193
|
+
bold={opt.id === selected.id}
|
|
194
|
+
dim={opt.id === selected.id}
|
|
195
|
+
onMouseUp={() => {
|
|
196
|
+
onChange(opt);
|
|
197
|
+
setOpen(false);
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{opt.id === selected.id ? "▸ " : " "}
|
|
201
|
+
{opt.label}
|
|
202
|
+
</text>
|
|
203
|
+
))}
|
|
204
|
+
</box>
|
|
205
|
+
)}
|
|
206
|
+
</box>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Main AI Chat View ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* AiChatView — SSE streaming chat with the AI agent.
|
|
214
|
+
*
|
|
215
|
+
* Pattern for TUI AI chat:
|
|
216
|
+
* 1. Pure function component, no required props
|
|
217
|
+
* 2. Subscribes to `useUIStore.activeView` to disconnect SSE when not active
|
|
218
|
+
* 3. Uses `agentChatStream()` for SSE streaming
|
|
219
|
+
* 4. Wraps in <ErrorBoundary viewName="AI Chat">
|
|
220
|
+
* 5. Chat history persisted under $HOME/.hoox/.tui-state/ (last 100 messages)
|
|
221
|
+
* 6. Explicit empty/error states instead of throwing
|
|
222
|
+
*/
|
|
223
|
+
export function AiChatView() {
|
|
224
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
225
|
+
const isActive = activeView === "ai-chat";
|
|
226
|
+
|
|
227
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
228
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
229
|
+
const [historyReady, setHistoryReady] = useState(false);
|
|
230
|
+
const [inputText, setInputText] = useState("");
|
|
231
|
+
const [selectedModel, setSelectedModel] = useState<AiModelOption>(
|
|
232
|
+
AI_MODEL_OPTIONS[0]
|
|
233
|
+
);
|
|
234
|
+
const [streamingContent, setStreamingContent] = useState("");
|
|
235
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
236
|
+
const [error, setError] = useState<string | null>(null);
|
|
237
|
+
const [streamStatus, setStreamStatus] = useState<
|
|
238
|
+
"connected" | "reconnecting" | "disconnected" | null
|
|
239
|
+
>(null);
|
|
240
|
+
|
|
241
|
+
// Ref to track current stream's AbortController for cleanup
|
|
242
|
+
const currentStreamRef = useRef<AbortController | null>(null);
|
|
243
|
+
const scrollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
244
|
+
/** Two-step confirm for destructive clear-history action. */
|
|
245
|
+
const [confirmClear, setConfirmClear] = useState(false);
|
|
246
|
+
|
|
247
|
+
// ── Load chat history from disk on mount ───────────────────────────────────
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
let cancelled = false;
|
|
250
|
+
void (async () => {
|
|
251
|
+
const stored = await readJsonState<ChatMessage[]>(
|
|
252
|
+
TuiStateFiles.chatHistory,
|
|
253
|
+
[]
|
|
254
|
+
);
|
|
255
|
+
if (cancelled) return;
|
|
256
|
+
if (Array.isArray(stored)) {
|
|
257
|
+
setMessages(sanitizeChatHistory(stored));
|
|
258
|
+
}
|
|
259
|
+
setHistoryReady(true);
|
|
260
|
+
})();
|
|
261
|
+
return () => {
|
|
262
|
+
cancelled = true;
|
|
263
|
+
};
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
// ── Persist messages to disk whenever they change (after initial load) ────
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!historyReady) return;
|
|
269
|
+
const trimmed = sanitizeChatHistory(messages);
|
|
270
|
+
void writeJsonState(TuiStateFiles.chatHistory, trimmed);
|
|
271
|
+
}, [messages, historyReady]);
|
|
272
|
+
|
|
273
|
+
// ── SSE disconnect when view is not active ────────────────────────────────
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (!isActive && currentStreamRef.current) {
|
|
276
|
+
currentStreamRef.current.abort();
|
|
277
|
+
currentStreamRef.current = null;
|
|
278
|
+
setIsStreaming(false);
|
|
279
|
+
setStreamingContent("");
|
|
280
|
+
}
|
|
281
|
+
}, [isActive]);
|
|
282
|
+
|
|
283
|
+
// ── Abort stream + timers on unmount (fail-safe) ──────────────────────────
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
return () => {
|
|
286
|
+
if (currentStreamRef.current) {
|
|
287
|
+
currentStreamRef.current.abort();
|
|
288
|
+
currentStreamRef.current = null;
|
|
289
|
+
}
|
|
290
|
+
if (scrollRef.current) {
|
|
291
|
+
clearTimeout(scrollRef.current);
|
|
292
|
+
scrollRef.current = null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
// ── Scroll to bottom on new content ───────────────────────────────────────
|
|
298
|
+
// We use a ref-based timeout to batch scroll updates during streaming
|
|
299
|
+
const scheduleScroll = useCallback(() => {
|
|
300
|
+
if (scrollRef.current) clearTimeout(scrollRef.current);
|
|
301
|
+
scrollRef.current = setTimeout(() => {
|
|
302
|
+
// OpenTUI scrollbox auto-scrolls; no explicit scroll needed
|
|
303
|
+
}, 50);
|
|
304
|
+
}, []);
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
scheduleScroll();
|
|
308
|
+
}, [messages, streamingContent, scheduleScroll]);
|
|
309
|
+
|
|
310
|
+
// ── Send message ───────────────────────────────────────────────────────────
|
|
311
|
+
const sendMessage = useCallback(async () => {
|
|
312
|
+
const trimmed = inputText.trim();
|
|
313
|
+
if (!trimmed || isStreaming) return;
|
|
314
|
+
|
|
315
|
+
const userMsg = sanitizeChatMessage({
|
|
316
|
+
role: "user",
|
|
317
|
+
content: trimmed,
|
|
318
|
+
timestamp: new Date().toISOString(),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Append user message immediately
|
|
322
|
+
setMessages((prev) => sanitizeChatHistory([...prev, userMsg]));
|
|
323
|
+
setInputText("");
|
|
324
|
+
setStreamingContent("");
|
|
325
|
+
setIsStreaming(true);
|
|
326
|
+
setError(null);
|
|
327
|
+
setConfirmClear(false);
|
|
328
|
+
|
|
329
|
+
// Build the messages array for the API (last MAX_STORED_MESSAGES)
|
|
330
|
+
const allMessages = sanitizeChatHistory([...messages, userMsg]);
|
|
331
|
+
|
|
332
|
+
// Abort any existing stream
|
|
333
|
+
if (currentStreamRef.current) {
|
|
334
|
+
currentStreamRef.current.abort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let assistantContent = "";
|
|
338
|
+
|
|
339
|
+
const { abort: streamAbort, finished } = agentChatStream(
|
|
340
|
+
{
|
|
341
|
+
messages: allMessages,
|
|
342
|
+
model: selectedModel.id,
|
|
343
|
+
temperature: 0.7,
|
|
344
|
+
maxTokens: 500,
|
|
345
|
+
},
|
|
346
|
+
process.env.HOOX_API_URL ?? "http://localhost:8787",
|
|
347
|
+
process.env.HOOX_API_TOKEN ?? "",
|
|
348
|
+
(token: string) => {
|
|
349
|
+
assistantContent += token;
|
|
350
|
+
setStreamingContent(assistantContent);
|
|
351
|
+
},
|
|
352
|
+
(status: "connected" | "reconnecting" | "disconnected") => {
|
|
353
|
+
setStreamStatus(status);
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Store the stream's AbortController for cancellation
|
|
358
|
+
currentStreamRef.current = streamAbort;
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
await finished;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
364
|
+
// User cancelled — expected
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const msg = err instanceof Error ? err.message : "Chat stream failed";
|
|
368
|
+
setError(msg);
|
|
369
|
+
} finally {
|
|
370
|
+
if (!streamAbort.signal.aborted) {
|
|
371
|
+
// Stream completed normally — save assistant message (scrubbed)
|
|
372
|
+
if (assistantContent.length > 0) {
|
|
373
|
+
const assistantMsg = sanitizeChatMessage({
|
|
374
|
+
role: "assistant",
|
|
375
|
+
content: assistantContent,
|
|
376
|
+
timestamp: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
setMessages((prev) => sanitizeChatHistory([...prev, assistantMsg]));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
setIsStreaming(false);
|
|
382
|
+
setStreamingContent("");
|
|
383
|
+
currentStreamRef.current = null;
|
|
384
|
+
}
|
|
385
|
+
}, [inputText, isStreaming, messages, selectedModel]);
|
|
386
|
+
|
|
387
|
+
// ── Handle Enter key in text area ──────────────────────────────────────────
|
|
388
|
+
// In OpenTUI, textarea onKeyDown receives keyboard events.
|
|
389
|
+
// Enter without Shift = send; Enter with Shift = newline (no-op here).
|
|
390
|
+
const handleInputKeyDown = useCallback(
|
|
391
|
+
(event: { key: string; shiftKey: boolean; preventDefault: () => void }) => {
|
|
392
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
void sendMessage();
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
[sendMessage]
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// ── Auto-focus input when view becomes active ──────────────────────────────
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (isActive) {
|
|
403
|
+
// The input field would be focused via ref in browser — in OpenTUI
|
|
404
|
+
// we just ensure the view renders with the input visible (no explicit focus API)
|
|
405
|
+
}
|
|
406
|
+
}, [isActive]);
|
|
407
|
+
|
|
408
|
+
// ── Derived state ───────────────────────────────────────────────────────────
|
|
409
|
+
const statusColor =
|
|
410
|
+
streamStatus === "connected"
|
|
411
|
+
? ConnectionStatusColor.connected
|
|
412
|
+
: streamStatus === "reconnecting"
|
|
413
|
+
? ConnectionStatusColor.reconnecting
|
|
414
|
+
: streamStatus === "disconnected"
|
|
415
|
+
? ConnectionStatusColor.offline
|
|
416
|
+
: Colors.muted;
|
|
417
|
+
|
|
418
|
+
const statusLabel =
|
|
419
|
+
streamStatus === "connected"
|
|
420
|
+
? "◉ connected"
|
|
421
|
+
: streamStatus === "reconnecting"
|
|
422
|
+
? "◉ reconnecting"
|
|
423
|
+
: streamStatus === "disconnected"
|
|
424
|
+
? "! disconnected"
|
|
425
|
+
: "";
|
|
426
|
+
|
|
427
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
428
|
+
return (
|
|
429
|
+
<ErrorBoundary viewName="AI Chat">
|
|
430
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={1}>
|
|
431
|
+
<ViewHeader
|
|
432
|
+
title="AI CHAT"
|
|
433
|
+
showDivider={false}
|
|
434
|
+
meta={
|
|
435
|
+
<box flexDirection="row" gap={2} alignItems="center">
|
|
436
|
+
{statusLabel ? (
|
|
437
|
+
<text fg={statusColor} dim>
|
|
438
|
+
{statusLabel}
|
|
439
|
+
</text>
|
|
440
|
+
) : null}
|
|
441
|
+
<ModelSelector
|
|
442
|
+
selected={selectedModel}
|
|
443
|
+
onChange={setSelectedModel}
|
|
444
|
+
/>
|
|
445
|
+
</box>
|
|
446
|
+
}
|
|
447
|
+
/>
|
|
448
|
+
|
|
449
|
+
{/* Error banner */}
|
|
450
|
+
{error && (
|
|
451
|
+
<box
|
|
452
|
+
flexDirection="row"
|
|
453
|
+
gap={1}
|
|
454
|
+
border={true}
|
|
455
|
+
borderStyle="single"
|
|
456
|
+
borderColor={Colors.error}
|
|
457
|
+
paddingLeft={1}
|
|
458
|
+
paddingRight={1}
|
|
459
|
+
>
|
|
460
|
+
<text fg={Colors.error} bold>
|
|
461
|
+
!
|
|
462
|
+
</text>
|
|
463
|
+
<text fg={Colors.error}>
|
|
464
|
+
{error.length > 70 ? error.slice(0, 67) + "…" : error}
|
|
465
|
+
</text>
|
|
466
|
+
</box>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{/* Message list */}
|
|
470
|
+
<box
|
|
471
|
+
flexDirection="column"
|
|
472
|
+
flexGrow={1}
|
|
473
|
+
border={true}
|
|
474
|
+
borderStyle="single"
|
|
475
|
+
borderColor={Colors.border}
|
|
476
|
+
>
|
|
477
|
+
{messages.length === 0 && !isStreaming ? (
|
|
478
|
+
<box
|
|
479
|
+
padding={1}
|
|
480
|
+
justifyContent="center"
|
|
481
|
+
alignItems="center"
|
|
482
|
+
flexGrow={1}
|
|
483
|
+
>
|
|
484
|
+
<text fg={Colors.muted} dim>
|
|
485
|
+
Start a conversation with the AI agent
|
|
486
|
+
</text>
|
|
487
|
+
</box>
|
|
488
|
+
) : (
|
|
489
|
+
<scrollbox width="100%" flexGrow={1}>
|
|
490
|
+
{/* Historical messages */}
|
|
491
|
+
{messages.map((msg, i) => (
|
|
492
|
+
<MessageRow key={i} message={msg} />
|
|
493
|
+
))}
|
|
494
|
+
|
|
495
|
+
{/* Live streaming response */}
|
|
496
|
+
{isStreaming && streamingContent.length === 0 && (
|
|
497
|
+
<StreamingRow content="" active={true} />
|
|
498
|
+
)}
|
|
499
|
+
{isStreaming && streamingContent.length > 0 && (
|
|
500
|
+
<StreamingRow content={streamingContent} active={true} />
|
|
501
|
+
)}
|
|
502
|
+
</scrollbox>
|
|
503
|
+
)}
|
|
504
|
+
</box>
|
|
505
|
+
|
|
506
|
+
{/* Input hint */}
|
|
507
|
+
<text fg={Colors.muted} dim>
|
|
508
|
+
Enter to send · Shift+Enter for newline
|
|
509
|
+
</text>
|
|
510
|
+
|
|
511
|
+
{/* Input area */}
|
|
512
|
+
<box
|
|
513
|
+
flexDirection="row"
|
|
514
|
+
gap={1}
|
|
515
|
+
border={true}
|
|
516
|
+
borderStyle="single"
|
|
517
|
+
borderColor={isStreaming ? Colors.muted : Colors.border}
|
|
518
|
+
paddingLeft={1}
|
|
519
|
+
paddingRight={1}
|
|
520
|
+
paddingTop={0}
|
|
521
|
+
paddingBottom={0}
|
|
522
|
+
>
|
|
523
|
+
{/* Multi-line text input rendered as a box with onChange */}
|
|
524
|
+
<textarea
|
|
525
|
+
value={inputText}
|
|
526
|
+
onChange={(val: string) => setInputText(val)}
|
|
527
|
+
onKeyDown={handleInputKeyDown}
|
|
528
|
+
placeholder="Type your message..."
|
|
529
|
+
disabled={isStreaming}
|
|
530
|
+
width="100%"
|
|
531
|
+
height={3}
|
|
532
|
+
transparent={false}
|
|
533
|
+
backgroundColor={Colors.card}
|
|
534
|
+
foregroundColor={Colors.foreground}
|
|
535
|
+
/>
|
|
536
|
+
</box>
|
|
537
|
+
|
|
538
|
+
{/* Send button */}
|
|
539
|
+
<box flexDirection="row" justifyContent="flex-end">
|
|
540
|
+
<text
|
|
541
|
+
fg={isStreaming || !inputText.trim() ? Colors.muted : Colors.accent}
|
|
542
|
+
bold={!isStreaming && !!inputText.trim()}
|
|
543
|
+
dim={isStreaming || !inputText.trim()}
|
|
544
|
+
onMouseUp={
|
|
545
|
+
isStreaming || !inputText.trim()
|
|
546
|
+
? undefined
|
|
547
|
+
: () => void sendMessage()
|
|
548
|
+
}
|
|
549
|
+
>
|
|
550
|
+
{isStreaming ? " … " : " [SEND] "}
|
|
551
|
+
</text>
|
|
552
|
+
</box>
|
|
553
|
+
|
|
554
|
+
{/* Clear history — two-step confirm (destructive) */}
|
|
555
|
+
{messages.length > 0 && (
|
|
556
|
+
<text
|
|
557
|
+
fg={confirmClear ? Colors.error : Colors.muted}
|
|
558
|
+
bold={confirmClear}
|
|
559
|
+
dim={!confirmClear}
|
|
560
|
+
onMouseUp={() => {
|
|
561
|
+
if (!confirmClear) {
|
|
562
|
+
setConfirmClear(true);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
setMessages([]);
|
|
566
|
+
setConfirmClear(false);
|
|
567
|
+
void removeJsonState(TuiStateFiles.chatHistory);
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
570
|
+
{confirmClear ? "[CONFIRM CLEAR HISTORY]" : "[CLEAR HISTORY]"}
|
|
571
|
+
</text>
|
|
572
|
+
)}
|
|
573
|
+
</box>
|
|
574
|
+
</ErrorBoundary>
|
|
575
|
+
);
|
|
576
|
+
}
|