diff --git a/src/tau_coding/session.py b/src/tau_coding/session.py index 81f1eb366..decf732c5 100644 --- a/src/tau_coding/session.py +++ b/src/tau_coding/session.py @@ -37,7 +37,6 @@ ) from tau_agent.session.entries import SessionEntry from tau_agent.session.jsonl import entry_to_json_line -from tau_agent.session.tree import SessionTreeError, path_to_entry from tau_agent.tool_history import ToolHistoryRepair, repair_tool_history from tau_agent.tools import AgentTool from tau_agent.types import JSONValue @@ -139,6 +138,51 @@ normalize_session_name, ) from tau_coding.session_stats import SessionStats, calculate_session_stats +from tau_coding.session_terminal import ( + TerminalCommandRequest as TerminalCommandRequest, +) +from tau_coding.session_terminal import ( + TerminalCommandResult as TerminalCommandResult, +) +from tau_coding.session_terminal import ( + parse_terminal_command as parse_terminal_command, +) +from tau_coding.session_terminal import ( + terminal_command_context_message, +) +from tau_coding.session_tree import ( + SessionTreeBranchResult as SessionTreeBranchResult, +) +from tau_coding.session_tree import ( + SessionTreeChoice as SessionTreeChoice, +) +from tau_coding.session_tree import ( + detach_missing_parents as _detach_missing_parents, +) +from tau_coding.session_tree import ( + is_branchable_tree_entry as _is_branchable_tree_entry, +) +from tau_coding.session_tree import ( + is_tool_call_tree_entry as _is_tool_call_tree_entry, +) +from tau_coding.session_tree import ( + last_parent_id_from_state as _last_parent_id_from_state, +) +from tau_coding.session_tree import ( + latest_leaf_entry as _latest_leaf_entry, +) +from tau_coding.session_tree import ( + messages_after_entry_on_active_path as _messages_after_entry_on_active_path, +) +from tau_coding.session_tree import ( + ordered_tree_entries as _ordered_tree_entries, +) +from tau_coding.session_tree import ( + tree_branch_indents as _tree_branch_indents, +) +from tau_coding.session_tree import ( + tree_choice_label as _tree_choice_label, +) from tau_coding.skills import Skill, expand_skill_command, load_skills_with_diagnostics from tau_coding.system_prompt import ( BuildSystemPromptOptions, @@ -226,43 +270,6 @@ class ModelSelectionResult: changed: bool -@dataclass(frozen=True, slots=True) -class TerminalCommandResult: - """Result of an input-bar terminal command.""" - - command: str - output: str - exit_code: int | None - ok: bool - added_to_context: bool - - -@dataclass(frozen=True, slots=True) -class SessionTreeChoice: - """One branchable entry in the active session tree.""" - - entry_id: str - label: str - active: bool = False - is_tool_call: bool = False - - -@dataclass(frozen=True, slots=True) -class SessionTreeBranchResult: - """Result of moving the active session tree leaf.""" - - message: str - input_prefill: str | None = None - - -@dataclass(frozen=True, slots=True) -class TerminalCommandRequest: - """Parsed input-bar terminal command request.""" - - command: str - add_to_context: bool - - @dataclass(frozen=True, slots=True) class SessionResources: """Tau-owned resources loaded around a coding session.""" @@ -2779,7 +2786,7 @@ async def run_terminal_command( if add_to_context: await self._flush_pending_message_writes(context=self._diagnostic_context()) context_message = UserMessage( - content=_terminal_command_context_message( + content=terminal_command_context_message( normalized_command, result.text, ) @@ -3701,167 +3708,6 @@ def is_retryable_huggingface_route_error(message: AssistantMessage) -> bool: return False -def _detach_missing_parents(entries: list[SessionEntry]) -> list[SessionEntry]: - """Return entries with dangling parent pointers detached from external history.""" - entry_ids = {entry.id for entry in entries} - return [ - entry.model_copy(update={"parent_id": None}) - if entry.parent_id is not None and entry.parent_id not in entry_ids - else entry - for entry in entries - ] - - -def _last_parent_id_from_state(state: SessionState) -> str | None: - if state.active_leaf_id is not None: - return state.active_leaf_id - if state.entries: - return state.entries[-1].id - return None - - -def _latest_leaf_entry(entries: list[SessionEntry]) -> LeafEntry | None: - for entry in reversed(entries): - if isinstance(entry, LeafEntry): - return entry - return None - - -def _is_branchable_tree_entry(entry: SessionEntry) -> bool: - if entry.type in {"compaction", "branch_summary"}: - return True - if entry.type != "message": - return False - return isinstance(entry.message, UserMessage | AssistantMessage) - - -def _tree_choice_label(entry: SessionEntry, *, branch_indent: int = 0) -> str: - prefix = " " * branch_indent - return f"{prefix}{_tree_entry_title(entry)}" - - -def _tree_branch_indents(entries: list[SessionEntry]) -> dict[str, int]: - children_by_parent: dict[str | None, list[str]] = {} - for entry in entries: - if entry.type != "leaf": - children_by_parent.setdefault(entry.parent_id, []).append(entry.id) - - sibling_indexes = { - child_id: index - for children in children_by_parent.values() - for index, child_id in enumerate(children) - } - indents: dict[str, int] = {} - for entry in entries: - if entry.type == "leaf": - continue - parent_indent = indents.get(entry.parent_id, 0) if entry.parent_id is not None else 0 - sibling_index = sibling_indexes.get(entry.id, 0) - indents[entry.id] = parent_indent + (1 if sibling_index > 0 else 0) - return indents - - -def _ordered_tree_entries(entries: list[SessionEntry]) -> tuple[SessionEntry, ...]: - children_by_parent: dict[str | None, list[SessionEntry]] = {} - for entry in entries: - if entry.type != "leaf": - children_by_parent.setdefault(entry.parent_id, []).append(entry) - - ordered: list[SessionEntry] = [] - seen: set[str] = set() - expanded: set[str | None] = set() - - def append_descendants(root_parent_id: str | None) -> None: - # Iterative depth-first walk rather than recursion so a long session (a - # deep root-to-leaf entry chain) cannot exceed Python's recursion limit. - # `expanded` also makes a malformed parent cycle terminate instead of - # recursing forever. Emitting a node's direct children before descending, - # and pushing them reversed so the first child is processed next, - # preserves the original traversal order. - stack: list[str | None] = [root_parent_id] - while stack: - parent_id = stack.pop() - if parent_id in expanded: - continue - expanded.add(parent_id) - children = children_by_parent.get(parent_id, []) - for child in children: - if child.id not in seen: - ordered.append(child) - seen.add(child.id) - for child in reversed(children): - stack.append(child.id) - - append_descendants(None) - for entry in entries: - if entry.type != "leaf" and entry.id not in seen: - ordered.append(entry) - seen.add(entry.id) - append_descendants(entry.id) - return tuple(ordered) - - -def _is_tool_call_tree_entry(entry: SessionEntry) -> bool: - return ( - entry.type == "message" - and isinstance(entry.message, AssistantMessage) - and bool(entry.message.tool_calls) - ) - - -def _tree_entry_title(entry: SessionEntry) -> str: - match entry.type: - case "message": - message = entry.message - if ( - isinstance(message, AssistantMessage) - and message.tool_calls - and not message.text.strip() - ): - tool_names = ", ".join(call.name for call in message.tool_calls) - return f"tool call: {tool_names}" - return f"{message.role}: {_message_text_preview(message)}" - case "compaction": - return f"compaction summary: {_short_preview(entry.summary)}" - case "branch_summary": - return f"branch summary: {_short_preview(entry.summary)}" - case _: - return entry.type - - -def _message_text_preview(message: AgentMessage) -> str: - return _short_preview(message_text(message)) - - -def _short_preview(text: str, *, limit: int = 72) -> str: - normalized = " ".join(text.split()) - if len(normalized) <= limit: - return normalized or "(empty)" - return f"{normalized[: limit - 1]}..." - - -def _messages_after_entry_on_active_path( - entries: list[SessionEntry], - entry_id: str, - active_leaf_id: str | None, -) -> tuple[AgentMessage, ...]: - if active_leaf_id is None: - return () - try: - active_path = path_to_entry(entries, active_leaf_id) - except SessionTreeError: - return () - try: - target_index = next( - index for index, entry in enumerate(active_path) if entry.id == entry_id - ) - except StopIteration: - return () - return tuple( - entry.message for entry in active_path[target_index + 1 :] if entry.type == "message" - ) - - def _storage_path(storage: SessionStorage) -> Path | None: path = getattr(storage, "path", None) return path if isinstance(path, Path) else None @@ -4278,30 +4124,6 @@ def _fallback_session_name(first_message: str) -> str | None: return _sanitize_session_name(first_message) -def _terminal_command_context_message(command: str, output: str) -> str: - return ( - "Terminal command executed by the user.\n\n" - f"Command:\n```bash\n{command}\n```\n\n" - f"Output:\n```text\n{output}\n```" - ) - - -def parse_terminal_command(text: str) -> TerminalCommandRequest | None: - """Parse input-bar terminal command syntax.""" - stripped = text.strip() - if stripped.startswith("!!"): - command = stripped[2:].strip() - if not command: - return None - return TerminalCommandRequest(command=command, add_to_context=False) - if stripped.startswith("!"): - command = stripped[1:].strip() - if not command: - return None - return TerminalCommandRequest(command=command, add_to_context=True) - return None - - def _category_summary( before: tuple[tuple[object, ...], ...], after: tuple[tuple[object, ...], ...], diff --git a/src/tau_coding/session_terminal.py b/src/tau_coding/session_terminal.py new file mode 100644 index 000000000..4b39997d9 --- /dev/null +++ b/src/tau_coding/session_terminal.py @@ -0,0 +1,53 @@ +"""Input-bar terminal command types and parsing. + +This module deliberately owns only the syntax and value objects. Executing a +command and persisting its result remain responsibilities of ``CodingSession``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class TerminalCommandResult: + """Result of an input-bar terminal command.""" + + command: str + output: str + exit_code: int | None + ok: bool + added_to_context: bool + + +@dataclass(frozen=True, slots=True) +class TerminalCommandRequest: + """Parsed input-bar terminal command request.""" + + command: str + add_to_context: bool + + +def terminal_command_context_message(command: str, output: str) -> str: + """Format terminal output for inclusion in agent context.""" + return ( + "Terminal command executed by the user.\n\n" + f"Command:\n```bash\n{command}\n```\n\n" + f"Output:\n```text\n{output}\n```" + ) + + +def parse_terminal_command(text: str) -> TerminalCommandRequest | None: + """Parse input-bar terminal command syntax.""" + stripped = text.strip() + if stripped.startswith("!!"): + command = stripped[2:].strip() + if not command: + return None + return TerminalCommandRequest(command=command, add_to_context=False) + if stripped.startswith("!"): + command = stripped[1:].strip() + if not command: + return None + return TerminalCommandRequest(command=command, add_to_context=True) + return None diff --git a/src/tau_coding/session_tree.py b/src/tau_coding/session_tree.py new file mode 100644 index 000000000..0f1dc5b75 --- /dev/null +++ b/src/tau_coding/session_tree.py @@ -0,0 +1,201 @@ +"""Session-tree value objects and traversal helpers. + +``CodingSession`` retains the state-changing branch operation. This module +contains the durable-entry traversal and presentation logic it delegates to. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tau_agent.messages import AgentMessage, AssistantMessage, UserMessage, message_text +from tau_agent.session import LeafEntry, SessionState +from tau_agent.session.entries import SessionEntry +from tau_agent.session.tree import SessionTreeError, path_to_entry + + +@dataclass(frozen=True, slots=True) +class SessionTreeChoice: + """One branchable entry in the active session tree.""" + + entry_id: str + label: str + active: bool = False + is_tool_call: bool = False + + +@dataclass(frozen=True, slots=True) +class SessionTreeBranchResult: + """Result of moving the active session tree leaf.""" + + message: str + input_prefill: str | None = None + + +def detach_missing_parents(entries: list[SessionEntry]) -> list[SessionEntry]: + """Detach parent pointers that reference entries outside this session.""" + entry_ids = {entry.id for entry in entries} + return [ + entry.model_copy(update={"parent_id": None}) + if entry.parent_id is not None and entry.parent_id not in entry_ids + else entry + for entry in entries + ] + + +def last_parent_id_from_state(state: SessionState) -> str | None: + """Return the active leaf, or the last durable entry when no leaf exists.""" + if state.active_leaf_id is not None: + return state.active_leaf_id + if state.entries: + return state.entries[-1].id + return None + + +def latest_leaf_entry(entries: list[SessionEntry]) -> LeafEntry | None: + """Return the newest leaf entry, if the session has one.""" + for entry in reversed(entries): + if isinstance(entry, LeafEntry): + return entry + return None + + +def is_branchable_tree_entry(entry: SessionEntry) -> bool: + """Return whether an entry can be selected as a branch point.""" + if entry.type in {"compaction", "branch_summary"}: + return True + if entry.type != "message": + return False + return isinstance(entry.message, UserMessage | AssistantMessage) + + +def tree_choice_label(entry: SessionEntry, *, branch_indent: int = 0) -> str: + """Create the indented label used by the tree picker.""" + prefix = " " * branch_indent + return f"{prefix}{tree_entry_title(entry)}" + + +def tree_branch_indents(entries: list[SessionEntry]) -> dict[str, int]: + """Calculate visual indentation for each persisted tree entry.""" + children_by_parent: dict[str | None, list[str]] = {} + for entry in entries: + if entry.type != "leaf": + children_by_parent.setdefault(entry.parent_id, []).append(entry.id) + + sibling_indexes = { + child_id: index + for children in children_by_parent.values() + for index, child_id in enumerate(children) + } + indents: dict[str, int] = {} + for entry in entries: + if entry.type == "leaf": + continue + parent_indent = indents.get(entry.parent_id, 0) if entry.parent_id is not None else 0 + sibling_index = sibling_indexes.get(entry.id, 0) + indents[entry.id] = parent_indent + (1 if sibling_index > 0 else 0) + return indents + + +def ordered_tree_entries(entries: list[SessionEntry]) -> tuple[SessionEntry, ...]: + """Return entries in visual tree order while tolerating malformed cycles.""" + children_by_parent: dict[str | None, list[SessionEntry]] = {} + for entry in entries: + if entry.type != "leaf": + children_by_parent.setdefault(entry.parent_id, []).append(entry) + + ordered: list[SessionEntry] = [] + seen: set[str] = set() + expanded: set[str | None] = set() + + def append_descendants(root_parent_id: str | None) -> None: + # Iterative depth-first walk rather than recursion so a long session + # cannot exceed Python's recursion limit. ``expanded`` also terminates + # malformed parent cycles while preserving original traversal order. + stack: list[str | None] = [root_parent_id] + while stack: + parent_id = stack.pop() + if parent_id in expanded: + continue + expanded.add(parent_id) + children = children_by_parent.get(parent_id, []) + for child in children: + if child.id not in seen: + ordered.append(child) + seen.add(child.id) + for child in reversed(children): + stack.append(child.id) + + append_descendants(None) + for entry in entries: + if entry.type != "leaf" and entry.id not in seen: + ordered.append(entry) + seen.add(entry.id) + append_descendants(entry.id) + return tuple(ordered) + + +def is_tool_call_tree_entry(entry: SessionEntry) -> bool: + """Return whether a tree entry represents an assistant tool call.""" + return ( + entry.type == "message" + and isinstance(entry.message, AssistantMessage) + and bool(entry.message.tool_calls) + ) + + +def tree_entry_title(entry: SessionEntry) -> str: + """Render the concise title shown for one tree entry.""" + match entry.type: + case "message": + message = entry.message + if ( + isinstance(message, AssistantMessage) + and message.tool_calls + and not message.text.strip() + ): + tool_names = ", ".join(call.name for call in message.tool_calls) + return f"tool call: {tool_names}" + return f"{message.role}: {message_text_preview(message)}" + case "compaction": + return f"compaction summary: {short_preview(entry.summary)}" + case "branch_summary": + return f"branch summary: {short_preview(entry.summary)}" + case _: + return entry.type + + +def message_text_preview(message: AgentMessage) -> str: + """Return a compact preview of an agent message.""" + return short_preview(message_text(message)) + + +def short_preview(text: str, *, limit: int = 72) -> str: + """Normalize and truncate a single-line preview.""" + normalized = " ".join(text.split()) + if len(normalized) <= limit: + return normalized or "(empty)" + return f"{normalized[: limit - 1]}..." + + +def messages_after_entry_on_active_path( + entries: list[SessionEntry], + entry_id: str, + active_leaf_id: str | None, +) -> tuple[AgentMessage, ...]: + """Return messages after an entry on the active path, if it is valid.""" + if active_leaf_id is None: + return () + try: + active_path = path_to_entry(entries, active_leaf_id) + except SessionTreeError: + return () + try: + target_index = next( + index for index, entry in enumerate(active_path) if entry.id == entry_id + ) + except StopIteration: + return () + return tuple( + entry.message for entry in active_path[target_index + 1 :] if entry.type == "message" + ) diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 78c7f0e6c..0fe9374ea 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -10,11 +10,10 @@ from datetime import datetime from enum import Enum, auto from inspect import isawaitable -from io import StringIO from pathlib import Path from typing import Any, ClassVar, Literal, Protocol, TypeVar, cast -from rich.console import Console, Group +from rich.console import Group from rich.style import Style from rich.text import Text from textual import events, on @@ -142,13 +141,44 @@ from tau_coding.thinking import ThinkingLevel from tau_coding.tui.adapter import TuiEventAdapter from tau_coding.tui.autocomplete import ( - CompletionItem, CompletionOption, CompletionState, build_completion_state, ) +from tau_coding.tui.completion_layout import ( + COMPLETION_MAX_VISIBLE_LINES, +) +from tau_coding.tui.completion_layout import ( + activity_prompt_border_color as _activity_prompt_border_color, +) +from tau_coding.tui.completion_layout import ( + completion_render_line_count as _completion_render_line_count, # noqa: F401 +) +from tau_coding.tui.completion_layout import ( + completion_selected_render_line as _completion_selected_render_line, # noqa: F401 +) +from tau_coding.tui.completion_layout import ( + completion_visible_line_limit as _completion_visible_line_limit, +) +from tau_coding.tui.completion_layout import ( + is_terminal_command_prompt as _is_terminal_command_prompt, +) +from tau_coding.tui.completion_layout import ( + is_user_message_end_event as _is_user_message_end_event, +) +from tau_coding.tui.completion_layout import ( + render_activity_indicator as _render_activity_indicator, +) +from tau_coding.tui.completion_layout import ( + should_optimistically_render_prompt as _should_optimistically_render_prompt, +) +from tau_coding.tui.completion_layout import ( + terminal_command_prefix_span as _terminal_command_prefix_span, +) +from tau_coding.tui.completion_layout import ( + visible_completion_state as _visible_completion_state, +) from tau_coding.tui.config import ( - TAU_DARK_THEME, TuiKeybindings, TuiSettings, TuiTheme, @@ -192,8 +222,6 @@ SIDEBAR_MIN_HEIGHT = 38 ACTIVITY_TICK_SECONDS = 0.15 ACTIVITY_COLOR_FADE_STEPS = 24 -ACTIVITY_INDICATOR_HEIGHT = 3 -COMPLETION_MAX_VISIBLE_LINES = 16 COMPLETION_INITIAL_TERMINAL_FRACTION = 3 COMPLETION_MIN_TRANSCRIPT_LINES = 4 COMPLETION_WIDGET_CHROME_LINES = 3 @@ -6745,233 +6773,6 @@ def _sync_prompt_shell_mode(self, text: str) -> None: self._apply_activity_indicator() -def _activity_prompt_border_color( - theme: TuiTheme, - *, - frame: int, - running: bool, - shell_mode: bool, -) -> str: - """Return the prompt border color for the current activity animation frame.""" - del frame, running - if shell_mode: - return theme.role_styles["tool"].border - return theme.prompt_border - - -def _render_activity_indicator( - theme: TuiTheme, - *, - frame: int, - running: bool, - shell_mode: bool = False, -) -> Text: - """Render the prompt prefix: a moving square while running, ``$`` in shell mode.""" - if shell_mode and not running: - return Text("$", style=f"bold {theme.role_styles['tool'].border}") - if not running: - return Text("τ", style=f"bold {theme.accent}") - - cycle_length = (ACTIVITY_INDICATOR_HEIGHT - 1) * 2 - cycle_position = frame % cycle_length - active_row = ( - cycle_position - if cycle_position < ACTIVITY_INDICATOR_HEIGHT - else cycle_length - cycle_position - ) - direction = 1 if cycle_position < ACTIVITY_INDICATOR_HEIGHT else -1 - trail_rows = { - active_row: theme.accent, - active_row - direction: _blend_hex_colors( - theme.accent, - theme.screen_background, - fraction=0.35, - ), - active_row - (direction * 2): _blend_hex_colors( - theme.accent, - theme.screen_background, - fraction=0.65, - ), - } - - rendered = Text() - for row in range(ACTIVITY_INDICATOR_HEIGHT): - color = trail_rows.get(row) - if color is None: - rendered.append(" ") - else: - rendered.append("■", style=color) - if row < ACTIVITY_INDICATOR_HEIGHT - 1: - rendered.append("\n") - return rendered - - -def _is_terminal_command_prompt(text: str) -> bool: - """Return whether the prompt is currently in terminal-command mode.""" - return _terminal_command_prefix_span(text) is not None - - -def _should_optimistically_render_prompt(text: str) -> bool: - """Return whether submitted text can be safely shown before session expansion.""" - stripped = text.strip() - return bool(stripped) and not stripped.startswith("/") - - -def _is_user_message_end_event(event: CodingSessionEvent) -> bool: - """Return whether an agent event closes a user-context message.""" - return isinstance(event, MessageEndEvent) and isinstance( - event.message, (UserMessage, CustomMessage) - ) - - -def _terminal_command_prefix_span(text: str) -> tuple[int, int] | None: - """Return the input span for a leading ! or !! terminal-command prefix.""" - leading_whitespace = len(text) - len(text.lstrip()) - stripped = text[leading_whitespace:] - if stripped.startswith("!!"): - return (leading_whitespace, leading_whitespace + 2) - if stripped.startswith("!"): - return (leading_whitespace, leading_whitespace + 1) - return None - - -def _blend_hex_colors(start: str, end: str, *, fraction: float) -> str: - """Blend two ``#rrggbb`` colors by ``fraction``.""" - start_rgb = _hex_to_rgb(start) - end_rgb = _hex_to_rgb(end) - blended = tuple( - round(start_channel + (end_channel - start_channel) * fraction) - for start_channel, end_channel in zip(start_rgb, end_rgb, strict=True) - ) - return f"#{blended[0]:02x}{blended[1]:02x}{blended[2]:02x}" - - -def _hex_to_rgb(color: str) -> tuple[int, int, int]: - value = color.removeprefix("#") - if len(value) != 6: - raise ValueError(f"Expected #rrggbb color, got {color!r}") - return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)) - - -def _completion_visible_line_limit(suggestions: Static) -> int: - """Return the number of completion render lines that fit in the widget body.""" - if suggestions.size.height > 0: - return max(min(COMPLETION_MAX_VISIBLE_LINES, suggestions.size.height), 1) - return COMPLETION_MAX_VISIBLE_LINES - - -def _visible_completion_state( - state: CompletionState, - *, - max_lines: int, - width: int | None = None, -) -> CompletionState: - """Return a completion-state window with the selected item visible.""" - if not state.items or max_lines <= 0: - return CompletionState() - - selected_line_limit = max(max_lines - 1, 1) - start = 0 - while start < state.selected_index: - candidate = CompletionState( - items=state.items[start:], - selected_index=state.selected_index - start, - ) - if _completion_selected_render_line(candidate, width=width) < selected_line_limit: - break - start += 1 - - end = len(state.items) - while end > state.selected_index + 1: - candidate = CompletionState( - items=state.items[start:end], - selected_index=state.selected_index - start, - ) - if _completion_render_line_count(candidate, width=width) <= max_lines: - break - end -= 1 - - while start < state.selected_index: - candidate = CompletionState( - items=state.items[start:end], - selected_index=state.selected_index - start, - ) - if _completion_render_line_count(candidate, width=width) <= max_lines: - break - start += 1 - - return CompletionState( - items=state.items[start:end], - selected_index=state.selected_index - start, - ) - - -def _completion_selected_render_line(state: CompletionState, *, width: int | None = None) -> int: - """Return the rendered line number for the selected completion item.""" - line = 0 - has_rendered_text = False - previous_category: str | None = None - for index, item in enumerate(state.items): - if item.category != previous_category: - if has_rendered_text: - line += 1 - if item.category: - line += 1 - has_rendered_text = True - previous_category = item.category - elif has_rendered_text: - line += 1 - if index == state.selected_index: - return line - line += _completion_item_extra_wrapped_lines(item, width=width) - has_rendered_text = True - return line - - -def _completion_render_line_count(state: CompletionState, *, width: int | None = None) -> int: - """Return how many lines the completion state renders into.""" - if not state.items: - return 0 - line_count = 0 - previous_category: str | None = None - for index, item in enumerate(state.items): - if item.category != previous_category: - if index: - line_count += 1 - if item.category: - line_count += 1 - previous_category = item.category - line_count += 1 + _completion_item_extra_wrapped_lines(item, width=width) - return line_count - - -def _completion_item_extra_wrapped_lines( - item: CompletionItem, - *, - width: int | None, -) -> int: - """Return extra rendered lines used when a completion description wraps.""" - if width is None or width <= 0 or not item.description: - return 0 - output = StringIO() - console = Console( - file=output, - width=width, - force_terminal=False, - color_system=None, - legacy_windows=False, - ) - console.print( - render_completion_suggestions( - CompletionState(items=(item,), selected_index=0), - theme=TAU_DARK_THEME, - ), - end="", - ) - line_count = len(output.getvalue().splitlines()) - return max(line_count - 1, 0) - - def _session_command_registry(session: CodingSession) -> CommandRegistry: registry = getattr(session, "command_registry", None) if isinstance(registry, CommandRegistry): diff --git a/src/tau_coding/tui/completion_layout.py b/src/tau_coding/tui/completion_layout.py new file mode 100644 index 000000000..52dec7f39 --- /dev/null +++ b/src/tau_coding/tui/completion_layout.py @@ -0,0 +1,247 @@ +"""Prompt activity and completion-layout helpers for the Textual adapter.""" + +from __future__ import annotations + +from io import StringIO + +from rich.console import Console +from rich.text import Text +from textual.widgets import Static + +from tau_agent.events import MessageEndEvent +from tau_agent.messages import CustomMessage, UserMessage +from tau_coding.events import CodingSessionEvent +from tau_coding.tui.autocomplete import CompletionItem, CompletionState +from tau_coding.tui.completion_widgets import render_completion_suggestions +from tau_coding.tui.config import TAU_DARK_THEME, TuiTheme + +ACTIVITY_INDICATOR_HEIGHT = 3 +COMPLETION_MAX_VISIBLE_LINES = 16 + + +def activity_prompt_border_color( + theme: TuiTheme, + *, + frame: int, + running: bool, + shell_mode: bool, +) -> str: + """Return the prompt border color for the current activity animation frame.""" + del frame, running + if shell_mode: + return theme.role_styles["tool"].border + return theme.prompt_border + + +def render_activity_indicator( + theme: TuiTheme, + *, + frame: int, + running: bool, + shell_mode: bool = False, +) -> Text: + """Render the prompt prefix: a moving square while running, ``$`` in shell mode.""" + if shell_mode and not running: + return Text("$", style=f"bold {theme.role_styles['tool'].border}") + if not running: + return Text("τ", style=f"bold {theme.accent}") + + cycle_length = (ACTIVITY_INDICATOR_HEIGHT - 1) * 2 + cycle_position = frame % cycle_length + active_row = ( + cycle_position + if cycle_position < ACTIVITY_INDICATOR_HEIGHT + else cycle_length - cycle_position + ) + direction = 1 if cycle_position < ACTIVITY_INDICATOR_HEIGHT else -1 + trail_rows = { + active_row: theme.accent, + active_row - direction: blend_hex_colors( + theme.accent, + theme.screen_background, + fraction=0.35, + ), + active_row - (direction * 2): blend_hex_colors( + theme.accent, + theme.screen_background, + fraction=0.65, + ), + } + + rendered = Text() + for row in range(ACTIVITY_INDICATOR_HEIGHT): + color = trail_rows.get(row) + if color is None: + rendered.append(" ") + else: + rendered.append("■", style=color) + if row < ACTIVITY_INDICATOR_HEIGHT - 1: + rendered.append("\n") + return rendered + + +def is_terminal_command_prompt(text: str) -> bool: + """Return whether the prompt is currently in terminal-command mode.""" + return terminal_command_prefix_span(text) is not None + + +def should_optimistically_render_prompt(text: str) -> bool: + """Return whether submitted text can be safely shown before session expansion.""" + stripped = text.strip() + return bool(stripped) and not stripped.startswith("/") + + +def is_user_message_end_event(event: CodingSessionEvent) -> bool: + """Return whether an agent event closes a user-context message.""" + return isinstance(event, MessageEndEvent) and isinstance( + event.message, (UserMessage, CustomMessage) + ) + + +def terminal_command_prefix_span(text: str) -> tuple[int, int] | None: + """Return the input span for a leading ! or !! terminal-command prefix.""" + leading_whitespace = len(text) - len(text.lstrip()) + stripped = text[leading_whitespace:] + if stripped.startswith("!!"): + return (leading_whitespace, leading_whitespace + 2) + if stripped.startswith("!"): + return (leading_whitespace, leading_whitespace + 1) + return None + + +def blend_hex_colors(start: str, end: str, *, fraction: float) -> str: + """Blend two ``#rrggbb`` colors by ``fraction``.""" + start_rgb = hex_to_rgb(start) + end_rgb = hex_to_rgb(end) + blended = tuple( + round(start_channel + (end_channel - start_channel) * fraction) + for start_channel, end_channel in zip(start_rgb, end_rgb, strict=True) + ) + return f"#{blended[0]:02x}{blended[1]:02x}{blended[2]:02x}" + + +def hex_to_rgb(color: str) -> tuple[int, int, int]: + """Parse a six-digit RGB hex color.""" + value = color.removeprefix("#") + if len(value) != 6: + raise ValueError(f"Expected #rrggbb color, got {color!r}") + return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)) + + +def completion_visible_line_limit(suggestions: Static) -> int: + """Return the number of completion render lines that fit in the widget body.""" + if suggestions.size.height > 0: + return max(min(COMPLETION_MAX_VISIBLE_LINES, suggestions.size.height), 1) + return COMPLETION_MAX_VISIBLE_LINES + + +def visible_completion_state( + state: CompletionState, + *, + max_lines: int, + width: int | None = None, +) -> CompletionState: + """Return a completion-state window with the selected item visible.""" + if not state.items or max_lines <= 0: + return CompletionState() + + selected_line_limit = max(max_lines - 1, 1) + start = 0 + while start < state.selected_index: + candidate = CompletionState( + items=state.items[start:], + selected_index=state.selected_index - start, + ) + if completion_selected_render_line(candidate, width=width) < selected_line_limit: + break + start += 1 + + end = len(state.items) + while end > state.selected_index + 1: + candidate = CompletionState( + items=state.items[start:end], + selected_index=state.selected_index - start, + ) + if completion_render_line_count(candidate, width=width) <= max_lines: + break + end -= 1 + + while start < state.selected_index: + candidate = CompletionState( + items=state.items[start:end], + selected_index=state.selected_index - start, + ) + if completion_render_line_count(candidate, width=width) <= max_lines: + break + start += 1 + + return CompletionState( + items=state.items[start:end], + selected_index=state.selected_index - start, + ) + + +def completion_selected_render_line(state: CompletionState, *, width: int | None = None) -> int: + """Return the rendered line number for the selected completion item.""" + line = 0 + has_rendered_text = False + previous_category: str | None = None + for index, item in enumerate(state.items): + if item.category != previous_category: + if has_rendered_text: + line += 1 + if item.category: + line += 1 + has_rendered_text = True + previous_category = item.category + elif has_rendered_text: + line += 1 + if index == state.selected_index: + return line + line += completion_item_extra_wrapped_lines(item, width=width) + has_rendered_text = True + return line + + +def completion_render_line_count(state: CompletionState, *, width: int | None = None) -> int: + """Return how many lines the completion state renders into.""" + if not state.items: + return 0 + line_count = 0 + previous_category: str | None = None + for index, item in enumerate(state.items): + if item.category != previous_category: + if index: + line_count += 1 + if item.category: + line_count += 1 + previous_category = item.category + line_count += 1 + completion_item_extra_wrapped_lines(item, width=width) + return line_count + + +def completion_item_extra_wrapped_lines( + item: CompletionItem, + *, + width: int | None, +) -> int: + """Return extra rendered lines used when a completion description wraps.""" + if width is None or width <= 0 or not item.description: + return 0 + output = StringIO() + console = Console( + file=output, + width=width, + force_terminal=False, + color_system=None, + legacy_windows=False, + ) + console.print( + render_completion_suggestions( + CompletionState(items=(item,), selected_index=0), + theme=TAU_DARK_THEME, + ), + end="", + ) + line_count = len(output.getvalue().splitlines()) + return max(line_count - 1, 0) diff --git a/src/tau_coding/tui/completion_widgets.py b/src/tau_coding/tui/completion_widgets.py new file mode 100644 index 000000000..2b7153217 --- /dev/null +++ b/src/tau_coding/tui/completion_widgets.py @@ -0,0 +1,46 @@ +"""Rendering for prompt-completion suggestions. + +The completion state is shared by the TUI input and the app layout, while its +Rich rendering is intentionally independent of either widget lifecycle. +""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.table import Table +from rich.text import Text + +from tau_coding.tui.autocomplete import CompletionState +from tau_coding.tui.config import TAU_DARK_THEME, TuiTheme + + +def render_completion_suggestions( + state: CompletionState, + *, + theme: TuiTheme = TAU_DARK_THEME, +) -> RenderableType: + """Render prompt completion suggestions in aligned command/description columns.""" + table = Table.grid(expand=True) + table.add_column(no_wrap=True) + table.add_column(ratio=1) + + previous_category: str | None = None + for index, item in enumerate(state.items): + if item.category != previous_category: + if index: + table.add_row(Text(""), Text("")) + if item.category: + table.add_row(Text(item.category, style=theme.completion_description), Text("")) + previous_category = item.category + + selected = index == state.selected_index + prefix = "› " if selected else " " + style = theme.completion_selected if selected else theme.prompt_text + description_style = ( + theme.completion_selected_description if selected else theme.completion_description + ) + command = Text(prefix, style=style) + command.append(item.display, style=style) + command.append(" ", style=style) + table.add_row(command, Text(item.description or "", style=description_style)) + return table diff --git a/src/tau_coding/tui/widgets.py b/src/tau_coding/tui/widgets.py index da642a6d3..35ac360a8 100644 --- a/src/tau_coding/tui/widgets.py +++ b/src/tau_coding/tui/widgets.py @@ -39,7 +39,9 @@ from tau_coding.session_stats import SessionStats from tau_coding.skills import Skill from tau_coding.system_prompt import ProjectContextFile, format_skills_for_prompt -from tau_coding.tui.autocomplete import CompletionState +from tau_coding.tui.completion_widgets import ( + render_completion_suggestions as render_completion_suggestions, +) from tau_coding.tui.config import TAU_DARK_THEME, TuiRoleStyle, TuiTheme from tau_coding.tui.state import ( RESULTFUL_FILE_GROUP_NAMES, @@ -2613,38 +2615,6 @@ def _syntax_language(raw: str) -> str: return language -def render_completion_suggestions( - state: CompletionState, - *, - theme: TuiTheme = TAU_DARK_THEME, -) -> RenderableType: - """Render prompt completion suggestions in aligned command/description columns.""" - table = Table.grid(expand=True) - table.add_column(no_wrap=True) - table.add_column(ratio=1) - - previous_category: str | None = None - for index, item in enumerate(state.items): - if item.category != previous_category: - if index: - table.add_row(Text(""), Text("")) - if item.category: - table.add_row(Text(item.category, style=theme.completion_description), Text("")) - previous_category = item.category - - selected = index == state.selected_index - prefix = "› " if selected else " " - style = theme.completion_selected if selected else theme.prompt_text - description_style = ( - theme.completion_selected_description if selected else theme.completion_description - ) - command = Text(prefix, style=style) - command.append(item.display, style=style) - command.append(" ", style=style) - table.add_row(command, Text(item.description or "", style=description_style)) - return table - - @dataclass(frozen=True, slots=True) class _LineLimitedCommaList: items: tuple[str, ...]