Skip to content

Commit e019d4a

Browse files
alexkromanclaude
andauthored
Tint transcript turns in speaker colors for visual distinction (#241)
## Summary Redesigned the visual rendering of conversation transcripts so that entire speaker turns (both label and body text) are tinted in the speaker's color, rather than just the label prefix. This makes "you:" and "agent:" lines visually distinct at a glance, even on terminals with limited color support. ## Key Changes - **Theme colors**: Introduced a new `AGENT` color (`#14B8A6`, teal) to replace the previous secondary accent (Cobolt 300) for agent labels. The teal was chosen specifically to downgrade to ANSI cyan on 16-color terminals, staying clearly distinct from the brand purple used for "you" lines. Previously, both speakers used near-identical cobolt purples that collapsed to the same hue after downsampling. - **Rendering**: Modified `_labeled()` in `aai_cli/agent/render.py` to apply the style to the entire line (label + body) rather than just the label prefix. This ensures the whole turn reads as a cohesive colored block. - **Tests**: Added `test_human_whole_turn_is_tinted_in_speaker_color()` to verify that both the label and body text are wrapped in the speaker's color SGR codes, and that the two speakers render in contrasting hues. Added `test_you_and_agent_stay_distinct_after_downsampling()` to ensure the colors remain visually distinct even after downsampling to 16-color ANSI palettes. ## Implementation Details - The `AGENT` teal (`#14B8A6`) was chosen outside the cobolt purple family on purpose: it downsamples to ANSI cyan, which is clearly distinct from the purple/blue that "you" downsamples to, ensuring readability across all terminal capabilities. - The `aai.you` style retains the brand purple and remains reserved (never reused for diarized speakers). - Updated theme documentation to clarify that the entire transcript turn is now tinted, not just the prefix. https://claude.ai/code/session_01AFrwHX8EdECmkNsU8LqJNf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b82d8d1 commit e019d4a

6 files changed

Lines changed: 123 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
check:
2727
name: lint + typecheck + tests (py${{ matrix.python-version }})
2828
runs-on: ubuntu-latest
29-
timeout-minutes: 15
29+
timeout-minutes: 20
3030
# Test both ends of the supported range: 3.12 is the floor (requires-python),
3131
# 3.13 is what the Homebrew formula ships. fail-fast off so one version's
3232
# failure doesn't mask the other's.
@@ -49,8 +49,9 @@ jobs:
4949

5050
# PortAudio backs sounddevice; ffmpeg decodes non-WAV/URL audio (the `--sample`
5151
# stream tests build a FileSource for the hosted sample, which needs ffmpeg).
52+
# Slow-mirror resilience (bounded retry + trimmed payload) lives in the script.
5253
- name: System deps (PortAudio + ffmpeg)
53-
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg
54+
run: ./scripts/ci_install_audio_deps.sh
5455

5556
# check.sh lints Markdown and template JS/CSS via Node CLIs; versions are
5657
# pinned in scripts/gate_tool_pins.sh (shared with the web session-start
@@ -245,7 +246,7 @@ jobs:
245246
pre-commit:
246247
name: pre-commit
247248
runs-on: ubuntu-latest
248-
timeout-minutes: 15
249+
timeout-minutes: 20
249250
steps:
250251
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
251252
with:
@@ -257,7 +258,7 @@ jobs:
257258

258259
# PortAudio backs sounddevice; ffmpeg decodes the `--sample` stream source.
259260
- name: System deps (PortAudio + ffmpeg)
260-
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg
261+
run: ./scripts/ci_install_audio_deps.sh
261262

262263
# The local pytest hook runs `uv run --frozen python -m pytest`, so the tests
263264
# resolve the LOCKED dependency versions (uv.lock) rather than the newest
@@ -341,7 +342,7 @@ jobs:
341342
# PortAudio + ffmpeg so `assembly --help` (which imports the full command
342343
# tree) loads cleanly; also lets install.sh's dep check find them present.
343344
- name: System deps (PortAudio + ffmpeg)
344-
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg
345+
run: ./scripts/ci_install_audio_deps.sh
345346

346347
- name: Run install.sh (editable, from this checkout)
347348
run: ./install.sh --install-method git

aai_cli/agent/render.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@
99

1010

1111
def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text:
12-
"""A line whose `label` prefix is accented in `style` and whose body is default."""
13-
return Text.assemble((label, style), body)
12+
"""A transcript line tinted entirely in `style` — both the `label` prefix and the body.
13+
14+
Coloring the whole turn (not just the prefix) is what makes a "you:" line and an
15+
"agent:" line read as different colors top to bottom; the two styles resolve to
16+
contrasting hues (see ``theme.aai.you`` / ``theme.aai.agent``).
17+
"""
18+
return Text(f"{label}{body}", style=style)
1419

1520

1621
class AgentRenderer(BaseRenderer):

aai_cli/ui/theme.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@
1717

1818
# Brand accent. Defined once so the whole CLI can be re-tinted here.
1919
BRAND = COBOLT_400
20-
# Secondary accent — the lighter Cobolt 300 used where a second hue is needed (agent
21-
# label, links), so it stays in the brand family yet reads distinct from BRAND.
20+
# Secondary accent — the lighter Cobolt 300 used where a second hue is needed (links),
21+
# so it stays in the brand family yet reads distinct from BRAND.
2222
ACCENT = COBOLT_300
2323

24+
# The agent's voice in a live conversation. A cool teal chosen *outside* the cobolt
25+
# purple family on purpose: "you" keeps the brand purple, and the two were previously
26+
# both purples (Cobolt 400 vs 300) that collapsed to the same hue once a terminal
27+
# downsampled truecolor to its 16-color palette — so "you:" and "agent:" lines looked
28+
# identically colored. Teal downsamples to ANSI cyan, staying clearly distinct from the
29+
# purple/blue "you" on any terminal.
30+
AGENT = "#14B8A6"
31+
2432
# Semantic error red, matching the design system's --color-error. Rich downsamples it
2533
# to plain red on terminals without truecolor.
2634
ERROR = "#F04438"
@@ -55,11 +63,13 @@
5563
"aai.brand": f"bold {BRAND}",
5664
"aai.heading": f"bold {BRAND}",
5765
"aai.label": BRAND,
58-
# Conversation labels: the human keeps the brand accent (reserved — never reused
59-
# for a diarized speaker, see SPEAKER_STYLES), the agent gets a distinct hue so
60-
# "you:" and "agent:" are easy to tell apart at a glance.
66+
# Conversation colors: a whole transcript turn is tinted in its speaker's hue
67+
# (not just the "you:"/"agent:" prefix), so the two read as different colors top
68+
# to bottom. The human keeps the brand purple (reserved — never reused for a
69+
# diarized speaker, see SPEAKER_STYLES); the agent gets a contrasting teal so the
70+
# two are unmistakable at a glance, even after downsampling (see AGENT).
6171
"aai.you": BRAND,
62-
"aai.agent": ACCENT,
72+
"aai.agent": AGENT,
6373
# Links/URLs in the lighter Cobolt secondary accent so a clickable target stands
6474
# out from prose without shouting (Vercel/Supabase use a cool accent for the same).
6575
"aai.url": ACCENT,

scripts/ci_install_audio_deps.sh

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
# CI-only helper: install the Linux system audio deps the suite needs — libportaudio2
3+
# (sounddevice's PortAudio backend) and ffmpeg (decodes non-WAV/URL audio for the
4+
# `--sample` stream tests; the require_ffmpeg probe needs it on PATH). Three Ubuntu jobs
5+
# in .github/workflows/ci.yml need the identical pair, so the slow-mirror resilience
6+
# below lives in one place.
7+
#
8+
# The Azure Ubuntu apt mirror periodically degrades to a crawl (~100 KB/s). A plain
9+
# `apt-get install ffmpeg` pulls ffmpeg's ~62 MB codec dependency closure, which then
10+
# overruns the job timeout mid-download and gets cancelled before the tests run, bouncing
11+
# the PR out of the merge queue. So, mirroring the Windows ffmpeg step's strategy of
12+
# falling back to a static build off GitHub's release CDN:
13+
# * libportaudio2 has no portable prebuilt, so it always comes from apt — but it's tiny,
14+
# so a bounded retry rides out a slow mirror.
15+
# * ffmpeg is fetched as a static build from GitHub's release CDN (BtbN/FFmpeg-Builds —
16+
# the same origin the Windows job uses), bypassing apt's heavy codec chain on the
17+
# flaky mirror entirely, and prepended to GITHUB_PATH so later steps see it.
18+
set -euo pipefail
19+
20+
# Run one bounded apt-get attempt, retrying a stalled/failed call a couple of times. A
21+
# stalled mirror connection is killed by `timeout` (run under sudo so the killer is root
22+
# and can reap apt) rather than wedging the whole job; the 3rd failure falls through.
23+
apt_retry() {
24+
local attempt
25+
for attempt in 1 2 3; do
26+
if sudo timeout --kill-after=10s 120s apt-get "$@"; then
27+
return 0
28+
fi
29+
if [ "$attempt" -lt 3 ]; then
30+
echo "::warning::apt-get $* failed or stalled (attempt ${attempt}/3); retrying" >&2
31+
sleep "$((attempt * 5))"
32+
fi
33+
done
34+
echo "::warning::apt-get $* failed after 3 attempts" >&2
35+
return 1
36+
}
37+
38+
# A stale list is fine (the runner image's apt cache is recent), so don't let a slow
39+
# `update` be fatal; libportaudio2 itself has no CDN fallback, so that one must succeed.
40+
apt_retry update -o Acquire::Retries=3 || true
41+
apt_retry install -y --no-install-recommends -o Acquire::Retries=3 libportaudio2
42+
43+
# ffmpeg from GitHub's release CDN, not apt: a static, self-contained build off a reliable
44+
# origin sidesteps the 62 MB codec download the degraded apt mirror kept failing to serve.
45+
url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
46+
dest="${RUNNER_TEMP:-/tmp}/ffmpeg-static"
47+
mkdir -p "$dest"
48+
curl -fsSL --retry 3 --retry-all-errors "$url" | tar -xJ -C "$dest" --strip-components=1
49+
echo "$dest/bin" >> "${GITHUB_PATH:-/dev/null}"
50+
export PATH="$dest/bin:$PATH"
51+
52+
command -v ffmpeg >/dev/null || {
53+
echo "::error::ffmpeg unavailable after setup" >&2
54+
exit 1
55+
}
56+
ffmpeg -version >/dev/null
57+
echo "audio deps ready: libportaudio2 + ffmpeg ($(command -v ffmpeg))"

tests/test_agent_render.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,26 @@ def test_human_you_label_is_colored():
192192
assert "you: " in out
193193
assert "what is the time" in out
194194
assert "\x1b[" in out
195+
196+
197+
def _truecolor_sgr(style_name: str) -> str:
198+
"""The foreground SGR a truecolor console emits for a named theme style."""
199+
color = theme.make_console(color_system="truecolor").get_style(style_name).color
200+
assert color is not None
201+
t = color.get_truecolor()
202+
return f"\x1b[38;2;{t.red};{t.green};{t.blue}m"
203+
204+
205+
def test_human_whole_turn_is_tinted_in_speaker_color():
206+
# The whole line — label *and* body — is wrapped in the speaker's color, and the two
207+
# speakers render in different colors, so a glance separates "you" from "agent".
208+
r, buf = _human(color_system="truecolor")
209+
r.user_final("hello")
210+
r.agent_transcript("hi there", interrupted=False)
211+
r.close()
212+
out = buf.getvalue()
213+
you_sgr, agent_sgr = _truecolor_sgr("aai.you"), _truecolor_sgr("aai.agent")
214+
assert you_sgr != agent_sgr # contrasting hues, not two shades of the same color
215+
# The body text rides inside the colored span (it follows the SGR, before any reset).
216+
assert f"{you_sgr}you: hello" in out
217+
assert f"{agent_sgr}agent: hi there" in out

tests/test_theme.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ def test_you_color_reserved_outside_speaker_palette():
5555
assert you_color not in speaker_colors
5656

5757

58+
def test_you_and_agent_stay_distinct_after_downsampling():
59+
# "you" and "agent" used to be two near-identical cobolt purples that downsampled to
60+
# the *same* 16-color ANSI slot, so a transcript looked single-colored on a basic
61+
# terminal. Assert they're different hues and stay different once downgraded.
62+
from rich.color import ColorSystem
63+
64+
console = theme.make_console()
65+
you = console.get_style("aai.you").color
66+
agent = console.get_style("aai.agent").color
67+
assert you is not None and agent is not None
68+
assert you != agent
69+
assert you.downgrade(ColorSystem.STANDARD) != agent.downgrade(ColorSystem.STANDARD)
70+
71+
5872
def test_output_console_is_themed_and_error_is_styled(monkeypatch):
5973
from aai_cli.core.errors import CLIError
6074
from aai_cli.ui import output, theme

0 commit comments

Comments
 (0)