@townco/ui 0.1.31 → 0.1.33

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,7 @@
1
1
  /**
2
2
  * Export all core hooks
3
3
  */
4
+ export * from "./use-acp-client.js";
4
5
  export * from "./use-chat-input.js";
5
6
  export * from "./use-chat-messages.js";
6
7
  export * from "./use-chat-session.js";
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Export all core hooks
3
3
  */
4
+ export * from "./use-acp-client.js";
4
5
  export * from "./use-chat-input.js";
5
6
  export * from "./use-chat-messages.js";
6
7
  export * from "./use-chat-session.js";
@@ -0,0 +1,22 @@
1
+ import { AcpClient } from "../../sdk/client/index.js";
2
+ export interface UseACPClientOptions {
3
+ serverUrl: string;
4
+ }
5
+ export interface UseACPClientReturn {
6
+ client: AcpClient | null;
7
+ error: string | null;
8
+ sessionId: string | null;
9
+ }
10
+ /**
11
+ * Custom hook to initialize and manage an ACP client connection
12
+ *
13
+ * Handles:
14
+ * - Extracting session ID from URL query parameters
15
+ * - Creating and initializing the ACP client
16
+ * - Cleanup on unmount
17
+ * - Error handling
18
+ *
19
+ * @param options - Configuration options for the ACP client
20
+ * @returns Object containing client, error state, and session ID
21
+ */
22
+ export declare function useACPClient(options: UseACPClientOptions): UseACPClientReturn;
@@ -0,0 +1,63 @@
1
+ import { createLogger } from "@townco/core";
2
+ import { useEffect, useState } from "react";
3
+ import { AcpClient } from "../../sdk/client/index.js";
4
+ const logger = createLogger("acp-client-hook");
5
+ /**
6
+ * Custom hook to initialize and manage an ACP client connection
7
+ *
8
+ * Handles:
9
+ * - Extracting session ID from URL query parameters
10
+ * - Creating and initializing the ACP client
11
+ * - Cleanup on unmount
12
+ * - Error handling
13
+ *
14
+ * @param options - Configuration options for the ACP client
15
+ * @returns Object containing client, error state, and session ID
16
+ */
17
+ export function useACPClient(options) {
18
+ const [client, setClient] = useState(null);
19
+ const [error, setError] = useState(null);
20
+ const [sessionId, setSessionId] = useState(null);
21
+ useEffect(() => {
22
+ // Extract session ID from URL query parameter (?session=abc123)
23
+ const urlParams = new URLSearchParams(window.location.search);
24
+ const urlSessionId = urlParams.get("session");
25
+ if (urlSessionId) {
26
+ logger.info("Session ID found in URL", { sessionId: urlSessionId });
27
+ setSessionId(urlSessionId);
28
+ }
29
+ // Create AcpClient with HTTP transport
30
+ try {
31
+ logger.info("Initializing ACP client", {
32
+ serverUrl: options.serverUrl,
33
+ });
34
+ const acpClient = new AcpClient({
35
+ type: "http",
36
+ options: {
37
+ baseUrl: options.serverUrl,
38
+ },
39
+ });
40
+ setClient(acpClient);
41
+ logger.info("ACP client initialized successfully");
42
+ // Clean up on unmount
43
+ return () => {
44
+ logger.debug("Disconnecting ACP client");
45
+ acpClient.disconnect().catch((err) => {
46
+ logger.error("Failed to disconnect ACP client", {
47
+ error: err instanceof Error ? err.message : String(err),
48
+ });
49
+ });
50
+ };
51
+ }
52
+ catch (err) {
53
+ const errorMessage = err instanceof Error ? err.message : "Failed to initialize ACP client";
54
+ setError(errorMessage);
55
+ logger.error("Failed to initialize ACP client", {
56
+ error: err instanceof Error ? err.message : String(err),
57
+ stack: err instanceof Error ? err.stack : undefined,
58
+ });
59
+ return undefined;
60
+ }
61
+ }, [options.serverUrl]);
62
+ return { client, error, sessionId };
63
+ }
@@ -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, initialSessionId?: string | null): {
6
- connectionStatus: "error" | "disconnected" | "connecting" | "connected";
6
+ connectionStatus: "disconnected" | "connecting" | "connected" | "error";
7
7
  sessionId: string | null;
8
8
  connect: () => Promise<void>;
9
9
  loadSession: (sessionIdToLoad: string) => Promise<void>;
@@ -27,21 +27,39 @@ export function useChatSession(client, initialSessionId) {
27
27
  // Convert SDK message to DisplayMessage
28
28
  // Filter out tool messages as they're not displayed in the chat
29
29
  if (update.message.role !== "tool") {
30
- const displayMessage = {
31
- id: update.message.id,
32
- role: update.message.role,
33
- content: update.message.content
34
- .map((c) => {
35
- if (c.type === "text") {
36
- return c.text;
37
- }
38
- return "";
39
- })
40
- .join(""),
41
- timestamp: update.message.timestamp,
42
- isStreaming: false,
43
- };
44
- addMessage(displayMessage);
30
+ const textContent = update.message.content
31
+ .map((c) => {
32
+ if (c.type === "text") {
33
+ return c.text;
34
+ }
35
+ return "";
36
+ })
37
+ .join("");
38
+ // During session replay, chunks for the same message arrive separately
39
+ // Check if we should append to the last assistant message or create a new one
40
+ const messages = useChatStore.getState().messages;
41
+ const lastMessage = messages[messages.length - 1];
42
+ if (update.message.role === "assistant" &&
43
+ lastMessage?.role === "assistant" &&
44
+ lastMessage.content === "" &&
45
+ textContent) {
46
+ // Append text to existing empty assistant message (created by tool call)
47
+ logger.debug("Appending text to existing assistant message");
48
+ useChatStore.getState().updateMessage(lastMessage.id, {
49
+ content: lastMessage.content + textContent,
50
+ });
51
+ }
52
+ else {
53
+ // Create new message
54
+ const displayMessage = {
55
+ id: update.message.id,
56
+ role: update.message.role,
57
+ content: textContent,
58
+ timestamp: update.message.timestamp,
59
+ isStreaming: false,
60
+ };
61
+ addMessage(displayMessage);
62
+ }
45
63
  }
46
64
  }
47
65
  });
@@ -155,8 +155,23 @@ export const useChatStore = create((set) => ({
155
155
  // Find the most recent assistant message (which should be streaming)
156
156
  const lastAssistantIndex = state.messages.findLastIndex((msg) => msg.role === "assistant");
157
157
  if (lastAssistantIndex === -1) {
158
- logger.warn("No assistant message found to add tool call to");
159
- return state;
158
+ // No assistant message exists yet - create one with the tool call
159
+ // This happens during session replay when a tool call comes before any text
160
+ logger.debug("No assistant message found, creating one for tool call at position 0");
161
+ const newMessage = {
162
+ id: `msg_${Date.now()}_assistant`,
163
+ role: "assistant",
164
+ content: "",
165
+ timestamp: new Date().toISOString(),
166
+ isStreaming: false,
167
+ toolCalls: [
168
+ {
169
+ ...toolCall,
170
+ contentPosition: 0, // Tool call at the start
171
+ },
172
+ ],
173
+ };
174
+ return { messages: [...state.messages, newMessage] };
160
175
  }
161
176
  const messages = [...state.messages];
162
177
  const lastAssistantMsg = messages[lastAssistantIndex];
@@ -0,0 +1,7 @@
1
+ import type { AcpClient } from "../../sdk/client/index.js";
2
+ export interface ChatViewProps {
3
+ client: AcpClient | null;
4
+ initialSessionId?: string | null;
5
+ error?: string | null;
6
+ }
7
+ export declare function ChatView({ client, initialSessionId, error: initError, }: ChatViewProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,153 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createLogger } from "@townco/core";
3
+ import { ArrowUp, ChevronUp, Code, PanelRight, Settings, Sparkles, } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import { useChatMessages, useChatSession, useToolCalls, } from "../../core/hooks/index.js";
6
+ import { useChatStore } from "../../core/store/chat-store.js";
7
+ import { cn } from "../lib/utils.js";
8
+ import { ChatEmptyState, ChatHeader, ChatInputActions, ChatInputAttachment, ChatInputCommandMenu, ChatInputField, ChatInputRoot, ChatInputSubmit, ChatInputToolbar, ChatInputVoiceInput, ChatLayout, FilesTabContent, Message, MessageContent, PanelTabsHeader, SourcesTabContent, Tabs, TabsContent, TodoTabContent, } from "./index.js";
9
+ const logger = createLogger("gui");
10
+ // Mobile header component that uses ChatHeader context
11
+ function MobileHeader({ agentName }) {
12
+ const { isExpanded, setIsExpanded } = ChatHeader.useChatHeaderContext();
13
+ return (_jsxs("div", { className: "flex lg:hidden items-center gap-2 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-1", children: [_jsx("h1", { className: "text-[20px] font-semibold leading-[1.2] tracking-[-0.4px] text-foreground", children: agentName }), _jsx("div", { className: "flex items-center justify-center shrink-0", children: _jsx(ChevronUp, { className: "size-4 rotate-180 text-muted-foreground" }) })] }), _jsx("button", { type: "button", className: "flex items-center justify-center shrink-0 cursor-pointer", "aria-label": "Toggle menu", onClick: () => setIsExpanded(!isExpanded), children: _jsx(PanelRight, { className: "size-4 text-muted-foreground" }) })] }));
14
+ }
15
+ // Header component that uses ChatLayout context (must be inside ChatLayout.Root)
16
+ function AppChatHeader({ agentName, todos, sources, showHeader, }) {
17
+ const { panelSize, setPanelSize } = ChatLayout.useChatLayoutContext();
18
+ return (_jsxs(ChatHeader.Root, { className: cn("border-b border-border bg-card relative lg:p-0", "[border-bottom-width:0.5px]"), children: [_jsxs("div", { className: "hidden lg:flex items-center gap-2 w-full h-16 py-5 pl-6 pr-4", children: [showHeader && (_jsxs("div", { className: "flex items-center gap-2 flex-1", children: [_jsx("h1", { className: "text-[20px] font-semibold leading-[1.2] tracking-[-0.4px] text-foreground", children: agentName }), _jsx("div", { className: "flex items-center justify-center shrink-0", children: _jsx(ChevronUp, { className: "size-4 rotate-180 text-muted-foreground" }) })] })), !showHeader && _jsx("div", { className: "flex-1" }), _jsx("button", { type: "button", className: "flex items-center justify-center shrink-0 cursor-pointer", "aria-label": "Toggle sidebar", onClick: () => {
19
+ setPanelSize(panelSize === "hidden" ? "small" : "hidden");
20
+ }, children: _jsx(PanelRight, { className: "size-4 text-muted-foreground" }) })] }), _jsx(MobileHeader, { agentName: agentName }), _jsx(ChatHeader.ExpandablePanel, { className: cn("pt-6 pb-8 px-6", "border-b border-border bg-card", "shadow-[0_4px_16px_0_rgba(0,0,0,0.04)]", "[border-bottom-width:0.5px]"), children: _jsxs(Tabs, { defaultValue: "todo", className: "w-full", children: [_jsx(PanelTabsHeader, { showIcons: true, visibleTabs: ["todo", "files", "sources"], variant: "default" }), _jsx(TabsContent, { value: "todo", className: "mt-4", children: _jsx(TodoTabContent, { todos: todos }) }), _jsx(TabsContent, { value: "files", className: "mt-4", children: _jsx(FilesTabContent, {}) }), _jsx(TabsContent, { value: "sources", className: "mt-4", children: _jsx(SourcesTabContent, { sources: sources }) })] }) })] }));
21
+ }
22
+ export function ChatView({ client, initialSessionId, error: initError, }) {
23
+ // 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 } = useChatMessages(client);
26
+ useToolCalls(client); // Still need to subscribe to tool call events
27
+ const error = useChatStore((state) => state.error);
28
+ const [agentName, setAgentName] = useState("Agent");
29
+ const [isLargeScreen, setIsLargeScreen] = useState(typeof window !== "undefined" ? window.innerWidth >= 1024 : true);
30
+ // Log connection status changes
31
+ useEffect(() => {
32
+ logger.debug("Connection status changed", { status: connectionStatus });
33
+ if (connectionStatus === "error" && error) {
34
+ logger.error("Connection error occurred", { error });
35
+ }
36
+ }, [connectionStatus, error]);
37
+ // Get agent name from session metadata
38
+ useEffect(() => {
39
+ if (client && sessionId) {
40
+ const session = client.getCurrentSession();
41
+ if (session?.metadata?.agentName) {
42
+ setAgentName(session.metadata.agentName);
43
+ }
44
+ }
45
+ }, [client, sessionId]);
46
+ // Monitor screen size changes and update isLargeScreen state
47
+ useEffect(() => {
48
+ const mediaQuery = window.matchMedia("(min-width: 1024px)");
49
+ const handleChange = (e) => {
50
+ setIsLargeScreen(e.matches);
51
+ };
52
+ // Set initial value
53
+ setIsLargeScreen(mediaQuery.matches);
54
+ // Listen for changes
55
+ mediaQuery.addEventListener("change", handleChange);
56
+ return () => {
57
+ mediaQuery.removeEventListener("change", handleChange);
58
+ };
59
+ }, []);
60
+ // If there's an initialization error, show error UI (after all hooks have been called)
61
+ if (initError) {
62
+ return (_jsx("div", { className: "flex items-center justify-center h-screen bg-background", children: _jsxs("div", { className: "text-center p-8 max-w-md", children: [_jsx("h1", { className: "text-2xl font-bold text-destructive mb-4", children: "Initialization Error" }), _jsx("p", { className: "text-foreground mb-4", children: initError }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Failed to initialize the ACP client. Check the console for details." })] }) }));
63
+ }
64
+ // TODO: Replace with useChatStore((state) => state.todos) when todos are added to the store
65
+ const todos = [];
66
+ // Dummy sources data based on Figma design
67
+ const sources = [
68
+ {
69
+ id: "1",
70
+ title: "Boeing Scores Early Wins at Dubai Airshow",
71
+ sourceName: "Reuters",
72
+ url: "https://www.reuters.com/markets/companies/BA.N",
73
+ snippet: "DUBAI, Nov 17 (Reuters) - Boeing (BA.N), opens new tab took centre stage at day one of the Dubai Airshow on Monday, booking a $38 billion order from host carrier Emirates and clinching more deals with African carriers, while China displayed its C919 in the Middle East for the first time.",
74
+ favicon: "https://www.google.com/s2/favicons?domain=reuters.com&sz=32",
75
+ },
76
+ {
77
+ id: "2",
78
+ title: "Boeing's Sustainable Aviation Goals Take Flight",
79
+ sourceName: "Forbes",
80
+ url: "https://www.forbes.com",
81
+ snippet: "SEATTLE, Nov 18 (Reuters) - Boeing is making headway towards its sustainability targets, unveiling plans for a new eco-friendly aircraft design aimed at reducing emissions by 50% by 2030.",
82
+ favicon: "https://www.google.com/s2/favicons?domain=forbes.com&sz=32",
83
+ },
84
+ {
85
+ id: "3",
86
+ title: "Boeing Faces Increased Competition in Global Aviation Market",
87
+ sourceName: "Reuters",
88
+ url: "https://www.reuters.com",
89
+ snippet: "CHICAGO, Nov 19 (Reuters) - As the global aviation industry rebounds post-pandemic, Boeing is grappling with intensified competition from rival manufacturers, particularly in the Asian market.",
90
+ favicon: "https://www.google.com/s2/favicons?domain=reuters.com&sz=32",
91
+ },
92
+ {
93
+ id: "4",
94
+ title: "Boeing's Starliner Successfully Completes Orbital Test Flight",
95
+ sourceName: "The Verge",
96
+ url: "https://www.theverge.com",
97
+ snippet: "NASA, Nov 20 (Reuters) - Boeing's CST-100 Starliner spacecraft achieves a significant milestone, successfully completing its orbital test flight, paving the way for future crewed missions to the International Space Station.",
98
+ favicon: "https://www.google.com/s2/favicons?domain=theverge.com&sz=32",
99
+ },
100
+ ];
101
+ // Command menu items for chat input
102
+ const commandMenuItems = [
103
+ {
104
+ id: "model-sonnet",
105
+ label: "Use Sonnet 4.5",
106
+ description: "Switch to Claude Sonnet 4.5 model",
107
+ icon: _jsx(Sparkles, { className: "h-4 w-4" }),
108
+ category: "model",
109
+ onSelect: () => {
110
+ logger.info("User selected Sonnet 4.5 model");
111
+ },
112
+ },
113
+ {
114
+ id: "model-opus",
115
+ label: "Use Opus",
116
+ description: "Switch to Claude Opus model",
117
+ icon: _jsx(Sparkles, { className: "h-4 w-4" }),
118
+ category: "model",
119
+ onSelect: () => {
120
+ logger.info("User selected Opus model");
121
+ },
122
+ },
123
+ {
124
+ id: "settings",
125
+ label: "Open Settings",
126
+ description: "Configure chat preferences",
127
+ icon: _jsx(Settings, { className: "h-4 w-4" }),
128
+ category: "action",
129
+ onSelect: () => {
130
+ logger.info("User opened settings");
131
+ },
132
+ },
133
+ {
134
+ id: "code-mode",
135
+ label: "Code Mode",
136
+ description: "Enable code-focused responses",
137
+ icon: _jsx(Code, { className: "h-4 w-4" }),
138
+ category: "mode",
139
+ onSelect: () => {
140
+ logger.info("User enabled code mode");
141
+ },
142
+ },
143
+ ];
144
+ return (_jsxs(ChatLayout.Root, { defaultPanelSize: "hidden", defaultActiveTab: "todo", children: [_jsxs(ChatLayout.Main, { children: [_jsx(AppChatHeader, { agentName: agentName, todos: todos, sources: sources, showHeader: messages.length > 0 }), connectionStatus === "error" && error && (_jsx("div", { className: "border-b border-destructive/20 bg-destructive/10 px-6 py-4", children: _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex-1", children: [_jsx("h3", { className: "mb-1 text-sm font-semibold text-destructive", children: "Connection Error" }), _jsx("p", { className: "whitespace-pre-line text-sm text-foreground", children: error })] }), _jsx("button", { type: "button", onClick: connect, className: "rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive-hover", children: "Retry" })] }) })), _jsxs(ChatLayout.Body, { children: [_jsx(ChatLayout.Messages, { className: messages.length > 0 ? "pt-4" : "", children: messages.length === 0 ? (_jsx("div", { className: "flex flex-1 items-center px-4", children: _jsx(ChatEmptyState, { title: agentName, description: "This agent can help you with your tasks. Start a conversation by typing a message below.", suggestedPrompts: [
145
+ "Help me debug this code",
146
+ "Explain how this works",
147
+ "Create a new feature",
148
+ "Review my changes",
149
+ ], onPromptClick: (prompt) => {
150
+ // TODO: Implement prompt click handler
151
+ logger.info("Prompt clicked", { prompt });
152
+ } }) })) : (_jsx("div", { className: "flex flex-col gap-4 px-4", children: messages.map((message, index) => (_jsx(Message, { message: message, isLastMessage: index === messages.length - 1, children: _jsx(MessageContent, { message: message, thinkingDisplayStyle: "collapsible" }) }, message.id))) })) }), _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 }) })] }) }))] }));
153
+ }
@@ -10,6 +10,7 @@ export { DatabaseTabContent, type DatabaseTabContentProps, FilesTabContent, type
10
10
  export { ChatSecondaryPanel, type ChatSecondaryPanelProps, } from "./ChatSecondaryPanel.js";
11
11
  export * as ChatSidebar from "./ChatSidebar.js";
12
12
  export { ChatStatus, type ChatStatusProps } from "./ChatStatus.js";
13
+ export { ChatView, type ChatViewProps } from "./ChatView.js";
13
14
  export { Conversation, type ConversationProps } from "./Conversation.js";
14
15
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } from "./Dialog.js";
15
16
  export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./DropdownMenu.js";
@@ -12,6 +12,7 @@ export { DatabaseTabContent, FilesTabContent, SourcesTabContent, TodoTabContent,
12
12
  export { ChatSecondaryPanel, } from "./ChatSecondaryPanel.js";
13
13
  export * as ChatSidebar from "./ChatSidebar.js";
14
14
  export { ChatStatus } from "./ChatStatus.js";
15
+ export { ChatView } from "./ChatView.js";
15
16
  // Chat components - shadcn.io/ai inspired primitives
16
17
  export { Conversation } from "./Conversation.js";
17
18
  // Dialog components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/ui",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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.9",
43
+ "@townco/core": "0.0.11",
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.28",
65
+ "@townco/tsconfig": "0.1.30",
66
66
  "@types/node": "^24.10.0",
67
67
  "@types/react": "^19.2.2",
68
68
  "ink": "^6.4.0",