├── 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 |