chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
This module provides the HarnessApp - the main Textual application that
|
||||
composes all UI components and integrates with the agent runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual import on, work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from .app_state import (
|
||||
BottomPanelMode,
|
||||
HarnessAppState,
|
||||
OutputEntryType,
|
||||
)
|
||||
from .components import (
|
||||
AgentModeAndHelp,
|
||||
AgentStatus,
|
||||
HarnessListSelection,
|
||||
HarnessScrollPanel,
|
||||
HarnessTextInput,
|
||||
PromptRule,
|
||||
)
|
||||
from .textual_state_driver import HarnessConsoleUXStateDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Agent, AgentSession
|
||||
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
from .commands import CommandHandler
|
||||
from .observers.base import ConsoleObserver
|
||||
|
||||
|
||||
class HarnessApp(App[None]):
|
||||
"""Main Textual application for the harness console.
|
||||
|
||||
Composes the scroll panel (conversation history), status bar (spinner, usage),
|
||||
mode/help display, and bottom panel (text input, list selection, or streaming
|
||||
indicator). Routes user input to the agent runner.
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $background;
|
||||
}
|
||||
|
||||
#scroll-panel {
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#bottom-panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#text-input-container {
|
||||
height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#list-selection-container {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#streaming-indicator {
|
||||
height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#mode-help {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#top-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#bottom-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#separator-rule {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
#text-input {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: 1;
|
||||
height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.prompt-label {
|
||||
width: 2;
|
||||
min-width: 2;
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "quit", "Quit", show=False),
|
||||
Binding("ctrl+q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
observers: list[ConsoleObserver],
|
||||
session: AgentSession | None = None,
|
||||
mode_colors: dict[str, str] | None = None,
|
||||
initial_mode: str | None = None,
|
||||
placeholder: str = "Type a message and press Enter...",
|
||||
title: str = "Harness Console",
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
command_handlers: list[CommandHandler] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the harness console application.
|
||||
|
||||
Args:
|
||||
agent: The agent to run.
|
||||
observers: List of console observers.
|
||||
session: Optional agent session.
|
||||
mode_colors: Optional mode color mapping.
|
||||
initial_mode: Initial agent mode.
|
||||
placeholder: Input placeholder text.
|
||||
title: Application title.
|
||||
max_context_window_tokens: Optional max context window tokens for usage display.
|
||||
max_output_tokens: Optional max output tokens for usage display.
|
||||
command_handlers: Optional list of command handlers. If None, auto-detected.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self._agent = agent
|
||||
self._observers = observers
|
||||
self._session = session
|
||||
self._mode_colors = mode_colors
|
||||
self._initial_mode = initial_mode
|
||||
self._placeholder = placeholder
|
||||
self._max_context_window_tokens = max_context_window_tokens
|
||||
self._max_output_tokens = max_output_tokens
|
||||
|
||||
# Build command handlers
|
||||
if command_handlers is None:
|
||||
from .commands import build_default_command_handlers
|
||||
|
||||
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
|
||||
else:
|
||||
self._command_handlers = command_handlers
|
||||
|
||||
# Compute help text from command handlers
|
||||
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
|
||||
help_text = ", ".join(help_parts) if help_parts else None
|
||||
|
||||
# State and driver
|
||||
self._app_state = HarnessAppState(
|
||||
placeholder=placeholder,
|
||||
mode_text=initial_mode,
|
||||
help_text=help_text,
|
||||
)
|
||||
self._ux_driver = HarnessConsoleUXStateDriver(
|
||||
app_state=self._app_state,
|
||||
on_state_changed=self._on_state_changed,
|
||||
mode_colors=mode_colors,
|
||||
)
|
||||
|
||||
# Agent runner (created after init)
|
||||
self._runner: HarnessAgentRunner | None = None
|
||||
|
||||
@property
|
||||
def ux_driver(self) -> HarnessConsoleUXStateDriver:
|
||||
"""Get the UX state driver."""
|
||||
return self._ux_driver
|
||||
|
||||
@property
|
||||
def runner(self) -> HarnessAgentRunner | None:
|
||||
"""Get the agent runner."""
|
||||
return self._runner
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the application layout."""
|
||||
with Vertical():
|
||||
# Main scroll panel for conversation history
|
||||
yield HarnessScrollPanel(id="scroll-panel")
|
||||
|
||||
# Blank line separating scroll content from status area
|
||||
yield Static(" ", id="separator-rule")
|
||||
|
||||
# Status bar (spinner + usage)
|
||||
yield AgentStatus(id="status-bar")
|
||||
|
||||
# Top rule (mode-colored)
|
||||
yield PromptRule(id="top-rule")
|
||||
|
||||
# Bottom panel - switches between text input, list selection, streaming
|
||||
with Container(id="bottom-panel"):
|
||||
# Text input (default)
|
||||
with Container(id="text-input-container"):
|
||||
text_input = HarnessTextInput(id="text-input")
|
||||
text_input.placeholder = self._placeholder
|
||||
yield text_input
|
||||
|
||||
# List selection (for follow-up questions)
|
||||
with Container(id="list-selection-container"):
|
||||
yield HarnessListSelection(id="list-selection")
|
||||
|
||||
# Bottom rule (mode-colored)
|
||||
yield PromptRule(id="bottom-rule")
|
||||
|
||||
# Mode and help
|
||||
yield AgentModeAndHelp(id="mode-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize after mount."""
|
||||
# Create agent runner now that everything is set up
|
||||
from .agent_runner import HarnessAgentRunner
|
||||
|
||||
self._runner = HarnessAgentRunner(
|
||||
agent=self._agent,
|
||||
observers=self._observers,
|
||||
state_driver=self._ux_driver,
|
||||
max_context_window_tokens=self._max_context_window_tokens,
|
||||
max_output_tokens=self._max_output_tokens,
|
||||
)
|
||||
|
||||
# Set initial mode
|
||||
if self._initial_mode:
|
||||
self._ux_driver.current_mode = self._initial_mode
|
||||
|
||||
# Focus the text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
# Set initial rule colors and mode display
|
||||
self._sync_mode_help()
|
||||
|
||||
# --- Event handlers ---
|
||||
|
||||
@on(HarnessTextInput.Submitted)
|
||||
def on_text_submitted(self, event: HarnessTextInput.Submitted) -> None:
|
||||
"""Handle text input submission."""
|
||||
text = event.value.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if self._app_state.pending_questions:
|
||||
# Answer the current follow-up question
|
||||
self._handle_follow_up_answer(text)
|
||||
elif self._app_state.mode == BottomPanelMode.STREAMING:
|
||||
# Input submitted while the agent is streaming — enqueue it for
|
||||
# injection into the ongoing run. Handled synchronously so it does
|
||||
# not cancel the exclusive turn worker.
|
||||
if self._runner is not None:
|
||||
self._runner.on_streaming_input(text, self._session)
|
||||
elif text.startswith("/"):
|
||||
# Try command handlers
|
||||
self._try_command_handlers(text)
|
||||
else:
|
||||
# Normal user input - run agent turn
|
||||
self._run_agent_turn(text)
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _try_command_handlers(self, text: str) -> None:
|
||||
"""Try each command handler; fall through to agent if none match."""
|
||||
session = self._session
|
||||
if session is None:
|
||||
# No session — fall through to agent turn
|
||||
self._run_agent_turn(text)
|
||||
return
|
||||
|
||||
for handler in self._command_handlers:
|
||||
if await handler.try_handle(text, session, self._ux_driver):
|
||||
# Command handled — check for shutdown/session swap signals
|
||||
self._process_command_signals()
|
||||
return
|
||||
|
||||
# No handler matched — treat as normal agent input
|
||||
self._run_agent_turn(text)
|
||||
|
||||
def _process_command_signals(self) -> None:
|
||||
"""Check and process signals set by command handlers."""
|
||||
if self._app_state.shutdown_requested:
|
||||
self.exit()
|
||||
return
|
||||
|
||||
if self._app_state.replaced_session is not None:
|
||||
self._session = self._app_state.replaced_session # type: ignore[assignment]
|
||||
self._app_state.replaced_session = None
|
||||
self._ux_driver.append_info_line("Session replaced.")
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
@on(HarnessListSelection.Selected)
|
||||
def on_list_selected(self, event: HarnessListSelection.Selected) -> None:
|
||||
"""Handle list selection."""
|
||||
self._handle_follow_up_answer(event.value)
|
||||
|
||||
# --- Agent turn ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _run_agent_turn(self, text: str) -> None:
|
||||
"""Run an agent turn in a background worker."""
|
||||
if self._runner is None:
|
||||
return
|
||||
|
||||
await self._runner.run_turn(text, session=self._session)
|
||||
|
||||
# After turn completes, check for follow-up questions
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- Follow-up question handling ---
|
||||
|
||||
@work(exclusive=True, thread=False)
|
||||
async def _handle_follow_up_answer(self, answer: str) -> None:
|
||||
"""Handle a user's answer to a follow-up question."""
|
||||
if not self._app_state.pending_questions:
|
||||
return
|
||||
|
||||
question = self._app_state.pending_questions[0]
|
||||
|
||||
# Call the continuation
|
||||
result_message = await question.continuation(answer, self._ux_driver)
|
||||
|
||||
# Add result to accumulated responses
|
||||
if result_message is not None:
|
||||
self._ux_driver.add_follow_up_response(result_message)
|
||||
|
||||
# Advance to next question
|
||||
self._ux_driver.advance_follow_up_question()
|
||||
|
||||
# If no more questions, resume the agent with accumulated responses
|
||||
if not self._app_state.pending_questions:
|
||||
responses = self._ux_driver.take_follow_up_responses()
|
||||
if responses and self._runner:
|
||||
await self._runner.start_agent_turn(responses, session=self._session)
|
||||
|
||||
self._sync_ui_from_state()
|
||||
|
||||
# --- State synchronization ---
|
||||
|
||||
def _on_state_changed(self) -> None:
|
||||
"""Called by state driver when state changes - schedule UI sync.
|
||||
|
||||
Since the agent runner uses @work(thread=False), state changes happen
|
||||
on the main event loop. We use call_later to batch updates.
|
||||
"""
|
||||
self.call_later(self._sync_ui_from_state)
|
||||
|
||||
def _sync_ui_from_state(self) -> None:
|
||||
"""Synchronize UI components with current application state."""
|
||||
state = self._app_state
|
||||
|
||||
# Update scroll panel with new entries
|
||||
self._sync_scroll_panel()
|
||||
|
||||
# Update bottom panel mode
|
||||
self._sync_bottom_panel(state.mode)
|
||||
|
||||
# Hide status bar and mode/help during list selection (matching C#)
|
||||
is_list_mode = state.mode == BottomPanelMode.LIST_SELECTION
|
||||
self._sync_chrome_visibility(not is_list_mode)
|
||||
|
||||
# Update status bar
|
||||
self._sync_status_bar()
|
||||
|
||||
# Update mode/help display
|
||||
self._sync_mode_help()
|
||||
|
||||
def _sync_scroll_panel(self) -> None:
|
||||
"""Sync the scroll panel with output entries."""
|
||||
try:
|
||||
panel = self.query_one("#scroll-panel", HarnessScrollPanel)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
entries = self._app_state.output_entries
|
||||
rendered_count = getattr(self, "_rendered_entry_count", 0)
|
||||
|
||||
if rendered_count < len(entries):
|
||||
# There are new entries to render
|
||||
for entry in entries[rendered_count:]:
|
||||
if entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(entry)
|
||||
else:
|
||||
# End any active streaming before appending other entry types
|
||||
panel.end_streaming()
|
||||
panel.append_entry(entry)
|
||||
self._rendered_entry_count = len(entries)
|
||||
elif rendered_count == len(entries) and entries:
|
||||
# Same count — check if the last entry is a streaming entry that was mutated
|
||||
last_entry = entries[-1]
|
||||
if last_entry.type == OutputEntryType.STREAMING_TEXT:
|
||||
panel.set_streaming_entry(last_entry)
|
||||
|
||||
def _sync_bottom_panel(self, mode: BottomPanelMode) -> None:
|
||||
"""Switch the bottom panel between text input, list, and streaming."""
|
||||
try:
|
||||
text_container = self.query_one("#text-input-container")
|
||||
list_container = self.query_one("#list-selection-container")
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
if mode == BottomPanelMode.TEXT_INPUT:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
# Restore the normal placeholder and focus to text input
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.placeholder = self._placeholder
|
||||
text_input.focus_input()
|
||||
except NoMatches:
|
||||
pass
|
||||
elif mode == BottomPanelMode.LIST_SELECTION:
|
||||
text_container.display = False
|
||||
list_container.display = True
|
||||
self._sync_list_selection()
|
||||
elif mode == BottomPanelMode.STREAMING:
|
||||
text_container.display = True
|
||||
list_container.display = False
|
||||
# Hint that typed input will be queued for injection into the run.
|
||||
try:
|
||||
text_input = self.query_one("#text-input", HarnessTextInput)
|
||||
text_input.placeholder = "type to queue a message for the agent…"
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _sync_list_selection(self) -> None:
|
||||
"""Sync the list selection widget with state."""
|
||||
try:
|
||||
list_widget = self.query_one("#list-selection", HarnessListSelection)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
list_widget.title = state.list_selection_title or ""
|
||||
list_widget.options = list(state.list_selection_options)
|
||||
list_widget.allow_custom_text = state.list_selection_custom_text_placeholder is not None
|
||||
|
||||
if state.list_selection_custom_text_placeholder:
|
||||
try:
|
||||
custom_input = list_widget.query_one("#custom-input", Input)
|
||||
custom_input.placeholder = state.list_selection_custom_text_placeholder
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Focus the option list so keyboard navigation works immediately
|
||||
list_widget.focus_list()
|
||||
|
||||
def _sync_status_bar(self) -> None:
|
||||
"""Sync the status bar with state."""
|
||||
try:
|
||||
status = self.query_one("#status-bar", AgentStatus)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
status.show_spinner = state.show_spinner
|
||||
status.usage_text = state.usage_text or ""
|
||||
status.queued_text = " ".join(state.queued_items)
|
||||
|
||||
def _sync_mode_help(self) -> None:
|
||||
"""Sync the mode/help display and rule colors with state."""
|
||||
try:
|
||||
mode_help = self.query_one("#mode-help", AgentModeAndHelp)
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
state = self._app_state
|
||||
mode_help.mode = state.mode_text or ""
|
||||
mode_help.mode_color = state.mode_color or "blue"
|
||||
mode_help.help_text = state.help_text or ""
|
||||
|
||||
# Sync rule colors to match mode
|
||||
color = state.mode_color or "cyan"
|
||||
try:
|
||||
top_rule = self.query_one("#top-rule", PromptRule)
|
||||
top_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
try:
|
||||
bottom_rule = self.query_one("#bottom-rule", PromptRule)
|
||||
bottom_rule.rule_color = color
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _sync_chrome_visibility(self, visible: bool) -> None:
|
||||
"""Show or hide chrome elements (status bar, mode/help).
|
||||
|
||||
During list selection mode, these are hidden to give more vertical
|
||||
space to the scroll panel and list picker.
|
||||
|
||||
Args:
|
||||
visible: Whether chrome elements should be visible.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#status-bar", AgentStatus).display = visible
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one("#mode-help", AgentModeAndHelp).display = visible
|
||||
|
||||
# --- Rendering count tracking ---
|
||||
|
||||
_rendered_entry_count: int = 0
|
||||
Reference in New Issue
Block a user