Problem
The face only ever reflects four states — idle, listening, thinking, speaking — pulled from ask_stream's text-delta events. There's no signal at all for a tool call actually running (Bash, a file write, a dispatched subagent): the face reads idle or thinking the whole time real work is happening underneath.
What this adds
A fifth bus state, working, watched from content_block_start rather than text deltas — set the instant a tool_use/server_tool_use block begins, re-asserted back to thinking when text resumes. The playback side (mouth.py) no longer assumes a turn is over just because its local speech queue drained; it checks a new WarmBrain.turn_active property first, so a filler line finishing mid-tool-call doesn't prematurely flip the face back to idle.
Companion issue on the ai-visualizer side covers the actual face treatments and a background-job marker-file convention that reuses this same state: jaredrhod/ai-visualizer#4
The diff
backtalk/brain.py — the working/thinking state watch, plus the new turn_active property:
diff --git a/backtalk/brain.py b/backtalk/brain.py
index 9600786..22cd103 100644
--- a/backtalk/brain.py
+++ b/backtalk/brain.py
@@ -42,6 +42,7 @@ except ImportError: # older SDKs: nothing to silence
from backtalk.config import CFG, DISCIPLINE
from backtalk.vlog import log
+from backtalk import signals
_SENTENCE_END = re.compile(r"(?<=[.!?])\s")
@@ -82,6 +83,13 @@ class WarmBrain:
# ResultMessage — i.e. the shared message pipe may hold leftovers.
self._dirty = False
+ @property
+ def turn_active(self) -> bool:
+ """True from the moment a turn is sent until its ResultMessage
+ lands. The mouth reads this before declaring idle, so a turn
+ that goes quiet mid-tool-call doesn't read as nothing happening."""
+ return self._dirty
+
async def start(self):
mode = CFG["permission_mode"]
if mode == "default":
@@ -300,7 +308,20 @@ class WarmBrain:
t = type(msg).__name__
if t == "StreamEvent":
ev = getattr(msg, "event", {}) or {}
- if ev.get("type") == "content_block_delta":
+ if ev.get("type") == "content_block_start":
+ # Tool calls carry no text delta, so without this
+ # the face reads "idle" the whole time a background
+ # task (Bash, a file write, a dispatched subagent)
+ # is actually running. A text block starting again
+ # means the model resumed composing after a tool
+ # result came back.
+ cbt = (ev.get("content_block") or {}).get("type")
+ if cbt in ("tool_use", "server_tool_use"):
+ signals.set_state("working")
+ signals.static_start()
+ elif cbt == "text":
+ signals.set_state("thinking")
+ elif ev.get("type") == "content_block_delta":
delta = ev.get("delta", {}) or {}
if delta.get("type") == "text_delta":
buf += delta.get("text", "")
backtalk/mouth.py — don't declare idle on queue-drain unless the turn has actually ended:
diff --git a/backtalk/mouth.py b/backtalk/mouth.py
index 9cdc2f4..c505ef1 100644
--- a/backtalk/mouth.py
+++ b/backtalk/mouth.py
@@ -268,11 +268,21 @@ class Mouth:
self.ducker = Ducker() # public: PTT ducks for the USER's voice too
self._worker = threading.Thread(target=self._run, daemon=True)
self._worker.start()
+ # Late-bound (main.py sets this once the brain exists): tells the
+ # worker whether a turn is still in flight, so draining the local
+ # speech queue mid-turn doesn't get mistaken for the reply being over.
+ self._turn_active = None
@property
def speaking(self) -> bool:
return self._speaking.is_set()
+ def nothing_queued(self) -> bool:
+ """Nothing left to play right now. Used at turn-end to settle the
+ face when a tool call was the very last thing (no trailing text
+ ever arrived to trigger the worker's own idle check)."""
+ return self._q.empty() and not self._speaking.is_set()
+
def say(self, text: str):
"""Queue text (split to sentences) for speech."""
for s in split_sentences(text):
@@ -337,7 +347,13 @@ class Mouth:
# the gap between two sentences of the same reply.
signals.reply_done()
self.ducker.speech_end()
- signals.set_state("idle")
+ # Only declare idle if the brain agrees the turn is
+ # actually over. Mid-turn (a tool call about to run,
+ # or more text still coming) this queue drains too —
+ # leave the state as brain.ask_stream last set it
+ # rather than flashing idle and back.
+ if self._turn_active is None or not self._turn_active():
+ signals.set_state("idle")
backtalk/signals.py — bus-contract docstring update:
diff --git a/backtalk/signals.py b/backtalk/signals.py
index fc3a91b..576cfc8 100644
--- a/backtalk/signals.py
+++ b/backtalk/signals.py
@@ -20,10 +20,15 @@
The voice line leaves notes; faces read the notes. That one dumb trick
is the whole integration surface:
- .voice_state idle | listening | thinking | speaking
+ .voice_state idle | listening | thinking | working | speaking
.voice_waveform JSON {ts, samples: [64 floats]} while audio plays
.voice_loading_pid exists while the thinking sound is playing
+"working" is distinct from "thinking": thinking is the model composing
+a reply with nothing to show yet; working is a tool call actually
+running (a file write, a shell command, a dispatched subagent) — set
+from brain.ask_stream the moment a tool_use content block starts.
+
Written to signals_dir (default: the repo root). Visualizers built on
this contract just work.
backtalk/main.py — three small hunks: the permission-gate restores working instead of thinking when it resumes, the post-turn settle logic now covers a turn whose last event was a silent tool call, and mouth._turn_active gets wired to the brain:
diff --git a/backtalk/main.py b/backtalk/main.py
index 92731d4..9fb947f 100644
--- a/backtalk/main.py
+++ b/backtalk/main.py
@@ -273,7 +273,7 @@ def make_permission_gate(mouth):
interrupt=False)
approved = _norm_speech(answer) in _YES
# the model keeps working either way: restore the working state
- signals.set_state("thinking")
+ signals.set_state("working")
signals.static_start()
if approved:
log("[perm] approved by voice")
@@ -616,10 +616,14 @@ async def speak_reply(brain: WarmBrain, mouth: Mouth, text: str):
if batch:
mouth.say_chunk(" ".join(batch), pending)
pending = []
- if first:
- # Zero sentences yielded (brain error / empty turn): nothing
- # will ever dequeue, so nothing resets the bus — park it here.
- signals.static_stop()
+ # ask_stream only returns after its ResultMessage, so the SDK-side
+ # turn is fully over here. If nothing is left queued or playing —
+ # whether because zero sentences were ever spoken, or because the
+ # very last thing that happened was a silent tool call — nothing
+ # else is coming to reset the bus, so settle it now. Otherwise the
+ # mouth's own worker will settle it once playback actually drains.
+ signals.static_stop()
+ if mouth.nothing_queued():
signals.set_state("idle")
except asyncio.CancelledError:
@@ -667,6 +671,7 @@ async def amain():
brain = WarmBrain(model=model,
can_use_tool=make_permission_gate(mouth),
resume_id=resume_id)
+ mouth._turn_active = lambda: brain.turn_active
mode = ("hands-free listening (the talk key still works)"
if _MIC["mode"] == "open"
(That last file, main.py, also picked up an unrelated audio-flush timing fix in the same commit on my fork — left out here since it's a separate bug, not part of this feature.)
Built and running for a few days now; happy to answer questions on any of it.
Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Problem
The face only ever reflects four states —
idle,listening,thinking,speaking— pulled fromask_stream's text-delta events. There's no signal at all for a tool call actually running (Bash, a file write, a dispatched subagent): the face reads idle or thinking the whole time real work is happening underneath.What this adds
A fifth bus state,
working, watched fromcontent_block_startrather than text deltas — set the instant atool_use/server_tool_useblock begins, re-asserted back tothinkingwhen text resumes. The playback side (mouth.py) no longer assumes a turn is over just because its local speech queue drained; it checks a newWarmBrain.turn_activeproperty first, so a filler line finishing mid-tool-call doesn't prematurely flip the face back to idle.Companion issue on the
ai-visualizerside covers the actual face treatments and a background-job marker-file convention that reuses this same state: jaredrhod/ai-visualizer#4The diff
backtalk/brain.py— theworking/thinkingstate watch, plus the newturn_activeproperty:backtalk/mouth.py— don't declare idle on queue-drain unless the turn has actually ended:backtalk/signals.py— bus-contract docstring update:backtalk/main.py— three small hunks: the permission-gate restoresworkinginstead ofthinkingwhen it resumes, the post-turn settle logic now covers a turn whose last event was a silent tool call, andmouth._turn_activegets wired to the brain:(That last file,
main.py, also picked up an unrelated audio-flush timing fix in the same commit on my fork — left out here since it's a separate bug, not part of this feature.)Built and running for a few days now; happy to answer questions on any of it.
Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com