diff --git a/config/bash/fns/chat b/config/bash/fns/chat index 4ca54e3..94d79bb 100644 --- a/config/bash/fns/chat +++ b/config/bash/fns/chat @@ -2,61 +2,94 @@ # Chat: a clean, persistent CLI chat — the anu conductor # ============================================================================== # -# `chat [message]` opens a conversation backed by `codex exec --json`. We consume -# the STRUCTURED event stream and render only the signal — the answer as real -# markdown, and "⟢ ran …" lines when it acts — so reasoning/raw output never -# reaches the screen. Cleanliness is intrinsic, not a codex config toggle. +# `chat [message]` opens a conversation backed by `codex exec --json`. The logo +# stays PINNED at the top; the conversation flows in a centered column below it, +# scrolling within a fixed region (DECSTBM) so the logo never moves. We consume +# the structured event stream and render only the signal — the answer as real +# markdown, and "▸ ran …" lines when it acts — so reasoning/raw never shows. # -# chat new chat -# chat new chat, sends as the first turn -# chat last (-c) resume the most recent chat -# chat resume resume a specific chat -# chat ls list saved chats +# chat [message…] new chat (optionally with a first message) +# chat last (-c) resume the most recent chat +# chat resume resume a specific chat · chat ls list chats # -# Persistent: each chat lives in ~/.anu/chats// as {thread, transcript.jsonl, -# meta}; continuity is codex's own session (resume ). Frictionless: -# ask one thing and leave (empty line / Ctrl-D), or keep typing. +# Persistent: ~/.anu/chats//{thread,transcript.jsonl,meta}; continuity is +# codex's own session. Empty line / Ctrl-D leaves. # ANU_CONDUCTOR_FULL_AUTO=1 let it act without sandbox limits +# ANU_CHAT_EFFORT=none|low|medium|high|xhigh reasoning effort (default none — +# thinking off + snappy; raise to think) # ------------------------------------------------------------------------------ _chat_root() { printf '%s' "${ANU_CHAT_DIR:-$HOME/.anu/chats}"; } +_chat_termsize() { local s; s=$(stty size 2>/dev/null); [[ "$s" =~ ^[0-9]+[[:space:]]+[0-9]+$ ]] || s="40 100"; printf '%s' "$s"; } +_chat_effort() { printf '%s' "${ANU_CHAT_EFFORT:-none}"; } -# usable render width (capped for readable line length) -_chat_width() { - local c; c=$(stty size 2>/dev/null | awk '{print $2}') - [[ "$c" =~ ^[0-9]+$ ]] || c=$(tput cols 2>/dev/null) - [[ "$c" =~ ^[0-9]+$ ]] || c=80 - (( c > 96 )) && c=96 - printf '%s' "$c" -} - -# model badge, read from the codex config (best-effort) +# model badge — reflects what the chat actually uses (low effort, not the +# heavyweight config default) _chat_badge() { - local cfg="$HOME/.codex/config.toml" model="" eff="" tier="" + local cfg="$HOME/.codex/config.toml" m="" t="" if [[ -f "$cfg" ]]; then - model=$(awk -F'"' '/^model[[:space:]]*=/{print $2; exit}' "$cfg" 2>/dev/null) - eff=$(awk -F'"' '/^model_reasoning_effort[[:space:]]*=/{print $2; exit}' "$cfg" 2>/dev/null) - tier=$(awk -F'"' '/^service_tier[[:space:]]*=/{print $2; exit}' "$cfg" 2>/dev/null) + m=$(awk -F'"' '/^model[[:space:]]*=/{print $2; exit}' "$cfg" 2>/dev/null) + t=$(awk -F'"' '/^service_tier[[:space:]]*=/{print $2; exit}' "$cfg" 2>/dev/null) + fi + local o="${m:-codex} · $(_chat_effort)"; [[ -n "$t" ]] && o="$o · $t" + printf '%s' "$o" +} + +# --- the pinned logo header --------------------------------------------------- + +# compact brand tile, 14x14 (# / . = white, R = red) +_chat_mark() { + cat <<'GRID' +############## +##RRRR###RRRR# +#R...R###R...# +#RR..R###R...# +#RR..R###R...# +#RR..R###R...# +##R..R###R...# +##RRRR###R...# +#########R...# +########RR...# +#######RR....# +#######R.....# +#######RRRRRR# +############## +GRID +} +_chat_paint_mark() { + awk -v pad="$1" ' + {rows[NR]=$0; n=NR} + function col(v){ return (v=="R")?"185;28;28":(v=="#")?"244;244;244":"18;18;18" } + END{ esc=sprintf("%c[",27) + for(i=1;i<=n;i+=2){ top=rows[i]; bot=(i+1<=n)?rows[i+1]:"" + out=""; for(p=0;pL) L=length(bot) + for(x=1;x<=L;x++){ tv=substr(top,x,1); bv=substr(bot,x,1) + out=out esc "38;2;" col(tv) "m" esc "48;2;" col(bv) "m" "\xe2\x96\x80" } + print out esc "0m" } }' +} +_chat_logo_header() { + local cols="$1" colw="$2" lm="$3" hlogo="$4" + if (( hlogo >= 8 )); then + local mpad=$(( (cols - 14) / 2 )); (( mpad < 0 )) && mpad=0 + _chat_mark | _chat_paint_mark "$mpad" fi - local out="${model:-codex}" - [[ -n "$eff" ]] && out="$out · $eff" - [[ -n "$tier" ]] && out="$out · $tier" - printf '%s' "$out" + local rule; rule=$(printf '─%.0s' $(seq 1 "$colw")) + printf '%*s\033[38;2;52;52;56m%s\033[0m\n' "$lm" '' "$rule" } # --- markdown -> ANSI --------------------------------------------------------- -# inline spans: **bold**, *italic*, `code`, [text](url) _chat_inline() { - perl -pe ' + local rst="$1"; [[ -z "$rst" ]] && rst=$'\033[39m' # color to return to after a span + RST="$rst" perl -pe ' + BEGIN{ $r=$ENV{RST} } s/\*\*(.+?)\*\*/\e[1m$1\e[22m/g; s/(?/dev/null } - -# a fenced code block: red left bar + bat syntax highlight _chat_codeblock() { local lang="$1" code="$2" hl if command -v bat >/dev/null 2>&1; then @@ -66,10 +99,9 @@ _chat_codeblock() { printf '\033[38;2;185;28;28m▌\033[0m %s\n' "$cl" done } - -# render markdown on stdin to width $1 _chat_md() { - local width="${1:-80}" in_code=0 lang="" code="" line + local width="${1:-72}" base="$2" in_code=0 lang="" code="" line + local rst="$base"; [[ -z "$rst" ]] && rst=$'\033[39m' while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ ^[[:space:]]*\`\`\`(.*)$ ]]; then if (( in_code == 0 )); then in_code=1; lang="${BASH_REMATCH[1]// /}"; code="" @@ -78,74 +110,66 @@ _chat_md() { fi if (( in_code )); then code+="$line"$'\n'; continue; fi if [[ "$line" =~ ^(#{1,6})[[:space:]]+(.*) ]]; then - printf '\033[1m%s\033[0m\n' "$(_chat_inline <<<"${BASH_REMATCH[2]}")" + printf '%b\033[1m%s\033[0m\n' "$base" "$(_chat_inline "$rst" <<<"${BASH_REMATCH[2]}")" elif [[ "$line" =~ ^[[:space:]]*[-*+][[:space:]]+(.*) ]]; then local b first=1 while IFS= read -r b || [[ -n "$b" ]]; do - if (( first )); then printf '\033[38;2;185;28;28m•\033[0m %s\n' "$(_chat_inline <<<"$b")"; first=0 - else printf ' %s\n' "$(_chat_inline <<<"$b")"; fi + if (( first )); then printf '%b•\033[0m %b%s\033[0m\n' "$base" "$base" "$(_chat_inline "$rst" <<<"$b")"; first=0 + else printf ' %b%s\033[0m\n' "$base" "$(_chat_inline "$rst" <<<"$b")"; fi done < <(printf '%s' "${BASH_REMATCH[1]}" | fold -s -w $((width-2))) elif [[ "$line" =~ ^[[:space:]]*([0-9]+)\.[[:space:]]+(.*) ]]; then - printf '\033[38;2;185;28;28m%s.\033[0m %s\n' "${BASH_REMATCH[1]}" "$(_chat_inline <<<"${BASH_REMATCH[2]}")" + printf '%b%s.\033[0m %b%s\033[0m\n' "$base" "${BASH_REMATCH[1]}" "$base" "$(_chat_inline "$rst" <<<"${BASH_REMATCH[2]}")" elif [[ "$line" =~ ^\>[[:space:]]?(.*) ]]; then - printf '\033[2m▏ %s\033[0m\n' "$(_chat_inline <<<"${BASH_REMATCH[1]}")" + printf '\033[2m▏ %s\033[0m\n' "$(_chat_inline "$rst" <<<"${BASH_REMATCH[1]}")" elif [[ -z "$line" ]]; then printf '\n' else printf '%s' "$line" | fold -s -w "$width" | while IFS= read -r wl || [[ -n "$wl" ]]; do - printf '%s\n' "$(_chat_inline <<<"$wl")" + printf '%b%s\033[0m\n' "$base" "$(_chat_inline "$rst" <<<"$wl")" done fi done } -# --- turn rendering ----------------------------------------------------------- +# --- turn rendering (everything prepends the centering margin $_CHAT_LM) ------ -_chat_you() { printf '\033[38;2;120;120;124myou\033[0m %s\n' "$1"; } _chat_indent() { local p="$1" l; while IFS= read -r l || [[ -n "$l" ]]; do printf '%s%s\n' "$p" "$l"; done; } - -# print an anu answer: red "anu" label, markdown body indented to align +# no labels: your message is white, anu's answer is red +_chat_you() { printf '%s\033[38;2;235;235;235m%s\033[0m\n' "${_CHAT_LM}" "$1"; } +_chat_action() { printf '%s\033[38;2;108;108;112m▸ ran\033[0m \033[38;2;150;150;154m%s\033[0m\n' "${_CHAT_LM}" "$1"; } _chat_anu_block() { - local text="$1" width; width=$(_chat_width) - printf '\033[38;2;185;28;28manu\033[0m\n' - printf '%s\n' "$text" | _chat_md $((width-6)) | _chat_indent ' ' + printf '%s\n' "$1" | _chat_md "${_CHAT_COLW:-72}" "$_CHAT_RED" | _chat_indent "${_CHAT_LM}" } -_chat_action() { printf ' \033[38;2;108;108;112m▸ ran\033[0m \033[38;2;150;150;154m%s\033[0m\n' "$1"; } - # Read codex --json on stdin, render only the signal, with a thinking spinner. -# Captures thread id to $1/thread and the final answer to $1/.last_answer. _chat_render_stream() { local chatdir="$1" line type itype text cmd rc local -a SP=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') - local spin=0 spinning=0 labeled=0 + local spin=0 spinning=0 lm="${_CHAT_LM}" w=$(( ${_CHAT_COLW:-72} )) _clr() { (( spinning )) && { printf '\r\033[K'; spinning=0; }; } - printf '\033[38;2;185;28;28manu\033[0m\n' # label once; content flows beneath while :; do if IFS= read -t 0.1 -r line; then [[ -z "$line" ]] && continue type=$(jq -r '.type // empty' <<<"$line" 2>/dev/null) case "$type" in - thread.started) - jq -r '.thread_id // empty' <<<"$line" 2>/dev/null > "$chatdir/thread" ;; + thread.started) jq -r '.thread_id // empty' <<<"$line" 2>/dev/null > "$chatdir/thread" ;; item.completed|item.done) itype=$(jq -r '.item.type // empty' <<<"$line" 2>/dev/null) case "$itype" in agent_message|assistant_message|message) - _clr - text=$(jq -r '.item.text // .item.content // empty' <<<"$line" 2>/dev/null) + _clr; text=$(jq -r '.item.text // .item.content // empty' <<<"$line" 2>/dev/null) printf '%s' "$text" > "$chatdir/.last_answer" - printf '%s\n' "$text" | _chat_md "$(( $(_chat_width) - 6 ))" | _chat_indent ' ' ;; + printf '%s\n' "$text" | _chat_md "$w" "$_CHAT_RED" | _chat_indent "${lm}" ;; command_execution|local_shell_call|exec_command|shell_command|function_call) cmd=$(jq -r '(.item.command // .item.cmd // .item.action.command // .item.name // empty) | if type=="array" then join(" ") else . end' <<<"$line" 2>/dev/null) [[ -n "$cmd" ]] && { _clr; _chat_action "$cmd"; } ;; - *) : ;; # reasoning, etc. -> never rendered + *) : ;; esac ;; *) : ;; esac else rc=$? if (( rc > 128 )); then - printf '\r \033[38;2;185;28;28m%s\033[0m \033[2mthinking…\033[0m\033[K' "${SP[$spin]}" + printf '\r%s\033[38;2;205;80;80m%s\033[0m\033[K' "$lm" "${SP[$spin]}" spinning=1; spin=$(( (spin+1) % ${#SP[@]} )) else _clr; break; fi fi @@ -154,50 +178,45 @@ _chat_render_stream() { # --- persistence -------------------------------------------------------------- -_chat_log() { - jq -nc --arg role "$2" --arg text "$3" --argjson ts "$(date +%s)" \ - '{role:$role,text:$text,ts:$ts}' >> "$1/transcript.jsonl" 2>/dev/null -} - +_chat_log() { jq -nc --arg role "$2" --arg text "$3" --argjson ts "$(date +%s)" '{role:$role,text:$text,ts:$ts}' >> "$1/transcript.jsonl" 2>/dev/null; } _chat_new() { local root id dir; root=$(_chat_root); mkdir -p "$root" id="$(date +%Y%m%d-%H%M%S)-$$"; dir="$root/$id"; mkdir -p "$dir" printf 'created\t%s\ncwd\t%s\nmodel\t%s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$PWD" "$(_chat_badge)" > "$dir/meta" printf '%s' "$dir" } - _chat_recent() { ls -dt "$(_chat_root)"/*/ 2>/dev/null | head -1 | sed 's:/*$::'; } # --- one turn ----------------------------------------------------------------- _chat_turn() { local chatdir="$1" msg="$2" thread; thread=$(cat "$chatdir/thread" 2>/dev/null) - local -a flags=(--skip-git-repo-check) - if [[ -n "$ANU_CONDUCTOR_FULL_AUTO" ]]; then flags+=(--dangerously-bypass-approvals-and-sandbox) - else flags+=(--sandbox workspace-write); fi - _chat_log "$chatdir" you "$msg" - rm -f "$chatdir/.last_answer" - # codex.err: keep status noise out of the chat (logged for debugging). if [[ -n "$thread" ]]; then - command codex exec resume "$thread" --json "${flags[@]}" -- "$msg" "$chatdir/codex.err" | _chat_render_stream "$chatdir" + else + # sandbox is a first-turn-only flag (read-only = snappy; full-auto set above) + [[ -z "$ANU_CONDUCTOR_FULL_AUTO" ]] && flags+=(--sandbox read-only) + command codex exec --json "${flags[@]}" -- "$msg" "$chatdir/codex.err" | _chat_render_stream "$chatdir" + fi + if [[ -s "$chatdir/.last_answer" ]]; then + _chat_log "$chatdir" anu "$(cat "$chatdir/.last_answer")" else - command codex exec --json "${flags[@]}" -- "$msg" /dev/null | tail -1 | tr -d '\r') + printf '%s\033[38;2;185;28;28m⚠\033[0m \033[2m%s\033[0m\n' "${_CHAT_LM}" "${err:-codex returned no response}" fi - [[ -s "$chatdir/.last_answer" ]] && _chat_log "$chatdir" anu "$(cat "$chatdir/.last_answer")" } # --- views -------------------------------------------------------------------- -_chat_header() { - local w; w=$(_chat_width) - printf '\033[?25h\033[2J\033[H' - printf '\033[1manu\033[0m \033[38;2;140;140;144mchat\033[0m' - local badge; badge=$(_chat_badge) - printf '\033[%dG\033[38;2;96;96;100m%s\033[0m\n' $(( w - ${#badge} + 1 )) "$badge" - local rule; rule=$(printf '─%.0s' $(seq 1 "$w")) - printf '\033[38;2;52;52;56m%s\033[0m\n\n' "$rule" -} - _chat_replay() { local chatdir="$1" entry role text [[ -s "$chatdir/transcript.jsonl" ]] || return 0 @@ -207,38 +226,65 @@ _chat_replay() { printf '\n' done < "$chatdir/transcript.jsonl" } - _chat_ls() { local root; root=$(_chat_root); local d [[ -d "$root" ]] || { echo "no chats yet"; return 0; } for d in $(ls -dt "$root"/*/ 2>/dev/null); do - d=${d%/} - local first turns when + d=${d%/}; local first turns when first=$(head -1 "$d/transcript.jsonl" 2>/dev/null | jq -r '.text' 2>/dev/null | head -c 50) turns=$(grep -c . "$d/transcript.jsonl" 2>/dev/null) when=$(awk -F'\t' '/^created/{print $2}' "$d/meta" 2>/dev/null) - printf ' \033[1m%s\033[0m \033[2m%s · %s turns\033[0m\n %s\n' \ - "$(basename "$d")" "${when:-?}" "${turns:-0}" "${first:-(empty)}" + printf ' \033[1m%s\033[0m \033[2m%s · %s turns\033[0m\n %s\n' "$(basename "$d")" "${when:-?}" "${turns:-0}" "${first:-(empty)}" done } -# --- the loop ----------------------------------------------------------------- +# Read one line char-by-char into $_CHAT_INPUT, echoing in place (readline is +# unreliable inside a scroll-region subshell). Returns 1 on empty / Ctrl-C/D. +_chat_read_line() { + local lm="$1" key rest; _CHAT_INPUT="" + printf '%s\033[38;2;185;28;28m❯\033[0m ' "$lm" + while IFS= read -rsn1 key; do + case "$key" in + ''|$'\r'|$'\n') break ;; # Enter + $'\x7f'|$'\b') [[ -n "$_CHAT_INPUT" ]] && { _CHAT_INPUT="${_CHAT_INPUT%?}"; printf '\b \b'; } ;; + $'\x15') while [[ -n "$_CHAT_INPUT" ]]; do _CHAT_INPUT="${_CHAT_INPUT%?}"; printf '\b \b'; done ;; + $'\x03'|$'\x04') _CHAT_INPUT=""; printf '\n'; return 1 ;; # Ctrl-C / Ctrl-D + $'\e') IFS= read -rsn8 -t 0.02 rest ;; # drain arrow/CSI -> ignore + [[:print:]]) _CHAT_INPUT+="$key"; printf '%s' "$key" ;; + *) : ;; + esac + done + printf '\n' + [[ -z "${_CHAT_INPUT//[[:space:]]/}" ]] && return 1 + return 0 +} + +# --- the framed loop ---------------------------------------------------------- _chat_repl() { - local chatdir="$1" first="$2" line - _chat_header - _chat_replay "$chatdir" - if [[ -n "$first" ]]; then - _chat_you "$first"; printf '\n'; _chat_turn "$chatdir" "$first"; printf '\n' - fi - while :; do - IFS= read -e -r -p $'\033[38;2;185;28;28m❯\033[0m ' line || break # Ctrl-D leaves - [[ -z "${line//[[:space:]]/}" ]] && break # empty leaves - printf '\033[1A\033[2K' # replace input with styled turn - _chat_you "$line"; printf '\n' - _chat_turn "$chatdir" "$line"; printf '\n' - done - printf '\033[2m— saved · resume with `chat last` —\033[0m\n' + local chatdir="$1" first="$2" cols rows + read -r rows cols < <(_chat_termsize) + local colw=$(( cols - 8 )); (( colw > 76 )) && colw=76; (( colw < 20 )) && colw=$(( cols - 2 )) + local lm=$(( (cols - colw) / 2 )); (( lm < 0 )) && lm=0 + local hlogo=8; (( rows < 16 )) && hlogo=1 # mark(7)+rule(1); tiny term: rule only + _CHAT_COLW=$colw; _CHAT_LM=$(printf '%*s' "$lm" ''); _CHAT_RED=$'\033[38;2;208;90;90m' + + # the framed session runs in a subshell so the scroll region is ALWAYS reset, + # even on Ctrl-C / error (a stuck DECSTBM region would break the terminal) + ( + trap 'printf "\033[r\033[?25h\033[0m"' EXIT INT TERM + printf '\033[?25h\033[2J\033[H' + _chat_logo_header "$cols" "$colw" "$lm" "$hlogo" + printf '\033[%d;%dr' $(( hlogo + 1 )) "$rows" # scroll region below the logo + printf '\033[%d;1H\n' $(( hlogo + 1 )) # drop into the region + _chat_replay "$chatdir" + [[ -n "$first" ]] && { _chat_you "$first"; printf '\n'; _chat_turn "$chatdir" "$first"; printf '\n'; } + while _chat_read_line "$_CHAT_LM"; do + printf '\033[1A\033[2K'; _chat_you "$_CHAT_INPUT"; printf '\n' + _chat_turn "$chatdir" "$_CHAT_INPUT"; printf '\n' + done + ) + printf '\n%s\033[2m— saved · resume with `chat last` —\033[0m\n' "$_CHAT_LM" } # --- entry --------------------------------------------------------------------