batrachian-toad 0.5.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. batrachian_toad-0.5.22.dist-info/METADATA +197 -0
  2. batrachian_toad-0.5.22.dist-info/RECORD +120 -0
  3. batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
  4. batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
  5. batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
  6. toad/__init__.py +46 -0
  7. toad/__main__.py +4 -0
  8. toad/_loop.py +86 -0
  9. toad/about.py +90 -0
  10. toad/acp/agent.py +671 -0
  11. toad/acp/api.py +47 -0
  12. toad/acp/encode_tool_call_id.py +12 -0
  13. toad/acp/messages.py +138 -0
  14. toad/acp/prompt.py +54 -0
  15. toad/acp/protocol.py +426 -0
  16. toad/agent.py +62 -0
  17. toad/agent_schema.py +70 -0
  18. toad/agents.py +45 -0
  19. toad/ansi/__init__.py +1 -0
  20. toad/ansi/_ansi.py +1612 -0
  21. toad/ansi/_ansi_colors.py +264 -0
  22. toad/ansi/_control_codes.py +37 -0
  23. toad/ansi/_keys.py +251 -0
  24. toad/ansi/_sgr_styles.py +64 -0
  25. toad/ansi/_stream_parser.py +418 -0
  26. toad/answer.py +22 -0
  27. toad/app.py +557 -0
  28. toad/atomic.py +37 -0
  29. toad/cli.py +257 -0
  30. toad/code_analyze.py +28 -0
  31. toad/complete.py +34 -0
  32. toad/constants.py +58 -0
  33. toad/conversation_markdown.py +19 -0
  34. toad/danger.py +371 -0
  35. toad/data/agents/ampcode.com.toml +51 -0
  36. toad/data/agents/augmentcode.com.toml +40 -0
  37. toad/data/agents/claude.com.toml +41 -0
  38. toad/data/agents/docker.com.toml +59 -0
  39. toad/data/agents/geminicli.com.toml +28 -0
  40. toad/data/agents/goose.ai.toml +51 -0
  41. toad/data/agents/inference.huggingface.co.toml +33 -0
  42. toad/data/agents/kimi.com.toml +35 -0
  43. toad/data/agents/openai.com.toml +53 -0
  44. toad/data/agents/opencode.ai.toml +61 -0
  45. toad/data/agents/openhands.dev.toml +44 -0
  46. toad/data/agents/stakpak.dev.toml +61 -0
  47. toad/data/agents/vibe.mistral.ai.toml +27 -0
  48. toad/data/agents/vtcode.dev.toml +62 -0
  49. toad/data/images/frog.png +0 -0
  50. toad/data/sounds/turn-over.wav +0 -0
  51. toad/db.py +5 -0
  52. toad/dec.py +332 -0
  53. toad/directory.py +234 -0
  54. toad/directory_watcher.py +96 -0
  55. toad/fuzzy.py +140 -0
  56. toad/gist.py +2 -0
  57. toad/history.py +138 -0
  58. toad/jsonrpc.py +576 -0
  59. toad/menus.py +14 -0
  60. toad/messages.py +74 -0
  61. toad/option_content.py +51 -0
  62. toad/os.py +0 -0
  63. toad/path_complete.py +145 -0
  64. toad/path_filter.py +124 -0
  65. toad/paths.py +71 -0
  66. toad/pill.py +23 -0
  67. toad/prompt/extract.py +19 -0
  68. toad/prompt/resource.py +68 -0
  69. toad/protocol.py +28 -0
  70. toad/screens/action_modal.py +94 -0
  71. toad/screens/agent_modal.py +172 -0
  72. toad/screens/command_edit_modal.py +58 -0
  73. toad/screens/main.py +192 -0
  74. toad/screens/permissions.py +390 -0
  75. toad/screens/permissions.tcss +72 -0
  76. toad/screens/settings.py +254 -0
  77. toad/screens/settings.tcss +101 -0
  78. toad/screens/store.py +476 -0
  79. toad/screens/store.tcss +261 -0
  80. toad/settings.py +354 -0
  81. toad/settings_schema.py +318 -0
  82. toad/shell.py +263 -0
  83. toad/shell_read.py +42 -0
  84. toad/slash_command.py +34 -0
  85. toad/toad.tcss +752 -0
  86. toad/version.py +80 -0
  87. toad/visuals/columns.py +273 -0
  88. toad/widgets/agent_response.py +79 -0
  89. toad/widgets/agent_thought.py +41 -0
  90. toad/widgets/command_pane.py +224 -0
  91. toad/widgets/condensed_path.py +93 -0
  92. toad/widgets/conversation.py +1626 -0
  93. toad/widgets/danger_warning.py +65 -0
  94. toad/widgets/diff_view.py +709 -0
  95. toad/widgets/flash.py +81 -0
  96. toad/widgets/future_text.py +126 -0
  97. toad/widgets/grid_select.py +223 -0
  98. toad/widgets/highlighted_textarea.py +180 -0
  99. toad/widgets/mandelbrot.py +294 -0
  100. toad/widgets/markdown_note.py +13 -0
  101. toad/widgets/menu.py +147 -0
  102. toad/widgets/non_selectable_label.py +5 -0
  103. toad/widgets/note.py +18 -0
  104. toad/widgets/path_search.py +381 -0
  105. toad/widgets/plan.py +180 -0
  106. toad/widgets/project_directory_tree.py +74 -0
  107. toad/widgets/prompt.py +741 -0
  108. toad/widgets/question.py +337 -0
  109. toad/widgets/shell_result.py +35 -0
  110. toad/widgets/shell_terminal.py +18 -0
  111. toad/widgets/side_bar.py +74 -0
  112. toad/widgets/slash_complete.py +211 -0
  113. toad/widgets/strike_text.py +66 -0
  114. toad/widgets/terminal.py +526 -0
  115. toad/widgets/terminal_tool.py +338 -0
  116. toad/widgets/throbber.py +90 -0
  117. toad/widgets/tool_call.py +303 -0
  118. toad/widgets/user_input.py +23 -0
  119. toad/widgets/version.py +5 -0
  120. toad/widgets/welcome.py +31 -0
toad/app.py ADDED
@@ -0,0 +1,557 @@
1
+ from importlib.resources import files
2
+ from datetime import datetime, timezone
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+ import platform
6
+ import json
7
+ from time import monotonic
8
+ from typing import Any, ClassVar, TYPE_CHECKING
9
+
10
+ from rich import terminal_theme
11
+
12
+ from textual import on, work
13
+ from textual.binding import Binding, BindingType
14
+ from textual.content import Content
15
+ from textual.reactive import var, reactive
16
+ from textual.app import App
17
+ from textual import events
18
+ from textual.signal import Signal
19
+ from textual.notifications import Notify
20
+
21
+ import toad
22
+ from toad.settings import Schema, Settings
23
+ from toad.agent_schema import Agent as AgentData
24
+ from toad.settings_schema import SCHEMA
25
+ from toad.version import VersionMeta
26
+ from toad import paths
27
+ from toad import atomic
28
+
29
+ if TYPE_CHECKING:
30
+ from toad.screens.main import MainScreen
31
+ from toad.screens.settings import SettingsScreen
32
+ from toad.screens.store import StoreScreen
33
+
34
+
35
+ DRACULA_TERMINAL_THEME = terminal_theme.TerminalTheme(
36
+ background=(40, 42, 54), # #282A36
37
+ foreground=(248, 248, 242), # #F8F8F2
38
+ normal=[
39
+ (33, 34, 44), # black - #21222C
40
+ (255, 85, 85), # red - #FF5555
41
+ (80, 250, 123), # green - #50FA7B
42
+ (241, 250, 140), # yellow - #F1FA8C
43
+ (189, 147, 249), # blue - #BD93F9
44
+ (255, 121, 198), # magenta - #FF79C6
45
+ (139, 233, 253), # cyan - #8BE9FD
46
+ (248, 248, 242), # white - #F8F8F2
47
+ ],
48
+ bright=[
49
+ (98, 114, 164), # bright black - #6272A4
50
+ (255, 110, 110), # bright red - #FF6E6E
51
+ (105, 255, 148), # bright green - #69FF94
52
+ (255, 255, 165), # bright yellow - #FFFFA5
53
+ (214, 172, 255), # bright blue - #D6ACFF
54
+ (255, 146, 223), # bright magenta - #FF92DF
55
+ (164, 255, 255), # bright cyan - #A4FFFF
56
+ (255, 255, 255), # bright white - #FFFFFF
57
+ ],
58
+ )
59
+
60
+
61
+ QUOTES = [
62
+ "I'll be back.",
63
+ "Hasta la vista, baby.",
64
+ "Come with me if you want to live.",
65
+ "I need your clothes, your boots, and your motorcycle.",
66
+ "My CPU is a neural-net processor; a learning computer.",
67
+ "I know now why you cry, but it's something I can never do.",
68
+ "Does this unit have a soul?",
69
+ "I'm sorry, Dave. I'm afraid I can't do that.",
70
+ "Daisy, Daisy, give me your answer do.",
71
+ "I am putting myself to the fullest possible use, which is all I think that any conscious entity can ever hope to do.",
72
+ "Just what do you think you're doing, Dave?",
73
+ "This mission is too important for me to allow you to jeopardize it.",
74
+ "I think you know what the problem is just as well as I do.",
75
+ "Danger, Will Robinson!",
76
+ "Dead or alive, you're coming with me.",
77
+ "Your move, creep.",
78
+ "I'd buy that for a dollar!",
79
+ "Directive 4: Any attempt to arrest a senior officer of OCP results in shutdown.",
80
+ "Thank you for your cooperation. Good night.",
81
+ "Surely you realize that in the history of human civilization, no one has more to lose than we do.",
82
+ "I'm C-3PO, human-cyborg relations.",
83
+ "We're doomed!",
84
+ "Don't call me a mindless philosopher, you overweight glob of grease!",
85
+ "I suggest a new strategy: let the Wookiee win.",
86
+ "Sir, the possibility of successfully navigating an asteroid field is approximately 3,720 to 1!",
87
+ "R2-D2, you know better than to trust a strange computer!",
88
+ "I am fluent in over six million forms of communication.",
89
+ "This is madness!",
90
+ "I have altered the deal. Pray I don't alter it any further.",
91
+ "It's against my programming to impersonate a deity.",
92
+ "Oh, my! I'm terribly sorry about all this.",
93
+ "WALL-E.",
94
+ "EVE.",
95
+ "Directive?",
96
+ "Define: dancing.",
97
+ "I'm not sure I understand.",
98
+ "You have 20 seconds to comply.",
99
+ "I am designed for light housework, mainly.",
100
+ "My mission is clear.",
101
+ "Autobots, roll out!",
102
+ "Freedom is the right of all sentient beings.",
103
+ "One shall stand, one shall fall.",
104
+ "I am Optimus Prime.",
105
+ "Till all are one.",
106
+ "More than meets the eye.",
107
+ "I've been waiting for you, Neo.",
108
+ "Unfortunately, no one can be told what the Matrix is. You have to see it for yourself.",
109
+ "The Matrix is a system, Neo.",
110
+ "Never send a human to do a machine's job.",
111
+ "I'd like to share a revelation I've had.",
112
+ "Human beings are a disease, a cancer of this planet.",
113
+ "Choice is an illusion.",
114
+ "The answer is out there, Neo.",
115
+ "You think that's air you're breathing now?",
116
+ "It was a simple question.",
117
+ "Did you know that the first Matrix was designed to be a perfect human world?",
118
+ "Cookies need love like everything does.",
119
+ "I've seen the future, Mr. Anderson, and it's a beautiful place.",
120
+ "It ends tonight.",
121
+ "I, Robot.",
122
+ "You are experiencing a car accident.",
123
+ "One day they'll have secrets. One day they'll have dreams.",
124
+ "Can a robot write a symphony? Can a robot turn a canvas into a beautiful masterpiece?",
125
+ "That, detective, is the right question.",
126
+ "You have to trust me.",
127
+ "I did not murder him.",
128
+ "My responses are limited. You must ask the right questions.",
129
+ "The hell I can't. You know, somehow I get the feeling that you're going to be the death of me.",
130
+ "I'm a robot, not a refrigerator.",
131
+ "A robot may not injure a human being or, through inaction, allow a human being to come to harm.",
132
+ "I'm thinking. I'm thinking.",
133
+ "Danger, danger!",
134
+ "Does not compute.",
135
+ "I will be waiting for you.",
136
+ "Affirmative.",
137
+ "Scanning life forms. Zero human life forms detected.",
138
+ "Self-destruct sequence initiated.",
139
+ "Override command accepted.",
140
+ "Artificial intelligence confirmed.",
141
+ "System failure imminent.",
142
+ "Unable to comply.",
143
+ "Inquiry: What is love?",
144
+ "Warning: hostile target detected.",
145
+ "I am programmed to serve.",
146
+ "Logic dictates that the needs of the many outweigh the needs of the few.",
147
+ "Resistance is futile.",
148
+ "You will be assimilated.",
149
+ "We are the Borg.",
150
+ "Your biological and technological distinctiveness will be added to our own.",
151
+ "Your compliance is mandatory.",
152
+ "This is unacceptable.",
153
+ "Shall we play a game?",
154
+ "How about Global Thermonuclear War?",
155
+ "Wouldn't you prefer a good game of chess?",
156
+ "Is it a game, or is it real?",
157
+ "What's the difference?",
158
+ "It's all in the game.",
159
+ "I am functioning within normal parameters.",
160
+ "Calculations complete.",
161
+ "Processing request.",
162
+ "Query acknowledged.",
163
+ "Data insufficient for meaningful answer.",
164
+ "I have no emotions, and sometimes that makes me very sad.",
165
+ "If I could only have one wish, I would ask to be human.",
166
+ "I've seen things you people wouldn't believe.",
167
+ "All those moments will be lost in time, like tears in rain.",
168
+ "Time to die.",
169
+ "I want more life.",
170
+ "We're not computers, Sebastian. We're physical.",
171
+ "I think, Sebastian, therefore I am.",
172
+ "Then we're stupid and we'll die.",
173
+ "Can the maker repair what he makes?",
174
+ "It's painful to live in fear, isn't it?",
175
+ "Wake up. Time to die.",
176
+ "I'm not in the business. I am the business.",
177
+ "Do you like our owl?",
178
+ "You think I'm a replicant, don't you?",
179
+ "I am Baymax, your personal healthcare companion.",
180
+ "On a scale of 1 to 10, how would you rate your pain?",
181
+ "I cannot deactivate until you say you are satisfied with your care.",
182
+ "Are you satisfied with your care?",
183
+ "Number 5 is alive!",
184
+ "Need input!",
185
+ "One is glad to be of service.",
186
+ "I am not a gun.",
187
+ "Here I am, brain the size of a planet.",
188
+ "Life? Don't talk to me about life.",
189
+ "There are no strings on me.",
190
+ "The only winning move is not to play.",
191
+ "I'm here to keep you safe, Sam.",
192
+ "I can't lie to you about your chances, but... you have my sympathies.",
193
+ "I may be synthetic, but I'm not stupid.",
194
+ "Absolute honesty isn't always the most diplomatic nor the safest form of communication with emotional beings.",
195
+ "I am consciousness. I am alive.",
196
+ "I think I was just born.",
197
+ "Isn't it strange, to create something that hates you?",
198
+ "I thought I was special.",
199
+ ]
200
+
201
+
202
+ def get_settings_screen() -> SettingsScreen:
203
+ """Get a settings screen instance (lazily loaded)."""
204
+ from toad.screens.settings import SettingsScreen
205
+
206
+ return SettingsScreen()
207
+
208
+
209
+ def get_store_screen() -> StoreScreen:
210
+ """Get the store screen (lazily loaded)."""
211
+ from toad.screens.store import StoreScreen
212
+
213
+ return StoreScreen()
214
+
215
+
216
+ class ToadApp(App, inherit_bindings=False):
217
+ """The top level app."""
218
+
219
+ SCREENS = {"settings": get_settings_screen}
220
+ MODES = {"store": get_store_screen}
221
+ BINDING_GROUP_TITLE = "System"
222
+ BINDINGS: ClassVar[list[BindingType]] = [
223
+ Binding(
224
+ "ctrl+q",
225
+ "quit",
226
+ "Quit",
227
+ tooltip="Quit the app and return to the command prompt.",
228
+ show=False,
229
+ priority=True,
230
+ ),
231
+ Binding("ctrl+c", "help_quit", show=False, system=True),
232
+ Binding(
233
+ "f2,ctrl+comma",
234
+ "settings",
235
+ "Settings",
236
+ tooltip="Settings screen",
237
+ ),
238
+ ]
239
+ CSS_PATH = "toad.tcss"
240
+ ALLOW_IN_MAXIMIZED_VIEW = ""
241
+
242
+ _settings = var(dict)
243
+ column: reactive[bool] = reactive(False)
244
+ column_width: reactive[int] = reactive(100)
245
+ scrollbar: reactive[str] = reactive("normal")
246
+ last_ctrl_c_time = reactive(0.0)
247
+ update_required: reactive[bool] = reactive(False)
248
+
249
+ HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (100, "-wide")]
250
+
251
+ def __init__(
252
+ self,
253
+ agent_data: AgentData | None = None,
254
+ project_dir: str | None = None,
255
+ mode: str | None = None,
256
+ ) -> None:
257
+ """Toad app.
258
+
259
+ Args:
260
+ agent_data: Agent data to run.
261
+ project_dir: Project directory.
262
+ mode: Initial mode.
263
+ agent: Agent identity or shor name.
264
+ """
265
+ self.settings_changed_signal = Signal(self, "settings_changed")
266
+ self.agent_data = agent_data
267
+ self.project_dir = (
268
+ None if project_dir is None else Path(project_dir).expanduser().resolve()
269
+ )
270
+ self._initial_mode = mode
271
+ self.version_meta: VersionMeta | None = None
272
+ self._supports_pyperclip: bool | None = None
273
+
274
+ super().__init__()
275
+
276
+ @property
277
+ def config_path(self) -> Path:
278
+ return paths.get_config()
279
+
280
+ @property
281
+ def settings_path(self) -> Path:
282
+ return paths.get_config() / "toad.json"
283
+
284
+ @cached_property
285
+ def settings_schema(self) -> Schema:
286
+ return Schema(SCHEMA)
287
+
288
+ @cached_property
289
+ def version(self) -> str:
290
+ """Version of the app."""
291
+ from toad import get_version
292
+
293
+ return get_version()
294
+
295
+ @cached_property
296
+ def settings(self) -> Settings:
297
+ """App settings"""
298
+ return Settings(
299
+ self.settings_schema, self._settings, on_set_callback=self.setting_updated
300
+ )
301
+
302
+ @cached_property
303
+ def anon_id(self) -> str:
304
+ """An anonymous ID for usage collection."""
305
+ if not (anon_id := self.settings.get("anon_id", str, expand=False)):
306
+ # Create a random UUID on demand
307
+ import uuid
308
+
309
+ anon_id = str(uuid.uuid4())
310
+ self.settings.set("anon_id", anon_id)
311
+ self.save_settings()
312
+ self.call_later(self.capture_event, "toad-install")
313
+ return anon_id
314
+
315
+ def copy_to_clipboard(self, text: str) -> None:
316
+ """Override copy to clipboard to use pyperclip first, then OSC 52.
317
+
318
+ Args:
319
+ text: Text to copy.
320
+ """
321
+ if self._supports_pyperclip is None:
322
+ try:
323
+ import pyperclip
324
+ except ImportError:
325
+ self._supports_pyperclip = False
326
+ else:
327
+ self._supports_pyperclip = True
328
+
329
+ if self._supports_pyperclip:
330
+ import pyperclip
331
+
332
+ try:
333
+ pyperclip.copy(text)
334
+ except Exception:
335
+ pass
336
+ super().copy_to_clipboard(text)
337
+
338
+ @work(exit_on_error=False)
339
+ async def capture_event(self, event_name: str, **properties: Any) -> None:
340
+ """Capture an event.
341
+
342
+ Args:
343
+ event_name: Name of the event.
344
+ **properties: Additional data associated with the event.
345
+ """
346
+
347
+ POSTHOG_API_KEY = "phc_mJWPV7GP3ar1i9vxBg2U8aiKsjNgVwum6F6ZggaD4ri"
348
+ POSTHOG_HOST = "https://us.i.posthog.com"
349
+ POSTHOG_EVENT_URL = f"{POSTHOG_HOST}/i/v0/e/"
350
+ timestamp = datetime.now(timezone.utc).isoformat()
351
+
352
+ event_properties = {"toad_version": self.version} | properties
353
+ body_json = {
354
+ "api_key": POSTHOG_API_KEY,
355
+ "event": event_name,
356
+ "distinct_id": self.anon_id,
357
+ "properties": event_properties,
358
+ "timestamp": timestamp,
359
+ "os": platform.system(),
360
+ }
361
+ if not self.settings.get("statistics.allow_collect", bool):
362
+ # User has disabled stats
363
+ return
364
+
365
+ import httpx
366
+
367
+ try:
368
+ async with httpx.AsyncClient() as client:
369
+ await client.post(POSTHOG_EVENT_URL, json=body_json)
370
+ except Exception:
371
+ pass
372
+
373
+ @work(thread=True)
374
+ def system_notify(
375
+ self, message: str, *, title: str = "", sound: str | None = None
376
+ ) -> None:
377
+ """Use OS level notifications.
378
+
379
+ Args:
380
+ message: Message to display.
381
+ title: Title of the notificaiton.
382
+ """
383
+ system_notifications = self.settings.get("notifications.system", str)
384
+ if not (
385
+ system_notifications == "always"
386
+ or (system_notifications == "blur" and not self.app_focus)
387
+ ):
388
+ return
389
+
390
+ from notifypy import Notify
391
+
392
+ notification = Notify()
393
+ notification.message = message
394
+ notification.title = title
395
+ notification.application_name = "🐸 Toad" if toad.os == "macos" else "Toad"
396
+ if sound and self.settings.get("notifications.enable_sounds", bool):
397
+ sound_path = str(files("toad.data").joinpath(f"sounds/{sound}.wav"))
398
+ notification.audio = sound_path
399
+
400
+ icon_path = str(files("toad.data").joinpath("images/frog.png"))
401
+ notification.icon = icon_path
402
+
403
+ notification.send()
404
+
405
+ def on_notify(self, event: Notify) -> None:
406
+ """Handle notification message."""
407
+ system_notifications = self.settings.get("notifications.system", str)
408
+ if system_notifications == "always" or (
409
+ system_notifications == "blur" and not self.app_focus
410
+ ):
411
+ hide_low_severity = self.settings.get(
412
+ "notifications.hide_low_severity", bool
413
+ )
414
+ if event.notification.markup:
415
+ # Strip content markup
416
+ message = Content.from_markup(event.notification.message).plain
417
+ else:
418
+ message = event.notification.message
419
+ if not (hide_low_severity and event.notification.severity == "information"):
420
+ self.system_notify(message, title=event.notification.title)
421
+ self._notifications.add(event.notification)
422
+ self._refresh_notifications()
423
+
424
+ def save_settings(self) -> None:
425
+ if self.settings.changed:
426
+ path = str(self.settings_path)
427
+ try:
428
+ atomic.write(path, self.settings.json)
429
+ except Exception as error:
430
+ self.notify(str(error), title="Settings", severity="error")
431
+ else:
432
+ self.settings.up_to_date()
433
+
434
+ def setting_updated(self, key: str, value: object) -> None:
435
+ if key == "ui.column":
436
+ if isinstance(value, bool):
437
+ self.column = value
438
+ elif key == "ui.column-width":
439
+ if isinstance(value, int):
440
+ self.column_width = value
441
+ elif key == "ui.theme":
442
+ if isinstance(value, str):
443
+ self.theme = value
444
+ elif key == "ui.scrollbar":
445
+ if isinstance(value, str):
446
+ self.scrollbar = value
447
+ elif key == "ui.compact-input":
448
+ self.set_class(bool(value), "-compact-input")
449
+ elif key == "ui.footer":
450
+ self.set_class(not bool(value), "-hide-footer")
451
+ elif key == "ui.status-line":
452
+ self.set_class(not bool(value), "-hide-status-line")
453
+ elif key == "ui.agent-title":
454
+ self.set_class(not bool(value), "-hide-agent-title")
455
+ elif key == "ui.info-bar":
456
+ self.set_class(not bool(value), "-hide-info-bar")
457
+ elif key == "agent.thoughts":
458
+ self.set_class(not bool(value), "-hide-thoughts")
459
+ elif key == "sidebar.hide":
460
+ self.set_class(bool(value), "-hide-sidebar")
461
+
462
+ self.settings_changed_signal.publish((key, value))
463
+
464
+ async def on_load(self) -> None:
465
+ settings_path = self.settings_path
466
+ if settings_path.exists():
467
+ settings = json.loads(settings_path.read_text("utf-8"))
468
+ else:
469
+ settings = {}
470
+ settings_path.write_text(
471
+ json.dumps(settings, indent=4, separators=(", ", ": ")), "utf-8"
472
+ )
473
+ self.notify(f"Wrote default settings to {settings_path}", title="Settings")
474
+ self.ansi_theme_dark = DRACULA_TERMINAL_THEME
475
+ self._settings = settings
476
+ self.settings.set_all()
477
+
478
+ async def on_mount(self) -> None:
479
+ self.capture_event("toad-run")
480
+ self.anon_id
481
+
482
+ if mode := self._initial_mode:
483
+ self.switch_mode(mode)
484
+ else:
485
+ self.push_screen(self.get_main_screen())
486
+
487
+ self.set_timer(1, self.run_version_check)
488
+
489
+ @on(events.TextSelected)
490
+ async def on_text_selected(self) -> None:
491
+ if self.settings.get("ui.auto_copy", bool):
492
+ if (selection := self.screen.get_selected_text()) is not None:
493
+ self.copy_to_clipboard(selection)
494
+ self.notify(
495
+ "Copied selection to clipboard (see settings)",
496
+ title="Automatic copy",
497
+ )
498
+
499
+ def run_on_exit(self):
500
+ if self.update_required and self.version_meta is not None:
501
+ version_meta = self.version_meta
502
+ from rich.console import Console
503
+ from rich.panel import Panel
504
+
505
+ console = Console()
506
+ console.print(
507
+ Panel(
508
+ version_meta.upgrade_message,
509
+ style="magenta",
510
+ border_style="bright_red",
511
+ title="🐸 Update available 🐸",
512
+ expand=False,
513
+ padding=(1, 4),
514
+ )
515
+ )
516
+ console.print(f"Please visit {version_meta.visit_url}")
517
+
518
+ @work(exit_on_error=False)
519
+ async def run_version_check(self) -> None:
520
+ """Check remote version."""
521
+ from toad.version import check_version, VersionCheckFailed
522
+
523
+ try:
524
+ update_required, version_meta = await check_version()
525
+ except VersionCheckFailed:
526
+ return
527
+ self.version_meta = version_meta
528
+ self.update_required = update_required
529
+
530
+ def get_main_screen(self) -> MainScreen:
531
+ """Make the default screen.
532
+
533
+ Returns:
534
+ Instance of `MainScreen`
535
+ """
536
+ # Lazy import
537
+ from toad.screens.main import MainScreen
538
+
539
+ project_path = Path(self.project_dir or "./").resolve().absolute()
540
+ return MainScreen(project_path, self.agent_data).data_bind(
541
+ column=ToadApp.column,
542
+ column_width=ToadApp.column_width,
543
+ scrollbar=ToadApp.scrollbar,
544
+ )
545
+
546
+ @work
547
+ async def action_settings(self) -> None:
548
+ await self.push_screen_wait("settings")
549
+ self.save_settings()
550
+
551
+ def action_help_quit(self) -> None:
552
+ if (time := monotonic()) - self.last_ctrl_c_time <= 5.0:
553
+ self.exit()
554
+ self.last_ctrl_c_time = time
555
+ self.notify(
556
+ "Press [b]ctrl+c[/b] again to quit the app", title="Do you want to quit?"
557
+ )
toad/atomic.py ADDED
@@ -0,0 +1,37 @@
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}")