diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a38cff..ac33ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/aai_cli/agent/render.py b/aai_cli/agent/render.py index e64df83..98f6a2b 100644 --- a/aai_cli/agent/render.py +++ b/aai_cli/agent/render.py @@ -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): diff --git a/aai_cli/ui/theme.py b/aai_cli/ui/theme.py index bb4238d..5d62c8d 100644 --- a/aai_cli/ui/theme.py +++ b/aai_cli/ui/theme.py @@ -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" @@ -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, diff --git a/scripts/ci_install_audio_deps.sh b/scripts/ci_install_audio_deps.sh new file mode 100755 index 0000000..dcbac81 --- /dev/null +++ b/scripts/ci_install_audio_deps.sh @@ -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))" diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 9b65d16..e6216af 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -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 diff --git a/tests/test_theme.py b/tests/test_theme.py index 530254e..ca5cde7 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -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