From 636b0323075225c584b62719ed51e75521bb7ffb Mon Sep 17 00:00:00 2001
From: aura <nw@moneybot.cc>
Date: Tue, 17 Feb 2026 22:39:42 +0100
Subject: push source

---
 moneyjsx/src/chat.tsx | 1023 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 1023 insertions(+)
 create mode 100644 moneyjsx/src/chat.tsx

(limited to 'moneyjsx/src/chat.tsx')

diff --git a/moneyjsx/src/chat.tsx b/moneyjsx/src/chat.tsx
new file mode 100644
index 0000000..56798ab
--- /dev/null
+++ b/moneyjsx/src/chat.tsx
@@ -0,0 +1,1023 @@
+import $ from 'jquery';
+import * as JSX from './jsx';
+import * as api from './api';
+import * as user from './user';
+import * as util from './util';
+
+import { Spinner, OkCancelPopup, addPopup, OkPopup } from './components';
+
+let chat_id = '';
+let chat_name = 'new chat'; // todo: use or rm
+export let msglog: Msg[] = [];
+export let file_list: MsgFile[] = [];
+let hljs = null;
+// todo: send done msg from server when tool limit is reached and dont append line
+// to prevent empty spinner
+let needs_append = false;
+let in_code_block = false;
+let in_reason_block = false;
+let was_in_reason = false;
+let reason_buf = '';
+let code_block_buf = '';
+let tool_buffer = '';
+
+export interface MsgFile {
+  name: string,
+  type: string,
+  content: string
+};
+
+export interface Msg {
+  content: string,
+  role: string,
+  timestamp: string,
+  toolCall?: string,
+  images?: string[],
+  files?: MsgFile[],
+};
+
+export interface ClientOptions {
+  seed?: number,
+  temperature?: number,
+};
+
+export interface ChatReq {
+  model: string,
+  messages: Msg[],
+  system: string,
+  chatfile?: string,
+  options?: ClientOptions,
+  generateTitle: boolean,
+  token: string
+};
+
+export interface ToolCall {
+  name: string,
+  parameters: any
+};
+
+interface ChatJsonRes {
+  done: boolean,
+  chunks?: string[],
+  msgs?: Msg[],
+  title?: string,
+  err?: string,
+};
+
+function getChatId() {
+  const url = new URL( window.location.href );
+  const id = url.searchParams.get( 'id' );
+  if( !id ) {
+    chat_id = '';
+    msglog = [];
+    return null;
+  }
+
+  if( id == '0' ) {
+    chat_id = '';
+    msglog = [];
+    return null;
+  }
+
+  chat_id = id.slice();
+  return chat_id;
+}
+
+export async function createChat() {
+  const res = await user.createChat();
+  chat_id = res.chatId;
+  chat_name = 'new chat';
+
+  JSX.pushParams( { id: chat_id } );
+  await user.updatePrefs(); // todo : error handle this w text box when component made
+  return getChatId();
+}
+
+function isToolStr( str: string ) : boolean {
+  const model = api.getModelFromName( user.settings.site_prefs.model || '' );
+  if( !model )
+    return false;
+
+  const trimmed = str.replace( /\s+/g, '' ).toLowerCase();
+
+  for( let tool in model.capabilities ) {
+    const str = `{"name":"${tool.toLowerCase()}"`;
+    let matched = true;
+
+    for( let i = 0; i < Math.min( trimmed.length, str.length ); ++i ) {
+      if( trimmed[i] !== str[i] ) {
+        matched = false;
+        break;
+      }
+    }
+
+    if( matched )
+      return true;
+  }
+
+  return false;
+}
+
+
+let chunk_buf = '';
+function parseChatJson( rawjson: string, is_done: boolean ) : ChatJsonRes {
+  let msgs = rawjson.split( '\n' );
+  let clean_done = false;
+  let ret_msgs: Msg[] = [];
+  let ret_chunks: string[] = [];
+  let title: string = '';
+
+  for( let raw of msgs ) {
+    if( !raw.length )
+      continue;
+
+    let json = null;
+    try {
+      json = JSON.parse( chunk_buf + raw );
+    } catch( e: any ) {
+      chunk_buf += raw;
+      return { done: false };
+    }
+
+    chunk_buf = '';
+
+    if( json.done )
+      clean_done = true;
+
+    if( !clean_done && is_done )
+      return { done: true, err: "the stream was closed abruptly. please try again." };
+    else if( json.status == 'error' || json.status == 'busy' )
+      return { done: true, err: json.msg };
+
+    if( json.status != 'ok' )
+      return { done: true, err: json.msg || 'unknown error' };
+
+    let chunk: string = json.response;
+    if( json.finalMsg && json.finalMsg.length > 0 ) {
+      ret_msgs.push( { timestamp: (new Date()).toLocaleString(), role: 'assistant', content: json.finalMsg } );
+      if( json.title && !title.length )
+        title = json.title;
+    } else if( json.tool ) {
+      ret_msgs.push( { timestamp: (new Date()).toLocaleString(), role: 'tool', content: chunk } );
+    } else {
+      ret_chunks.push( chunk );
+    }
+  }
+
+  return {
+    done: clean_done || is_done,
+    title: title.length > 0 ? title : null,
+    msgs: ret_msgs,
+    chunks: ret_chunks
+  };
+}
+
+function appendUserLine( msg: string, files?: MsgFile[] ) {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+  $( "#terminal-inner" ).append(
+    <div class="chat-line">
+      <div class="chat-role-user">
+        { user.settings.nickname }
+      <span style="color:#fff">:&nbsp;</span>
+      </div>
+      <div class="chat-content">
+        { msg }
+      </div>
+      { files && <ChatFiles id={ msglog.length.toString() } files={ files } noremove /> }
+    </div>
+  );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+function appendModelLine() {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  $( "#terminal-inner" ).append( <div class="chat-line">
+    <div class="chat-role-model">
+      { user.settings.site_prefs.model || 'err' }
+    <span style="color:#fff">:&nbsp;</span>
+    </div>
+    <div class="chat-content">
+      <Spinner style="display: inline" />
+    </div>
+  </div> );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+// inserts a new text div
+function appendText() {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  $( ".chat-line" ).last().append( <div class="chat-content" /> );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+// inserts a new code div
+function appendCode( lang?: string ) {
+  if( lang ) {
+    $( ".chat-line" ).last().append(
+      <pre>
+        <div class="chat-code-line">{ lang }</div>
+        <code class={ `language-${ lang }` }></code>
+      </pre>
+    );
+  } else {
+    $( ".chat-line" ).last().append( <pre><code></code></pre> );
+  }
+}
+
+
+function ToolInfo( props: any ) {
+  return <div class="tool-call-title-wrapper">
+    <div class="tool-call-title">
+      { props.title }
+    </div>
+    <div class="tool-call-collapse">
+      { props.collapsed ? '+' : '-' }
+    </div>
+  </div>
+}
+
+function appendReason() {
+  const collapseToggle = () => {
+    let list  = el.find( '.reason-output' );
+    let btn   = el.find( '.tool-call-collapse' );
+
+    const open = list.css( 'display' ) == 'block';
+    if( !open ) {
+      list.css( 'display', 'block' );
+      btn.text( '-' );
+    } else {
+      list.css( 'display', 'none' );
+      btn.text( '+' );
+    }
+  };
+
+  const el = $( <div class="tool-call" onclick={ collapseToggle } collapsed /> );
+  const ti = $( <ToolInfo title="Reasoning... " /> );
+  ti.find( ".tool-call-title" ).append( <Spinner style="display: inline" /> );
+  el.append( ti );
+  el.append( <div class="reason-output" style="display: none"></div> );
+
+  $( ".chat-line" ).last().find( ".chat-content" ).replaceWith( el );
+}
+
+function outputReason( buf: string ) {
+  const el = $( ".reason-output" ).last();
+  const txt = el.text();
+  el.text( txt + buf );
+}
+
+function outputTool( tool: ToolCall, appendLine?: boolean ) {
+  const content = $( ".chat-line" ).last().find( ".chat-content" );
+  if( needs_append || content.text().length > 1 )
+    appendModelLine();
+
+  const collapseToggle = () => {
+    let list  = el.find( '.tool-call-list' );
+    let btn   = el.find( '.tool-call-collapse' );
+
+    const open = list.css( 'display' ) == 'block';
+    if( !open ) {
+      list.css( 'display', 'block' );
+      btn.text( '-' );
+    } else {
+      list.css( 'display', 'none' );
+      btn.text( '+' );
+    }
+  };
+
+  const el = $( <div class="tool-call" onclick={ collapseToggle } /> );
+  const trimmed = tool.name.replace( /\s+/g, '' );
+  switch( trimmed.toLowerCase() ) {
+  case 'notes':
+    el.append( <ToolInfo title="Note saved" /> );
+    el.append( <div class="tool-call-list">{ tool.parameters.contents || '' }</div> );
+    break;
+  case 'web':
+    el.append( <ToolInfo title="Searching the web..." /> );
+    el.append( <div class="tool-call-list">{ tool.parameters.url || '' }</div> );
+    if( appendLine ) needs_append = true;
+    break;
+  case 'remind':
+    el.append( <ToolInfo title="Searching the conversation..." /> );
+    el.append( <div class="tool-call-list">{ tool.parameters.keywords ? tool.parameters.keywords.join( ' ' ) : '' }</div> );
+    if( appendLine ) needs_append = true;
+    break;
+  }
+  if( in_reason_block ) {
+    $( ".chat-line" ).last().find( ".tool-call" ).find( ".spinner" ).remove();
+    $( ".chat-line" ).last().append( el );
+    appendModelLine();
+    needs_append = false;
+    appendReason();
+  }
+  else {
+    $( ".chat-line" ).last().find( ".chat-content" ).replaceWith( el );
+  }
+}
+
+function outputError( e: string ) {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  if( needs_append ) {
+    appendModelLine();
+    needs_append = false;
+  }
+
+  let el = $( ".chat-line" ).last();
+  el.empty();
+  el.append( <div class="chat-error">{ e }</div> );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+function outputChunk( chunk: string ) {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  if( needs_append ) {
+    appendModelLine();
+    needs_append = false;
+  }
+
+  let el = $( ".chat-line" ).last().find( ".chat-content" ).last();
+  el.find( '.spinner' ).remove();
+  el.text( el.text() + chunk );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+function outputCode( chunk: string ) {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  if( needs_append ) {
+    appendModelLine();
+    needs_append = false;
+  }
+
+  let el = $( ".chat-line" ).last().find( "code" ).last();
+  el.find( '.spinner' ).remove();
+  el.text( el.text() + chunk );
+
+  if( hljs )
+    hljs.highlightElement( el[0] );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+function checkToolBuffer( chunk: string ) {
+  tool_buffer += chunk;
+
+  let tool: ToolCall | null = null;
+  if( isToolStr( tool_buffer ) ) {
+    try {
+      tool = JSON.parse( tool_buffer );
+      outputTool( tool, true );
+      tool_buffer = '';
+    } catch( e: any ) {}
+
+    return true;
+  }
+
+  let buf = tool_buffer.slice();
+  tool_buffer = '';
+  return buf;
+}
+
+
+/// returns 1 and sets in_reason_block if the msg starts with a reason token
+/// returns -1 if the msg does not match a reason token
+/// returns 0 if the reason token is partially completed
+function checkReasonBuffer( chunk: string ) {
+  const buf = chunk.toLowerCase();
+  const wanted_begin = '<think>';
+  if( !in_reason_block ) {
+    if( buf == wanted_begin || (buf.length > wanted_begin.length && buf.startsWith( wanted_begin )) ) {
+      appendReason();
+      in_reason_block = true;
+      return 1;
+    }
+    if( wanted_begin.startsWith( buf ) )
+      return 0;
+
+    return -1;
+  }
+
+  const wanted_end = '</think>';
+  if( buf == wanted_end || (buf.length > wanted_end.length && buf.startsWith( wanted_end )) ) {
+    $( ".chat-line" ).last().append( <div class="chat-content">{ '\n' }</div> );
+    $( ".tool-call" ).last().find( ".spinner" ).remove();
+    in_reason_block = false;
+    was_in_reason = true;
+    return 1;
+  }
+  if( wanted_end.endsWith( buf ) )
+    return 0;
+
+  return -1;
+}
+
+// todo: clean, static return type. maybe nullable string
+function checkCodeBuffer( chunk: string ) {
+  const backtick = chunk.search( '`' );
+  if( backtick != -1 ) {
+    code_block_buf += chunk.slice( backtick );
+    return true;
+  }
+
+  let buf = chunk;
+  let parts = chunk.split( '\n' );
+  if( code_block_buf.startsWith( '```' ) ) {
+    if( parts.length <= 1 )
+      return true;
+
+    buf = parts[1];
+    if( !in_code_block ) {
+      appendCode();
+      const lang = parts[0].substring( 3 );
+      if( lang.length > 0 ) {
+        $( ".chat-line" ).last().find( "code" ).insertBefore(
+          <div class="chat-code-line">
+            { lang }
+          </div>
+        );
+      }
+    }
+    else
+      appendText();
+    in_code_block = !in_code_block;
+    code_block_buf = '';
+  }
+
+  return buf;
+}
+
+function resetBuffers() {
+  in_reason_block = false;
+  in_code_block = false;
+  was_in_reason = false;
+  needs_append = false;
+  reason_buf = '';
+  tool_buffer = '';
+  code_block_buf = '';
+}
+
+// returns true when stream completes
+function handleChatJson( res: ChatJsonRes ) : boolean {
+  const model = api.getModelFromName( user.settings.site_prefs.model! );
+  tool_buffer = '';
+
+  if( res.err ) {
+    outputError( res.err );
+  }
+  else if( res.chunks ) {
+    for( let chunk of res.chunks ) {
+      let buf = checkToolBuffer( chunk );
+      if( buf === true )
+        continue;
+
+      if( model.capabilities.thinker ) {
+        const reason = checkReasonBuffer( buf );
+        if( reason > -1 )
+          continue;
+
+        if( in_reason_block ) {
+          outputReason( buf );
+          continue;
+        }
+      }
+
+      if( was_in_reason ) {
+        while( buf.startsWith( '\n' ) )
+          buf = buf.slice( 1 );
+      }
+
+      // todo: fix, this is str8 fkn voodoo .
+      buf = checkCodeBuffer( buf );
+      if( buf === true )
+        continue;
+      if( in_code_block )
+        outputCode( buf );
+      else
+        outputChunk( buf );
+    }
+  }
+
+  if( res.msgs ) {
+    for( let msg of res.msgs ) {
+      msglog.push( msg );
+    }
+  }
+
+  if( res.title ) {
+    user.updatePrefs().then( () => {
+      $( "#chat-list" ).replaceWith( <ChatList /> );
+    }).catch( ( e: any ) => {
+      outputError( e.msg );
+    });
+
+    updateChatTitle( res.title );
+  }
+
+  return res.done;
+}
+
+function makeChatReq() : ChatReq {
+  return {
+    model: user.settings.site_prefs.model!,
+    messages: msglog,
+    system: user.settings.prompt_data.system || '',
+    chatfile: getChatId(),
+    token: localStorage.getItem( 'session' ),
+    generateTitle: true
+  };
+}
+
+export async function sendChatReq( cur_chat: string ) {
+  let res = null;
+  try {
+    const req = makeChatReq();
+    res = await fetch( `${api.url}/chat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify( req )
+    } );
+  } catch( e: any ) {
+    return outputError( e.message );
+  }
+
+  if( !res.ok ) {
+    let json = null;
+    try{
+      json = await res.json();
+    } catch {
+      return outputError( `error contacting server (${res.statusCode}). are you connected to the internet?` );
+    }
+
+    return outputError( json.msg );
+  }
+
+  if( chat_id != cur_chat )
+    return;
+
+  const reader = res.body?.getReader();
+  const decoder = new TextDecoder();
+  if( !reader )
+    return outputError( "can't read response" );
+
+  resetBuffers();
+  return new Promise( ( resolve ) => {
+    const readLoop = async() => {
+      if( chat_id != cur_chat )
+        return resolve( true );
+
+      const { value, done } = await reader.read();
+      const raw = decoder.decode( value, { stream: true } );
+      const parsed = parseChatJson( raw, done );
+      if( handleChatJson( parsed ) )
+        return resolve( true );
+
+      setTimeout( readLoop, 50 );
+    }
+
+    setTimeout( readLoop, 50 );
+  } );
+}
+
+function disableInput() { $( "#chat-input" ).remove(); }
+function appendInput() {
+  const terminal = $( "#terminal-inner" );
+  const isbottom = terminal[0].scrollTop == (terminal[0] as any).scrollTopMax;
+
+  $( "#terminal-inner" ).append( <ChatInput noupdate /> );
+
+  if( isbottom )
+    terminal.scrollTop( terminal[0].scrollHeight );
+}
+
+export async function onChat( msg: string ) {
+  disableInput();
+
+  appendUserLine( msg, file_list );
+  appendModelLine();
+  let id = getChatId();
+  if( !id ) {
+    try { id = await createChat() }
+    catch( e: any ) {
+      return outputError( "error creating chat: " + e.message );
+    }
+  }
+
+  if( file_list.length ) {
+    msglog.push( { timestamp: (new Date()).toLocaleString(), content: msg, role: 'user', files: file_list } );
+  }
+  else {
+    msglog.push( { timestamp: (new Date()).toLocaleString(), content: msg, role: 'user' } );
+  }
+  file_list = [];
+  await sendChatReq( id );
+
+  if( getChatId() == id )
+    appendInput();
+}
+
+async function getChatHistory() {
+  if( !getChatId() )
+    throw new Error( 'chat_id not set' );
+
+  try {
+    const res = await api.post( 'get-chat', { token: localStorage.getItem( 'session' ), chatId: chat_id } );
+    return res;
+  } catch( e: any ) {} // todo: handle error ?
+
+  return null;
+}
+
+function updateChatTitle( name: string ) {
+  $( "#chat-name" ).text( name.substring( 0, 500 ) );
+  let cur_name = name;
+  if( cur_name.length > 25 ) {
+    cur_name = cur_name.substring( 0, 25 ) + "...";
+  }
+
+  $( "#chat-selection-current" ).text( cur_name );
+}
+
+function outputChatTextForHistory( msg: string ) {
+  let chunks = msg.split( "```" );
+
+  let in_code = false;
+  for( let chunk of chunks ) {
+    if( !in_code ) {
+      appendText();
+      outputChunk( chunk );
+    } else {
+      const newl = chunk.indexOf( "\n" );
+      let lang = null;
+
+      if( newl != -1 ) {
+        lang = chunk.substring( 0, newl );
+        chunk = chunk.substring( newl + 1 );
+      }
+
+      appendCode( lang );
+      outputCode( chunk );
+    }
+
+    in_code = !in_code;
+  }
+}
+
+function outputToolForHistory( msg: Msg ) {
+  let cur_idx = 0;
+  let open = -1;
+  let close = msg.content.lastIndexOf( '}' );
+  open = msg.content.indexOf( "{", cur_idx ); // why is this assigned here & not on def ? also, cur_idx will never not be 0 ???
+
+  if( open == -1 || close == -1 ) {
+    outputChatTextForHistory( msg.content );
+    return;
+  }
+
+  let slice = msg.content.substring( open, close + 1 );
+  try {
+    const parsed = JSON.parse( slice );
+    if( open != 0 )
+      outputChatTextForHistory( msg.content.substring( 0, open ) );
+    if( parsed.name )
+      outputTool( parsed );
+    else
+      outputChatTextForHistory( msg.content );
+  } catch( e ) {
+    outputChatTextForHistory( msg.content );
+  }
+}
+
+function outputChatHistory( history: Msg[] ) {
+  for( let msg of history ) {
+    if( msg.role == 'assistant' ) {
+      appendModelLine();
+      const content = $( ".chat-line" ).last().find( ".chat-content" );
+      content.empty();
+
+      if( msg.toolCall ) {
+        outputToolForHistory( msg );
+      }
+      else {
+        outputChatTextForHistory( msg.content );
+      }
+    }
+    else if( msg.role == 'user' ) {
+      appendUserLine( msg.content, msg.files );
+    }
+  }
+
+  setTimeout( () => {
+    const inner = $( "#terminal-inner" );
+    inner.scrollTop( inner.prop( "scrollTopMax" ) );
+
+    if( history.length > 0 ) {
+      $( "#suggested-prompts" ).remove();
+    }
+  } );
+}
+
+// don't ask me what the fuck is happening here, this is some javascript voodoo
+// - nave
+async function onInputKeyPress( e: KeyboardEvent ) {
+  let self = $( e.target as HTMLElement );
+  const text = self[0].innerText;
+
+  $( "#suggested-prompts" ).remove();
+
+  if( e.key == 'Enter' ) {
+    if( e.shiftKey )
+      return;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    if( text.length > 0 ) {
+      await onChat( text );
+    }
+  }
+}
+
+function handleAttachments() {
+  $( "#cmd-files" ).remove();
+  $( "#cmd" ).append( <ChatFiles id="cmd-files" files={ file_list } /> );
+}
+
+function onFileReadEvent( e: Event, img: boolean, name: string ) {
+  let contents = (e.target as any).result;
+  let b64 = contents.split( 'base64,' )[1];
+
+  if( img ) {
+    return;
+  }
+
+  let txt = atob( b64 );
+  if( txt.length > 1024 * 20 ) // todo: arbitrary change prolly
+    return;
+
+  file_list.push( { name, type: 'text', content: txt } );
+  handleAttachments();
+}
+
+async function onPaste( e: ClipboardEvent ) {
+  const model = api.getModelFromName( user.settings.site_prefs.model );
+  if( !model ) return;
+
+  const vision = !!model.capabilities.vision;
+  const items = e.clipboardData.items;
+
+  if( !items || !items.length )
+    return;
+
+  for( let it of items ) {
+    if( it.type == 'text/html' )
+      continue;
+
+    if( it.kind.startsWith( 'string' ) ) {
+      let s = e.clipboardData.getData( 'text' );
+      if( s.length > 512 ) {
+        e.preventDefault();
+        file_list.push( {
+          name: `file${file_list.length}.txt`,
+          content: s,
+          type: 'text'
+        } );
+        handleAttachments();
+      }
+    }
+    else {
+      let isImg = false;
+      if( it.type.startsWith( 'image' ) ) {
+        if( !vision )
+          continue;
+        isImg = true;
+      }
+
+      let blob = it.getAsFile();
+      let reader = new FileReader();
+
+      reader.onload = ( e ) => onFileReadEvent( e, isImg, blob.name );
+      reader.readAsDataURL( blob );
+      e.preventDefault();
+    }
+  }
+}
+
+function removeFile( key: number ) {
+  file_list.splice( key, 1 );
+
+  handleAttachments();
+}
+
+function ChatFile( props: any ) {
+  return <div id={ props.id } class="cmd-file">
+    <span class="cmd-file-name">{ props.name.toString() }</span>&nbsp;
+    <span class="cmd-file-size">{ util.sizeHumanReadable( props.size ) }</span>&nbsp;
+    { props.key !== undefined &&
+      <a class="cmd-file-remove"
+        onclick={ () => removeFile( props.key ) }
+        href="#"
+      >
+        remove
+      </a>
+    }
+  </div>
+}
+
+function ChatFiles( props: any ) {
+  if( !props.files.length )
+    return <div />
+
+  const show_remove = props.noremove === undefined;
+
+  return <div id={ props.id } class='cmd-files'>
+    <div class="cmd-files-header">
+      Attachments:
+    </div>
+    { props.files.map( ( f: MsgFile, i: number ) => {
+      if( !show_remove )
+        return <ChatFile id={ `${props.id}-${i}` } size={ f.content.length } name={ f.name } />
+      return <ChatFile id={ `${props.id}-${i}` } size={ f.content.length } name={ f.name } key={i} />
+    } ) }
+  </div>
+}
+
+export function ChatCmdLine() {
+  return <div id="cmd-input" contenteditable spellcheck="false"
+    onkeydown={ onInputKeyPress }
+    onpaste={ onPaste }
+  >
+    <span id="input-content"><br /></span>
+  </div>
+}
+
+export function ChatInput( props: any ) {
+  const task = async () => {
+    const chat_id = getChatId();
+    if( !chat_id ) {
+      return;
+    }
+
+    disableInput();
+    $( "#terminal-inner" ).empty();
+    const spinner = $( <Spinner class="chat-spinner" /> );
+    $( "#terminal-inner" ).append( spinner );
+
+    const chat = await getChatHistory();
+    if( !chat ) {
+      msglog = [];
+      spinner.remove();
+      return appendInput();
+    }
+
+    try {
+      msglog = JSON.parse( chat.contents );
+      outputChatHistory( JSON.parse( chat.contents ) );
+    } catch( e: any ) {
+      msglog = [];
+    }
+
+    updateChatTitle( chat.name );
+    spinner.remove();
+    setTimeout( appendInput );
+  };
+
+  if( !props.noupdate )
+    setTimeout( task );
+
+  const ret = $( <div class="chat-line" id="chat-input">
+    <div id="cmd">
+      <div class="chat-role-user" style="width: fit-content; float: left">
+        { user.settings.nickname }<span style="color: #fff">:</span>&nbsp;
+      </div>
+      <ChatCmdLine />
+      <div style="clear: both" />
+    </div>
+  </div> );
+
+  return ret[0];
+}
+
+function onSidebarHover() { $( "#chat-selection-sidebar" ).show(); }
+function onSidebarLeave() { $( "#chat-selection-sidebar" ).hide(); }
+
+async function deleteChat( id: string ) {
+  try {
+    await user.deleteChat( id );
+    await user.updatePrefs();
+
+    if( id == getChatId() )
+      JSX.navigateParams( "/terminal", {} );
+    else {
+      $( "#chat-list" ).replaceWith( <ChatList /> );
+    }
+
+    const popup = $( <OkPopup>
+      Chat deleted.
+    </OkPopup> );
+
+    addPopup( popup );
+  } catch( e: any ) {
+    const popup = $( <OkPopup>
+      Error deleting chat: { e.message }
+    </OkPopup> );
+
+    addPopup( popup );
+  }
+}
+
+function showDeletePopup( id: string, name: string ) {
+  const popup = $( <OkCancelPopup onclick={ () => { deleteChat( id ) } }>
+    <div>
+      Are you sure you want to delete {name}?
+    </div>
+  </OkCancelPopup> );
+
+  addPopup( popup );
+}
+
+function ChatListEntry( props: any ) {
+  let title = props.title;
+  if( title.length > 20 ) {
+    title = title.substring( 0, 20 ) + '...';
+  }
+
+  return <div class="chat-item" id={ 'chatbtn-' + props.id } style={ props.style }>
+    <div class="chat-name" onclick={ () => JSX.navigateParams( "/terminal", props.id != '0' ? { id: props.id } : {} ) }>
+      <a>{ title }</a>
+    </div>
+    {
+      (() => {
+      if( props.id != 0 )
+        return <div class="chat-delete">
+                 <a onclick={ () => showDeletePopup( props.id, props.title ) }>🗑</a>
+               </div>
+      })()
+    }
+  </div>
+}
+
+let hljs_imported = 0;
+async function importHlJs() {
+  if( hljs ) return;
+  if( hljs_imported ) return;
+
+  hljs_imported = 1;
+  hljs = ( await import( 'highlight.js' ) ).default;
+
+  $( ".chat-line.code" ).each( ( e ) => {
+    hljs.highlightElement( e[0] );
+  } );
+}
+
+export function ChatList() {
+  if( !hljs ) {
+    importHlJs();
+  }
+
+  setTimeout( () => {
+    if( !user.settings.chat_files || !user.settings.chat_files.files )
+      return;
+
+    const files = user.settings.chat_files.files;
+    const entries = files.reverse().map( ( file: user.ChatFile ) => {
+      return <ChatListEntry id={ file.id } title={ file.name } />
+    } );
+    for( let e of entries )
+      $( "#chat-selection-sidebar" ).append( e );
+  } );
+
+  return <div id="chat-list">
+    <div id="chat-selection">
+      current chat
+      <div id="chat-selection-inner" onmouseenter={ onSidebarHover } ontouchstart={ onSidebarHover }>
+        <div>
+          <span id="chat-selection-current">new chat</span>
+        </div>
+      </div>
+    </div>
+    <div id="chat-selection-sidebar" style="display: none" onmouseleave={ onSidebarLeave } ontouchend={ onSidebarLeave } ontouchcancel={ onSidebarLeave }>
+      <ChatListEntry id={ '0' } title={ "new chat [*]" } style="padding-top: 5px" />
+    </div>
+  </div>
+}
-- 
cgit v1.2.3

