@townco/ui 0.1.36 → 0.1.37

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.
@@ -2,7 +2,7 @@ import type { AcpClient } from "../../sdk/client/index.js";
2
2
  /**
3
3
  * Hook for managing chat input
4
4
  */
5
- export declare function useChatInput(client: AcpClient | null): {
5
+ export declare function useChatInput(client: AcpClient | null, startSession: () => Promise<string | null>): {
6
6
  value: string;
7
7
  isSubmitting: boolean;
8
8
  attachedFiles: {
@@ -6,13 +6,13 @@ const logger = createLogger("use-chat-input", "debug");
6
6
  /**
7
7
  * Hook for managing chat input
8
8
  */
9
- export function useChatInput(client) {
9
+ export function useChatInput(client, startSession) {
10
10
  const input = useChatStore((state) => state.input);
11
11
  const setInputValue = useChatStore((state) => state.setInputValue);
12
12
  const setInputSubmitting = useChatStore((state) => state.setInputSubmitting);
13
13
  const addFileAttachment = useChatStore((state) => state.addFileAttachment);
14
14
  const removeFileAttachment = useChatStore((state) => state.removeFileAttachment);
15
- const { sendMessage } = useChatMessages(client);
15
+ const { sendMessage } = useChatMessages(client, startSession);
16
16
  /**
17
17
  * Handle input value change
18
18
  */
@@ -2,7 +2,7 @@ import type { AcpClient } from "../../sdk/client/index.js";
2
2
  /**
3
3
  * Hook for managing chat messages
4
4
  */
5
- export declare function useChatMessages(client: AcpClient | null): {
5
+ export declare function useChatMessages(client: AcpClient | null, startSession: () => Promise<string | null>): {
6
6
  messages: {
7
7
  id: string;
8
8
  role: "user" | "assistant" | "system";
@@ -5,7 +5,7 @@ const logger = createLogger("use-chat-messages", "debug");
5
5
  /**
6
6
  * Hook for managing chat messages
7
7
  */
8
- export function useChatMessages(client) {
8
+ export function useChatMessages(client, startSession) {
9
9
  const messages = useChatStore((state) => state.messages);
10
10
  const isStreaming = useChatStore((state) => state.isStreaming);
11
11
  const sessionId = useChatStore((state) => state.sessionId);
@@ -23,10 +23,20 @@ export function useChatMessages(client) {
23
23
  setError("No client available");
24
24
  return;
25
25
  }
26
- if (!sessionId) {
27
- logger.error("No active session");
28
- setError("No active session");
29
- return;
26
+ // Create session lazily if it doesn't exist
27
+ let activeSessionId = sessionId;
28
+ if (!activeSessionId) {
29
+ logger.info("Creating new session before sending first message");
30
+ const newSessionId = await startSession();
31
+ if (!newSessionId) {
32
+ logger.error("Failed to create session");
33
+ setError("Failed to create session");
34
+ return;
35
+ }
36
+ activeSessionId = newSessionId;
37
+ logger.info("Session created successfully", {
38
+ sessionId: newSessionId,
39
+ });
30
40
  }
31
41
  try {
32
42
  // Start streaming and track time immediately
@@ -57,7 +67,7 @@ export function useChatMessages(client) {
57
67
  const messageStream = client.receiveMessages();
58
68
  // Send ONLY the new message (not full history)
59
69
  // The agent backend now manages conversation context
60
- client.sendMessage(content, sessionId).catch((error) => {
70
+ client.sendMessage(content, activeSessionId).catch((error) => {
61
71
  const message = error instanceof Error ? error.message : String(error);
62
72
  setError(message);
63
73
  setIsStreaming(false);
@@ -106,6 +116,7 @@ export function useChatMessages(client) {
106
116
  }, [
107
117
  client,
108
118
  sessionId,
119
+ startSession,
109
120
  addMessage,
110
121
  updateMessage,
111
122
  setIsStreaming,
@@ -7,6 +7,6 @@ export declare function useChatSession(client: AcpClient | null, initialSessionI
7
7
  sessionId: string | null;
8
8
  connect: () => Promise<void>;
9
9
  loadSession: (sessionIdToLoad: string) => Promise<void>;
10
- startSession: () => Promise<void>;
10
+ startSession: () => Promise<string | null>;
11
11
  disconnect: () => Promise<void>;
12
12
  };
@@ -149,11 +149,12 @@ export function useChatSession(client, initialSessionId) {
149
149
  ]);
150
150
  /**
151
151
  * Start a new session
152
+ * @returns The new session ID, or null if creation failed
152
153
  */
153
154
  const startSession = useCallback(async () => {
154
155
  if (!client) {
155
156
  setError("No client available");
156
- return;
157
+ return null;
157
158
  }
158
159
  try {
159
160
  const id = await client.startSession();
@@ -166,10 +167,12 @@ export function useChatSession(client, initialSessionId) {
166
167
  url.searchParams.set("session", id);
167
168
  window.history.pushState({}, "", url.toString());
168
169
  }
170
+ return id;
169
171
  }
170
172
  catch (error) {
171
173
  const message = error instanceof Error ? error.message : String(error);
172
174
  setError(message);
175
+ return null;
173
176
  }
174
177
  }, [client, setSessionId, setError, clearMessages, resetTokens]);
175
178
  /**
@@ -205,12 +208,6 @@ export function useChatSession(client, initialSessionId) {
205
208
  connect();
206
209
  }
207
210
  }, [client, connectionStatus, initialSessionId, connect, loadSession]);
208
- // Auto-start new session after connecting (only if no initial session)
209
- useEffect(() => {
210
- if (connectionStatus === "connected" && !sessionId && !initialSessionId) {
211
- startSession();
212
- }
213
- }, [connectionStatus, sessionId, initialSessionId, startSession]);
214
211
  return {
215
212
  connectionStatus,
216
213
  sessionId,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Browser-compatible logger
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
6
+ */
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;
34
+ export declare class Logger {
35
+ private service;
36
+ private minLevel;
37
+ private logFilePath?;
38
+ private logsDir?;
39
+ private writeQueue;
40
+ private isWriting;
41
+ constructor(service: string, minLevel?: LogLevel);
42
+ private setupFileLogging;
43
+ private writeToFile;
44
+ private shouldLog;
45
+ private log;
46
+ trace(message: string, metadata?: Record<string, unknown>): void;
47
+ debug(message: string, metadata?: Record<string, unknown>): void;
48
+ info(message: string, metadata?: Record<string, unknown>): void;
49
+ warn(message: string, metadata?: Record<string, unknown>): void;
50
+ error(message: string, metadata?: Record<string, unknown>): void;
51
+ fatal(message: string, metadata?: Record<string, unknown>): void;
52
+ }
53
+ /**
54
+ * Create a logger instance for a service
55
+ * @param service - Service name (e.g., "gui", "http-agent", "tui")
56
+ * @param minLevel - Minimum log level to display (default: "debug")
57
+ */
58
+ export declare function createLogger(service: string, minLevel?: LogLevel): Logger;
59
+ export {};
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Browser-compatible logger
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
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
+ }
43
+ const LOG_LEVELS = {
44
+ trace: 0,
45
+ debug: 1,
46
+ info: 2,
47
+ warn: 3,
48
+ error: 4,
49
+ fatal: 5,
50
+ };
51
+ const _LOG_COLORS = {
52
+ trace: "#6B7280", // gray
53
+ debug: "#3B82F6", // blue
54
+ info: "#10B981", // green
55
+ warn: "#F59E0B", // orange
56
+ error: "#EF4444", // red
57
+ fatal: "#DC2626", // dark red
58
+ };
59
+ const _LOG_STYLES = {
60
+ trace: "color: #6B7280",
61
+ debug: "color: #3B82F6; font-weight: bold",
62
+ info: "color: #10B981; font-weight: bold",
63
+ warn: "color: #F59E0B; font-weight: bold",
64
+ error: "color: #EF4444; font-weight: bold",
65
+ fatal: "color: #DC2626; font-weight: bold; background: #FEE2E2",
66
+ };
67
+ export class Logger {
68
+ service;
69
+ minLevel;
70
+ logFilePath;
71
+ logsDir;
72
+ writeQueue = [];
73
+ isWriting = false;
74
+ constructor(service, minLevel = "debug") {
75
+ this.service = service;
76
+ this.minLevel = minLevel;
77
+ // In production, suppress trace and debug logs
78
+ if (typeof process !== "undefined" &&
79
+ process.env?.NODE_ENV === "production") {
80
+ this.minLevel = "info";
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;
124
+ }
125
+ shouldLog(level) {
126
+ return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
127
+ }
128
+ log(level, message, metadata) {
129
+ if (!this.shouldLog(level)) {
130
+ return;
131
+ }
132
+ const entry = {
133
+ id: `log_${++logIdCounter}`,
134
+ timestamp: new Date().toISOString(),
135
+ level,
136
+ service: this.service,
137
+ message,
138
+ ...(metadata && { metadata }),
139
+ };
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();
148
+ }
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
+ });
161
+ }
162
+ // No console output - logs are only captured and displayed in UI
163
+ // This prevents logs from polluting stdout/stderr in TUI mode
164
+ }
165
+ trace(message, metadata) {
166
+ this.log("trace", message, metadata);
167
+ }
168
+ debug(message, metadata) {
169
+ this.log("debug", message, metadata);
170
+ }
171
+ info(message, metadata) {
172
+ this.log("info", message, metadata);
173
+ }
174
+ warn(message, metadata) {
175
+ this.log("warn", message, metadata);
176
+ }
177
+ error(message, metadata) {
178
+ this.log("error", message, metadata);
179
+ }
180
+ fatal(message, metadata) {
181
+ this.log("fatal", message, metadata);
182
+ }
183
+ }
184
+ /**
185
+ * Create a logger instance for a service
186
+ * @param service - Service name (e.g., "gui", "http-agent", "tui")
187
+ * @param minLevel - Minimum log level to display (default: "debug")
188
+ */
189
+ export function createLogger(service, minLevel = "debug") {
190
+ return new Logger(service, minLevel);
191
+ }
@@ -8,6 +8,10 @@ export interface ChatInputRootProps extends Omit<React.FormHTMLAttributes<HTMLFo
8
8
  * Either client or value/onChange/onSubmit must be provided
9
9
  */
10
10
  client?: AcpClient | null;
11
+ /**
12
+ * Start session function (required when using client)
13
+ */
14
+ startSession?: () => Promise<string | null>;
11
15
  /**
12
16
  * Input value (legacy prop-based pattern)
13
17
  * Either client or value/onChange/onSubmit must be provided
@@ -15,18 +15,23 @@ const useChatInputContext = () => {
15
15
  }
16
16
  return context;
17
17
  };
18
- const ChatInputRoot = React.forwardRef(({ client, value: valueProp, onChange: onChangeProp, onSubmit: onSubmitProp, disabled = false, isSubmitting: isSubmittingProp, submitOnEnter = true, className, children, ...props }, ref) => {
18
+ const ChatInputRoot = React.forwardRef(({ client, startSession, value: valueProp, onChange: onChangeProp, onSubmit: onSubmitProp, disabled = false, isSubmitting: isSubmittingProp, submitOnEnter = true, className, children, ...props }, ref) => {
19
19
  const textareaRef = React.useRef(null);
20
+ // Provide a dummy startSession function if not provided (for React hooks rule)
21
+ const dummyStartSession = React.useCallback(async () => Promise.resolve(null), []);
20
22
  // Always call hooks unconditionally (React rules)
21
- const hookData = useCoreChatInput(client ?? null);
23
+ const hookData = useCoreChatInput(client ?? null, startSession ?? dummyStartSession);
22
24
  const storeIsStreaming = useChatStore((state) => state.isStreaming);
23
25
  // Choose data source based on whether client is provided
24
- const value = hookData ? hookData.value : valueProp || "";
25
- const onChange = hookData ? hookData.onChange : onChangeProp || (() => { });
26
- const onSubmit = hookData
26
+ const useHookData = client && startSession;
27
+ const value = useHookData ? hookData.value : valueProp || "";
28
+ const onChange = useHookData
29
+ ? hookData.onChange
30
+ : onChangeProp || (() => { });
31
+ const onSubmit = useHookData
27
32
  ? hookData.onSubmit
28
33
  : onSubmitProp || (async () => { });
29
- const isSubmitting = hookData
34
+ const isSubmitting = useHookData
30
35
  ? hookData.isSubmitting || storeIsStreaming
31
36
  : isSubmittingProp || false;
32
37
  // Command menu state
@@ -4,6 +4,7 @@ import { ArrowUp, ChevronUp, Code, PanelRight, Settings, Sparkles, } from "lucid
4
4
  import { useEffect, useState } from "react";
5
5
  import { useChatMessages, useChatSession, useToolCalls, } from "../../core/hooks/index.js";
6
6
  import { useChatStore } from "../../core/store/chat-store.js";
7
+ import { formatTokenPercentage } from "../../core/utils/model-context.js";
7
8
  import { cn } from "../lib/utils.js";
8
9
  import { ChatEmptyState, ChatHeader, ChatInputActions, ChatInputAttachment, ChatInputCommandMenu, ChatInputField, ChatInputRoot, ChatInputSubmit, ChatInputToolbar, ChatInputVoiceInput, ChatLayout, FilesTabContent, Message, MessageContent, PanelTabsHeader, SourcesTabContent, Tabs, TabsContent, TodoTabContent, } from "./index.js";
9
10
  const logger = createLogger("gui");
@@ -21,10 +22,11 @@ function AppChatHeader({ agentName, todos, sources, showHeader, }) {
21
22
  }
22
23
  export function ChatView({ client, initialSessionId, error: initError, }) {
23
24
  // Use shared hooks from @townco/ui/core - MUST be called before any early returns
24
- const { connectionStatus, connect, sessionId } = useChatSession(client, initialSessionId);
25
- const { messages, sendMessage } = useChatMessages(client);
25
+ const { connectionStatus, connect, sessionId, startSession } = useChatSession(client, initialSessionId);
26
+ const { messages, sendMessage } = useChatMessages(client, startSession);
26
27
  useToolCalls(client); // Still need to subscribe to tool call events
27
28
  const error = useChatStore((state) => state.error);
29
+ const currentModel = useChatStore((state) => state.currentModel);
28
30
  const [agentName, setAgentName] = useState("Agent");
29
31
  const [isLargeScreen, setIsLargeScreen] = useState(typeof window !== "undefined" ? window.innerWidth >= 1024 : true);
30
32
  // Log connection status changes
@@ -98,6 +100,11 @@ export function ChatView({ client, initialSessionId, error: initError, }) {
98
100
  favicon: "https://www.google.com/s2/favicons?domain=theverge.com&sz=32",
99
101
  },
100
102
  ];
103
+ // Get the latest token usage from the most recent assistant message
104
+ const latestTokenUsage = messages
105
+ .slice()
106
+ .reverse()
107
+ .find((msg) => msg.role === "assistant" && msg.tokenUsage)?.tokenUsage;
101
108
  // Command menu items for chat input
102
109
  const commandMenuItems = [
103
110
  {
@@ -168,5 +175,5 @@ export function ChatView({ client, initialSessionId, error: initError, }) {
168
175
  previousMessage?.role === "assistant" ? "mt-2" : "mt-6";
169
176
  }
170
177
  return (_jsx(Message, { message: message, className: spacingClass, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id));
171
- }) })) }), _jsx(ChatLayout.Footer, { children: _jsxs(ChatInputRoot, { client: client, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), _jsx(ChatInputField, { placeholder: "Type a message or / for commands...", autoFocus: true }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, {})] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsxs(Tabs, { defaultValue: "todo", className: "flex flex-col h-full", children: [_jsx("div", { className: cn("border-b border-border bg-card", "px-6 py-2 h-16", "flex items-center", "[border-bottom-width:0.5px]"), children: _jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources"], variant: "compact" }) }), _jsx(TabsContent, { value: "todo", className: "flex-1 p-4 mt-0", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "flex-1 p-4 mt-0", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "flex-1 p-4 mt-0", children: _jsx(SourcesTabContent, { sources: sources }) })] }) }))] }));
178
+ }) })) }), _jsx(ChatLayout.Footer, { children: _jsxs(ChatInputRoot, { client: client, startSession: startSession, children: [_jsx(ChatInputCommandMenu, { commands: commandMenuItems }), _jsx(ChatInputField, { placeholder: "Type a message or / for commands...", autoFocus: true }), _jsxs(ChatInputToolbar, { children: [_jsxs("div", { className: "flex items-baseline gap-1", children: [_jsx(ChatInputActions, {}), _jsx(ChatInputAttachment, {}), latestTokenUsage && (_jsxs("span", { className: "text-xs text-muted-foreground/50 ml-2", children: ["Context:", " ", formatTokenPercentage(latestTokenUsage.totalTokens ?? 0, currentModel ?? undefined), " ", "(", (latestTokenUsage.totalTokens ?? 0).toLocaleString(), " ", "tokens)"] }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(ChatInputVoiceInput, {}), _jsx(ChatInputSubmit, { children: _jsx(ArrowUp, { className: "size-4" }) })] })] })] }) })] })] }), isLargeScreen && (_jsx(ChatLayout.Aside, { breakpoint: "lg", children: _jsxs(Tabs, { defaultValue: "todo", className: "flex flex-col h-full", children: [_jsx("div", { className: cn("border-b border-border bg-card", "px-6 py-2 h-16", "flex items-center", "[border-bottom-width:0.5px]"), children: _jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources"], variant: "compact" }) }), _jsx(TabsContent, { value: "todo", className: "flex-1 p-4 mt-0", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "flex-1 p-4 mt-0", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "flex-1 p-4 mt-0", children: _jsx(SourcesTabContent, { sources: sources }) })] }) }))] }));
172
179
  }
@@ -135,7 +135,7 @@ export const MessageContent = React.forwardRef(({ role: roleProp, variant, isStr
135
135
  }
136
136
  }
137
137
  return _jsx(_Fragment, { children: elements });
138
- })()) : (_jsx("div", { className: "whitespace-pre-wrap", children: message.content })), message.role === "assistant" && message.tokenUsage && (_jsx("div", { className: "mt-3 pt-2 border-t border-border/30 text-caption text-muted-foreground/60", children: _jsxs("span", { children: ["Context:", " ", formatTokenPercentage(message.tokenUsage.totalTokens ?? 0, currentModel ?? undefined), " ", "(", (message.tokenUsage.totalTokens ?? 0).toLocaleString(), " ", "tokens)"] }) }))] }));
138
+ })()) : (_jsx("div", { className: "whitespace-pre-wrap", children: message.content }))] }));
139
139
  }
140
140
  return (_jsx("div", { ref: ref, className: cn(messageContentVariants({ role, variant }), isStreaming && "animate-pulse-subtle", className), ...props, children: content }));
141
141
  });
@@ -20,6 +20,7 @@ export declare class HttpTransport implements Transport {
20
20
  private abortController;
21
21
  private options;
22
22
  private isReceivingMessages;
23
+ private isInReplayMode;
23
24
  constructor(options: HttpTransportOptions);
24
25
  connect(): Promise<void>;
25
26
  /**
@@ -21,6 +21,7 @@ export class HttpTransport {
21
21
  abortController = null;
22
22
  options;
23
23
  isReceivingMessages = false;
24
+ isInReplayMode = false; // True during session replay, ignores non-replay streaming
24
25
  constructor(options) {
25
26
  // Ensure baseUrl doesn't end with a slash
26
27
  this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
@@ -100,6 +101,8 @@ export class HttpTransport {
100
101
  capabilities: initResponse.agentCapabilities,
101
102
  });
102
103
  // Step 2: Open SSE connection FIRST so we can receive replayed messages
104
+ // Enter replay mode - ignore non-replay streaming until user sends a message
105
+ this.isInReplayMode = true;
103
106
  this.currentSessionId = sessionId;
104
107
  await this.connectSSE();
105
108
  // Step 3: Load existing session (will trigger message replay)
@@ -116,6 +119,7 @@ export class HttpTransport {
116
119
  });
117
120
  this.connected = true;
118
121
  this.reconnectAttempts = 0;
122
+ // Note: isInReplayMode will be set to false when user sends their first message
119
123
  }
120
124
  catch (error) {
121
125
  this.connected = false;
@@ -163,6 +167,11 @@ export class HttpTransport {
163
167
  if (!this.connected || !this.currentSessionId) {
164
168
  throw new Error("Transport not connected");
165
169
  }
170
+ // Exit replay mode when user sends their first message
171
+ if (this.isInReplayMode) {
172
+ logger.info("Exiting replay mode - user sent a message");
173
+ this.isInReplayMode = false;
174
+ }
166
175
  try {
167
176
  // Reset stream state for new message
168
177
  this.streamComplete = false;
@@ -729,6 +738,15 @@ export class HttpTransport {
729
738
  this.notifySessionUpdate(sessionUpdate);
730
739
  }
731
740
  else if (update?.sessionUpdate === "agent_message_chunk") {
741
+ // Check if this is a replay (not live streaming)
742
+ const isReplay = update._meta &&
743
+ typeof update._meta === "object" &&
744
+ "isReplay" in update._meta &&
745
+ update._meta.isReplay === true;
746
+ // If in replay mode, ignore any chunks that aren't marked as replay
747
+ if (this.isInReplayMode && !isReplay) {
748
+ return;
749
+ }
732
750
  // Handle agent message chunks
733
751
  const sessionUpdate = {
734
752
  type: "generic",
@@ -741,10 +759,7 @@ export class HttpTransport {
741
759
  "tokenUsage" in update._meta
742
760
  ? update._meta.tokenUsage
743
761
  : undefined;
744
- logger.debug("Agent message chunk", {
745
- tokenUsage,
746
- });
747
- // Queue message chunks if present
762
+ // Queue message chunks if present (but skip during replay)
748
763
  // For agent_message_chunk, content is an object, not an array
749
764
  const content = update.content;
750
765
  if (content && typeof content === "object") {
@@ -759,7 +774,8 @@ export class HttpTransport {
759
774
  isComplete: false,
760
775
  };
761
776
  }
762
- if (chunk) {
777
+ // Only queue chunks for live streaming, not replay
778
+ if (chunk && !isReplay) {
763
779
  // Resolve any waiting receive() calls immediately
764
780
  const resolver = this.chunkResolvers.shift();
765
781
  if (resolver) {
@@ -771,10 +787,11 @@ export class HttpTransport {
771
787
  }
772
788
  }
773
789
  // Also notify as a complete message for session replay
774
- // Only send session updates when NOT actively receiving messages (prevents duplication during normal streaming)
790
+ // During replay: always send session updates with complete messages
791
+ // During live streaming: only send when NOT actively receiving (prevents duplication)
775
792
  if (chunk &&
776
793
  typeof contentObj.text === "string" &&
777
- !this.isReceivingMessages) {
794
+ (isReplay || !this.isReceivingMessages)) {
778
795
  const messageSessionUpdate = {
779
796
  type: "generic",
780
797
  sessionId,
@@ -795,7 +812,10 @@ export class HttpTransport {
795
812
  this.notifySessionUpdate(messageSessionUpdate);
796
813
  }
797
814
  }
798
- this.notifySessionUpdate(sessionUpdate);
815
+ // Only send generic session update during live streaming (not replay)
816
+ if (!isReplay) {
817
+ this.notifySessionUpdate(sessionUpdate);
818
+ }
799
819
  }
800
820
  else if (update?.sessionUpdate === "user_message_chunk") {
801
821
  // Handle user message chunks (could be from replay or new messages)
@@ -12,10 +12,10 @@ export function ChatView({ client }) {
12
12
  const _currentModel = useChatStore((state) => state.currentModel);
13
13
  const _tokenDisplayMode = useChatStore((state) => state.tokenDisplayMode);
14
14
  // Use headless hooks for business logic
15
- useChatSession(client); // Subscribe to session changes
16
- const { messages, isStreaming } = useChatMessages(client);
15
+ const { startSession } = useChatSession(client); // Subscribe to session changes
16
+ const { messages, isStreaming } = useChatMessages(client, startSession);
17
17
  useToolCalls(client); // Still need to subscribe to tool call events
18
- const { value, isSubmitting, attachedFiles, onChange, onSubmit } = useChatInput(client);
18
+ const { value, isSubmitting, attachedFiles, onChange, onSubmit } = useChatInput(client, startSession);
19
19
  // Check if we're actively receiving content (hide waiting indicator)
20
20
  const _hasStreamingContent = messages.some((msg) => msg.isStreaming && msg.content.length > 0);
21
21
  // Callbacks for keyboard shortcuts
@@ -34,8 +34,8 @@ export function ChatViewStatus({ client }) {
34
34
  const currentContext = useChatStore((state) => state.currentContext);
35
35
  const currentModel = useChatStore((state) => state.currentModel);
36
36
  const tokenDisplayMode = useChatStore((state) => state.tokenDisplayMode);
37
- const { connectionStatus, sessionId } = useChatSession(client);
38
- const { messages, isStreaming } = useChatMessages(client);
37
+ const { connectionStatus, sessionId, startSession } = useChatSession(client);
38
+ const { messages, isStreaming } = useChatMessages(client, startSession);
39
39
  const hasStreamingContent = messages.some((msg) => msg.isStreaming && msg.content.length > 0);
40
40
  return (_jsx(StatusBar, { connectionStatus: connectionStatus, sessionId: sessionId, isStreaming: isStreaming, streamingStartTime: streamingStartTime, hasStreamingContent: hasStreamingContent, totalBilled: totalBilled, currentContext: currentContext, currentModel: currentModel, tokenDisplayMode: tokenDisplayMode }));
41
41
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@agentclientprotocol/sdk": "^0.5.1",
43
- "@townco/core": "0.0.14",
43
+ "@townco/core": "0.0.15",
44
44
  "@radix-ui/react-dialog": "^1.1.15",
45
45
  "@radix-ui/react-dropdown-menu": "^2.1.16",
46
46
  "@radix-ui/react-label": "^2.1.8",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@tailwindcss/postcss": "^4.1.17",
65
- "@townco/tsconfig": "0.1.33",
65
+ "@townco/tsconfig": "0.1.34",
66
66
  "@types/node": "^24.10.0",
67
67
  "@types/react": "^19.2.2",
68
68
  "ink": "^6.4.0",