Angel is a TUI-based autonomous coding agent built on fauxtp GenServers.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

bwuh

fizzAI f75abf88 da05f105

+131 -58
+19 -23
README.md
··· 2 2 3 3 > *"Hey there, sugar~ I'm Angel. Your sassy autonomous coding agent."* ๐Ÿ’… 4 4 5 - Angel is a TUI-based autonomous coding agent built on [fauxtp](https://github.com/fizzAI/fauxtp) GenServers and [Textual](https://textual.textualize.io/). Named after Angel Dust from Hazbin Hotel โ€” flirty, dramatic, sharp-tongued, but secretly competent and caring underneath. 5 + Angel is a TUI-based autonomous coding agent built on [fauxtp](https://github.com/fizzAI/fauxtp) GenServers. Named after Angel Dust from Hazbin Hotel โ€” flirty, dramatic, sharp-tongued, but secretly competent and caring underneath. 6 6 7 7 ## Architecture 8 8 9 9 Angel is built as a supervision tree of GenServer actors communicating via message passing: 10 10 11 11 ``` 12 - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 13 - โ”‚ Textual TUI โ”‚ 14 - โ”‚ (main thread / asyncio) โ”‚ 15 - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 16 - โ”‚ BlockingPortal 17 - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 18 - โ”‚ anyio actor thread โ”‚ 19 - โ”‚ โ”‚ 20 - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 21 - โ”‚ โ”‚ UserServer โ”‚โ—„โ”€โ”€โ–บโ”‚ AgentServer โ”‚ โ”‚ 22 - โ”‚ โ”‚ (bridge) โ”‚ โ”‚ (brain) โ”‚ โ”‚ 23 - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”˜ โ”‚ 24 - โ”‚ โ”‚ โ”‚ โ”‚ 25 - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ” โ”Œโ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ 26 - โ”‚ โ”‚LLMServerโ”‚ โ”‚ ToolServer โ”‚ โ”‚ 27 - โ”‚ โ”‚(litellm)โ”‚ โ”‚ (fs/shell) โ”‚ โ”‚ 28 - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ 29 - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 12 + +------------------------------------------+ 13 + | TUI | 14 + | (main thread / asyncio) | 15 + +------------------------------------------+ 16 + | BlockingPortal 17 + - - - - - | - - - - - - - - - - - - - - - - 18 + | anyio actor thread 19 + v 20 + +---------------+ +----------------+ 21 + | UserServer |<----->| AgentServer | 22 + | (bridge) | | (brain) | 23 + +---------------+ +----------------+ 24 + | | 25 + v v 26 + +-----------+ +-----------+ 27 + | LLMServer | |ToolServer | 28 + | (litellm) | | (fs/shell)| 29 + +-----------+ +-----------+ 30 30 ``` 31 31 32 32 - **LLMServer** โ€” wraps litellm for chat completions (any provider) ··· 93 93 - Repeats until the LLM produces a final text response 94 94 5. UserServer relays all status updates to the TUI via thread-safe callbacks 95 95 6. The TUI displays tool calls, results, and the final response 96 - 97 - ## License 98 - 99 - MIT
+66 -35
angel/tui.py
··· 3 3 A lightweight full-screen TUI that keeps an output pane above a persistent 4 4 input prompt. Thinking/tool status is shown in a dedicated status bar above 5 5 the input line (ร  la Claude Code), keeping the chat window clean. 6 + 7 + Agent responses are rendered as Markdown via Rich โ†’ ANSI โ†’ prompt_toolkit 8 + styled fragments so code blocks, bold, italics, lists, etc. display properly. 6 9 """ 7 10 8 11 from __future__ import annotations 9 12 13 + import io 14 + import os 10 15 from typing import Any, Callable 11 16 12 17 from prompt_toolkit.application import Application 13 18 from prompt_toolkit.buffer import Buffer 14 - from prompt_toolkit.document import Document 15 19 from prompt_toolkit.filters import Condition 16 20 from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples 17 21 from prompt_toolkit.key_binding import KeyBindings 18 22 from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window 19 23 from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl 20 24 from prompt_toolkit.layout.processors import BeforeInput 21 - from prompt_toolkit.lexers import Lexer 22 25 from prompt_toolkit.styles import Style 26 + from rich.console import Console 27 + from rich.markdown import Markdown 28 + from rich.theme import Theme 23 29 24 30 25 31 # โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ··· 40 46 } 41 47 ) 42 48 43 - 44 - # โ”€โ”€ Lexer โ€” colours output lines by their prefix โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 45 - class _ChatLexer(Lexer): 46 - """Assigns a style class to each output line based on its content.""" 47 - 48 - def lex_document(self, document): # type: ignore[override] 49 - lines = document.lines 50 - 51 - def _get_line(lineno: int) -> StyleAndTextTuples: 52 - if lineno >= len(lines): 53 - return [("", "")] 54 - line = lines[lineno] 55 - cls = _classify(line) 56 - return [(f"class:{cls}", line)] 57 - 58 - return _get_line 49 + # Rich theme โ€” keep markdown rendering visually consistent with the TUI palette. 50 + _RICH_THEME = Theme( 51 + { 52 + "markdown.h1": "bold #ff6eb4", 53 + "markdown.h2": "bold #c77dff", 54 + "markdown.h3": "bold #ffb347", 55 + "markdown.code": "#e8e0f0 on #2a2040", 56 + "markdown.block_code": "#e8e0f0", 57 + "markdown.item.bullet": "#c77dff bold", 58 + "markdown.link": "underline #7dffb3", 59 + "markdown.link_url": "dim #7dffb3", 60 + } 61 + ) 59 62 60 63 61 64 def _classify(line: str) -> str: ··· 74 77 return "output.title" 75 78 if any(line.startswith(p) for p in ("โœจ", "๐Ÿง ", "Still")): 76 79 return "output.status" 77 - if line.startswith("I'm Angel โ€” your autonomous coding agent.") or line.startswith("Type a message and I'll get to work. Ctrl+C to quit"): 80 + if line.startswith( 81 + "I'm Angel โ€” your autonomous coding agent." 82 + ) or line.startswith("Type a message and I'll get to work. Ctrl+C to quit"): 78 83 return "output.welcome" 79 84 return "output.default" 80 85 81 86 87 + def _render_markdown(text: str) -> StyleAndTextTuples: 88 + """Render *text* as Markdown via Rich โ†’ ANSI โ†’ prompt_toolkit fragments.""" 89 + try: 90 + width = os.get_terminal_size().columns - 2 91 + except OSError: 92 + width = 88 93 + buf = io.StringIO() 94 + console = Console( 95 + file=buf, 96 + force_terminal=True, 97 + width=max(width, 40), 98 + highlight=False, 99 + theme=_RICH_THEME, 100 + ) 101 + console.print(Markdown(text)) 102 + ansi_str = buf.getvalue().rstrip("\n") 103 + return list(ANSI(ansi_str).__pt_formatted_text__()) 104 + 105 + 82 106 # โ”€โ”€ Application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 83 107 class AngelApp: 84 108 """Lightweight full-screen TUI for Angel. ··· 101 125 self._app: Application[None] | None = None 102 126 self._status_text: str = "" 103 127 104 - self._output_buffer = Buffer( 105 - name="output", 106 - read_only=Condition(lambda: True), 107 - ) 128 + # Output is a list of (style, text) tuples rendered by FormattedTextControl. 129 + self._output_fragments: StyleAndTextTuples = [] 108 130 self._input_buffer = Buffer(name="input", multiline=False) 109 131 110 132 # โ”€โ”€ status bar helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ··· 144 166 145 167 @kb.add("c-l") 146 168 def _clear(_event: Any) -> None: 147 - self._output_buffer.set_document(Document(""), bypass_readonly=True) 169 + self._output_fragments.clear() 148 170 self._append("Chat cleared~ โœจ") 149 171 150 172 output_window = Window( 151 - content=BufferControl( 152 - buffer=self._output_buffer, 153 - lexer=_ChatLexer(), 173 + content=FormattedTextControl( 174 + lambda: self._output_fragments, 154 175 focusable=False, 155 176 ), 156 177 wrap_lines=True, ··· 205 226 return 0 206 227 207 228 def _append(self, text: str) -> None: 208 - """Append one or more lines to the output buffer.""" 209 - current = self._output_buffer.text 210 - new = (current + "\n" + text) if current else text 211 - self._output_buffer.set_document( 212 - Document(new, len(new)), 213 - bypass_readonly=True, 214 - ) 229 + """Append plain styled lines to the output pane.""" 230 + if self._output_fragments: 231 + self._output_fragments.append(("", "\n")) 232 + for i, line in enumerate(text.split("\n")): 233 + if i > 0: 234 + self._output_fragments.append(("", "\n")) 235 + cls = _classify(line) 236 + self._output_fragments.append((f"class:{cls}", line)) 237 + if self._app is not None: 238 + self._app.invalidate() 239 + 240 + def _append_markdown(self, text: str) -> None: 241 + """Render *text* as Markdown and append rich-styled fragments.""" 242 + if self._output_fragments: 243 + self._output_fragments.append(("", "\n")) 244 + self._output_fragments.extend(_render_markdown(text)) 215 245 if self._app is not None: 216 246 self._app.invalidate() 217 247 ··· 260 290 261 291 def on_agent_done(self, final_text: str) -> None: 262 292 self._set_status("") # clear the status bar 263 - self._append(f"โŸฉ Angel\n{final_text}") 293 + self._append("โŸฉ Angel") 294 + self._append_markdown(final_text) 264 295 self._append("โ”€" * 60) 265 296 self._busy = False
+1
pyproject.toml
··· 8 8 "fauxtp @ git+https://github.com/fizzAI/fauxtp", 9 9 "litellm>=1.81.9", 10 10 "prompt-toolkit>=3.0", 11 + "rich>=13.0", 11 12 ] 12 13 13 14 [project.scripts]
+45
uv.lock
··· 134 134 { name = "fauxtp" }, 135 135 { name = "litellm" }, 136 136 { name = "prompt-toolkit" }, 137 + { name = "rich" }, 137 138 ] 138 139 139 140 [package.metadata] ··· 141 142 { name = "fauxtp", git = "https://github.com/fizzAI/fauxtp" }, 142 143 { name = "litellm", specifier = ">=1.81.9" }, 143 144 { name = "prompt-toolkit", specifier = ">=3.0" }, 145 + { name = "rich", specifier = ">=13.0" }, 144 146 ] 145 147 146 148 [[package]] ··· 725 727 ] 726 728 727 729 [[package]] 730 + name = "markdown-it-py" 731 + version = "4.0.0" 732 + source = { registry = "https://pypi.org/simple" } 733 + dependencies = [ 734 + { name = "mdurl" }, 735 + ] 736 + sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } 737 + wheels = [ 738 + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, 739 + ] 740 + 741 + [[package]] 728 742 name = "markupsafe" 729 743 version = "3.0.3" 730 744 source = { registry = "https://pypi.org/simple" } ··· 796 810 { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, 797 811 { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, 798 812 { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, 813 + ] 814 + 815 + [[package]] 816 + name = "mdurl" 817 + version = "0.1.2" 818 + source = { registry = "https://pypi.org/simple" } 819 + sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } 820 + wheels = [ 821 + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, 799 822 ] 800 823 801 824 [[package]] ··· 1167 1190 ] 1168 1191 1169 1192 [[package]] 1193 + name = "pygments" 1194 + version = "2.19.2" 1195 + source = { registry = "https://pypi.org/simple" } 1196 + sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 1197 + wheels = [ 1198 + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 1199 + ] 1200 + 1201 + [[package]] 1170 1202 name = "python-dotenv" 1171 1203 version = "1.2.1" 1172 1204 source = { registry = "https://pypi.org/simple" } ··· 1361 1393 sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } 1362 1394 wheels = [ 1363 1395 { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, 1396 + ] 1397 + 1398 + [[package]] 1399 + name = "rich" 1400 + version = "14.3.2" 1401 + source = { registry = "https://pypi.org/simple" } 1402 + dependencies = [ 1403 + { name = "markdown-it-py" }, 1404 + { name = "pygments" }, 1405 + ] 1406 + sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } 1407 + wheels = [ 1408 + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, 1364 1409 ] 1365 1410 1366 1411 [[package]]