Skip to content

Commit 8a3cef1

Browse files
committed
feat: ink-based terminal UI with real-time streaming
- Ink (React for terminals) replaces raw readline for TTY sessions - Real-time token-by-token streaming (text appears as it's generated) - Thinking animation with spinner while model reasons - Tool execution spinners with timing display - Waiting indicator while model processes - Automatic fallback to basic readline for piped/non-TTY input - onStreamDelta callback in LLM client feeds text to UI in real-time - Removes duplicate text emission (streamed live, not buffered)
1 parent b1e074f commit 8a3cef1

File tree

13 files changed

+1220
-112
lines changed

13 files changed

+1220
-112
lines changed

dist/agent/llm.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ export declare class ModelClient {
4444
/**
4545
* Non-streaming completion for simple requests.
4646
*/
47-
complete(request: ModelRequest, signal?: AbortSignal, onToolReady?: (tool: CapabilityInvocation) => void): Promise<{
47+
complete(request: ModelRequest, signal?: AbortSignal, onToolReady?: (tool: CapabilityInvocation) => void, onStreamDelta?: (delta: {
48+
type: 'text' | 'thinking';
49+
text: string;
50+
}) => void): Promise<{
4851
content: ContentPart[];
4952
usage: CompletionUsage;
5053
stopReason: string;

dist/agent/llm.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class ModelClient {
7373
/**
7474
* Non-streaming completion for simple requests.
7575
*/
76-
async complete(request, signal, onToolReady) {
76+
async complete(request, signal, onToolReady, onStreamDelta) {
7777
const collected = [];
7878
let usage = { inputTokens: 0, outputTokens: 0 };
7979
let stopReason = 'end_turn';
@@ -106,10 +106,16 @@ export class ModelClient {
106106
if (!delta)
107107
break;
108108
if (delta.type === 'text_delta') {
109-
currentText += delta.text || '';
109+
const text = delta.text || '';
110+
currentText += text;
111+
if (text)
112+
onStreamDelta?.({ type: 'text', text });
110113
}
111114
else if (delta.type === 'thinking_delta') {
112-
currentThinking += delta.thinking || '';
115+
const text = delta.thinking || '';
116+
currentThinking += text;
117+
if (text)
118+
onStreamDelta?.({ type: 'thinking', text });
113119
}
114120
else if (delta.type === 'input_json_delta') {
115121
currentToolInput += delta.partial_json || '';

dist/agent/loop.js

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,16 @@ export async function interactiveSession(config, getUserInput, onEvent) {
269269
stream: true,
270270
}, abort.signal,
271271
// Start concurrent tools as soon as their input is fully received
272-
(tool) => streamExec.onToolReceived(tool));
272+
(tool) => streamExec.onToolReceived(tool),
273+
// Stream text/thinking deltas to UI in real-time
274+
(delta) => {
275+
if (delta.type === 'text') {
276+
onEvent({ kind: 'text_delta', text: delta.text });
277+
}
278+
else if (delta.type === 'thinking') {
279+
onEvent({ kind: 'thinking_delta', text: delta.text });
280+
}
281+
});
273282
responseParts = result.content;
274283
usage = result.usage;
275284
stopReason = result.stopReason;
@@ -311,32 +320,20 @@ export async function interactiveSession(config, getUserInput, onEvent) {
311320
console.error(`[runcode] Max tokens hit — escalating to ${maxTokensOverride}`);
312321
}
313322
}
314-
// Append what we got + a continuation prompt
323+
// Append what we got + a continuation prompt (text already streamed)
315324
history.push({ role: 'assistant', content: responseParts });
316325
history.push({
317326
role: 'user',
318327
content: 'Continue where you left off. Do not repeat what you already said.',
319328
});
320-
// Emit partial text
321-
for (const part of responseParts) {
322-
if (part.type === 'text') {
323-
onEvent({ kind: 'text_delta', text: part.text });
324-
}
325-
}
326329
continue; // Retry with higher limit
327330
}
328331
// Reset recovery counter on successful completion
329332
recoveryAttempts = 0;
330-
// Emit text and thinking
333+
// Extract tool invocations (text/thinking already streamed in real-time)
331334
const invocations = [];
332335
for (const part of responseParts) {
333-
if (part.type === 'text') {
334-
onEvent({ kind: 'text_delta', text: part.text });
335-
}
336-
else if (part.type === 'thinking') {
337-
onEvent({ kind: 'thinking_delta', text: part.thinking });
338-
}
339-
else if (part.type === 'tool_use') {
336+
if (part.type === 'tool_use') {
340337
invocations.push(part);
341338
}
342339
}

dist/commands/start.js

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { printBanner } from '../banner.js';
66
import { assembleInstructions } from '../agent/context.js';
77
import { interactiveSession } from '../agent/loop.js';
88
import { allCapabilities, createSubAgentCapability } from '../tools/index.js';
9-
import { TerminalUI } from '../ui/terminal.js';
9+
import { launchInkUI } from '../ui/app.js';
1010
import { pickModel, resolveModel } from '../ui/model-picker.js';
1111
export async function startCommand(options) {
1212
const version = options.version ?? '1.0.0';
1313
const chain = loadChain();
1414
const apiUrl = API_URLS[chain];
1515
const config = loadConfig();
16-
// Resolve model — show picker if interactive and no model specified
16+
// Resolve model
1717
let model;
1818
let bannerShown = false;
1919
const configModel = config['default-model'];
@@ -24,7 +24,6 @@ export async function startCommand(options) {
2424
model = configModel;
2525
}
2626
else if (process.stdin.isTTY) {
27-
// Interactive — show model picker
2827
printBanner(version);
2928
bannerShown = true;
3029
const picked = await pickModel();
@@ -57,15 +56,12 @@ export async function startCommand(options) {
5756
return;
5857
}
5958
}
60-
// Print banner (skip if already shown during model pick)
6159
if (!bannerShown)
6260
printBanner(version);
6361
const workDir = process.cwd();
64-
const ui = new TerminalUI();
65-
ui.printWelcome(model, workDir);
6662
// Assemble system instructions
6763
const systemInstructions = assembleInstructions(workDir);
68-
// Build capabilities including sub-agent
64+
// Build capabilities
6965
const subAgent = createSubAgentCapability(apiUrl, chain, allCapabilities);
7066
const capabilities = [...allCapabilities, subAgent];
7167
// Agent config
@@ -80,38 +76,69 @@ export async function startCommand(options) {
8076
permissionMode: options.trust ? 'trust' : 'default',
8177
debug: options.debug,
8278
};
83-
// Run interactive session
79+
// Use ink UI if TTY, fallback to basic readline for piped input
80+
if (process.stdin.isTTY) {
81+
await runWithInkUI(agentConfig, model, workDir, version);
82+
}
83+
else {
84+
await runWithBasicUI(agentConfig, model, workDir);
85+
}
86+
}
87+
// ─── Ink UI (interactive terminal) ─────────────────────────────────────────
88+
async function runWithInkUI(agentConfig, model, workDir, version) {
89+
const ui = launchInkUI({ model, workDir, version });
90+
try {
91+
await interactiveSession(agentConfig, async () => {
92+
const input = await ui.waitForInput();
93+
if (input === null)
94+
return null;
95+
if (input === '')
96+
return '';
97+
// Handle slash commands
98+
if (input.startsWith('/')) {
99+
const result = await handleSlashCommand(input, agentConfig, ui);
100+
if (result === 'exit')
101+
return null;
102+
if (result === null)
103+
return ''; // re-prompt
104+
return result;
105+
}
106+
return input;
107+
}, (event) => ui.handleEvent(event));
108+
}
109+
catch (err) {
110+
if (err.name !== 'AbortError') {
111+
console.error(chalk.red(`\nError: ${err.message}`));
112+
}
113+
}
114+
ui.cleanup();
115+
console.log(chalk.dim('\nGoodbye.\n'));
116+
}
117+
// ─── Basic readline UI (piped input) ───────────────────────────────────────
118+
async function runWithBasicUI(agentConfig, model, workDir) {
119+
const { TerminalUI } = await import('../ui/terminal.js');
120+
const ui = new TerminalUI();
121+
ui.printWelcome(model, workDir);
84122
try {
85123
await interactiveSession(agentConfig, async () => {
86124
while (true) {
87125
const input = await ui.promptUser();
88126
if (input === null)
89-
return null; // EOF
127+
return null;
90128
if (input === '')
91-
continue; // empty → re-prompt
92-
if (input.startsWith('/')) {
93-
const result = await handleSlashCommand(input, agentConfig);
94-
if (result === 'exit')
95-
return null;
96-
if (result === null)
97-
continue; // command handled → re-prompt
98-
return result; // string → send to agent
99-
}
129+
continue;
100130
return input;
101131
}
102132
}, (event) => ui.handleEvent(event));
103133
}
104134
catch (err) {
105-
if (err.name === 'AbortError') {
106-
// User interrupted
107-
}
108-
else {
135+
if (err.name !== 'AbortError') {
109136
console.error(chalk.red(`\nError: ${err.message}`));
110137
}
111138
}
112139
ui.printGoodbye();
113140
}
114-
async function handleSlashCommand(cmd, config) {
141+
async function handleSlashCommand(cmd, config, ui) {
115142
const parts = cmd.trim().split(/\s+/);
116143
const command = parts[0].toLowerCase();
117144
switch (command) {
@@ -121,39 +148,37 @@ async function handleSlashCommand(cmd, config) {
121148
case '/model': {
122149
const newModel = parts[1];
123150
if (newModel) {
124-
// Direct switch via shortcut or full ID
125151
config.model = resolveModel(newModel);
126152
console.error(chalk.green(` Model → ${config.model}`));
127153
return null;
128154
}
129-
// No arg — show interactive picker
130155
const picked = await pickModel(config.model);
131156
if (picked) {
132157
config.model = picked;
133158
console.error(chalk.green(` Model → ${config.model}`));
134159
}
135160
return null;
136161
}
137-
case '/models':
138-
// Shortcut: same as /model with no args
162+
case '/models': {
139163
const picked = await pickModel(config.model);
140164
if (picked) {
141165
config.model = picked;
142166
console.error(chalk.green(` Model → ${config.model}`));
143167
}
144168
return null;
169+
}
145170
case '/cost':
146171
case '/usage': {
147172
const { getStatsSummary } = await import('../stats/tracker.js');
148-
const { stats, opusCost, saved } = getStatsSummary();
173+
const { stats, saved } = getStatsSummary();
149174
console.error(chalk.dim(`\n Requests: ${stats.totalRequests} | Cost: $${stats.totalCostUsd.toFixed(4)} | Saved: $${saved.toFixed(2)} vs Opus\n`));
150175
return null;
151176
}
152177
case '/help':
153178
console.error(chalk.bold('\n Commands:'));
154-
console.error(' /model [name] — switch model (interactive picker if no name)');
155-
console.error(' /models — browse all available models');
156-
console.error(' /cost — show session cost and savings');
179+
console.error(' /model [name] — switch model (picker if no name)');
180+
console.error(' /models — browse available models');
181+
console.error(' /cost — session cost and savings');
157182
console.error(' /exit — quit');
158183
console.error(' /help — this help\n');
159184
console.error(chalk.dim(' Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4\n'));

dist/ui/app.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* RunCode ink-based terminal UI.
3+
* Real-time streaming, thinking animation, tool progress, markdown output.
4+
*/
5+
import type { StreamEvent } from '../agent/types.js';
6+
export interface InkUIHandle {
7+
handleEvent: (event: StreamEvent) => void;
8+
waitForInput: () => Promise<string | null>;
9+
cleanup: () => void;
10+
}
11+
export declare function launchInkUI(opts: {
12+
model: string;
13+
workDir: string;
14+
version: string;
15+
}): InkUIHandle;

0 commit comments

Comments
 (0)