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
17 changes: 8 additions & 9 deletions aai_cli/code_agent/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
VOICE_FRAMES,
_spinner_text,
_status_text,
copy_note,
voicebar_markup,
)
from aai_cli.code_agent.voice_ui import _VoiceIO, _VoiceLegs
Expand Down Expand Up @@ -82,12 +83,11 @@ class CodeAgentApp(_VoiceLegs):
#promptmark {{ width: 3; color: {banner.BRAND_HEX}; content-align: center middle; }}
#prompt {{ border: none; background: #000000; padding: 0; }}
/* Shown in place of the prompt while voice capture is on (Ctrl-V brings the prompt back). */
#voicebar {{ dock: bottom; height: 3; background: #000000; border: round {banner.BRAND_HEX};
margin: 1 1; content-align: center middle; display: none; }}
#voicebar {{ dock: bottom; height: 3; background: #000000; border: round {banner.BRAND_HEX}; margin: 1 1; content-align: center middle; display: none; }}
/* In normal flow below the 1fr log, so it sits just above the docked prompt bar. */
#spinner {{ height: 1; background: #000000; padding: 0 2;
color: {banner.BRAND_HEX}; display: none; }}
#status {{ dock: bottom; height: 1; background: #000000; padding: 0 1; }}
#spinner {{ height: 1; background: #000000; padding: 0 2; color: {banner.BRAND_HEX}; display: none; }}
/* Two rows: the mode/cwd/branch/voice line and the dim key-legend below it. */
#status {{ dock: bottom; height: 2; background: #000000; padding: 0 1; }}
"""
TITLE = "AssemblyAI Code"
# Ctrl-C quits (in addition to Ctrl-Q); the built-in command palette is removed.
Expand Down Expand Up @@ -245,12 +245,10 @@ def _finalize_reply(self, text: str) -> None:
msg.finalize(text)

def action_copy_last(self) -> None:
"""Copy the most recent assistant reply to the system clipboard."""
"""Copy the most recent assistant reply to the system clipboard, noting the outcome."""
import pyperclip

if self._last_reply:
pyperclip.copy(self._last_reply)
self._note("(copied last reply to clipboard)")
self._note(copy_note(self._last_reply, pyperclip.copy))

def action_toggle_output(self) -> None:
"""Ctrl-O: expand/collapse the most recent tool output (a no-op if there's none)."""
Expand Down Expand Up @@ -321,6 +319,7 @@ def action_toggle_voice(self) -> None:
self._refresh_status()
self._sync_input_mode() # show/hide the text box vs. the listening affordance
if self._voice_paused:
self._voice.cancel() # release the mic now, don't leave a capture running unseen
self.notify("Voice off — type your request")
elif not self._turn_running():
self.notify("Voice on — listening")
Expand Down
47 changes: 43 additions & 4 deletions aai_cli/code_agent/tui_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import pyperclip
from rich.markup import escape

from aai_cli.ui import theme

if TYPE_CHECKING:
from collections.abc import Callable

# Animated meter for the voice bar — a 3-cell block-char pulse (BMP, single-width, no emoji).
# Public: both the `code` and `live` TUIs cycle it for their bar animation.
VOICE_FRAMES = ("▁▃▅", "▃▅▇", "▅▇▆", "▆▇▅", "▇▅▃", "▅▃▁") # pragma: no mutate
Expand Down Expand Up @@ -39,6 +44,38 @@ def _spinner_text(elapsed_s: int, frame: str) -> str:
return f"{frame} Working… ({elapsed_s}s)"


def keyhints_text(*, voice: bool) -> str:
"""The dim key-legend footer for the `code` TUI — the shortcuts worth surfacing.

The keyboard chords are otherwise undiscoverable (the app has no Footer widget). The
Ctrl-V voice toggle is only listed when the session has a voice front-end. Caret notation
(``^Y``) keeps the legend short enough to fit a narrow terminal; the chords are bold so
they read against the dim labels.
"""
hints = ["[b]^Y[/b] copy"]
if voice:
hints.append("[b]^V[/b] voice")
hints += ["[b]^O[/b] expand", "[b]esc[/b] interrupt", "[b]^C[/b] quit"]
return f"[dim]{' · '.join(hints)}[/dim]"


def copy_note(reply: str, copier: Callable[[str], None]) -> str:
"""Copy ``reply`` to the clipboard via ``copier``, returning the transcript note to show.

Keeps the Ctrl-Y action a one-liner and handles its two non-happy paths so they can't
surprise the user: nothing has been said yet, and a headless/clipboard-less box where
``pyperclip`` raises (an unhandled raise there would tear down the whole TUI). ``copier``
is ``pyperclip.copy`` in production, injected so this unit-tests with no real clipboard.
"""
if not reply:
return "(nothing to copy yet)"
try:
copier(reply)
except pyperclip.PyperclipException:
return "(couldn't copy: no clipboard available)"
return "(copied last reply to clipboard)"


def _abbrev_home(path: Path) -> str:
"""Render ``path`` with the home directory collapsed to ``~``."""
try:
Expand All @@ -58,10 +95,12 @@ def _git_branch(start: Path) -> str | None:


def _status_text(cwd: Path, *, auto_approve: bool, voice_state: str | None = None) -> str:
"""The bottom status line: a mode badge, the working directory, git branch, and voice state.
"""The two-row bottom footer: a status line, and a dim key-legend beneath it.

``voice_state`` is ``"on"``/``"off"`` when the session has a voice front-end (so the
Ctrl-V toggle shows its effect), or ``None`` when voice isn't wired up at all.
Row one is a mode badge, the working directory, the git branch, and voice state; row two
is :func:`keyhints_text`. ``voice_state`` is ``"on"``/``"off"`` when the session has a
voice front-end (so the Ctrl-V toggle shows its effect, and the legend lists it), or
``None`` when voice isn't wired up at all.
"""
mode = "auto" if auto_approve else "manual"
badge = f"[black on #f59e0b] {mode} [/]"
Expand All @@ -73,4 +112,4 @@ def _status_text(cwd: Path, *, auto_approve: bool, voice_state: str | None = Non
# A filled/hollow dot (BMP glyphs, like the rest of the UI — no double-width emoji).
glyph, color = ("●", "#22c55e") if voice_state == "on" else ("○", "#6b7280")
parts.append(f"[{color}]{glyph} voice {voice_state}[/]")
return " ".join(parts)
return " ".join(parts) + "\n" + keyhints_text(voice=voice_state is not None)
5 changes: 4 additions & 1 deletion aai_cli/code_agent/voice_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ def _capture_voice_turn(self) -> None:
self._voice_typed = True
self.call_from_thread(self._notice_voice_off, exc.message)
return
if transcript:
# Re-check after listen(): the user may have switched to text (Ctrl-V) or interrupted
# (Escape/Ctrl-C) while this capture was blocking, in which case a turn that finalized
# in that window must not be submitted behind their back.
if transcript and self._voice_active():
self.call_from_thread(self._enter_and_submit, transcript)

def _notice_voice_off(self, detail: str) -> None:
Expand Down
152 changes: 76 additions & 76 deletions tests/__snapshots__/test_tui_snapshots/test_code_approval_modal.raw

Large diffs are not rendered by default.

Loading
Loading