@townco/ui 0.1.22 → 0.1.23

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,6 +1,8 @@
1
1
  import { useCallback } from "react";
2
+ import { createLogger } from "../lib/logger.js";
2
3
  import { useChatStore } from "../store/chat-store.js";
3
4
  import { useChatMessages } from "./use-chat-messages.js";
5
+ const logger = createLogger("use-chat-input", "debug");
4
6
  /**
5
7
  * Hook for managing chat input
6
8
  */
@@ -33,7 +35,9 @@ export function useChatInput(client) {
33
35
  }
34
36
  catch (error) {
35
37
  // Error is handled in useChatMessages
36
- console.error("Failed to send message:", error);
38
+ logger.error("Failed to send message", {
39
+ error: error instanceof Error ? error.message : String(error),
40
+ });
37
41
  }
38
42
  finally {
39
43
  setInputSubmitting(false);
@@ -1,5 +1,7 @@
1
1
  import { useCallback } from "react";
2
+ import { createLogger } from "../lib/logger.js";
2
3
  import { useChatStore } from "../store/chat-store.js";
4
+ const logger = createLogger("use-chat-messages", "debug");
3
5
  /**
4
6
  * Hook for managing chat messages
5
7
  */
@@ -17,12 +19,12 @@ export function useChatMessages(client) {
17
19
  */
18
20
  const sendMessage = useCallback(async (content) => {
19
21
  if (!client) {
20
- console.error("No client available");
22
+ logger.error("No client available");
21
23
  setError("No client available");
22
24
  return;
23
25
  }
24
26
  if (!sessionId) {
25
- console.error("No active session");
27
+ logger.error("No active session");
26
28
  setError("No active session");
27
29
  return;
28
30
  }
@@ -74,7 +76,9 @@ export function useChatMessages(client) {
74
76
  let accumulatedContent = "";
75
77
  for await (const chunk of messageStream) {
76
78
  if (chunk.tokenUsage) {
77
- console.error("DEBUG use-chat-messages: chunk.tokenUsage:", JSON.stringify(chunk.tokenUsage));
79
+ logger.debug("chunk.tokenUsage", {
80
+ tokenUsage: chunk.tokenUsage,
81
+ });
78
82
  }
79
83
  if (chunk.isComplete) {
80
84
  // Update final message
@@ -3,7 +3,7 @@ import type { AcpClient } from "../../sdk/client/index.js";
3
3
  * Hook for managing chat session lifecycle
4
4
  */
5
5
  export declare function useChatSession(client: AcpClient | null): {
6
- connectionStatus: "error" | "connecting" | "connected" | "disconnected";
6
+ connectionStatus: "disconnected" | "connecting" | "connected" | "error";
7
7
  sessionId: string | null;
8
8
  connect: () => Promise<void>;
9
9
  startSession: () => Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useEffect } from "react";
2
+ import { createLogger } from "../lib/logger.js";
2
3
  import { useChatStore } from "../store/chat-store.js";
4
+ const logger = createLogger("use-chat-session", "debug");
3
5
  /**
4
6
  * Hook for managing chat session lifecycle
5
7
  */
@@ -26,7 +28,9 @@ export function useChatSession(client) {
26
28
  setConnectionStatus("connected");
27
29
  }
28
30
  catch (error) {
29
- console.log(error);
31
+ logger.error("Failed to connect", {
32
+ error: error instanceof Error ? error.message : String(error),
33
+ });
30
34
  const message = error instanceof Error ? error.message : String(error);
31
35
  setError(message);
32
36
  setConnectionStatus("error");
@@ -1,12 +1,46 @@
1
1
  /**
2
2
  * Browser-compatible logger
3
3
  * Outputs structured JSON logs to console with color-coding
4
+ * Also captures logs to a global store for in-app viewing
5
+ * In Node.js environment with logsDir option, also writes to .logs/ directory
4
6
  */
5
7
  export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
8
+ export interface LogEntry {
9
+ id: string;
10
+ timestamp: string;
11
+ level: LogLevel;
12
+ service: string;
13
+ message: string;
14
+ metadata?: Record<string, unknown>;
15
+ }
16
+ /**
17
+ * Get all captured logs
18
+ */
19
+ export declare function getCapturedLogs(): LogEntry[];
20
+ /**
21
+ * Clear all captured logs
22
+ */
23
+ export declare function clearCapturedLogs(): void;
24
+ /**
25
+ * Subscribe to log updates
26
+ */
27
+ type LogSubscriber = (entry: LogEntry) => void;
28
+ export declare function subscribeToLogs(callback: LogSubscriber): () => void;
29
+ /**
30
+ * Configure global logs directory for file writing
31
+ * Must be called before creating any loggers (typically at TUI startup)
32
+ */
33
+ export declare function configureLogsDir(logsDir: string): void;
6
34
  export declare class Logger {
7
35
  private service;
8
36
  private minLevel;
37
+ private logFilePath?;
38
+ private logsDir?;
39
+ private writeQueue;
40
+ private isWriting;
9
41
  constructor(service: string, minLevel?: LogLevel);
42
+ private setupFileLogging;
43
+ private writeToFile;
10
44
  private shouldLog;
11
45
  private log;
12
46
  trace(message: string, metadata?: Record<string, unknown>): void;
@@ -22,3 +56,4 @@ export declare class Logger {
22
56
  * @param minLevel - Minimum log level to display (default: "debug")
23
57
  */
24
58
  export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
59
+ export {};
@@ -1,7 +1,45 @@
1
1
  /**
2
2
  * Browser-compatible logger
3
3
  * Outputs structured JSON logs to console with color-coding
4
+ * Also captures logs to a global store for in-app viewing
5
+ * In Node.js environment with logsDir option, also writes to .logs/ directory
4
6
  */
7
+ // Check if running in Node.js
8
+ const isNode = typeof process !== "undefined" && process.versions?.node;
9
+ // Global logs directory configuration (set once at app startup for TUI)
10
+ let globalLogsDir;
11
+ // Global log store
12
+ const globalLogStore = [];
13
+ let logIdCounter = 0;
14
+ /**
15
+ * Get all captured logs
16
+ */
17
+ export function getCapturedLogs() {
18
+ return [...globalLogStore];
19
+ }
20
+ /**
21
+ * Clear all captured logs
22
+ */
23
+ export function clearCapturedLogs() {
24
+ globalLogStore.length = 0;
25
+ }
26
+ const logSubscribers = new Set();
27
+ export function subscribeToLogs(callback) {
28
+ logSubscribers.add(callback);
29
+ return () => logSubscribers.delete(callback);
30
+ }
31
+ function notifyLogSubscribers(entry) {
32
+ for (const callback of logSubscribers) {
33
+ callback(entry);
34
+ }
35
+ }
36
+ /**
37
+ * Configure global logs directory for file writing
38
+ * Must be called before creating any loggers (typically at TUI startup)
39
+ */
40
+ export function configureLogsDir(logsDir) {
41
+ globalLogsDir = logsDir;
42
+ }
5
43
  const LOG_LEVELS = {
6
44
  trace: 0,
7
45
  debug: 1,
@@ -18,7 +56,7 @@ const _LOG_COLORS = {
18
56
  error: "#EF4444", // red
19
57
  fatal: "#DC2626", // dark red
20
58
  };
21
- const LOG_STYLES = {
59
+ const _LOG_STYLES = {
22
60
  trace: "color: #6B7280",
23
61
  debug: "color: #3B82F6; font-weight: bold",
24
62
  info: "color: #10B981; font-weight: bold",
@@ -29,6 +67,10 @@ const LOG_STYLES = {
29
67
  export class Logger {
30
68
  service;
31
69
  minLevel;
70
+ logFilePath;
71
+ logsDir;
72
+ writeQueue = [];
73
+ isWriting = false;
32
74
  constructor(service, minLevel = "debug") {
33
75
  this.service = service;
34
76
  this.minLevel = minLevel;
@@ -37,6 +79,48 @@ export class Logger {
37
79
  process.env?.NODE_ENV === "production") {
38
80
  this.minLevel = "info";
39
81
  }
82
+ // Note: File logging setup is done lazily in log() method
83
+ // This allows loggers created before configureLogsDir() to still write to files
84
+ }
85
+ setupFileLogging() {
86
+ if (!isNode || !globalLogsDir)
87
+ return;
88
+ try {
89
+ // Dynamic import for Node.js modules
90
+ const path = require("node:path");
91
+ const fs = require("node:fs");
92
+ this.logsDir = globalLogsDir;
93
+ this.logFilePath = path.join(this.logsDir, `${this.service}.log`);
94
+ // Create logs directory if it doesn't exist
95
+ if (!fs.existsSync(this.logsDir)) {
96
+ fs.mkdirSync(this.logsDir, { recursive: true });
97
+ }
98
+ }
99
+ catch (_error) {
100
+ // Silently fail if we can't set up file logging
101
+ }
102
+ }
103
+ async writeToFile(content) {
104
+ if (!this.logFilePath || !isNode)
105
+ return;
106
+ this.writeQueue.push(content);
107
+ if (this.isWriting) {
108
+ return;
109
+ }
110
+ this.isWriting = true;
111
+ while (this.writeQueue.length > 0) {
112
+ const batch = this.writeQueue.splice(0, this.writeQueue.length);
113
+ const data = `${batch.join("\n")}\n`;
114
+ try {
115
+ // Dynamic import for Node.js modules
116
+ const fs = require("node:fs");
117
+ await fs.promises.appendFile(this.logFilePath, data, "utf-8");
118
+ }
119
+ catch (_error) {
120
+ // Silently fail
121
+ }
122
+ }
123
+ this.isWriting = false;
40
124
  }
41
125
  shouldLog(level) {
42
126
  return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
@@ -46,38 +130,37 @@ export class Logger {
46
130
  return;
47
131
  }
48
132
  const entry = {
133
+ id: `log_${++logIdCounter}`,
49
134
  timestamp: new Date().toISOString(),
50
135
  level,
51
136
  service: this.service,
52
137
  message,
53
138
  ...(metadata && { metadata }),
54
139
  };
55
- // Console output with color-coding
56
- const style = LOG_STYLES[level];
57
- const levelUpper = level.toUpperCase().padEnd(5);
58
- if (typeof console !== "undefined") {
59
- // Format: [timestamp] [SERVICE] [LEVEL] message
60
- const prefix = `%c[${entry.timestamp}] [${this.service}] [${levelUpper}]`;
61
- const msg = metadata ? `${message} %o` : message;
62
- switch (level) {
63
- case "trace":
64
- case "debug":
65
- case "info":
66
- console.log(prefix, style, msg, ...(metadata ? [metadata] : []));
67
- break;
68
- case "warn":
69
- console.warn(prefix, style, msg, ...(metadata ? [metadata] : []));
70
- break;
71
- case "error":
72
- case "fatal":
73
- console.error(prefix, style, msg, ...(metadata ? [metadata] : []));
74
- break;
75
- }
140
+ // Store in global log store
141
+ globalLogStore.push(entry);
142
+ // Notify subscribers
143
+ notifyLogSubscribers(entry);
144
+ // Write to file in Node.js (for logs tab to read)
145
+ // Lazily set up file logging if globalLogsDir was configured after this logger was created
146
+ if (isNode && !this.logFilePath && globalLogsDir) {
147
+ this.setupFileLogging();
76
148
  }
77
- // Also log the structured JSON for debugging
78
- if (level === "fatal" || level === "error") {
79
- console.debug("Structured log:", JSON.stringify(entry));
149
+ if (isNode && this.logFilePath) {
150
+ // Write as JSON without the id field (to match expected format)
151
+ const fileEntry = {
152
+ timestamp: entry.timestamp,
153
+ level: entry.level,
154
+ service: entry.service,
155
+ message: entry.message,
156
+ ...(entry.metadata && { metadata: entry.metadata }),
157
+ };
158
+ this.writeToFile(JSON.stringify(fileEntry)).catch(() => {
159
+ // Silently fail
160
+ });
80
161
  }
162
+ // No console output - logs are only captured and displayed in UI
163
+ // This prevents logs from polluting stdout/stderr in TUI mode
81
164
  }
82
165
  trace(message, metadata) {
83
166
  this.log("trace", message, metadata);
@@ -1,3 +1,4 @@
1
+ import { type LogEntry } from "../lib/logger.js";
1
2
  import type { ConnectionStatus, DisplayMessage, InputState } from "../schemas/index.js";
2
3
  import type { ToolCall, ToolCallUpdate } from "../schemas/tool-call.js";
3
4
  /**
@@ -23,6 +24,8 @@ export interface ChatStore {
23
24
  };
24
25
  currentModel: string | null;
25
26
  tokenDisplayMode: "context" | "input" | "output";
27
+ logs: LogEntry[];
28
+ activeTab: "chat" | "logs";
26
29
  input: InputState;
27
30
  setConnectionStatus: (status: ConnectionStatus) => void;
28
31
  setSessionId: (id: string | null) => void;
@@ -49,6 +52,9 @@ export interface ChatStore {
49
52
  setCurrentModel: (model: string) => void;
50
53
  resetTokens: () => void;
51
54
  cycleTokenDisplayMode: () => void;
55
+ addLog: (log: LogEntry) => void;
56
+ clearLogs: () => void;
57
+ setActiveTab: (tab: "chat" | "logs") => void;
52
58
  }
53
59
  /**
54
60
  * Create chat store
@@ -1,5 +1,7 @@
1
1
  import { create } from "zustand";
2
+ import { createLogger } from "../lib/logger.js";
2
3
  import { mergeToolCallUpdate } from "../schemas/tool-call.js";
4
+ const logger = createLogger("chat-store", "debug");
3
5
  /**
4
6
  * Create chat store
5
7
  */
@@ -24,6 +26,8 @@ export const useChatStore = create((set) => ({
24
26
  },
25
27
  currentModel: "claude-sonnet-4-5-20250929", // Default model, TODO: get from server
26
28
  tokenDisplayMode: "context", // Default to showing context (both billed and current)
29
+ logs: [],
30
+ activeTab: "chat",
27
31
  input: {
28
32
  value: "",
29
33
  isSubmitting: false,
@@ -46,7 +50,10 @@ export const useChatStore = create((set) => ({
46
50
  let finalUpdates = updates;
47
51
  if (updates.tokenUsage) {
48
52
  const existingTokenUsage = existingMessage?.tokenUsage;
49
- console.error("DEBUG updateMessage: incoming tokenUsage:", JSON.stringify(updates.tokenUsage), "existing:", JSON.stringify(existingTokenUsage));
53
+ logger.debug("updateMessage: incoming tokenUsage", {
54
+ incoming: updates.tokenUsage,
55
+ existing: existingTokenUsage,
56
+ });
50
57
  // LangChain sends multiple token updates:
51
58
  // 1. Early chunk: inputTokens (context) + outputTokens (estimate) + totalTokens
52
59
  // 2. Later chunk: inputTokens=0 + outputTokens (final) + totalTokens (just output)
@@ -58,7 +65,9 @@ export const useChatStore = create((set) => ({
58
65
  totalTokens: Math.max(updates.tokenUsage.inputTokens ?? 0, existingTokenUsage?.inputTokens ?? 0) +
59
66
  Math.max(updates.tokenUsage.outputTokens ?? 0, existingTokenUsage?.outputTokens ?? 0),
60
67
  };
61
- console.error("DEBUG updateMessage: merged tokenUsage:", JSON.stringify(messageMaxTokens));
68
+ logger.debug("updateMessage: merged tokenUsage", {
69
+ merged: messageMaxTokens,
70
+ });
62
71
  // Replace the tokenUsage in updates with the max values
63
72
  finalUpdates = {
64
73
  ...updates,
@@ -112,7 +121,7 @@ export const useChatStore = create((set) => ({
112
121
  const isBilledCorrect = actualSum.inputTokens === newTotalBilled.inputTokens &&
113
122
  actualSum.outputTokens === newTotalBilled.outputTokens &&
114
123
  actualSum.totalTokens === newTotalBilled.totalTokens;
115
- console.error("DEBUG updateMessage: tokenUsage update", JSON.stringify({
124
+ logger.debug("updateMessage: tokenUsage update", {
116
125
  messageId: id,
117
126
  updates: updates.tokenUsage,
118
127
  existing: existingTokenUsage,
@@ -125,7 +134,7 @@ export const useChatStore = create((set) => ({
125
134
  messageCount: messages.length,
126
135
  messagesWithTokens: messageTokenBreakdown.length,
127
136
  breakdown: messageTokenBreakdown,
128
- }));
137
+ });
129
138
  }
130
139
  return {
131
140
  messages,
@@ -146,7 +155,7 @@ export const useChatStore = create((set) => ({
146
155
  // Find the most recent assistant message (which should be streaming)
147
156
  const lastAssistantIndex = state.messages.findLastIndex((msg) => msg.role === "assistant");
148
157
  if (lastAssistantIndex === -1) {
149
- console.warn("No assistant message found to add tool call to");
158
+ logger.warn("No assistant message found to add tool call to");
150
159
  return state;
151
160
  }
152
161
  const messages = [...state.messages];
@@ -163,7 +172,7 @@ export const useChatStore = create((set) => ({
163
172
  // Find the most recent assistant message
164
173
  const lastAssistantIndex = state.messages.findLastIndex((msg) => msg.role === "assistant");
165
174
  if (lastAssistantIndex === -1) {
166
- console.warn("No assistant message found to update tool call in");
175
+ logger.warn("No assistant message found to update tool call in");
167
176
  return state;
168
177
  }
169
178
  const messages = [...state.messages];
@@ -173,7 +182,7 @@ export const useChatStore = create((set) => ({
173
182
  const toolCalls = lastAssistantMsg.toolCalls || [];
174
183
  const existingIndex = toolCalls.findIndex((tc) => tc.id === update.id);
175
184
  if (existingIndex === -1) {
176
- console.warn(`Tool call ${update.id} not found in message`);
185
+ logger.warn(`Tool call ${update.id} not found in message`);
177
186
  return state;
178
187
  }
179
188
  const existing = toolCalls[existingIndex];
@@ -271,4 +280,9 @@ export const useChatStore = create((set) => ({
271
280
  return state; // Should never happen, but satisfies TypeScript
272
281
  return { tokenDisplayMode: nextMode };
273
282
  }),
283
+ addLog: (log) => set((state) => ({
284
+ logs: [...state.logs, log],
285
+ })),
286
+ clearLogs: () => set({ logs: [] }),
287
+ setActiveTab: (tab) => set({ activeTab: tab }),
274
288
  }));
@@ -1,6 +1,8 @@
1
+ import { createLogger } from "../../core/lib/logger.js";
1
2
  import { HttpTransport } from "../transports/http.js";
2
3
  import { StdioTransport } from "../transports/stdio.js";
3
4
  import { WebSocketTransport } from "../transports/websocket.js";
5
+ const logger = createLogger("acp-client", "debug");
4
6
  /**
5
7
  * Simplified ACP client with explicit transport selection
6
8
  */
@@ -17,7 +19,9 @@ export class AcpClient {
17
19
  this.setupTransportListeners();
18
20
  if (config.autoConnect) {
19
21
  this.connect().catch((error) => {
20
- console.error("Failed to auto-connect:", error);
22
+ logger.error("Failed to auto-connect", {
23
+ error: error instanceof Error ? error.message : String(error),
24
+ });
21
25
  });
22
26
  }
23
27
  }
@@ -191,7 +195,9 @@ export class AcpClient {
191
195
  handler(update);
192
196
  }
193
197
  catch (error) {
194
- console.error("Error in session update handler:", error);
198
+ logger.error("Error in session update handler", {
199
+ error: error instanceof Error ? error.message : String(error),
200
+ });
195
201
  }
196
202
  }
197
203
  }
@@ -202,7 +208,9 @@ export class AcpClient {
202
208
  handler(error);
203
209
  }
204
210
  catch (err) {
205
- console.error("Error in error handler:", err);
211
+ logger.error("Error in error handler", {
212
+ error: err instanceof Error ? err.message : String(err),
213
+ });
206
214
  }
207
215
  }
208
216
  }
@@ -3,3 +3,6 @@ export interface ChatViewProps {
3
3
  client: AcpClient | null;
4
4
  }
5
5
  export declare function ChatView({ client }: ChatViewProps): import("react/jsx-runtime").JSX.Element;
6
+ export declare function ChatViewStatus({ client }: {
7
+ client: AcpClient | null;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -25,5 +25,17 @@ export function ChatView({ client }) {
25
25
  setIsStreaming(false);
26
26
  }
27
27
  };
28
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(MessageList, { messages: messages }) }), _jsx(InputBox, { value: value, isSubmitting: isSubmitting, attachedFiles: attachedFiles, onChange: onChange, onSubmit: onSubmit, onEscape: handleEscape }), _jsx(StatusBar, { connectionStatus: connectionStatus, sessionId: sessionId, isStreaming: isStreaming, streamingStartTime: streamingStartTime, hasStreamingContent: hasStreamingContent, totalBilled: totalBilled, currentContext: currentContext, currentModel: currentModel, tokenDisplayMode: tokenDisplayMode })] }));
28
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(MessageList, { messages: messages }) }), _jsx(InputBox, { value: value, isSubmitting: isSubmitting, attachedFiles: attachedFiles, onChange: onChange, onSubmit: onSubmit, onEscape: handleEscape })] }));
29
+ }
30
+ // Export helper to render status content separately
31
+ export function ChatViewStatus({ client }) {
32
+ const streamingStartTime = useChatStore((state) => state.streamingStartTime);
33
+ const totalBilled = useChatStore((state) => state.totalBilled);
34
+ const currentContext = useChatStore((state) => state.currentContext);
35
+ const currentModel = useChatStore((state) => state.currentModel);
36
+ const tokenDisplayMode = useChatStore((state) => state.tokenDisplayMode);
37
+ const { connectionStatus, sessionId } = useChatSession(client);
38
+ const { messages, isStreaming } = useChatMessages(client);
39
+ const hasStreamingContent = messages.some((msg) => msg.isStreaming && msg.content.length > 0);
40
+ return (_jsx(StatusBar, { connectionStatus: connectionStatus, sessionId: sessionId, isStreaming: isStreaming, streamingStartTime: streamingStartTime, hasStreamingContent: hasStreamingContent, totalBilled: totalBilled, currentContext: currentContext, currentModel: currentModel, tokenDisplayMode: tokenDisplayMode }));
29
41
  }
@@ -1,10 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useStdout } from "ink";
3
- import { ReadlineInput } from "./ReadlineInput.js";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import { useState } from "react";
4
+ import { SimpleTextInput } from "./SimpleTextInput.js";
4
5
  export function InputBox({ value, isSubmitting, attachedFiles, onChange, onSubmit, onEscape, }) {
5
6
  const { stdout } = useStdout();
6
7
  const terminalWidth = stdout?.columns || 80;
7
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "blue", children: "─".repeat(terminalWidth) }), attachedFiles.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsx(Text, { dimColor: true, children: "Attached files:" }), attachedFiles.map((file) => (_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [" ", file.name] }), _jsxs(Text, { dimColor: true, children: [" (", formatFileSize(file.size), ")"] })] }, file.path)))] })), _jsxs(Box, { paddingY: 1, children: [_jsx(Text, { bold: true, color: "blue", children: "> " }), isSubmitting ? (_jsx(Text, { color: "gray", italic: true, children: "Sending..." })) : onEscape ? (_jsx(ReadlineInput, { value: value, onChange: onChange, onSubmit: onSubmit, onEscape: onEscape, placeholder: "Type your message..." })) : (_jsx(ReadlineInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: "Type your message..." }))] }), _jsx(Text, { color: "blue", children: "─".repeat(terminalWidth) })] }));
8
+ // Handle special keys for multi-line and escape
9
+ useInput((_input, key) => {
10
+ // Escape key
11
+ if (key.escape && onEscape) {
12
+ onEscape();
13
+ return;
14
+ }
15
+ // Don't interfere if submitting
16
+ if (isSubmitting)
17
+ return;
18
+ // Shift+Enter or Alt+Enter: insert newline
19
+ if (key.return && (key.shift || key.meta)) {
20
+ onChange(value + "\n");
21
+ return;
22
+ }
23
+ });
24
+ // Split value into lines for display
25
+ const lines = value.split("\n");
26
+ const hasMultipleLines = lines.length > 1;
27
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "blue", children: "─".repeat(terminalWidth) }), attachedFiles.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Attached files:" }), attachedFiles.map((file) => (_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [" ", file.name] }), _jsxs(Text, { dimColor: true, children: [" (", formatFileSize(file.size), ")"] })] }, file.path)))] })), _jsx(Box, { paddingY: 1, flexDirection: "column", children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { bold: true, color: "blue", children: "> " }), _jsx(Box, { flexGrow: 1, children: isSubmitting ? (_jsx(Text, { color: "gray", italic: true, children: "Sending..." })) : (_jsx(SimpleTextInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: "Type your message... (\\ or Shift+Enter for newline)" })) })] }) })] }));
8
28
  }
9
29
  function formatFileSize(bytes) {
10
30
  if (bytes < 1024)
@@ -0,0 +1,5 @@
1
+ import type { LogEntry } from "../../core/lib/logger.js";
2
+ export interface LogsPanelProps {
3
+ logs: LogEntry[];
4
+ }
5
+ export declare function LogsPanel({ logs: initialLogs }: LogsPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useEffect, useState } from "react";
4
+ import { subscribeToLogs } from "../../core/lib/logger.js";
5
+ // Color mapping for log levels
6
+ const LOG_LEVEL_COLORS = {
7
+ trace: "gray",
8
+ debug: "blue",
9
+ info: "green",
10
+ warn: "yellow",
11
+ error: "red",
12
+ fatal: "red",
13
+ };
14
+ export function LogsPanel({ logs: initialLogs }) {
15
+ const [logs, setLogs] = useState(initialLogs);
16
+ // Subscribe to new logs
17
+ useEffect(() => {
18
+ const unsubscribe = subscribeToLogs((entry) => {
19
+ setLogs((prev) => [...prev, entry]);
20
+ });
21
+ return unsubscribe;
22
+ }, []);
23
+ if (logs.length === 0) {
24
+ return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "No logs yet..." }) }));
25
+ }
26
+ // Show last 100 logs
27
+ const displayLogs = logs.slice(-100);
28
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: displayLogs.map((log) => (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { dimColor: true, children: new Date(log.timestamp).toLocaleTimeString() }), _jsxs(Text, { color: LOG_LEVEL_COLORS[log.level], bold: true, children: ["[", log.level.toUpperCase(), "]"] }), _jsxs(Text, { dimColor: true, children: ["[", log.service, "]"] }), _jsx(Text, { children: log.message }), log.metadata && (_jsx(Text, { dimColor: true, children: JSON.stringify(log.metadata) }))] }, log.id))) }));
29
+ }
@@ -3,7 +3,7 @@ import { Box, Text } from "ink";
3
3
  import { GameOfLife } from "./GameOfLife.js";
4
4
  import { ToolCall } from "./ToolCall.js";
5
5
  export function MessageList({ messages }) {
6
- return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: messages.length === 0 ? (_jsx(GameOfLife, {})) : (messages.map((message) => (_jsx(Message, { message: message }, message.id)))) }));
6
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, height: "100%", children: messages.length === 0 ? (_jsx(GameOfLife, {})) : (messages.map((message) => (_jsx(Message, { message: message }, message.id)))) }));
7
7
  }
8
8
  function Message({ message }) {
9
9
  const roleColor = message.role === "user"
@@ -0,0 +1,7 @@
1
+ export interface SimpleTextInputProps {
2
+ value: string;
3
+ onChange: (value: string) => void;
4
+ onSubmit: () => void;
5
+ placeholder?: string;
6
+ }
7
+ export declare function SimpleTextInput({ value, onChange, onSubmit, placeholder, }: SimpleTextInputProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,208 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, useInput } from "ink";
3
+ import { useEffect, useState } from "react";
4
+ export function SimpleTextInput({ value, onChange, onSubmit, placeholder = "", }) {
5
+ const [cursorOffset, setCursorOffset] = useState(0);
6
+ useInput((input, key) => {
7
+ // Handle return/enter
8
+ if (key.return) {
9
+ onSubmit();
10
+ return;
11
+ }
12
+ // Handle backspace/delete
13
+ if (key.backspace || key.delete) {
14
+ const cursorPos = value.length + cursorOffset;
15
+ if (cursorPos > 0) {
16
+ const newValue = value.slice(0, cursorPos - 1) + value.slice(cursorPos);
17
+ onChange(newValue);
18
+ if (cursorOffset < 0) {
19
+ setCursorOffset(cursorOffset + 1);
20
+ }
21
+ }
22
+ return;
23
+ }
24
+ // Handle left arrow
25
+ if (key.leftArrow) {
26
+ const cursorPos = value.length + cursorOffset;
27
+ if (cursorPos > 0) {
28
+ setCursorOffset(cursorOffset - 1);
29
+ }
30
+ return;
31
+ }
32
+ // Handle right arrow
33
+ if (key.rightArrow) {
34
+ const cursorPos = value.length + cursorOffset;
35
+ if (cursorPos < value.length) {
36
+ setCursorOffset(cursorOffset + 1);
37
+ }
38
+ return;
39
+ }
40
+ // Handle up arrow - navigate to previous line
41
+ if (key.upArrow) {
42
+ const cursorPos = value.length + cursorOffset;
43
+ const lines = value.split("\n");
44
+ // Find current line and position within it
45
+ let currentLineIndex = 0;
46
+ let charCount = 0;
47
+ let posInLine = cursorPos;
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const lineLength = (lines[i]?.length || 0) + (i < lines.length - 1 ? 1 : 0);
50
+ if (charCount + lineLength > cursorPos || i === lines.length - 1) {
51
+ currentLineIndex = i;
52
+ posInLine = cursorPos - charCount;
53
+ break;
54
+ }
55
+ charCount += lineLength;
56
+ }
57
+ // Move to previous line if possible
58
+ if (currentLineIndex > 0) {
59
+ const prevLine = lines[currentLineIndex - 1] || "";
60
+ const currentLine = lines[currentLineIndex] || "";
61
+ const prevLineStart = charCount - (currentLine.length + 1);
62
+ const targetPos = Math.min(posInLine, prevLine.length);
63
+ const newCursorPos = prevLineStart + targetPos;
64
+ setCursorOffset(newCursorPos - value.length);
65
+ }
66
+ return;
67
+ }
68
+ // Handle down arrow - navigate to next line
69
+ if (key.downArrow) {
70
+ const cursorPos = value.length + cursorOffset;
71
+ const lines = value.split("\n");
72
+ // Find current line and position within it
73
+ let currentLineIndex = 0;
74
+ let charCount = 0;
75
+ let posInLine = cursorPos;
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const lineLength = (lines[i]?.length || 0) + (i < lines.length - 1 ? 1 : 0);
78
+ if (charCount + lineLength > cursorPos || i === lines.length - 1) {
79
+ currentLineIndex = i;
80
+ posInLine = cursorPos - charCount;
81
+ break;
82
+ }
83
+ charCount += lineLength;
84
+ }
85
+ // Move to next line if possible
86
+ if (currentLineIndex < lines.length - 1) {
87
+ const nextLine = lines[currentLineIndex + 1] || "";
88
+ const currentLine = lines[currentLineIndex] || "";
89
+ const nextLineStart = charCount + currentLine.length + 1;
90
+ const targetPos = Math.min(posInLine, nextLine.length);
91
+ const newCursorPos = nextLineStart + targetPos;
92
+ setCursorOffset(newCursorPos - value.length);
93
+ }
94
+ return;
95
+ }
96
+ // Handle Ctrl+A (move to beginning)
97
+ if (key.ctrl && input === "a") {
98
+ setCursorOffset(-value.length);
99
+ return;
100
+ }
101
+ // Handle Ctrl+E (move to end)
102
+ if (key.ctrl && input === "e") {
103
+ setCursorOffset(0);
104
+ return;
105
+ }
106
+ // Handle Ctrl+W (delete word backward)
107
+ if (key.ctrl && input === "w") {
108
+ const cursorPos = value.length + cursorOffset;
109
+ const before = value.slice(0, cursorPos);
110
+ const after = value.slice(cursorPos);
111
+ const match = before.match(/\s*\S*$/);
112
+ if (match) {
113
+ const newBefore = before.slice(0, -match[0].length);
114
+ onChange(newBefore + after);
115
+ setCursorOffset(-after.length);
116
+ }
117
+ return;
118
+ }
119
+ // Handle Ctrl+U (delete to beginning)
120
+ if (key.ctrl && input === "u") {
121
+ const cursorPos = value.length + cursorOffset;
122
+ onChange(value.slice(cursorPos));
123
+ setCursorOffset(-value.slice(cursorPos).length);
124
+ return;
125
+ }
126
+ // Handle Ctrl+K (delete to end)
127
+ if (key.ctrl && input === "k") {
128
+ const cursorPos = value.length + cursorOffset;
129
+ onChange(value.slice(0, cursorPos));
130
+ setCursorOffset(0);
131
+ return;
132
+ }
133
+ // Regular character input
134
+ if (!key.ctrl && !key.meta && input.length > 0) {
135
+ const cursorPos = value.length + cursorOffset;
136
+ const newValue = value.slice(0, cursorPos) + input + value.slice(cursorPos);
137
+ // If user types backslash at the end, automatically add newline
138
+ if (input === "\\" && cursorPos === value.length) {
139
+ onChange(newValue.slice(0, -1) + "\n");
140
+ // Reset cursor to end (no offset)
141
+ setCursorOffset(0);
142
+ }
143
+ else {
144
+ onChange(newValue);
145
+ // Keep cursor at same relative position
146
+ if (cursorOffset < 0) {
147
+ setCursorOffset(cursorOffset - input.length);
148
+ }
149
+ }
150
+ }
151
+ });
152
+ // Reset cursor when value changes externally (e.g., after submit)
153
+ useEffect(() => {
154
+ if (value.length === 0) {
155
+ setCursorOffset(0);
156
+ }
157
+ }, [value]);
158
+ // Display the input with cursor
159
+ const cursorPos = value.length + cursorOffset;
160
+ // Show placeholder if empty
161
+ if (value.length === 0) {
162
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] }));
163
+ }
164
+ // Split by newlines for multi-line rendering
165
+ const allLines = value.split("\n");
166
+ // Find which line the cursor is on and position within that line
167
+ let charCount = 0;
168
+ let cursorLineIndex = 0;
169
+ let cursorPosInLine = 0;
170
+ for (let i = 0; i < allLines.length; i++) {
171
+ const line = allLines[i] || "";
172
+ const lineEndPos = charCount + line.length;
173
+ // Check if cursor is within this line (including at the end before newline)
174
+ if (cursorPos <= lineEndPos) {
175
+ cursorLineIndex = i;
176
+ cursorPosInLine = cursorPos - charCount;
177
+ break;
178
+ }
179
+ // Move past the newline character for next iteration
180
+ charCount = lineEndPos + 1;
181
+ }
182
+ // Build the full display text with cursor
183
+ let displayText = "";
184
+ for (let i = 0; i < allLines.length; i++) {
185
+ const line = allLines[i] || "";
186
+ if (i === cursorLineIndex) {
187
+ // Add text before cursor
188
+ displayText += line.slice(0, cursorPosInLine);
189
+ // Mark cursor position - we'll add it separately
190
+ displayText += "\0"; // placeholder for cursor
191
+ // Add text after cursor
192
+ displayText += line.slice(cursorPosInLine + 1);
193
+ }
194
+ else {
195
+ displayText += line;
196
+ }
197
+ // Add newline if not last line
198
+ // if (i < allLines.length - 1) {
199
+ displayText += "\n";
200
+ // }
201
+ }
202
+ // Split by cursor placeholder
203
+ const parts = displayText.split("\0");
204
+ const beforeCursor = parts[0] || "";
205
+ const afterCursor = parts[1] || "";
206
+ const cursorChar = allLines[cursorLineIndex]?.[cursorPosInLine] || " ";
207
+ return (_jsxs(Text, { children: [beforeCursor, _jsx(Text, { inverse: true, children: cursorChar }), afterCursor] }));
208
+ }
@@ -113,7 +113,7 @@ export function StatusBar({ connectionStatus, isStreaming, streamingStartTime, h
113
113
  const contextPercentage = calculateTokenPercentage(contextTokens, currentModel ?? undefined);
114
114
  const contextPercentageStr = formatTokenPercentage(contextTokens, currentModel ?? undefined);
115
115
  const contextColor = getTokenColor(contextPercentage);
116
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "Context: " }), _jsx(Text, { color: contextColor, children: contextPercentageStr }), _jsx(Text, { dimColor: true, children: " | Input: " }), _jsx(Text, { children: formatTokenCount(inputTokens) }), _jsx(Text, { dimColor: true, children: " | Output: " }), _jsx(Text, { children: formatTokenCount(outputTokens) })] }));
116
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Ctx: " }), _jsx(Text, { color: contextColor, children: contextPercentageStr }), _jsx(Text, { dimColor: true, children: " | In: " }), _jsx(Text, { children: formatTokenCount(inputTokens) }), _jsx(Text, { dimColor: true, children: " | Out: " }), _jsx(Text, { children: formatTokenCount(outputTokens) })] }));
117
117
  };
118
- return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", gap: 2, children: [_jsx(Text, { dimColor: true, children: "Status: " }), _jsx(Text, { color: statusColor, children: connectionStatus }), showWaiting && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(WaitingElapsedTime, { startTime: streamingStartTime })] }))] }), renderTokenDisplay()] }));
118
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusColor, children: "\u25CF" }), _jsxs(Text, { children: [" ", connectionStatus] }), showWaiting && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(WaitingElapsedTime, { startTime: streamingStartTime })] })), _jsx(Text, { dimColor: true, children: " | " }), renderTokenDisplay()] }));
119
119
  }
@@ -7,6 +7,7 @@ export * from "./InputBox.js";
7
7
  export * from "./MessageList.js";
8
8
  export * from "./MultiSelect.js";
9
9
  export * from "./ReadlineInput.js";
10
+ export * from "./SimpleTextInput.js";
10
11
  export * from "./SingleSelect.js";
11
12
  export * from "./StatusBar.js";
12
13
  export * from "./ToolCall.js";
@@ -7,6 +7,7 @@ export * from "./InputBox.js";
7
7
  export * from "./MessageList.js";
8
8
  export * from "./MultiSelect.js";
9
9
  export * from "./ReadlineInput.js";
10
+ export * from "./SimpleTextInput.js";
10
11
  export * from "./SingleSelect.js";
11
12
  export * from "./StatusBar.js";
12
13
  export * from "./ToolCall.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -61,11 +61,10 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@tailwindcss/postcss": "^4.1.17",
64
- "@townco/tsconfig": "0.1.19",
64
+ "@townco/tsconfig": "0.1.20",
65
65
  "@types/node": "^24.10.0",
66
66
  "@types/react": "^19.2.2",
67
67
  "ink": "^6.4.0",
68
- "ink-text-input": "^6.0.0",
69
68
  "react": "^19.2.0",
70
69
  "tailwindcss": "^4.1.17",
71
70
  "typescript": "^5.9.3"