Skip to content

Commit 84e6b6e

Browse files
committed
add chat app
1 parent 20ba1d7 commit 84e6b6e

25 files changed

Lines changed: 1231 additions & 22 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# OpenAI / Compatible API configuration
2+
OPENAI_API_KEY=sk-your-key-here
3+
OPENAI_BASE_URL=https://api.openai.com/v1
4+
OPENAI_MODEL=gpt-4o-mini

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"dependencies": {
2222
"@acme/core": "workspace:*",
2323
"@acme/tui": "workspace:*",
24-
"commander": "^14.0.3"
24+
"@lydell/node-pty": "1.2.0-beta.3",
25+
"commander": "^14.0.3",
26+
"dotenv": "^17.3.1"
2527
},
2628
"devDependencies": {
2729
"tsup": "^8.3.0",

packages/cli/src/index.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dotenv/config'; // load .env into process.env (overrides globals)
12
import { Command } from 'commander';
23
import { greet } from '@acme/core';
34

@@ -17,11 +18,18 @@ program
1718

1819
program
1920
.command('ui')
20-
.argument('[name]', 'Your name', 'World')
21-
.description('Launch the Ink TUI')
22-
.action(async (name: string) => {
21+
.description('Launch the multi-tab terminal TUI')
22+
.action(async () => {
2323
const { runTUI } = await import('@acme/tui');
24-
await runTUI(name);
24+
await runTUI();
25+
});
26+
27+
program
28+
.command('chat')
29+
.description('Launch the OpenAI chat TUI (requires OPENAI_API_KEY)')
30+
.action(async () => {
31+
const { runChatTUI } = await import('@acme/tui');
32+
await runChatTUI();
2533
});
2634

2735
program.parseAsync(process.argv);

packages/cli/tsup.config.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { defineConfig } from 'tsup';
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, resolve } from 'node:path';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
26

37
export default defineConfig({
48
entry: ['src/index.ts'],
@@ -7,7 +11,14 @@ export default defineConfig({
711
clean: true,
812
sourcemap: true,
913
target: 'node18',
10-
banner: { js: '#!/usr/bin/env node' },
14+
platform: 'node',
15+
banner: {
16+
js: [
17+
'#!/usr/bin/env node',
18+
'import { createRequire } from "node:module";',
19+
'const require = createRequire(import.meta.url);',
20+
].join('\n'),
21+
},
1122
noExternal: ['@acme/core', '@acme/tui'],
12-
external: ['react-devtools-core'],
23+
external: ['react-devtools-core', '@lydell/node-pty'],
1324
});

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
"format": "prettier -w \"src/**/*.{ts,tsx}\"",
1616
"test": "vitest run"
1717
},
18+
"dependencies": {
19+
"@lydell/node-pty": "1.2.0-beta.3",
20+
"openai": "^6.22.0"
21+
},
1822
"devDependencies": {
1923
"tsup": "^8.3.0",
2024
"typescript": "^5.6.0",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import OpenAI from 'openai';
2+
import { EventEmitter } from 'node:events';
3+
4+
/* ------------------------------------------------------------------ */
5+
/* Types */
6+
/* ------------------------------------------------------------------ */
7+
8+
export interface ChatMessage {
9+
role: 'system' | 'user' | 'assistant';
10+
content: string;
11+
}
12+
13+
export interface TokenUsage {
14+
prompt: number;
15+
completion: number;
16+
total: number;
17+
}
18+
19+
export interface ChatSession {
20+
id: string;
21+
name: string;
22+
messages: ChatMessage[];
23+
tokenUsage: TokenUsage;
24+
}
25+
26+
export interface ChatClientEvents {
27+
/** Fired for every streamed token chunk */
28+
token: (sessionId: string, token: string) => void;
29+
/** Fired when the full assistant message is complete */
30+
'message-complete': (sessionId: string, content: string, usage: TokenUsage) => void;
31+
/** Fired on error */
32+
error: (sessionId: string, err: Error) => void;
33+
}
34+
35+
/* ------------------------------------------------------------------ */
36+
/* ChatClient */
37+
/* ------------------------------------------------------------------ */
38+
39+
let nextSessionId = 1;
40+
41+
export class ChatClient extends EventEmitter {
42+
private openai: OpenAI;
43+
private sessions = new Map<string, ChatSession>();
44+
private model: string;
45+
private totalUsage: TokenUsage = { prompt: 0, completion: 0, total: 0 };
46+
47+
constructor(opts?: { apiKey?: string; baseURL?: string; model?: string }) {
48+
super();
49+
50+
const apiKey = opts?.apiKey ?? process.env.OPENAI_API_KEY ?? '';
51+
const baseURL = opts?.baseURL ?? process.env.OPENAI_BASE_URL ?? undefined;
52+
this.model = opts?.model ?? process.env.OPENAI_MODEL ?? 'gpt-4o-mini';
53+
54+
if (!apiKey) {
55+
throw new Error(
56+
'OpenAI API key is required. Set OPENAI_API_KEY env var or pass apiKey option.',
57+
);
58+
}
59+
60+
console.log(`Using model: ${this.model} `);
61+
console.log({ apiKey, baseURL });
62+
63+
this.openai = new OpenAI({ apiKey, baseURL });
64+
}
65+
66+
/* ---- Session management ---------------------------------------- */
67+
68+
createSession(name?: string, systemPrompt?: string): ChatSession {
69+
const id = `chat-${nextSessionId++}`;
70+
const messages: ChatMessage[] = [];
71+
if (systemPrompt) {
72+
messages.push({ role: 'system', content: systemPrompt });
73+
}
74+
const session: ChatSession = {
75+
id,
76+
name: name ?? `Chat ${nextSessionId - 1}`,
77+
messages,
78+
tokenUsage: { prompt: 0, completion: 0, total: 0 },
79+
};
80+
this.sessions.set(id, session);
81+
return session;
82+
}
83+
84+
getSession(id: string): ChatSession | undefined {
85+
return this.sessions.get(id);
86+
}
87+
88+
getAllSessions(): ChatSession[] {
89+
return Array.from(this.sessions.values());
90+
}
91+
92+
deleteSession(id: string): void {
93+
this.sessions.delete(id);
94+
}
95+
96+
get sessionCount(): number {
97+
return this.sessions.size;
98+
}
99+
100+
getTotalUsage(): TokenUsage {
101+
return { ...this.totalUsage };
102+
}
103+
104+
/* ---- Chat ------------------------------------------------------ */
105+
106+
/**
107+
* Send a user message and stream the assistant response.
108+
* Emits `token` events for each chunk, then `message-complete` when done.
109+
*/
110+
async sendMessage(sessionId: string, content: string): Promise<void> {
111+
const session = this.sessions.get(sessionId);
112+
if (!session) {
113+
throw new Error(`Session ${sessionId} not found`);
114+
}
115+
116+
session.messages.push({ role: 'user', content });
117+
118+
try {
119+
const stream = await this.openai.chat.completions.create({
120+
model: this.model,
121+
messages: session.messages.map((m) => ({
122+
role: m.role,
123+
content: m.content,
124+
})),
125+
stream: true,
126+
stream_options: { include_usage: true },
127+
});
128+
129+
let fullContent = '';
130+
131+
for await (const chunk of stream) {
132+
// Collect usage from the final chunk
133+
if (chunk.usage) {
134+
const usage: TokenUsage = {
135+
prompt: chunk.usage.prompt_tokens ?? 0,
136+
completion: chunk.usage.completion_tokens ?? 0,
137+
total: chunk.usage.total_tokens ?? 0,
138+
};
139+
session.tokenUsage.prompt += usage.prompt;
140+
session.tokenUsage.completion += usage.completion;
141+
session.tokenUsage.total += usage.total;
142+
this.totalUsage.prompt += usage.prompt;
143+
this.totalUsage.completion += usage.completion;
144+
this.totalUsage.total += usage.total;
145+
}
146+
147+
const delta = chunk.choices?.[0]?.delta?.content;
148+
if (delta) {
149+
fullContent += delta;
150+
this.emit('token', sessionId, delta);
151+
}
152+
}
153+
154+
session.messages.push({ role: 'assistant', content: fullContent });
155+
this.emit('message-complete', sessionId, fullContent, session.tokenUsage);
156+
} catch (err) {
157+
const error = err instanceof Error ? err : new Error(String(err));
158+
this.emit('error', sessionId, error);
159+
}
160+
}
161+
}

packages/core/src/chat/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ChatClient } from './ChatClient.js';
2+
export type { ChatMessage, ChatSession, TokenUsage, ChatClientEvents } from './ChatClient.js';

packages/core/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@ export function greet(name: string): string {
22
const trimmed = name?.trim();
33
return `Hello, ${trimmed && trimmed.length > 0 ? trimmed : 'World'} 👋`;
44
}
5+
6+
export { PtyManager } from './pty/index.js';
7+
export type { Session, PtyManagerEvents } from './pty/index.js';
8+
9+
export { ChatClient } from './chat/index.js';
10+
export type { ChatMessage, ChatSession, TokenUsage, ChatClientEvents } from './chat/index.js';
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { spawn, type IPty } from '@lydell/node-pty';
2+
import os from 'node:os';
3+
import { EventEmitter } from 'node:events';
4+
5+
export interface Session {
6+
id: string;
7+
name: string;
8+
pty: IPty;
9+
scrollback: string[];
10+
/** Visible buffer: last `rows` lines of output */
11+
buffer: string;
12+
}
13+
14+
export interface PtyManagerEvents {
15+
data: (sessionId: string, data: string) => void;
16+
exit: (sessionId: string, exitCode: number, signal: number) => void;
17+
}
18+
19+
let nextId = 1;
20+
21+
export class PtyManager extends EventEmitter {
22+
private sessions = new Map<string, Session>();
23+
24+
getDefaultShell(): string {
25+
if (process.env.SHELL) return process.env.SHELL;
26+
return os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash';
27+
}
28+
29+
createSession(name?: string, cols = 80, rows = 24, shell?: string, cwd?: string): Session {
30+
const id = `tab-${nextId++}`;
31+
const shellPath = shell ?? this.getDefaultShell();
32+
33+
const pty = spawn(shellPath, [], {
34+
name: 'xterm-256color',
35+
cols,
36+
rows,
37+
cwd: cwd ?? process.cwd(),
38+
env: process.env as Record<string, string>,
39+
});
40+
41+
const session: Session = {
42+
id,
43+
name: name ?? `Tab ${nextId - 1}`,
44+
pty,
45+
scrollback: [],
46+
buffer: '',
47+
};
48+
49+
pty.onData((data: string) => {
50+
session.buffer += data;
51+
// Keep buffer at a reasonable size (last ~50KB)
52+
if (session.buffer.length > 50_000) {
53+
session.buffer = session.buffer.slice(-40_000);
54+
}
55+
this.emit('data', id, data);
56+
});
57+
58+
pty.onExit(({ exitCode, signal }) => {
59+
this.emit('exit', id, exitCode, signal);
60+
});
61+
62+
this.sessions.set(id, session);
63+
return session;
64+
}
65+
66+
getSession(id: string): Session | undefined {
67+
return this.sessions.get(id);
68+
}
69+
70+
getAllSessions(): Session[] {
71+
return Array.from(this.sessions.values());
72+
}
73+
74+
write(sessionId: string, data: string): void {
75+
const session = this.sessions.get(sessionId);
76+
if (session) {
77+
session.pty.write(data);
78+
}
79+
}
80+
81+
resize(sessionId: string, cols: number, rows: number): void {
82+
const session = this.sessions.get(sessionId);
83+
if (session) {
84+
session.pty.resize(cols, rows);
85+
}
86+
}
87+
88+
killSession(sessionId: string): void {
89+
const session = this.sessions.get(sessionId);
90+
if (session) {
91+
session.pty.kill();
92+
this.sessions.delete(sessionId);
93+
}
94+
}
95+
96+
killAll(): void {
97+
for (const session of this.sessions.values()) {
98+
session.pty.kill();
99+
}
100+
this.sessions.clear();
101+
}
102+
103+
get sessionCount(): number {
104+
return this.sessions.size;
105+
}
106+
}

packages/core/src/pty/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PtyManager } from './PtyManager.js';
2+
export type { Session, PtyManagerEvents } from './PtyManager.js';

0 commit comments

Comments
 (0)