123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432(* Claude Code
*
* Copyright (C) 2026 Yoann Padioleau
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public License
* (LGPL) as published by the Free Software Foundation; either version
* 2 of the License, or (at your option) any later version.
*)(* See Vt.mli *)(*****************************************************************************)(* Types *)(*****************************************************************************)typecolor=Default|Black|Red|Green|Yellow|Blue|Magenta|Cyan|Whitetypeattrs={fg:color;bg:color;bold:bool;reverse:bool}typecell={glyph:string;attrs:attrs}letplain={fg=Default;bg=Default;bold=false;reverse=false}letblank={glyph=" ";attrs=plain}(* where the parser is in a sequence (Vt.mli's diagram) *)typestate=|Ground|Escape(* ESC ( B, ESC # 8: one more byte, then done *)|Escape_skip(* after ESC [: the parameters (and intermediates) so far *)|Csiofstring|Osc(* an ESC inside an OSC: the start of its ESC \ ending *)|Osc_escape(* a UTF-8 character: its bytes so far, how many still to come *)|Utf8ofstring*int(* mutable, but only ever a copy: [feed] copies, then changes the copy *)typet={rows:int;cols:int;cells:cellarrayarray;mutablerow:int;mutablecol:int;mutablewrap_pending:bool;mutableattrs:attrs;(* the scrolling region, rows from 0, both included *)mutabletop:int;mutablebottom:int;mutablesaved:int*int*attrs;mutablecursor_visible:bool;mutablebells:int;mutablestate:state;}letcreate~rows~cols={rows;cols;cells=Array.initrows(fun_->Array.makecolsblank);row=0;col=0;wrap_pending=false;attrs=plain;top=0;bottom=rows-1;saved=(0,0,plain);cursor_visible=true;bells=0;state=Ground;}letcopy(t:t):t={twithcells=Array.mapArray.copyt.cells}(*****************************************************************************)(* The cursor *)(*****************************************************************************)letclamplohix=maxlo(minhix)(* every move but writing a character forgets a pending wrap *)letmove_to(t:t)(row:int)(col:int):unit=t.row<-clamp0(t.rows-1)row;t.col<-clamp0(t.cols-1)col;t.wrap_pending<-false(*****************************************************************************)(* Scrolling and erasing *)(*****************************************************************************)(* the rows [top..bottom] move up by [n], blank rows coming in at the
bottom: what LF does on the region's last row *)letscroll_up(t:t)~top~bottom(n:int):unit=letn=minn(bottom-top+1)inforr=toptobottomdot.cells.(r)<-(ifr+n<=bottomthent.cells.(r+n)elseArray.maket.colsblank)done(* the other way, blank rows coming in at the top: ESC M on the
region's first row, and CSI L *)letscroll_down(t:t)~top~bottom(n:int):unit=letn=minn(bottom-top+1)inforr=bottomdowntotopdot.cells.(r)<-(ifr-n>=topthent.cells.(r-n)elseArray.maket.colsblank)doneletline_feed(t:t):unit=ift.row=t.bottomthenscroll_upt~top:t.top~bottom:t.bottom1elseift.row<t.rows-1thent.row<-t.row+1;t.wrap_pending<-falseletreverse_index(t:t):unit=ift.row=t.topthenscroll_downt~top:t.top~bottom:t.bottom1elseift.row>0thent.row<-t.row-1;t.wrap_pending<-false(* columns [c1..c2] of a row, blanked *)leterase(t:t)(row:int)(c1:int)(c2:int):unit=forc=max0c1tomin(t.cols-1)c2dot.cells.(row).(c)<-blankdone(*****************************************************************************)(* Characters *)(*****************************************************************************)(* the deferred wrap: writing in the last column only raises the flag,
and the wrap happens when the next character comes (Vt.mli) *)letput(t:t)(glyph:string):unit=ift.wrap_pendingthenbegint.col<-0;line_feedtend;t.cells.(t.row).(t.col)<-{glyph;attrs=t.attrs};ift.col=t.cols-1thent.wrap_pending<-trueelset.col<-t.col+1letreplacement="\xEF\xBF\xBD"(* U+FFFD *)(* the controls, done at once in any state *)letcontrol(t:t)(c:char):unit=matchcwith|'\x07'->t.bells<-t.bells+1|'\b'->move_tott.row(t.col-1)|'\t'->move_tott.row((t.col/8+1)*8)|'\n'|'\x0B'|'\x0C'->line_feedt|'\r'->move_tott.row0|_->()(*****************************************************************************)(* SGR: how the next characters look *)(*****************************************************************************)letcolor_of(n:int):color=matchnwith|0->Black|1->Red|2->Green|3->Yellow|4->Blue|5->Magenta|6->Cyan|_->Whiteletrecsgr(a:attrs)(ps:intlist):attrs=matchpswith|[]->a|0::rest->sgrplainrest|1::rest->sgr{awithbold=true}rest|22::rest->sgr{awithbold=false}rest|7::rest->sgr{awithreverse=true}rest|27::rest->sgr{awithreverse=false}rest|n::restwhenn>=30&&n<=37->sgr{awithfg=color_of(n-30)}rest|39::rest->sgr{awithfg=Default}rest|n::restwhenn>=40&&n<=47->sgr{awithbg=color_of(n-40)}rest|49::rest->sgr{awithbg=Default}rest(* aixterm's bright colours: the colour, bold *)|n::restwhenn>=90&&n<=97->sgr{awithfg=color_of(n-90);bold=true}rest|n::restwhenn>=100&&n<=107->sgr{awithbg=color_of(n-100)}rest(* 256 colours and 24-bit ones: their arguments skipped, ignored *)|(38|48)::5::_::rest->sgrarest|(38|48)::2::_::_::_::rest->sgrarest|_::rest->sgrarest(*****************************************************************************)(* CSI: the sequences with numbers *)(*****************************************************************************)(* "1;31" -> [1; 31], an empty parameter being 0 ("" -> [0], ";5" -> [0; 5]) *)letparams(s:string):intlist=String.split_on_char';'s|>List.map(funp->Option.value(int_of_string_optp)~default:0)(* the i-th parameter, 0 or missing meaning [default] *)letnth(ps:intlist)(i:int)(default:int):int=matchList.nth_optpsiwith|Somenwhenn>0->n|_->defaultletcsi(t:t)(collected:string)(final:char):unit=letprivate_=String.lengthcollected>0&&collected.[0]='?'inletps=params(ifprivate_thenString.subcollected1(String.lengthcollected-1)elsecollected)inletn=nthps01inletmode=matchpswithm::_->m|[]->0inmatchfinalwith|_whenString.exists(func->c>=' '&&c<='/')collected->()(* intermediates: none we know *)|'h'|'l'->ifprivate_&&mode=25thent.cursor_visible<-final='h'|_whenprivate_->()|'A'->move_tot(t.row-n)t.col|'B'->move_tot(t.row+n)t.col|'C'->move_tott.row(t.col+n)|'D'->move_tott.row(t.col-n)|'E'->move_tot(t.row+n)0|'F'->move_tot(t.row-n)0|'G'->move_tott.row(n-1)|'d'->move_tot(n-1)t.col|'H'|'f'->move_tot(nthps01-1)(nthps11-1)|'J'->letbefore()=forr=0tot.row-1doerasetr0(t.cols-1)doneinletafter()=forr=t.row+1tot.rows-1doerasetr0(t.cols-1)donein(matchmodewith|0->erasett.rowt.col(t.cols-1);after()|1->before();erasett.row0t.col|_->before();erasett.row0(t.cols-1);after())|'K'->(matchmodewith|0->erasett.rowt.col(t.cols-1)|1->erasett.row0t.col|_->erasett.row0(t.cols-1))|'X'->erasett.rowt.col(t.col+n-1)(* lines inserted and deleted at the cursor, inside the region *)|'L'->ift.row>=t.top&&t.row<=t.bottomthenscroll_downt~top:t.row~bottom:t.bottomn|'M'->ift.row>=t.top&&t.row<=t.bottomthenscroll_upt~top:t.row~bottom:t.bottomn(* characters inserted and deleted at the cursor, the rest of the
line sliding right or left *)|'@'->letline=t.cells.(t.row)inforc=t.cols-1downtot.coldoline.(c)<-(ifc-n>=t.colthenline.(c-n)elseblank)done|'P'->letline=t.cells.(t.row)inforc=t.coltot.cols-1doline.(c)<-(ifc+n<t.colsthenline.(c+n)elseblank)done|'m'->t.attrs<-sgrt.attrsps|'r'->lettop=nthps01-1andbottom=nthps1t.rows-1iniftop<bottom&&bottom<t.rowsthenbegint.top<-top;t.bottom<-bottom;move_tot00end|'s'->t.saved<-(t.row,t.col,t.attrs)|'u'->letrow,col,attrs=t.savedinmove_totrowcol;t.attrs<-attrs|_->()(*****************************************************************************)(* The state machine *)(*****************************************************************************)letreset(t:t):unit=letfresh=create~rows:t.rows~cols:t.colsinArray.iteri(funrrow->t.cells.(r)<-row)fresh.cells;move_tot00;t.attrs<-plain;t.top<-0;t.bottom<-t.rows-1;t.saved<-(0,0,plain);t.cursor_visible<-trueletescape(t:t)(c:char):state=matchcwith|'['->Csi""|']'->Osc|'('|')'|'#'->Escape_skip|'7'->t.saved<-(t.row,t.col,t.attrs);Ground|'8'->letrow,col,attrs=t.savedinmove_totrowcol;t.attrs<-attrs;Ground|'D'->line_feedt;Ground|'M'->reverse_indext;Ground|'E'->move_tott.row0;line_feedt;Ground|'c'->resett;Ground|_->Ground(* how many bytes follow a UTF-8 lead byte; None if it isn't one *)letutf8_following(c:char):intoption=letb=Char.codecinifbland0xE0=0xC0thenSome1elseifbland0xF0=0xE0thenSome2elseifbland0xF8=0xF0thenSome3elseNoneletrecstep(t:t)(c:char):unit=matcht.state,cwith(* in any state: ESC starts over, CAN and SUB abandon *)|(Escape|Csi_),'\x1b'->t.state<-Escape|(Escape|Escape_skip|Csi_),('\x18'|'\x1a')->t.state<-Ground|Ground,'\x1b'->t.state<-Escape|Ground,cwhenc<' '->controltc|Ground,'\x7f'->()|Ground,cwhenc<'\x80'->putt(String.make1c)|Ground,c->(matchutf8_followingcwith|Somen->t.state<-Utf8(String.make1c,n)|None->puttreplacement)|Utf8(bytes,n),cwhenChar.codecland0xC0=0x80->letbytes=bytes^String.make1cinifn=1thenbegint.state<-Ground;puttbytesendelset.state<-Utf8(bytes,n-1)(* a character cut short: shown as U+FFFD, the byte read again *)|Utf8_,c->t.state<-Ground;puttreplacement;steptc|(Escape|Escape_skip|Csi_),cwhenc<' '->controltc|Escape,c->t.state<-escapetc|Escape_skip,_->t.state<-Ground|Csicollected,cwhenc>=' '&&c<='?'->t.state<-Csi(collected^String.make1c)|Csicollected,c->t.state<-Ground;ifc>='@'&&c<='~'thencsitcollectedc|Osc,'\x07'->t.state<-Ground|Osc,'\x1b'->t.state<-Osc_escape|Osc,_->()|Osc_escape,_->t.state<-Groundletfeed(t:t)(bytes:string):t=lett=copytinString.iter(stept)bytes;t(*****************************************************************************)(* Reading the screen *)(*****************************************************************************)letrows(t:t)=t.rowsletcols(t:t)=t.colsletcell(t:t)rc=ifr>=0&&r<t.rows&&c>=0&&c<t.colsthent.cells.(r).(c)elseblankletcursor(t:t)=(t.row,t.col)letcursor_visible(t:t)=t.cursor_visibleletbells(t:t)=t.bellsletrtrim(s:string):string=letn=ref(String.lengths)inwhile!n>0&&s.[!n-1]=' 'dodecrndone;String.subs0!nlettext(t:t):stringlist=Array.to_listt.cells|>List.map(funrow->rtrim(String.concat""(Array.to_list(Array.map(func->c.glyph)row))))(*****************************************************************************)(* The keyboard *)(*****************************************************************************)(* the bytes of a named key alone *)letnamed_key(name:string):stringoption=matchnamewith|"Enter"->Some"\r"|"Backspace"->Some"\x7f"|"Tab"->Some"\t"|"Escape"->Some"\x1b"|"ArrowUp"->Some"\x1b[A"|"ArrowDown"->Some"\x1b[B"|"ArrowRight"->Some"\x1b[C"|"ArrowLeft"->Some"\x1b[D"|"Home"->Some"\x1b[H"|"End"->Some"\x1b[F"|"Insert"->Some"\x1b[2~"|"Delete"->Some"\x1b[3~"|"PageUp"->Some"\x1b[5~"|"PageDown"->Some"\x1b[6~"|"F1"->Some"\x1bOP"|"F2"->Some"\x1bOQ"|"F3"->Some"\x1bOR"|"F4"->Some"\x1bOS"(* claude: xterm's F5 to F12, numbered with gaps, as the VT220's were *)|"F5"->Some"\x1b[15~"|"F6"->Some"\x1b[17~"|"F7"->Some"\x1b[18~"|"F8"->Some"\x1b[19~"|"F9"->Some"\x1b[20~"|"F10"->Some"\x1b[21~"|"F11"->Some"\x1b[23~"|"F12"->Some"\x1b[24~"|_->None(* claude: a named key with Control or Alt, as xterm sends it: the
modifier as a parameter, 1 + 2 for Alt + 4 for Control -- ESC [ 20 ;
5 ~ is Control-F9, ESC [ 1 ; 3 A Alt-up; a key of one byte with Alt
is Escape then the byte, Meta as terminals send it *)letwith_modifiers~(ctrl:bool)~(alt:bool)(seq:string):string=letm=1+(ifaltthen2else0)+ifctrlthen4else0inletn=String.lengthseqinifm=1thenseqelseifn=1then(ifaltthen"\x1b"^seqelseseq)elseifn=3&&(seq.[1]='['||seq.[1]='O')thenPrintf.sprintf"\x1b[1;%d%c"mseq.[2]elseifseq.[n-1]='~'thenPrintf.sprintf"%s;%d~"(String.subseq0(n-1))melseseqletkey?(alt=false)~(ctrl:bool)(name:string):stringoption=matchnamewith|_whenctrl&&String.lengthname=1&&Char.lowercase_asciiname.[0]>='a'&&Char.lowercase_asciiname.[0]<='z'->letc=String.make1(Char.chr(Char.code(Char.lowercase_asciiname.[0])-Char.code'a'+1))inSome(ifaltthen"\x1b"^celsec)(* claude: the two a terminal sends without a letter, Emacs's C-SPC
(set the mark: NUL, C-@) and C-/ (undo: 0x1F, C-_) *)|" "|"space"|"Space"|"@"whenctrl->Some"\x00"|"/"|"_"|"-"whenctrl->Some"\x1f"(* claude: Control and a digit, which ASCII has no code for: xterm's
modifyOtherKeys, ESC [ 27 ; 5 ; <the digit's code> ~ (Control-9
ESC [ 27 ; 5 ; 57 ~), one key to a program that does not know it
(Line_discipline.split_keys), Control and an F key to TinyTurboPascal
on a keyboard without them (Tui_turbo.function_key) *)|_whenctrl&&String.lengthname=1&&name.[0]>='0'&&name.[0]<='9'->Some(Printf.sprintf"\x1b[27;%d;%d~"(ifaltthen7else5)(Char.codename.[0]))|_->Option.map(with_modifiers~ctrl~alt)(named_keyname)