feat(initial): scaffold @push.rocks/smartchat with core, CLI, and web layers

Three-layer architecture built on @push.rocks/smartagent:
- ts/ — ChatSession wrapping runAgent() with conversation state management
- ts_cli/ — ink-based terminal chat TUI (React.createElement, no JSX)
- ts_web/ — Lit web components (smartchat-window, smartchat-message, smartchat-input)
This commit is contained in:
2026-03-06 23:20:12 +00:00
commit dd04edb420
24 changed files with 11344 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { React, h, Box, Text, useInput, useApp, ChatSession } from './plugins.js';
import type { LanguageModelV3, ToolSet, IChatUsage } from './plugins.js';
import { MessageList } from './components.messagelist.js';
import { InputArea } from './components.inputarea.js';
import { StatusBar } from './components.statusbar.js';
import type { IChatMessage } from './components.message.js';
export interface IChatAppProps {
model: LanguageModelV3;
system?: string;
tools?: ToolSet;
maxSteps?: number;
modelName?: string;
}
const emptyUsage: IChatUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turns: 0 };
export function ChatApp({ model, system, tools, maxSteps, modelName }: IChatAppProps): React.ReactElement {
const { exit } = useApp();
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
const [streamingText, setStreamingText] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [usage, setUsage] = React.useState<IChatUsage>({ ...emptyUsage });
const sessionRef = React.useRef<ChatSession | null>(null);
if (!sessionRef.current) {
sessionRef.current = new ChatSession(
{ model, system, tools, maxSteps: maxSteps ?? 20 },
{
onToken: (delta) => {
setStreamingText((prev) => prev + delta);
},
onToolCall: (name, input) => {
setMessages((prev) => [
...prev,
{
role: 'tool' as const,
content: '',
toolName: name,
toolInput: typeof input === 'string' ? input : JSON.stringify(input),
},
]);
},
},
);
}
useInput((input, key) => {
if (input === 'c' && key.ctrl) {
exit();
}
if (input === 'l' && key.ctrl) {
setMessages([]);
setStreamingText('');
sessionRef.current?.clear();
setUsage({ ...emptyUsage });
}
});
const handleSubmit = async (text: string) => {
if (busy) return;
if (text === '/clear') {
setMessages([]);
setStreamingText('');
sessionRef.current?.clear();
setUsage({ ...emptyUsage });
return;
}
if (text === '/quit' || text === '/exit') {
exit();
return;
}
setMessages((prev) => [...prev, { role: 'user', content: text }]);
setBusy(true);
setStreamingText('');
try {
const result = await sessionRef.current!.send(text);
setStreamingText('');
setMessages((prev) => [...prev, { role: 'assistant', content: result.text }]);
setUsage(sessionRef.current!.getUsage());
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
setMessages((prev) => [...prev, { role: 'assistant', content: `Error: ${errMsg}` }]);
} finally {
setBusy(false);
}
};
return h(Box, { flexDirection: 'column' as const },
h(Box, { paddingX: 1 },
h(Text, { bold: true, color: 'cyan' }, 'smartchat'),
modelName ? h(Text, { dimColor: true }, ` \u2014 ${modelName}`) : null,
),
h(MessageList, { messages, streamingText, busy }),
h(InputArea, { disabled: busy, onSubmit: handleSubmit }),
h(StatusBar, { modelName, usage, busy }),
);
}
+29
View File
@@ -0,0 +1,29 @@
import { React, h, Box, Text, TextInput } from './plugins.js';
interface IInputAreaProps {
disabled: boolean;
onSubmit: (text: string) => void;
}
export function InputArea({ disabled, onSubmit }: IInputAreaProps): React.ReactElement {
const [value, setValue] = React.useState('');
const handleSubmit = (text: string) => {
if (!text.trim()) return;
onSubmit(text.trim());
setValue('');
};
return h(
Box,
{ borderStyle: 'single' as const, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1 },
h(Text, { bold: true, color: disabled ? 'gray' : 'cyan' }, '> '),
h(TextInput, {
value,
onChange: setValue,
onSubmit: handleSubmit,
placeholder: disabled ? 'Waiting for response...' : 'Type a message...',
focus: !disabled,
}),
);
}
+48
View File
@@ -0,0 +1,48 @@
import { React, h, Box, Text } from './plugins.js';
export interface IChatMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
toolName?: string;
toolInput?: string;
}
interface IMessageProps {
message: IChatMessage;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen) + '...';
}
export function Message({ message }: IMessageProps): React.ReactElement | null {
switch (message.role) {
case 'user':
return h(Box, { marginY: 0 },
h(Text, { bold: true, color: 'blue' }, 'You: '),
h(Text, null, message.content),
);
case 'assistant':
return h(Box, { marginY: 0 },
h(Text, { bold: true, color: 'green' }, 'Assistant: '),
h(Text, null, message.content),
);
case 'tool':
return h(Box, { marginY: 0, marginLeft: 2, flexDirection: 'column' as const },
h(Text, { dimColor: true },
`tool: ${message.toolName}${message.toolInput ? ` (${message.toolInput})` : ''}`,
),
message.content
? h(Box, { marginLeft: 2 },
h(Text, { dimColor: true }, truncate(message.content, 200)),
)
: null,
);
default:
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { React, h, Box, Text } from './plugins.js';
import { Message, type IChatMessage } from './components.message.js';
interface IMessageListProps {
messages: IChatMessage[];
streamingText: string;
busy: boolean;
}
export function MessageList({ messages, streamingText, busy }: IMessageListProps): React.ReactElement {
const children: React.ReactNode[] = [];
if (messages.length === 0 && !busy) {
children.push(
h(Box, { justifyContent: 'center', marginY: 1, key: 'empty' },
h(Text, { dimColor: true }, 'Type a message to start chatting.'),
),
);
}
for (let i = 0; i < messages.length; i++) {
children.push(h(Message, { key: `msg-${i}`, message: messages[i]! }));
}
if (busy && streamingText) {
children.push(
h(Box, { marginY: 0, key: 'streaming' },
h(Text, { bold: true, color: 'green' }, 'Assistant: '),
h(Text, null, streamingText),
h(Text, { dimColor: true }, '\u2588'),
),
);
}
if (busy && !streamingText) {
children.push(
h(Box, { marginY: 0, key: 'spinner' },
h(Text, { color: 'yellow' }, '\u25CF'),
h(Text, { dimColor: true }, ' Thinking...'),
),
);
}
return h(Box, { flexDirection: 'column' as const, flexGrow: 1 }, ...children);
}
+31
View File
@@ -0,0 +1,31 @@
import { React, h, Box, Text } from './plugins.js';
import type { IChatUsage } from './plugins.js';
interface IStatusBarProps {
modelName?: string;
usage: IChatUsage;
busy: boolean;
}
export function StatusBar({ modelName, usage, busy }: IStatusBarProps): React.ReactElement {
const children: React.ReactNode[] = [
h(Text, { dimColor: true, key: 'model' }, modelName ? `model: ${modelName}` : 'smartchat'),
h(Text, { dimColor: true, key: 'sep1' }, ' | '),
h(Text, { dimColor: true, key: 'tokens' }, `tokens: ${usage.totalTokens.toLocaleString()}`),
h(Text, { dimColor: true, key: 'sep2' }, ' | '),
h(Text, { dimColor: true, key: 'turns' }, `turns: ${usage.turns}`),
];
if (busy) {
children.push(
h(Text, { dimColor: true, key: 'sep3' }, ' | '),
h(Text, { color: 'yellow', key: 'thinking' }, 'thinking...'),
);
}
return h(
Box,
{ borderStyle: 'single' as const, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 1 },
...children,
);
}
+2
View File
@@ -0,0 +1,2 @@
export { startChat } from './smartchat.startchat.js';
export type { IStartChatOptions } from './smartchat.startchat.js';
+21
View File
@@ -0,0 +1,21 @@
// ink (React-based terminal UI)
import { render, Box, Text, Static, Newline, Spacer, useInput, useApp } from 'ink';
export { render, Box, Text, Static, Newline, Spacer, useInput, useApp };
// ink components
import TextInput from 'ink-text-input';
export { TextInput };
// React
import React from 'react';
export { React };
export const h = React.createElement;
// Core (cross-folder import)
import { ChatSession } from '../ts/smartchat.classes.chatsession.js';
export { ChatSession };
export type { IChatSessionOptions, IChatCallbacks, IChatUsage } from '../ts/smartchat.interfaces.js';
// smartagent types
export type { IAgentRunResult } from '@push.rocks/smartagent';
export type { LanguageModelV3, ToolSet } from '@push.rocks/smartai';
+34
View File
@@ -0,0 +1,34 @@
import { React, h, render } from './plugins.js';
import type { LanguageModelV3, ToolSet } from './plugins.js';
import { ChatApp } from './components.chatapp.js';
export interface IStartChatOptions {
/** The language model to use */
model: LanguageModelV3;
/** System prompt for the agent */
system?: string;
/** Tools available to the agent */
tools?: ToolSet;
/** Maximum agentic steps per turn */
maxSteps?: number;
/** Display name for the model (shown in header/status) */
modelName?: string;
}
/**
* Start an interactive chat TUI in the terminal.
* Returns a promise that resolves when the user exits.
*/
export async function startChat(options: IStartChatOptions): Promise<void> {
const instance = render(
h(ChatApp, {
model: options.model,
system: options.system,
tools: options.tools,
maxSteps: options.maxSteps,
modelName: options.modelName,
}),
);
await instance.waitUntilExit();
}