@townco/cli 0.1.27 → 0.1.28

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.
@@ -5,8 +5,8 @@ import { readFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { isInsideTownProject } from "@townco/agent/storage";
7
7
  import { createLogger } from "@townco/agent/utils";
8
- import { AcpClient } from "@townco/ui";
9
- import { ChatView } from "@townco/ui/tui";
8
+ import { AcpClient, configureLogsDir } from "@townco/ui";
9
+ import { ChatView, ChatViewStatus } from "@townco/ui/tui";
10
10
  import { Box, render, Text } from "ink";
11
11
  import open from "open";
12
12
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -16,10 +16,16 @@ import { findAvailablePort } from "../lib/port-utils.js";
16
16
  function TuiRunner({ agentPath, workingDir, onExit }) {
17
17
  const [client, setClient] = useState(null);
18
18
  const [error, setError] = useState(null);
19
+ // Configure logs directory for UI package loggers BEFORE any loggers are created
20
+ // This must run synchronously before any component initialization
21
+ useMemo(() => {
22
+ configureLogsDir(join(workingDir, ".logs"));
23
+ }, [workingDir]);
19
24
  // Session start time for log filtering - set once when component mounts
25
+ // Set to 5 seconds in the past to capture logs generated during initialization
20
26
  const sessionStartTime = useMemo(() => {
21
27
  const now = new Date();
22
- now.setSeconds(now.getSeconds() - 1);
28
+ now.setSeconds(now.getSeconds() - 5);
23
29
  return now.toISOString();
24
30
  }, []);
25
31
  // Create logger with agent directory as logs location
@@ -62,6 +68,11 @@ function TuiRunner({ agentPath, workingDir, onExit }) {
62
68
  }
63
69
  return _jsx(ChatView, { client: client });
64
70
  },
71
+ renderStatus: () => {
72
+ if (!client || error)
73
+ return null;
74
+ return _jsx(ChatViewStatus, { client: client });
75
+ },
65
76
  },
66
77
  {
67
78
  name: "Logs",
@@ -274,6 +285,10 @@ export async function runCommand(options) {
274
285
  }
275
286
  // Default: Start TUI interface with the agent
276
287
  logger.info("Starting TUI mode", { name, agentPath });
288
+ // Clear terminal and move cursor to top
289
+ if (process.stdout.isTTY) {
290
+ process.stdout.write("\x1b[2J\x1b[H");
291
+ }
277
292
  // Set stdin to raw mode for Ink
278
293
  if (process.stdin.isTTY) {
279
294
  process.stdin.setRawMode(true);
@@ -1,4 +1,4 @@
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 { spawn } from "node:child_process";
3
3
  import { existsSync, readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -36,6 +36,18 @@ export function LogsPane({ logsDir: customLogsDir, sessionStartTime: providedSes
36
36
  const [scrollOffset, setScrollOffset] = useState(0); // 0 = at bottom, positive = scrolled up
37
37
  const [searchMode, setSearchMode] = useState(false);
38
38
  const [searchQuery, setSearchQuery] = useState("");
39
+ const [expandedLogs, setExpandedLogs] = useState(false); // Start collapsed
40
+ const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows ?? 30);
41
+ // Track terminal resize
42
+ useEffect(() => {
43
+ const handleResize = () => {
44
+ setTerminalHeight(process.stdout.rows ?? 30);
45
+ };
46
+ process.stdout.on("resize", handleResize);
47
+ return () => {
48
+ process.stdout.off("resize", handleResize);
49
+ };
50
+ }, []);
39
51
  // Use provided session start time or default to 1 second before component mount
40
52
  const sessionStartTime = useMemo(() => {
41
53
  if (providedSessionStartTime)
@@ -167,16 +179,6 @@ export function LogsPane({ logsDir: customLogsDir, sessionStartTime: providedSes
167
179
  if (searchMode) {
168
180
  return;
169
181
  }
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
182
  // 's' to cycle through service filters
181
183
  if (input === "s") {
182
184
  if (serviceFilter === null) {
@@ -213,26 +215,39 @@ export function LogsPane({ logsDir: customLogsDir, sessionStartTime: providedSes
213
215
  setScrollOffset(0); // Also jump to bottom when clearing
214
216
  return;
215
217
  }
218
+ // 'e' to toggle expanded/collapsed metadata view
219
+ if (input === "e") {
220
+ setExpandedLogs((prev) => !prev);
221
+ return;
222
+ }
216
223
  });
217
224
  // Apply fuzzy search if active
218
225
  const searchedLogs = searchQuery
219
226
  ? filteredLogs.filter((log) => fuzzyMatch(searchQuery, log.message))
220
227
  : filteredLogs;
221
- // Calculate display window (25 lines) based on scroll position
228
+ // Calculate display window based on terminal height
229
+ // Reserve space for: status line (3), header (3), search box if active (3), footer (3), padding (1)
230
+ const reservedLines = 10 + (searchMode ? 3 : 0);
231
+ const availableLines = Math.max(10, terminalHeight - reservedLines);
232
+ // When expanded, each log entry with metadata takes ~5 lines on average
233
+ // When collapsed, each log entry takes 1 line
234
+ const avgLinesPerEntry = expandedLogs ? 5 : 1;
235
+ const maxLines = Math.floor(availableLines / avgLinesPerEntry);
222
236
  const totalLogs = searchedLogs.length;
223
- const maxLines = 25;
224
237
  const isAtBottom = scrollOffset === 0;
225
238
  // Calculate which slice of logs to show
226
239
  const displayLogs = isAtBottom
227
- ? searchedLogs.slice(-maxLines) // Show last 25 when at bottom
240
+ ? searchedLogs.slice(-maxLines) // Show last N lines when at bottom
228
241
  : searchedLogs.slice(Math.max(0, totalLogs - maxLines - scrollOffset), totalLogs - scrollOffset);
229
242
  const canScrollUp = totalLogs > maxLines && scrollOffset < totalLogs - maxLines;
230
243
  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) => {
244
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [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, 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
245
  const serviceColor = SERVICE_COLORS[availableServices.indexOf(log.service) % SERVICE_COLORS.length] || "white";
233
246
  const levelColor = LOG_COLORS[log.level] || "white";
234
247
  const time = new Date(log.timestamp).toLocaleTimeString();
235
248
  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 && "↓"] }))] }))] }));
249
+ return (_jsxs(Box, { flexDirection: "column", children: [_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] })] }), expandedLogs &&
250
+ log.metadata &&
251
+ Object.keys(log.metadata).length > 0 && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: JSON.stringify(log.metadata, null, 2) }) }))] }, keyStr));
252
+ })) }), _jsxs(Box, { borderStyle: "single", borderTop: true, borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [serviceFilter && _jsxs(Text, { children: ["[", serviceFilter, "] "] }), levelFilter && (_jsxs(Text, { children: ["[", ">=", levelFilter, "]", " "] })), searchQuery && _jsxs(Text, { color: "cyan", children: ["[SEARCH: ", searchQuery, "] "] }), !isAtBottom && _jsx(Text, { color: "yellow", children: "[SCROLLED] " }), expandedLogs && _jsx(Text, { color: "green", children: "[EXPANDED] " }), _jsxs(Text, { dimColor: true, children: [displayLogs.length, "/", totalLogs, " logs", scrollOffset > 0 && ` (${scrollOffset} from bottom)`] })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "(/)search | (s)ervice | (l)evel | (e)xpand | (c)lear | ESC" }) })] })] }));
238
253
  }
@@ -1,4 +1,4 @@
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, useInput } from "ink";
3
3
  import TextInput from "ink-text-input";
4
4
  import { useEffect, useMemo, useState } from "react";
@@ -86,16 +86,6 @@ export function MergedLogsPane({ services, onClear }) {
86
86
  if (searchMode) {
87
87
  return;
88
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
89
  // 's' to cycle through service filters
100
90
  if (input === "s") {
101
91
  if (serviceFilter === null) {
@@ -190,12 +180,12 @@ export function MergedLogsPane({ services, onClear }) {
190
180
  : overallStatus === "starting"
191
181
  ? "yellow"
192
182
  : "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
183
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [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
184
  ? "No logs match the current filters. Press 'c' to clear, 's' for service, or 'l' for level."
195
185
  : "Waiting for output..." })) : (displayLines.map((logLine, idx) => {
196
186
  const serviceColor = SERVICE_COLORS[availableServices.indexOf(logLine.service) %
197
187
  SERVICE_COLORS.length] || "white";
198
188
  const keyStr = `${logLine.service}-${logLine.serviceIndex}-${logLine.index}-${idx}`;
199
189
  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 && ""] }))] })] }));
190
+ })) }), _jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexShrink: 0, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", overallStatus] }), serviceFilter && _jsxs(Text, { children: [" | [", serviceFilter, "]"] }), levelFilter && (_jsxs(Text, { children: [" ", "| [", ">=", levelFilter, "]"] })), searchQuery && _jsxs(Text, { color: "cyan", children: [" | [SEARCH: ", searchQuery, "]"] }), !isAtBottom && _jsx(Text, { color: "yellow", children: " | [SCROLLED]" }), _jsxs(Text, { dimColor: true, children: [" ", "| ", displayLines.length, "/", searchedLines.length, " lines"] }), scrollOffset > 0 && (_jsxs(Text, { dimColor: true, children: [" (", scrollOffset, " from bottom)"] }))] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "(/)search | (s)ervice | (l)evel | (c)lear | ESC" }) })] })] }));
201
191
  }
@@ -1,5 +1,7 @@
1
+ import type React from "react";
1
2
  export interface StatusLineProps {
2
3
  activeTab: number;
3
4
  tabs: string[];
5
+ rightContent?: React.ReactNode;
4
6
  }
5
- export declare function StatusLine({ activeTab, tabs }: StatusLineProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function StatusLine({ activeTab, tabs, rightContent }: StatusLineProps): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function StatusLine({ activeTab, tabs }) {
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" })] }));
3
+ export function StatusLine({ activeTab, tabs, rightContent }) {
4
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { 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: " to switch" })] }), rightContent && _jsx(Box, { children: rightContent })] }));
5
5
  }
@@ -9,6 +9,7 @@ export interface CustomTabInfo {
9
9
  name: string;
10
10
  type: "custom";
11
11
  render: () => React.ReactNode;
12
+ renderStatus?: () => React.ReactNode;
12
13
  }
13
14
  export type TabInfo = ProcessInfo | CustomTabInfo;
14
15
  export interface TabbedOutputProps {
@@ -44,12 +44,6 @@ export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPo
44
44
  setActiveTab((activeTab + 1) % allTabs.length);
45
45
  return;
46
46
  }
47
- if (key.leftArrow && activeTab > 0) {
48
- setActiveTab(activeTab - 1);
49
- }
50
- else if (key.rightArrow && activeTab < allTabs.length - 1) {
51
- setActiveTab(activeTab + 1);
52
- }
53
47
  });
54
48
  // Set up process output listeners
55
49
  useEffect(() => {
@@ -208,5 +202,9 @@ export function TabbedOutput({ processes, customTabs = [], logsDir, onExit, onPo
208
202
  return (_jsx(Box, { flexDirection: "column", height: "100%", children: _jsx(MergedLogsPane, { services: serviceOutputs, onClear: handleClearMergedOutput }) }));
209
203
  }
210
204
  // 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] }));
205
+ // Get status content from current tab if available
206
+ const statusContent = isCustomTab(currentTab) && currentTab.renderStatus
207
+ ? currentTab.renderStatus()
208
+ : null;
209
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [isProcessTab(currentTab) ? (_jsx(ProcessPane, { title: currentTab.name, output: outputs[activeTab] || [], port: ports[activeTab], status: statuses[activeTab] || "starting", onClear: handleClearOutput })) : isCustomTab(currentTab) ? (currentTab.render()) : null, _jsx(StatusLine, { activeTab: activeTab, tabs: allTabs.map((t) => t.name), rightContent: statusContent })] }));
212
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/cli",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
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.19",
21
+ "@townco/tsconfig": "0.1.20",
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.27",
29
- "@townco/secret": "0.1.22",
30
- "@townco/ui": "0.1.22",
28
+ "@townco/agent": "0.1.28",
29
+ "@townco/secret": "0.1.23",
30
+ "@townco/ui": "0.1.23",
31
31
  "@types/inquirer": "^9.0.9",
32
32
  "ink": "^6.4.0",
33
33
  "ink-text-input": "^6.0.0",