Skip to content
Merged
Show file tree
Hide file tree
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
11 changes: 6 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
check:
name: lint + typecheck + tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
# Test both ends of the supported range: 3.12 is the floor (requires-python),
# 3.13 is what the Homebrew formula ships. fail-fast off so one version's
# failure doesn't mask the other's.
Expand All @@ -49,8 +49,9 @@ jobs:

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

# check.sh lints Markdown and template JS/CSS via Node CLIs; versions are
# pinned in scripts/gate_tool_pins.sh (shared with the web session-start
Expand Down Expand Up @@ -245,7 +246,7 @@ jobs:
pre-commit:
name: pre-commit
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
Expand All @@ -257,7 +258,7 @@ jobs:

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

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

- name: Run install.sh (editable, from this checkout)
run: ./install.sh --install-method git
Expand Down
9 changes: 7 additions & 2 deletions aai_cli/agent/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@


def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text:
"""A line whose `label` prefix is accented in `style` and whose body is default."""
return Text.assemble((label, style), body)
"""A transcript line tinted entirely in `style` — both the `label` prefix and the body.

Coloring the whole turn (not just the prefix) is what makes a "you:" line and an
"agent:" line read as different colors top to bottom; the two styles resolve to
contrasting hues (see ``theme.aai.you`` / ``theme.aai.agent``).
"""
return Text(f"{label}{body}", style=style)


class AgentRenderer(BaseRenderer):
Expand Down
22 changes: 16 additions & 6 deletions aai_cli/ui/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@

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

# The agent's voice in a live conversation. A cool teal chosen *outside* the cobolt
# purple family on purpose: "you" keeps the brand purple, and the two were previously
# both purples (Cobolt 400 vs 300) that collapsed to the same hue once a terminal
# downsampled truecolor to its 16-color palette — so "you:" and "agent:" lines looked
# identically colored. Teal downsamples to ANSI cyan, staying clearly distinct from the
# purple/blue "you" on any terminal.
AGENT = "#14B8A6"

# Semantic error red, matching the design system's --color-error. Rich downsamples it
# to plain red on terminals without truecolor.
ERROR = "#F04438"
Expand Down Expand Up @@ -55,11 +63,13 @@
"aai.brand": f"bold {BRAND}",
"aai.heading": f"bold {BRAND}",
"aai.label": BRAND,
# Conversation labels: the human keeps the brand accent (reserved — never reused
# for a diarized speaker, see SPEAKER_STYLES), the agent gets a distinct hue so
# "you:" and "agent:" are easy to tell apart at a glance.
# Conversation colors: a whole transcript turn is tinted in its speaker's hue
# (not just the "you:"/"agent:" prefix), so the two read as different colors top
# to bottom. The human keeps the brand purple (reserved — never reused for a
# diarized speaker, see SPEAKER_STYLES); the agent gets a contrasting teal so the
# two are unmistakable at a glance, even after downsampling (see AGENT).
"aai.you": BRAND,
"aai.agent": ACCENT,
"aai.agent": AGENT,
# Links/URLs in the lighter Cobolt secondary accent so a clickable target stands
# out from prose without shouting (Vercel/Supabase use a cool accent for the same).
"aai.url": ACCENT,
Expand Down
57 changes: 57 additions & 0 deletions scripts/ci_install_audio_deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# CI-only helper: install the Linux system audio deps the suite needs — libportaudio2
# (sounddevice's PortAudio backend) and ffmpeg (decodes non-WAV/URL audio for the
# `--sample` stream tests; the require_ffmpeg probe needs it on PATH). Three Ubuntu jobs
# in .github/workflows/ci.yml need the identical pair, so the slow-mirror resilience
# below lives in one place.
#
# The Azure Ubuntu apt mirror periodically degrades to a crawl (~100 KB/s). A plain
# `apt-get install ffmpeg` pulls ffmpeg's ~62 MB codec dependency closure, which then
# overruns the job timeout mid-download and gets cancelled before the tests run, bouncing
# the PR out of the merge queue. So, mirroring the Windows ffmpeg step's strategy of
# falling back to a static build off GitHub's release CDN:
# * libportaudio2 has no portable prebuilt, so it always comes from apt — but it's tiny,
# so a bounded retry rides out a slow mirror.
# * ffmpeg is fetched as a static build from GitHub's release CDN (BtbN/FFmpeg-Builds —
# the same origin the Windows job uses), bypassing apt's heavy codec chain on the
# flaky mirror entirely, and prepended to GITHUB_PATH so later steps see it.
set -euo pipefail

# Run one bounded apt-get attempt, retrying a stalled/failed call a couple of times. A
# stalled mirror connection is killed by `timeout` (run under sudo so the killer is root
# and can reap apt) rather than wedging the whole job; the 3rd failure falls through.
apt_retry() {
local attempt
for attempt in 1 2 3; do
if sudo timeout --kill-after=10s 120s apt-get "$@"; then
return 0
fi
if [ "$attempt" -lt 3 ]; then
echo "::warning::apt-get $* failed or stalled (attempt ${attempt}/3); retrying" >&2
sleep "$((attempt * 5))"
fi
done
echo "::warning::apt-get $* failed after 3 attempts" >&2
return 1
}

# A stale list is fine (the runner image's apt cache is recent), so don't let a slow
# `update` be fatal; libportaudio2 itself has no CDN fallback, so that one must succeed.
apt_retry update -o Acquire::Retries=3 || true
apt_retry install -y --no-install-recommends -o Acquire::Retries=3 libportaudio2

# ffmpeg from GitHub's release CDN, not apt: a static, self-contained build off a reliable
# origin sidesteps the 62 MB codec download the degraded apt mirror kept failing to serve.
url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz"
dest="${RUNNER_TEMP:-/tmp}/ffmpeg-static"
mkdir -p "$dest"
curl -fsSL --retry 3 --retry-all-errors "$url" | tar -xJ -C "$dest" --strip-components=1
echo "$dest/bin" >> "${GITHUB_PATH:-/dev/null}"
export PATH="$dest/bin:$PATH"

command -v ffmpeg >/dev/null || {
echo "::error::ffmpeg unavailable after setup" >&2
exit 1
}
ffmpeg -version >/dev/null
echo "audio deps ready: libportaudio2 + ffmpeg ($(command -v ffmpeg))"
23 changes: 23 additions & 0 deletions tests/test_agent_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,26 @@ def test_human_you_label_is_colored():
assert "you: " in out
assert "what is the time" in out
assert "\x1b[" in out


def _truecolor_sgr(style_name: str) -> str:
"""The foreground SGR a truecolor console emits for a named theme style."""
color = theme.make_console(color_system="truecolor").get_style(style_name).color
assert color is not None
t = color.get_truecolor()
return f"\x1b[38;2;{t.red};{t.green};{t.blue}m"


def test_human_whole_turn_is_tinted_in_speaker_color():
# The whole line — label *and* body — is wrapped in the speaker's color, and the two
# speakers render in different colors, so a glance separates "you" from "agent".
r, buf = _human(color_system="truecolor")
r.user_final("hello")
r.agent_transcript("hi there", interrupted=False)
r.close()
out = buf.getvalue()
you_sgr, agent_sgr = _truecolor_sgr("aai.you"), _truecolor_sgr("aai.agent")
assert you_sgr != agent_sgr # contrasting hues, not two shades of the same color
# The body text rides inside the colored span (it follows the SGR, before any reset).
assert f"{you_sgr}you: hello" in out
assert f"{agent_sgr}agent: hi there" in out
14 changes: 14 additions & 0 deletions tests/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ def test_you_color_reserved_outside_speaker_palette():
assert you_color not in speaker_colors


def test_you_and_agent_stay_distinct_after_downsampling():
# "you" and "agent" used to be two near-identical cobolt purples that downsampled to
# the *same* 16-color ANSI slot, so a transcript looked single-colored on a basic
# terminal. Assert they're different hues and stay different once downgraded.
from rich.color import ColorSystem

console = theme.make_console()
you = console.get_style("aai.you").color
agent = console.get_style("aai.agent").color
assert you is not None and agent is not None
assert you != agent
assert you.downgrade(ColorSystem.STANDARD) != agent.downgrade(ColorSystem.STANDARD)


def test_output_console_is_themed_and_error_is_styled(monkeypatch):
from aai_cli.core.errors import CLIError
from aai_cli.ui import output, theme
Expand Down
Loading