Skip to content

Commit 8018c37

Browse files
NianJiuZstclaude
andcommitted
feat: add conversation state and streaming chat API integration to repl
Wire the REPL to the MiniMax Messages API for real chat: - Introduce ReplState to track messages, model, system prompt, and sampling parameters across turns - Implement sendMessages() with SSE streaming, thinking/response display separation, and automatic conversation history accumulation - Wire /clear, /system, /model, /save, and /history slash commands to operate on the live conversation state - Add SIGINT handler with double-Ctrl+C to quit and single-interrupt during response streaming - Initialize state from CLI flags (--model, --system, --temperature, etc.) and config defaults The echo placeholder is replaced with a full chat loop — each user message is added to the conversation, streamed to the API, and the assistant response is appended back to history. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 929daa7 commit 8018c37

1 file changed

Lines changed: 189 additions & 8 deletions

File tree

src/commands/text/repl.ts

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { defineCommand } from '../../command';
2+
import { request } from '../../client/http';
3+
import { chatEndpoint } from '../../client/endpoints';
4+
import { parseSSE } from '../../client/stream';
25
import { CLIError } from '../../errors/base';
36
import { ExitCode } from '../../errors/codes';
47
import { isInteractive } from '../../utils/env';
58
import type { Config } from '../../config/schema';
69
import type { GlobalFlags } from '../../types/flags';
10+
import type { ChatMessage, ChatRequest, StreamEvent } from '../../types/api';
11+
import { writeFileSync } from 'node:fs';
712

813
// ---------------------------------------------------------------------------
914
// ANSI helpers
@@ -42,6 +47,19 @@ function showHelp(): void {
4247
process.stdout.write('\n');
4348
}
4449

50+
// ---------------------------------------------------------------------------
51+
// Conversation state
52+
// ---------------------------------------------------------------------------
53+
54+
interface ReplState {
55+
messages: ChatMessage[];
56+
system: string | undefined;
57+
model: string;
58+
maxTokens: number;
59+
temperature: number | undefined;
60+
topP: number | undefined;
61+
}
62+
4563
// ---------------------------------------------------------------------------
4664
// Custom line editor — raw-mode keypress handling with full render control
4765
// ---------------------------------------------------------------------------
@@ -303,9 +321,23 @@ export default defineCommand({
303321
const dim = config.noColor ? '' : '\x1b[2m';
304322
const reset = config.noColor ? '' : '\x1b[0m';
305323

324+
// ---- Initialize state ----
325+
const state: ReplState = {
326+
messages: [],
327+
system: flags.system as string | undefined,
328+
model: (flags.model as string) || config.defaultTextModel || 'MiniMax-M2.7',
329+
maxTokens: (flags.maxTokens as number) ?? 4096,
330+
temperature: flags.temperature !== undefined ? flags.temperature as number : undefined,
331+
topP: flags.topP !== undefined ? flags.topP as number : undefined,
332+
};
333+
306334
const bold = config.noColor ? '' : '\x1b[1m';
307335

308336
process.stdout.write(`\n${bold}MiniMax Chat REPL${reset}\n`);
337+
process.stdout.write(`${dim}Model: ${state.model}${reset}\n`);
338+
if (state.system) {
339+
process.stdout.write(`${dim}System: ${state.system.slice(0, 80)}${state.system.length > 80 ? '...' : ''}${reset}\n`);
340+
}
309341
process.stdout.write(`${dim}Type / to see commands, /exit to quit.${reset}\n`);
310342

311343
const stdin = process.stdin;
@@ -317,6 +349,91 @@ export default defineCommand({
317349
stdin.resume();
318350
stdin.setEncoding('utf8');
319351

352+
let waitingForResponse = false;
353+
let sigintCount = 0;
354+
355+
// ---- Helper: send messages and stream response ----
356+
async function sendMessages(): Promise<void> {
357+
if (state.messages.length === 0) {
358+
stdout.write(`${dim}No messages to send. Type something first.${reset}\n`);
359+
return;
360+
}
361+
362+
const body: ChatRequest = {
363+
model: state.model,
364+
messages: state.messages,
365+
max_tokens: state.maxTokens,
366+
stream: true,
367+
};
368+
if (state.system) body.system = state.system;
369+
if (state.temperature !== undefined) body.temperature = state.temperature;
370+
if (state.topP !== undefined) body.top_p = state.topP;
371+
372+
waitingForResponse = true;
373+
const url = chatEndpoint(config.baseUrl);
374+
375+
try {
376+
const res = await request(config, {
377+
url,
378+
method: 'POST',
379+
body,
380+
stream: true,
381+
authStyle: 'x-api-key',
382+
});
383+
384+
const contentType = res.headers.get('content-type') || '';
385+
if (!contentType.includes('text/event-stream') && !contentType.includes('stream')) {
386+
throw new CLIError(
387+
`Expected SSE stream but got content-type "${contentType}".`,
388+
ExitCode.GENERAL,
389+
);
390+
}
391+
392+
let textContent = '';
393+
let inThinking = false;
394+
395+
for await (const event of parseSSE(res)) {
396+
if (event.data === '[DONE]') break;
397+
try {
398+
const parsed = JSON.parse(event.data) as StreamEvent;
399+
400+
if (parsed.type === 'content_block_start') {
401+
if (parsed.content_block.type === 'thinking') {
402+
inThinking = true;
403+
stdout.write(`${dim}Thinking:\n`);
404+
} else if (parsed.content_block.type === 'text' && inThinking) {
405+
stdout.write(`${reset}\nResponse:\n`);
406+
inThinking = false;
407+
}
408+
} else if (parsed.type === 'content_block_delta') {
409+
if (parsed.delta.type === 'text_delta') {
410+
textContent += parsed.delta.text;
411+
stdout.write(parsed.delta.text);
412+
} else if (parsed.delta.type === 'thinking_delta') {
413+
stdout.write(parsed.delta.thinking);
414+
}
415+
}
416+
} catch {
417+
// Skip malformed chunks
418+
}
419+
}
420+
421+
if (inThinking) stdout.write(reset);
422+
423+
if (textContent) {
424+
state.messages.push({ role: 'assistant', content: textContent });
425+
stdout.write('\n');
426+
} else {
427+
stdout.write(`${dim}[empty response]${reset}\n`);
428+
}
429+
} catch (err) {
430+
stdout.write(`${dim}[error] ${err instanceof Error ? err.message : String(err)}${reset}\n`);
431+
} finally {
432+
waitingForResponse = false;
433+
sigintCount = 0;
434+
}
435+
}
436+
320437
// ---- Helper: handle slash commands ----
321438
function handleSlash(input: string): 'exit' | 'ok' {
322439
const parts = input.trim().split(/\s+/);
@@ -333,38 +450,75 @@ export default defineCommand({
333450
return 'ok';
334451

335452
case '/clear':
453+
state.messages = [];
336454
stdout.write(`${dim}Conversation cleared.${reset}\n`);
337455
return 'ok';
338456

339457
case '/system': {
340458
if (arg) {
459+
state.system = arg;
341460
stdout.write(`${dim}System prompt set.${reset}\n`);
342461
} else {
343-
stdout.write(`${dim}No system prompt set.${reset}\n`);
462+
if (state.system) {
463+
stdout.write(`${dim}System prompt:${reset}\n${state.system}\n`);
464+
} else {
465+
stdout.write(`${dim}No system prompt set.${reset}\n`);
466+
}
344467
}
345468
return 'ok';
346469
}
347470

348471
case '/model': {
349472
if (arg) {
350-
stdout.write(`${dim}Model set to: ${arg}${reset}\n`);
473+
state.model = arg;
474+
stdout.write(`${dim}Model set to: ${state.model}${reset}\n`);
351475
} else {
352-
stdout.write(`${dim}Current model will be displayed here.${reset}\n`);
476+
stdout.write(`${dim}Current model: ${state.model}${reset}\n`);
353477
}
354478
return 'ok';
355479
}
356480

357481
case '/save': {
358482
if (!arg) {
359483
stdout.write(`${dim}Usage: /save <file-path>${reset}\n`);
360-
} else {
361-
stdout.write(`${dim}Conversation saving will be available once chat is integrated.${reset}\n`);
484+
return 'ok';
485+
}
486+
try {
487+
const toSave: Array<{ role: string; content: string }> = [];
488+
if (state.system) toSave.push({ role: 'system', content: state.system });
489+
for (const m of state.messages) {
490+
toSave.push({
491+
role: m.role,
492+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
493+
});
494+
}
495+
writeFileSync(arg, JSON.stringify(toSave, null, 2), 'utf-8');
496+
stdout.write(`${dim}Conversation saved to ${arg} (${toSave.length} messages)${reset}\n`);
497+
} catch (err) {
498+
stdout.write(`${dim}[error] Failed to save: ${err instanceof Error ? err.message : String(err)}${reset}\n`);
362499
}
363500
return 'ok';
364501
}
365502

366503
case '/history': {
367-
stdout.write(`${dim}History will be available once chat is integrated.${reset}\n`);
504+
if (state.messages.length === 0) {
505+
stdout.write(`${dim}No messages in conversation.${reset}\n`);
506+
return 'ok';
507+
}
508+
if (state.system) {
509+
stdout.write(`${dim}[system] ${state.system.slice(0, 100)}${state.system.length > 100 ? '...' : ''}${reset}\n`);
510+
}
511+
let index = 1;
512+
for (const m of state.messages) {
513+
const roleLabel = m.role === 'user' ? 'user' : 'assistant';
514+
const raw = typeof m.content === 'string' ? m.content : '[structured content]';
515+
const preview = raw.length > 120 ? raw.slice(0, 120) + '...' : raw;
516+
const oneline = preview.replace(/\n/g, '\u21B5');
517+
const prefix = ` ${String(index).padStart(2)} ${roleLabel.padEnd(11)}`;
518+
stdout.write(`${dim}${prefix}${reset}${oneline}\n`);
519+
index++;
520+
}
521+
stdout.write(`${dim}\u2500\u2500 ${index - 1} messages \u2500\u2500${reset}\n`);
368522
return 'ok';
369523
}
370524

@@ -377,6 +531,31 @@ export default defineCommand({
377531
}
378532
}
379533

534+
// ---- SIGINT handler ----
535+
const onSigint = () => {
536+
if (waitingForResponse) {
537+
stdout.write(`\n${dim}[interrupted]${reset}\n`);
538+
sigintCount = 0;
539+
waitingForResponse = false;
540+
return;
541+
}
542+
sigintCount++;
543+
if (sigintCount >= 2) {
544+
stdout.write(`\n${dim}Goodbye!${reset}\n`);
545+
running = false;
546+
if (typeof stdin.setRawMode === 'function') {
547+
stdin.setRawMode(false);
548+
}
549+
stdin.pause();
550+
process.removeListener('SIGINT', onSigint);
551+
process.exit(0);
552+
} else {
553+
stdout.write(`\n${dim}Press Ctrl+C again or type /exit to quit.${reset}\n`);
554+
}
555+
};
556+
557+
process.on('SIGINT', onSigint);
558+
380559
const editor = new LineEditor(stdout, '> ', dim, reset);
381560
let running = true;
382561

@@ -404,15 +583,17 @@ export default defineCommand({
404583
continue;
405584
}
406585

407-
// Placeholder: echo back until chat integration is added
408-
stdout.write(`You said: ${trimmed}\n`);
586+
// Normal message
587+
state.messages.push({ role: 'user', content: trimmed });
588+
await sendMessages();
409589
}
410590
} finally {
411591
stdout.write(SHOW_CURSOR);
412592
if (typeof stdin.setRawMode === 'function') {
413593
stdin.setRawMode(false);
414594
}
415595
stdin.pause();
596+
process.removeListener('SIGINT', onSigint);
416597
stdin.removeAllListeners('data');
417598
stdout.write('\n');
418599
}

0 commit comments

Comments
 (0)