11import { defineCommand } from '../../command' ;
2+ import { request } from '../../client/http' ;
3+ import { chatEndpoint } from '../../client/endpoints' ;
4+ import { parseSSE } from '../../client/stream' ;
25import { CLIError } from '../../errors/base' ;
36import { ExitCode } from '../../errors/codes' ;
47import { isInteractive } from '../../utils/env' ;
58import type { Config } from '../../config/schema' ;
69import 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