@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.
@@ -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 { render } from "ink";
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 GuiRunner({ agentProcess, guiProcess, agentPort, onExit, }) {
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
- // Silently fail - user can open manually
24
- console.warn(`Could not automatically open browser: ${error.message}`);
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 (_error) {
58
- console.warn(`Warning: Could not load environment variables from ${envPath}`);
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
- console.log(`Starting interactive terminal for agent "${name}"...\n`);
166
- // Get path to TUI from agent's tui directory
167
- const tuiPath = join(agentPath, "tui", "dist", "index.js");
168
- // Check if TUI exists
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
- // Run TUI with the agent
180
- const tuiProcess = spawn("bun", [tuiPath, "--agent", binPath], {
181
- cwd: agentPath,
182
- stdio: "inherit",
183
- env: {
184
- ...process.env,
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,5 @@
1
+ export interface LogsPaneProps {
2
+ logsDir?: string;
3
+ sessionStartTime?: string;
4
+ }
5
+ export declare function LogsPane({ logsDir: customLogsDir, sessionStartTime: providedSessionStartTime, }?: LogsPaneProps): import("react/jsx-runtime").JSX.Element;
@@ -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 { useMemo } from "react";
4
- export function ProcessPane({ title, output, port, status }) {
5
- // Keep only last 50 lines to prevent memory issues
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
- return output.slice(-50);
8
- }, [output]);
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)}`)))) }), _jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexShrink: 0, children: _jsxs(Text, { color: "gray", children: ["Lines: ", output.length, " | Displaying last ", displayOutput.length] }) })] }));
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 { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
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: [_jsxs(Text, { color: activeTab === idx ? "green" : "gray", children: [idx + 1, ". ", 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: ", " }), _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), _jsx(Text, { color: "gray", children: " or " }), _jsxs(Text, { color: "yellow", children: ["1-", tabs.length] }), _jsx(Text, { color: "gray", children: " to switch" })] }));
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
- export function TabbedOutput({ processes, onExit, onPortDetected, }) {
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) % processes.length);
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 < processes.length - 1) {
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 currentProcess = processes[activeTab];
132
- if (!currentProcess) {
180
+ }, [processes, onPortDetected, logsDir]);
181
+ const currentTab = allTabs[activeTab];
182
+ if (!currentTab) {
133
183
  return null;
134
184
  }
135
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(StatusLine, { activeTab: activeTab, tabs: processes.map((p) => p.name) }), _jsx(ProcessPane, { title: currentProcess.name, output: outputs[activeTab] || [], port: ports[activeTab], status: statuses[activeTab] || "starting" })] }));
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.19",
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.11",
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.19",
29
- "@townco/secret": "0.1.14",
30
- "@townco/ui": "0.1.14",
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",