%% chatui.sty %% A LaTeX package for typesetting AI chat transcripts, reasoning/thinking %% traces, tool-call traces, and attachments, styled as terminal, web, or %% desktop chat UIs. %% %% Author: Nilesh Verma %% License: LPPL 1.3c %% See chatui-doc.pdf for the full manual. %% \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{chatui}[2026/08/21 v1.0.0 Chat UI, reasoning trace, tool-call trace, and attachment boxes] \RequirePackage{etoolbox} \RequirePackage{xparse} \RequirePackage{kvoptions} \RequirePackage{xcolor} \RequirePackage[most]{tcolorbox} \RequirePackage{tikz} \usetikzlibrary{shadows} \RequirePackage{amssymb} \RequirePackage{pifont} \RequirePackage{graphicx} %% ----------------------------------------------------------------------- %% Font hooks: every box below uses these two macros instead of a literal %% \sffamily / \ttfamily, so \renewcommand{\chatuiFontSans}{...} (or Mono) %% after \usepackage swaps the font package-wide. Redefine BEFORE the first %% chatui environment in the document, e.g.: %% \usepackage{inconsolata} %% \renewcommand{\chatuiFontMono}{\ttfamily} % inconsolata redefines \ttfamily %% \renewcommand{\chatuiFontSans}{\fontfamily{qhv}\selectfont} %% ----------------------------------------------------------------------- \newcommand{\chatuiFontSans}{\sffamily} \newcommand{\chatuiFontMono}{\ttfamily} %% ----------------------------------------------------------------------- %% Package options: style = terminal | web | desktop, theme = light | dark %% ----------------------------------------------------------------------- \SetupKeyvalOptions{family=chatui, prefix=chatui@} \DeclareStringOption[web]{style} \DeclareStringOption[auto]{theme} \ProcessKeyvalOptions* \newbool{chatui@isterminal} \newbool{chatui@isweb} \newbool{chatui@isdesktop} \newbool{chatui@isdark} \ifdefstring{\chatui@style}{terminal}{\booltrue{chatui@isterminal}}{} \ifdefstring{\chatui@style}{web}{\booltrue{chatui@isweb}}{} \ifdefstring{\chatui@style}{desktop}{\booltrue{chatui@isdesktop}}{} %% theme=auto (default): terminal defaults to dark, web/desktop default to %% light. theme=light / theme=dark override that regardless of style. \ifdefstring{\chatui@theme}{dark}{\booltrue{chatui@isdark}}{} \ifdefstring{\chatui@theme}{auto}{\ifbool{chatui@isterminal}{\booltrue{chatui@isdark}}{}}{} \ifbool{chatui@isterminal}{}{\ifbool{chatui@isweb}{}{\ifbool{chatui@isdesktop}{% }{\booltrue{chatui@isweb}}}} % fall back to web if style is unrecognised %% ----------------------------------------------------------------------- %% Palette: users may \colorlet{chatuiUserBg}{...} etc. after loading %% ----------------------------------------------------------------------- \definecolor{chatuiUserBg}{HTML}{2F81F7} \definecolor{chatuiUserFg}{HTML}{FFFFFF} \definecolor{chatuiAssistantBg}{HTML}{F0F1F3} \definecolor{chatuiAssistantFg}{HTML}{1A1A1A} \colorlet{chatuiAssistantBorder}{chatuiAssistantBg} \definecolor{chatuiThinkBg}{HTML}{F5F7F8} \definecolor{chatuiThinkBorder}{HTML}{C7CDD3} \definecolor{chatuiThinkFg}{HTML}{55606A} \definecolor{chatuiToolBg}{HTML}{FAFAF9} \definecolor{chatuiToolBorder}{HTML}{D8DEE4} \definecolor{chatuiToolAccent}{HTML}{7C5CFC} \definecolor{chatuiOk}{HTML}{2E9E5B} \definecolor{chatuiErr}{HTML}{D64545} \definecolor{chatuiWarn}{HTML}{C8871E} \definecolor{chatuiWindowChrome}{HTML}{E4E6E9} %% App-window chrome (web/desktop styles): light defaults, dark overrides below \colorlet{chatuiAppBg}{white} \colorlet{chatuiAppText}{black} \colorlet{chatuiAppChrome}{chatuiWindowChrome} \colorlet{chatuiAppChromeText}{black!55} \colorlet{chatuiAppBorder}{chatuiWindowChrome!60!black} \colorlet{chatuiAppPillBg}{white} \colorlet{chatuiAppPillBorder}{chatuiWindowChrome!80!black} \colorlet{chatuiAppPillText}{black!55} \definecolor{chatuiTermBg}{HTML}{101418} \definecolor{chatuiTermFg}{HTML}{D7DEE4} \definecolor{chatuiTermAccent}{HTML}{63E6A5} \definecolor{chatuiTermUser}{HTML}{63C7FF} \definecolor{chatuiTermThinkBg}{HTML}{1B2129} \definecolor{chatuiTermThinkBorder}{HTML}{2E3947} \definecolor{chatuiTermThinkFg}{HTML}{9FADBA} \definecolor{chatuiTermToolBg}{HTML}{161B22} \definecolor{chatuiTermToolBorder}{HTML}{30363D} \definecolor{chatuiTermToolAccent}{HTML}{9D8CFF} \colorlet{chatuiTermBarFill}{black!70} \colorlet{chatuiTermBarText}{chatuiTermFg!70!white} \newcommand{\chatuiTermWindowRule}{0pt} \colorlet{chatuiTermWindowBorder}{chatuiTermBg} %% Light-terminal palette (used when style=terminal, theme=light) \definecolor{chatuiTermBgLight}{HTML}{FFFFFF} \definecolor{chatuiTermFgLight}{HTML}{1F2328} \definecolor{chatuiTermAccentLight}{HTML}{1B7F4C} \definecolor{chatuiTermUserLight}{HTML}{1B5FBF} \definecolor{chatuiTermThinkBgLight}{HTML}{F5F7F8} \definecolor{chatuiTermThinkBorderLight}{HTML}{C7CDD3} \definecolor{chatuiTermThinkFgLight}{HTML}{55606A} \definecolor{chatuiTermToolBgLight}{HTML}{FAFAF9} \definecolor{chatuiTermToolBorderLight}{HTML}{D8DEE4} \definecolor{chatuiTermToolAccentLight}{HTML}{7C5CFC} %% Dark theme reuses the exact terminal-dark palette for every style, so %% web/desktop/terminal all look like the same "dark mode" instead of each %% style inventing its own dark colors. \ifbool{chatui@isdark}{% \colorlet{chatuiAssistantBg}{chatuiTermBg!80!white} \colorlet{chatuiAssistantFg}{chatuiTermFg} \colorlet{chatuiAssistantBorder}{gray!30} \colorlet{chatuiThinkBg}{chatuiTermThinkBg} \colorlet{chatuiThinkBorder}{chatuiTermThinkBorder} \colorlet{chatuiThinkFg}{chatuiTermThinkFg} \colorlet{chatuiToolBg}{chatuiTermToolBg} \colorlet{chatuiToolBorder}{chatuiTermToolBorder} \colorlet{chatuiToolAccent}{chatuiTermToolAccent} \colorlet{chatuiAppBg}{chatuiTermBg} \colorlet{chatuiAppText}{chatuiTermFg} \colorlet{chatuiAppChrome}{chatuiTermBarFill} \colorlet{chatuiAppChromeText}{chatuiTermBarText} \colorlet{chatuiAppPillBg}{chatuiTermToolBg} \colorlet{chatuiAppPillBorder}{chatuiTermToolBorder} \colorlet{chatuiAppPillText}{chatuiTermBarText} }{% \colorlet{chatuiTermBg}{chatuiTermBgLight} \colorlet{chatuiTermFg}{chatuiTermFgLight} \colorlet{chatuiTermAccent}{chatuiTermAccentLight} \colorlet{chatuiTermUser}{chatuiTermUserLight} \colorlet{chatuiTermThinkBg}{chatuiTermThinkBgLight} \colorlet{chatuiTermThinkBorder}{chatuiTermThinkBorderLight} \colorlet{chatuiTermThinkFg}{chatuiTermThinkFgLight} \colorlet{chatuiTermToolBg}{chatuiTermToolBgLight} \colorlet{chatuiTermToolBorder}{chatuiTermToolBorderLight} \colorlet{chatuiTermToolAccent}{chatuiTermToolAccentLight} \colorlet{chatuiTermBarFill}{chatuiWindowChrome} \colorlet{chatuiTermBarText}{black!55} \renewcommand{\chatuiTermWindowRule}{0.7pt} \colorlet{chatuiTermWindowBorder}{black!35} } %% ----------------------------------------------------------------------- %% Window chrome (terminal / browser / desktop title bars) %% ----------------------------------------------------------------------- \newcommand{\chatui@dots}[3]{% \begin{tikzpicture}[baseline=-0.5ex] \foreach \x/\c in {0/#1,0.35/#2,0.7/#3}{% \fill[\c] (\x,0) circle (0.06); } \end{tikzpicture}% } %% All three window-chrome boxes use tcolorbox's native title area for the %% bar (colbacktitle) instead of a hand-drawn overlay rectangle: the title %% area shares the box's own rounded-corner path, so it never seams against %% the frame the way an independently-rounded overlay node can. \newtcolorbox{chatui@windowbox}[2][]{% enhanced, breakable, colback=chatuiAppBg, coltext=chatuiAppText, boxrule=\chatuiTermWindowRule, colframe=chatuiTermWindowBorder, colbacktitle=chatuiAppChrome, coltitle=chatuiAppChromeText, arc=3mm, left=4mm, right=4mm, bottom=4mm, toptitle=2.6mm, bottomtitle=2.6mm, halign title=center, fonttitle=\small\chatuiFontSans, title={#2}, overlay={% \ifnum\value{tcbbreakpart}=1 \node[anchor=west, xshift=2mm] at (title.west) {\chatui@dots{red!70!black}{orange!80!yellow}{green!60!black}}; \fi }, #1 } \newtcolorbox{chatui@browserbox}[2][]{% enhanced, breakable, colback=chatuiAppBg, coltext=chatuiAppText, boxrule=\chatuiTermWindowRule, colframe=chatuiTermWindowBorder, colbacktitle=chatuiAppChrome, arc=2mm, left=4mm, right=4mm, bottom=4mm, toptitle=2.4mm, bottomtitle=2.4mm, fonttitle=\scriptsize\chatuiFontMono, title={\phantom{Xg}}, overlay={% \ifnum\value{tcbbreakpart}=1 \node[anchor=west, xshift=2mm] at (title.west) {\chatui@dots{red!70!black}{orange!80!yellow}{green!60!black}}; \node[fill=chatuiAppPillBg, draw=chatuiAppPillBorder, rounded corners=2mm, minimum width=0.5\linewidth, minimum height=3.6mm, font=\scriptsize\chatuiFontMono, text=chatuiAppPillText] at (title.center) {#2}; \fi }, #1 } \newtcolorbox{chatui@termbox}[2][]{% enhanced, breakable, colback=chatuiTermBg, coltext=chatuiTermFg, boxrule=\chatuiTermWindowRule, colframe=chatuiTermWindowBorder, colbacktitle=chatuiTermBarFill, coltitle=chatuiTermBarText, arc=2mm, left=4mm, right=4mm, bottom=4mm, toptitle=2.4mm, bottomtitle=2.4mm, halign title=center, fonttitle=\scriptsize\chatuiFontMono, title={#2}, fontupper=\chatuiFontMono\small, overlay={% \ifnum\value{tcbbreakpart}=1 \node[anchor=west, xshift=2mm] at (title.west) {\chatui@dots{red!70!black}{orange!80!yellow}{green!60!black}}; \fi }, #1 } %% chatconversation: outer window that frames a whole transcript \newenvironment{chatconversation}[1][chatui]{% \ifbool{chatui@isterminal}{\begin{chatui@termbox}{#1}}{}% \ifbool{chatui@isweb}{\begin{chatui@browserbox}{#1}}{}% \ifbool{chatui@isdesktop}{\begin{chatui@windowbox}{#1}}{}% }{% \ifbool{chatui@isterminal}{\end{chatui@termbox}}{}% \ifbool{chatui@isweb}{\end{chatui@browserbox}}{}% \ifbool{chatui@isdesktop}{\end{chatui@windowbox}}{}% } %% ----------------------------------------------------------------------- %% Chat bubbles: chatuser / chatassistant %% ----------------------------------------------------------------------- \newtcolorbox{chatui@bubbleuser}{% enhanced, colback=chatuiUserBg, colframe=chatuiUserBg, coltext=chatuiUserFg, arc=3mm, boxrule=0pt, left=3mm, right=3mm, top=1.6mm, bottom=1.6mm, width=0.78\linewidth, halign=left, fontupper=\chatuiFontSans\small, drop shadow={opacity=0.12, shadow xshift=0pt, shadow yshift=-1pt} } \newtcolorbox{chatui@bubbleassistant}{% enhanced, colback=chatuiAssistantBg, colframe=chatuiAssistantBorder, coltext=chatuiAssistantFg, arc=3mm, boxrule=0.4pt, left=3mm, right=3mm, top=1.6mm, bottom=1.6mm, width=0.78\linewidth, halign=left, fontupper=\chatuiFontSans\small, drop shadow={opacity=0.08, shadow xshift=0pt, shadow yshift=-1pt} } %% chatuser/chatassistant used to wrap their bubble in a \minipage to align %% it left/right, but minipages can never break across a page, so a long %% bubble that overflowed the page dragged the whole enclosing breakable %% window down with it and corrupted the chrome on the continuation page. %% flushright/flushleft are list-based (like itemize), not a single boxed %% unit, so the inner breakable tcolorbox is still free to split normally. \newenvironment{chatuser}[1][user]{% \ifbool{chatui@isterminal}{% \par\noindent\chatuiFontMono\small\color{chatuiTermFg}% \textcolor{chatuiTermUser}{#1>}\space% }{% \begin{flushright} \begin{chatui@bubbleuser}% }% }{% \ifbool{chatui@isterminal}{% \par\vspace{2mm}% }{% \end{chatui@bubbleuser}% \end{flushright}% }% } \newenvironment{chatassistant}[1][Assistant]{% \ifbool{chatui@isterminal}{% \par\noindent\chatuiFontMono\small\color{chatuiTermFg}% \textcolor{chatuiTermAccent}{#1>}\space% }{% \noindent \begin{chatui@bubbleassistant}% }% }{% \ifbool{chatui@isterminal}{% \par\vspace{2mm}% }{% \end{chatui@bubbleassistant}\par\vspace{2mm}% }% } %% ----------------------------------------------------------------------- %% Reasoning / thinking trace %% mode = steps (default, arrow-prefixed \tstep bullets) | paragraph (plain %% upright prose, easier to read for long reasoning blocks than italics). %% ----------------------------------------------------------------------- \newtcolorbox{chatui@think@light}[2][]{% enhanced, colback=chatuiThinkBg, colframe=chatuiThinkBorder, coltext=chatuiThinkFg, arc=2mm, boxrule=0.4pt, fonttitle=\scriptsize\chatuiFontSans\bfseries, coltitle=chatuiThinkFg, title={\raisebox{-0.15ex}{$\rightsquigarrow$}\ #2}, fontupper=\itshape\chatuiFontSans\small, left=3mm, right=3mm, top=1.5mm, bottom=1.5mm, boxsep=1mm, top=2mm, #1 } \newtcolorbox{chatui@think@dark}[2][]{% enhanced, colback=chatuiTermThinkBg, colframe=chatuiTermThinkBorder, coltext=chatuiTermThinkFg, arc=2mm, boxrule=0.4pt, fonttitle=\scriptsize\chatuiFontMono\bfseries, coltitle=chatuiTermAccent, title={\raisebox{-0.15ex}{$\rightsquigarrow$}\ #2}, fontupper=\itshape\chatuiFontMono\small, left=3mm, right=3mm, top=1.5mm, bottom=1.5mm, boxsep=1mm, top=2mm, #1 } \NewDocumentEnvironment{chatthinking}{O{Reasoning} O{steps}}{% \ifstrequal{#2}{paragraph}{% \ifbool{chatui@isterminal}{\begin{chatui@think@dark}[fontupper=\upshape\chatuiFontMono\small]{#1}}% {\begin{chatui@think@light}[fontupper=\upshape\chatuiFontSans\small]{#1}}% }{% \ifbool{chatui@isterminal}{\begin{chatui@think@dark}{#1}}% {\begin{chatui@think@light}{#1}}% }% }{% \ifbool{chatui@isterminal}{\end{chatui@think@dark}}{\end{chatui@think@light}}% } \newcommand{\tstep}[1]{\par\noindent{\upshape$\rightarrow$}~#1\par} %% ----------------------------------------------------------------------- %% Tool-call trace: chattool[status][kind]{tool name} %% kind = api (default, "tool: name" chip) | cli (shell-style "$ name") %% | code (function-call style " name") %% ----------------------------------------------------------------------- \newcommand{\chatui@tool@label}[3]{% \ifstrequal{#1}{ok}{\textcolor{#3}{\ding{51}}}{}% \ifstrequal{#1}{error}{\textcolor{chatuiErr}{\ding{55}}}{}% \ifstrequal{#1}{running}{\textcolor{chatuiWarn}{$\cdots$}}{}% \ % \ifstrequal{#2}{cli}{\textcolor{#3}{\$}\ }{}% \ifstrequal{#2}{code}{\textcolor{#3}{\textless/\textgreater}\ }{}% } \newtcolorbox{chatui@tool@light}[3]{% enhanced, colback=chatuiToolBg, colframe=chatuiToolBorder, arc=2mm, boxrule=0.5pt, left=3mm, right=3mm, top=1.5mm, bottom=1.5mm, fonttitle=\scriptsize\chatuiFontMono\bfseries, colbacktitle=chatuiToolAccent!12, coltitle=chatuiToolAccent!70!black, attach boxed title to top left={yshift=-2mm, xshift=3mm}, boxed title style={boxrule=0pt, arc=1.5mm, colback=chatuiToolAccent!12}, title={\chatui@tool@label{#1}{#2}{chatuiOk}% \ifstrequal{#2}{api}{tool:~#3}{#3}}, fontupper=\chatuiFontMono\small } \newtcolorbox{chatui@tool@dark}[3]{% enhanced, colback=chatuiTermToolBg, colframe=chatuiTermToolBorder, coltext=chatuiTermFg, arc=2mm, boxrule=0.5pt, left=3mm, right=3mm, top=1.5mm, bottom=1.5mm, fonttitle=\scriptsize\chatuiFontMono\bfseries, colbacktitle=chatuiTermToolAccent!25!chatuiTermBg, coltitle=chatuiTermToolAccent, attach boxed title to top left={yshift=-2mm, xshift=3mm}, boxed title style={boxrule=0pt, arc=1.5mm, colback=chatuiTermToolAccent!25!chatuiTermBg}, title={\chatui@tool@label{#1}{#2}{chatuiTermAccent}% \ifstrequal{#2}{api}{tool:~#3}{#3}}, fontupper=\chatuiFontMono\small } \NewDocumentEnvironment{chattool}{O{ok} O{api} m}{% \ifbool{chatui@isterminal}{\begin{chatui@tool@dark}{#1}{#2}{#3}}% {\begin{chatui@tool@light}{#1}{#2}{#3}}% }{% \ifbool{chatui@isterminal}{\end{chatui@tool@dark}}{\end{chatui@tool@light}}% } \newcommand{\toolargs}[1]{% \ifbool{chatui@isterminal}{% {\small\chatuiFontMono\color{chatuiTermFg!70!black}args\space\{#1\}\par}% }{% {\small\chatuiFontMono\color{chatuiAppText!65!chatuiAppBg}args\space\{#1\}\par}% }% } \newcommand{\toolresult}[1]{% \ifbool{chatui@isterminal}{% \vspace{0.5mm}{\small\chatuiFontMono\color{chatuiTermFg}result\space#1\par}% }{% \vspace{0.5mm}{\small\chatuiFontMono\color{chatuiAppText!85!chatuiAppBg}result\space#1\par}% }% } %% JSON tool-call payloads: pass raw JSON with manual \\ line breaks and %% \quad/\hspace for indentation (LaTeX does not preserve literal whitespace). \newtcolorbox{chatui@json@light}{% enhanced, colback=chatuiToolBg!55!white, boxrule=0pt, borderline west={1.2pt}{0pt}{chatuiToolAccent}, left=3mm, right=2.5mm, top=1mm, bottom=1mm, boxsep=0.5mm, fontupper=\chatuiFontMono\scriptsize, coltext=black!75 } \newtcolorbox{chatui@json@dark}{% enhanced, colback=chatuiTermToolBg!85!black, boxrule=0pt, borderline west={1.2pt}{0pt}{chatuiTermToolAccent}, left=3mm, right=2.5mm, top=1mm, bottom=1mm, boxsep=0.5mm, fontupper=\chatuiFontMono\scriptsize, coltext=chatuiTermFg } \newcommand{\tooljson}[1]{% \vspace{0.5mm}% \ifbool{chatui@isterminal}{% \begin{chatui@json@dark}#1\end{chatui@json@dark}% }{% \begin{chatui@json@light}#1\end{chatui@json@light}% }% } %% ----------------------------------------------------------------------- %% Attachments: \chatimage[width]{path} and \chatfile{name}{size}: use %% inside chatuser/chatassistant (or standalone) to show an image or a %% file chip, e.g. an uploaded screenshot or an attached PDF. %% ----------------------------------------------------------------------- \newsavebox{\chatui@imgbox} \newcommand{\chatimage}[2][\linewidth]{% \par\vspace{1.5mm}\noindent \sbox{\chatui@imgbox}{\includegraphics[width=#1]{#2}}% \begin{tikzpicture} \clip[rounded corners=3mm] (0,0) rectangle (\wd\chatui@imgbox,\dimexpr\ht\chatui@imgbox+\dp\chatui@imgbox\relax); \node[anchor=south west, inner sep=0pt] at (0,0) {\usebox{\chatui@imgbox}}; \end{tikzpicture}% \par\vspace{1mm}% } \newcommand{\chatui@fileicon}[1]{% \begin{tikzpicture}[baseline=-0.4ex, scale=0.16] \draw[#1, line width=0.9pt, rounded corners=0.5pt] (0,0) -- (0,5) -- (3,5) -- (4,4) -- (4,0) -- cycle; \draw[#1, line width=0.9pt] (3,5) -- (3,4) -- (4,4); \end{tikzpicture}% } \newtcolorbox{chatui@file@light}{% enhanced, colback=chatuiToolBg, colframe=chatuiToolBorder, boxrule=0.5pt, arc=2mm, left=2mm, right=3mm, top=1.2mm, bottom=1.2mm, fontupper=\chatuiFontMono\small, coltext=black!75 } \newtcolorbox{chatui@file@dark}{% enhanced, colback=chatuiTermToolBg, colframe=chatuiTermToolBorder, boxrule=0.5pt, arc=2mm, left=2mm, right=3mm, top=1.2mm, bottom=1.2mm, fontupper=\chatuiFontMono\small, coltext=chatuiTermFg } \newcommand{\chatfile}[2]{% \par\vspace{1.5mm}\noindent \ifbool{chatui@isterminal}{% \begin{chatui@file@dark} \chatui@fileicon{chatuiTermToolAccent}\ \textbf{#1}% \ifstrequal{#2}{}{}{\ {\scriptsize\color{chatuiTermFg!70!black}(#2)}}% \end{chatui@file@dark}% }{% \begin{chatui@file@light} \chatui@fileicon{chatuiToolAccent}\ \textbf{#1}% \ifstrequal{#2}{}{}{\ {\scriptsize\color{black!55}(#2)}}% \end{chatui@file@light}% }% \par\vspace{1mm}% } \endinput