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,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""UI components for the harness console.
|
||||
|
||||
This module provides Textual widgets for building the harness console UI,
|
||||
including status displays, input fields, choice selectors, and scrolling panels.
|
||||
"""
|
||||
|
||||
from .agent_status import AgentStatus
|
||||
from .list_selection import HarnessListSelection
|
||||
from .mode_help import AgentModeAndHelp
|
||||
from .prompt_rule import PromptRule
|
||||
from .scroll_panel import HarnessScrollPanel
|
||||
from .text_input import HarnessTextInput
|
||||
|
||||
__all__ = [
|
||||
"AgentStatus",
|
||||
"AgentModeAndHelp",
|
||||
"HarnessListSelection",
|
||||
"PromptRule",
|
||||
"HarnessScrollPanel",
|
||||
"HarnessTextInput",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent status widget with spinner animation and usage statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentStatus(Static):
|
||||
"""Agent status bar with animated spinner and token usage display.
|
||||
|
||||
Displays an animated braille pattern spinner when the agent is active,
|
||||
along with token usage statistics. The component automatically updates
|
||||
the spinner animation at ~10fps for smooth visual feedback.
|
||||
|
||||
Attributes:
|
||||
show_spinner: Whether to display the animated spinner.
|
||||
usage_text: Token usage text to display (e.g., "1.2K in / 856 out").
|
||||
"""
|
||||
|
||||
# Braille pattern spinner frames for smooth animation
|
||||
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
show_spinner: reactive[bool] = reactive(False)
|
||||
usage_text: reactive[str] = reactive("")
|
||||
queued_text: reactive[str] = reactive("")
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the agent status widget."""
|
||||
super().__init__(**kwargs)
|
||||
self._spinner_index = 0
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Start the spinner animation timer when the widget is mounted."""
|
||||
# Update spinner at ~10fps (every 0.1 seconds)
|
||||
self.set_interval(0.1, self._advance_spinner)
|
||||
|
||||
def _advance_spinner(self) -> None:
|
||||
"""Advance the spinner to the next frame."""
|
||||
if self.show_spinner:
|
||||
self._spinner_index = (self._spinner_index + 1) % len(self.SPINNER_FRAMES)
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the status bar with spinner and usage text.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for spinner and usage display.
|
||||
"""
|
||||
if not self.show_spinner and not self.usage_text and not self.queued_text:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
if self.show_spinner:
|
||||
frame = self.SPINNER_FRAMES[self._spinner_index]
|
||||
parts.append(f"[cyan]{frame}[/cyan]")
|
||||
else:
|
||||
# Keep consistent spacing when spinner is off
|
||||
parts.append(" ")
|
||||
|
||||
if self.usage_text:
|
||||
parts.append(f"[dim]{self.usage_text}[/dim]")
|
||||
|
||||
if self.queued_text:
|
||||
parts.append(f"[dim]{self.queued_text}[/dim]")
|
||||
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,269 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""List selection widget with optional custom text input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container
|
||||
from textual.css.query import NoMatches
|
||||
from textual.events import Key
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
class HarnessListSelection(Widget):
|
||||
"""List selection widget with numbered choices and optional custom text input.
|
||||
|
||||
Displays a title, a list of numbered choices that can be selected via
|
||||
keyboard navigation or number keys (1-9), and an optional custom text
|
||||
input field at the bottom.
|
||||
|
||||
All child nodes (title label, option list, custom input) are always
|
||||
present in the DOM; visibility is toggled via reactive watchers.
|
||||
|
||||
Navigation:
|
||||
- Down arrow on last list item moves focus to the custom text input
|
||||
- Up arrow on the custom text input moves focus back to the option list
|
||||
- When custom input has focus, the option list highlight is cleared
|
||||
|
||||
Attributes:
|
||||
title: The title text displayed above the options.
|
||||
options: List of option strings to display.
|
||||
allow_custom_text: Whether to show a custom text input field.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
HarnessListSelection {
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
}
|
||||
|
||||
HarnessListSelection .list-selection-container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
HarnessListSelection #selection-title {
|
||||
height: auto;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
padding: 0 0 0 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #option-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
margin-top: 0;
|
||||
border: tall transparent;
|
||||
}
|
||||
|
||||
HarnessListSelection #custom-input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("1", "select_option(0)", "Select option 1", show=False),
|
||||
Binding("2", "select_option(1)", "Select option 2", show=False),
|
||||
Binding("3", "select_option(2)", "Select option 3", show=False),
|
||||
Binding("4", "select_option(3)", "Select option 4", show=False),
|
||||
Binding("5", "select_option(4)", "Select option 5", show=False),
|
||||
Binding("6", "select_option(5)", "Select option 6", show=False),
|
||||
Binding("7", "select_option(6)", "Select option 7", show=False),
|
||||
Binding("8", "select_option(7)", "Select option 8", show=False),
|
||||
Binding("9", "select_option(8)", "Select option 9", show=False),
|
||||
]
|
||||
|
||||
title: reactive[str] = reactive("")
|
||||
options: reactive[list[str]] = reactive(list, always_update=True)
|
||||
allow_custom_text: reactive[bool] = reactive(False)
|
||||
|
||||
class Selected(Message):
|
||||
"""Message sent when an option is selected.
|
||||
|
||||
Attributes:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Selected message.
|
||||
|
||||
Args:
|
||||
value: The selected option text or custom text.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the widget — all nodes are always present.
|
||||
|
||||
Yields:
|
||||
Title label (hidden if empty), option list, custom input (hidden by default).
|
||||
"""
|
||||
with Container(classes="list-selection-container"):
|
||||
yield Label("", id="selection-title")
|
||||
yield OptionList(id="option-list")
|
||||
yield Input(
|
||||
placeholder="Or type a custom response...",
|
||||
id="custom-input",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Configure initial visibility after mount."""
|
||||
title_label = self.query_one("#selection-title", Label)
|
||||
title_label.display = bool(self.title)
|
||||
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = self.allow_custom_text
|
||||
|
||||
self._update_options()
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
"""Handle key navigation between option list and custom input.
|
||||
|
||||
Args:
|
||||
event: The key event.
|
||||
"""
|
||||
if not self.allow_custom_text:
|
||||
return
|
||||
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
|
||||
# Down arrow on last item → move to custom input
|
||||
if event.key == "down" and option_list.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0 and option_list.highlighted == last_index:
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
custom_input.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
# Up arrow on custom input → move back to option list (last item)
|
||||
elif event.key == "up" and custom_input.has_focus:
|
||||
last_index = option_list.option_count - 1
|
||||
if last_index >= 0:
|
||||
option_list.highlighted = last_index
|
||||
option_list.focus()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
@on(Input.Changed, "#custom-input")
|
||||
def on_custom_input_focused_or_changed(self, event: Input.Changed) -> None:
|
||||
"""Clear option list highlight when user is typing in custom input.
|
||||
|
||||
Args:
|
||||
event: The input changed event.
|
||||
"""
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.highlighted = None # type: ignore[assignment]
|
||||
|
||||
def watch_title(self, new_title: str) -> None:
|
||||
"""Update the title label when the title changes.
|
||||
|
||||
Args:
|
||||
new_title: The new title text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#selection-title", Label)
|
||||
label.update(new_title)
|
||||
label.display = bool(new_title)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def watch_options(self, new_options: list[str]) -> None:
|
||||
"""Update the option list when options change.
|
||||
|
||||
Args:
|
||||
new_options: The new list of options.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(NoMatches):
|
||||
self._update_options()
|
||||
|
||||
def watch_allow_custom_text(self, allow: bool) -> None:
|
||||
"""Show/hide the custom input field.
|
||||
|
||||
Args:
|
||||
allow: Whether to show the custom text input.
|
||||
"""
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.display = allow
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def _update_options(self) -> None:
|
||||
"""Update the OptionList with numbered options."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
for i, option_text in enumerate(self.options):
|
||||
display_text = f"{i + 1}. {option_text}" if i < 9 else f" {option_text}"
|
||||
option_list.add_option(Option(display_text, id=str(i)))
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
@on(OptionList.OptionSelected)
|
||||
def on_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Handle option selection from the list.
|
||||
|
||||
Args:
|
||||
event: The OptionList.OptionSelected event.
|
||||
"""
|
||||
option_index = int(event.option.id or "0")
|
||||
if 0 <= option_index < len(self.options):
|
||||
selected_value = self.options[option_index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle custom text input submission.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
if self.allow_custom_text and event.value:
|
||||
self.post_message(self.Selected(event.value))
|
||||
event.input.clear()
|
||||
|
||||
def action_select_option(self, index: int) -> None:
|
||||
"""Select an option by index (0-based).
|
||||
|
||||
Args:
|
||||
index: The option index to select.
|
||||
"""
|
||||
if 0 <= index < len(self.options):
|
||||
selected_value = self.options[index]
|
||||
self.post_message(self.Selected(selected_value))
|
||||
|
||||
def focus_list(self) -> None:
|
||||
"""Focus the option list."""
|
||||
try:
|
||||
option_list = self.query_one("#option-list", OptionList)
|
||||
option_list.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
def focus_custom_input(self) -> None:
|
||||
"""Focus the custom text input field."""
|
||||
if self.allow_custom_text:
|
||||
try:
|
||||
custom_input = self.query_one("#custom-input", Input)
|
||||
custom_input.focus()
|
||||
except NoMatches:
|
||||
pass
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent mode and help text display widget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class AgentModeAndHelp(Static):
|
||||
"""Widget displaying the current agent mode and help text.
|
||||
|
||||
Shows the current agent mode (e.g., "plan", "execute") in a colored label,
|
||||
followed by available commands and help text in a dimmed style. Used in
|
||||
the fixed bottom area of the console.
|
||||
|
||||
Attributes:
|
||||
mode: Current mode name (e.g., "plan", "execute"), or None if no mode.
|
||||
mode_color: Rich color string for the mode label (e.g., "yellow", "green").
|
||||
help_text: Help text to display (e.g., "/exit to quit, /mode to switch").
|
||||
"""
|
||||
|
||||
mode: reactive[str | None] = reactive(None)
|
||||
mode_color: reactive[str] = reactive("yellow")
|
||||
help_text: reactive[str] = reactive("")
|
||||
|
||||
def render(self) -> Text:
|
||||
"""Render the mode indicator and help text.
|
||||
|
||||
Returns:
|
||||
Rich Text object with styled mode and help display.
|
||||
"""
|
||||
result = Text()
|
||||
|
||||
if self.mode:
|
||||
result.append(f"[{self.mode}]", style=self.mode_color)
|
||||
|
||||
if self.help_text:
|
||||
if self.mode:
|
||||
result.append(" ")
|
||||
result.append(self.help_text, style="dim")
|
||||
|
||||
if not result.plain:
|
||||
result.append(" ")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mode-colored horizontal rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class PromptRule(Static):
|
||||
"""A full-width horizontal rule colored by the current agent mode.
|
||||
|
||||
Renders a line of '─' characters across the terminal width,
|
||||
colored to match the current mode (e.g., cyan for plan, green for execute).
|
||||
|
||||
Attributes:
|
||||
rule_color: Rich color string for the rule (e.g., "cyan", "green").
|
||||
"""
|
||||
|
||||
rule_color: reactive[str] = reactive("cyan")
|
||||
|
||||
def render(self) -> str:
|
||||
"""Render the horizontal rule.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup.
|
||||
"""
|
||||
color = self.rule_color
|
||||
width = self.size.width or 80
|
||||
return f"[{color}]{'─' * width}[/{color}]"
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scrolling panel for conversation history display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from textual.widgets import RichLog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app_state import OutputEntry
|
||||
|
||||
|
||||
class HarnessScrollPanel(RichLog):
|
||||
"""Scrolling panel for displaying conversation history.
|
||||
|
||||
Uses Textual's RichLog widget for efficient append-only rendering with
|
||||
Rich text formatting support. Automatically scrolls to the bottom when
|
||||
new entries are added.
|
||||
|
||||
For streaming text, the panel uses a truncate-and-rewrite strategy: it
|
||||
tracks where streaming began in the RichLog lines list, and on each update
|
||||
truncates back to that point and rewrites the full accumulated text as a
|
||||
single write. This ensures consistent rendering without line-break artifacts
|
||||
between streamed chunks.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
"""Initialize the scroll panel.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments passed to RichLog.
|
||||
"""
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
auto_scroll=True, # Automatically scroll to bottom
|
||||
wrap=True, # Wrap long lines instead of horizontal scroll
|
||||
markup=True, # Enable Rich markup
|
||||
highlight=True, # Enable syntax highlighting
|
||||
)
|
||||
self._entries: list[OutputEntry] = []
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start: int = 0
|
||||
|
||||
def append_entry(self, entry: OutputEntry) -> None:
|
||||
"""Append a new output entry to the conversation history.
|
||||
|
||||
Args:
|
||||
entry: The output entry to append.
|
||||
"""
|
||||
self._entries.append(entry)
|
||||
text = self._format_entry(entry)
|
||||
self.write(text)
|
||||
|
||||
def set_streaming_entry(self, entry: OutputEntry) -> None:
|
||||
"""Set or update the current streaming entry.
|
||||
|
||||
On each update, truncates the RichLog back to where streaming
|
||||
started, then rewrites the full streaming text as a single block.
|
||||
This ensures no spurious line breaks between chunks while avoiding
|
||||
a full rewrite of all entries.
|
||||
|
||||
Args:
|
||||
entry: The streaming entry (will be mutated externally).
|
||||
"""
|
||||
if not self._is_streaming:
|
||||
# First streaming chunk — record where streaming lines begin
|
||||
self._is_streaming = True
|
||||
self._entries.append(entry)
|
||||
self._streaming_line_start = len(self.lines)
|
||||
|
||||
# Truncate lines back to where streaming started
|
||||
if len(self.lines) > self._streaming_line_start:
|
||||
del self.lines[self._streaming_line_start :]
|
||||
from textual.geometry import Size
|
||||
|
||||
self.virtual_size = Size(self._widest_line_width, len(self.lines))
|
||||
|
||||
# Write full streaming text as a single renderable
|
||||
formatted = self._format_text(entry.text, entry.color)
|
||||
self.write(formatted)
|
||||
|
||||
def end_streaming(self) -> None:
|
||||
"""End the current streaming mode."""
|
||||
if self._is_streaming:
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
|
||||
def _rewrite_all(self) -> None:
|
||||
"""Clear and rewrite all entries from scratch."""
|
||||
self.clear()
|
||||
for entry in self._entries:
|
||||
self.write(self._format_entry(entry))
|
||||
|
||||
def _format_entry(self, entry: OutputEntry) -> str:
|
||||
"""Format an output entry with Rich markup.
|
||||
|
||||
Args:
|
||||
entry: The entry to format.
|
||||
|
||||
Returns:
|
||||
Formatted string with Rich markup for color and styling.
|
||||
"""
|
||||
return self._format_text(entry.text, entry.color)
|
||||
|
||||
@staticmethod
|
||||
def _format_text(text: str, color: str | None) -> str:
|
||||
"""Format text with optional Rich color markup.
|
||||
|
||||
Args:
|
||||
text: The text to format.
|
||||
color: Optional Rich color name.
|
||||
|
||||
Returns:
|
||||
Formatted string.
|
||||
"""
|
||||
if color:
|
||||
return f"[{color}]{text}[/{color}]"
|
||||
return text
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear all conversation history from the panel."""
|
||||
self._entries.clear()
|
||||
self._is_streaming = False
|
||||
self._streaming_line_start = 0
|
||||
self.clear()
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Text input widget with inline prompt for the harness console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label
|
||||
|
||||
|
||||
class HarnessTextInput(Widget):
|
||||
"""Text input widget with a prompt label on the left.
|
||||
|
||||
Displays a prompt (e.g., "> ") followed by a borderless input field.
|
||||
Sits between the two mode-colored horizontal rules.
|
||||
|
||||
Attributes:
|
||||
prompt: The prompt text displayed on the left (e.g., "> ").
|
||||
placeholder: Placeholder text shown when the input is empty.
|
||||
"""
|
||||
|
||||
prompt: reactive[str] = reactive("> ")
|
||||
placeholder: reactive[str] = reactive("")
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message sent when the input is submitted.
|
||||
|
||||
Attributes:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
"""Initialize the Submitted message.
|
||||
|
||||
Args:
|
||||
value: The submitted text value.
|
||||
"""
|
||||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the prompt label and input field.
|
||||
|
||||
Yields:
|
||||
A horizontal container with the prompt and input field.
|
||||
"""
|
||||
with Horizontal(classes="prompt-container"):
|
||||
yield Label(self.prompt, classes="prompt-label", id="prompt-label")
|
||||
yield Input(placeholder=self.placeholder, classes="input-field", id="input-field")
|
||||
|
||||
def watch_prompt(self, new_prompt: str) -> None:
|
||||
"""Update the prompt label when the prompt attribute changes.
|
||||
|
||||
Args:
|
||||
new_prompt: The new prompt text.
|
||||
"""
|
||||
try:
|
||||
label = self.query_one("#prompt-label", Label)
|
||||
label.update(new_prompt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def watch_placeholder(self, new_placeholder: str) -> None:
|
||||
"""Update the input placeholder when the placeholder attribute changes.
|
||||
|
||||
Args:
|
||||
new_placeholder: The new placeholder text.
|
||||
"""
|
||||
try:
|
||||
input_field = self.query_one("#input-field", Input)
|
||||
input_field.placeholder = new_placeholder
|
||||
except Exception:
|
||||
# Input doesn't exist yet (before compose), ignore
|
||||
pass
|
||||
|
||||
@on(Input.Submitted)
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle input submission.
|
||||
|
||||
Clears the input field and posts a Submitted message with the value.
|
||||
|
||||
Args:
|
||||
event: The Input.Submitted event.
|
||||
"""
|
||||
value = event.value
|
||||
event.input.clear()
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.focus()
|
||||
|
||||
def clear_input(self) -> None:
|
||||
"""Clear the input field."""
|
||||
input_field = self.query_one(".input-field", Input)
|
||||
input_field.clear()
|
||||
Reference in New Issue
Block a user