Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 154 additions & 108 deletions config/bash/fns/chat
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message…> new chat, sends <message…> as the first turn
# chat last (-c) resume the most recent chat
# chat resume <id> 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 <id> resume a specific chat · chat ls list chats
#
# Persistent: each chat lives in ~/.anu/chats/<id>/ as {thread, transcript.jsonl,
# meta}; continuity is codex's own session (resume <thread_id>). Frictionless:
# ask one thing and leave (empty line / Ctrl-D), or keep typing.
# Persistent: ~/.anu/chats/<id>/{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;p<pad;p++) out=out" "
L=length(top); if(length(bot)>L) 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/(?<![\*\w])\*(?!\s)(.+?)(?<!\s)\*(?![\*\w])/\e[3m$1\e[23m/g;
s/`([^`]+)`/\e[38;2;226;170;120m$1\e[39m/g;
s/`([^`]+)`/\e[1m$1\e[22m/g;
s/\[([^\]]+)\]\(([^)]+)\)/$1 \e[2m($2)\e[22m/g;
' 2>/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
Expand All @@ -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=""
Expand All @@ -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
Expand All @@ -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"
# </dev/null: codex exec reads OPEN stdin as extra prompt context and hangs.
local -a flags=(--skip-git-repo-check
-c "model_reasoning_effort=\"$(_chat_effort)\"" # none = thinking off (fast)
-c 'model_verbosity="low"') # short, snappy answers
[[ -n "$ANU_CONDUCTOR_FULL_AUTO" ]] && flags+=(--dangerously-bypass-approvals-and-sandbox)
_chat_log "$chatdir" you "$msg"; rm -f "$chatdir/.last_answer"
# </dev/null: codex reads open stdin as extra context (hangs).
# 2>codex.err: keep status noise out of the chat (logged for debugging).
if [[ -n "$thread" ]]; then
command codex exec resume "$thread" --json "${flags[@]}" -- "$msg" </dev/null | _chat_render_stream "$chatdir"
# NOTE: `codex exec resume` rejects --sandbox; it continues the session as-is.
command codex exec resume "$thread" --json "${flags[@]}" -- "$msg" </dev/null 2>"$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" </dev/null 2>"$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 | _chat_render_stream "$chatdir"
# no answer — never fail silently; show codex's last error line
local err; err=$(grep -v '^[[:space:]]*$' "$chatdir/codex.err" 2>/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
Expand All @@ -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 --------------------------------------------------------------------
Expand Down