├── src └── toad │ ├── os.py │ ├── gist.py │ ├── ansi │ ├── __init__.py │ ├── _control_codes.py │ ├── _sgr_styles.py │ └── _ansi_colors.py │ ├── __main__.py │ ├── widgets │ ├── version.py │ ├── non_selectable_label.py │ ├── markdown_note.py │ ├── shell_terminal.py │ ├── note.py │ ├── welcome.py │ ├── user_input.py │ ├── shell_result.py │ ├── agent_thought.py │ ├── strike_text.py │ ├── danger_warning.py │ ├── flash.py │ ├── project_directory_tree.py │ ├── throbber.py │ ├── agent_response.py │ ├── condensed_path.py │ ├── side_bar.py │ ├── future_text.py │ ├── menu.py │ ├── plan.py │ ├── highlighted_textarea.py │ ├── grid_select.py │ ├── command_pane.py │ └── slash_complete.py │ ├── db.py │ ├── menus.py │ ├── acp │ ├── encode_tool_call_id.py │ ├── api.py │ ├── prompt.py │ └── messages.py │ ├── prompt │ ├── extract.py │ └── resource.py │ ├── conversation_markdown.py │ ├── answer.py │ ├── pill.py │ ├── protocol.py │ ├── code_analyze.py │ ├── data │ └── agents │ │ ├── geminicli.com.toml │ │ ├── vibe.mistral.ai.toml │ │ ├── kimi.com.toml │ │ ├── inference.huggingface.co.toml │ │ ├── claude.com.toml │ │ ├── augmentcode.com.toml │ │ ├── openhands.dev.toml │ │ ├── openai.com.toml │ │ ├── goose.ai.toml │ │ ├── ampcode.com.toml │ │ ├── stakpak.dev.toml │ │ ├── opencode.ai.toml │ │ ├── docker.com.toml │ │ └── vtcode.dev.toml │ ├── slash_command.py │ ├── complete.py │ ├── atomic.py │ ├── __init__.py │ ├── agents.py │ ├── shell_read.py │ ├── agent.py │ ├── paths.py │ ├── messages.py │ ├── screens │ ├── permissions.tcss │ ├── settings.tcss │ ├── action_modal.py │ ├── store.tcss │ ├── agent_modal.py │ └── main.py │ ├── constants.py │ ├── option_content.py │ ├── about.py │ ├── version.py │ ├── _loop.py │ ├── agent_schema.py │ ├── history.py │ ├── path_complete.py │ ├── fuzzy.py │ ├── cli.py │ └── shell.py ├── .python-version ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── pull_request_template.md │ └── bug_report.yml ├── CONTRIBUTING.md ├── .gitignore ├── CHANGELOG.md ├── tools ├── make_qr.py └── echo_client.py ├── Makefile ├── notes.md ├── project ├── calculator.tcss └── calculator.py ├── pyproject.toml └── README.md /src/toad/os.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.14.0 2 | -------------------------------------------------------------------------------- /src/toad/gist.py: -------------------------------------------------------------------------------- 1 | async def upload(content: str) -> None: 2 | pass 3 | -------------------------------------------------------------------------------- /src/toad/ansi/__init__.py: -------------------------------------------------------------------------------- 1 | from toad.ansi._ansi import TerminalState as TerminalState 2 | -------------------------------------------------------------------------------- /src/toad/__main__.py: -------------------------------------------------------------------------------- 1 | from toad.cli import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /src/toad/widgets/version.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Static 2 | 3 | 4 | class Version(Static): 5 | pass 6 | -------------------------------------------------------------------------------- /src/toad/db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | 4 | def connect(path: str) -> sqlite3.Connection: 5 | return sqlite3.connect(path) 6 | -------------------------------------------------------------------------------- /src/toad/widgets/non_selectable_label.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Label 2 | 3 | 4 | class NonSelectableLabel(Label): 5 | ALLOW_SELECT = False 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Toad Discussions 4 | url: https://github.com/batrachianai/toad/discussions 5 | about: Bugs / feature requests should be discussed here first. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Guide to Contributing 2 | 3 | Thank you for your interesting in improving Toad! 4 | 5 | If you are thinking of fixing a bug or contributing a feature, please open a Discussion first. 6 | You will be asked for a link to the discussion when you contribute a PR. 7 | 8 | TODO: add technical help 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | __pycache__ 9 | 10 | # Virtual environments 11 | .venv 12 | .DS_Store 13 | .vscode 14 | .zed 15 | .git 16 | .mypy_cache 17 | .github 18 | 19 | 20 | agent.jsonl 21 | replay.jsonl 22 | sandbox/ 23 | -------------------------------------------------------------------------------- /src/toad/menus.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | class MenuItem(NamedTuple): 5 | """An entry in a Menu.""" 6 | 7 | description: str 8 | action: str | None 9 | key: str | None = None 10 | 11 | 12 | CONVERSATION_MENUS: dict[str, list[MenuItem]] = { 13 | "fence": [MenuItem("Run this code", "run", "r")] 14 | } 15 | -------------------------------------------------------------------------------- /src/toad/widgets/markdown_note.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from textual.widgets import Markdown 3 | 4 | from toad.menus import MenuItem 5 | 6 | 7 | class MarkdownNote(Markdown): 8 | def get_block_menu(self) -> Iterable[MenuItem]: 9 | return 10 | yield 11 | 12 | def get_block_content(self, destination: str) -> str | None: 13 | return self.source 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | **Link to issue or discussion** 10 | 11 | 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.5.0] - 2025-12-18 9 | 10 | ### Added 11 | 12 | - First release. This document will be updated for subsequent releases. 13 | 14 | [0.5.0]: https://example.org -------------------------------------------------------------------------------- /tools/make_qr.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "qrcode", 4 | # ] 5 | # /// 6 | 7 | import qrcode 8 | 9 | qr = qrcode.QRCode( 10 | border=0, 11 | version=1, 12 | error_correction=qrcode.constants.ERROR_CORRECT_L, 13 | ) 14 | # qr.add_data('https://github.com/sponsors/willmcgugan') 15 | qr.add_data("https://tinyurl.com/52r7fd25") 16 | qr.make(fit=True) 17 | 18 | # Print using Unicode blocks (looks more square) 19 | qr.print_ascii() 20 | -------------------------------------------------------------------------------- /src/toad/acp/encode_tool_call_id.py: -------------------------------------------------------------------------------- 1 | def encode_tool_call_id(tool_call_id: str) -> str: 2 | """Encode the tool call id so that it fits within a TCSS id. 3 | 4 | Args: 5 | tool_call_id: Raw tool call id. 6 | 7 | Returns: 8 | Tool call usable as widget id. 9 | """ 10 | hex_tool_call_id = "".join(f"{ord(character):2X}" for character in tool_call_id) 11 | encoded_tool_call_id = f"tool-call-{hex_tool_call_id}" 12 | return encoded_tool_call_id 13 | -------------------------------------------------------------------------------- /src/toad/widgets/shell_terminal.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from toad.menus import MenuItem 4 | from toad.widgets.terminal import Terminal 5 | 6 | 7 | class ShellTerminal(Terminal): 8 | """Subclass of Terminal used in the Shell view.""" 9 | 10 | def get_block_menu(self) -> Iterable[MenuItem]: 11 | return 12 | yield 13 | 14 | def get_block_content(self, destination: str) -> str | None: 15 | return "\n".join(line.content.plain for line in self.state.buffer.lines) 16 | -------------------------------------------------------------------------------- /src/toad/widgets/note.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from textual.widgets import Static 3 | 4 | from toad.menus import MenuItem 5 | 6 | 7 | class Note(Static): 8 | DEFAULT_CLASSES = "block" 9 | 10 | def get_block_menu(self) -> Iterable[MenuItem]: 11 | return 12 | yield 13 | 14 | def get_block_content(self, destination: str) -> str | None: 15 | return str(self.render()) 16 | 17 | def action_hello(self, message: str) -> None: 18 | self.notify(message, severity="warning") 19 | -------------------------------------------------------------------------------- /src/toad/prompt/extract.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Iterable 3 | 4 | 5 | RE_MATCH_FILE_PROMPT = re.compile(r"@(\S+)|@\"(.*)\"") 6 | 7 | 8 | def extract_paths_from_prompt(prompt: str) -> Iterable[tuple[str, int, int]]: 9 | """Find file syntax in prompts. 10 | 11 | Args: 12 | prompt: A line of prompt. 13 | 14 | Yields: 15 | A tuple of (PATH, START, END). 16 | """ 17 | for match in RE_MATCH_FILE_PROMPT.finditer(prompt): 18 | path, quoted_path = match.groups() 19 | yield (path or quoted_path, match.start(0), match.end(0)) 20 | -------------------------------------------------------------------------------- /src/toad/conversation_markdown.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Markdown 2 | from textual.widgets._markdown import MarkdownBlock 3 | from textual.content import Content 4 | 5 | 6 | class ConversationCodeFence(Markdown.BLOCKS["fence"]): 7 | pass 8 | 9 | 10 | CUSTOM_BLOCKS = {"fence": ConversationCodeFence} 11 | 12 | 13 | class ConversationMarkdown(Markdown): 14 | """Markdown widget with custom blocks.""" 15 | 16 | def get_block_class(self, block_name: str) -> type[MarkdownBlock]: 17 | if (custom_block := CUSTOM_BLOCKS.get("block_name")) is not None: 18 | return custom_block 19 | return super().get_block_class(block_name) 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | run := uv run toad 3 | 4 | .PHONY: run 5 | run: 6 | $(run) 7 | 8 | .PHONY: gemini-acp 9 | gemini-acp: 10 | $(run) acp "gemini --experimental-acp" --project-dir ~/sandbox --title "Google Gemini" 11 | 12 | .PHONY: claude-acp 13 | claude-acp: 14 | $(run) acp "claude-code-acp" --project-dir ~/sandbox --title "Claude" 15 | 16 | 17 | .PHONE: codex-acp 18 | codex-acp: 19 | $(run) acp "codex-acp" --project-dir ~/sandbox --title="OpenAI Codex" 20 | 21 | .PHONY: replay 22 | replay: 23 | ACP_INITIALIZE=0 $(run) acp "$(run) replay $(realpath replay.jsonl)" --project-dir ~/sandbox 24 | 25 | .PHONY: echo 26 | echo: 27 | $(run) acp "uv run echo_client.py" 28 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # Toad notes 2 | 3 | This is notes.md in the root of the repository. 4 | I'm using this file to keep track of what works in Toad and what doesn't. 5 | 6 | 7 | ## What works 8 | 9 | - ACP support broadly works, barring a few rough edges. 10 | - Slash commands 11 | - Tools calls 12 | - Modes (only reported by Claude so far) 13 | - Terminal 14 | - Settings (press `F2` or `ctrl+,`). 15 | - Multiline prompt (should be intuitive) 16 | - Shell commands 17 | - Colors in shell commands 18 | - Interactive shell commands / terminals, a few rought edges but generally useful 19 | 20 | ## What doesn't work (yet) 21 | 22 | - File tree doesn't do much 23 | - Chat bots have been temporarily disabled 24 | -------------------------------------------------------------------------------- /src/toad/answer.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, NamedTuple 2 | 3 | 4 | """ 5 | allow_once - Allow this operation only this time 6 | allow_always - Allow this operation and remember the choice 7 | reject_once - Reject this operation only this time 8 | reject_always - Reject this operation and remember the choice 9 | """ 10 | 11 | 12 | class Answer(NamedTuple): 13 | """An answer to a question posed by the agent.""" 14 | 15 | text: str 16 | """The textual response.""" 17 | id: str 18 | """The id of the response.""" 19 | kind: ( 20 | Literal["allow_once", "allow_always", "reject_once", "reject_always"] | None 21 | ) = None 22 | """Enumeration to potentially influence UI""" 23 | -------------------------------------------------------------------------------- /src/toad/widgets/welcome.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual import containers 3 | 4 | from textual.widgets import Label, Markdown 5 | 6 | 7 | ASCII_TOAD = r""" 8 | _ _ 9 | (.)_(.) 10 | _ ( _ ) _ 11 | / \/`-----'\/ \ 12 | __\ ( ( ) ) /__ 13 | ) /\ \._./ /\ ( 14 | )_/ /|\ /|\ \_( 15 | """ 16 | 17 | 18 | WELCOME_MD = """\ 19 | ## Toad v1.0 20 | 21 | Welcome, **Will**! 22 | 23 | 24 | """ 25 | 26 | 27 | class Welcome(containers.Vertical): 28 | def compose(self) -> ComposeResult: 29 | with containers.Center(): 30 | yield Label(ASCII_TOAD, id="logo") 31 | yield Markdown(WELCOME_MD, id="message", classes="note") 32 | -------------------------------------------------------------------------------- /project/calculator.tcss: -------------------------------------------------------------------------------- 1 | Screen { 2 | overflow: auto; 3 | } 4 | 5 | #calculator { 6 | layout: grid; 7 | grid-size: 4; 8 | grid-gutter: 1 2; 9 | grid-columns: 1fr; 10 | grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr; 11 | margin: 1 2; 12 | min-height: 25; 13 | min-width: 26; 14 | height: 100%; 15 | 16 | &:inline { 17 | margin: 0 2; 18 | } 19 | } 20 | 21 | Button { 22 | width: 100%; 23 | height: 100%; 24 | } 25 | 26 | #numbers { 27 | column-span: 4; 28 | padding: 0 1; 29 | height: 100%; 30 | background: $panel; 31 | color: $text; 32 | content-align: center middle; 33 | text-align: right; 34 | } 35 | 36 | #number-0 { 37 | column-span: 2; 38 | } 39 | -------------------------------------------------------------------------------- /src/toad/pill.py: -------------------------------------------------------------------------------- 1 | from textual.content import Content 2 | 3 | 4 | def pill(text: Content | str, background: str, foreground: str) -> Content: 5 | """Format text as a pill (half block ends). 6 | 7 | Args: 8 | text: Pill contents as Content object or text. 9 | background: Background color. 10 | foreground: Foreground color. 11 | 12 | Returns: 13 | Pill content. 14 | """ 15 | content = Content(text) if isinstance(text, str) else text 16 | main_style = f"{foreground} on {background}" 17 | end_style = f"{background} on transparent r" 18 | pill_content = Content.assemble( 19 | ("▌", end_style), 20 | content.stylize(main_style), 21 | ("▐", end_style), 22 | ) 23 | return pill_content 24 | -------------------------------------------------------------------------------- /src/toad/ansi/_control_codes.py: -------------------------------------------------------------------------------- 1 | CONTROL_CODES = { 2 | "D": "ind", 3 | "E": "nel", 4 | "H": "hts", 5 | "I": "htj", 6 | "J": "vts", 7 | "K": "pld", 8 | "L": "plu", 9 | "M": "ri", 10 | "N": "ss2", 11 | "O": "ss3", 12 | "P": "dcs", 13 | "Q": "pu1", 14 | "R": "pu2", 15 | "S": "sts", 16 | "T": "cch", 17 | "U": "mw", 18 | "V": "spa", 19 | "W": "epa", 20 | "X": "sos", 21 | "Z": "decid", 22 | "[": "csi", 23 | "\\": "st", 24 | "]": "osc", 25 | "^": "pm", 26 | "_": "apc", 27 | "c": "ris", 28 | "7": "decsc", 29 | "8": "decrc", 30 | "=": "deckpam", 31 | ">": "deckpnm", 32 | "#": "decaln", 33 | "(": "scs_g0", 34 | ")": "scs_g1", 35 | "*": "scs_g2", 36 | "+": "scs_g3", 37 | } 38 | -------------------------------------------------------------------------------- /src/toad/widgets/user_input.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from textual.app import ComposeResult 3 | from textual import containers 4 | from textual.widgets import Markdown 5 | 6 | from toad.menus import MenuItem 7 | from toad.widgets.non_selectable_label import NonSelectableLabel 8 | 9 | 10 | class UserInput(containers.HorizontalGroup): 11 | def __init__(self, content: str) -> None: 12 | super().__init__() 13 | self.content = content 14 | 15 | def compose(self) -> ComposeResult: 16 | yield NonSelectableLabel("❯", id="prompt") 17 | yield Markdown(self.content, id="content") 18 | 19 | def get_block_menu(self) -> Iterable[MenuItem]: 20 | yield from () 21 | 22 | def get_block_content(self, destination: str) -> str | None: 23 | return self.content 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or issue 3 | title: "BUG" 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Issues require an initial discussion and approval by a Toad developer before creating an issue. 10 | - type: input 11 | id: discussion-link 12 | attributes: 13 | label: Discussion link 14 | description: Please enter the discussion link here 15 | placeholder: https://github.com/batrachianai/toad/discussions/123 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: bug-description 21 | attributes: 22 | label: Bug Description 23 | description: Describe the bug you encountered 24 | placeholder: | 25 | What happened? 26 | What did you expect to happen? 27 | Steps to reproduce... 28 | validations: 29 | required: true 30 | -------------------------------------------------------------------------------- /src/toad/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, runtime_checkable, Iterable 2 | 3 | from textual.widget import Widget 4 | 5 | from toad.menus import MenuItem 6 | 7 | 8 | @runtime_checkable 9 | class BlockProtocol(Protocol): 10 | def block_cursor_up(self) -> Widget | None: ... 11 | def block_cursor_down(self) -> Widget | None: ... 12 | def get_cursor_block(self) -> Widget | None: ... 13 | def block_cursor_clear(self) -> None: ... 14 | def block_select(self, widget: Widget) -> None: ... 15 | 16 | 17 | @runtime_checkable 18 | class MenuProtocol(Protocol): 19 | def get_block_menu(self) -> Iterable[MenuItem]: ... 20 | def get_block_content(self, destination: str) -> str | None: ... 21 | 22 | 23 | @runtime_checkable 24 | class ExpandProtocol(Protocol): 25 | def can_expand(self) -> bool: ... 26 | def expand_block(self) -> None: ... 27 | def collapse_block(self) -> None: ... 28 | def is_block_expanded(self) -> bool: ... 29 | -------------------------------------------------------------------------------- /src/toad/code_analyze.py: -------------------------------------------------------------------------------- 1 | from textual.highlight import highlight, guess_language 2 | from pygments.util import ClassNotFound 3 | from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename 4 | from pygments.token import Token 5 | 6 | SPECIAL = {Token.Name.Function.Magic, Token.Name.Function, Token.Name.Class} 7 | 8 | 9 | def get_special_name_from_code(code: str, language: str) -> list[str]: 10 | try: 11 | lexer = get_lexer_by_name( 12 | language, 13 | stripnl=False, 14 | ensurenl=True, 15 | tabsize=8, 16 | ) 17 | except ClassNotFound: 18 | lexer = get_lexer_by_name( 19 | "text", 20 | stripnl=False, 21 | ensurenl=True, 22 | tabsize=8, 23 | ) 24 | special: list[str] = [] 25 | for token_type, token in lexer.get_tokens(code): 26 | if token_type in SPECIAL: 27 | special.append(token) 28 | return special 29 | -------------------------------------------------------------------------------- /src/toad/data/agents/geminicli.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/google-gemini/gemini-cli 3 | identity = "geminicli.com" 4 | name = "Gemini CLI" 5 | short_name = "gemini" 6 | url = "https://geminicli.com/" 7 | protocol = "acp" 8 | author_name = "Google" 9 | author_url = "https://www.gooogle.com" 10 | publisher_name = "Will McGugan" 11 | publisher_url = "https://willmcgugan.github.io/" 12 | type = "coding" 13 | description = "Query and edit large codebases, generate apps from images or PDFs, and automate complex workflows—all from your terminal." 14 | tags = [] 15 | run_command."*" = "gemini --experimental-acp" 16 | 17 | help = ''' 18 | # Gemini CLI 19 | 20 | **Build debug & deploy with AI** 21 | 22 | Query and edit large codebases, generate apps from images or PDFs, and automate complex workflows—all from your terminal. 23 | 24 | ''' 25 | 26 | [actions."*".install] 27 | command = "npm install -g @google/gemini-cli" 28 | description = "Install Gemini CLI" -------------------------------------------------------------------------------- /src/toad/slash_command.py: -------------------------------------------------------------------------------- 1 | import rich.repr 2 | 3 | from textual.content import Content 4 | 5 | 6 | @rich.repr.auto 7 | class SlashCommand: 8 | """A record of a slash command.""" 9 | 10 | def __init__(self, command: str, help: str, hint: str | None = None) -> None: 11 | """ 12 | 13 | Args: 14 | command: The command name. 15 | help: Description of command. 16 | hint: Hint text (displayed as suggestion) 17 | """ 18 | self.command = command 19 | self.help = help 20 | self.hint: str | None = hint 21 | 22 | def __rich_repr__(self) -> rich.repr.Result: 23 | yield self.command 24 | yield "help", self.help 25 | yield "hint", self.hint, None 26 | 27 | def __str__(self) -> str: 28 | return self.command 29 | 30 | @property 31 | def content(self) -> Content: 32 | return Content.assemble( 33 | (self.command, "$text-success"), "\t", (self.help, "dim") 34 | ) 35 | -------------------------------------------------------------------------------- /src/toad/complete.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Iterable 3 | 4 | 5 | class Complete: 6 | """Stores substrings and their potential completions.""" 7 | 8 | def __init__(self) -> None: 9 | self._word_map: defaultdict[str, set[str]] = defaultdict(set) 10 | 11 | def add_words(self, words: Iterable[str]) -> None: 12 | """Add word(s) word map. 13 | 14 | Args: 15 | words: Iterable of words to add. 16 | """ 17 | word_map = self._word_map 18 | for word in words: 19 | for index in range(1, len(word)): 20 | word_map[word[:index]].add(word[index:]) 21 | 22 | def __call__(self, word: str) -> list[str]: 23 | return sorted(self._word_map.get(word, []), key=len) 24 | 25 | 26 | if __name__ == "__main__": 27 | complete = Complete() 28 | complete.add_words(["ls", "ls -al", "echo 'hello'"]) 29 | 30 | print(complete("l")) 31 | 32 | from rich import print 33 | 34 | print(complete._word_map) 35 | -------------------------------------------------------------------------------- /src/toad/data/agents/vibe.mistral.ai.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://www.claude.com/product/claude-code 3 | 4 | identity = "vibe.mistral.ai" 5 | name = "Mistral Vibe" 6 | short_name = "vibe" 7 | url = "https://mistral.ai/news/devstral-2-vibe-cli" 8 | protocol = "acp" 9 | author_name = "Mistral" 10 | author_url = "https://mistral.ai/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "State-of-the-art, open-source agentic coding models and CLI agent." 15 | tags = [] 16 | run_command."*" = "vibe-acp" 17 | 18 | help = ''' 19 | # Devstral2 Mistral Vibe CLI 20 | 21 | Today, we're releasing Devstral 2—our next-generation coding model family available in two sizes: Devstral 2 (123B) and Devstral Small 2 (24B). Devstral 2 ships under a modified MIT license, while Devstral Small 2 uses Apache 2.0. Both are open-source and permissively licensed to accelerate distributed intelligence. 22 | ''' 23 | 24 | 25 | [actions."*".install] 26 | command = "curl -LsSf https://mistral.ai/vibe/install.sh | bash" 27 | description = "Install Mistral Vibe" 28 | -------------------------------------------------------------------------------- /src/toad/data/agents/kimi.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/MoonshotAI/kimi-cli 3 | 4 | identity = "kimi.com" 5 | name = "Kimi CLI" 6 | short_name = "kimi" 7 | url = "https://www.kimi.com/" 8 | protocol = "acp" 9 | author_name = "Moonshot AI" 10 | author_url = "https://www.kimi.com/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Kimi CLI is a new CLI agent that can help you with your software development tasks and terminal operations." 15 | tags = [] 16 | run_command."*" = "kimi --acp" 17 | 18 | help = ''' 19 | # Kimi CLI 20 | 21 | Kimi CLI is a new CLI agent that can help you with your software development tasks and terminal operations. 22 | 23 | See the following [instructions](https://github.com/MoonshotAI/kimi-cli?tab=readme-ov-file#usage) for how to configure Kimi before running. 24 | 25 | ''' 26 | 27 | 28 | [actions."*".install] 29 | command = "uv tool install kimi-cli --no-cache" 30 | description = "Install Kimi CLI" 31 | 32 | 33 | [actions."*".upgrade] 34 | command = "uv tool upgrade kimi-cli --no-cache" 35 | description = "Upgrade Kimi CLI" 36 | -------------------------------------------------------------------------------- /src/toad/widgets/shell_result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Iterable 3 | 4 | from textual.app import ComposeResult 5 | from textual import containers 6 | from textual.highlight import highlight 7 | from textual.widgets import Static 8 | 9 | 10 | from toad.menus import MenuItem 11 | from toad.widgets.non_selectable_label import NonSelectableLabel 12 | 13 | 14 | class ShellResult(containers.HorizontalGroup): 15 | def __init__( 16 | self, 17 | command: str, 18 | *, 19 | name: str | None = None, 20 | id: str | None = None, 21 | classes: str | None = None, 22 | disabled: bool = False, 23 | ) -> None: 24 | self._command = command 25 | super().__init__(name=name, id=id, classes=classes, disabled=disabled) 26 | 27 | def compose(self) -> ComposeResult: 28 | yield NonSelectableLabel("$", id="prompt") 29 | yield Static(highlight(self._command, language="sh")) 30 | 31 | def get_block_menu(self) -> Iterable[MenuItem]: 32 | yield from () 33 | 34 | def get_block_content(self, destination: str) -> str | None: 35 | return self._command 36 | -------------------------------------------------------------------------------- /src/toad/atomic.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | 4 | 5 | class AtomicWriteError(Exception): 6 | """An Atomic write failed.""" 7 | 8 | 9 | def write(path: str, content: str) -> None: 10 | """Write a file in an atomic manner. 11 | 12 | Args: 13 | filename: Filename of new file. 14 | content: Content to write. 15 | 16 | """ 17 | path = os.path.abspath(path) 18 | dir_name = os.path.dirname(path) or "." 19 | try: 20 | with tempfile.NamedTemporaryFile( 21 | mode="w", 22 | encoding="utf-8", 23 | delete=False, 24 | dir=dir_name, 25 | prefix=f".{os.path.basename(path)}_tmp_", 26 | ) as temporary_file: 27 | temporary_file.write(content) 28 | temp_name = temporary_file.name 29 | except Exception as error: 30 | raise AtomicWriteError( 31 | f"Failed to write {path!r}; error creating temporary file: {error}" 32 | ) 33 | 34 | try: 35 | os.replace(temp_name, path) # Atomic on POSIX and Windows 36 | except Exception as error: 37 | raise AtomicWriteError(f"Failed to write {path!r}; {error}") 38 | -------------------------------------------------------------------------------- /src/toad/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Mapping 2 | import platform 3 | 4 | NAME = "toad" 5 | TITLE = "Toad" 6 | 7 | type OS = Literal["linux", "macos", "windows", "*"] 8 | 9 | _system = platform.system() 10 | _OS_map: dict[str, OS] = { 11 | "Linux": "linux", 12 | "Darwin": "macos", 13 | "Windows": "windows", 14 | } 15 | os: OS = _OS_map.get(_system, "linux") 16 | 17 | 18 | def get_os_matrix(matrix: Mapping[OS, str]) -> str | None: 19 | """Get a value from a mapping where the key is an OS, falling back to a wildcard ("*"). 20 | 21 | Args: 22 | matrix: A mapping where an OS literal is the key. 23 | 24 | Returns: 25 | The value, if one is found, or `None`. 26 | """ 27 | if (result := matrix.get(os)) is None: 28 | result = matrix.get("*") 29 | return result 30 | 31 | 32 | def get_version() -> str: 33 | """Get the current version of Toad. 34 | 35 | Returns: 36 | str: Version string, e.g "1.2.3" 37 | """ 38 | from importlib.metadata import version 39 | 40 | try: 41 | return version("batrachian-toad") 42 | except Exception: 43 | try: 44 | return version("toad") 45 | except Exception: 46 | return "0.1.0unknown" 47 | -------------------------------------------------------------------------------- /src/toad/data/agents/inference.huggingface.co.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | 3 | active = true 4 | identity = "inference.huggingface.co" 5 | name = "Hugging Face Inference Providers" 6 | short_name = "hf" 7 | url = "https://huggingface.co" 8 | protocol = "acp" 9 | author_name = "Hugging Face" 10 | author_url = "https://huffingface.co" 11 | publisher_name = "Hugging Face" 12 | publisher_url = "https://huffingface.co" 13 | type = "chat" 14 | description = """ 15 | Chat with the latest open weight models from Hugging Face inference Providers. Create an account at HuggingFace account and register with [b]toad-hf-inference-explorers[/] for [bold $success]$10[/] of free credit!""" 16 | tags = [] 17 | run_command."*" = "hf-inference-acp -x" 18 | 19 | help = ''' 20 | # Hugging Face Inference Providers 21 | 22 | Chat with the latest open weight models using Hugging Face inference providers. 23 | 24 | --- 25 | 26 | Create an account at huggingface.co/join and register with [Toad Explorers](https://huggingface.co/toad-hf-inference-explorers) for **$10** of free credit! 27 | ''' 28 | 29 | recommended = true 30 | 31 | [actions."*".install] 32 | command = "uv tool install -U hf-inference-acp --with-executables-from huggingface_hub --force" 33 | description = "Install Hugging Face Inference Providers" -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "batrachian-toad" 3 | version = "0.5.4" 4 | description = "A unified experience for AI in your terminal." 5 | readme = "README.md" 6 | requires-python = ">=3.14" 7 | dependencies = [ 8 | "textual[syntax]>=6.11.0", 9 | "click>=8.2.1", 10 | "gitpython>=3.1.44", 11 | "tree-sitter>=0.24.0", 12 | "httpx>=0.28.1", 13 | "platformdirs>=4.3.8", 14 | "rich", 15 | "typeguard>=4.4.4", 16 | "xdg-base-dirs>=6.0.2", 17 | "textual-serve>=1.1.2", 18 | "textual-speedups==0.2.1", 19 | "packaging>=25.0", 20 | "bashlex>=0.18", 21 | "pathspec>=0.12.1", 22 | ] 23 | 24 | [tool.uv.workspace] 25 | members = [ 26 | "toad", 27 | ] 28 | 29 | [tool.uv.sources] 30 | #textual-serve = { path = "../textual-serve", editable = true } 31 | #textual = { path = "../textual", editable = true } 32 | #textual-dev = { path = "../textual-dev", editable = true } 33 | #rich = { path = "../rich", editable = true } 34 | 35 | [project.scripts] 36 | toad = "toad.cli:main" 37 | 38 | [build-system] 39 | requires = ["hatchling==1.28.0"] 40 | build-backend = "hatchling.build" 41 | 42 | [tool.hatch.build.targets.wheel] 43 | packages = ["src/toad"] 44 | 45 | [dependency-groups] 46 | dev = [ 47 | "pyinstrument>=5.1.1", 48 | "textual-dev>=1.8.0", 49 | ] 50 | -------------------------------------------------------------------------------- /src/toad/agents.py: -------------------------------------------------------------------------------- 1 | from importlib.resources import files 2 | import asyncio 3 | 4 | from toad.agent_schema import Agent 5 | 6 | 7 | class AgentReadError(Exception): 8 | """Problem reading the agents.""" 9 | 10 | 11 | async def read_agents() -> dict[str, Agent]: 12 | """Read agent information from data/agents 13 | 14 | Raises: 15 | AgentReadError: If the files could not be read. 16 | 17 | Returns: 18 | A mapping of identity on to Agent dict. 19 | """ 20 | import tomllib 21 | 22 | def read_agents() -> list[Agent]: 23 | """Read agent information. 24 | 25 | Stored in data/agents 26 | 27 | Returns: 28 | List of agent dicts. 29 | """ 30 | agents: list[Agent] = [] 31 | try: 32 | for file in files("toad.data").joinpath("agents").iterdir(): 33 | agent: Agent = tomllib.load(file.open("rb")) 34 | if agent.get("active", True): 35 | agents.append(agent) 36 | 37 | except Exception as error: 38 | raise AgentReadError(f"Failed to read agents; {error}") 39 | 40 | return agents 41 | 42 | agents = await asyncio.to_thread(read_agents) 43 | agent_map = {agent["identity"]: agent for agent in agents} 44 | 45 | return agent_map 46 | -------------------------------------------------------------------------------- /src/toad/data/agents/claude.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://www.claude.com/product/claude-code 3 | 4 | identity = "claude.com" 5 | name = "Claude Code" 6 | short_name = "claude" 7 | url = "https://www.claude.com/product/claude-code" 8 | protocol = "acp" 9 | author_name = "Anthropic" 10 | author_url = "https://www.anthropic.com/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Unleash Claude’s raw power directly in your terminal." 15 | tags = [] 16 | run_command."*" = "claude-code-acp" 17 | 18 | help = ''' 19 | # Claude Code 20 | 21 | Built for developers 22 | Unleash Claude’s raw power directly in your terminal. 23 | Search million-line codebases instantly. 24 | Turn hours-long workflows into a single command. 25 | Your tools. 26 | Your workflow. 27 | Your codebase, evolving at thought speed. 28 | 29 | --- 30 | [ACP adapter for Claude Code](https://github.com/zed-industries/claude-code-acp) by Zed Industries. 31 | 32 | ''' 33 | 34 | 35 | [actions."*".install] 36 | command = "curl -fsSL https://claude.ai/install.sh | bash && npm install -g @zed-industries/claude-code-acp" 37 | description = "Install Claude Code + ACP adapter" 38 | 39 | [actions."*".install_acp] 40 | command = "npm install -g @zed-industries/claude-code-acp" 41 | description = "Install ACP adapter" -------------------------------------------------------------------------------- /src/toad/acp/api.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="empty-body" 2 | """ 3 | ACP remote API 4 | """ 5 | 6 | from toad import jsonrpc 7 | from toad.acp import protocol 8 | 9 | API = jsonrpc.API() 10 | 11 | 12 | @API.method() 13 | def initialize( 14 | protocolVersion: int, 15 | clientCapabilities: protocol.ClientCapabilities, 16 | clientInfo: protocol.Implementation, 17 | ) -> protocol.InitializeResponse: 18 | """https://agentclientprotocol.com/protocol/initialization""" 19 | ... 20 | 21 | 22 | @API.method(name="session/new") 23 | def session_new( 24 | cwd: str, mcpServers: list[protocol.McpServer] 25 | ) -> protocol.NewSessionResponse: 26 | """https://agentclientprotocol.com/protocol/session-setup#session-id""" 27 | ... 28 | 29 | 30 | @API.notification(name="session/cancel") 31 | def session_cancel(sessionId: str, _meta: dict): 32 | """https://agentclientprotocol.com/protocol/prompt-turn#cancellation""" 33 | ... 34 | 35 | 36 | @API.method(name="session/prompt") 37 | def session_prompt( 38 | prompt: list[protocol.ContentBlock], sessionId: str 39 | ) -> protocol.SessionPromptResponse: 40 | """https://agentclientprotocol.com/protocol/prompt-turn#1-user-message""" 41 | ... 42 | 43 | 44 | @API.method(name="session/set_mode") 45 | def session_set_mode(sessionId: str, modeId: str) -> protocol.SetSessionModeResponse: 46 | """https://agentclientprotocol.com/protocol/session-modes#from-the-client""" 47 | ... 48 | -------------------------------------------------------------------------------- /src/toad/data/agents/augmentcode.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/augmentcode/auggie 3 | 4 | identity = "augmentcode.com" 5 | name = "Auggie (Augment Code)" 6 | short_name = "auggie" 7 | url = "https://www.augmentcode.com/product/CLI" 8 | protocol = "acp" 9 | author_name = "Augment Code" 10 | author_url = "https://www.augmentcode.com/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "An AI agent that brings Augment Code's power to the terminal with ACP support for Zed, Neovim, and Emacs." 15 | tags = [] 16 | run_command."*" = "auggie --acp" 17 | 18 | help = ''' 19 | # Auggie (Augment Code) 20 | 21 | *The agentic CLI that goes where your code does* 22 | 23 | ## Features 24 | 25 | - **Agent Client Protocol (ACP) Support**: Use Auggie in Zed, Neovim, Emacs, and other ACP-compatible editors 26 | - **Autonomous Code Analysis**: Intelligently explore codebases and build working memory 27 | - **Multi-Editor Integration**: Seamlessly integrates with your favorite development environment 28 | 29 | --- 30 | 31 | **Documentation**: https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli 32 | ''' 33 | 34 | [actions."*".install] 35 | command = "npm install -g @augmentcode/auggie" 36 | description = "Install Auggie CLI (requires Node 22+)" 37 | 38 | [actions."*".login] 39 | command = "auggie login" 40 | description = "Login it Auggie (run once)" -------------------------------------------------------------------------------- /src/toad/shell_read.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import suppress 3 | from time import monotonic 4 | 5 | 6 | async def shell_read( 7 | reader: asyncio.StreamReader, 8 | buffer_size: int, 9 | *, 10 | buffer_period: float | None = 1 / 100, 11 | max_buffer_duration: float = 1 / 60, 12 | ) -> bytes: 13 | """Read data from a stream reader, with buffer logic to reduce the number of chunks. 14 | 15 | Args: 16 | reader: A reader instance. 17 | buffer_size: Maximum buffer size. 18 | buffer_period: Time in seconds where reads are batched, or `None` for no batching. 19 | max_buffer_duration: Maximum time in seconds to buffer. 20 | 21 | Returns: 22 | Bytes read. May be empty on the last read. 23 | """ 24 | try: 25 | data = await reader.read(buffer_size) 26 | except OSError: 27 | data = b"" 28 | if data and buffer_period is not None: 29 | buffer_time = monotonic() + max_buffer_duration 30 | with suppress(asyncio.TimeoutError): 31 | while len(data) < buffer_size and (time := monotonic()) < buffer_time: 32 | async with asyncio.timeout(min(buffer_time - time, buffer_period)): 33 | try: 34 | if chunk := await reader.read(buffer_size - len(data)): 35 | data += chunk 36 | else: 37 | break 38 | except OSError: 39 | break 40 | return data 41 | -------------------------------------------------------------------------------- /src/toad/agent.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from pathlib import Path 4 | 5 | from textual.content import Content 6 | from textual.message import Message 7 | 8 | 9 | class AgentReady(Message): 10 | """Agent is ready.""" 11 | 12 | 13 | @dataclass 14 | class AgentFail(Message): 15 | """Agent failed to start.""" 16 | 17 | message: str 18 | details: str = "" 19 | 20 | 21 | class AgentBase(ABC): 22 | """Base class for an 'agent'.""" 23 | 24 | def __init__(self, project_root: Path) -> None: 25 | self.project_root_path = project_root 26 | super().__init__() 27 | 28 | @abstractmethod 29 | async def send_prompt(self, prompt: str) -> str | None: 30 | """Send a prompt to the agent. 31 | 32 | Args: 33 | prompt: Prompt text. 34 | 35 | Returns: 36 | str: The stop reason. 37 | """ 38 | 39 | async def set_mode(self, mode_id: str) -> str | None: 40 | """Put the agent in a new mode. 41 | 42 | Args: 43 | mode_id: Mode id. 44 | 45 | Returns: 46 | str: The stop reason. 47 | """ 48 | 49 | async def cancel(self) -> bool: 50 | """Cancel prompt. 51 | 52 | Returns: 53 | bool: `True` if success, `False` if the turn wasn't cancelled. 54 | 55 | """ 56 | return False 57 | 58 | def get_info(self) -> Content: 59 | return Content("") 60 | 61 | async def stop(self) -> None: 62 | """Stop the agent (gracefully exit the process)""" 63 | -------------------------------------------------------------------------------- /src/toad/data/agents/openhands.dev.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://www.claude.com/product/claude-code 3 | 4 | identity = "openhands.dev" 5 | name = "OpenHands" 6 | short_name = "openhands" 7 | url = "https://openhands.dev/" 8 | protocol = "acp" 9 | author_name = "OpenHands" 10 | author_url = "https://openhands.dev/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "The open platform for cloud coding agents. Scale from one to thousands of agents — open source, model-agnostic, and enterprise-ready. New users get [$text-success bold]$10[/] in free OpenHands Cloud credits!" 15 | tags = [] 16 | run_command."*" = "openhands acp" 17 | recommended = true 18 | 19 | help = ''' 20 | # OpenHands 21 | 22 | The open platform for cloud coding agents 23 | 24 | Scale from one to thousands of agents -- open source, model agnostic, and enterprise-ready. 25 | 26 | [openhands-dev](https://openhands.dev/) 27 | 28 | --- 29 | 30 | ''' 31 | 32 | welcome = ''' 33 | ## The future of software development must be written by engineers 34 | 35 | Software development is changing. That change needs to happen in the open, driven by a community of professional developers. That's why OpenHands' software agent is MIT-licensed and trusted by a growing community. 36 | 37 | Visit [openhands-dev](https://openhands.dev/) for more information. 38 | ''' 39 | 40 | [actions."*".install] 41 | command = "uv tool install openhands -U --python 3.12 && openhands login" 42 | bootstrap_uv = true 43 | description = "Install OpenHands" 44 | 45 | -------------------------------------------------------------------------------- /src/toad/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Final 3 | 4 | from xdg_base_dirs import xdg_config_home, xdg_data_home, xdg_state_home 5 | 6 | 7 | APP_NAME: Final[str] = "toad" 8 | 9 | 10 | def path_to_name(path: Path) -> str: 11 | """Converts a path to a name (suitable as a path component). 12 | 13 | Args: 14 | path: A path. 15 | 16 | Returns: 17 | A stringified version of the path. 18 | """ 19 | name = str(path.resolve()).lstrip("/").replace("/", "-") 20 | return name 21 | 22 | 23 | def get_data() -> Path: 24 | """Return (possibly creating) the application data directory.""" 25 | path = xdg_data_home() / APP_NAME 26 | path.mkdir(0o700, exist_ok=True, parents=True) 27 | return path 28 | 29 | 30 | def get_config() -> Path: 31 | """Return (possibly creating) the application config directory.""" 32 | path = xdg_config_home() / APP_NAME 33 | path.mkdir(0o700, exist_ok=True, parents=True) 34 | return path 35 | 36 | 37 | def get_state() -> Path: 38 | """Return (possibly creating) the application state directory.""" 39 | path = xdg_state_home() / APP_NAME 40 | path.mkdir(0o700, exist_ok=True, parents=True) 41 | return path 42 | 43 | 44 | def get_project_data(project_path: Path) -> Path: 45 | """Get a directory for per-project data. 46 | 47 | Args: 48 | project_path: Path of project. 49 | 50 | """ 51 | project_data_path = get_data() / path_to_name(project_path) 52 | project_data_path.mkdir(0o700, exist_ok=True, parents=True) 53 | return project_data_path 54 | -------------------------------------------------------------------------------- /src/toad/messages.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from typing import Literal 4 | 5 | from textual.content import Content 6 | from textual.widget import Widget 7 | from textual.message import Message 8 | 9 | 10 | class WorkStarted(Message): 11 | """Work has started.""" 12 | 13 | 14 | class WorkFinished(Message): 15 | """Work has finished.""" 16 | 17 | 18 | @dataclass 19 | class HistoryMove(Message): 20 | """Getting a new item form history.""" 21 | 22 | direction: Literal[-1, +1] 23 | shell: bool 24 | body: str 25 | 26 | 27 | @dataclass 28 | class UserInputSubmitted(Message): 29 | body: str 30 | shell: bool = False 31 | auto_complete: bool = False 32 | 33 | 34 | @dataclass 35 | class PromptSuggestion(Message): 36 | suggestion: str 37 | 38 | 39 | @dataclass 40 | class Dismiss(Message): 41 | widget: Widget 42 | 43 | @property 44 | def control(self) -> Widget: 45 | return self.widget 46 | 47 | 48 | @dataclass 49 | class InsertPath(Message): 50 | path: str 51 | 52 | 53 | @dataclass 54 | class ChangeMode(Message): 55 | mode_id: str | None 56 | 57 | 58 | @dataclass 59 | class Flash(Message): 60 | """Request a message flash. 61 | 62 | Args: 63 | Message: Content of flash. 64 | style: Semantic style. 65 | duration: Duration in seconds or `None` for default. 66 | """ 67 | 68 | content: str | Content 69 | style: Literal["default", "warning", "success", "error"] 70 | duration: float | None = None 71 | 72 | 73 | class ProjectDirectoryUpdated(Message): 74 | """The project directory may may changed.""" 75 | -------------------------------------------------------------------------------- /src/toad/widgets/agent_thought.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import ClassVar 3 | 4 | from textual.binding import Binding, BindingType 5 | from textual.reactive import var 6 | from textual.widgets import Markdown 7 | from textual.widgets.markdown import MarkdownStream 8 | 9 | 10 | class AgentThought(Markdown, can_focus=True): 11 | """The agent's 'thoughts'.""" 12 | 13 | BINDINGS: ClassVar[list[BindingType]] = [ 14 | Binding("up", "scroll_up", "Scroll Up", show=False), 15 | Binding("down", "scroll_down", "Scroll Down", show=False), 16 | Binding("left", "scroll_left", "Scroll Left", show=False), 17 | Binding("right", "scroll_right", "Scroll Right", show=False), 18 | Binding("home", "scroll_home", "Scroll Home", show=False), 19 | Binding("end", "scroll_end", "Scroll End", show=False), 20 | Binding("pageup", "page_up", "Page Up", show=False), 21 | Binding("pagedown", "page_down", "Page Down", show=False), 22 | Binding("ctrl+pageup", "page_left", "Page Left", show=False), 23 | Binding("ctrl+pagedown", "page_right", "Page Right", show=False), 24 | ] 25 | 26 | ALLOW_MAXIMIZE = True 27 | _stream: var[MarkdownStream | None] = var(None) 28 | 29 | def watch_loading(self, loading: bool) -> None: 30 | self.set_class(loading, "-loading") 31 | 32 | @property 33 | def stream(self) -> MarkdownStream: 34 | if self._stream is None: 35 | self._stream = self.get_stream(self) 36 | return self._stream 37 | 38 | async def append_fragment(self, fragment: str) -> None: 39 | self.loading = False 40 | await self.stream.write(fragment) 41 | self.scroll_end() 42 | -------------------------------------------------------------------------------- /tools/echo_client.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # dependencies = [ 3 | # "agent-client-protocol" 4 | # ] 5 | # /// 6 | 7 | import asyncio 8 | 9 | from acp import ( 10 | Agent, 11 | AgentSideConnection, 12 | InitializeRequest, 13 | InitializeResponse, 14 | NewSessionRequest, 15 | NewSessionResponse, 16 | PromptRequest, 17 | PromptResponse, 18 | session_notification, 19 | stdio_streams, 20 | text_block, 21 | update_agent_message, 22 | ) 23 | 24 | 25 | class EchoAgent(Agent): 26 | def __init__(self, conn): 27 | self._conn = conn 28 | 29 | async def initialize(self, params: InitializeRequest) -> InitializeResponse: 30 | return InitializeResponse(protocolVersion=params.protocolVersion) 31 | 32 | async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: 33 | return NewSessionResponse(sessionId="sess-1") 34 | 35 | async def prompt(self, params: PromptRequest) -> PromptResponse: 36 | for block in params.prompt: 37 | text = ( 38 | block.get("text", "") 39 | if isinstance(block, dict) 40 | else getattr(block, "text", "") 41 | ) 42 | await self._conn.sessionUpdate( 43 | session_notification( 44 | params.sessionId, 45 | update_agent_message(text_block(text)), 46 | ) 47 | ) 48 | return PromptResponse(stopReason="end_turn") 49 | 50 | 51 | async def main() -> None: 52 | reader, writer = await stdio_streams() 53 | AgentSideConnection(lambda conn: EchoAgent(conn), writer, reader) 54 | await asyncio.Event().wait() 55 | 56 | 57 | if __name__ == "__main__": 58 | asyncio.run(main()) 59 | -------------------------------------------------------------------------------- /src/toad/screens/permissions.tcss: -------------------------------------------------------------------------------- 1 | # Styles for the Permissions Screen 2 | 3 | 4 | PermissionsScreen { 5 | 6 | 7 | background: $background; 8 | 9 | #instructions { 10 | background: black 10%; 11 | border: tall black 10%; 12 | padding: 0 2; 13 | # margin: 0 0 1 0; 14 | 15 | } 16 | 17 | .top { 18 | height: 1fr; 19 | background: $background; 20 | margin: 1; 21 | padding: 0 0; 22 | grid-size: 2 2; 23 | grid-columns: auto 1fr; 24 | grid-rows: auto 1fr; 25 | } 26 | 27 | #changes { 28 | width: 1fr; 29 | height: 1fr; 30 | } 31 | 32 | OptionList#navigator { 33 | width: auto; 34 | height: 1fr; 35 | min-width: 20; 36 | width: 1fr; 37 | expand: optimal; 38 | } 39 | Question { 40 | width: auto; 41 | expand: optimal; 42 | border: tall black 10%; 43 | background: black 10%; 44 | # dock: bottom; 45 | height: auto; 46 | margin: 1 0; 47 | padding: 0 2 0 1; 48 | &:focus { 49 | border: tall $primary; 50 | } 51 | } 52 | 53 | #tool-container { 54 | background: $background; 55 | height: 1fr; 56 | border: tall black 10%; 57 | margin-top: 1; 58 | &:focus { 59 | border: tall $primary; 60 | } 61 | } 62 | 63 | #nav-container { 64 | width: auto; 65 | height: 1fr; 66 | Select { 67 | width: auto; 68 | height: auto; 69 | expand: optimal; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/toad/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains constants, which may be set in environment variables. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import os 8 | from typing import Final 9 | 10 | get_environ = os.environ.get 11 | 12 | 13 | def _get_environ_bool(name: str, default: bool = False) -> bool: 14 | """Check an environment variable switch. 15 | 16 | Args: 17 | name: Name of environment variable. 18 | 19 | Returns: 20 | `True` if the env var is "1", otherwise `False`. 21 | """ 22 | has_environ = get_environ(name, "1" if default else "0") == "1" 23 | return has_environ 24 | 25 | 26 | def _get_environ_int( 27 | name: str, default: int, minimum: int | None = None, maximum: int | None = None 28 | ) -> int: 29 | """Retrieves an integer environment variable. 30 | 31 | Args: 32 | name: Name of environment variable. 33 | default: The value to use if the value is not set, or set to something other 34 | than a valid integer. 35 | minimum: Optional minimum value. 36 | 37 | Returns: 38 | The integer associated with the environment variable if it's set to a valid int 39 | or the default value otherwise. 40 | """ 41 | try: 42 | value = int(os.environ[name]) 43 | except KeyError: 44 | return default 45 | except ValueError: 46 | return default 47 | if minimum is not None: 48 | return max(minimum, value) 49 | if maximum is not None: 50 | return min(maximum, value) 51 | return value 52 | 53 | 54 | ACP_INITIALIZE: Final[bool] = _get_environ_bool("TOAD_ACP_INITIALIZE", True) 55 | """Initialize ACP agents?""" 56 | 57 | DEBUG: Final[bool] = _get_environ_bool("DEBUG", False) 58 | """Debug flag.""" 59 | -------------------------------------------------------------------------------- /src/toad/data/agents/openai.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/openai/codex 3 | 4 | identity = "openai.com" 5 | name = "Codex CLI" 6 | short_name = "codex" 7 | url = "https://developers.openai.com/codex/cli/" 8 | protocol = "acp" 9 | author_name = "OpenAI" 10 | author_url = "https://www.openai.com/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Lightweight coding agent by OpenAI that runs in your terminal with native ACP support." 15 | tags = [] 16 | run_command."*" = "npx @zed-industries/codex-acp" 17 | 18 | help = ''' 19 | # Codex CLI 20 | 21 | **Lightweight coding agent that runs in your terminal** 22 | 23 | Codex CLI is OpenAI's terminal-based coding agent with built-in support for the Agent Client Protocol. 24 | 25 | ## Features 26 | 27 | - **Agent Client Protocol (ACP)**: Native ACP support for seamless editor integration 28 | - **Zed Integration**: Built-in support in Zed IDE (v0.208+) 29 | - **Terminal-First**: Designed for developers who live in the command line 30 | 31 | ## ACP Integration 32 | 33 | Codex works out-of-the-box with ACP-compatible editors: 34 | - Zed: Open agent panel (cmd-?/ctrl-?) and start a new Codex thread 35 | - Other ACP clients: Use the `codex-acp` command 36 | 37 | ## Installation 38 | 39 | Install globally via npm or Homebrew: 40 | - npm: `npm i -g @openai/codex` 41 | - Homebrew: `brew install --cask codex` 42 | 43 | For ACP adapter (used by editors): Install from https://github.com/zed-industries/codex-acp/releases 44 | 45 | --- 46 | 47 | **GitHub**: https://github.com/openai/codex 48 | **ACP Adapter**: https://github.com/zed-industries/codex-acp 49 | ''' 50 | 51 | [actions."*".install] 52 | command = "npm install -g @openai/codex" 53 | description = "Install Codex CLI" 54 | -------------------------------------------------------------------------------- /src/toad/data/agents/goose.ai.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/block/goose 3 | 4 | identity = "goose.ai" 5 | name = "Goose" 6 | short_name = "goose" 7 | url = "https://block.github.io/goose/" 8 | protocol = "acp" 9 | author_name = "Block" 10 | author_url = "https://block.xyz/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "An open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM." 15 | tags = [] 16 | run_command."*" = "goose acp" 17 | 18 | help = ''' 19 | # Goose 🪿 20 | 21 | **An open source, extensible AI agent** 22 | 23 | Goose is an open framework for AI agents that goes beyond code suggestions to install dependencies, execute commands, edit files, and run tests. 24 | 25 | ## Key Features 26 | 27 | - **Extensible Framework**: Plugin-based architecture for custom tools and behaviors 28 | - **Multi-LLM Support**: Works with various LLM providers 29 | - **Agent Client Protocol (ACP)**: Native ACP support for editor integration 30 | - **Multiple Interfaces**: CLI, and ACP server modes 31 | 32 | ## Configuration 33 | 34 | You can override ACP configurations using environment variables: 35 | - `GOOSE_PROVIDER`: Set your preferred LLM provider 36 | - `GOOSE_MODEL`: Specify the model to use 37 | 38 | --- 39 | 40 | **Documentation**: https://block.github.io/goose/docs/guides/acp-clients/ 41 | **GitHub**: https://github.com/block/goose 42 | **Quickstart**: https://block.github.io/goose/docs/quickstart/ 43 | ''' 44 | 45 | [actions."*".install] 46 | command = "curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash" 47 | description = "Install Goose" 48 | 49 | [action."*".update] 50 | command = "Update Goose" 51 | description = "Update Goose" 52 | -------------------------------------------------------------------------------- /src/toad/data/agents/ampcode.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/tao12345666333/amp-acp 3 | 4 | identity = "ampcode.com" 5 | name = "Amp (AmpCode)" 6 | short_name = "amp" 7 | url = "https://ampcode.com" 8 | protocol = "acp" 9 | author_name = "AmpCode" 10 | author_url = "https://ampcode.com" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Open-source ACP adapter that exposes the Amp CLI to editors such as Zed via the Agent Client Protocol." 15 | tags = [] 16 | run_command."*" = "npx -y amp-acp" 17 | 18 | help = ''' 19 | # Amp (AmpCode) 20 | 21 | Amp is a frontier coding agent for your terminal and editor, built by Sourcegraph. 22 | 23 | - **Multi-Model** Sonnet, GPT-5, fast models—Amp uses them all, for what each model is best at. 24 | - **Opinionated** You're always using the good parts of Amp. If we don't use and love a feature, we kill it. 25 | - **On the Frontier** Amp goes where the models take it. No backcompat, no legacy features. 26 | - **Threads** You can save and share your interactions with Amp. You wouldn't code without version control, would you? 27 | 28 | ## Prerequisites 29 | 30 | - Node.js 18+ so `npx` can run the adapter 31 | - Ensure the `AMP_EXECUTABLE` environment variable points at your Amp binary (or place `amp` on `PATH`) 32 | 33 | 34 | --- 35 | 36 | ## ACP adapter for AmpCode 37 | 38 | **Repository**: https://github.com/tao12345666333/amp-acp 39 | ''' 40 | 41 | [actions."*".install] 42 | command = "curl -fsSL https://ampcode.com/install.sh | bash && npm install -g amp-acp" 43 | description = "Install AMP Code" 44 | 45 | [actions."*".install_adapter] 46 | command = "npm install -g amp-acp" 47 | description = "Install the Amp ACP adapter" 48 | 49 | [actions."*".login] 50 | command = "amp login" 51 | description = "Login to Amp (run once)" -------------------------------------------------------------------------------- /src/toad/prompt/resource.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import mimetypes 3 | from pathlib import Path 4 | 5 | 6 | @dataclass 7 | class Resource: 8 | root: Path 9 | path: Path 10 | mime_type: str 11 | text: str | None 12 | data: bytes | None 13 | 14 | 15 | class ResourceError(Exception): 16 | """An error occurred reading a resource.""" 17 | 18 | 19 | class ResourceNotRelative(ResourceError): 20 | """Attempted to read a resource, not in the project directory.""" 21 | 22 | 23 | class ResourceReadError(ResourceError): 24 | """Failed to read the resource.""" 25 | 26 | 27 | def load_resource(root: Path, path: Path) -> Resource: 28 | """Load a resource from the project directory. 29 | 30 | Args: 31 | root: The project root. 32 | path: Relative path within project. 33 | 34 | Returns: 35 | A resource. 36 | """ 37 | resource_path = root / path 38 | 39 | if not resource_path.is_relative_to(root): 40 | raise ResourceNotRelative("Resource path is not relative to project root.") 41 | 42 | mime_type, encoding = mimetypes.guess_file_type(resource_path) 43 | if mime_type is None: 44 | mime_type = "application/octet-stream" 45 | 46 | data: bytes | None 47 | text: str | None 48 | 49 | try: 50 | if encoding is not None: 51 | data = resource_path.read_bytes() 52 | text = None 53 | else: 54 | data = None 55 | text = resource_path.read_text(encoding, errors="replace") 56 | except FileNotFoundError: 57 | raise ResourceReadError(f"File not found {str(path)!r}") 58 | except Exception as error: 59 | raise ResourceReadError(f"Failed to read {str(path)!r}; {error}") 60 | 61 | resource = Resource( 62 | root, 63 | resource_path, 64 | mime_type=mime_type, 65 | text=text, 66 | data=data, 67 | ) 68 | return resource 69 | -------------------------------------------------------------------------------- /src/toad/acp/prompt.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from pathlib import Path 3 | 4 | from toad.acp import protocol 5 | from toad.prompt.extract import extract_paths_from_prompt 6 | from toad.prompt.resource import load_resource, ResourceError 7 | 8 | 9 | def build(project_path: Path, prompt: str) -> list[protocol.ContentBlock]: 10 | """Build the prompt structure and extract paths with the @ syntax. 11 | 12 | Args: 13 | project_path: The project root. 14 | prompt: The prompt text. 15 | 16 | Returns: 17 | A list of content blocks. 18 | """ 19 | prompt_content: list[protocol.ContentBlock] = [] 20 | 21 | prompt_content.append({"type": "text", "text": prompt}) 22 | for path, _, _ in extract_paths_from_prompt(prompt): 23 | if path.endswith("/"): 24 | continue 25 | try: 26 | resource = load_resource(project_path, Path(path)) 27 | except ResourceError: 28 | # TODO: How should this be handled? 29 | continue 30 | uri = f"file://{resource.path.absolute().resolve()}" 31 | if resource.text is not None: 32 | prompt_content.append( 33 | { 34 | "type": "resource", 35 | "resource": { 36 | "uri": uri, 37 | "text": resource.text, 38 | "mimeType": resource.mime_type, 39 | }, 40 | } 41 | ) 42 | elif resource.data is not None: 43 | prompt_content.append( 44 | { 45 | "type": "resource", 46 | "resource": { 47 | "uri": uri, 48 | "blob": base64.b64encode(resource.data).decode("utf-8"), 49 | "mimeType": resource.mime_type, 50 | }, 51 | } 52 | ) 53 | 54 | return prompt_content 55 | -------------------------------------------------------------------------------- /src/toad/widgets/strike_text.py: -------------------------------------------------------------------------------- 1 | from time import monotonic 2 | 3 | from textual.widget import Widget 4 | 5 | from textual.content import Content 6 | from textual.reactive import reactive 7 | 8 | 9 | class StrikeText(Widget): 10 | DEFAULT_CSS = """ 11 | StrikeText { 12 | height: auto; 13 | } 14 | """ 15 | 16 | strike_time: reactive[float | None] = reactive(None) 17 | 18 | def __init__( 19 | self, 20 | content: Content, 21 | name: str | None = None, 22 | id: str | None = None, 23 | classes: str | None = None, 24 | ): 25 | self.content = content 26 | super().__init__(name=name, id=id, classes=classes) 27 | 28 | def strike(self) -> None: 29 | self.strike_time = monotonic() 30 | self.auto_refresh = 1 / 30 31 | 32 | def render(self) -> Content: 33 | content = self.content 34 | if self.strike_time is not None: 35 | position = int((monotonic() - self.strike_time) * 70) 36 | content = content.stylize("strike", 0, position) 37 | if position > len(content): 38 | self.auto_refresh = None 39 | return content 40 | 41 | 42 | if __name__ == "__main__": 43 | from textual.app import App, ComposeResult 44 | from textual.widgets import Static 45 | 46 | class StrikeApp(App): 47 | CSS = """ 48 | Screen { 49 | overflow: auto; 50 | } 51 | 52 | """ 53 | BINDINGS = [("space", "strike", "Strike")] 54 | 55 | def compose(self) -> ComposeResult: 56 | for n in range(20): 57 | yield Static("HELLO") 58 | yield StrikeText(Content("Where there is a Will, there is a way")) 59 | for n in range(200): 60 | yield Static("World") 61 | 62 | def action_strike(self): 63 | self.query_one(StrikeText).strike() 64 | 65 | app = StrikeApp() 66 | app.run() 67 | -------------------------------------------------------------------------------- /src/toad/widgets/danger_warning.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from textual.content import Content 3 | 4 | from textual.widget import Widget 5 | 6 | 7 | from toad.danger import DangerLevel 8 | 9 | 10 | class DangerWarning(Widget): 11 | DEFAULT_CSS = """ 12 | DangerWarning { 13 | height: auto; 14 | padding: 0 1; 15 | margin: 1 0; 16 | &.-dangerous { 17 | color: $text-warning; 18 | border: round $warning; 19 | } 20 | 21 | &.-destructive { 22 | color: $text-error; 23 | border: round $error; 24 | } 25 | } 26 | 27 | """ 28 | 29 | def __init__( 30 | self, 31 | level: Literal[DangerLevel.DANGEROUS, DangerLevel.DESTRUCTIVE], 32 | *, 33 | id: str | None = None, 34 | classes: str | None = None, 35 | ): 36 | self.level = level 37 | super().__init__(id=id, classes=classes) 38 | 39 | def on_mount(self) -> None: 40 | if self.level == DangerLevel.DANGEROUS: 41 | self.add_class("-dangerous") 42 | elif self.level == DangerLevel.DESTRUCTIVE: 43 | self.add_class("-destructive") 44 | 45 | def render(self) -> Content: 46 | if self.level == DangerLevel.DANGEROUS: 47 | return Content.from_markup( 48 | "🐸 Potentially dangeous operation — [dim]please review carefully!" 49 | ) 50 | else: 51 | return Content.from_markup( 52 | "🐸 [b]Destructive operation[/b] (may alter files outside of project directory) — [dim]please review carefully!" 53 | ) 54 | 55 | 56 | if __name__ == "__main__": 57 | from textual.app import App, ComposeResult 58 | 59 | class DangerApp(App): 60 | def compose(self) -> ComposeResult: 61 | yield DangerWarning(DangerLevel.DANGEROUS) 62 | yield DangerWarning(DangerLevel.DESTRUCTIVE) 63 | 64 | app = DangerApp() 65 | app.run() 66 | -------------------------------------------------------------------------------- /src/toad/data/agents/stakpak.dev.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/stakpak/agent 3 | 4 | identity = "stakpak.dev" 5 | name = "Stakpak Agent" 6 | short_name = "stakpak" 7 | url = "https://stakpak.dev/" 8 | protocol = "acp" 9 | author_name = "Stakpak" 10 | author_url = "https://stakpak.dev/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Terminal-native DevOps Agent in Rust with enterprise-grade security, ACP support, and IaC generation capabilities." 15 | tags = [] 16 | run_command."*" = "stakpak acp" 17 | 18 | help = ''' 19 | # Stakpak Agent 20 | 21 | **The most secure agent built for operations & DevOps** 22 | 23 | Stakpak is a terminal-native DevOps Agent built in Rust with enterprise-grade security features and Agent Client Protocol support. 24 | 25 | ## Key Features 26 | 27 | - **Enterprise-Grade Security**: 28 | - Mutual TLS (mTLS) encryption 29 | - Dynamic secret redaction 30 | - Privacy-first architecture 31 | - **DevOps Capabilities**: Run commands, edit files, search docs, and generate high-quality IaC 32 | - **Agent Client Protocol (ACP)**: Native support for editor integration 33 | - **Rust Performance**: Built in Rust for speed and reliability 34 | 35 | ## ACP Integration 36 | 37 | Stakpak implements the Agent Client Protocol, enabling integration with ACP-compatible editors and development environments like Zed, Neovim, and others. 38 | 39 | ## Security 40 | 41 | Stakpak emphasizes security with: 42 | - End-to-end encryption via mTLS 43 | - Automatic detection and redaction of sensitive information 44 | - Privacy-first design principles 45 | 46 | ## Use Cases 47 | 48 | - Infrastructure as Code (IaC) generation 49 | - DevOps automation 50 | - Secure operations in production environments 51 | - Terminal-based development workflows 52 | 53 | --- 54 | 55 | **GitHub**: https://github.com/stakpak/agent 56 | **Website**: https://stakpak.dev/ 57 | ''' 58 | 59 | [actions."*".install] 60 | command = "cargo install stakpak" 61 | description = "Install Stakpak Agent via Cargo" 62 | -------------------------------------------------------------------------------- /src/toad/data/agents/opencode.ai.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/sst/opencode 3 | 4 | identity = "opencode.ai" 5 | name = "OpenCode" 6 | short_name = "opencode" 7 | url = "https://opencode.ai/" 8 | protocol = "acp" 9 | author_name = "SST" 10 | author_url = "https://sst.dev/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "The AI coding agent built for the terminal with client/server architecture and ACP support via adapter." 15 | tags = [] 16 | run_command."*" = "opencode acp" 17 | 18 | help = ''' 19 | # OpenCode 20 | 21 | **The AI coding agent built for the terminal** 22 | 23 | OpenCode is an open source agent that helps you write and run code directly from the terminal with a flexible client/server architecture. 24 | 25 | ## Key Features 26 | 27 | - **Client/Server Architecture**: Run OpenCode on your computer while controlling it remotely 28 | - **Terminal-Native**: Built for developers who work in the command line 29 | - **Multi-LLM Support**: Works with various AI providers 30 | - **GitHub Integration**: Deep integration with GitHub workflows 31 | 32 | ## ACP Integration 33 | 34 | OpenCode supports the Agent Client Protocol via the **opencode-acp** adapter by josephschmitt. This adapter: 35 | - Launches a per-session MCP HTTP server 36 | - Proxies file and terminal actions back to ACP 37 | - Translates OpenCode streaming updates to ACP notifications 38 | - Enables OpenCode to work with ACP-compatible editors 39 | 40 | The ACP adapter allows standardized communication between OpenCode and various development tools. 41 | 42 | ## Installation 43 | 44 | Install OpenCode globally: 45 | ```bash 46 | npm install -g opencode 47 | ``` 48 | 49 | For ACP support, install the adapter from: 50 | https://github.com/josephschmitt/opencode-acp 51 | 52 | --- 53 | 54 | **Website**: https://opencode.ai/ 55 | **GitHub**: https://github.com/sst/opencode 56 | **ACP Adapter**: https://github.com/josephschmitt/opencode-acp 57 | ''' 58 | 59 | [actions."*".install] 60 | command = "npm i -g opencode-ai" 61 | description = "Install OpenCode" 62 | -------------------------------------------------------------------------------- /src/toad/option_content.py: -------------------------------------------------------------------------------- 1 | from textual.content import Content 2 | from textual.css.styles import RulesMap 3 | from textual.style import Style 4 | from textual.visual import Visual, RenderOptions 5 | from textual.strip import Strip 6 | 7 | from itertools import zip_longest 8 | 9 | 10 | class OptionContent(Visual): 11 | def __init__(self, option: str | Content, help: str | Content) -> None: 12 | self.option = Content(option) if isinstance(option, str) else option 13 | self.help = Content(help) if isinstance(help, str) else help 14 | self._label = Content(f"{option} {help}") 15 | 16 | def __str__(self) -> str: 17 | return str(self.option) 18 | 19 | def render_strips( 20 | self, width: int, height: int | None, style: Style, options: RenderOptions 21 | ) -> list[Strip]: 22 | option_strips = [ 23 | Strip( 24 | self.option.render_segments(style), cell_length=self.option.cell_length 25 | ) 26 | ] 27 | 28 | option_width = self.option.cell_length 29 | remaining_width = width - self.option.cell_length 30 | 31 | help_strips = self.help.render_strips( 32 | remaining_width, None, style, options=options 33 | ) 34 | help_width = max(strip.cell_length for strip in help_strips) 35 | help_width = [strip.extend_cell_length(help_width) for strip in help_strips] 36 | 37 | strips: list[Strip] = [] 38 | for option_strip, help_strip in zip_longest(option_strips, help_strips): 39 | if option_strip is None: 40 | option_strip = Strip.blank(option_width) 41 | assert isinstance(help_strip, Strip) 42 | strips.append(Strip.join([option_strip, help_strip])) 43 | return strips 44 | 45 | def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: 46 | return self._label.get_optimal_width(rules, container_width) 47 | 48 | def get_height(self, rules: RulesMap, width: int) -> int: 49 | label_width = self.option.cell_length + 1 50 | height = self.help.get_height(rules, width - label_width) 51 | return height 52 | -------------------------------------------------------------------------------- /src/toad/data/agents/docker.com.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/docker/cagent 3 | 4 | identity = "docker.com" 5 | name = "Docker cagent" 6 | short_name = "cagent" 7 | url = "https://docs.docker.com/ai/cagent/" 8 | protocol = "acp" 9 | author_name = "Docker" 10 | author_url = "https://www.docker.com/" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Agent Builder and Runtime by Docker Engineering. Build, orchestrate, and share AI agents with MCP and ACP support." 15 | tags = [] 16 | run_command."*" = "cagent acp" 17 | recommended = false 18 | 19 | help = ''' 20 | # Docker cagent 21 | 22 | **Agent Builder and Runtime by Docker Engineering** 23 | 24 | Docker cagent lets you build, orchestrate, and share AI agents that work together as a team. 25 | 26 | ## Key Features 27 | 28 | - **Hierarchical Agent System**: Intelligent task delegation between multiple agents 29 | - **Model Context Protocol (MCP)**: Rich tool ecosystem via MCP integration 30 | - **Multiple Interfaces**: CLI, TUI, API server, and MCP server modes 31 | - **Share & Distribute**: Package and share agents to Docker Hub as OCI artifacts 32 | 33 | ## Agent Client Protocol Support 34 | 35 | cagent supports ACP, enabling integration with ACP-compatible editors and development environments. 36 | 37 | ## Installation 38 | 39 | The easiest way to get cagent is to install Docker Desktop version 4.49 or later, which includes cagent. 40 | 41 | ## Distribution 42 | 43 | Agent configurations can be packaged and shared using the `cagent push` command, treating agents as reproducible OCI artifacts. 44 | 45 | --- 46 | 47 | **Documentation**: https://docs.docker.com/ai/cagent/ 48 | **GitHub**: https://github.com/docker/cagent 49 | **Blog Post**: https://www.docker.com/blog/cagent-build-and-distribute-ai-agents-and-workflows/ 50 | ''' 51 | 52 | welcome = ''' 53 | Say "hello" to CAgent! 54 | 55 | ''' 56 | 57 | [actions."*".install] 58 | command = "echo 'Install Docker Desktop 4.49+ which includes cagent: https://www.docker.com/products/docker-desktop/'" 59 | description = "Install Docker Desktop with cagent" 60 | -------------------------------------------------------------------------------- /src/toad/data/agents/vtcode.dev.toml: -------------------------------------------------------------------------------- 1 | # Schema defined in agent_schema.py 2 | # https://github.com/vinhnx/vtcode 3 | 4 | identity = "vtcode.dev" 5 | name = "VT Code" 6 | short_name = "vtcode" 7 | url = "https://github.com/vinhnx/vtcode" 8 | protocol = "acp" 9 | author_name = "Vinh Nguyen" 10 | author_url = "https://github.com/vinhnx" 11 | publisher_name = "Will McGugan" 12 | publisher_url = "https://willmcgugan.github.io/" 13 | type = "coding" 14 | description = "Rust-based terminal coding agent with semantic code intelligence via Tree-sitter, ast-grep, and native Zed IDE integration via ACP." 15 | tags = [] 16 | run_command."*" = "vtcode acp" 17 | 18 | help = ''' 19 | # VT Code 20 | 21 | **Semantic Coding Agent** 22 | 23 | VT Code is a Rust-based terminal coding agent with semantic code intelligence and native support for the Agent Client Protocol. 24 | 25 | ## Key Features 26 | 27 | - **Semantic Code Intelligence**: 28 | - Tree-sitter integration for syntax-aware analysis 29 | - ast-grep integration for semantic search 30 | - Advanced token budget tracking 31 | - **Multi-LLM Support**: Works with multiple LLM providers with automatic failover 32 | - **Rich Terminal UI**: Real-time streaming in a beautiful TUI 33 | - **Editor Integration**: Native support for Zed IDE via ACP 34 | - **Security**: Defense-in-depth security model 35 | 36 | ## Smart Tools 37 | 38 | - Built-in code analysis and refactoring 39 | - File operations with semantic understanding 40 | - Terminal command execution 41 | - Lifecycle hooks for custom shell commands 42 | 43 | ## Agent Client Protocol (ACP) 44 | 45 | VT Code integrates natively with Zed IDE and other ACP-compatible editors. The ACP standardizes communication between code editors and coding agents. 46 | 47 | ## Context Management 48 | 49 | Efficient context curation with: 50 | - Semantic search capabilities 51 | - Token budget tracking 52 | - Smart context window management 53 | 54 | --- 55 | 56 | **GitHub**: https://github.com/vinhnx/vtcode 57 | **Author**: Vinh Nguyen (@vinhnx) 58 | ''' 59 | 60 | [actions."*".install] 61 | command = "cargo install --git https://github.com/vinhnx/vtcode" 62 | description = "Install VT Code via Cargo" 63 | -------------------------------------------------------------------------------- /src/toad/screens/settings.tcss: -------------------------------------------------------------------------------- 1 | SettingsScreen { 2 | overflow: hidden; 3 | background: $background 60%; 4 | align-horizontal: right; 5 | 6 | #contents { 7 | width: 50%; 8 | padding: 0 1; 9 | background: black 10%; 10 | & > VerticalScroll { 11 | overflow: hidden scroll; 12 | } 13 | } 14 | 15 | .hidden { 16 | display: none; 17 | } 18 | 19 | .setting:last-child .input { 20 | margin: 1 0 0 0; 21 | } 22 | 23 | Input,Select SelectCurrent, Checkbox,TextArea { 24 | border: tall black 20%; 25 | &:focus { 26 | border: tall $primary; 27 | } 28 | } 29 | Select:focus > SelectCurrent { 30 | border: tall $primary; 31 | } 32 | 33 | .input { 34 | margin: 1 0 1 0; 35 | } 36 | 37 | Input.-invalid { 38 | border: tall $error 60%; 39 | } 40 | 41 | TextArea { 42 | height: 10; 43 | } 44 | 45 | .search-container { 46 | dock: top; 47 | padding: 1 0; 48 | } 49 | 50 | .setting-group { 51 | padding: 0; 52 | & > .setting-object:last-child { 53 | margin-bottom: 0; 54 | } 55 | } 56 | 57 | .setting-object { 58 | border: tall $secondary-muted; 59 | &:light { 60 | border: tall $foreground 20%; 61 | } 62 | padding: 0 1; 63 | 64 | &:focus-within { 65 | border: tall $secondary; 66 | } 67 | .heading { 68 | margin-bottom: 1; 69 | } 70 | margin-bottom: 1; 71 | 72 | &:last-child { 73 | margin: 0 0 0 0; 74 | } 75 | .heading .title { 76 | color: $primary; 77 | text-style: none; 78 | } 79 | } 80 | 81 | .title { 82 | margin: 0; 83 | padding: 0 0 0 1; 84 | text-style: bold; 85 | } 86 | .help { 87 | color: $text-muted; 88 | padding: 0 0 0 1; 89 | } 90 | 91 | } 92 | 93 | -------------------------------------------------------------------------------- /src/toad/about.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib.metadata import version 3 | import platform 4 | from string import Template 5 | 6 | from toad.app import ToadApp 7 | from toad import paths 8 | from toad import get_version 9 | 10 | ABOUT_TEMPLATE = Template("""\ 11 | # About Toad v${TOAD_VERSION} 12 | 13 | © Will McGugan. 14 | 15 | Toad is licensed under the terms of the [GNU AFFERO GENERAL PUBLIC LICENSE](https://www.gnu.org/licenses/agpl-3.0.txt). 16 | 17 | 18 | ## Config 19 | 20 | config read from `$SETTINGS_PATH` 21 | 22 | ```json 23 | $CONFIG 24 | ``` 25 | 26 | Additional app data stored in `$DATA_PATH` 27 | 28 | ## System 29 | 30 | | System | Version | 31 | | --- | --- | 32 | | Python | $PYTHON | 33 | | OS | $PLATFORM | 34 | 35 | ## Dependencies 36 | 37 | | Library | Version | 38 | | --- | --- | 39 | | Textual | ${TEXTUAL_VERSION} | 40 | | Rich | ${RICH_VERSION} | 41 | 42 | ## Environment 43 | 44 | | Environment variable | Value | 45 | | --- | --- | 46 | | `TERM` | $TERM | 47 | | `COLORTERM` | $COLORTERM | 48 | | `TERM_PROGRAM` | $TERM_PROGRAM | 49 | | `TERM_PROGRAM_VERSION` | $TERM_PROGRAM_VERSION | 50 | """) 51 | 52 | 53 | def render(app: ToadApp) -> str: 54 | """Render about markdown. 55 | 56 | Returns: 57 | Markdown string. 58 | """ 59 | 60 | try: 61 | config: str | None = app.settings_path.read_text() 62 | except Exception: 63 | config = None 64 | 65 | template_data = { 66 | "DATA_PATH": paths.get_data(), 67 | "TOAD_VERSION": get_version(), 68 | "TEXTUAL_VERSION": version("textual"), 69 | "RICH_VERSION": version("rich"), 70 | "PYTHON": f"{platform.python_implementation()} {platform.python_version()}", 71 | "PLATFORM": platform.platform(), 72 | "TERM": os.environ.get("TERM", ""), 73 | "COLORTERM": os.environ.get("COLORTERM", ""), 74 | "TERM_PROGRAM": os.environ.get("TERM_PROGRAM", ""), 75 | "TERM_PROGRAM_VERSION": os.environ.get("TERM_PROGRAM_VERSION", ""), 76 | "SETTINGS_PATH": str(app.settings_path), 77 | "CONFIG": config, 78 | } 79 | return ABOUT_TEMPLATE.safe_substitute(template_data) 80 | -------------------------------------------------------------------------------- /src/toad/widgets/flash.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from textual.content import Content 4 | from textual.reactive import var 5 | from textual.widgets import Static 6 | from textual.timer import Timer 7 | from textual import getters 8 | 9 | 10 | from toad.app import ToadApp 11 | 12 | 13 | class Flash(Static): 14 | DEFAULT_CSS = """ 15 | Flash { 16 | height: 1; 17 | width: 1fr; 18 | background: $success 10%; 19 | color: $text-success; 20 | text-align: center; 21 | visibility: hidden; 22 | text-wrap: nowrap; 23 | text-overflow: ellipsis; 24 | # overlay: screen; 25 | # offset-y: -1; 26 | &.-default { 27 | background: $primary 10%; 28 | color: $text-primary; 29 | } 30 | 31 | &.-success { 32 | background: $success 10%; 33 | color: $text-success; 34 | } 35 | 36 | 37 | &.-warning { 38 | background: $warning 10%; 39 | color: $text-warning; 40 | } 41 | 42 | &.-error { 43 | background: $error 10%; 44 | color: $text-error; 45 | } 46 | } 47 | """ 48 | app = getters.app(ToadApp) 49 | flash_timer: var[Timer | None] = var(None) 50 | 51 | def flash( 52 | self, 53 | content: str | Content, 54 | *, 55 | duration: float | None = None, 56 | style: Literal["default", "success", "warning", "error"] = "default", 57 | ) -> None: 58 | """Flash the content for a brief period. 59 | 60 | Args: 61 | content: Content to show. 62 | duration: Duration in seconds to show content. 63 | style: A semantic style. 64 | """ 65 | if self.flash_timer is not None: 66 | self.flash_timer.stop() 67 | self.visible = False 68 | 69 | def hide() -> None: 70 | """Hide the content after a while.""" 71 | self.visible = False 72 | 73 | self.update(content) 74 | self.remove_class("-default", "-success", "-warning", "-error", update=False) 75 | self.add_class(f"-{style}") 76 | self.visible = True 77 | 78 | if duration is None: 79 | duration = self.app.settings.get("ui.flash_duration", float) 80 | 81 | self.flash_timer = self.set_timer(duration or 3, hide) 82 | -------------------------------------------------------------------------------- /src/toad/widgets/project_directory_tree.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Iterable 3 | 4 | import pathspec.patterns 5 | from pathspec import PathSpec 6 | 7 | from textual import work 8 | from textual.widgets import DirectoryTree 9 | 10 | 11 | class ProjectDirectoryTree(DirectoryTree): 12 | def __init__( 13 | self, 14 | path: str | Path, 15 | *, 16 | name: str | None = None, 17 | id: str | None = None, 18 | classes: str | None = None, 19 | disabled: bool = False, 20 | ) -> None: 21 | super().__init__(path, name=name, id=id, classes=classes, disabled=disabled) 22 | self._path_spec: PathSpec | None = None 23 | 24 | @work(thread=True) 25 | async def load_path_spec(self, git_ignore_path: Path) -> PathSpec | None: 26 | """Get a path spec instance if there is a .gitignore file present. 27 | 28 | Args: 29 | git_ignore_path): Path to .gitignore. 30 | 31 | Returns: 32 | A `PathSpec` instance. 33 | """ 34 | try: 35 | if git_ignore_path.is_file(): 36 | spec_text = git_ignore_path.read_text() 37 | spec = PathSpec.from_lines( 38 | pathspec.patterns.GitWildMatchPattern, spec_text.splitlines() 39 | ) 40 | return spec 41 | except OSError: 42 | return None 43 | return None 44 | 45 | async def get_path_spec(self) -> PathSpec | None: 46 | if self._path_spec is None: 47 | path = ( 48 | Path(self.path) if isinstance(self.path, str) else self.path 49 | ) / ".gitignore" 50 | self._path_spec = await self.load_path_spec(path).wait() 51 | return self._path_spec 52 | 53 | async def on_mount(self) -> None: 54 | await self.get_path_spec() 55 | 56 | def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: 57 | """Filter the paths before adding them to the tree. 58 | 59 | Args: 60 | paths: The paths to be filtered. 61 | 62 | Returns: 63 | The filtered paths. 64 | 65 | By default this method returns all of the paths provided. To create 66 | a filtered `DirectoryTree` inherit from it and implement your own 67 | version of this method. 68 | """ 69 | 70 | if path_spec := self._path_spec: 71 | for path in paths: 72 | if not path_spec.match_file(path): 73 | yield path 74 | yield from paths 75 | -------------------------------------------------------------------------------- /src/toad/widgets/throbber.py: -------------------------------------------------------------------------------- 1 | from time import monotonic 2 | from typing import Callable 3 | 4 | from rich.segment import Segment 5 | from rich.style import Style as RichStyle 6 | 7 | from textual.visual import Visual 8 | from textual.color import Color, Gradient 9 | 10 | from textual.style import Style 11 | from textual.strip import Strip 12 | from textual.visual import RenderOptions 13 | from textual.widget import Widget 14 | from textual.css.styles import RulesMap 15 | 16 | 17 | COLORS = [ 18 | "#881177", 19 | "#aa3355", 20 | "#cc6666", 21 | "#ee9944", 22 | "#eedd00", 23 | "#99dd55", 24 | "#44dd88", 25 | "#22ccbb", 26 | "#00bbcc", 27 | "#0099cc", 28 | "#3366bb", 29 | "#663399", 30 | ] 31 | 32 | 33 | class ThrobberVisual(Visual): 34 | """A Textual 'Visual' object. 35 | 36 | Analogous to a Rich renderable, but with support for transparency. 37 | 38 | """ 39 | 40 | gradient = Gradient.from_colors(*[Color.parse(color) for color in COLORS]) 41 | 42 | def render_strips( 43 | self, width: int, height: int | None, style: Style, options: RenderOptions 44 | ) -> list[Strip]: 45 | """Render the Visual into an iterable of strips. 46 | 47 | Args: 48 | width: Width of desired render. 49 | height: Height of desired render or `None` for any height. 50 | style: The base style to render on top of. 51 | options: Additional render options. 52 | 53 | Returns: 54 | An list of Strips. 55 | """ 56 | 57 | time = monotonic() 58 | gradient = self.gradient 59 | background = style.rich_style.bgcolor 60 | 61 | strips = [ 62 | Strip( 63 | [ 64 | Segment( 65 | "━", 66 | RichStyle.from_color( 67 | gradient.get_rich_color((offset / width - time) % 1.0), 68 | background, 69 | ), 70 | ) 71 | for offset in range(width) 72 | ], 73 | width, 74 | ) 75 | ] 76 | return strips 77 | 78 | def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: 79 | return container_width 80 | 81 | def get_height(self, rules: RulesMap, width: int) -> int: 82 | return 1 83 | 84 | 85 | class Throbber(Widget): 86 | def on_mount(self) -> None: 87 | self.auto_refresh = 1 / 15 88 | 89 | def render(self) -> ThrobberVisual: 90 | return ThrobberVisual() 91 | -------------------------------------------------------------------------------- /src/toad/version.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | VERSION_TOML_URL = "https://www.batrachian.ai/toad.toml" 5 | 6 | 7 | class VersionMeta(NamedTuple): 8 | """Information about the current version of Toad.""" 9 | 10 | version: str 11 | upgrade_message: str 12 | visit_url: str 13 | 14 | 15 | class VersionCheckFailed(Exception): 16 | """Something went wrong in the version check.""" 17 | 18 | 19 | async def check_version() -> tuple[bool, VersionMeta]: 20 | """Check for a new version of Toad. 21 | 22 | Returns: 23 | A tuple containing a boolean that indicates if there is a newer version, 24 | and a `VersionMeta` structure with meta information. 25 | """ 26 | import httpx 27 | import packaging.version 28 | import tomllib 29 | 30 | from toad import get_version 31 | 32 | try: 33 | current_version = packaging.version.parse(get_version()) 34 | except packaging.version.InvalidVersion as error: 35 | raise VersionCheckFailed(f"Invalid version;{error}") 36 | 37 | try: 38 | async with httpx.AsyncClient() as client: 39 | response = await client.get(VERSION_TOML_URL) 40 | version_toml_bytes = await response.aread() 41 | except Exception as error: 42 | raise VersionCheckFailed(f"Failed to retrieve version;{error}") 43 | 44 | try: 45 | version_toml = version_toml_bytes.decode("utf-8", "replace") 46 | version_meta = tomllib.loads(version_toml) 47 | except Exception as error: 48 | raise VersionCheckFailed(f"Failed to decode version TOML;{error}") 49 | 50 | if not isinstance(version_meta, dict): 51 | raise VersionCheckFailed("Response isn't TOML") 52 | 53 | toad_version = str(version_meta.get("version", "0")) 54 | version_message = str(version_meta.get("upgrade_message", "")) 55 | version_message = version_message.replace("$VERSION", toad_version) 56 | verison_meta = VersionMeta( 57 | version=toad_version, 58 | upgrade_message=version_message, 59 | visit_url=str(version_meta.get("visit_url", "")), 60 | ) 61 | 62 | try: 63 | new_version = packaging.version.parse(verison_meta.version) 64 | except packaging.version.InvalidVersion as error: 65 | raise VersionCheckFailed(f"Invalid remote version;{error}") 66 | 67 | return new_version > current_version, verison_meta 68 | 69 | 70 | if __name__ == "__main__": 71 | 72 | async def run() -> None: 73 | result = await check_version() 74 | from rich import print 75 | 76 | print(result) 77 | 78 | import asyncio 79 | 80 | asyncio.run(run()) 81 | -------------------------------------------------------------------------------- /src/toad/widgets/agent_response.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from textual.reactive import var 4 | from textual import work 5 | from textual.widget import Widget 6 | from textual.widgets import Markdown 7 | from textual.widgets.markdown import MarkdownStream 8 | 9 | from toad import messages 10 | 11 | 12 | SYSTEM = """\ 13 | If asked to output code add inline documentation in the google style format, and always use type hinting where appropriate. 14 | Avoid using external libraries where possible, and favor code that writes output to the terminal. 15 | When asked for a table do not wrap it in a code fence. 16 | """ 17 | 18 | 19 | class AgentResponse(Markdown): 20 | block_cursor_offset = var(-1) 21 | 22 | def __init__(self, markdown: str | None = None) -> None: 23 | super().__init__(markdown) 24 | self._stream: MarkdownStream | None = None 25 | 26 | def block_cursor_clear(self) -> None: 27 | self.block_cursor_offset = -1 28 | 29 | def block_cursor_up(self) -> Widget | None: 30 | self.log(self, self.children, self.block_cursor_offset) 31 | if self.block_cursor_offset == -1: 32 | if self.children: 33 | self.block_cursor_offset = len(self.children) - 1 34 | else: 35 | return None 36 | else: 37 | self.block_cursor_offset -= 1 38 | 39 | if self.block_cursor_offset == -1: 40 | return None 41 | try: 42 | return self.children[self.block_cursor_offset] 43 | except IndexError: 44 | self.block_cursor_offset = -1 45 | return None 46 | 47 | def block_cursor_down(self) -> Widget | None: 48 | if self.block_cursor_offset == -1: 49 | if self.children: 50 | self.block_cursor_offset = 0 51 | else: 52 | return None 53 | else: 54 | self.block_cursor_offset += 1 55 | if self.block_cursor_offset >= len(self.children): 56 | self.block_cursor_offset = -1 57 | return None 58 | try: 59 | return self.children[self.block_cursor_offset] 60 | except IndexError: 61 | self.block_cursor_offset = -1 62 | return None 63 | 64 | def get_cursor_block(self) -> Widget | None: 65 | if self.block_cursor_offset == -1: 66 | return None 67 | return self.children[self.block_cursor_offset] 68 | 69 | def block_select(self, widget: Widget) -> None: 70 | self.block_cursor_offset = self.children.index(widget) 71 | 72 | @property 73 | def stream(self) -> MarkdownStream: 74 | if self._stream is None: 75 | self._stream = self.get_stream(self) 76 | return self._stream 77 | 78 | async def append_fragment(self, fragment: str) -> None: 79 | self.loading = False 80 | await self.stream.write(fragment) 81 | -------------------------------------------------------------------------------- /src/toad/ansi/_sgr_styles.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | from textual.color import Color 4 | from textual.style import Style, NULL_STYLE 5 | 6 | SGR_STYLES: Mapping[int, Style] = { 7 | 1: Style(bold=True), 8 | 2: Style(dim=True), 9 | 3: Style(italic=True), 10 | 4: Style(underline=True), 11 | 5: Style(blink=True), 12 | 6: Style(blink=True), 13 | 7: Style(reverse=True), 14 | 8: Style(reverse=True), 15 | 9: Style(strike=True), 16 | 21: Style(underline2=True), 17 | 22: Style(dim=False, bold=False), 18 | 23: Style(italic=False), 19 | 24: Style(underline=False), 20 | 25: Style(blink=False), 21 | 26: Style(blink=False), 22 | 27: Style(reverse=False), 23 | 28: NULL_STYLE, # "not conceal", 24 | 29: Style(strike=False), 25 | 30: Style(foreground=Color(0, 0, 0, ansi=0)), 26 | 31: Style(foreground=Color(128, 0, 0, ansi=1)), 27 | 32: Style(foreground=Color(0, 128, 0, ansi=2)), 28 | 33: Style(foreground=Color(128, 128, 0, ansi=3)), 29 | 34: Style(foreground=Color(0, 0, 128, ansi=4)), 30 | 35: Style(foreground=Color(128, 0, 128, ansi=5)), 31 | 36: Style(foreground=Color(0, 128, 128, ansi=6)), 32 | 37: Style(foreground=Color(192, 192, 192, ansi=7)), 33 | 39: Style(foreground=Color(0, 0, 0, ansi=-1)), 34 | 40: Style(background=Color(0, 0, 0, ansi=0)), 35 | 41: Style(background=Color(128, 0, 0, ansi=1)), 36 | 42: Style(background=Color(0, 128, 0, ansi=2)), 37 | 43: Style(background=Color(128, 128, 0, ansi=3)), 38 | 44: Style(background=Color(0, 0, 128, ansi=4)), 39 | 45: Style(background=Color(128, 0, 128, ansi=5)), 40 | 46: Style(background=Color(0, 128, 128, ansi=6)), 41 | 47: Style(background=Color(192, 192, 192, ansi=7)), 42 | 49: Style(background=Color(0, 0, 0, ansi=-1)), 43 | 51: NULL_STYLE, # "frame", 44 | 52: NULL_STYLE, # "encircle", 45 | 53: NULL_STYLE, # "overline", 46 | 54: NULL_STYLE, # "not frame not encircle", 47 | 55: NULL_STYLE, # "not overline", 48 | 90: Style(foreground=Color(128, 128, 128, ansi=8)), 49 | 91: Style(foreground=Color(255, 0, 0, ansi=9)), 50 | 92: Style(foreground=Color(0, 255, 0, ansi=10)), 51 | 93: Style(foreground=Color(255, 255, 0, ansi=11)), 52 | 94: Style(foreground=Color(0, 0, 255, ansi=12)), 53 | 95: Style(foreground=Color(255, 0, 255, ansi=13)), 54 | 96: Style(foreground=Color(0, 255, 255, ansi=14)), 55 | 97: Style(foreground=Color(255, 255, 255, ansi=15)), 56 | 100: Style(background=Color(128, 128, 128, ansi=8)), 57 | 101: Style(background=Color(255, 0, 0, ansi=9)), 58 | 102: Style(background=Color(0, 255, 0, ansi=10)), 59 | 103: Style(background=Color(255, 255, 0, ansi=11)), 60 | 104: Style(background=Color(0, 0, 255, ansi=12)), 61 | 105: Style(background=Color(255, 0, 255, ansi=13)), 62 | 106: Style(background=Color(0, 255, 255, ansi=14)), 63 | 107: Style(background=Color(255, 255, 255, ansi=15)), 64 | } 65 | -------------------------------------------------------------------------------- /src/toad/_loop.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterable, Literal, Sequence, TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]: 9 | """Iterate and generate a tuple with a flag for first value.""" 10 | iter_values = iter(values) 11 | try: 12 | value = next(iter_values) 13 | except StopIteration: 14 | return 15 | yield True, value 16 | for value in iter_values: 17 | yield False, value 18 | 19 | 20 | def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]: 21 | """Iterate and generate a tuple with a flag for last value.""" 22 | iter_values = iter(values) 23 | try: 24 | previous_value = next(iter_values) 25 | except StopIteration: 26 | return 27 | for value in iter_values: 28 | yield False, previous_value 29 | previous_value = value 30 | yield True, previous_value 31 | 32 | 33 | def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]: 34 | """Iterate and generate a tuple with a flag for first and last value.""" 35 | iter_values = iter(values) 36 | try: 37 | previous_value = next(iter_values) 38 | except StopIteration: 39 | return 40 | first = True 41 | for value in iter_values: 42 | yield first, False, previous_value 43 | first = False 44 | previous_value = value 45 | yield first, True, previous_value 46 | 47 | 48 | def loop_from_index( 49 | values: Sequence[T], 50 | index: int, 51 | direction: Literal[-1, +1] = +1, 52 | wrap: bool = True, 53 | ) -> Iterable[tuple[int, T]]: 54 | """Iterate over values in a sequence from a given starting index, potentially wrapping the index 55 | if it would go out of bounds. 56 | 57 | Note that the first value to be yielded is a step from `index`, and `index` will be yielded *last*. 58 | 59 | 60 | Args: 61 | values: A sequence of values. 62 | index: Starting index. 63 | direction: Direction to move index (+1 for forward, -1 for backward). 64 | bool: Should the index wrap when out of bounds? 65 | 66 | Yields: 67 | A tuple of index and value from the sequence. 68 | """ 69 | # Sanity check for devs who miss the typing errors 70 | assert direction in (-1, +1), "direction must be -1 or +1" 71 | count = len(values) 72 | if wrap: 73 | for _ in range(count): 74 | index = (index + direction) % count 75 | yield (index, values[index]) 76 | else: 77 | if direction == +1: 78 | for _ in range(count): 79 | if (index := index + 1) >= count: 80 | break 81 | yield (index, values[index]) 82 | else: 83 | for _ in range(count): 84 | if (index := index - 1) < 0: 85 | break 86 | yield (index, values[index]) 87 | -------------------------------------------------------------------------------- /src/toad/widgets/condensed_path.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | import os.path 3 | from typing import Iterable 4 | 5 | from rich.cells import cell_len 6 | from textual.geometry import Size 7 | from textual.reactive import reactive 8 | from textual.content import Content 9 | from textual.widget import Widget 10 | 11 | 12 | def radiate_range(total: int) -> Iterable[tuple[int, int]]: 13 | """Generate pairs of indexes, gradually growing from the center. 14 | 15 | Args: 16 | total: Total size of range. 17 | 18 | Yields: 19 | Pairs of indexes. 20 | """ 21 | if not total: 22 | return 23 | left = right = total // 2 24 | yield (left, right) 25 | while left >= 0 or right < total: 26 | left -= 1 27 | if left >= 0: 28 | yield (left + 1, right) 29 | right += 1 30 | if right <= total: 31 | yield (left + 1, right) 32 | 33 | 34 | @lru_cache(maxsize=16) 35 | def condense_path(path: str, width: int, *, prefix: str = "") -> str: 36 | """Condense a path to fit within the given cell width. 37 | 38 | Args: 39 | path: The path to condense. 40 | width: Maximum cell width. 41 | prefix: A string to be prepended to the result. 42 | 43 | Returns: 44 | A condensed string. 45 | """ 46 | # TODO: handle OS separators and path issues 47 | if cell_len(path) <= width: 48 | return path 49 | components = path.split("/") 50 | condensed = components 51 | trailing_slash = path.endswith("/") 52 | candidate = prefix + "/".join(condensed) 53 | if trailing_slash and candidate and not candidate.endswith("/"): 54 | candidate += "/" 55 | 56 | for left, right in radiate_range(len(components)): 57 | if cell_len(candidate) < width: 58 | return candidate 59 | condensed = [*components[:left], "…", *components[right:]] 60 | candidate = prefix + "/".join(condensed) 61 | if trailing_slash and candidate and not candidate.endswith("/"): 62 | candidate += "/" 63 | 64 | return candidate 65 | 66 | 67 | class CondensedPath(Widget): 68 | path = reactive("") 69 | display_path = reactive("") 70 | 71 | def on_resize(self) -> None: 72 | self.watch_path(self.path) 73 | 74 | def watch_path(self, path: str) -> None: 75 | if not path or not self.size: 76 | return 77 | path = os.path.abspath(path) 78 | self.tooltip = str(path) 79 | user_root = os.path.abspath(os.path.expanduser("~/")) 80 | if not user_root.endswith("/"): 81 | user_root += "/" 82 | if path.startswith(user_root): 83 | path = "~/" + path[len(user_root) :] 84 | self.display_path = path 85 | 86 | def render(self) -> Content: 87 | return Content(condense_path(self.display_path, self.size.width)) 88 | 89 | def get_content_width(self, container: Size, viewport: Size) -> int: 90 | if self.display_path: 91 | return Content(self.display_path).cell_length 92 | else: 93 | return container.width 94 | -------------------------------------------------------------------------------- /src/toad/widgets/side_bar.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from textual.app import ComposeResult 4 | from textual.widget import Widget 5 | from textual import containers 6 | from textual import widgets 7 | from textual.message import Message 8 | 9 | 10 | class SideBar(containers.Vertical): 11 | BINDINGS = [("escape", "dismiss", "Dismiss sidebar")] 12 | DEFAULT_CSS = """ 13 | SideBar { 14 | height: 1fr; 15 | layout: vertical; 16 | overflow: hidden scroll; 17 | scrollbar-size: 0 0; 18 | 19 | Collapsible { 20 | height: 1fr; 21 | min-height: 3; 22 | 23 | &.-collapsed { 24 | height: auto; 25 | } 26 | 27 | &.-fixed { 28 | height: auto; 29 | } 30 | 31 | Contents { 32 | height: auto; 33 | padding: 0; 34 | margin: 1 0 0 0; 35 | } 36 | } 37 | } 38 | """ 39 | 40 | class Dismiss(Message): 41 | pass 42 | 43 | @dataclass(frozen=True) 44 | class Panel: 45 | title: str 46 | widget: Widget 47 | flex: bool = False 48 | collapsed: bool = False 49 | id: str | None = None 50 | 51 | def __init__( 52 | self, 53 | *panels: Panel, 54 | name: str | None = None, 55 | id: str | None = None, 56 | classes: str | None = None, 57 | disabled: bool = False, 58 | hide: bool = False, 59 | ) -> None: 60 | super().__init__(name=name, id=id, classes=classes, disabled=disabled) 61 | self.panels: list[SideBar.Panel] = [*panels] 62 | self.hide = hide 63 | 64 | def on_mount(self) -> None: 65 | self.trap_focus() 66 | 67 | def compose(self) -> ComposeResult: 68 | for panel in self.panels: 69 | yield widgets.Collapsible( 70 | panel.widget, 71 | title=panel.title, 72 | collapsed=panel.collapsed, 73 | classes="-flex" if panel.flex else "-fixed", 74 | id=panel.id, 75 | ) 76 | 77 | def action_dismiss(self) -> None: 78 | self.post_message(self.Dismiss()) 79 | 80 | 81 | if __name__ == "__main__": 82 | from textual.app import App, ComposeResult 83 | 84 | class SApp(App): 85 | def compose(self) -> ComposeResult: 86 | yield SideBar( 87 | SideBar.Panel("Hello", widgets.Label("Hello, World!")), 88 | SideBar.Panel( 89 | "Files", 90 | widgets.DirectoryTree( 91 | "~/", 92 | ), 93 | flex=True, 94 | ), 95 | SideBar.Panel( 96 | "Hello", 97 | widgets.Static("Where there is a Will! " * 10), 98 | ), 99 | ) 100 | 101 | SApp().run() 102 | -------------------------------------------------------------------------------- /src/toad/screens/action_modal.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | from textual.app import ComposeResult 4 | from textual import on, work 5 | from textual import containers 6 | from textual import getters 7 | from textual.screen import ModalScreen 8 | from textual import widgets 9 | from textual.widget import Widget 10 | 11 | from toad.app import ToadApp 12 | from toad.widgets.command_pane import CommandPane 13 | 14 | 15 | UV_INSTALL = "curl -LsSf https://astral.sh/uv/install.sh | sh" 16 | 17 | 18 | class ActionModal(ModalScreen): 19 | """Executes an action command.""" 20 | 21 | command_pane = getters.query_one(CommandPane) 22 | ok_button = getters.query_one("#ok", widgets.Button) 23 | 24 | app = getters.app(ToadApp) 25 | 26 | BINDINGS = [("escape", "dismiss_modal", "Dismiss")] 27 | 28 | def __init__( 29 | self, 30 | action: str, 31 | agent: str, 32 | title: str, 33 | command: str, 34 | *, 35 | bootstrap_uv: bool = False, 36 | name: str | None = None, 37 | id: str | None = None, 38 | classes: str | None = None, 39 | ) -> None: 40 | self._action = action 41 | self._agent = agent 42 | self._title = title 43 | self._command = command 44 | self._bootstrap_uv = bootstrap_uv 45 | super().__init__(name=name, id=id, classes=classes) 46 | 47 | def get_loading_widget(self) -> Widget: 48 | return widgets.LoadingIndicator() 49 | 50 | def compose(self) -> ComposeResult: 51 | with containers.VerticalGroup(id="container"): 52 | yield CommandPane() 53 | yield widgets.Button("OK", id="ok", disabled=True) 54 | 55 | def enable_button(self) -> None: 56 | self.ok_button.loading = False 57 | self.ok_button.disabled = False 58 | self.ok_button.focus() 59 | 60 | @on(CommandPane.CommandComplete) 61 | def on_command_complete(self, event: CommandPane.CommandComplete) -> None: 62 | self.enable_button() 63 | 64 | def on_mount(self) -> None: 65 | self.ok_button.loading = True 66 | self.command_pane.border_title = self._title 67 | 68 | self.run_command() 69 | 70 | @work() 71 | async def run_command(self) -> None: 72 | """Write and execute the command.""" 73 | self.command_pane.anchor() 74 | if self._bootstrap_uv and shutil.which("uv") is None: 75 | # Bootstrap UV if required 76 | await self.command_pane.write(f"$ {UV_INSTALL}\n") 77 | await self.command_pane.execute(UV_INSTALL, final=False) 78 | 79 | await self.command_pane.write(f"$ {self._command}\n") 80 | action_task = self.command_pane.execute(self._command) 81 | await action_task 82 | self.app.capture_event( 83 | "agent-action", 84 | action=self._action, 85 | agent=self._agent, 86 | fail=self.command_pane.return_code != 0, 87 | ) 88 | 89 | @on(widgets.Button.Pressed) 90 | def on_button_pressed(self) -> None: 91 | self.action_dismiss_modal() 92 | 93 | def action_dismiss_modal(self) -> None: 94 | self.dismiss(self.command_pane.return_code) 95 | -------------------------------------------------------------------------------- /src/toad/acp/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from asyncio import Future 6 | from typing import Any, Mapping, TYPE_CHECKING 7 | from textual.message import Message 8 | 9 | import rich.repr 10 | 11 | from toad.answer import Answer 12 | from toad.acp import protocol 13 | from toad.acp.encode_tool_call_id import encode_tool_call_id 14 | from toad.acp.agent import Mode 15 | 16 | if TYPE_CHECKING: 17 | from toad.widgets.terminal_tool import ToolState 18 | 19 | 20 | class AgentMessage(Message): 21 | pass 22 | 23 | 24 | @dataclass 25 | class Thinking(AgentMessage): 26 | type: str 27 | text: str 28 | 29 | 30 | @dataclass 31 | class UpdateStatusLine(AgentMessage): 32 | status_line: str 33 | 34 | 35 | @dataclass 36 | class Update(AgentMessage): 37 | type: str 38 | text: str 39 | 40 | 41 | @dataclass 42 | @rich.repr.auto 43 | class RequestPermission(AgentMessage): 44 | options: list[protocol.PermissionOption] 45 | tool_call: protocol.ToolCallUpdatePermissionRequest 46 | result_future: Future[Answer] 47 | 48 | 49 | @dataclass 50 | class Plan(AgentMessage): 51 | entries: list[protocol.PlanEntry] 52 | 53 | 54 | @dataclass 55 | class ToolCall(AgentMessage): 56 | tool_call: protocol.ToolCall 57 | 58 | @property 59 | def tool_id(self) -> str: 60 | """An id suitable for use as a TCSS ID.""" 61 | return encode_tool_call_id(self.tool_call["toolCallId"]) 62 | 63 | 64 | @dataclass 65 | class ToolCallUpdate(AgentMessage): 66 | tool_call: protocol.ToolCall 67 | update: protocol.ToolCallUpdate 68 | 69 | @property 70 | def tool_id(self) -> str: 71 | """An id suitable for use as a TCSS ID.""" 72 | return encode_tool_call_id(self.tool_call["toolCallId"]) 73 | 74 | 75 | @dataclass 76 | class AvailableCommandsUpdate(AgentMessage): 77 | """The agent is reporting its slash commands.""" 78 | 79 | commands: list[protocol.AvailableCommand] 80 | 81 | 82 | @dataclass 83 | class CreateTerminal(AgentMessage): 84 | """Request a terminal in the conversation.""" 85 | 86 | terminal_id: str 87 | command: str 88 | result_future: Future[bool] 89 | args: list[str] | None = None 90 | cwd: str | None = None 91 | env: Mapping[str, str] | None = None 92 | output_byte_limit: int | None = None 93 | 94 | 95 | @dataclass 96 | class KillTerminal(AgentMessage): 97 | """Kill a terminal process.""" 98 | 99 | terminal_id: str 100 | 101 | 102 | @dataclass 103 | class GetTerminalState(AgentMessage): 104 | """Get the state of the terminal.""" 105 | 106 | terminal_id: str 107 | result_future: Future[ToolState] 108 | 109 | 110 | @dataclass 111 | class ReleaseTerminal(AgentMessage): 112 | """Release the terminal.""" 113 | 114 | terminal_id: str 115 | 116 | 117 | @dataclass 118 | class WaitForTerminalExit(AgentMessage): 119 | """Wait for the terminal to exit.""" 120 | 121 | terminal_id: str 122 | result_future: Future[tuple[int, str | None]] 123 | 124 | 125 | @rich.repr.auto 126 | @dataclass 127 | class SetModes(AgentMessage): 128 | """Set modes from agent.""" 129 | 130 | current_mode: str 131 | modes: dict[str, Mode] 132 | 133 | 134 | @dataclass 135 | class ModeUpdate(AgentMessage): 136 | """Agent informed us about a mode change.""" 137 | 138 | current_mode: str 139 | -------------------------------------------------------------------------------- /src/toad/agent_schema.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, Literal, NotRequired 2 | 3 | type Tag = str 4 | """A tag used for categorizing the agent. For example: 'open-source', 'reasoning'.""" 5 | type OS = Literal["macos", "linux", "windows", "*"] 6 | """An operating system identifier, or a '*" wildcard, if it is the same for all OSes.""" 7 | type Action = str 8 | """An action which the agent supports.""" 9 | type AgentType = Literal["coding", "chat"] 10 | """The type of agent. More types TBD.""" 11 | type AgentProtocol = Literal["acp"] 12 | """The protocol used to communicate with the agent. Currently only "acp" is supported.""" 13 | 14 | 15 | class Command(TypedDict): 16 | """Used to perform an action associate with an Agent.""" 17 | 18 | description: str 19 | """Describes what the script will do. For example: 'Install Claude Code'.""" 20 | command: str 21 | """Command to run.""" 22 | bootstrap_uv: NotRequired[bool] 23 | """Bootstrap UV installer (set to `true` if the command users `uv`).""" 24 | 25 | 26 | class Agent(TypedDict): 27 | """Describes an agent which Toad can connect to. Currently only Agent Client Protocol is supported. 28 | 29 | This information is stoed within TOML files, where the filename is the "identity" key plus the extension ".toml" 30 | 31 | """ 32 | 33 | active: NotRequired[bool] 34 | """If `True` (default), the agent will be shown in the UI. If `False` the agent will be removed from the UI.""" 35 | recommended: NotRequired[bool] 36 | """Agent is in recommended set. Set to `True` in main branch only if previously agreed with Will McGugan.""" 37 | identity: str 38 | """A unique identifier for this agent. Should be a domain the agent developer owns, 39 | although it doesn't have to resolve to anything. Must be useable in a filename on all platforms. 40 | For example: 'claude.anthropic.ai'""" 41 | name: str 42 | """The name of the agent. For example: 'Claude Code'.""" 43 | short_name: str 44 | """A short name, usable on the command line. Try to make it unique. For example: 'claude'.""" 45 | url: str 46 | """A URL for the agent.""" 47 | protocol: AgentProtocol 48 | """The protocol used by the agent. Currently only 'acp' is supported.""" 49 | type: "AgentType" 50 | """The type of the agent. Currently "coding" or "chat". More types TBD.""" 51 | author_name: str 52 | """The author of the agent. For example 'Anthropic'.""" 53 | author_url: str 54 | """The authors homepage. For example 'https://www.anthropic.com/'.""" 55 | publisher_name: str 56 | """The publisher's name (individual or organization that wrote this data).""" 57 | publisher_url: str 58 | """The publisher's url.""" 59 | description: str 60 | """A description of the agent. A few sentences max. May contain content markup (https://textual.textualize.io/guide/content/#markup) if used subtly.""" 61 | tags: list[Tag] 62 | """Tags which identify the agent. Should be empty for now.""" 63 | help: str 64 | """A Markdown document with additional details regarding the agent.""" 65 | welcome: NotRequired[str] 66 | """A Markdown document shown to the user when the conversation starts. Should contain a welcome message and any advice on getting started.""" 67 | run_command: dict[OS, str] 68 | """Command to run the agent, by OS or wildcard.""" 69 | actions: dict[OS, dict[Action, Command]] 70 | """Scripts to perform actions, typically at least to install the agent.""" 71 | -------------------------------------------------------------------------------- /src/toad/widgets/future_text.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import ceil 4 | from typing import ClassVar 5 | from time import monotonic 6 | 7 | from textual.color import Color 8 | from textual.reactive import var 9 | from textual.content import Content 10 | from textual.style import Style 11 | from textual.timer import Timer 12 | from textual.widgets import Static 13 | 14 | 15 | class FutureText(Static): 16 | """Text which appears one letter at time, like the movies.""" 17 | 18 | DEFAULT_CSS = """ 19 | FutureText { 20 | width: auto; 21 | height: 1; 22 | text-wrap: nowrap; 23 | text-align: center; 24 | color: $primary; 25 | &>.future-text--cursor { 26 | color: $primary; 27 | } 28 | } 29 | """ 30 | ALLOW_SELECT = False 31 | COMPONENT_CLASSES = {"future-text--cursor"} 32 | 33 | BARS: ClassVar[list[str]] = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "] 34 | text_offset = var(0) 35 | 36 | def __init__( 37 | self, 38 | text_list: list[Content], 39 | *, 40 | speed: float = 16.0, 41 | name: str | None = None, 42 | id: str | None = None, 43 | classes: str | None = None, 44 | ): 45 | self.text_list = text_list 46 | self.speed = speed 47 | self.start_time = monotonic() 48 | super().__init__(name=name, id=id, classes=classes) 49 | self._update_timer: Timer | None = None 50 | 51 | @property 52 | def text(self) -> Content: 53 | return self.text_list[self.text_offset % len(self.text_list)] 54 | 55 | @property 56 | def time(self) -> float: 57 | return monotonic() - self.start_time 58 | 59 | def on_mount(self) -> None: 60 | self.start_time = monotonic() 61 | self.set_interval(1 / 60, self._update_text) 62 | 63 | def _update_text(self) -> None: 64 | if not self.is_attached or not self.screen.is_active: 65 | return 66 | text = self.text + " " 67 | speed_time = self.time * self.speed 68 | progress, fractional_progress = divmod(speed_time, 1) 69 | end = progress >= len(text) 70 | cursor_progress = 0 if end else int(fractional_progress * 8) 71 | text = text[: ceil(progress)] 72 | 73 | bar_character = self.BARS[7 - cursor_progress] 74 | 75 | cursor_styles = self.get_component_styles("future-text--cursor") 76 | cursor_style = Style(foreground=cursor_styles.color) 77 | reverse_cursor_style = cursor_style + Style(reverse=True) 78 | 79 | # Fade in last character 80 | fade_style = Style( 81 | foreground=Color.blend( 82 | cursor_styles.background, cursor_styles.color, ceil(fractional_progress) 83 | ) 84 | ) 85 | 86 | fade_text = Content.assemble( 87 | text[:-1], 88 | ((text[-1].plain if text else " "), fade_style), 89 | ) 90 | 91 | if speed_time >= 1: 92 | text = Content.assemble( 93 | fade_text, 94 | (bar_character, reverse_cursor_style), 95 | (bar_character, cursor_style), 96 | " " * (len(self.text) + 1 - len(fade_text)), 97 | ) 98 | self.update(text, layout=False) 99 | 100 | if progress > len(text) + 10 * 5: 101 | self.text_offset += 1 102 | self.start_time = monotonic() 103 | 104 | 105 | if __name__ == "__main__": 106 | from textual.app import App, ComposeResult 107 | 108 | TEXT = [Content("Thinking..."), Content("Working hard..."), Content("Nearly there")] 109 | 110 | class TextApp(App): 111 | CSS = """ 112 | Screen { 113 | padding: 2 4; 114 | FutureText { 115 | width: auto; 116 | max-width: 1fr; 117 | height: auto; 118 | 119 | } 120 | } 121 | """ 122 | 123 | def compose(self) -> ComposeResult: 124 | yield FutureText(TEXT) 125 | 126 | TextApp().run() 127 | -------------------------------------------------------------------------------- /src/toad/history.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | import asyncio 3 | import json 4 | from pathlib import Path 5 | from time import time 6 | 7 | import rich.repr 8 | 9 | from toad.complete import Complete 10 | 11 | 12 | class HistoryEntry(TypedDict): 13 | """An entry in the history file.""" 14 | 15 | input: str 16 | timestamp: float 17 | 18 | 19 | @rich.repr.auto 20 | class History: 21 | """Manages a history file.""" 22 | 23 | def __init__(self, path: Path) -> None: 24 | self.path = path 25 | self._lines: list[str] = [] 26 | self._opened: bool = False 27 | self._current: str | None = None 28 | self.complete = Complete() 29 | 30 | def __rich_repr__(self) -> rich.repr.Result: 31 | yield self.path 32 | 33 | @property 34 | def current(self) -> str | None: 35 | return self._current 36 | 37 | @current.setter 38 | def current(self, current: str) -> None: 39 | self._current = current 40 | 41 | @property 42 | def size(self) -> int: 43 | return len(self._lines) 44 | 45 | async def open(self) -> bool: 46 | """Open the history file, read initial lines. 47 | 48 | Returns: 49 | `True` if lines were read, otherwise `False`. 50 | """ 51 | if self._opened: 52 | return True 53 | 54 | def read_history() -> bool: 55 | """Read the history file (in a thread). 56 | 57 | Returns: 58 | `True` on success. 59 | """ 60 | try: 61 | self.path.touch(exist_ok=True) 62 | with self.path.open("r") as history_file: 63 | self._lines = history_file.readlines() 64 | 65 | inputs: list[str] = [] 66 | for line in self._lines: 67 | if (input := json.loads(line).get("input")) is not None: 68 | inputs.append(input.split(" ", 1)[0]) 69 | self.complete.add_words(inputs) 70 | except Exception: 71 | return False 72 | return True 73 | 74 | self._opened = await asyncio.to_thread(read_history) 75 | return self._opened 76 | 77 | async def append(self, input: str) -> bool: 78 | """Append a history entry. 79 | 80 | Args: 81 | text: Text in the history. 82 | shell: Boolean that indicates if the text is shell (`True`) or prompt (`False`). 83 | 84 | Returns: 85 | `True` on success. 86 | """ 87 | 88 | if not input: 89 | return True 90 | self.complete.add_words([input.split(" ")[0]]) 91 | 92 | def write_line() -> bool: 93 | """Append a line to the history. 94 | 95 | Returns: 96 | `True` on success, `False` if write failed. 97 | """ 98 | history_entry: HistoryEntry = { 99 | "input": input, 100 | "timestamp": time(), 101 | } 102 | line = json.dumps(history_entry) 103 | self._lines.append(line) 104 | try: 105 | with self.path.open("a") as history_file: 106 | history_file.write(f"{line}\n") 107 | except Exception: 108 | return False 109 | self._current = None 110 | return True 111 | 112 | if not self._opened: 113 | await self.open() 114 | 115 | return await asyncio.to_thread(write_line) 116 | 117 | async def get_entry(self, index: int) -> HistoryEntry: 118 | """Get a history entry via its index. 119 | 120 | Args: 121 | index: Index of entry. 0 for the last entry, negative indexes for previous entries. 122 | 123 | Returns: 124 | A history entry dict. 125 | """ 126 | if index > 0: 127 | raise IndexError("History indices must be 0 or negative.") 128 | if not self._opened: 129 | await self.open() 130 | 131 | if index == 0: 132 | return {"input": self.current or "", "timestamp": time()} 133 | try: 134 | entry_line = self._lines[index] 135 | except IndexError: 136 | raise IndexError(f"No history entry at index {index}") 137 | history_entry: HistoryEntry = json.loads(entry_line) 138 | return history_entry 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Toad 2 | 3 | A unified interface for AI in your terminal ([release announcement](https://willmcgugan.github.io/toad-released/)). 4 | 5 | Run coding agents seamlessly under a single beautiful terminal UI, thanks to the [ACP](https://agentclientprotocol.com/protocol/initialization) protocol. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
Screenshot 2025-10-23 at 08 58 58Screenshot 2025-10-23 at 08 59 04
Screenshot 2025-10-23 at 08 59 22Screenshot 2025-10-23 at 08 59 37
25 | 26 | ## Compatibility 27 | 28 | Toad runs on Linux and macOS. Native Windows support is lacking, but Toad will run quite well with WSL. 29 | 30 | Toad is a terminal application. 31 | Any terminal will work, although if you are using the default terminal on macOS you will get a much reduced experience. 32 | I recommend [Ghostty](https://ghostty.org/) which is fully featured and has amazing performance. 33 | 34 | 35 | ## Getting Started 36 | 37 | The easiest way to install Toad is by pasting the following in to your terminal: 38 | 39 | ```bash 40 | curl -fsSL batrachian.ai/install | sh 41 | ``` 42 | 43 | You should now have `toad` installed. 44 | 45 | If that doesn't work for any reason, then you can install with the following steps: 46 | 47 | First [install UV](https://docs.astral.sh/uv/getting-started/installation/): 48 | 49 | ```bash 50 | curl -LsSf https://astral.sh/uv/install.sh | sh 51 | ``` 52 | 53 | Then use UV to install toad: 54 | 55 | ```bash 56 | uv tool install -U batrachian-toad --python 3.14 57 | ``` 58 | 59 | ## Using Toad 60 | 61 | Launch Toad with the following: 62 | 63 | ```bash 64 | toad 65 | ``` 66 | 67 | You should see something like this: 68 | 69 | front-fs8 70 | 71 | From this screen you will be able to find, install, and launch a coding agent. 72 | If you already have an agent installed, you can skip the install step. 73 | To launch an agent, select it and press space. 74 | 75 | The footer will always display the most significant keys for the current context. 76 | To see all the keys, summon the command palette with `ctrl+p` and search for "keys". 77 | 78 | ### Toad CLI 79 | 80 | When running Toad, the current working directory is assumed to be your project directory. 81 | To use another project directory, add the path to the command. 82 | For example: 83 | 84 | ```bash 85 | toad ~/projects/my-awesome-app 86 | ``` 87 | 88 | If you want to skip the initial agent screen, add the `-a` switch with the name of your chosen agent. 89 | For example: 90 | 91 | ```bash 92 | toad -a open-hands 93 | ``` 94 | 95 | To see all subcommands and switches, add the `--help` switch: 96 | 97 | ```bash 98 | toad --help 99 | ``` 100 | 101 | ### Web server 102 | 103 | You can run Toad as a web application. 104 | 105 | Run the following, and click the link in the terminal: 106 | 107 | ```bash 108 | toad serve 109 | ``` 110 | 111 | ![textual-serve](https://github.com/user-attachments/assets/1d861d48-d30b-44cd-972d-5986a01360bf) 112 | 113 | ## Toad development 114 | 115 | Toad was built by [Will McGugan](https://github.com/willmcgugan) and is currently under active development. 116 | 117 | To discuss Toad, see the Discussions tab, or join the #toad channel on the [Textualize discord server](https://discord.gg/Enf6Z3qhVr). 118 | 119 | ### Roadmap 120 | 121 | Some planned features: 122 | 123 | - UI for MCP servers 124 | - Expose model selection (waiting on ACP update) 125 | - Sessions 126 | - Multiple agents 127 | 128 | ### Reporting bugs 129 | 130 | This project is trialling a non-traditional approach to issues. 131 | Before an issue is created, there must be a post in Dicussions, approved by a Toad dev (Currently @willmcgugan). 132 | 133 | By allowing the discussions to happen in the Discussion tabs, issues can be reserved for actionable tasks with a clear description and goal. 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /src/toad/path_complete.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from pathlib import Path 4 | from typing import Literal, Sequence 5 | 6 | 7 | def longest_common_prefix(strings: list[str]) -> str: 8 | """ 9 | Find the longest common prefix among a list of strings. 10 | 11 | Arguments: 12 | strings: List of strings 13 | 14 | Returns: 15 | The longest common prefix string 16 | """ 17 | if not strings: 18 | return "" 19 | 20 | # Start with the first string as reference 21 | prefix: str = strings[0] 22 | 23 | # Compare with each subsequent string 24 | for current_string in strings[1:]: 25 | # Reduce prefix until it matches the start of current string 26 | while not current_string.startswith(prefix): 27 | prefix = prefix[:-1] 28 | if not prefix: 29 | return "" 30 | 31 | return prefix 32 | 33 | 34 | class DirectoryReadTask: 35 | """A task to read a directory.""" 36 | 37 | def __init__(self, path: Path) -> None: 38 | self.path = path 39 | self.done_event = asyncio.Event() 40 | self.directory_listing: list[Path] = [] 41 | self._task: asyncio.Task | None = None 42 | 43 | def read(self) -> None: 44 | # TODO: Should this be cancellable, or have a maximum number of paths for the case of very large directories? 45 | for path in self.path.iterdir(): 46 | self.directory_listing.append(path) 47 | 48 | def start(self) -> None: 49 | asyncio.create_task(self.run(), name=f"DirectoryReadTask({str(self.path)!r})") 50 | 51 | async def run(self): 52 | await asyncio.to_thread(self.read) 53 | self.done_event.set() 54 | 55 | async def wait(self) -> list[Path]: 56 | await self.done_event.wait() 57 | return self.directory_listing 58 | 59 | 60 | class PathComplete: 61 | """Auto completes paths.""" 62 | 63 | def __init__(self) -> None: 64 | self.read_tasks: dict[Path, DirectoryReadTask] = {} 65 | self.directory_listings: dict[Path, list[Path]] = {} 66 | 67 | async def __call__( 68 | self, 69 | current_working_directory: Path, 70 | path: str, 71 | *, 72 | exclude_type: Literal["file"] | Literal["dir"] | None = None, 73 | ) -> tuple[str | None, list[str] | None]: 74 | current_working_directory = ( 75 | current_working_directory.expanduser().resolve().absolute() 76 | ) 77 | directory_path = (current_working_directory / Path(path).expanduser()).resolve() 78 | 79 | node: str = path 80 | if not directory_path.is_dir(): 81 | node = directory_path.name 82 | directory_path = directory_path.parent 83 | 84 | if (listing := self.directory_listings.get(directory_path)) is None: 85 | read_task = DirectoryReadTask(directory_path) 86 | self.read_tasks[directory_path] = read_task 87 | read_task.start() 88 | listing = await read_task.wait() 89 | 90 | if exclude_type is not None: 91 | if exclude_type == "dir": 92 | listing = [ 93 | listing_path 94 | for listing_path in listing 95 | if not listing_path.is_dir() 96 | ] 97 | else: 98 | listing = [ 99 | listing_path for listing_path in listing if listing_path.is_dir() 100 | ] 101 | 102 | if not node: 103 | return None, [listing_path.name for listing_path in listing] 104 | 105 | matching_nodes = [ 106 | listing_path 107 | for listing_path in listing 108 | if listing_path.name.startswith(node) 109 | ] 110 | if not (matching_nodes): 111 | # Nothing matches 112 | return None, None 113 | 114 | if not ( 115 | prefix := longest_common_prefix( 116 | [node_path.name for node_path in matching_nodes] 117 | ) 118 | ): 119 | return None, None 120 | 121 | picked_path = directory_path / prefix 122 | path_size = ( 123 | len(str(Path(directory_path).expanduser().resolve())) + 1 + len(node) 124 | ) 125 | completed_prefix = str(picked_path)[path_size:] 126 | path_options = [ 127 | str(path)[path_size + len(completed_prefix) :] for path in matching_nodes 128 | ] 129 | path_options = [name for name in path_options if name] 130 | 131 | if picked_path.is_dir() and not path_options: 132 | completed_prefix += os.sep 133 | 134 | return completed_prefix or None, path_options 135 | 136 | 137 | if __name__ == "__main__": 138 | 139 | async def run(): 140 | path_complete = PathComplete() 141 | cwd = Path("~/sandbox") 142 | 143 | print(await path_complete(cwd, "~/p")) 144 | 145 | asyncio.run(run()) 146 | -------------------------------------------------------------------------------- /src/toad/fuzzy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fuzzy matcher. 3 | 4 | This class is used by the [command palette](/guide/command_palette) to match search terms. 5 | 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from functools import lru_cache 11 | from operator import itemgetter 12 | from re import finditer 13 | from typing import Iterable, Sequence 14 | 15 | import rich.repr 16 | 17 | from textual.cache import LRUCache 18 | from textual.content import Content 19 | from textual.visual import Style 20 | 21 | 22 | class FuzzySearch: 23 | """Performs a fuzzy search. 24 | 25 | Unlike a regex solution, this will finds all possible matches. 26 | """ 27 | 28 | def __init__( 29 | self, case_sensitive: bool = False, *, cache_size: int = 1024 * 4 30 | ) -> None: 31 | """Initialize fuzzy search. 32 | 33 | Args: 34 | case_sensitive: Is the match case sensitive? 35 | cache_size: Number of queries to cache. 36 | """ 37 | 38 | self.case_sensitive = case_sensitive 39 | self.cache: LRUCache[tuple[str, str], tuple[float, Sequence[int]]] = LRUCache( 40 | cache_size 41 | ) 42 | 43 | def match(self, query: str, candidate: str) -> tuple[float, Sequence[int]]: 44 | """Match against a query. 45 | 46 | Args: 47 | query: The fuzzy query. 48 | candidate: A candidate to check,. 49 | 50 | Returns: 51 | A pair of (score, tuple of offsets). `(0, ())` for no result. 52 | """ 53 | 54 | cache_key = (query, candidate) 55 | if cache_key in self.cache: 56 | return self.cache[cache_key] 57 | default: tuple[float, Sequence[int]] = (0.0, []) 58 | result = max(self._match(query, candidate), key=itemgetter(0), default=default) 59 | self.cache[cache_key] = result 60 | return result 61 | 62 | @classmethod 63 | @lru_cache(maxsize=1024) 64 | def get_first_letters(cls, candidate: str) -> frozenset[int]: 65 | return frozenset({match.start() for match in finditer(r"\w+", candidate)}) 66 | 67 | def score(self, candidate: str, positions: Sequence[int]) -> float: 68 | """Score a search. 69 | 70 | Args: 71 | search: Search object. 72 | 73 | Returns: 74 | Score. 75 | """ 76 | first_letters = self.get_first_letters(candidate) 77 | # This is a heuristic, and can be tweaked for better results 78 | # Boost first letter matches 79 | offset_count = len(positions) 80 | score: float = offset_count + len(first_letters.intersection(positions)) 81 | 82 | groups = 1 83 | last_offset, *offsets = positions 84 | for offset in offsets: 85 | if offset != last_offset + 1: 86 | groups += 1 87 | last_offset = offset 88 | 89 | # Boost to favor less groups 90 | normalized_groups = (offset_count - (groups - 1)) / offset_count 91 | score *= 1 + (normalized_groups * normalized_groups) 92 | return score 93 | 94 | def _match( 95 | self, query: str, candidate: str 96 | ) -> Iterable[tuple[float, Sequence[int]]]: 97 | letter_positions: list[list[int]] = [] 98 | position = 0 99 | 100 | if not self.case_sensitive: 101 | candidate = candidate.lower() 102 | query = query.lower() 103 | 104 | score = self.score 105 | 106 | for offset, letter in enumerate(query): 107 | last_index = len(candidate) - offset 108 | positions: list[int] = [] 109 | letter_positions.append(positions) 110 | index = position 111 | while (location := candidate.find(letter, index)) != -1: 112 | positions.append(location) 113 | index = location + 1 114 | if index >= last_index: 115 | break 116 | if not positions: 117 | yield (0.0, ()) 118 | return 119 | position = positions[0] + 1 120 | 121 | possible_offsets: list[list[int]] = [] 122 | query_length = len(query) 123 | 124 | def get_offsets(offsets: list[int], positions_index: int) -> None: 125 | """Recursively match offsets. 126 | 127 | Args: 128 | offsets: A list of offsets. 129 | positions_index: Index of query letter. 130 | 131 | """ 132 | for offset in letter_positions[positions_index]: 133 | if not offsets or offset > offsets[-1]: 134 | new_offsets = [*offsets, offset] 135 | if len(new_offsets) == query_length: 136 | possible_offsets.append(new_offsets) 137 | else: 138 | get_offsets(new_offsets, positions_index + 1) 139 | 140 | get_offsets([], 0) 141 | 142 | for offsets in possible_offsets: 143 | yield score(candidate, offsets), offsets 144 | -------------------------------------------------------------------------------- /src/toad/widgets/menu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from textual import on 6 | from textual.app import ComposeResult 7 | from textual.binding import Binding 8 | from textual.message import Message 9 | from textual.widgets import ListView, ListItem, Label 10 | from textual._partition import partition 11 | from textual import events 12 | from textual.widget import Widget 13 | 14 | from toad.menus import MenuItem 15 | 16 | 17 | class NonSelectableLabel(Label): 18 | ALLOW_SELECT = False 19 | 20 | 21 | class MenuOption(ListItem): 22 | ALLOW_SELECT = False 23 | 24 | def __init__(self, action: str | None, description: str, key: str | None) -> None: 25 | self._action = action 26 | self._description = description 27 | self._key = key 28 | super().__init__(classes="-has-key" if key else "-no_key") 29 | 30 | def compose(self) -> ComposeResult: 31 | yield NonSelectableLabel(self._key or " ", id="key") 32 | yield NonSelectableLabel(self._description, id="description") 33 | 34 | 35 | class Menu(ListView, can_focus=True): 36 | BINDINGS = [Binding("escape", "dismiss", "Dismiss")] 37 | 38 | DEFAULT_CSS = """ 39 | Menu { 40 | margin: 1 1; 41 | width: auto; 42 | height: auto; 43 | max-width: 100%; 44 | overlay: screen; 45 | position: absolute; 46 | color: $foreground; 47 | background: $panel; 48 | border: block $panel; 49 | constrain: inside inside; 50 | 51 | & > MenuOption { 52 | 53 | layout: horizontal; 54 | width: 1fr; 55 | padding: 0 1; 56 | height: auto !important; 57 | overflow: auto; 58 | expand: optimal; 59 | #description { 60 | color: $text 80%; 61 | width: 1fr; 62 | } 63 | #key { 64 | padding-right: 1; 65 | text-style: bold; 66 | } 67 | 68 | } 69 | 70 | &:blur { 71 | background-tint: transparent; 72 | & > ListItem.-highlight { 73 | color: $block-cursor-blurred-foreground; 74 | background: $block-cursor-blurred-background 30%; 75 | text-style: $block-cursor-blurred-text-style; 76 | } 77 | } 78 | 79 | &:focus { 80 | background-tint: transparent; 81 | & > ListItem.-highlight { 82 | color: $block-cursor-blurred-foreground; 83 | background: $block-cursor-blurred-background; 84 | text-style: $block-cursor-blurred-text-style; 85 | } 86 | } 87 | } 88 | """ 89 | 90 | @dataclass 91 | class OptionSelected(Message): 92 | """The user selected on of the options.""" 93 | 94 | menu: Menu 95 | owner: Widget 96 | action: str | None 97 | 98 | @dataclass 99 | class Dismissed(Message): 100 | """Menu was dismissed.""" 101 | 102 | menu: Menu 103 | 104 | def __init__(self, owner: Widget, options: list[MenuItem], *args, **kwargs) -> None: 105 | self._owner = owner 106 | self._options = options 107 | super().__init__(*args, **kwargs) 108 | 109 | def _insert_options(self) -> None: 110 | with_keys, without_keys = partition( 111 | lambda option: option.key is None, self._options 112 | ) 113 | self.extend( 114 | MenuOption(menu_item.action, menu_item.description, menu_item.key) 115 | for menu_item in with_keys 116 | ) 117 | self.extend( 118 | MenuOption(menu_item.action, menu_item.description, menu_item.key) 119 | for menu_item in without_keys 120 | ) 121 | 122 | def on_mount(self) -> None: 123 | self._insert_options() 124 | 125 | async def activate_index(self, index: int) -> None: 126 | action = self._options[index].action 127 | self.post_message(self.OptionSelected(self, self._owner, action)) 128 | 129 | async def action_dismiss(self) -> None: 130 | self.post_message(self.Dismissed(self)) 131 | 132 | async def on_blur(self) -> None: 133 | self.post_message(self.Dismissed(self)) 134 | 135 | @on(events.Key) 136 | async def on_key(self, event: events.Key) -> None: 137 | for index, option in enumerate(self._options): 138 | if event.key == option.key: 139 | self.index = index 140 | event.stop() 141 | await self.activate_index(index) 142 | break 143 | 144 | @on(ListView.Selected) 145 | async def on_list_view_selected(self, event: ListView.Selected) -> None: 146 | event.stop() 147 | await self.activate_index(event.index) 148 | -------------------------------------------------------------------------------- /src/toad/screens/store.tcss: -------------------------------------------------------------------------------- 1 | StoreScreen { 2 | 3 | #container { 4 | hatch: right $primary 15%; 5 | } 6 | 7 | .instruction-text { 8 | text-style: dim italic; 9 | padding: 1 2; 10 | } 11 | 12 | .heading { 13 | margin: 0 0 0 0; 14 | padding: 1 1 0 2; 15 | # width: 100%; 16 | # border-bottom: solid $text-secondary 50%; 17 | text-style: underline ; 18 | color: $text-warning; 19 | } 20 | 21 | #title-container { 22 | align: center top; 23 | dock: top; 24 | 25 | #title-grid { 26 | margin: 0 1; 27 | border: block black 20%; 28 | background: black 20%; 29 | grid-size: 2 1; 30 | # max-width: 100; 31 | height: auto; 32 | grid-columns: 24 1fr; 33 | grid-rows: auto; 34 | grid-gutter: 1 2; 35 | min-width: 40; 36 | 37 | #info { 38 | 39 | } 40 | 41 | Mandelbrot { 42 | border: none; 43 | height: 100%; 44 | # min-height: 8; 45 | } 46 | } 47 | } 48 | 49 | LoadingIndicator { 50 | height: 3; 51 | } 52 | 53 | GridSelect { 54 | height: auto; 55 | padding: 0; 56 | margin: 0 0; 57 | 58 | &.-highlight { 59 | background: $panel; 60 | border: tall $primary 30%; 61 | } 62 | 63 | &:focus * { 64 | &.-highlight { 65 | background: $panel; 66 | border: tall $primary; 67 | } 68 | } 69 | 70 | } 71 | 72 | #sponsored-agents { 73 | GridSelect { 74 | margin:0 1; 75 | grid-gutter: 1; 76 | keyline: thin white 10%; 77 | } 78 | } 79 | 80 | 81 | AgentItem { 82 | height: auto; 83 | border: tall transparent; 84 | padding: 0 1; 85 | &:hover { 86 | background: $panel; 87 | } 88 | 89 | Grid { 90 | grid-size: 2 1; 91 | grid-columns: 1fr auto; 92 | height: auto; 93 | #type { 94 | text-align: right; 95 | } 96 | } 97 | #description { 98 | text-style: dim; 99 | } 100 | #author { 101 | text-style: italic; 102 | color: $text-secondary; 103 | } 104 | } 105 | Launcher { 106 | GridSelect { 107 | width: 1fr; 108 | } 109 | &.-empty { 110 | LauncherGridSelect { 111 | visibility: hidden; 112 | } 113 | } 114 | .no-agents { 115 | text-style: dim italic; 116 | padding: 1 2; 117 | } 118 | 119 | } 120 | 121 | LauncherItem { 122 | # width: 40; 123 | # max-width: 40; 124 | height: auto; 125 | border: tall transparent; 126 | padding: 0 1; 127 | &:hover { 128 | background: $panel; 129 | } 130 | Digits { 131 | width: auto; 132 | padding: 0 1 0 0; 133 | color: $text-success; 134 | # text-style: bold; 135 | } 136 | #description { 137 | text-style: dim; 138 | text-wrap: nowrap; 139 | text-overflow: ellipsis; 140 | } 141 | #author { 142 | text-style: italic; 143 | color: $text-secondary; 144 | } 145 | } 146 | 147 | } 148 | 149 | AgentModal { 150 | align: center middle; 151 | # background: $surface 80%; 152 | #container { 153 | margin: 2 4 1 4; 154 | padding: 0 1 0 0; 155 | max-width: 100; 156 | height: auto; 157 | border: thick $primary 20%; 158 | 159 | #description-container { 160 | height: auto; 161 | padding: 0 0 1 1; 162 | max-height: 20; 163 | overflow-y: auto; 164 | Markdown { 165 | padding: 0 1; 166 | MarkdownH1 { 167 | margin: 1 1 1 0 168 | } 169 | } 170 | } 171 | 172 | Select { 173 | width: 1fr; 174 | margin-right: 1; 175 | } 176 | Checkbox { 177 | margin: 0; 178 | } 179 | } 180 | Footer { 181 | # margin: 0 0 0 0; 182 | opacity: 0.0; 183 | FooterKey { 184 | .footer-key--key { 185 | color: $accent !important; 186 | } 187 | } 188 | } 189 | } 190 | 191 | ActionModal { 192 | align: center middle; 193 | #container { 194 | margin: 3 6; 195 | height: 1fr; 196 | max-height: 48; 197 | CommandPane { 198 | padding: 1 1; 199 | background: black 10%; 200 | border: tab $primary; 201 | &.-success { 202 | border: tab $text-success 70%; 203 | } 204 | &.-fail { 205 | border: tab $text-error 50%; 206 | } 207 | } 208 | Button { 209 | width: 100%; 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /src/toad/widgets/plan.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from textual.app import ComposeResult 4 | from textual.content import Content 5 | from textual.layout import Layout 6 | from textual.reactive import reactive 7 | from textual import containers 8 | from textual.widgets import Static 9 | 10 | from toad.pill import pill 11 | from toad.widgets.strike_text import StrikeText 12 | 13 | 14 | class NonSelectableStatic(Static): 15 | ALLOW_SELECT = False 16 | 17 | 18 | class Plan(containers.Grid): 19 | BORDER_TITLE = "Plan" 20 | DEFAULT_CLASSES = "block" 21 | DEFAULT_CSS = """ 22 | Plan { 23 | background: black 20%; 24 | height: auto; 25 | padding: 0 1; 26 | margin: 0 1 1 1; 27 | border: tall transparent; 28 | 29 | grid-size: 2; 30 | grid-columns: auto 1fr; 31 | grid-rows: auto; 32 | height: auto; 33 | 34 | .plan { 35 | color: $text-secondary; 36 | } 37 | .status { 38 | padding: 0 0 0 0; 39 | color: $text-secondary; 40 | } 41 | .priority { 42 | padding: 0 0 0 0; 43 | } 44 | .status.status-completed { 45 | color: $text-success; 46 | text-style: bold; 47 | } 48 | .status-pending { 49 | opacity: 0.8; 50 | } 51 | } 52 | 53 | """ 54 | 55 | @dataclass(frozen=True) 56 | class Entry: 57 | """Information about an entry in the Plan.""" 58 | 59 | content: Content 60 | priority: str 61 | status: str 62 | 63 | entries: reactive[list[Entry] | None] = reactive(None, recompose=True) 64 | 65 | LEFT = Content.styled("▌", "$error-muted on transparent r") 66 | 67 | PRIORITIES = { 68 | "high": pill("H", "$error-muted", "$text-error"), 69 | "medium": pill("M", "$warning-muted", "$text-warning"), 70 | "low": pill("L", "$primary-muted", "$text-primary"), 71 | } 72 | 73 | def __init__( 74 | self, 75 | entries: list[Entry], 76 | name: str | None = None, 77 | id: str | None = None, 78 | classes: str | None = None, 79 | ): 80 | self.newly_completed: set[Plan.Entry] = set() 81 | super().__init__(name=name, id=id, classes=classes) 82 | self.set_reactive(Plan.entries, entries) 83 | 84 | def watch_entries(self, old_entries: list[Entry], new_entries: list[Entry]) -> None: 85 | entry_map = {entry.content: entry for entry in old_entries} 86 | newly_completed: set[Plan.Entry] = set() 87 | for entry in new_entries: 88 | old_entry = entry_map.get(entry.content, None) 89 | if ( 90 | old_entry is not None 91 | and entry.status == "completed" 92 | and entry.status != old_entry.status 93 | ): 94 | newly_completed.add(entry) 95 | self.newly_completed = newly_completed 96 | 97 | def compose(self) -> ComposeResult: 98 | if not self.entries: 99 | yield Static("No plan yet", classes="-no-plan") 100 | return 101 | for entry in self.entries: 102 | classes = f"priority-{entry.priority} status-{entry.status}" 103 | yield NonSelectableStatic( 104 | self.render_status(entry.status), 105 | classes=f"status {classes}", 106 | ) 107 | 108 | yield ( 109 | strike_text := StrikeText( 110 | entry.content, 111 | classes=f"plan {classes}", 112 | ) 113 | ) 114 | if entry in self.newly_completed or ( 115 | not self.is_mounted and entry.status == "completed" 116 | ): 117 | self.call_after_refresh(strike_text.strike) 118 | 119 | def render_status(self, status: str) -> Content: 120 | if status == "completed": 121 | return Content.from_markup("✔ ") 122 | elif status == "pending": 123 | return Content.styled("⏲ ") 124 | elif status == "in_progress": 125 | return Content.from_markup("⮕") 126 | return Content() 127 | 128 | 129 | if __name__ == "__main__": 130 | from textual.app import App 131 | 132 | entries = [ 133 | Plan.Entry( 134 | Content.from_markup( 135 | "Build the best damn UI for agentic coding in the terminal" 136 | ), 137 | "high", 138 | "in_progress", 139 | ), 140 | Plan.Entry(Content.from_markup("???"), "medium", "in_progress"), 141 | Plan.Entry( 142 | Content.from_markup("[b]Profit[/b]. Retire to Costa Rica"), 143 | "low", 144 | "pending", 145 | ), 146 | ] 147 | 148 | new_entries = [ 149 | Plan.Entry( 150 | Content.from_markup( 151 | "Build the best damn UI for agentic coding in the terminal" 152 | ), 153 | "high", 154 | "completed", 155 | ), 156 | Plan.Entry(Content.from_markup("???"), "medium", "in_progress"), 157 | Plan.Entry( 158 | Content.from_markup("[b]Profit[/b]. Retire to Costa Rica"), 159 | "low", 160 | "pending", 161 | ), 162 | ] 163 | 164 | class PlanApp(App): 165 | BINDINGS = [("space", "strike")] 166 | 167 | CSS = """ 168 | Screen { 169 | align: center middle; 170 | } 171 | """ 172 | 173 | def compose(self) -> ComposeResult: 174 | yield Plan(entries) 175 | 176 | def action_strike(self) -> None: 177 | self.query_one(Plan).entries = new_entries 178 | 179 | app = PlanApp() 180 | app.run() 181 | -------------------------------------------------------------------------------- /src/toad/screens/agent_modal.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from textual import on 4 | from textual import getters 5 | from textual.app import ComposeResult 6 | 7 | from textual import work 8 | from textual.screen import ModalScreen 9 | from textual import containers 10 | from textual import widgets 11 | from textual.reactive import var 12 | 13 | import toad 14 | from textual.binding import Binding 15 | from toad.agent_schema import Action, Agent, OS, Command 16 | from toad.app import ToadApp 17 | 18 | 19 | class AgentModal(ModalScreen): 20 | AUTO_FOCUS = "#launcher-checkbox" 21 | 22 | BINDINGS = [ 23 | Binding("escape", "dismiss(None)", "Dismiss", show=False), 24 | Binding("space", "dismiss('launch')", "Launch agent", priority=True), 25 | ] 26 | 27 | action = var("") 28 | 29 | app = getters.app(ToadApp) 30 | action_select = getters.query_one("#action-select", widgets.Select) 31 | launcher_checkbox = getters.query_one("#launcher-checkbox", widgets.Checkbox) 32 | 33 | def __init__(self, agent: Agent) -> None: 34 | self._agent = agent 35 | super().__init__() 36 | 37 | def compose(self) -> ComposeResult: 38 | launcher_set = frozenset( 39 | self.app.settings.get("launcher.agents", str).splitlines() 40 | ) 41 | 42 | agent = self._agent 43 | 44 | app = self.app 45 | launcher_set = frozenset(app.settings.get("launcher.agents", str).splitlines()) 46 | agent = self._agent 47 | actions = agent["actions"] 48 | 49 | script_os = cast(OS, toad.os) 50 | if script_os not in actions: 51 | script_os = "*" 52 | 53 | commands: dict[Action, Command] = actions[cast(OS, script_os)] 54 | script_choices = [ 55 | (action["description"], name) for name, action in commands.items() 56 | ] 57 | script_choices.append((f"Launch {agent['name']}", "__launch__")) 58 | 59 | with containers.Vertical(id="container"): 60 | with containers.VerticalScroll(id="description-container"): 61 | yield widgets.Markdown(agent["help"], id="description") 62 | with containers.VerticalGroup(): 63 | with containers.HorizontalGroup(): 64 | yield widgets.Checkbox( 65 | "Show in launcher", 66 | value=agent["identity"] in launcher_set, 67 | id="launcher-checkbox", 68 | ) 69 | yield widgets.Select( 70 | script_choices, 71 | prompt="Actions", 72 | allow_blank=True, 73 | id="action-select", 74 | ) 75 | yield widgets.Button( 76 | "Go", variant="primary", id="run-action", disabled=True 77 | ) 78 | yield widgets.Footer() 79 | 80 | def on_mount(self) -> None: 81 | self.query_one("Footer").styles.animate("opacity", 1.0, duration=500 / 1000) 82 | 83 | @on(widgets.Checkbox.Changed) 84 | def on_checkbox_changed(self, event: widgets.Select.Changed) -> None: 85 | launcher_agents = self.app.settings.get("launcher.agents", str).splitlines() 86 | agent_identity = self._agent["identity"] 87 | if agent_identity in launcher_agents: 88 | launcher_agents.remove(agent_identity) 89 | if event.value: 90 | launcher_agents.insert(0, agent_identity) 91 | self.app.settings.set("launcher.agents", "\n".join(launcher_agents)) 92 | 93 | @on(widgets.Select.Changed) 94 | def on_select_changed(self, event: widgets.Select.Changed) -> None: 95 | self.action = event.value if isinstance(event.value, str) else "" 96 | 97 | @work 98 | @on(widgets.Button.Pressed) 99 | async def on_button_pressed(self) -> None: 100 | agent = self._agent 101 | action = self.action_select.value 102 | 103 | assert isinstance(action, str) 104 | if action == "__launch__": 105 | self.dismiss("launch") 106 | return 107 | 108 | agent_actions = self._agent["actions"] 109 | 110 | if (commands := agent_actions.get(toad.os, None)) is None: 111 | commands = agent_actions.get("*", None) 112 | if commands is None: 113 | self.notify( 114 | "Action is not available on this platform", 115 | title="Agent action", 116 | severity="error", 117 | ) 118 | return 119 | command = commands[action] 120 | 121 | from toad.screens.action_modal import ActionModal 122 | 123 | title = command["description"] 124 | agent_id = self._agent["identity"] 125 | action_command = command["command"] 126 | bootstrap_uv = command.get("bootstrap_uv", False) 127 | 128 | agent = self._agent 129 | # Focus the select 130 | # It's unlikely the user wants to re-run the action 131 | self.action_select.focus() 132 | 133 | return_code = await self.app.push_screen_wait( 134 | ActionModal( 135 | action, 136 | agent_id, 137 | title, 138 | action_command, 139 | bootstrap_uv=bootstrap_uv, 140 | ) 141 | ) 142 | if return_code == 0 and action in {"install", "install-acp"}: 143 | # Add to launcher if we installed something 144 | if not self.launcher_checkbox.value: 145 | self.notify( 146 | f"{agent['name']} has been added to your launcher", 147 | title="Add agent", 148 | severity="information", 149 | ) 150 | self.launcher_checkbox.value = True 151 | 152 | def watch_action(self, action: str) -> None: 153 | go_button = self.query_one("#run-action", widgets.Button) 154 | go_button.disabled = not action 155 | -------------------------------------------------------------------------------- /project/calculator.py: -------------------------------------------------------------------------------- 1 | """ 2 | An implementation of a classic calculator, with a layout inspired by macOS calculator. 3 | 4 | Works like a real calculator. Click the buttons or press the equivalent keys. 5 | """ 6 | 7 | from decimal import Decimal 8 | 9 | from textual import events, on 10 | from textual.app import App, ComposeResult 11 | from textual.containers import Container 12 | from textual.css.query import NoMatches 13 | from textual.reactive import var 14 | from textual.widgets import Button, Digits 15 | 16 | 17 | class CalculatorApp(App): 18 | """A working 'desktop' calculator.""" 19 | 20 | CSS_PATH = "calculator.tcss" 21 | 22 | numbers = var("0") 23 | show_ac = var(True) 24 | left = var(Decimal("0")) 25 | right = var(Decimal("0")) 26 | value = var("") 27 | operator = var("plus") 28 | 29 | # Maps button IDs on to the corresponding key name 30 | NAME_MAP = { 31 | "asterisk": "multiply", 32 | "slash": "divide", 33 | "underscore": "plus-minus", 34 | "full_stop": "point", 35 | "plus_minus_sign": "plus-minus", 36 | "percent_sign": "percent", 37 | "equals_sign": "equals", 38 | "minus": "minus", 39 | "plus": "plus", 40 | } 41 | 42 | def watch_numbers(self, value: str) -> None: 43 | """Called when numbers is updated.""" 44 | self.query_one("#numbers", Digits).update(value) 45 | 46 | def compute_show_ac(self) -> bool: 47 | """Compute switch to show AC or C button""" 48 | return self.value in ("", "0") and self.numbers == "0" 49 | 50 | def watch_show_ac(self, show_ac: bool) -> None: 51 | """Called when show_ac changes.""" 52 | self.query_one("#c").display = not show_ac 53 | self.query_one("#ac").display = show_ac 54 | 55 | def compose(self) -> ComposeResult: 56 | """Add our buttons.""" 57 | with Container(id="calculator"): 58 | yield Digits(id="numbers") 59 | yield Button("AC", id="ac", variant="primary") 60 | yield Button("C", id="c", variant="primary") 61 | yield Button("+/-", id="plus-minus", variant="primary") 62 | yield Button("%", id="percent", variant="primary") 63 | yield Button("÷", id="divide", variant="warning") 64 | yield Button("7", id="number-7", classes="number") 65 | yield Button("8", id="number-8", classes="number") 66 | yield Button("9", id="number-9", classes="number") 67 | yield Button("×", id="multiply", variant="warning") 68 | yield Button("4", id="number-4", classes="number") 69 | yield Button("5", id="number-5", classes="number") 70 | yield Button("6", id="number-6", classes="number") 71 | yield Button("-", id="minus", variant="warning") 72 | yield Button("1", id="number-1", classes="number") 73 | yield Button("2", id="number-2", classes="number") 74 | yield Button("3", id="number-3", classes="number") 75 | yield Button("+", id="plus", variant="warning") 76 | yield Button("0", id="number-0", classes="number") 77 | yield Button(".", id="point") 78 | yield Button("=", id="equals", variant="warning") 79 | 80 | def on_key(self, event: events.Key) -> None: 81 | """Called when the user presses a key.""" 82 | 83 | def press(button_id: str) -> None: 84 | """Press a button, should it exist.""" 85 | try: 86 | self.query_one(f"#{button_id}", Button).press() 87 | except NoMatches: 88 | pass 89 | 90 | key = event.key 91 | if key.isdecimal(): 92 | press(f"number-{key}") 93 | elif key == "c": 94 | press("c") 95 | press("ac") 96 | else: 97 | button_id = self.NAME_MAP.get(key) 98 | if button_id is not None: 99 | press(self.NAME_MAP.get(key, key)) 100 | 101 | @on(Button.Pressed, ".number") 102 | def number_pressed(self, event: Button.Pressed) -> None: 103 | """Pressed a number.""" 104 | assert event.button.id is not None 105 | number = event.button.id.partition("-")[-1] 106 | self.numbers = self.value = self.value.lstrip("0") + number 107 | 108 | @on(Button.Pressed, "#plus-minus") 109 | def plus_minus_pressed(self) -> None: 110 | """Pressed + / -""" 111 | self.numbers = self.value = str(Decimal(self.value or "0") * -1) 112 | 113 | @on(Button.Pressed, "#percent") 114 | def percent_pressed(self) -> None: 115 | """Pressed %""" 116 | self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) 117 | 118 | @on(Button.Pressed, "#point") 119 | def pressed_point(self) -> None: 120 | """Pressed .""" 121 | if "." not in self.value: 122 | self.numbers = self.value = (self.value or "0") + "." 123 | 124 | @on(Button.Pressed, "#ac") 125 | def pressed_ac(self) -> None: 126 | """Pressed AC""" 127 | self.value = "" 128 | self.left = self.right = Decimal(0) 129 | self.operator = "plus" 130 | self.numbers = "0" 131 | 132 | @on(Button.Pressed, "#c") 133 | def pressed_c(self) -> None: 134 | """Pressed C""" 135 | self.value = "" 136 | self.numbers = "0" 137 | 138 | def _do_math(self) -> None: 139 | """Does the math: LEFT OPERATOR RIGHT""" 140 | try: 141 | if self.operator == "plus": 142 | self.left += self.right 143 | elif self.operator == "minus": 144 | self.left -= self.right 145 | elif self.operator == "divide": 146 | self.left /= self.right 147 | elif self.operator == "multiply": 148 | self.left *= self.right 149 | self.numbers = str(self.left) 150 | self.value = "" 151 | except Exception: 152 | self.numbers = "Error" 153 | 154 | @on(Button.Pressed, "#plus,#minus,#divide,#multiply") 155 | def pressed_op(self, event: Button.Pressed) -> None: 156 | """Pressed one of the arithmetic operations.""" 157 | self.right = Decimal(self.value or "0") 158 | self._do_math() 159 | assert event.button.id is not None 160 | self.operator = event.button.id 161 | 162 | @on(Button.Pressed, "#equals") 163 | def pressed_equals(self) -> None: 164 | """Pressed =""" 165 | if self.value: 166 | self.right = Decimal(self.value) 167 | self._do_math() 168 | 169 | 170 | if __name__ == "__main__": 171 | CalculatorApp().run(inline=True) 172 | -------------------------------------------------------------------------------- /src/toad/widgets/highlighted_textarea.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | import re 4 | from typing import Sequence 5 | 6 | from rich.text import Text 7 | 8 | from textual import on 9 | from textual.reactive import reactive 10 | from textual.content import Content 11 | from textual.highlight import highlight, HighlightTheme, TokenType 12 | from textual.message import Message 13 | from textual.widgets import TextArea 14 | from textual.widgets.text_area import Selection 15 | 16 | from pygments.token import Token 17 | 18 | 19 | RE_MATCH_FILE_PROMPT = re.compile(r"(@\S+)|@\"(.*)\"") 20 | RE_SLASH_COMMAND = re.compile(r"(\/\S*)(\W.*)?$") 21 | 22 | 23 | class TextualHighlightTheme(HighlightTheme): 24 | """Contains the style definition for user with the highlight method.""" 25 | 26 | STYLES: dict[TokenType, str] = { 27 | Token.Comment: "$text 60%", 28 | Token.Error: "$text-error on $error-muted", 29 | Token.Generic.Strong: "bold", 30 | Token.Generic.Emph: "italic", 31 | Token.Generic.Error: "$text-error on $error-muted", 32 | Token.Generic.Heading: "$text-primary underline", 33 | Token.Generic.Subheading: "$text-primary", 34 | Token.Keyword: "$text-accent", 35 | Token.Keyword.Constant: "bold $text-success 80%", 36 | Token.Keyword.Namespace: "$text-error", 37 | Token.Keyword.Type: "bold", 38 | Token.Literal.Number: "$text-warning", 39 | Token.Literal.String.Backtick: "$text 60%", 40 | Token.Literal.String: "$text-success 90%", 41 | Token.Literal.String.Doc: "$text-success 80% italic", 42 | Token.Literal.String.Double: "$text-success 90%", 43 | Token.Name: "$text-primary", 44 | Token.Name.Attribute: "$text-warning", 45 | Token.Name.Builtin: "$text-accent", 46 | Token.Name.Builtin.Pseudo: "italic", 47 | Token.Name.Class: "$text-warning bold", 48 | Token.Name.Constant: "$text-error", 49 | Token.Name.Decorator: "$text-primary bold", 50 | Token.Name.Entity: "$text", 51 | Token.Name.Function: "$text-warning underline", 52 | Token.Name.Function.Magic: "$text-warning underline", 53 | Token.Name.Tag: "$text-primary bold", 54 | Token.Name.Variable: "$text-secondary", 55 | Token.Number: "$text-warning", 56 | Token.Operator: "bold", 57 | Token.Operator.Word: "bold $text-error", 58 | Token.String: "$text-success", 59 | Token.Whitespace: "", 60 | } 61 | 62 | 63 | class HighlightedTextArea(TextArea): 64 | highlight_language = reactive("markdown") 65 | 66 | @dataclass 67 | class CursorMove(Message): 68 | selection: Selection 69 | 70 | def __init__( 71 | self, 72 | text: str = "", 73 | *, 74 | name: str | None = None, 75 | id: str | None = None, 76 | classes: str | None = None, 77 | disabled: bool = False, 78 | placeholder: str | Content = "", 79 | ): 80 | self._text_cache: dict[int, Text] = {} 81 | self._highlight_lines: list[Content] | None = None 82 | super().__init__( 83 | text, 84 | name=name, 85 | id=id, 86 | classes=classes, 87 | disabled=disabled, 88 | highlight_cursor_line=False, 89 | placeholder=placeholder, 90 | ) 91 | self.compact = True 92 | 93 | def _clear_caches(self) -> None: 94 | self._highlight_lines = None 95 | self._text_cache.clear() 96 | 97 | def notify_style_update(self) -> None: 98 | self._clear_caches() 99 | return super().notify_style_update() 100 | 101 | def _watch_selection( 102 | self, previous_selection: Selection, selection: Selection 103 | ) -> None: 104 | self.post_message(self.CursorMove(selection)) 105 | super()._watch_selection(previous_selection, selection) 106 | 107 | @property 108 | def highlight_lines(self) -> Sequence[Content]: 109 | if self._highlight_lines is None: 110 | text = self.text 111 | if text.startswith("/") and "\n" not in text: 112 | content = self.highlight_slash_command(text) 113 | self._highlight_lines = [content] 114 | return self._highlight_lines 115 | 116 | language = self.highlight_language 117 | if language == "markdown": 118 | content = self.highlight_markdown(text) 119 | content_lines = content.split("\n", allow_blank=True)[:-1] 120 | self._highlight_lines = content_lines 121 | elif language == "shell": 122 | content = self.highlight_shell(text) 123 | content_lines = content.split("\n", allow_blank=True) 124 | self._highlight_lines = content_lines 125 | else: 126 | raise ValueError("highlight_language must be `markdown` or `shell`") 127 | return self._highlight_lines 128 | 129 | def highlight_slash_command(self, text: str) -> Content: 130 | return Content.styled(text, "$text-success") 131 | 132 | def highlight_markdown(self, text: str) -> Content: 133 | """Highlight markdown content. 134 | 135 | Args: 136 | text: Text containing Markdown. 137 | 138 | Returns: 139 | Highlighted content. 140 | """ 141 | content = highlight( 142 | text + "\n```", 143 | language="markdown", 144 | theme=TextualHighlightTheme, 145 | ) 146 | content = content.highlight_regex(RE_MATCH_FILE_PROMPT, style="$primary") 147 | return content 148 | 149 | def highlight_shell(self, text: str) -> Content: 150 | """Highlight text with a bash shell command. 151 | 152 | Args: 153 | text: Text containing shell command. 154 | 155 | Returns: 156 | Highlighted content. 157 | """ 158 | content = highlight(text, language="sh") 159 | return content 160 | 161 | @on(TextArea.Changed) 162 | def _on_changed(self) -> None: 163 | self._highlight_lines = None 164 | self._text_cache.clear() 165 | 166 | def get_line(self, line_index: int) -> Text: 167 | if (cached_line := self._text_cache.get(line_index)) is not None: 168 | return cached_line.copy() 169 | try: 170 | line = self.highlight_lines[line_index] 171 | except IndexError: 172 | return Text("", end="", no_wrap=True) 173 | rendered_line = list(line.render_segments(self.visual_style)) 174 | text = Text.assemble( 175 | *[(text, style) for text, style, _ in rendered_line], 176 | end="", 177 | no_wrap=True, 178 | ) 179 | self._text_cache[line_index] = text.copy() 180 | return text 181 | -------------------------------------------------------------------------------- /src/toad/ansi/_ansi_colors.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from textual.color import Color 4 | 5 | _ANSI_COLORS: Sequence[str] = [ 6 | "ansi_black", 7 | "ansi_red", 8 | "ansi_green", 9 | "ansi_yellow", 10 | "ansi_blue", 11 | "ansi_magenta", 12 | "ansi_cyan", 13 | "ansi_white", 14 | "ansi_bright_black", 15 | "ansi_bright_red", 16 | "ansi_bright_green", 17 | "ansi_bright_yellow", 18 | "ansi_bright_blue", 19 | "ansi_bright_magenta", 20 | "ansi_bright_cyan", 21 | "ansi_bright_white", 22 | "rgb(0,0,0)", 23 | "rgb(0,0,95)", 24 | "rgb(0,0,135)", 25 | "rgb(0,0,175)", 26 | "rgb(0,0,215)", 27 | "rgb(0,0,255)", 28 | "rgb(0,95,0)", 29 | "rgb(0,95,95)", 30 | "rgb(0,95,135)", 31 | "rgb(0,95,175)", 32 | "rgb(0,95,215)", 33 | "rgb(0,95,255)", 34 | "rgb(0,135,0)", 35 | "rgb(0,135,95)", 36 | "rgb(0,135,135)", 37 | "rgb(0,135,175)", 38 | "rgb(0,135,215)", 39 | "rgb(0,135,255)", 40 | "rgb(0,175,0)", 41 | "rgb(0,175,95)", 42 | "rgb(0,175,135)", 43 | "rgb(0,175,175)", 44 | "rgb(0,175,215)", 45 | "rgb(0,175,255)", 46 | "rgb(0,215,0)", 47 | "rgb(0,215,95)", 48 | "rgb(0,215,135)", 49 | "rgb(0,215,175)", 50 | "rgb(0,215,215)", 51 | "rgb(0,215,255)", 52 | "rgb(0,255,0)", 53 | "rgb(0,255,95)", 54 | "rgb(0,255,135)", 55 | "rgb(0,255,175)", 56 | "rgb(0,255,215)", 57 | "rgb(0,255,255)", 58 | "rgb(95,0,0)", 59 | "rgb(95,0,95)", 60 | "rgb(95,0,135)", 61 | "rgb(95,0,175)", 62 | "rgb(95,0,215)", 63 | "rgb(95,0,255)", 64 | "rgb(95,95,0)", 65 | "rgb(95,95,95)", 66 | "rgb(95,95,135)", 67 | "rgb(95,95,175)", 68 | "rgb(95,95,215)", 69 | "rgb(95,95,255)", 70 | "rgb(95,135,0)", 71 | "rgb(95,135,95)", 72 | "rgb(95,135,135)", 73 | "rgb(95,135,175)", 74 | "rgb(95,135,215)", 75 | "rgb(95,135,255)", 76 | "rgb(95,175,0)", 77 | "rgb(95,175,95)", 78 | "rgb(95,175,135)", 79 | "rgb(95,175,175)", 80 | "rgb(95,175,215)", 81 | "rgb(95,175,255)", 82 | "rgb(95,215,0)", 83 | "rgb(95,215,95)", 84 | "rgb(95,215,135)", 85 | "rgb(95,215,175)", 86 | "rgb(95,215,215)", 87 | "rgb(95,215,255)", 88 | "rgb(95,255,0)", 89 | "rgb(95,255,95)", 90 | "rgb(95,255,135)", 91 | "rgb(95,255,175)", 92 | "rgb(95,255,215)", 93 | "rgb(95,255,255)", 94 | "rgb(135,0,0)", 95 | "rgb(135,0,95)", 96 | "rgb(135,0,135)", 97 | "rgb(135,0,175)", 98 | "rgb(135,0,215)", 99 | "rgb(135,0,255)", 100 | "rgb(135,95,0)", 101 | "rgb(135,95,95)", 102 | "rgb(135,95,135)", 103 | "rgb(135,95,175)", 104 | "rgb(135,95,215)", 105 | "rgb(135,95,255)", 106 | "rgb(135,135,0)", 107 | "rgb(135,135,95)", 108 | "rgb(135,135,135)", 109 | "rgb(135,135,175)", 110 | "rgb(135,135,215)", 111 | "rgb(135,135,255)", 112 | "rgb(135,175,0)", 113 | "rgb(135,175,95)", 114 | "rgb(135,175,135)", 115 | "rgb(135,175,175)", 116 | "rgb(135,175,215)", 117 | "rgb(135,175,255)", 118 | "rgb(135,215,0)", 119 | "rgb(135,215,95)", 120 | "rgb(135,215,135)", 121 | "rgb(135,215,175)", 122 | "rgb(135,215,215)", 123 | "rgb(135,215,255)", 124 | "rgb(135,255,0)", 125 | "rgb(135,255,95)", 126 | "rgb(135,255,135)", 127 | "rgb(135,255,175)", 128 | "rgb(135,255,215)", 129 | "rgb(135,255,255)", 130 | "rgb(175,0,0)", 131 | "rgb(175,0,95)", 132 | "rgb(175,0,135)", 133 | "rgb(175,0,175)", 134 | "rgb(175,0,215)", 135 | "rgb(175,0,255)", 136 | "rgb(175,95,0)", 137 | "rgb(175,95,95)", 138 | "rgb(175,95,135)", 139 | "rgb(175,95,175)", 140 | "rgb(175,95,215)", 141 | "rgb(175,95,255)", 142 | "rgb(175,135,0)", 143 | "rgb(175,135,95)", 144 | "rgb(175,135,135)", 145 | "rgb(175,135,175)", 146 | "rgb(175,135,215)", 147 | "rgb(175,135,255)", 148 | "rgb(175,175,0)", 149 | "rgb(175,175,95)", 150 | "rgb(175,175,135)", 151 | "rgb(175,175,175)", 152 | "rgb(175,175,215)", 153 | "rgb(175,175,255)", 154 | "rgb(175,215,0)", 155 | "rgb(175,215,95)", 156 | "rgb(175,215,135)", 157 | "rgb(175,215,175)", 158 | "rgb(175,215,215)", 159 | "rgb(175,215,255)", 160 | "rgb(175,255,0)", 161 | "rgb(175,255,95)", 162 | "rgb(175,255,135)", 163 | "rgb(175,255,175)", 164 | "rgb(175,255,215)", 165 | "rgb(175,255,255)", 166 | "rgb(215,0,0)", 167 | "rgb(215,0,95)", 168 | "rgb(215,0,135)", 169 | "rgb(215,0,175)", 170 | "rgb(215,0,215)", 171 | "rgb(215,0,255)", 172 | "rgb(215,95,0)", 173 | "rgb(215,95,95)", 174 | "rgb(215,95,135)", 175 | "rgb(215,95,175)", 176 | "rgb(215,95,215)", 177 | "rgb(215,95,255)", 178 | "rgb(215,135,0)", 179 | "rgb(215,135,95)", 180 | "rgb(215,135,135)", 181 | "rgb(215,135,175)", 182 | "rgb(215,135,215)", 183 | "rgb(215,135,255)", 184 | "rgb(215,175,0)", 185 | "rgb(215,175,95)", 186 | "rgb(215,175,135)", 187 | "rgb(215,175,175)", 188 | "rgb(215,175,215)", 189 | "rgb(215,175,255)", 190 | "rgb(215,215,0)", 191 | "rgb(215,215,95)", 192 | "rgb(215,215,135)", 193 | "rgb(215,215,175)", 194 | "rgb(215,215,215)", 195 | "rgb(215,215,255)", 196 | "rgb(215,255,0)", 197 | "rgb(215,255,95)", 198 | "rgb(215,255,135)", 199 | "rgb(215,255,175)", 200 | "rgb(215,255,215)", 201 | "rgb(215,255,255)", 202 | "rgb(255,0,0)", 203 | "rgb(255,0,95)", 204 | "rgb(255,0,135)", 205 | "rgb(255,0,175)", 206 | "rgb(255,0,215)", 207 | "rgb(255,0,255)", 208 | "rgb(255,95,0)", 209 | "rgb(255,95,95)", 210 | "rgb(255,95,135)", 211 | "rgb(255,95,175)", 212 | "rgb(255,95,215)", 213 | "rgb(255,95,255)", 214 | "rgb(255,135,0)", 215 | "rgb(255,135,95)", 216 | "rgb(255,135,135)", 217 | "rgb(255,135,175)", 218 | "rgb(255,135,215)", 219 | "rgb(255,135,255)", 220 | "rgb(255,175,0)", 221 | "rgb(255,175,95)", 222 | "rgb(255,175,135)", 223 | "rgb(255,175,175)", 224 | "rgb(255,175,215)", 225 | "rgb(255,175,255)", 226 | "rgb(255,215,0)", 227 | "rgb(255,215,95)", 228 | "rgb(255,215,135)", 229 | "rgb(255,215,175)", 230 | "rgb(255,215,215)", 231 | "rgb(255,215,255)", 232 | "rgb(255,255,0)", 233 | "rgb(255,255,95)", 234 | "rgb(255,255,135)", 235 | "rgb(255,255,175)", 236 | "rgb(255,255,215)", 237 | "rgb(255,255,255)", 238 | "rgb(8,8,8)", 239 | "rgb(18,18,18)", 240 | "rgb(28,28,28)", 241 | "rgb(38,38,38)", 242 | "rgb(48,48,48)", 243 | "rgb(58,58,58)", 244 | "rgb(68,68,68)", 245 | "rgb(78,78,78)", 246 | "rgb(88,88,88)", 247 | "rgb(98,98,98)", 248 | "rgb(108,108,108)", 249 | "rgb(118,118,118)", 250 | "rgb(128,128,128)", 251 | "rgb(138,138,138)", 252 | "rgb(148,148,148)", 253 | "rgb(158,158,158)", 254 | "rgb(168,168,168)", 255 | "rgb(178,178,178)", 256 | "rgb(188,188,188)", 257 | "rgb(198,198,198)", 258 | "rgb(208,208,208)", 259 | "rgb(218,218,218)", 260 | "rgb(228,228,228)", 261 | "rgb(238,238,238)", 262 | ] 263 | 264 | ANSI_COLORS: list[Color] = [Color.parse(color) for color in _ANSI_COLORS] 265 | -------------------------------------------------------------------------------- /src/toad/screens/main.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from pathlib import Path 3 | import random 4 | 5 | from textual import on 6 | from textual.app import ComposeResult 7 | from textual import getters 8 | from textual.binding import Binding 9 | from textual.command import Hit, Hits, Provider, DiscoveryHit 10 | from textual.content import Content 11 | from textual.screen import Screen 12 | from textual.reactive import var, reactive 13 | from textual.widgets import Footer, OptionList, DirectoryTree, Tree 14 | from textual import containers 15 | from textual.widget import Widget 16 | 17 | 18 | from toad.app import ToadApp 19 | from toad import messages 20 | from toad.agent_schema import Agent 21 | from toad.acp import messages as acp_messages 22 | from toad.widgets.plan import Plan 23 | from toad.widgets.throbber import Throbber 24 | from toad.widgets.conversation import Conversation 25 | from toad.widgets.project_directory_tree import ProjectDirectoryTree 26 | from toad.widgets.side_bar import SideBar 27 | 28 | 29 | class ModeProvider(Provider): 30 | async def search(self, query: str) -> Hits: 31 | """Search for Python files.""" 32 | matcher = self.matcher(query) 33 | 34 | screen = self.screen 35 | assert isinstance(screen, MainScreen) 36 | 37 | for mode in sorted( 38 | screen.conversation.modes.values(), key=lambda mode: mode.name 39 | ): 40 | command = mode.name 41 | score = matcher.match(command) 42 | if score > 0: 43 | yield Hit( 44 | score, 45 | matcher.highlight(command), 46 | partial(screen.conversation.set_mode, mode.id), 47 | help=mode.description, 48 | ) 49 | 50 | async def discover(self) -> Hits: 51 | screen = self.screen 52 | assert isinstance(screen, MainScreen) 53 | 54 | for mode in sorted( 55 | screen.conversation.modes.values(), key=lambda mode: mode.name 56 | ): 57 | yield DiscoveryHit( 58 | mode.name, 59 | partial(screen.conversation.set_mode, mode.id), 60 | help=mode.description, 61 | ) 62 | 63 | 64 | class MainScreen(Screen, can_focus=False): 65 | AUTO_FOCUS = "Conversation Prompt TextArea" 66 | 67 | COMMANDS = {ModeProvider} 68 | BINDINGS = [ 69 | Binding("f3", "show_sidebar", "Sidebar"), 70 | ] 71 | 72 | BINDING_GROUP_TITLE = "Screen" 73 | busy_count = var(0) 74 | throbber: getters.query_one[Throbber] = getters.query_one("#throbber") 75 | conversation = getters.query_one(Conversation) 76 | side_bar = getters.query_one(SideBar) 77 | project_directory_tree = getters.query_one("#project_directory_tree") 78 | 79 | column = reactive(False) 80 | column_width = reactive(100) 81 | scrollbar = reactive("") 82 | project_path: var[Path] = var(Path("./").expanduser().absolute()) 83 | 84 | app = getters.app(ToadApp) 85 | 86 | def __init__(self, project_path: Path, agent: Agent | None = None) -> None: 87 | super().__init__() 88 | self.set_reactive(MainScreen.project_path, project_path) 89 | self._agent = agent 90 | 91 | def get_loading_widget(self) -> Widget: 92 | throbber = self.app.settings.get("ui.throbber", str) 93 | if throbber == "quotes": 94 | from toad.app import QUOTES 95 | from toad.widgets.future_text import FutureText 96 | 97 | quotes = QUOTES.copy() 98 | random.shuffle(quotes) 99 | return FutureText([Content(quote) for quote in quotes]) 100 | return super().get_loading_widget() 101 | 102 | def compose(self) -> ComposeResult: 103 | with containers.Center(): 104 | yield SideBar( 105 | SideBar.Panel("Plan", Plan([])), 106 | SideBar.Panel( 107 | "Project", 108 | ProjectDirectoryTree( 109 | self.project_path, 110 | id="project_directory_tree", 111 | ), 112 | flex=True, 113 | ), 114 | ) 115 | yield Conversation(self.project_path, self._agent).data_bind( 116 | MainScreen.project_path 117 | ) 118 | yield Footer() 119 | 120 | @on(messages.ProjectDirectoryUpdated) 121 | async def on_project_directory_update(self) -> None: 122 | await self.query_one(ProjectDirectoryTree).reload() 123 | 124 | @on(DirectoryTree.FileSelected, "ProjectDirectoryTree") 125 | def on_project_directory_tree_selected(self, event: Tree.NodeSelected): 126 | if (data := event.node.data) is not None: 127 | self.conversation.insert_path_into_prompt(data.path) 128 | 129 | @on(acp_messages.Plan) 130 | async def on_acp_plan(self, message: acp_messages.Plan): 131 | message.stop() 132 | entries = [ 133 | Plan.Entry( 134 | Content(entry["content"]), 135 | entry.get("priority", "medium"), 136 | entry.get("status", "pending"), 137 | ) 138 | for entry in message.entries 139 | ] 140 | self.query_one("SideBar Plan", Plan).entries = entries 141 | 142 | def on_mount(self) -> None: 143 | for tree in self.query("#project_directory_tree").results(DirectoryTree): 144 | tree.data_bind(path=MainScreen.project_path) 145 | for tree in self.query(DirectoryTree): 146 | tree.show_guides = False 147 | tree.guide_depth = 3 148 | 149 | @on(OptionList.OptionHighlighted) 150 | def on_option_list_option_highlighted( 151 | self, event: OptionList.OptionHighlighted 152 | ) -> None: 153 | if event.option.id is not None: 154 | self.conversation.prompt.suggest(event.option.id) 155 | 156 | def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: 157 | if action == "show_sidebar" and self.side_bar.has_focus_within: 158 | return False 159 | return True 160 | 161 | def action_show_sidebar(self) -> None: 162 | self.side_bar.query_one("Collapsible CollapsibleTitle").focus() 163 | 164 | def action_focus_prompt(self) -> None: 165 | self.conversation.focus_prompt() 166 | 167 | @on(SideBar.Dismiss) 168 | def on_side_bar_dismiss(self, message: SideBar.Dismiss): 169 | message.stop() 170 | self.conversation.focus_prompt() 171 | 172 | def watch_column(self, column: bool) -> None: 173 | self.conversation.set_class(column, "-column") 174 | self.conversation.styles.max_width = ( 175 | max(10, self.column_width) if column else None 176 | ) 177 | 178 | def watch_column_width(self, column_width: int) -> None: 179 | self.conversation.styles.max_width = ( 180 | max(10, column_width) if self.column else None 181 | ) 182 | 183 | def watch_scrollbar(self, old_scrollbar: str, scrollbar: str) -> None: 184 | if old_scrollbar: 185 | self.conversation.remove_class(f"-scrollbar-{old_scrollbar}") 186 | self.conversation.add_class(f"-scrollbar-{scrollbar}") 187 | -------------------------------------------------------------------------------- /src/toad/widgets/grid_select.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from textual import containers 4 | from textual.binding import Binding 5 | from textual import events 6 | from textual.message import Message 7 | from textual.reactive import reactive 8 | from textual.layouts.grid import GridLayout 9 | from textual.widget import Widget 10 | 11 | 12 | class GridSelect(containers.ItemGrid, can_focus=True): 13 | FOCUS_ON_CLICK = False 14 | CURSOR_GROUP = Binding.Group("Select") 15 | FOCUS_GROUP = Binding.Group("Focus") 16 | BINDINGS = [ 17 | Binding("up", "cursor_up", "Cursor Up", group=CURSOR_GROUP), 18 | Binding("down", "cursor_down", "Cursor Down", group=CURSOR_GROUP), 19 | Binding("left", "cursor_left", "Cursor Left", group=CURSOR_GROUP), 20 | Binding("right", "cursor_right", "Cursor Right", group=CURSOR_GROUP), 21 | Binding("enter", "select", "Select"), 22 | ] 23 | 24 | highlighted: reactive[int | None] = reactive(None) 25 | 26 | @dataclass 27 | class Selected(Message): 28 | grid_select: "GridSelect" 29 | selected_widget: Widget 30 | 31 | @property 32 | def control(self) -> Widget: 33 | return self.grid_select 34 | 35 | def __init__( 36 | self, 37 | name: str | None = None, 38 | id: str | None = None, 39 | classes: str | None = None, 40 | min_column_width: int = 30, 41 | max_column_width: int | None = None, 42 | ): 43 | super().__init__( 44 | name=name, 45 | id=id, 46 | classes=classes, 47 | min_column_width=min_column_width, 48 | max_column_width=max_column_width, 49 | ) 50 | 51 | @property 52 | def grid_size(self) -> tuple[int, int] | None: 53 | assert isinstance(self.layout, GridLayout) 54 | return self.layout.grid_size 55 | 56 | def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: 57 | if action in {"cursor_up", "cursor_down", "cursor_left", "cursor_right"}: 58 | return ( 59 | None 60 | if ( 61 | (self.grid_size is None or self.highlighted is None) 62 | or len(self.children) <= 1 63 | ) 64 | else True 65 | ) 66 | return True 67 | 68 | def on_focus(self): 69 | if self.highlighted is None: 70 | self.highlighted = 0 71 | self.reveal_highlight() 72 | 73 | def on_blur(self) -> None: 74 | self.highlighted = None 75 | 76 | def reveal_highlight(self): 77 | if self.highlighted is None: 78 | return 79 | try: 80 | highlighted_widget = self.children[self.highlighted] 81 | except IndexError: 82 | pass 83 | else: 84 | if not self.screen.can_view_entire(highlighted_widget): 85 | self.screen.scroll_to_center(highlighted_widget, origin_visible=True) 86 | 87 | def watch_highlighted( 88 | self, old_highlighted: int | None, highlighted: int | None 89 | ) -> None: 90 | if old_highlighted is not None: 91 | try: 92 | self.children[old_highlighted].remove_class("-highlight") 93 | except IndexError: 94 | pass 95 | if highlighted is not None: 96 | try: 97 | highlighted_widget = self.children[highlighted] 98 | highlighted_widget.add_class("-highlight") 99 | except IndexError: 100 | pass 101 | self.reveal_highlight() 102 | 103 | def validate_highlighted(self, highlighted: int | None) -> int | None: 104 | if highlighted is None: 105 | return None 106 | 107 | if not self.children: 108 | return None 109 | if highlighted < 0: 110 | return 0 111 | if highlighted >= len(self.children): 112 | return len(self.children) - 1 113 | return highlighted 114 | 115 | def action_cursor_up(self): 116 | if (grid_size := self.grid_size) is None: 117 | return 118 | if self.highlighted is None: 119 | self.highlighted = 0 120 | else: 121 | width, _height = grid_size 122 | if self.highlighted >= width: 123 | self.highlighted -= width 124 | 125 | def action_cursor_down(self): 126 | if (grid_size := self.grid_size) is None: 127 | return 128 | if self.highlighted is None: 129 | self.highlighted = 0 130 | else: 131 | width, height = grid_size 132 | if self.highlighted + width < len(self.children): 133 | self.highlighted += width 134 | 135 | def action_cursor_left(self): 136 | if self.highlighted is None: 137 | self.highlighted = 0 138 | else: 139 | self.highlighted -= 1 140 | 141 | def action_cursor_right(self): 142 | if self.highlighted is None: 143 | self.highlighted = 0 144 | else: 145 | self.highlighted += 1 146 | 147 | def on_click(self, event: events.Click) -> None: 148 | if event.widget is None: 149 | return 150 | 151 | highlighted_widget: Widget | None = None 152 | if self.highlighted is not None: 153 | try: 154 | highlighted_widget = self.children[self.highlighted] 155 | except IndexError: 156 | pass 157 | for widget in event.widget.ancestors_with_self: 158 | if widget in self.children: 159 | if highlighted_widget is not None and highlighted_widget is widget: 160 | self.action_select() 161 | else: 162 | self.highlighted = self.children.index(widget) 163 | break 164 | self.focus() 165 | 166 | def action_select(self): 167 | if self.highlighted is not None: 168 | try: 169 | highlighted_widget = self.children[self.highlighted] 170 | except IndexError: 171 | pass 172 | else: 173 | self.post_message(self.Selected(self, highlighted_widget)) 174 | 175 | 176 | if __name__ == "__main__": 177 | from textual.app import App, ComposeResult 178 | from textual import widgets 179 | 180 | class GridApp(App): 181 | CSS = """ 182 | .grid-item { 183 | width: 1fr; 184 | padding: 0 1; 185 | # background: blue 20%; 186 | border: blank; 187 | 188 | &:hover { 189 | background: $panel; 190 | } 191 | 192 | &.-highlight { 193 | border: tall $primary; 194 | background: $panel; 195 | } 196 | } 197 | """ 198 | 199 | def compose(self) -> ComposeResult: 200 | yield widgets.Footer() 201 | with GridSelect(): 202 | for n in range(50): 203 | yield widgets.Label( 204 | f"#{n} Where there is a Will, there is a Way!", 205 | classes="grid-item", 206 | ) 207 | 208 | GridApp().run() 209 | -------------------------------------------------------------------------------- /src/toad/widgets/command_pane.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import codecs 3 | from dataclasses import dataclass 4 | 5 | import os 6 | import fcntl 7 | import pty 8 | import struct 9 | import termios 10 | 11 | 12 | from textual import events 13 | from textual.message import Message 14 | 15 | from toad.shell_read import shell_read 16 | 17 | from toad.widgets.terminal import Terminal 18 | 19 | 20 | class CommandError(Exception): 21 | """An error occurred running the command.""" 22 | 23 | 24 | class CommandPane(Terminal): 25 | DEFAULT_CSS = """ 26 | CommandPane { 27 | scrollbar-size: 0 0; 28 | 29 | } 30 | 31 | """ 32 | 33 | def __init__( 34 | self, 35 | name: str | None = None, 36 | id: str | None = None, 37 | classes: str | None = None, 38 | ): 39 | self._execute_task: asyncio.Task | None = None 40 | self._return_code: int | None = None 41 | self._master: int | None = None 42 | super().__init__(name=name, id=id, classes=classes) 43 | 44 | @property 45 | def return_code(self) -> int | None: 46 | return self._return_code 47 | 48 | @dataclass 49 | class CommandComplete(Message): 50 | return_code: int 51 | 52 | def execute(self, command: str, *, final: bool = True) -> asyncio.Task: 53 | self._execute_task = asyncio.create_task(self._execute(command, final=final)) 54 | self.anchor() 55 | return self._execute_task 56 | 57 | def on_resize(self, event: events.Resize): 58 | event.prevent_default() 59 | if self._master is None: 60 | return 61 | self._size_changed() 62 | 63 | def _size_changed(self): 64 | if self._master is None: 65 | return 66 | width, height = self.scrollable_content_region.size 67 | try: 68 | size = struct.pack("HHHH", height, width, 0, 0) 69 | fcntl.ioctl(self._master, termios.TIOCSWINSZ, size) 70 | except OSError: 71 | pass 72 | self.update_size(width, height) 73 | 74 | @property 75 | def is_cooked(self) -> bool: 76 | """Is the terminal in 'cooked' mode?""" 77 | if self._master is None: 78 | return True 79 | attrs = termios.tcgetattr(self._master) 80 | lflag = attrs[3] 81 | return bool(lflag & termios.ICANON) 82 | 83 | async def write_stdin(self, text: str | bytes, hide_echo: bool = False) -> int: 84 | if self._master is None: 85 | return 0 86 | text_bytes = text.encode("utf-8", "ignore") if isinstance(text, str) else text 87 | try: 88 | return await asyncio.to_thread(os.write, self._master, text_bytes) 89 | except OSError: 90 | return 0 91 | 92 | async def _execute(self, command: str, *, final: bool = True) -> None: 93 | # width, height = self.scrollable_content_region.size 94 | 95 | await self.wait_for_refresh() 96 | 97 | master, slave = pty.openpty() 98 | self._master = master 99 | 100 | flags = fcntl.fcntl(master, fcntl.F_GETFL) 101 | fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK) 102 | 103 | # # Get terminal attributes 104 | # attrs = termios.tcgetattr(slave) 105 | 106 | # # Apply the changes 107 | # termios.tcsetattr(slave, termios.TCSANOW, attrs) 108 | 109 | env = os.environ.copy() 110 | env["FORCE_COLOR"] = "1" 111 | env["TTY_COMPATIBLE"] = "1" 112 | env["TERM"] = "xterm-256color" 113 | env["COLORTERM"] = "truecolor" 114 | env["TOAD"] = "1" 115 | env["CLICOLOR"] = "1" 116 | 117 | try: 118 | process = await asyncio.create_subprocess_shell( 119 | command, 120 | stdin=slave, 121 | stdout=slave, 122 | stderr=slave, 123 | env=env, 124 | start_new_session=True, # Linux / macOS only 125 | ) 126 | except Exception as error: 127 | raise CommandError(f"Failed to execute {command!r}; {error}") 128 | 129 | os.close(slave) 130 | 131 | self._size_changed() 132 | 133 | self.set_write_to_stdin(self.write_stdin) 134 | 135 | BUFFER_SIZE = 64 * 1024 136 | reader = asyncio.StreamReader(BUFFER_SIZE) 137 | protocol = asyncio.StreamReaderProtocol(reader) 138 | 139 | loop = asyncio.get_event_loop() 140 | transport, _ = await loop.connect_read_pipe( 141 | lambda: protocol, os.fdopen(master, "rb", 0) 142 | ) 143 | 144 | # Create write transport 145 | writer_protocol = asyncio.BaseProtocol() 146 | self.write_transport, _ = await loop.connect_write_pipe( 147 | lambda: writer_protocol, 148 | os.fdopen(os.dup(master), "wb", 0), 149 | ) 150 | unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") 151 | try: 152 | while True: 153 | data = await shell_read(reader, BUFFER_SIZE) 154 | if line := unicode_decoder.decode(data, final=not data): 155 | try: 156 | await self.write(line) 157 | except Exception as error: 158 | print(repr(line)) 159 | print(error) 160 | from traceback import print_exc 161 | 162 | print_exc() 163 | 164 | if not data: 165 | break 166 | finally: 167 | transport.close() 168 | 169 | await process.wait() 170 | return_code = self._return_code = process.returncode 171 | if final: 172 | self.set_class(return_code == 0, "-success") 173 | self.set_class(return_code != 0, "-fail") 174 | self.post_message(self.CommandComplete(return_code or 0)) 175 | self.hide_cursor = True 176 | 177 | 178 | if __name__ == "__main__": 179 | from textual.app import App, ComposeResult 180 | 181 | COMMAND = os.environ["SHELL"] 182 | # COMMAND = "python test_input.py" 183 | 184 | # COMMAND = "htop" 185 | # COMMAND = "python test_scroll_margins.py" 186 | 187 | # COMMAND = "python cpr.py" 188 | 189 | COMMAND = "python test_input.py" 190 | 191 | class CommandApp(App): 192 | CSS = """ 193 | Screen { 194 | align: center middle; 195 | } 196 | CommandPane { 197 | # background: blue 20%; 198 | scrollbar-gutter: stable; 199 | background: black 10%; 200 | max-height: 40; 201 | # border: green; 202 | border: tab $text-primary; 203 | margin: 0 2; 204 | } 205 | # CommandPane { 206 | # width: 1fr; 207 | # height: 1fr; 208 | # # background: black 10%; 209 | # # color: white; 210 | # background: ansi_default; 211 | # # color: ansi_default; 212 | # } 213 | """ 214 | 215 | def compose(self) -> ComposeResult: 216 | yield CommandPane() 217 | 218 | def on_mount(self) -> None: 219 | command_pane = self.query_one(CommandPane) 220 | command_pane.border_title = COMMAND 221 | command_pane.execute(COMMAND) 222 | 223 | app = CommandApp() 224 | app.run() 225 | -------------------------------------------------------------------------------- /src/toad/widgets/slash_complete.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from operator import itemgetter 3 | from typing import Iterable, Self, Sequence 4 | 5 | from textual import on 6 | from textual.app import ComposeResult 7 | from textual.binding import Binding 8 | from textual.content import Content, Span 9 | 10 | from textual import getters 11 | from textual.message import Message 12 | from textual.reactive import var 13 | from textual import containers 14 | from textual import widgets 15 | from textual.widgets.option_list import Option 16 | 17 | from toad.fuzzy import FuzzySearch 18 | from toad.messages import Dismiss 19 | from toad.slash_command import SlashCommand 20 | from toad.visuals.columns import Columns 21 | 22 | 23 | class SlashComplete(containers.VerticalGroup): 24 | """A widget to auto-complete slash commands.""" 25 | 26 | CURSOR_BINDING_GROUP = Binding.Group(description="Select") 27 | BINDINGS = [ 28 | Binding( 29 | "up", 30 | "cursor_up", 31 | "Cursor up", 32 | group=CURSOR_BINDING_GROUP, 33 | priority=True, 34 | ), 35 | Binding( 36 | "down", 37 | "cursor_down", 38 | "Cursor down", 39 | group=CURSOR_BINDING_GROUP, 40 | priority=True, 41 | ), 42 | Binding("enter", "submit", "Insert /command", priority=True), 43 | Binding("escape", "dismiss", "Dismiss", priority=True), 44 | ] 45 | 46 | DEFAULT_CSS = """ 47 | SlashComplete { 48 | OptionList { 49 | height: auto; 50 | } 51 | } 52 | """ 53 | 54 | input = getters.query_one(widgets.Input) 55 | option_list = getters.query_one(widgets.OptionList) 56 | 57 | slash_commands: var[list[SlashCommand]] = var(list) 58 | 59 | @dataclass 60 | class Completed(Message): 61 | command: str 62 | 63 | def __init__( 64 | self, 65 | slash_commands: Iterable[SlashCommand] | None = None, 66 | id: str | None = None, 67 | classes: str | None = None, 68 | ) -> None: 69 | super().__init__(id=id, classes=classes) 70 | self.slash_commands = list(slash_commands) if slash_commands else [] 71 | self.fuzzy_search = FuzzySearch(case_sensitive=False) 72 | 73 | def compose(self) -> ComposeResult: 74 | yield widgets.Input(compact=True, placeholder="fuzzy search") 75 | yield widgets.OptionList() 76 | 77 | def focus(self, scroll_visible: bool = False) -> Self: 78 | self.filter_slash_commands("") 79 | self.input.focus(scroll_visible) 80 | return self 81 | 82 | def on_mount(self) -> None: 83 | self.filter_slash_commands("") 84 | 85 | def on_descendant_blur(self) -> None: 86 | self.post_message(Dismiss(self)) 87 | 88 | @on(widgets.Input.Changed) 89 | def on_input_changed(self, event: widgets.Input.Changed) -> None: 90 | event.stop() 91 | self.filter_slash_commands(event.value) 92 | 93 | async def watch_slash_commands(self) -> None: 94 | self.filter_slash_commands(self.input.value) 95 | 96 | def filter_slash_commands(self, prompt: str) -> None: 97 | """Filter slash commands by the given prompt. 98 | 99 | Args: 100 | prompt: Text prompt. 101 | """ 102 | prompt = prompt.lstrip("/").casefold() 103 | columns = self.columns = Columns("auto", "flex") 104 | 105 | slash_commands = sorted( 106 | self.slash_commands, 107 | key=lambda slash_command: slash_command.command.casefold(), 108 | ) 109 | deduplicated_slash_commands = { 110 | slash_command.command: slash_command for slash_command in slash_commands 111 | } 112 | self.fuzzy_search.cache.grow(len(deduplicated_slash_commands)) 113 | 114 | if prompt: 115 | slash_prompt = f"/{prompt}" 116 | scores: list[tuple[float, Sequence[int], SlashCommand]] = [ 117 | ( 118 | *self.fuzzy_search.match(prompt, slash_command.command[1:]), 119 | slash_command, 120 | ) 121 | for slash_command in slash_commands 122 | ] 123 | 124 | scores = sorted( 125 | [ 126 | ( 127 | ( 128 | score * 2 129 | if slash_command.command.casefold().startswith(slash_prompt) 130 | else score 131 | ), 132 | highlights, 133 | slash_command, 134 | ) 135 | for score, highlights, slash_command in scores 136 | if score 137 | ], 138 | key=itemgetter(0), 139 | reverse=True, 140 | ) 141 | else: 142 | scores = [(1.0, [], slash_command) for slash_command in slash_commands] 143 | 144 | def make_row( 145 | slash_command: SlashCommand, indices: Iterable[int] 146 | ) -> tuple[Content, ...]: 147 | """Make a row for the Columns display. 148 | 149 | Args: 150 | slash_command: The slash command instance. 151 | indices: Indices of matching characters. 152 | 153 | Returns: 154 | A tuple of `Content` instances for use as a column row. 155 | """ 156 | command = Content.styled(slash_command.command, "$text-success") 157 | command = command.add_spans( 158 | [Span(index + 1, index + 2, "underline not dim") for index in indices] 159 | ) 160 | return (command, Content.styled(slash_command.help, "dim")) 161 | 162 | rows = [ 163 | ( 164 | columns.add_row( 165 | *make_row(slash_command, indices), 166 | ), 167 | slash_command.command, 168 | ) 169 | for _, indices, slash_command in scores 170 | ] 171 | self.option_list.set_options( 172 | Option(row, id=command_name) for row, command_name in rows 173 | ) 174 | if self.display: 175 | self.option_list.highlighted = 0 176 | else: 177 | with self.option_list.prevent(widgets.OptionList.OptionHighlighted): 178 | self.option_list.highlighted = 0 179 | 180 | def action_cursor_down(self) -> None: 181 | self.option_list.action_cursor_down() 182 | 183 | def action_cursor_up(self) -> None: 184 | self.option_list.action_cursor_up() 185 | 186 | def action_dismiss(self) -> None: 187 | self.post_message(Dismiss(self)) 188 | 189 | def action_submit(self) -> None: 190 | if (option := self.option_list.highlighted_option) is not None: 191 | with self.input.prevent(widgets.Input.Changed): 192 | self.input.clear() 193 | self.post_message(Dismiss(self)) 194 | self.post_message(self.Completed(option.id or "")) 195 | 196 | 197 | if __name__ == "__main__": 198 | from textual.app import App, ComposeResult 199 | 200 | COMMANDS = [ 201 | SlashCommand("/help", "Help with slash commands"), 202 | SlashCommand("/foo", "This is FOO"), 203 | SlashCommand("/bar", "This is BAR"), 204 | SlashCommand("/baz", "This is BAZ"), 205 | ] 206 | 207 | class SlashApp(App): 208 | def compose(self) -> ComposeResult: 209 | yield SlashComplete(COMMANDS) 210 | 211 | SlashApp().run() 212 | -------------------------------------------------------------------------------- /src/toad/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | from toad.app import ToadApp 5 | from toad.agent_schema import Agent 6 | 7 | 8 | def check_directory(path: str) -> None: 9 | """Check a path is directory, or exit the app. 10 | 11 | Args: 12 | path: Path to check. 13 | """ 14 | from pathlib import Path 15 | 16 | if not Path(path).resolve().is_dir(): 17 | print(f"Not a directory: {path}") 18 | sys.exit(-1) 19 | 20 | 21 | async def get_agent_data(launch_agent) -> Agent | None: 22 | launch_agent = launch_agent.lower() 23 | 24 | from toad.agents import read_agents, AgentReadError 25 | 26 | try: 27 | agents = await read_agents() 28 | except AgentReadError: 29 | agents = {} 30 | 31 | for agent_data in agents.values(): 32 | if ( 33 | agent_data["short_name"].lower() == launch_agent 34 | or agent_data["identity"].lower() == launch_agent 35 | ): 36 | launch_agent = agent_data["identity"] 37 | break 38 | 39 | return agents.get(launch_agent) 40 | 41 | 42 | class DefaultCommandGroup(click.Group): 43 | def parse_args(self, ctx, args): 44 | if "--help" in args or "-h" in args: 45 | return super().parse_args(ctx, args) 46 | # Check if first arg is a known subcommand 47 | if not args or args[0] not in self.commands: 48 | # If not a subcommand, prepend the default command name 49 | args.insert(0, "run") 50 | return super().parse_args(ctx, args) 51 | 52 | def format_usage(self, ctx, formatter): 53 | formatter.write_usage(ctx.command_path, "[OPTIONS] PATH OR COMMAND [ARGS]...") 54 | 55 | 56 | @click.group(cls=DefaultCommandGroup) 57 | def main(): 58 | """🐸 Toad — AI for your terminal.""" 59 | 60 | 61 | # @click.group(invoke_without_command=True) 62 | # @click.pass_context 63 | @main.command("run") 64 | @click.argument("project_dir", metavar="PATH", required=False, default=".") 65 | @click.option("-a", "--agent", metavar="AGENT", default="") 66 | @click.option( 67 | "--port", 68 | metavar="PORT", 69 | default=8000, 70 | type=int, 71 | help="Port to use in conjunction with --serve", 72 | ) 73 | @click.option( 74 | "--host", 75 | metavar="HOST", 76 | default="localhost", 77 | type=str, 78 | help="Host to use in conjunction with --serve", 79 | ) 80 | @click.option("--serve", is_flag=True, help="Serve Toad as a web application") 81 | def run(port: int, host: str, serve: bool, project_dir: str = ".", agent: str = "1"): 82 | """Run an agent (with also run with `toad PATH`).""" 83 | 84 | check_directory(project_dir) 85 | 86 | if agent: 87 | import asyncio 88 | 89 | agent_data = asyncio.run(get_agent_data(agent)) 90 | else: 91 | agent_data = None 92 | 93 | app = ToadApp( 94 | mode=None if agent_data else "store", 95 | agent_data=agent_data, 96 | project_dir=project_dir, 97 | ) 98 | if serve: 99 | import shlex 100 | from textual_serve.server import Server 101 | 102 | command_args = sys.argv 103 | try: 104 | command_args.remove("--serve") 105 | except ValueError: 106 | pass 107 | serve_command = shlex.join(command_args) 108 | server = Server( 109 | serve_command, 110 | host=host, 111 | port=port, 112 | title=serve_command, 113 | ) 114 | server.serve() 115 | else: 116 | app.run() 117 | app.run_on_exit() 118 | 119 | 120 | @main.command("acp") 121 | @click.argument("command", metavar="COMMAND") 122 | @click.option( 123 | "--title", 124 | metavar="TITLE", 125 | help="Optional title to display in the status bar", 126 | default=None, 127 | ) 128 | @click.option("--project-dir", metavar="PATH", default=None) 129 | @click.option( 130 | "--port", 131 | metavar="PORT", 132 | default=8000, 133 | type=int, 134 | help="Port to use in conjunction with --serve", 135 | ) 136 | @click.option( 137 | "--host", 138 | metavar="HOST", 139 | default="localhost", 140 | help="Host to use in conjunction with --serve", 141 | ) 142 | @click.option("--serve", is_flag=True, help="Serve Toad as a web application") 143 | def acp( 144 | command: str, 145 | host: str, 146 | port: int, 147 | title: str | None, 148 | project_dir: str | None, 149 | serve: bool = False, 150 | ) -> None: 151 | """Run an ACP agent.""" 152 | 153 | from rich import print 154 | 155 | from toad.agent_schema import Agent as AgentData 156 | 157 | command_name = command.split(" ", 1)[0].lower() 158 | identity = f"{command_name}.custom.batrachian.ai" 159 | 160 | agent_data: AgentData = { 161 | "identity": identity, 162 | "name": title or command.partition(" ")[0], 163 | "short_name": "agent", 164 | "url": "https://github.com/batrachianai/toad", 165 | "protocol": "acp", 166 | "type": "coding", 167 | "author_name": "Will McGugan", 168 | "author_url": "https://willmcgugan.github.io/", 169 | "publisher_name": "Will McGugan", 170 | "publisher_url": "https://willmcgugan.github.io/", 171 | "description": "Agent launched from CLI", 172 | "tags": [], 173 | "help": "", 174 | "run_command": {"*": command}, 175 | "actions": {}, 176 | } 177 | if serve: 178 | import shlex 179 | from textual_serve.server import Server 180 | 181 | command_components = [sys.argv[0], "acp", command] 182 | if project_dir: 183 | command_components.append(f"--project-dir={project_dir}") 184 | serve_command = shlex.join(command_components) 185 | 186 | server = Server( 187 | serve_command, 188 | host=host, 189 | port=port, 190 | title=serve_command, 191 | ) 192 | server.serve() 193 | else: 194 | app = ToadApp(agent_data=agent_data, project_dir=project_dir) 195 | app.run() 196 | app.run_on_exit() 197 | 198 | print("") 199 | print("[bold magenta]Thanks for trying out Toad!") 200 | print("Please head to Discussions to share your experiences (good or bad).") 201 | print("https://github.com/batrachianai/toad/discussions") 202 | 203 | 204 | @main.command("settings") 205 | def settings() -> None: 206 | """Settings information.""" 207 | app = ToadApp() 208 | print(f"{app.settings_path}") 209 | 210 | 211 | # @main.command("replay") 212 | # @click.argument("path", metavar="PATH.jsonl") 213 | # def replay(path: str) -> None: 214 | # """Replay interaction from a jsonl file.""" 215 | # import time 216 | 217 | # stdout = sys.stdout.buffer 218 | # with open(path, "rb") as replay_file: 219 | # for line in replay_file.readlines(): 220 | # time.sleep(0.1) 221 | # stdout.write(line) 222 | # stdout.flush() 223 | 224 | 225 | @main.command("serve") 226 | @click.option("--port", metavar="PORT", default=8000, type=int) 227 | @click.option("--host", metavar="HOST", default="localhost") 228 | def serve(port: int, host: str) -> None: 229 | """Serve Toad as a web application.""" 230 | from textual_serve.server import Server 231 | 232 | server = Server(sys.argv[0], host=host, port=port, title="Toad") 233 | server.serve() 234 | 235 | 236 | @main.command("about") 237 | def about() -> None: 238 | """Show about information.""" 239 | 240 | from toad import about 241 | 242 | app = ToadApp() 243 | 244 | print(about.render(app)) 245 | 246 | 247 | if __name__ == "__main__": 248 | main() 249 | -------------------------------------------------------------------------------- /src/toad/shell.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | from contextlib import suppress 5 | import os 6 | import asyncio 7 | import codecs 8 | import fcntl 9 | import platform 10 | import pty 11 | import struct 12 | import termios 13 | from dataclasses import dataclass 14 | from typing import TYPE_CHECKING 15 | 16 | from textual.message import Message 17 | 18 | from toad.shell_read import shell_read 19 | 20 | from toad.widgets.terminal import Terminal 21 | 22 | if TYPE_CHECKING: 23 | from toad.widgets.conversation import Conversation 24 | 25 | IS_MACOS = platform.system() == "Darwin" 26 | 27 | 28 | def resize_pty(fd, cols, rows): 29 | """Resize the pseudo-terminal""" 30 | # Pack the dimensions into the format expected by TIOCSWINSZ 31 | try: 32 | size = struct.pack("HHHH", rows, cols, 0, 0) 33 | fcntl.ioctl(fd, termios.TIOCSWINSZ, size) 34 | except OSError: 35 | # Possibly file descriptor closed 36 | pass 37 | 38 | 39 | @dataclass 40 | class CurrentWorkingDirectoryChanged(Message): 41 | """Current working directory has changed in shell.""" 42 | 43 | path: str 44 | 45 | 46 | @dataclass 47 | class ShellFinished(Message): 48 | """The shell finished.""" 49 | 50 | 51 | class Shell: 52 | """Responsible for shell interactions in Conversation.""" 53 | 54 | def __init__( 55 | self, 56 | conversation: Conversation, 57 | working_directory: str, 58 | shell="", 59 | start="", 60 | ) -> None: 61 | self.conversation = conversation 62 | self.working_directory = working_directory 63 | 64 | self.terminal: Terminal | None = None 65 | self.new_log: bool = False 66 | self.shell = shell or os.environ.get("SHELL", "sh") 67 | self.shell_start = start 68 | self.master: int | None = None 69 | self._task: asyncio.Task | None = None 70 | self._process: asyncio.subprocess.Process | None = None 71 | 72 | self._finished: bool = False 73 | self._ready_event: asyncio.Event = asyncio.Event() 74 | 75 | self._hide_echo: set[bytes] = set() 76 | """A set of byte strings to remove from output.""" 77 | 78 | @property 79 | def is_finished(self) -> bool: 80 | return self._finished 81 | 82 | async def send(self, command: str, width: int, height: int) -> None: 83 | await self._ready_event.wait() 84 | if self.master is None: 85 | print("TTY FD not set") 86 | return 87 | 88 | self.terminal = None 89 | await asyncio.to_thread(resize_pty, self.master, width, max(height, 1)) 90 | 91 | get_pwd_command = f"{command};" + r'printf "\e]2025;$(pwd);\e\\"' + "\n" 92 | await self.write(get_pwd_command, hide_echo=True) 93 | 94 | def start(self) -> None: 95 | assert self._task is None 96 | self._task = asyncio.create_task(self.run(), name=repr(self)) 97 | 98 | async def interrupt(self) -> None: 99 | """Interrupt the running command.""" 100 | await self.write(b"\x03") 101 | 102 | def update_size(self, width: int, height: int) -> None: 103 | """Update the size of the shell pty. 104 | 105 | Args: 106 | width: Desired width. 107 | height: Desired height. 108 | """ 109 | if self.master is None: 110 | return 111 | with suppress(OSError): 112 | resize_pty(self.master, width, max(height, 1)) 113 | 114 | async def write(self, text: str | bytes, hide_echo: bool = False) -> int: 115 | if self.master is None: 116 | return 0 117 | text_bytes = text.encode("utf-8", "ignore") if isinstance(text, str) else text 118 | if hide_echo: 119 | self._hide_echo.add(text_bytes) 120 | return await asyncio.to_thread(os.write, self.master, text_bytes) 121 | 122 | async def run(self) -> None: 123 | current_directory = self.working_directory 124 | 125 | master, slave = pty.openpty() 126 | self.master = master 127 | 128 | flags = fcntl.fcntl(master, fcntl.F_GETFL) 129 | fcntl.fcntl(master, fcntl.F_SETFL, flags | os.O_NONBLOCK) 130 | 131 | env = os.environ.copy() 132 | env["FORCE_COLOR"] = "1" 133 | env["TTY_COMPATIBLE"] = "1" 134 | env["TERM"] = "xterm-256color" 135 | env["COLORTERM"] = "truecolor" 136 | env["TOAD"] = "1" 137 | env["CLICOLOR"] = "1" 138 | 139 | shell = self.shell 140 | 141 | def setup_pty(): 142 | os.setsid() 143 | fcntl.ioctl(slave, termios.TIOCSCTTY, 0) 144 | 145 | try: 146 | _process = await asyncio.create_subprocess_shell( 147 | shell, 148 | stdin=slave, 149 | stdout=slave, 150 | stderr=slave, 151 | env=env, 152 | cwd=current_directory, 153 | preexec_fn=setup_pty, 154 | ) 155 | except Exception as error: 156 | self.conversation.notify( 157 | f"Unable to start shell: {error}\n\nCheck your settings.", 158 | title="Shell", 159 | severity="error", 160 | ) 161 | return 162 | 163 | os.close(slave) 164 | BUFFER_SIZE = 64 * 1024 165 | reader = asyncio.StreamReader(BUFFER_SIZE) 166 | protocol = asyncio.StreamReaderProtocol(reader) 167 | 168 | loop = asyncio.get_event_loop() 169 | transport, _ = await loop.connect_read_pipe( 170 | lambda: protocol, os.fdopen(master, "rb", 0) 171 | ) 172 | 173 | self._ready_event.set() 174 | 175 | if shell_start := self.shell_start.strip(): 176 | shell_start = self.shell_start.strip() 177 | if not shell_start.endswith("\n"): 178 | shell_start += "\n" 179 | await self.write(shell_start, hide_echo=True) 180 | 181 | unicode_decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") 182 | 183 | while True: 184 | data = await shell_read(reader, BUFFER_SIZE) 185 | 186 | for string_bytes in list(self._hide_echo): 187 | remove_bytes = string_bytes.replace(b"\n", b"\r\n") 188 | if remove_bytes in data: 189 | data = data.replace(remove_bytes, b"") 190 | self._hide_echo.discard(string_bytes) 191 | if not data: 192 | data = b"\r" 193 | 194 | if line := unicode_decoder.decode(data, final=not data): 195 | if self.terminal is None or self.terminal.is_finalized: 196 | previous_state = ( 197 | None if self.terminal is None else self.terminal.state 198 | ) 199 | self.terminal = await self.conversation.new_terminal() 200 | # if previous_state is not None: 201 | # self.terminal.set_state(previous_state) 202 | self.terminal.set_write_to_stdin(self.write) 203 | if await self.terminal.write(line) and not self.terminal.display: 204 | if ( 205 | self.terminal.alternate_screen 206 | or not self.terminal.state.scrollback_buffer.is_blank 207 | ): 208 | self.terminal.display = True 209 | new_directory = self.terminal.current_directory 210 | if new_directory and new_directory != current_directory: 211 | current_directory = new_directory 212 | self.conversation.post_message( 213 | CurrentWorkingDirectoryChanged(current_directory) 214 | ) 215 | if ( 216 | self.terminal is not None 217 | and self.terminal.is_finalized 218 | and self.terminal.state.scrollback_buffer.is_blank 219 | ): 220 | await self.terminal.remove() 221 | self.terminal = None 222 | 223 | if not data: 224 | break 225 | 226 | self.master = None 227 | self._finished = True 228 | self.conversation.post_message(ShellFinished()) 229 | --------------------------------------------------------------------------------