Thinking improvements.

This commit is contained in:
2026-04-03 10:40:52 -07:00
parent 2166af01be
commit 4e411f8060
10 changed files with 489 additions and 195 deletions
+2 -2
View File
@@ -149,7 +149,7 @@ agents:
prompt_file: miranda_chief_of_staff.md
provider: vastblueai_thinking # Routing and synthesis benefit from deep reasoning
model: ""
temperature: 0.6 # Qwen3 thinking-mode recommendation
temperature: 1.0 # Qwen3 thinking-mode recommendation
max_tokens: 32768 # Synthesizes the longest outputs; needs headroom
stateful: true
@@ -159,7 +159,7 @@ agents:
prompt_file: vera_auditor.md
provider: vastblueai_thinking # Careful auditing benefits from thinking
model: ""
temperature: 0.6 # Qwen3 thinking-mode recommendation
temperature: 1.0 # Qwen3 thinking-mode recommendation
max_tokens: 16384
stateful: false
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
{
"session_id": "20260403_101101",
"session_start": "2026-04-03T10:11:26",
"tasks": {
"T-20260403-001-A": {
"task_id": "T-20260403-001-A",
"directive": "Analyze Evelyn's agent prompt.",
"assigned_to": "clio",
"brief": "TASK BRIEF\nIssued by: Miranda\nTo: Clio — Director of Analysis\nTask ID: T-20260403-001\nDirective: Analyze Evelyn's agent prompt specifications for the 5 EHS Specialist Agents.\nScope:\n - Review the system prompts designed for Monitor, Tracker, Triage, Validator, and Generator agents\n - Validate alignment with security architecture (T-20260402-003)\n - Identify any gaps in regulatory compliance controls, especially for SCAQMD rule ingestion\n - Confirm the Validator Agent's role as security gate is adequately enforced\nConstraints:\n - Deadline: 2026-04-05 14:00 (2 business days)\n - Depth: Technical review sufficient to confirm or reject prompt readiness for instantiation\n - Assumptions: Evelyn's prompt documents are available in the secure repository\nDependencies: T-20260402-003 (Clio's Architecture Design); Evelyn's Agent Generation System (Evelyn)\nReturn format: Analysis memo with Go/No-Go recommendation for agent instantiation\n```\n\n---\n\n**IMPLICATIONS**\n\nWhile Clio performs this analysis:\n- Cole must hold all EHS construction tasks pending T-20260402-004 approval\n- Evelyn cannot finalize agent instantiation until prompts are validated\n\nI will update the Standing Brief when Clio's analysis returns. Please confirm this routing is acceptable, or specify if you require a different focus for this review.\n\n---\n\n**STANDING BRIEF NOTE**\n\nThis will be recorded at session close. No changes to active tasks yet — T-20260402-004 remains pending Principal Authorization, but now has an additional validation gate before Evelyn can proceed.",
"status": "complete",
"output": "",
"error": "",
"created_at": "2026-04-03T10:11:26",
"updated_at": "2026-04-03T10:13:18"
}
}
}
+77 -11
View File
@@ -526,17 +526,47 @@ All agent calls default to `stream=True`. The flow:
2. `call_async_streaming()` on `OpenAIClient` connects with `stream=True` and
yields `('thinking', text)` and `('content', text)` tuples as chunks arrive.
Thinking tokens come from `delta.reasoning_content` (Qwen3 / DeepSeek R1).
3. `stream_agent_output()` in `ui.py` consumes the async generator and renders
output live using Rich's `Live` display.
3. `stream_agent_output()` in `ui.py` consumes the async generator using a
two-phase rendering strategy (see below).
**Stream modes** (passed as `stream_mode` to `call_agent_async`):
| Mode | Used for | Behaviour |
|------|----------|-----------|
| `"deliverable"` | Miranda's routing/synthesis responses | Full panel with thinking + content |
| `"deliverable"` | Miranda's routing/synthesis responses | Two-phase thinking + content render |
| `"audit"` | Vera's audit memos | Yellow thinking identity; verdict-aware border colour |
| `"background"` | Standing brief update | Compact transient spinner; no panel |
**Two-phase rendering** (`"deliverable"` and `"audit"` modes):
The generator is consumed in two distinct, non-competing display regions to
prevent thinking text from scrolling off the top of the screen as content grows.
*Phase 1 — Thinking* (`transient=True`, `vertical_overflow="crop"`):
- A compact Live display shows the last 8 lines of thinking text with a
`+N lines earlier` overflow indicator.
- `transient=True`: the display clears from the screen when Phase 1 ends —
it does not permanently occupy lines in the scroll buffer.
- `vertical_overflow="crop"`: the panel is hard-capped at terminal height and
can never grow to push content off screen.
- The generator is iterated with manual `__anext__()` calls (not `async for`)
so that breaking out of the loop does **not** call `.aclose()` on the
generator — the same iterator is resumed in Phase 2.
*Thinking summary* (printed between phases):
After Phase 1 a single persistent line is printed to the scroll buffer:
```
▸ Miranda — thinking complete [4,821 chars · 47 lines] /thinking to expand
```
This line is always visible in the terminal's scrollback history.
*Phase 2 — Content* (`transient=False`, `vertical_overflow="visible"`):
- A separate Live display picks up the same iterator and streams content.
- The final Markdown panel (with verdict border in audit mode) is rendered
inside the Live block before it exits, avoiding any erase/reprint flash.
- Content is the only thing in this display — thinking can never compete
for vertical space here.
`_last_stream_rendered` is set to `True` after `stream_agent_output` completes,
causing the subsequent `print_deliverable()` / `print_audit_memo()` to become
no-ops (avoiding double rendering).
@@ -573,7 +603,10 @@ that adding or recoloring an agent in config is reflected automatically.
| Function | Description |
|----------|-------------|
| `stream_agent_output(agent_name, gen, mode)` | Live streaming render with thinking panel |
| `get_directive()` | Interactive `Principal ` prompt with inline slash-command completion |
| `set_completions(entries)` | Register `(name, description)` pairs for the completion dropdown |
| `stream_agent_output(agent_name, gen, mode)` | Two-phase live streaming render |
| `_build_thinking_panel(text, agent, mode)` | Thinking panel renderable (last 8 lines, height-capped) |
| `print_agent_panel(agent_name, content)` | Static Markdown panel for an agent |
| `print_deliverable(content, task_id)` | Orchestrator's synthesized output |
| `print_audit_memo(content, task_id)` | Vera's memo with verdict-based border |
@@ -584,15 +617,46 @@ that adding or recoloring an agent in config is reflected automatically.
| `print_session_footer(count)` | Closing rule |
| `print_status(...)` | `/status` command output |
| `print_agents(configs, provider)` | `/agents` roster table |
| `get_directive()` | Styled `Principal ` input prompt |
| `get_last_thinking()` | Returns stored thinking from last response |
### Slash-command completion
`get_directive()` uses a `prompt_toolkit` `PromptSession` to provide an
inline vertical dropdown as the user types at the `Principal ` prompt.
**Behaviour:**
- Typing `/` immediately opens a dropdown showing the top 5 matching commands
with their descriptions alongside each name.
- Each additional character narrows the list in real time (`complete_while_typing=True`).
- Arrow keys navigate the list; Enter or Tab accepts a selection; Escape dismisses.
- Up/Down arrows outside the dropdown navigate the session's input history
(backed by `InMemoryHistory` — persists for the lifetime of the process).
- No dropdown appears for non-slash input — the completer only activates when
the text starts with `/`.
**Wiring:**
`orchestrator.py` calls `ui.set_completions([(name, desc), ...])` once at
module import time, after all `@command` decorators have executed. The list is
built from the `COMMANDS` registry so any new `@command` is automatically
included with no extra wiring.
`_completions` is a module-level `list[tuple[str, str]]` sorted alphabetically.
The `_SlashCompleter` class reads it at query time, so `set_completions` can be
called at any point before the user types.
**Fallback:** if `prompt_toolkit` is not installed, `get_directive()` falls
back to a plain styled `input()` with no completion.
### Thinking panel behaviour
During the thinking phase a dim panel shows a rolling 300-character window
of the thinking text with an overflow indicator (`+N chars earlier`) when
it exceeds the window. When the first content token arrives the panel
collapses to a single summary line: `▸ Thinking [N chars · L lines]`.
The thinking display is Phase 1 of the two-phase render (see Section 9). It
shows the **last 8 lines** of thinking text with a `+N lines earlier` overflow
indicator when earlier lines have been clipped. The panel is `transient` and
`crop`-overflow so it never grows beyond terminal height and clears cleanly
when content begins.
After Phase 1, a persistent one-line summary is printed to the scroll buffer:
`▸ Agent — thinking complete [N chars · L lines] /thinking to expand`.
### Audit verdict border
@@ -857,5 +921,7 @@ async def cmd_mycommand(session: Session, args: str) -> bool:
return True
```
Commands self-register at import time via `COMMANDS` list; no other
wiring needed.
Commands self-register at import time via the `COMMANDS` list. The
`ui.set_completions(...)` call that follows `_dispatch_command` rebuilds the
completion list from `COMMANDS`, so the new command and all its aliases
automatically appear in the `Principal ` dropdown — no extra wiring needed.
+4 -8
View File
@@ -1,10 +1,7 @@
Principal, the Standing Brief has been updated to Version 3 to reflect the closure of Session 1 and the completion of Task T-20260402-001. Below is the finalized document ready for save.
```markdown
# Standing Brief
**Maintained by:** Miranda — Chief of Staff
**For:** Principal
**Version:** 32026-04-02
**Version:** 1[YYYY-MM-DD]
---
@@ -47,7 +44,7 @@ Miranda will surface these at the start of each session if unresolved.
Completed tasks from recent sessions that may be relevant to ongoing work.
Items older than 30 days or with no ongoing relevance are moved to the Archive.
* **T-20260402-001** — Identity inquiry (Direct response by Miranda).
*No recent completions.*
---
@@ -64,7 +61,7 @@ Questions identified but not yet formally tasked, or ongoing background inquirie
Status of recent Vera reviews. Full audit memos go directly to the Principal —
this section tracks coverage only.
* **T-20260402-001** — Cleared by Vera.
*No audit items.*
---
@@ -99,7 +96,7 @@ Not a comprehensive archive — only what is currently load-bearing for decision
A brief record of each session. Most recent at the top.
Keep only the last 10 sessions; archive older entries.
* **2026-04-02** — Session 1. Directive: Identity inquiry. Outcome: Completed. Task ID: T-20260402-001.
*No sessions recorded yet.*
---
@@ -115,4 +112,3 @@ The archive is not loaded into session context by default — request it explici
*End of Standing Brief*
*Next update due: close of next session*
```
Binary file not shown.
+10 -13
View File
@@ -832,13 +832,9 @@ async def cmd_reload(session: Session, args: str) -> bool:
return True
@command(["/thinking"], "Show the thinking output from the last response")
@command(["/thinking"], "Browse thinking output history for this session")
async def cmd_thinking(session: Session, args: str) -> bool:
last = ui.get_last_thinking()
if not last:
ui.print_system("No thinking output recorded — thinking mode may not be active.")
else:
ui.print_thinking_expansion(last)
await ui.show_thinking_picker()
return True
@@ -897,6 +893,11 @@ def _dispatch_command(name: str):
return None
# Register all slash command names for tab completion.
# This runs once at import time, after all @command decorators have executed.
ui.set_completions([(name, desc) for names, desc, _ in COMMANDS for name in names])
# ---------------------------------------------------------------------------
# Direct Agent Session
# Opens an interactive REPL with one named agent.
@@ -920,7 +921,7 @@ async def direct_agent_session(agent_name: str, dry_run: bool = False):
while True:
try:
user_input = ui.get_directive()
user_input = await ui.get_directive()
except (EOFError, KeyboardInterrupt):
break
@@ -928,11 +929,7 @@ async def direct_agent_session(agent_name: str, dry_run: bool = False):
break
if user_input.lower() == "/thinking":
last = ui.get_last_thinking()
if not last:
ui.print_system("No thinking output recorded — thinking mode may not be active.")
else:
ui.print_thinking_expansion(last)
await ui.show_thinking_picker()
continue
response = await call_agent_async(agent, user_input, dry_run=dry_run, stream=True)
@@ -955,7 +952,7 @@ async def principal_session(dry_run: bool = False):
while True:
try:
raw = ui.get_directive()
raw = await ui.get_directive()
except (EOFError, KeyboardInterrupt):
ui.console.print()
break
+1
View File
@@ -12,6 +12,7 @@ pyyaml>=6.0.0 # reads config/agents.yaml
ruamel.yaml>=0.18 # comment-preserving YAML round-trips (tools.py)
openai>=1.50.0 # covers openai, openai_compatible, lmstudio, llamacpp
rich>=13.0.0 # terminal UI (Iris)
prompt_toolkit>=3.0.0 # interactive input with inline slash-command completion
# ---------------------------------------------------------------------------
# Provider SDKs — install only if using that provider
+339 -161
View File
@@ -22,6 +22,17 @@ from contextlib import contextmanager
from datetime import datetime
import json as _json
try:
from prompt_toolkit import PromptSession as _PromptSession
from prompt_toolkit.completion import Completer as _PTCompleter, Completion as _PTCompletion
from prompt_toolkit.formatted_text import FormattedText as _FormattedText
from prompt_toolkit.history import InMemoryHistory as _InMemoryHistory
from prompt_toolkit.shortcuts import CompleteStyle as _CompleteStyle, radiolist_dialog as _radiolist_dialog
from prompt_toolkit.styles import Style as _PTStyle
_HAS_PROMPT_TOOLKIT = True
except ImportError:
_HAS_PROMPT_TOOLKIT = False
import config as _config
from rich.console import Console, Group
@@ -77,9 +88,96 @@ def _build_theme() -> Theme:
console = Console(theme=_build_theme(), highlight=False)
_last_thinking: str = "" # Stored from most recent streamed response
_last_stream_rendered: bool = False # True when stream_agent_output rendered the final panel
# Thinking history — most recent appended last, capped at _THINKING_MAX entries.
# Each entry: {agent, content, n_chars, n_lines, timestamp, index}
_thinking_history: list[dict] = []
_THINKING_MAX = 20
def _record_thinking(agent_name: str, content: str) -> None:
"""Append a thinking record to the history, trimming to _THINKING_MAX."""
if not content:
return
_thinking_history.append({
"agent": agent_name,
"content": content,
"n_chars": len(content),
"n_lines": content.count("\n") + 1,
"timestamp": datetime.now().strftime("%H:%M"),
"index": len(_thinking_history) + 1,
})
if len(_thinking_history) > _THINKING_MAX:
del _thinking_history[:-_THINKING_MAX]
# Slash-command completions: list of (command_name, description) pairs.
_completions: list[tuple[str, str]] = []
# ---------------------------------------------------------------------------
# Inline slash-command completion (prompt_toolkit)
# ---------------------------------------------------------------------------
if _HAS_PROMPT_TOOLKIT:
class _SlashCompleter(_PTCompleter):
"""
Yields up to 5 matching slash commands as the user types.
Only activates when the current input starts with '/'.
Each completion shows the command name and its description.
"""
_MAX = 5
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
return
count = 0
for word, desc in _completions:
if word.startswith(text):
yield _PTCompletion(
word,
start_position=-len(text),
display=word,
display_meta=desc,
)
count += 1
if count >= self._MAX:
break
_PT_STYLE = _PTStyle.from_dict({
# Prompt text
"prompt": "bold #ffffff",
# Completion dropdown — unselected
"completion-menu.completion": "bg:#1e1e1e #aaaaaa",
"completion-menu.meta.completion": "bg:#1e1e1e #555555",
# Completion dropdown — selected row
"completion-menu.completion.current": "bg:#005f87 bold #ffffff",
"completion-menu.meta.completion.current": "bg:#005f87 #bbbbbb",
# Scrollbar
"scrollbar.background": "bg:#1e1e1e",
"scrollbar.button": "bg:#4e4e4e",
})
_pt_session: _PromptSession | None = _PromptSession(
history=_InMemoryHistory(),
completer=_SlashCompleter(),
complete_while_typing=True, # popup appears as the user types '/'
complete_style=_CompleteStyle.COLUMN,
style=_PT_STYLE,
)
else:
_pt_session = None
def set_completions(entries: list[tuple[str, str]]) -> None:
"""
Register (command_name, description) pairs for inline completion.
Call once at startup after all slash commands are defined.
"""
global _completions
_completions = sorted(entries, key=lambda e: e[0])
# ---------------------------------------------------------------------------
# Agent visual config
@@ -133,10 +231,31 @@ def print_session_footer(task_count: int):
# Input prompt
# ---------------------------------------------------------------------------
def get_directive() -> str:
"""Styled input prompt for the Principal."""
async def get_directive() -> str:
"""
Styled input prompt for the Principal.
When prompt_toolkit is available (the default):
- An inline vertical dropdown appears as soon as the user types '/'.
- Up to 5 matching commands are shown with their descriptions.
- Arrow keys navigate the list; Enter or Tab accepts a selection.
- Up/Down arrow keys also navigate input history within the session.
Uses prompt_async() so it cooperates with the running asyncio event loop
instead of trying to nest a second one (which raises RuntimeError).
Falls back to plain input() if prompt_toolkit is not installed.
"""
console.print()
return console.input("[bold white]Principal [/bold white] ").strip()
if _pt_session is not None:
return (await _pt_session.prompt_async(
_FormattedText([("class:prompt", "Principal ")]),
)).strip()
# Fallback: plain styled prompt with no completion.
console.print("[bold white]Principal [/bold white] ", end="")
if hasattr(console.file, "flush"):
console.file.flush()
return input().strip()
# ---------------------------------------------------------------------------
# System / status messages
@@ -451,47 +570,41 @@ def _thinking_panel_title(agent_name: str, mode: str) -> str:
return f"[{color} dim]Thinking…[/{color} dim]"
def _build_thinking_renderable(
thinking_text: str,
active: bool,
agent_name: str,
mode: str,
window: int = 300,
) -> object:
def _build_thinking_panel(thinking_text: str, agent_name: str, mode: str,
visible_lines: int = 8) -> object:
"""
Build the thinking panel (active) or collapsed summary line (done).
Build the live thinking panel for Phase 1 streaming.
Active: dim panel with a rolling window of the last `window` chars,
plus an overflow indicator when earlier content has scrolled off.
Collapsed: single dim line — ' ▸ Thinking (N chars, L lines)'.
Shows the last `visible_lines` lines of thinking so the panel never
grows beyond a predictable height (safe with vertical_overflow="crop").
An overflow indicator shows how many earlier lines have scrolled off.
"""
if not thinking_text:
return None
if active:
overflow = len(thinking_text) - window
if overflow > 0:
preview = thinking_text[-window:]
header = Text(f" +{overflow} chars earlier\n", style="dim italic")
body = Text(preview, style="dim")
content = Group(header, body)
else:
content = Text(thinking_text, style="dim")
return Panel(
content,
title=_thinking_panel_title(agent_name, mode),
border_style="dim",
padding=(0, 1),
box=box.SIMPLE,
)
else:
lines = thinking_text.count("\n") + 1
return Text(
f" ▸ Thinking [{len(thinking_text):,} chars · {lines} lines]",
f" [{_agent_color(agent_name)} dim]{_agent_display_title(agent_name)} thinking…"
f"[/{_agent_color(agent_name)} dim]",
style="dim",
)
lines = thinking_text.splitlines()
hidden = max(0, len(lines) - visible_lines)
visible = lines[-visible_lines:] if hidden else lines
items = []
if hidden:
items.append(Text(f" +{hidden} line{'s' if hidden != 1 else ''} earlier\n",
style="dim italic"))
items.append(Text("\n".join(visible), style="dim"))
content = Group(*items) if len(items) > 1 else items[0]
return Panel(
content,
title=_thinking_panel_title(agent_name, mode),
border_style="dim",
padding=(0, 1),
box=box.SIMPLE,
)
async def stream_agent_output(
agent_name: str,
@@ -502,47 +615,41 @@ async def stream_agent_output(
Consume an async generator of ('thinking'|'content', text) chunks and
render them live in the terminal.
Two-phase rendering
-------------------
Phase 1 — Thinking (transient, cropped to terminal height):
A compact Live panel streams thinking text showing the last 8 lines.
It is transient — it clears from the screen when Phase 1 ends.
A persistent one-line summary is then printed to the scroll buffer:
"▸ Agent — thinking complete [N chars · L lines] /thinking to expand"
Phase 2 — Content (non-transient, persists in scroll buffer):
A separate Live display streams the content and renders the final
Markdown panel inside the Live block before exiting (no flash).
This keeps thinking and content in separate, non-competing display
regions. Thinking never pushes content off-screen, and the summary
line is always visible in the scroll buffer.
mode values
-----------
"deliverable" Normal agent response — thinking panel + full styled panel.
The Live display is NOT transient: the final Markdown panel
is rendered inside the Live block, so there is no erase/
reprint flash. Sets _last_stream_rendered = True so that
the caller's print_deliverable / print_agent_panel becomes
a no-op.
"audit" Like "deliverable" but uses Vera's yellow identity cues
during the thinking phase. Sets _last_stream_rendered so
print_audit_memo is a no-op (the verdict-border panel is
rendered here instead).
"background" Housekeeping call (e.g. standing-brief update). A compact
transient progress line is shown; no full panel is printed.
_last_stream_rendered is set so the caller's print_* is a
no-op.
In all modes the full content string is returned and _last_thinking is
updated so /thinking works.
Thinking tokens stream inside a dim panel. When the first content token
arrives the panel collapses to a single dim summary line and content begins
streaming in the agent's normal styled panel.
The full thinking text is stored in _last_thinking and can be retrieved
with get_last_thinking() (exposed to the /thinking command).
"deliverable" Normal agent response.
"audit" Like deliverable but with Vera's identity and verdict border.
"background" Housekeeping — compact transient spinner only, no panel.
In all modes the thinking content is recorded via _record_thinking() so
the /thinking picker has a full history.
Returns the full content string.
"""
global _last_thinking, _last_stream_rendered
global _last_stream_rendered
color = _agent_color(agent_name)
title_text = _agent_display_title(agent_name)
# ── Background mode: compact transient progress line ────────────────────
# ── Background mode ──────────────────────────────────────────────────────
if mode == "background":
thinking_parts: list[str] = []
content_parts: list[str] = []
with Progress(
SpinnerColumn(style="dim"),
TextColumn("[dim]Updating standing brief…[/dim]"),
@@ -556,20 +663,16 @@ async def stream_agent_output(
thinking_parts.append(text)
elif chunk_type == "content":
content_parts.append(text)
_last_thinking = "".join(thinking_parts)
_record_thinking(agent_name, "".join(thinking_parts))
_last_stream_rendered = True
return "".join(content_parts)
# ── Deliverable / audit modes ────────────────────────────────────────────
# ── Deliverable / audit modes ────────────────────────────────────────────
thinking_parts: list[str] = []
content_parts: list[str] = []
phase = "start" # "start" | "thinking" | "content"
def _build_final_panel(content_text: str) -> Panel:
"""Render the agent's finished output as a polished Markdown panel."""
if mode == "audit":
# Re-derive verdict border from completed content
aud_name = _config.AUDITOR_AGENT
aud_title = _config.agent_title(aud_name)
aud_color = _agent_color(aud_name)
@@ -577,120 +680,127 @@ async def stream_agent_output(
for line in content_text.splitlines():
upper = line.upper()
if "VERDICT:" in upper:
if "REJECT" in upper:
verdict_style = "reject"
elif "FLAG" in upper:
verdict_style = "flag"
elif "PASS WITH NOTES" in upper:
verdict_style = "warning"
elif "PASS" in upper:
verdict_style = "pass"
if "REJECT" in upper: verdict_style = "reject"
elif "FLAG" in upper: verdict_style = "flag"
elif "PASS WITH NOTES" in upper: verdict_style = "warning"
elif "PASS" in upper: verdict_style = "pass"
break
return Panel(
Markdown(content_text),
title=f"[{aud_color}] {aud_name.capitalize()}{aud_title} [/{aud_color}]",
border_style=verdict_style if verdict_style != "dim" else aud_color,
padding=(1, 2),
box=box.ROUNDED,
padding=(1, 2), box=box.ROUNDED,
)
else:
return Panel(
Markdown(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2),
box=box.ROUNDED,
)
def _renderable(final: bool = False):
"""
Build the live renderable for the current stream state.
final=True: replace plain Text content with Markdown and apply the
verdict-aware border so the Live display ends in its final polished
form. This avoids a transient erase + reprint flash.
"""
thinking_text = "".join(thinking_parts)
content_text = "".join(content_parts)
items: list = []
# ── Agent identity line (shown while only thinking, not yet content) ──
if phase in ("start", "thinking") and not content_text:
items.append(Text(
f" {title_text}",
style=f"dim {color}",
))
# ── Thinking panel / collapsed summary ──────────────────────────────
thinking_active = (phase == "thinking")
think_renderable = _build_thinking_renderable(
thinking_text, thinking_active, agent_name, mode
return Panel(
Markdown(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2), box=box.ROUNDED,
)
if think_renderable is not None:
items.append(think_renderable)
# ── Content panel ────────────────────────────────────────────────────
if content_text or phase == "content":
if final and content_text:
items.append(_build_final_panel(content_text))
else:
items.append(Panel(
Text(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2),
box=box.ROUNDED,
))
if not items:
# Nothing yet — show a faint connecting indicator
return Text(
f" [{_agent_color(agent_name)} dim]{title_text} …[/{_agent_color(agent_name)} dim]",
style="dim",
)
return Group(*items) if len(items) > 1 else items[0]
console.print()
# transient=False: the Live display persists — we render the final panel
# inside it before stopping, so there is no erase/reprint cycle.
# ── Phase 1: Thinking — transient, height-capped Live display ────────────
# We use manual __anext__() instead of `async for` so that breaking out of
# the loop does NOT call aclose() on the generator. The same iterator is
# then reused in Phase 2 to continue from where we left off.
aiter = stream_gen.__aiter__()
exhausted = False
with Live(
_renderable(),
_build_thinking_panel("", agent_name, mode),
console=console,
refresh_per_second=15,
transient=True, # clears from screen when the block exits
vertical_overflow="crop", # never grows beyond terminal height
) as live:
while True:
try:
chunk_type, text = await aiter.__anext__()
except StopAsyncIteration:
exhausted = True
break
if chunk_type == "thinking":
thinking_parts.append(text)
live.update(_build_thinking_panel(
"".join(thinking_parts), agent_name, mode
))
elif chunk_type == "content":
content_parts.append(text)
break # hand off to Phase 2; generator is NOT closed
# Print a persistent collapsed summary when thinking was present.
# This line lives in the terminal's scroll buffer — always scrollable.
if thinking_parts:
full_thinking = "".join(thinking_parts)
n_chars = len(full_thinking)
n_lines = full_thinking.count("\n") + 1
id_color = _agent_color(_config.AUDITOR_AGENT) if mode == "audit" else color
console.print(Text.assemble(
("", "dim"),
(title_text, f"dim {id_color}"),
(" thinking complete ", "dim"),
(f"[{n_chars:,} chars · {n_lines} lines]", "dim"),
(" /thinking to expand", "dim italic"),
))
# Edge case: generator exhausted with no content (thinking-only response)
if exhausted:
_record_thinking(agent_name, "".join(thinking_parts))
_last_stream_rendered = True
return ""
# ── Phase 2: Content — non-transient, persists in scroll buffer ──────────
def _content_renderable(final: bool = False) -> object:
content_text = "".join(content_parts)
if not content_text:
return Text(
f" [{color} dim]{title_text} …[/{color} dim]",
style="dim",
)
if final:
return _build_final_panel(content_text)
return Panel(
Text(content_text),
title=f"[{color}] {title_text} [/{color}]",
border_style=color,
padding=(1, 2), box=box.ROUNDED,
)
with Live(
_content_renderable(),
console=console,
refresh_per_second=15,
transient=False,
vertical_overflow="visible",
) as live:
async for chunk_type, text in stream_gen:
if chunk_type == "thinking":
if phase == "start":
phase = "thinking"
thinking_parts.append(text)
elif chunk_type == "content":
if phase in ("start", "thinking"):
phase = "content"
async for chunk_type, text in aiter:
if chunk_type == "content":
content_parts.append(text)
live.update(_renderable())
live.update(_content_renderable())
live.update(_content_renderable(final=True))
# Final update: replace plain Text with formatted Markdown panel
# (and apply verdict border for audit mode) before the Live context
# exits and the display freezes in place.
live.update(_renderable(final=True))
_last_thinking = "".join(thinking_parts)
_record_thinking(agent_name, "".join(thinking_parts))
_last_stream_rendered = True
return "".join(content_parts)
def get_last_thinking() -> str:
"""Return the thinking text captured from the most recent streamed response."""
return _last_thinking
"""Return the thinking text from the most recent response (empty string if none)."""
return _thinking_history[-1]["content"] if _thinking_history else ""
def print_thinking_expansion(thinking_text: str):
"""Render the stored thinking output in a dim panel."""
def _display_thinking_entry(entry: dict) -> None:
"""Render one thinking history entry in a dim panel."""
title_text = _agent_display_title(entry["agent"])
panel = Panel(
Text(thinking_text, style="dim"),
title="[dim] Thinking — last response [/dim]",
Text(entry["content"], style="dim"),
title=(
f"[dim] {title_text} — thinking "
f"#{entry['index']} "
f"[{entry['n_chars']:,} chars · {entry['n_lines']} lines] [/dim]"
),
subtitle=f"[dim]{entry['timestamp']}[/dim]",
border_style="dim",
padding=(1, 2),
box=box.ROUNDED,
@@ -699,6 +809,74 @@ def print_thinking_expansion(thinking_text: str):
console.print(panel)
async def show_thinking_picker() -> None:
"""
Browse the thinking history for this session.
- 0 entries : prints a system notice.
- 1 entry : displays it directly.
- 2+ entries: opens an interactive radiolist dialog (prompt_toolkit) so the
user can navigate with arrow keys and press Enter to view an
entry. Falls back to a numbered text list + input() prompt
when prompt_toolkit is unavailable.
"""
if not _thinking_history:
print_system("No thinking output recorded — thinking mode may not be active.")
return
if len(_thinking_history) == 1:
_display_thinking_entry(_thinking_history[0])
return
if _HAS_PROMPT_TOOLKIT:
# Show most-recent-first in the dialog.
values = [
(
entry,
(
f"#{entry['index']} "
f"{entry['agent'].capitalize()} "
f"[{entry['n_chars']:,} chars · {entry['n_lines']} lines]"
f" {entry['timestamp']}"
),
)
for entry in reversed(_thinking_history)
]
selected = await _radiolist_dialog(
title="Thinking History",
text=f"Select a thinking output to view ({len(_thinking_history)} stored):",
values=values,
style=_PT_STYLE,
).run_async()
if selected is not None:
_display_thinking_entry(selected)
else:
# Fallback: numbered list + input prompt.
console.print()
console.rule("[dim]Thinking History[/dim]", style="dim")
console.print()
for entry in reversed(_thinking_history):
console.print(
f" [dim]{entry['index']:>2}.[/dim] "
f"[white]{entry['agent'].capitalize()}[/white] "
f"[dim]{entry['n_chars']:,} chars · {entry['n_lines']} lines"
f" {entry['timestamp']}[/dim]"
)
console.print()
try:
choice = input(" Enter number to view (or Enter to cancel): ").strip()
if choice.isdigit():
match = next(
(e for e in _thinking_history if e["index"] == int(choice)), None
)
if match:
_display_thinking_entry(match)
else:
print_system(f"No thinking output #{choice}.")
except (EOFError, KeyboardInterrupt):
pass
# ---------------------------------------------------------------------------
# Dry-run callout
# ---------------------------------------------------------------------------