@townco/cli 0.1.19 → 0.1.21
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/dist/commands/run.js +120 -45
- package/dist/components/LogsPane.d.ts +5 -0
- package/dist/components/LogsPane.js +238 -0
- package/dist/components/MergedLogsPane.d.ts +11 -0
- package/dist/components/MergedLogsPane.js +201 -0
- package/dist/components/ProcessPane.d.ts +2 -1
- package/dist/components/ProcessPane.js +81 -7
- package/dist/components/StatusLine.js +2 -2
- package/dist/components/TabbedOutput.d.ts +10 -1
- package/dist/components/TabbedOutput.js +88 -12
- package/package.json +5 -5
package/dist/commands/run.js
CHANGED
|
@@ -1,16 +1,78 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { agentExists, getAgentPath } from "@townco/agent/storage";
|
|
8
|
-
import {
|
|
8
|
+
import { createLogger } from "@townco/agent/utils";
|
|
9
|
+
import { AcpClient } from "@townco/ui";
|
|
10
|
+
import { ChatView } from "@townco/ui/tui";
|
|
11
|
+
import { Box, render, Text } from "ink";
|
|
9
12
|
import open from "open";
|
|
10
|
-
import { useCallback, useMemo, useRef } from "react";
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
14
|
+
import { LogsPane } from "../components/LogsPane.js";
|
|
11
15
|
import { TabbedOutput } from "../components/TabbedOutput.js";
|
|
12
16
|
import { findAvailablePort } from "../lib/port-utils.js";
|
|
13
|
-
function
|
|
17
|
+
function TuiRunner({ agentPath, workingDir, onExit }) {
|
|
18
|
+
const [client, setClient] = useState(null);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
// Session start time for log filtering - set once when component mounts
|
|
21
|
+
const sessionStartTime = useMemo(() => {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
now.setSeconds(now.getSeconds() - 1);
|
|
24
|
+
return now.toISOString();
|
|
25
|
+
}, []);
|
|
26
|
+
// Create logger with agent directory as logs location
|
|
27
|
+
const logger = useMemo(() => createLogger("tui", "debug", { silent: true, logsDir: workingDir }), [workingDir]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
try {
|
|
30
|
+
logger.info("Creating ACP client", { agentPath, workingDir });
|
|
31
|
+
const newClient = new AcpClient({
|
|
32
|
+
type: "stdio",
|
|
33
|
+
options: {
|
|
34
|
+
agentPath,
|
|
35
|
+
workingDirectory: workingDir,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
setClient(newClient);
|
|
39
|
+
return () => {
|
|
40
|
+
logger.debug("Disconnecting ACP client");
|
|
41
|
+
newClient.disconnect().catch((err) => {
|
|
42
|
+
logger.error("Failed to disconnect client", { error: err });
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const errorMsg = err instanceof Error ? err.message : "Failed to create ACP client";
|
|
48
|
+
logger.error("Failed to create ACP client", { error: errorMsg });
|
|
49
|
+
setError(errorMsg);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}, [agentPath, workingDir, logger]);
|
|
53
|
+
const customTabs = useMemo(() => [
|
|
54
|
+
{
|
|
55
|
+
name: "Chat",
|
|
56
|
+
type: "custom",
|
|
57
|
+
render: () => {
|
|
58
|
+
if (error) {
|
|
59
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error] }), _jsx(Text, { color: "gray", children: "Please check your agent path and try again." })] }));
|
|
60
|
+
}
|
|
61
|
+
if (!client) {
|
|
62
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: "Loading chat interface..." }) }));
|
|
63
|
+
}
|
|
64
|
+
return _jsx(ChatView, { client: client });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "Logs",
|
|
69
|
+
type: "custom",
|
|
70
|
+
render: () => (_jsx(LogsPane, { logsDir: join(workingDir, ".logs"), sessionStartTime: sessionStartTime })),
|
|
71
|
+
},
|
|
72
|
+
], [client, error, workingDir, sessionStartTime]);
|
|
73
|
+
return (_jsx(TabbedOutput, { processes: [], customTabs: customTabs, onExit: onExit }));
|
|
74
|
+
}
|
|
75
|
+
function GuiRunner({ agentProcess, guiProcess, agentPort, agentPath, logger, onExit, }) {
|
|
14
76
|
const browserOpenedRef = useRef(false);
|
|
15
77
|
const handlePortDetected = useCallback((processIndex, port) => {
|
|
16
78
|
// Process index 1 is the GUI process
|
|
@@ -19,13 +81,16 @@ function GuiRunner({ agentProcess, guiProcess, agentPort, onExit, }) {
|
|
|
19
81
|
if (!browserOpenedRef.current) {
|
|
20
82
|
browserOpenedRef.current = true;
|
|
21
83
|
const guiUrl = `http://localhost:${port}`;
|
|
84
|
+
logger.info("Opening browser", { url: guiUrl });
|
|
22
85
|
open(guiUrl).catch((error) => {
|
|
23
|
-
|
|
24
|
-
|
|
86
|
+
logger.warn("Could not automatically open browser", {
|
|
87
|
+
error: error.message,
|
|
88
|
+
url: guiUrl,
|
|
89
|
+
});
|
|
25
90
|
});
|
|
26
91
|
}
|
|
27
92
|
}
|
|
28
|
-
}, []);
|
|
93
|
+
}, [logger]);
|
|
29
94
|
// Memoize processes array based only on actual process objects and initial ports
|
|
30
95
|
// Don't include guiPort as dependency to prevent re-creating array when port is detected
|
|
31
96
|
// TabbedOutput will update the displayed port internally via onPortDetected callback
|
|
@@ -33,9 +98,9 @@ function GuiRunner({ agentProcess, guiProcess, agentPort, onExit, }) {
|
|
|
33
98
|
{ name: "Agent", process: agentProcess, port: agentPort },
|
|
34
99
|
{ name: "GUI", process: guiProcess }, // Port will be detected dynamically
|
|
35
100
|
], [agentProcess, guiProcess, agentPort]);
|
|
36
|
-
return (_jsx(TabbedOutput, { processes: processes, onExit: onExit, onPortDetected: handlePortDetected }));
|
|
101
|
+
return (_jsx(TabbedOutput, { processes: processes, logsDir: join(agentPath, ".logs"), onExit: onExit, onPortDetected: handlePortDetected }));
|
|
37
102
|
}
|
|
38
|
-
async function loadEnvVars() {
|
|
103
|
+
async function loadEnvVars(logger) {
|
|
39
104
|
const envPath = join(homedir(), ".config", "town", ".env");
|
|
40
105
|
const envVars = {};
|
|
41
106
|
if (!existsSync(envPath)) {
|
|
@@ -53,9 +118,16 @@ async function loadEnvVars() {
|
|
|
53
118
|
envVars[key.trim()] = value;
|
|
54
119
|
}
|
|
55
120
|
}
|
|
121
|
+
logger?.debug("Loaded environment variables", {
|
|
122
|
+
path: envPath,
|
|
123
|
+
count: Object.keys(envVars).length,
|
|
124
|
+
});
|
|
56
125
|
}
|
|
57
|
-
catch (
|
|
58
|
-
|
|
126
|
+
catch (error) {
|
|
127
|
+
logger?.warn("Could not load environment variables", {
|
|
128
|
+
path: envPath,
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
});
|
|
59
131
|
}
|
|
60
132
|
return envVars;
|
|
61
133
|
}
|
|
@@ -72,6 +144,16 @@ export async function runCommand(options) {
|
|
|
72
144
|
}
|
|
73
145
|
const agentPath = getAgentPath(name);
|
|
74
146
|
const binPath = join(agentPath, "bin.ts");
|
|
147
|
+
// Create logger with agent directory as logs location
|
|
148
|
+
const logger = createLogger("cli", "debug", {
|
|
149
|
+
silent: true,
|
|
150
|
+
logsDir: agentPath,
|
|
151
|
+
});
|
|
152
|
+
logger.info("Starting agent", {
|
|
153
|
+
name,
|
|
154
|
+
mode: gui ? "gui" : http ? "http" : "tui",
|
|
155
|
+
agentPath,
|
|
156
|
+
});
|
|
75
157
|
// If GUI mode, run with tabbed interface
|
|
76
158
|
if (gui) {
|
|
77
159
|
const guiPath = join(agentPath, "gui");
|
|
@@ -81,6 +163,7 @@ export async function runCommand(options) {
|
|
|
81
163
|
await stat(guiPath);
|
|
82
164
|
}
|
|
83
165
|
catch {
|
|
166
|
+
logger.error("GUI not found for agent", { name, guiPath });
|
|
84
167
|
console.error(`Error: GUI not found for agent "${name}".`);
|
|
85
168
|
console.log(`\nThe GUI was not bundled with this agent.`);
|
|
86
169
|
console.log(`Recreate the agent with "town create" to include the GUI.`);
|
|
@@ -89,8 +172,16 @@ export async function runCommand(options) {
|
|
|
89
172
|
// Find an available port for the agent
|
|
90
173
|
const availablePort = await findAvailablePort(port);
|
|
91
174
|
if (availablePort !== port) {
|
|
175
|
+
logger.info("Port in use, using alternative", {
|
|
176
|
+
requestedPort: port,
|
|
177
|
+
actualPort: availablePort,
|
|
178
|
+
});
|
|
92
179
|
console.log(`Port ${port} is in use, using port ${availablePort} instead`);
|
|
93
180
|
}
|
|
181
|
+
logger.info("Starting GUI mode", {
|
|
182
|
+
agentPort: availablePort,
|
|
183
|
+
guiPort: 5173,
|
|
184
|
+
});
|
|
94
185
|
console.log(`Starting agent "${name}" with GUI...`);
|
|
95
186
|
console.log(`Agent HTTP server will run on port ${availablePort}`);
|
|
96
187
|
console.log(`GUI dev server will run on port 5173\n`);
|
|
@@ -120,7 +211,7 @@ export async function runCommand(options) {
|
|
|
120
211
|
},
|
|
121
212
|
});
|
|
122
213
|
// Render the tabbed UI with dynamic port detection
|
|
123
|
-
const { waitUntilExit } = render(_jsx(GuiRunner, { agentProcess: agentProcess, guiProcess: guiProcess, agentPort: availablePort, onExit: () => {
|
|
214
|
+
const { waitUntilExit } = render(_jsx(GuiRunner, { agentProcess: agentProcess, guiProcess: guiProcess, agentPort: availablePort, agentPath: agentPath, logger: logger, onExit: () => {
|
|
124
215
|
agentProcess.kill();
|
|
125
216
|
guiProcess.kill();
|
|
126
217
|
} }));
|
|
@@ -131,8 +222,13 @@ export async function runCommand(options) {
|
|
|
131
222
|
// Find an available port for the agent
|
|
132
223
|
const availablePort = await findAvailablePort(port);
|
|
133
224
|
if (availablePort !== port) {
|
|
225
|
+
logger.info("Port in use, using alternative", {
|
|
226
|
+
requestedPort: port,
|
|
227
|
+
actualPort: availablePort,
|
|
228
|
+
});
|
|
134
229
|
console.log(`Port ${port} is in use, using port ${availablePort} instead\n`);
|
|
135
230
|
}
|
|
231
|
+
logger.info("Starting HTTP mode", { port: availablePort });
|
|
136
232
|
console.log(`Starting agent "${name}" in HTTP mode on port ${availablePort}...`);
|
|
137
233
|
console.log(`\nEndpoints:`);
|
|
138
234
|
console.log(` http://localhost:${availablePort}/health - Health check`);
|
|
@@ -150,11 +246,13 @@ export async function runCommand(options) {
|
|
|
150
246
|
},
|
|
151
247
|
});
|
|
152
248
|
agentProcess.on("error", (error) => {
|
|
249
|
+
logger.error("Failed to start agent", { error: error.message });
|
|
153
250
|
console.error(`Failed to start agent: ${error.message}`);
|
|
154
251
|
process.exit(1);
|
|
155
252
|
});
|
|
156
253
|
agentProcess.on("close", (code) => {
|
|
157
254
|
if (code !== 0 && code !== null) {
|
|
255
|
+
logger.error("Agent exited with error code", { code });
|
|
158
256
|
console.error(`Agent exited with code ${code}`);
|
|
159
257
|
process.exit(code);
|
|
160
258
|
}
|
|
@@ -162,38 +260,15 @@ export async function runCommand(options) {
|
|
|
162
260
|
return;
|
|
163
261
|
}
|
|
164
262
|
// Default: Start TUI interface with the agent
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
const { stat } = await import("node:fs/promises");
|
|
171
|
-
await stat(tuiPath);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
console.error(`Error: TUI not found for agent "${name}".`);
|
|
175
|
-
console.log(`\nThe TUI was not bundled with this agent.`);
|
|
176
|
-
console.log(`Recreate the agent with "town create" to include the TUI.`);
|
|
177
|
-
process.exit(1);
|
|
263
|
+
logger.info("Starting TUI mode", { name, agentPath });
|
|
264
|
+
// Set stdin to raw mode for Ink
|
|
265
|
+
if (process.stdin.isTTY) {
|
|
266
|
+
process.stdin.setRawMode(true);
|
|
178
267
|
}
|
|
179
|
-
//
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
...configEnvVars,
|
|
186
|
-
NODE_ENV: process.env.NODE_ENV || "production",
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
tuiProcess.on("error", (error) => {
|
|
190
|
-
console.error(`Failed to start TUI: ${error.message}`);
|
|
191
|
-
process.exit(1);
|
|
192
|
-
});
|
|
193
|
-
tuiProcess.on("close", (code) => {
|
|
194
|
-
if (code !== 0 && code !== null) {
|
|
195
|
-
console.error(`TUI exited with code ${code}`);
|
|
196
|
-
process.exit(code);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
268
|
+
// Render the tabbed UI with Chat and Logs
|
|
269
|
+
const { waitUntilExit } = render(_jsx(TuiRunner, { agentPath: binPath, workingDir: agentPath, onExit: () => {
|
|
270
|
+
// Cleanup is handled by the ACP client disconnect
|
|
271
|
+
} }));
|
|
272
|
+
await waitUntilExit();
|
|
273
|
+
process.exit(0);
|
|
199
274
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Box, Text, useInput } from "ink";
|
|
6
|
+
import TextInput from "ink-text-input";
|
|
7
|
+
import { useEffect, useMemo, useState } from "react";
|
|
8
|
+
const LOG_COLORS = {
|
|
9
|
+
trace: "gray",
|
|
10
|
+
debug: "blue",
|
|
11
|
+
info: "green",
|
|
12
|
+
warn: "yellow",
|
|
13
|
+
error: "red",
|
|
14
|
+
fatal: "redBright",
|
|
15
|
+
};
|
|
16
|
+
const SERVICE_COLORS = ["cyan", "magenta", "blue", "green", "yellow"];
|
|
17
|
+
// Fuzzy search: checks if query characters appear in order in the target string
|
|
18
|
+
function fuzzyMatch(query, target) {
|
|
19
|
+
if (!query)
|
|
20
|
+
return true;
|
|
21
|
+
const lowerQuery = query.toLowerCase();
|
|
22
|
+
const lowerTarget = target.toLowerCase();
|
|
23
|
+
let queryIndex = 0;
|
|
24
|
+
for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
|
|
25
|
+
if (lowerTarget[i] === lowerQuery[queryIndex]) {
|
|
26
|
+
queryIndex++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return queryIndex === lowerQuery.length;
|
|
30
|
+
}
|
|
31
|
+
export function LogsPane({ logsDir: customLogsDir, sessionStartTime: providedSessionStartTime, } = {}) {
|
|
32
|
+
const [logLines, setLogLines] = useState([]);
|
|
33
|
+
const [serviceFilter, setServiceFilter] = useState(null);
|
|
34
|
+
const [levelFilter, setLevelFilter] = useState(null);
|
|
35
|
+
const [availableServices, setAvailableServices] = useState([]);
|
|
36
|
+
const [scrollOffset, setScrollOffset] = useState(0); // 0 = at bottom, positive = scrolled up
|
|
37
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
38
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
39
|
+
// Use provided session start time or default to 1 second before component mount
|
|
40
|
+
const sessionStartTime = useMemo(() => {
|
|
41
|
+
if (providedSessionStartTime)
|
|
42
|
+
return providedSessionStartTime;
|
|
43
|
+
const now = new Date();
|
|
44
|
+
now.setSeconds(now.getSeconds() - 1);
|
|
45
|
+
return now.toISOString();
|
|
46
|
+
}, [providedSessionStartTime]);
|
|
47
|
+
const logsDir = customLogsDir ?? join(process.cwd(), ".logs");
|
|
48
|
+
// Update available services based on logs from current session
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const servicesWithLogs = new Set(logLines.map((log) => log.service));
|
|
51
|
+
const newServices = Array.from(servicesWithLogs);
|
|
52
|
+
setAvailableServices(newServices);
|
|
53
|
+
// Clear service filter if the selected service is no longer available
|
|
54
|
+
if (serviceFilter && !newServices.includes(serviceFilter)) {
|
|
55
|
+
setServiceFilter(null);
|
|
56
|
+
}
|
|
57
|
+
}, [logLines, serviceFilter]);
|
|
58
|
+
// Tail all log files and merge output
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!existsSync(logsDir)) {
|
|
61
|
+
setLogLines([
|
|
62
|
+
{
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
level: "info",
|
|
65
|
+
service: "logs",
|
|
66
|
+
message: `No logs directory found at ${logsDir}`,
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const logFiles = readdirSync(logsDir).filter((f) => f.endsWith(".log"));
|
|
72
|
+
if (logFiles.length === 0) {
|
|
73
|
+
setLogLines([
|
|
74
|
+
{
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
level: "info",
|
|
77
|
+
service: "logs",
|
|
78
|
+
message: "No log files found. Logs will appear here once services start logging.",
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Tail all log files (get last 50 lines, then follow)
|
|
84
|
+
const tailProcesses = logFiles.map((file) => {
|
|
85
|
+
const filePath = join(logsDir, file);
|
|
86
|
+
const tail = spawn("tail", ["-f", "-n", "50", filePath]);
|
|
87
|
+
tail.stdout.on("data", (data) => {
|
|
88
|
+
const lines = data
|
|
89
|
+
.toString()
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter((line) => line.trim());
|
|
92
|
+
const entries = lines
|
|
93
|
+
.map((line) => {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(line);
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// If not JSON, create a simple entry
|
|
100
|
+
return {
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
level: "info",
|
|
103
|
+
service: file.replace(".log", ""),
|
|
104
|
+
message: line,
|
|
105
|
+
_raw: line,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
.filter((entry) => entry.timestamp >= sessionStartTime);
|
|
110
|
+
setLogLines((prev) => {
|
|
111
|
+
const combined = [...prev, ...entries];
|
|
112
|
+
// Sort by timestamp and keep last 200 entries
|
|
113
|
+
const sorted = combined.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
114
|
+
return sorted.slice(-200);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
return tail;
|
|
118
|
+
});
|
|
119
|
+
// Cleanup
|
|
120
|
+
return () => {
|
|
121
|
+
for (const tail of tailProcesses) {
|
|
122
|
+
tail.kill();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}, [logsDir, sessionStartTime]);
|
|
126
|
+
// Apply filters
|
|
127
|
+
const filteredLogs = logLines.filter((log) => {
|
|
128
|
+
if (serviceFilter && log.service !== serviceFilter) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (levelFilter) {
|
|
132
|
+
const levelOrder = [
|
|
133
|
+
"trace",
|
|
134
|
+
"debug",
|
|
135
|
+
"info",
|
|
136
|
+
"warn",
|
|
137
|
+
"error",
|
|
138
|
+
"fatal",
|
|
139
|
+
];
|
|
140
|
+
const logLevelIndex = levelOrder.indexOf(log.level);
|
|
141
|
+
const filterLevelIndex = levelOrder.indexOf(levelFilter);
|
|
142
|
+
if (logLevelIndex < filterLevelIndex) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
// Handle keyboard input for filtering and scrolling
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
// '/' to enter search mode
|
|
151
|
+
if (input === "/" && !searchMode) {
|
|
152
|
+
setSearchMode(true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Escape to exit search mode or jump to bottom
|
|
156
|
+
if (key.escape) {
|
|
157
|
+
if (searchMode) {
|
|
158
|
+
setSearchMode(false);
|
|
159
|
+
setSearchQuery("");
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
setScrollOffset(0);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Don't handle other inputs in search mode (let TextInput handle them)
|
|
167
|
+
if (searchMode) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Up arrow to scroll up
|
|
171
|
+
if (key.upArrow) {
|
|
172
|
+
setScrollOffset((prev) => Math.min(prev + 1, Math.max(0, filteredLogs.length - 25)));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Down arrow to scroll down
|
|
176
|
+
if (key.downArrow) {
|
|
177
|
+
setScrollOffset((prev) => Math.max(prev - 1, 0));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// 's' to cycle through service filters
|
|
181
|
+
if (input === "s") {
|
|
182
|
+
if (serviceFilter === null) {
|
|
183
|
+
setServiceFilter(availableServices[0] ?? null);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const currentIndex = availableServices.indexOf(serviceFilter);
|
|
187
|
+
const nextIndex = (currentIndex + 1) % (availableServices.length + 1);
|
|
188
|
+
setServiceFilter(nextIndex === availableServices.length
|
|
189
|
+
? null
|
|
190
|
+
: (availableServices[nextIndex] ?? null));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 'l' to cycle through level filters
|
|
194
|
+
if (input === "l") {
|
|
195
|
+
const levels = [
|
|
196
|
+
null,
|
|
197
|
+
"debug",
|
|
198
|
+
"info",
|
|
199
|
+
"warn",
|
|
200
|
+
"error",
|
|
201
|
+
"fatal",
|
|
202
|
+
];
|
|
203
|
+
const currentIndex = levelFilter === null ? 0 : levels.indexOf(levelFilter);
|
|
204
|
+
const nextIndex = (currentIndex + 1) % levels.length;
|
|
205
|
+
const nextLevel = levels[nextIndex];
|
|
206
|
+
setLevelFilter(nextLevel ?? null);
|
|
207
|
+
}
|
|
208
|
+
// 'c' to clear all filters, logs, and reset scroll
|
|
209
|
+
if (input === "c") {
|
|
210
|
+
setServiceFilter(null);
|
|
211
|
+
setLevelFilter(null);
|
|
212
|
+
setLogLines([]); // Clear the log buffer
|
|
213
|
+
setScrollOffset(0); // Also jump to bottom when clearing
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// Apply fuzzy search if active
|
|
218
|
+
const searchedLogs = searchQuery
|
|
219
|
+
? filteredLogs.filter((log) => fuzzyMatch(searchQuery, log.message))
|
|
220
|
+
: filteredLogs;
|
|
221
|
+
// Calculate display window (25 lines) based on scroll position
|
|
222
|
+
const totalLogs = searchedLogs.length;
|
|
223
|
+
const maxLines = 25;
|
|
224
|
+
const isAtBottom = scrollOffset === 0;
|
|
225
|
+
// Calculate which slice of logs to show
|
|
226
|
+
const displayLogs = isAtBottom
|
|
227
|
+
? searchedLogs.slice(-maxLines) // Show last 25 when at bottom
|
|
228
|
+
: searchedLogs.slice(Math.max(0, totalLogs - maxLines - scrollOffset), totalLogs - scrollOffset);
|
|
229
|
+
const canScrollUp = totalLogs > maxLines && scrollOffset < totalLogs - maxLines;
|
|
230
|
+
const canScrollDown = scrollOffset > 0;
|
|
231
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", borderStyle: "round", borderColor: "gray", children: [_jsxs(Box, { borderStyle: "single", borderBottom: true, borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { bold: true, children: ["Logs ", serviceFilter && `[${serviceFilter}]`, " ", levelFilter && `[>=${levelFilter}]`, searchQuery && _jsxs(Text, { color: "cyan", children: [" [SEARCH: ", searchQuery, "]"] }), !isAtBottom && _jsx(Text, { color: "yellow", children: " [SCROLLED]" })] }), _jsx(Text, { dimColor: true, children: "(/)search | (s)ervice | (l)evel | (c)lear | \u2191\u2193 | ESC" })] }), searchMode && (_jsxs(Box, { borderStyle: "single", borderBottom: true, borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { color: "cyan", children: "Search: " }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search..." }), _jsx(Text, { dimColor: true, children: " (ESC to exit)" })] })), _jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, flexGrow: 1, children: displayLogs.length === 0 ? (_jsx(Text, { dimColor: true, children: "No logs match the current filters. Press 'c' to clear filters." })) : (displayLogs.map((log, idx) => {
|
|
232
|
+
const serviceColor = SERVICE_COLORS[availableServices.indexOf(log.service) % SERVICE_COLORS.length] || "white";
|
|
233
|
+
const levelColor = LOG_COLORS[log.level] || "white";
|
|
234
|
+
const time = new Date(log.timestamp).toLocaleTimeString();
|
|
235
|
+
const keyStr = `${log.timestamp}-${idx}`;
|
|
236
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["[", time, "]"] }), _jsxs(Text, { color: serviceColor, children: [" [", log.service, "]"] }), _jsxs(Text, { color: levelColor, children: [" [", log.level.toUpperCase(), "]"] }), _jsxs(Text, { children: [" ", log.message] })] }, keyStr));
|
|
237
|
+
})) }), totalLogs > 0 && (_jsxs(Box, { borderStyle: "single", borderTop: true, borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Showing ", displayLogs.length, " of ", totalLogs, " logs", searchQuery && ` (${totalLogs} matches)`, scrollOffset > 0 && ` (${scrollOffset} from bottom)`] }), totalLogs > maxLines && (_jsxs(Text, { dimColor: true, children: [canScrollUp && "↑ ", canScrollDown && "↓"] }))] }))] }));
|
|
238
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ServiceOutput {
|
|
2
|
+
service: string;
|
|
3
|
+
output: string[];
|
|
4
|
+
port: number | undefined;
|
|
5
|
+
status: "starting" | "running" | "stopped" | "error";
|
|
6
|
+
}
|
|
7
|
+
export interface MergedLogsPaneProps {
|
|
8
|
+
services: ServiceOutput[];
|
|
9
|
+
onClear?: (serviceIndex: number) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function MergedLogsPane({ services, onClear }: MergedLogsPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
const SERVICE_COLORS = ["cyan", "magenta", "blue", "green", "yellow"];
|
|
6
|
+
const LOG_LEVELS = {
|
|
7
|
+
trace: 0,
|
|
8
|
+
debug: 1,
|
|
9
|
+
info: 2,
|
|
10
|
+
warn: 3,
|
|
11
|
+
error: 4,
|
|
12
|
+
fatal: 5,
|
|
13
|
+
};
|
|
14
|
+
// Parse log level from a line by looking for [LEVEL] markers
|
|
15
|
+
function parseLogLevel(line) {
|
|
16
|
+
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\]/i);
|
|
17
|
+
if (match?.[1]) {
|
|
18
|
+
return match[1].toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// Fuzzy search: checks if query characters appear in order in the target string
|
|
23
|
+
function fuzzyMatch(query, target) {
|
|
24
|
+
if (!query)
|
|
25
|
+
return true;
|
|
26
|
+
const lowerQuery = query.toLowerCase();
|
|
27
|
+
const lowerTarget = target.toLowerCase();
|
|
28
|
+
let queryIndex = 0;
|
|
29
|
+
for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
|
|
30
|
+
if (lowerTarget[i] === lowerQuery[queryIndex]) {
|
|
31
|
+
queryIndex++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return queryIndex === lowerQuery.length;
|
|
35
|
+
}
|
|
36
|
+
export function MergedLogsPane({ services, onClear }) {
|
|
37
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
38
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
39
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
40
|
+
const [serviceFilter, setServiceFilter] = useState(null);
|
|
41
|
+
const [levelFilter, setLevelFilter] = useState(null);
|
|
42
|
+
const maxLines = 25;
|
|
43
|
+
// Only include services that actually have output
|
|
44
|
+
const availableServices = useMemo(() => services.filter((s) => s.output.length > 0).map((s) => s.service), [services]);
|
|
45
|
+
// Clear service filter if the selected service is no longer available
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (serviceFilter && !availableServices.includes(serviceFilter)) {
|
|
48
|
+
setServiceFilter(null);
|
|
49
|
+
}
|
|
50
|
+
}, [availableServices, serviceFilter]);
|
|
51
|
+
// Merge all outputs into a single array with service tags and parsed levels
|
|
52
|
+
const mergedLines = useMemo(() => {
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (const [serviceIndex, service] of services.entries()) {
|
|
55
|
+
for (const [index, line] of service.output.entries()) {
|
|
56
|
+
lines.push({
|
|
57
|
+
service: service.service,
|
|
58
|
+
line,
|
|
59
|
+
index,
|
|
60
|
+
serviceIndex,
|
|
61
|
+
level: parseLogLevel(line),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}, [services]);
|
|
67
|
+
// Handle keyboard input
|
|
68
|
+
useInput((input, key) => {
|
|
69
|
+
// '/' to enter search mode
|
|
70
|
+
if (input === "/" && !searchMode) {
|
|
71
|
+
setSearchMode(true);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Escape to exit search mode or jump to bottom
|
|
75
|
+
if (key.escape) {
|
|
76
|
+
if (searchMode) {
|
|
77
|
+
setSearchMode(false);
|
|
78
|
+
setSearchQuery("");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
setScrollOffset(0);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Don't handle other inputs in search mode
|
|
86
|
+
if (searchMode) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Up arrow to scroll up
|
|
90
|
+
if (key.upArrow) {
|
|
91
|
+
setScrollOffset((prev) => Math.min(prev + 1, Math.max(0, mergedLines.length - maxLines)));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Down arrow to scroll down
|
|
95
|
+
if (key.downArrow) {
|
|
96
|
+
setScrollOffset((prev) => Math.max(prev - 1, 0));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// 's' to cycle through service filters
|
|
100
|
+
if (input === "s") {
|
|
101
|
+
if (serviceFilter === null) {
|
|
102
|
+
setServiceFilter(availableServices[0] ?? null);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const currentIndex = availableServices.indexOf(serviceFilter);
|
|
106
|
+
const nextIndex = (currentIndex + 1) % (availableServices.length + 1);
|
|
107
|
+
setServiceFilter(nextIndex === availableServices.length
|
|
108
|
+
? null
|
|
109
|
+
: (availableServices[nextIndex] ?? null));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// 'l' to cycle through level filters
|
|
114
|
+
if (input === "l") {
|
|
115
|
+
const levels = [
|
|
116
|
+
null,
|
|
117
|
+
"debug",
|
|
118
|
+
"info",
|
|
119
|
+
"warn",
|
|
120
|
+
"error",
|
|
121
|
+
"fatal",
|
|
122
|
+
];
|
|
123
|
+
const currentIndex = levelFilter === null ? 0 : levels.indexOf(levelFilter);
|
|
124
|
+
const nextIndex = (currentIndex + 1) % levels.length;
|
|
125
|
+
const nextLevel = levels[nextIndex];
|
|
126
|
+
setLevelFilter(nextLevel ?? null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// 'c' to clear all outputs
|
|
130
|
+
if (input === "c") {
|
|
131
|
+
if (onClear) {
|
|
132
|
+
// Clear all services
|
|
133
|
+
for (const [index] of services.entries()) {
|
|
134
|
+
onClear(index);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
setScrollOffset(0);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Apply service filter
|
|
142
|
+
const serviceFilteredLines = useMemo(() => {
|
|
143
|
+
if (!serviceFilter)
|
|
144
|
+
return mergedLines;
|
|
145
|
+
return mergedLines.filter((log) => log.service === serviceFilter);
|
|
146
|
+
}, [mergedLines, serviceFilter]);
|
|
147
|
+
// Apply level filter
|
|
148
|
+
const levelFilteredLines = useMemo(() => {
|
|
149
|
+
if (!levelFilter)
|
|
150
|
+
return serviceFilteredLines;
|
|
151
|
+
return serviceFilteredLines.filter((log) => {
|
|
152
|
+
if (!log.level)
|
|
153
|
+
return true; // Show lines without level info
|
|
154
|
+
const logLevelIndex = LOG_LEVELS[log.level];
|
|
155
|
+
const filterLevelIndex = LOG_LEVELS[levelFilter];
|
|
156
|
+
return logLevelIndex >= filterLevelIndex;
|
|
157
|
+
});
|
|
158
|
+
}, [serviceFilteredLines, levelFilter]);
|
|
159
|
+
// Apply fuzzy search
|
|
160
|
+
const searchedLines = useMemo(() => {
|
|
161
|
+
if (!searchQuery)
|
|
162
|
+
return levelFilteredLines;
|
|
163
|
+
return levelFilteredLines.filter((log) => fuzzyMatch(searchQuery, log.line));
|
|
164
|
+
}, [levelFilteredLines, searchQuery]);
|
|
165
|
+
const isAtBottom = scrollOffset === 0;
|
|
166
|
+
// Calculate which slice to show
|
|
167
|
+
const displayLines = useMemo(() => {
|
|
168
|
+
if (isAtBottom) {
|
|
169
|
+
return searchedLines.slice(-maxLines);
|
|
170
|
+
}
|
|
171
|
+
return searchedLines.slice(Math.max(0, searchedLines.length - maxLines - scrollOffset), searchedLines.length - scrollOffset);
|
|
172
|
+
}, [searchedLines, scrollOffset, isAtBottom]);
|
|
173
|
+
const canScrollUp = searchedLines.length > maxLines &&
|
|
174
|
+
scrollOffset < searchedLines.length - maxLines;
|
|
175
|
+
const canScrollDown = scrollOffset > 0;
|
|
176
|
+
// Get overall status (error if any error, stopped if all stopped, etc.)
|
|
177
|
+
const overallStatus = useMemo(() => {
|
|
178
|
+
if (services.some((s) => s.status === "error"))
|
|
179
|
+
return "error";
|
|
180
|
+
if (services.every((s) => s.status === "stopped"))
|
|
181
|
+
return "stopped";
|
|
182
|
+
if (services.some((s) => s.status === "running"))
|
|
183
|
+
return "running";
|
|
184
|
+
return "starting";
|
|
185
|
+
}, [services]);
|
|
186
|
+
const statusColor = overallStatus === "running"
|
|
187
|
+
? "green"
|
|
188
|
+
: overallStatus === "error"
|
|
189
|
+
? "red"
|
|
190
|
+
: overallStatus === "starting"
|
|
191
|
+
? "yellow"
|
|
192
|
+
: "gray";
|
|
193
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, marginBottom: 1, flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: ["Logs ", serviceFilter && `[${serviceFilter}]`, levelFilter && ` [>=${levelFilter}]`, searchQuery && _jsxs(Text, { color: "cyan", children: [" [SEARCH: ", searchQuery, "]"] }), !isAtBottom && _jsx(Text, { color: "yellow", children: " [SCROLLED]" })] }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", overallStatus] })] }), _jsx(Text, { dimColor: true, children: "(/)search | (s)ervice | (l)evel | \u2191\u2193 | (c)lear | ESC" })] }), searchMode && (_jsxs(Box, { borderStyle: "single", borderBottom: true, borderColor: "cyan", paddingX: 1, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: "cyan", children: "Search: " }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search..." }), _jsx(Text, { dimColor: true, children: " (ESC to exit)" })] })), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, paddingX: 1, children: displayLines.length === 0 ? (_jsx(Text, { dimColor: true, children: searchQuery || serviceFilter || levelFilter
|
|
194
|
+
? "No logs match the current filters. Press 'c' to clear, 's' for service, or 'l' for level."
|
|
195
|
+
: "Waiting for output..." })) : (displayLines.map((logLine, idx) => {
|
|
196
|
+
const serviceColor = SERVICE_COLORS[availableServices.indexOf(logLine.service) %
|
|
197
|
+
SERVICE_COLORS.length] || "white";
|
|
198
|
+
const keyStr = `${logLine.service}-${logLine.serviceIndex}-${logLine.index}-${idx}`;
|
|
199
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: serviceColor, children: ["[", logLine.service, "]"] }), _jsxs(Text, { children: [" ", logLine.line] })] }, keyStr));
|
|
200
|
+
})) }), _jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: ["Showing ", displayLines.length, " of ", searchedLines.length, " lines", searchQuery && ` (${searchedLines.length} matches)`, scrollOffset > 0 && ` (${scrollOffset} from bottom)`] }), searchedLines.length > maxLines && (_jsxs(Text, { dimColor: true, children: [canScrollUp && "↑ ", canScrollDown && "↓"] }))] })] }));
|
|
201
|
+
}
|
|
@@ -3,5 +3,6 @@ export interface ProcessPaneProps {
|
|
|
3
3
|
output: string[];
|
|
4
4
|
port: number | undefined;
|
|
5
5
|
status: "starting" | "running" | "stopped" | "error";
|
|
6
|
+
onClear?: () => void;
|
|
6
7
|
}
|
|
7
|
-
export declare function ProcessPane({ title, output, port, status }: ProcessPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare function ProcessPane({ title, output, port, status, onClear, }: ProcessPaneProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,11 +1,85 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
// Fuzzy search: checks if query characters appear in order in the target string
|
|
6
|
+
function fuzzyMatch(query, target) {
|
|
7
|
+
if (!query)
|
|
8
|
+
return true;
|
|
9
|
+
const lowerQuery = query.toLowerCase();
|
|
10
|
+
const lowerTarget = target.toLowerCase();
|
|
11
|
+
let queryIndex = 0;
|
|
12
|
+
for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
|
|
13
|
+
if (lowerTarget[i] === lowerQuery[queryIndex]) {
|
|
14
|
+
queryIndex++;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return queryIndex === lowerQuery.length;
|
|
18
|
+
}
|
|
19
|
+
export function ProcessPane({ title, output, port, status, onClear, }) {
|
|
20
|
+
const [scrollOffset, setScrollOffset] = useState(0); // 0 = at bottom, positive = scrolled up
|
|
21
|
+
const [searchMode, setSearchMode] = useState(false);
|
|
22
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
23
|
+
const maxLines = 25;
|
|
24
|
+
// Handle keyboard input for scrolling, clearing, and search
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
// '/' to enter search mode
|
|
27
|
+
if (input === "/" && !searchMode) {
|
|
28
|
+
setSearchMode(true);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Escape to exit search mode or jump to bottom
|
|
32
|
+
if (key.escape) {
|
|
33
|
+
if (searchMode) {
|
|
34
|
+
setSearchMode(false);
|
|
35
|
+
setSearchQuery("");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
setScrollOffset(0);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Don't handle other inputs in search mode (let TextInput handle them)
|
|
43
|
+
if (searchMode) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Up arrow to scroll up
|
|
47
|
+
if (key.upArrow) {
|
|
48
|
+
setScrollOffset((prev) => Math.min(prev + 1, Math.max(0, output.length - maxLines)));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Down arrow to scroll down
|
|
52
|
+
if (key.downArrow) {
|
|
53
|
+
setScrollOffset((prev) => Math.max(prev - 1, 0));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// 'c' to clear output
|
|
57
|
+
if (input === "c") {
|
|
58
|
+
if (onClear) {
|
|
59
|
+
onClear();
|
|
60
|
+
}
|
|
61
|
+
setScrollOffset(0);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const isAtBottom = scrollOffset === 0;
|
|
66
|
+
// Apply fuzzy search if active
|
|
67
|
+
const searchedOutput = useMemo(() => {
|
|
68
|
+
if (!searchQuery)
|
|
69
|
+
return output;
|
|
70
|
+
return output.filter((line) => fuzzyMatch(searchQuery, line));
|
|
71
|
+
}, [output, searchQuery]);
|
|
72
|
+
// Calculate which slice of output to show
|
|
6
73
|
const displayOutput = useMemo(() => {
|
|
7
|
-
|
|
8
|
-
|
|
74
|
+
const source = searchedOutput;
|
|
75
|
+
if (isAtBottom) {
|
|
76
|
+
return source.slice(-maxLines); // Show last 25 when at bottom
|
|
77
|
+
}
|
|
78
|
+
return source.slice(Math.max(0, source.length - maxLines - scrollOffset), source.length - scrollOffset);
|
|
79
|
+
}, [searchedOutput, scrollOffset, isAtBottom]);
|
|
80
|
+
const canScrollUp = searchedOutput.length > maxLines &&
|
|
81
|
+
scrollOffset < searchedOutput.length - maxLines;
|
|
82
|
+
const canScrollDown = scrollOffset > 0;
|
|
9
83
|
const statusColor = status === "running"
|
|
10
84
|
? "green"
|
|
11
85
|
: status === "error"
|
|
@@ -13,5 +87,5 @@ export function ProcessPane({ title, output, port, status }) {
|
|
|
13
87
|
: status === "starting"
|
|
14
88
|
? "yellow"
|
|
15
89
|
: "gray";
|
|
16
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: "cyan", bold: true, children: title }), port && _jsxs(Text, { color: "gray", children: [" - http://localhost:", port] }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", status] })] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, paddingX: 1, children: displayOutput.length === 0 ? (_jsx(Text, { color: "gray", children: "Waiting for output..." })) : (displayOutput.map((line, idx) => (_jsx(Text, { children: line }, `${idx}-${line.slice(0, 20)}`)))) }),
|
|
90
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, marginBottom: 1, flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: title }), port && _jsxs(Text, { color: "gray", children: [" - http://localhost:", port] }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", status] }), searchQuery && _jsxs(Text, { color: "cyan", children: [" [SEARCH: ", searchQuery, "]"] }), !isAtBottom && _jsx(Text, { color: "yellow", children: " [SCROLLED]" })] }), _jsx(Text, { color: "gray", children: "(/)search | \u2191\u2193 | (c)lear | ESC" })] }), searchMode && (_jsxs(Box, { borderStyle: "single", borderBottom: true, borderColor: "cyan", paddingX: 1, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: "cyan", children: "Search: " }), _jsx(TextInput, { value: searchQuery, onChange: setSearchQuery, placeholder: "Type to search..." }), _jsx(Text, { dimColor: true, children: " (ESC to exit)" })] })), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, paddingX: 1, children: displayOutput.length === 0 ? (_jsx(Text, { color: "gray", children: "Waiting for output..." })) : (displayOutput.map((line, idx) => (_jsx(Text, { children: line }, `${idx}-${line.slice(0, 20)}`)))) }), _jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Text, { color: "gray", children: ["Showing ", displayOutput.length, " of ", searchedOutput.length, " lines", searchQuery && ` (${searchedOutput.length} matches)`, scrollOffset > 0 && ` (${scrollOffset} from bottom)`] }), searchedOutput.length > maxLines && (_jsxs(Text, { color: "gray", children: [canScrollUp && "↑ ", canScrollDown && "↓"] }))] })] }));
|
|
17
91
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
export function StatusLine({ activeTab, tabs }) {
|
|
4
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1, children: [tabs.map((tab, idx) => (_jsxs(Text, { children: [
|
|
4
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1, children: [tabs.map((tab, idx) => (_jsxs(Text, { children: [_jsx(Text, { color: activeTab === idx ? "green" : "gray", children: tab }), idx < tabs.length - 1 && _jsx(Text, { color: "gray", children: " | " })] }, tab))), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: "yellow", children: "tab" }), _jsx(Text, { color: "gray", children: " or " }), _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), _jsx(Text, { color: "gray", children: " to switch" })] }));
|
|
5
5
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import type React from "react";
|
|
2
3
|
export interface ProcessInfo {
|
|
3
4
|
name: string;
|
|
4
5
|
process: ChildProcess;
|
|
5
6
|
port?: number;
|
|
6
7
|
}
|
|
8
|
+
export interface CustomTabInfo {
|
|
9
|
+
name: string;
|
|
10
|
+
type: "custom";
|
|
11
|
+
render: () => React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export type TabInfo = ProcessInfo | CustomTabInfo;
|
|
7
14
|
export interface TabbedOutputProps {
|
|
8
15
|
processes: ProcessInfo[];
|
|
16
|
+
customTabs?: CustomTabInfo[];
|
|
17
|
+
logsDir?: string;
|
|
9
18
|
onExit: () => void;
|
|
10
19
|
onPortDetected?: (processIndex: number, port: number) => void;
|
|
11
20
|
}
|
|
12
|
-
export declare function TabbedOutput({ processes, onExit, onPortDetected, }: TabbedOutputProps): import("react/jsx-runtime").JSX.Element | null;
|
|
21
|
+
export declare function TabbedOutput({ processes, customTabs, logsDir, onExit, onPortDetected, }: TabbedOutputProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { Box, useApp, useInput } from "ink";
|
|
3
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { MergedLogsPane } from "./MergedLogsPane.js";
|
|
4
7
|
import { ProcessPane } from "./ProcessPane.js";
|
|
5
8
|
import { StatusLine } from "./StatusLine.js";
|
|
6
|
-
|
|
9
|
+
function isProcessTab(tab) {
|
|
10
|
+
return "process" in tab;
|
|
11
|
+
}
|
|
12
|
+
function isCustomTab(tab) {
|
|
13
|
+
return "type" in tab && tab.type === "custom";
|
|
14
|
+
}
|
|
15
|
+
export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPortDetected, }) {
|
|
7
16
|
const { exit } = useApp();
|
|
17
|
+
const allTabs = [...processes, ...customTabs];
|
|
8
18
|
const [activeTab, setActiveTab] = useState(0);
|
|
9
19
|
const [outputs, setOutputs] = useState(processes.map(() => []));
|
|
10
20
|
const [statuses, setStatuses] = useState(processes.map(() => "starting"));
|
|
11
21
|
const [ports, setPorts] = useState(processes.map((p) => p.port));
|
|
12
22
|
const portDetectedRef = useRef(new Set());
|
|
23
|
+
// Ensure logs directory exists if provided
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (logsDir && !existsSync(logsDir)) {
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(logsDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error("Failed to create logs directory:", error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}, [logsDir]);
|
|
13
34
|
// Handle keyboard input
|
|
14
35
|
useInput((input, key) => {
|
|
15
36
|
if (key.ctrl && input === "c") {
|
|
@@ -20,20 +41,15 @@ export function TabbedOutput({ processes, onExit, onPortDetected, }) {
|
|
|
20
41
|
}
|
|
21
42
|
// Tab key to toggle between tabs
|
|
22
43
|
if (key.tab) {
|
|
23
|
-
setActiveTab((activeTab + 1) %
|
|
44
|
+
setActiveTab((activeTab + 1) % allTabs.length);
|
|
24
45
|
return;
|
|
25
46
|
}
|
|
26
47
|
if (key.leftArrow && activeTab > 0) {
|
|
27
48
|
setActiveTab(activeTab - 1);
|
|
28
49
|
}
|
|
29
|
-
else if (key.rightArrow && activeTab <
|
|
50
|
+
else if (key.rightArrow && activeTab < allTabs.length - 1) {
|
|
30
51
|
setActiveTab(activeTab + 1);
|
|
31
52
|
}
|
|
32
|
-
// Number keys to jump to tabs
|
|
33
|
-
const num = Number.parseInt(input, 10);
|
|
34
|
-
if (!Number.isNaN(num) && num >= 1 && num <= processes.length) {
|
|
35
|
-
setActiveTab(num - 1);
|
|
36
|
-
}
|
|
37
53
|
});
|
|
38
54
|
// Set up process output listeners
|
|
39
55
|
useEffect(() => {
|
|
@@ -59,6 +75,23 @@ export function TabbedOutput({ processes, onExit, onPortDetected, }) {
|
|
|
59
75
|
return newOutputs;
|
|
60
76
|
});
|
|
61
77
|
markAsRunning();
|
|
78
|
+
// Write to log file if logsDir is provided
|
|
79
|
+
if (logsDir) {
|
|
80
|
+
const logFile = join(logsDir, `${processInfo.name.toLowerCase()}.log`);
|
|
81
|
+
try {
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
const logEntry = JSON.stringify({
|
|
84
|
+
timestamp,
|
|
85
|
+
level: "info",
|
|
86
|
+
service: processInfo.name.toLowerCase(),
|
|
87
|
+
message: output.trim(),
|
|
88
|
+
});
|
|
89
|
+
appendFileSync(logFile, `${logEntry}\n`, "utf-8");
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
console.error("Failed to write to log file:", error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
62
95
|
// Check for Vite port in output and notify if callback provided
|
|
63
96
|
if (onPortDetected && !portDetectedRef.current.has(idx)) {
|
|
64
97
|
const portMatch = output.match(/Local:\s+http:\/\/localhost:(\d+)/);
|
|
@@ -86,6 +119,23 @@ export function TabbedOutput({ processes, onExit, onPortDetected, }) {
|
|
|
86
119
|
return newOutputs;
|
|
87
120
|
});
|
|
88
121
|
markAsRunning();
|
|
122
|
+
// Write to log file if logsDir is provided
|
|
123
|
+
if (logsDir) {
|
|
124
|
+
const logFile = join(logsDir, `${processInfo.name.toLowerCase()}.log`);
|
|
125
|
+
try {
|
|
126
|
+
const timestamp = new Date().toISOString();
|
|
127
|
+
const logEntry = JSON.stringify({
|
|
128
|
+
timestamp,
|
|
129
|
+
level: "error",
|
|
130
|
+
service: processInfo.name.toLowerCase(),
|
|
131
|
+
message: text.trim(),
|
|
132
|
+
});
|
|
133
|
+
appendFileSync(logFile, `${logEntry}\n`, "utf-8");
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error("Failed to write to log file:", error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
89
139
|
});
|
|
90
140
|
// Handle process exit
|
|
91
141
|
process.on("close", (code) => {
|
|
@@ -127,10 +177,36 @@ export function TabbedOutput({ processes, onExit, onPortDetected, }) {
|
|
|
127
177
|
process.kill();
|
|
128
178
|
});
|
|
129
179
|
};
|
|
130
|
-
}, [processes, onPortDetected]);
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
180
|
+
}, [processes, onPortDetected, logsDir]);
|
|
181
|
+
const currentTab = allTabs[activeTab];
|
|
182
|
+
if (!currentTab) {
|
|
133
183
|
return null;
|
|
134
184
|
}
|
|
135
|
-
|
|
185
|
+
const handleClearOutput = () => {
|
|
186
|
+
setOutputs((prev) => {
|
|
187
|
+
const newOutputs = [...prev];
|
|
188
|
+
newOutputs[activeTab] = [];
|
|
189
|
+
return newOutputs;
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const handleClearMergedOutput = (serviceIndex) => {
|
|
193
|
+
setOutputs((prev) => {
|
|
194
|
+
const newOutputs = [...prev];
|
|
195
|
+
newOutputs[serviceIndex] = [];
|
|
196
|
+
return newOutputs;
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
// GUI mode: only process tabs, no custom tabs - use merged logs view
|
|
200
|
+
const isGuiMode = customTabs.length === 0 && processes.length > 0;
|
|
201
|
+
if (isGuiMode) {
|
|
202
|
+
const serviceOutputs = processes.map((proc, idx) => ({
|
|
203
|
+
service: proc.name,
|
|
204
|
+
output: outputs[idx] || [],
|
|
205
|
+
port: ports[idx],
|
|
206
|
+
status: statuses[idx] || "starting",
|
|
207
|
+
}));
|
|
208
|
+
return (_jsx(Box, { flexDirection: "column", height: "100%", children: _jsx(MergedLogsPane, { services: serviceOutputs, onClear: handleClearMergedOutput }) }));
|
|
209
|
+
}
|
|
210
|
+
// TUI mode: has custom tabs - use tabbed interface
|
|
211
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(StatusLine, { activeTab: activeTab, tabs: allTabs.map((t) => t.name) }), isProcessTab(currentTab) ? (_jsx(ProcessPane, { title: currentTab.name, output: outputs[activeTab] || [], port: ports[activeTab], status: statuses[activeTab] || "starting", onClear: handleClearOutput })) : isCustomTab(currentTab) ? (currentTab.render()) : null] }));
|
|
136
212
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"town": "./dist/index.js"
|
|
@@ -18,16 +18,16 @@
|
|
|
18
18
|
"build": "tsc"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@townco/tsconfig": "0.1.
|
|
21
|
+
"@townco/tsconfig": "0.1.13",
|
|
22
22
|
"@types/bun": "^1.3.1",
|
|
23
23
|
"@types/react": "^19.2.2"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@optique/core": "^0.6.2",
|
|
27
27
|
"@optique/run": "^0.6.2",
|
|
28
|
-
"@townco/agent": "0.1.
|
|
29
|
-
"@townco/secret": "0.1.
|
|
30
|
-
"@townco/ui": "0.1.
|
|
28
|
+
"@townco/agent": "0.1.21",
|
|
29
|
+
"@townco/secret": "0.1.16",
|
|
30
|
+
"@townco/ui": "0.1.16",
|
|
31
31
|
"@types/inquirer": "^9.0.9",
|
|
32
32
|
"ink": "^6.4.0",
|
|
33
33
|
"ink-text-input": "^6.0.0",
|