dlab-cli 0.1.0__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.
dlab/tui/app.py ADDED
@@ -0,0 +1,664 @@
1
+ """
2
+ Main Textual application for dlab connect TUI.
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from textual.app import App, ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import Header, Footer, Static, TabbedContent, TabPane
11
+ from textual.binding import Binding
12
+ from textual.timer import Timer
13
+
14
+ from dlab.tui.widgets.agent_list import AgentSelector
15
+ from dlab.tui.widgets.log_view import LogView
16
+ from dlab.tui.widgets.status_bar import StatusBar
17
+ from dlab.tui.widgets.artifacts_pane import ArtifactList, FileViewer
18
+ from dlab.tui.widgets.search_popup import SearchPopup
19
+ from dlab.tui.log_watcher import LogWatcher
20
+ from dlab.tui.models import SessionState, LogEvent
21
+ from dlab.timeline import (
22
+ is_log_complete,
23
+ discover_agents,
24
+ )
25
+
26
+
27
+ def load_default_agent(work_dir: Path) -> str | None:
28
+ """
29
+ Load default agent name from opencode.json.
30
+
31
+ Parameters
32
+ ----------
33
+ work_dir : Path
34
+ Work directory path.
35
+
36
+ Returns
37
+ -------
38
+ str | None
39
+ Default agent name or None if not found.
40
+ """
41
+ opencode_json = work_dir / ".opencode" / "opencode.json"
42
+ if opencode_json.exists():
43
+ try:
44
+ data = json.loads(opencode_json.read_text())
45
+ return data.get("default_agent")
46
+ except (json.JSONDecodeError, IOError):
47
+ pass
48
+ return None
49
+
50
+
51
+ def get_global_start_ts(logs_dir: Path) -> int | None:
52
+ """
53
+ Get the global start timestamp from main.log.
54
+
55
+ The global start is defined as the first timestamp in main.log.
56
+ This is used as the reference point for ALL relative timestamps
57
+ across all agents in the session.
58
+
59
+ Parameters
60
+ ----------
61
+ logs_dir : Path
62
+ Path to _opencode_logs directory.
63
+
64
+ Returns
65
+ -------
66
+ int | None
67
+ First timestamp from main.log in milliseconds, or None if not found.
68
+ """
69
+ main_log = logs_dir / "main.log"
70
+ if not main_log.exists():
71
+ return None
72
+
73
+ try:
74
+ with open(main_log, "r") as f:
75
+ for line in f:
76
+ line = line.strip()
77
+ if not line or not line.startswith("{"):
78
+ continue
79
+ try:
80
+ data = json.loads(line)
81
+ ts = data.get("timestamp")
82
+ if ts and isinstance(ts, int) and ts > 0:
83
+ return ts
84
+ except json.JSONDecodeError:
85
+ continue
86
+ except (IOError, OSError):
87
+ pass
88
+
89
+ return None
90
+
91
+
92
+ class ConnectApp(App):
93
+ """
94
+ TUI application for monitoring running dlab sessions.
95
+
96
+ Layout:
97
+ ┌─────────────────────────────────────────────────────────────┐
98
+ │ Header: dlab connect - {work_dir} │
99
+ ├──────────────┬──────────────────────────────────────────────┤
100
+ │ Agents │ [Logs] [Files] ← TabbedContent │
101
+ │ ● main-poet │ ─────────────────────────────────────────────│
102
+ │ ○ inst-1 │ + 0.0s | step_start | Started │
103
+ │ ○ inst-2 │ + 1.2s | text | I'll help... │
104
+ │──────────────│ + 5.0s | tool_use | write: ... │
105
+ │ Files │ │
106
+ │ 📄 poem.md │ (scrollable content) │
107
+ │ 🐍 script.py │ │
108
+ ├──────────────┴──────────────────────────────────────────────┤
109
+ │ RUNNING | Cost: $0.05 | Duration: 45s | Agent: main-poet │
110
+ └─────────────────────────────────────────────────────────────┘
111
+ """
112
+
113
+ CSS = """
114
+ #main-container {
115
+ height: 1fr;
116
+ }
117
+
118
+ #left-sidebar {
119
+ width: 28;
120
+ min-width: 20;
121
+ max-width: 36;
122
+ border-right: vkey $surface-lighten-1;
123
+ }
124
+
125
+ .section-header {
126
+ height: 1;
127
+ padding: 0 1;
128
+ color: $text-muted;
129
+ text-style: bold;
130
+ }
131
+
132
+ AgentSelector {
133
+ height: 1fr;
134
+ padding: 0 1;
135
+ }
136
+
137
+ ArtifactList {
138
+ height: 1fr;
139
+ padding: 0 1;
140
+ border-top: hkey $surface-lighten-1;
141
+ }
142
+
143
+ #main-tabs {
144
+ width: 1fr;
145
+ }
146
+
147
+ /* Flatten tab underline bar */
148
+ Underline > .underline--bar {
149
+ background: $foreground 5%;
150
+ }
151
+
152
+ LogView {
153
+ padding: 0 1;
154
+ }
155
+
156
+ FileViewer {
157
+ padding: 0 1;
158
+ }
159
+
160
+ StatusBar {
161
+ height: 1;
162
+ padding: 0 1;
163
+ }
164
+
165
+ /* Search popup overlay */
166
+ SearchPopup {
167
+ dock: bottom;
168
+ margin-bottom: 2;
169
+ margin-left: 28;
170
+ }
171
+ """
172
+
173
+ BINDINGS = [
174
+ Binding("q", "quit", "Quit"),
175
+ Binding("/", "show_search", "Search"),
176
+ Binding("escape", "hide_search", "Close", show=False),
177
+ Binding("e", "expand_all", "Expand All"),
178
+ Binding("c", "collapse_all", "Collapse All"),
179
+ Binding("o", "open_file", "Open"),
180
+ Binding("y", "yank_log", "Yank"),
181
+ Binding("f", "flush_clip", "Flush"),
182
+ Binding("j", "next_agent", "Next Agent", show=False),
183
+ Binding("k", "prev_agent", "Prev Agent", show=False),
184
+ Binding("left", "focus_sidebar", "Sidebar"),
185
+ Binding("right", "focus_main", "Main"),
186
+ Binding("tab", "cycle_sidebar_focus", "Cycle", show=False),
187
+ Binding("up", "prev_item", "Up", show=False),
188
+ Binding("down", "next_item", "Down", show=False),
189
+ Binding("enter", "select_item", "Select", show=False),
190
+ Binding("1", "show_logs_tab", "Logs", show=False),
191
+ Binding("2", "show_files_tab", "Files", show=False),
192
+ Binding("n", "next_match", "Next Match", show=False),
193
+ Binding("N", "prev_match", "Prev Match", show=False),
194
+ ]
195
+
196
+ TITLE = "dlab connect"
197
+ theme = "monokai"
198
+
199
+ def __init__(self, work_dir: Path) -> None:
200
+ super().__init__()
201
+ self._work_dir = work_dir
202
+ self._logs_dir = work_dir / "_opencode_logs"
203
+ self._state = SessionState(work_dir=work_dir)
204
+ self._watcher: LogWatcher | None = None
205
+ self._selected_agent: str | None = None
206
+ self._known_agents: set[str] = set()
207
+ self._update_timer: Timer | None = None
208
+ self._default_agent = load_default_agent(work_dir)
209
+ self._search_matches: list[int] = []
210
+ self._current_match_index: int = 0
211
+
212
+ def compose(self) -> ComposeResult:
213
+ """Create child widgets."""
214
+ yield Header(show_clock=False)
215
+
216
+ with Horizontal(id="main-container"):
217
+ with Vertical(id="left-sidebar"):
218
+ yield Static("Agents", classes="section-header")
219
+ yield AgentSelector(id="agent-selector")
220
+ yield Static("Files", classes="section-header")
221
+ yield ArtifactList(self._work_dir, id="artifact-list")
222
+
223
+ with TabbedContent(id="main-tabs"):
224
+ with TabPane("Logs", id="logs-tab"):
225
+ yield LogView(id="log-view")
226
+ with TabPane("Files", id="files-tab"):
227
+ yield FileViewer(id="file-viewer")
228
+
229
+ yield SearchPopup(id="search-popup")
230
+ yield StatusBar(id="status-bar")
231
+ yield Footer()
232
+
233
+ async def on_mount(self) -> None:
234
+ """Initialize on app mount."""
235
+ self.title = f"dlab connect - {self._work_dir.name}"
236
+
237
+ # Discover known agents
238
+ opencode_dir = self._work_dir / ".opencode"
239
+ if opencode_dir.exists():
240
+ self._known_agents = discover_agents(opencode_dir)
241
+
242
+ # Check if job is running
243
+ main_log = self._logs_dir / "main.log"
244
+ self._state.is_job_running = main_log.exists() and not is_log_complete(main_log)
245
+
246
+ # Get global start timestamp from main.log FIRST
247
+ # This is the authoritative reference for all relative timestamps
248
+ self._state.global_start_ts = get_global_start_ts(self._logs_dir)
249
+
250
+ # Start log watcher
251
+ self._watcher = LogWatcher(self._logs_dir)
252
+ self._watcher.start()
253
+
254
+ # Poll for initial events (populates queue before processing)
255
+ self._watcher.poll()
256
+
257
+ # Process initial events
258
+ self._process_pending_events()
259
+
260
+ # Select first agent
261
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
262
+ agent_selector.select_first()
263
+
264
+ # Show placeholder in file viewer
265
+ file_viewer = self.query_one("#file-viewer", FileViewer)
266
+ file_viewer.show_placeholder()
267
+
268
+ # Start periodic update timer
269
+ self._update_timer = self.set_interval(0.5, self._on_update_tick)
270
+
271
+ async def on_unmount(self) -> None:
272
+ """Cleanup on app unmount."""
273
+ if self._watcher:
274
+ self._watcher.stop()
275
+ if self._update_timer:
276
+ self._update_timer.stop()
277
+
278
+ def _get_display_name(self, source: str) -> str:
279
+ """
280
+ Get display name for a source, renaming 'main' to 'main-{agent}'.
281
+
282
+ Parameters
283
+ ----------
284
+ source : str
285
+ Original source name from log file.
286
+
287
+ Returns
288
+ -------
289
+ str
290
+ Display name for the agent.
291
+ """
292
+ if source == "main" and self._default_agent:
293
+ return f"main-{self._default_agent}"
294
+ return source
295
+
296
+ def _process_pending_events(self) -> None:
297
+ """Process any pending events from the watcher."""
298
+ if not self._watcher:
299
+ return
300
+
301
+ events = self._watcher.get_events()
302
+ for source, raw_event in events:
303
+ display_name = self._get_display_name(source)
304
+ event = LogEvent.from_raw(raw_event, display_name)
305
+ agent_state = self._state.get_or_create_agent(display_name)
306
+
307
+ was_added = agent_state.add_event(event)
308
+
309
+ if was_added:
310
+ # Only update global_start_ts with valid timestamps (not 0)
311
+ # raw_text and additional_output events have timestamp=0
312
+ if event.timestamp > 0 and (
313
+ self._state.global_start_ts is None
314
+ or event.timestamp < self._state.global_start_ts
315
+ ):
316
+ self._state.global_start_ts = event.timestamp
317
+
318
+ if display_name == self._selected_agent:
319
+ log_view = self.query_one("#log-view", LogView)
320
+ log_view.append_event(event)
321
+
322
+ if events:
323
+ self._update_agent_list()
324
+ self._update_status_bar()
325
+
326
+ def _get_log_path(self, display_name: str) -> Path:
327
+ """
328
+ Get the log file path for a display name.
329
+
330
+ Parameters
331
+ ----------
332
+ display_name : str
333
+ Display name of the agent.
334
+
335
+ Returns
336
+ -------
337
+ Path
338
+ Path to the log file.
339
+ """
340
+ if display_name.startswith("main-"):
341
+ return self._logs_dir / "main.log"
342
+
343
+ log_path = self._logs_dir / f"{display_name}.log"
344
+ if log_path.exists():
345
+ return log_path
346
+
347
+ parts = display_name.split("/")
348
+ if len(parts) == 2:
349
+ return self._logs_dir / parts[0] / f"{parts[1]}.log"
350
+
351
+ return log_path
352
+
353
+ def _update_agent_list(self) -> None:
354
+ """Update the agent selector with current state."""
355
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
356
+
357
+ def sort_by_start_time(name: str) -> int:
358
+ agent_state = self._state.agents.get(name)
359
+ if agent_state and agent_state.start_time:
360
+ return int(agent_state.start_time.timestamp() * 1000)
361
+ return 0
362
+
363
+ agents = sorted(self._state.agents.keys(), key=sort_by_start_time)
364
+
365
+ running: set[str] = set()
366
+ main_display_name = self._get_display_name("main")
367
+
368
+ # Check if main agent is complete — if so, all sub-agents are done
369
+ # (sub-agents may lack a clean stop event if the container was killed)
370
+ main_log = self._get_log_path("main")
371
+ main_complete = main_log.exists() and is_log_complete(main_log)
372
+
373
+ if not main_complete:
374
+ for name in self._state.agents.keys():
375
+ log_path = self._get_log_path(name)
376
+ if log_path.exists() and not is_log_complete(log_path):
377
+ running.add(name)
378
+
379
+ agent_selector.update_agents(agents, running)
380
+
381
+ self._state.is_job_running = main_display_name in running
382
+
383
+ def _update_status_bar(self) -> None:
384
+ """Update the status bar."""
385
+ status_bar = self.query_one("#status-bar", StatusBar)
386
+ status_bar.update_status(
387
+ is_running=self._state.is_job_running,
388
+ cost=self._state.total_cost,
389
+ duration=self._state.duration_seconds,
390
+ agent=self._selected_agent,
391
+ )
392
+
393
+ def _on_update_tick(self) -> None:
394
+ """Periodic update tick."""
395
+ # Poll log files directly as fallback for unreliable watchdog events
396
+ # (especially on macOS where FSEvents can miss Docker/atomic writes)
397
+ if self._watcher:
398
+ self._watcher.poll()
399
+
400
+ self._process_pending_events()
401
+ self._update_status_bar()
402
+
403
+ # Refresh artifacts periodically (detect new files created during execution)
404
+ artifact_list = self.query_one("#artifact-list", ArtifactList)
405
+ artifact_list.refresh_if_changed()
406
+
407
+ def on_agent_selector_agent_selected(
408
+ self, event: AgentSelector.AgentSelected
409
+ ) -> None:
410
+ """Handle agent selection."""
411
+ self._selected_agent = event.agent_name
412
+
413
+ log_view = self.query_one("#log-view", LogView)
414
+
415
+ if event.agent_name in self._state.agents:
416
+ agent_state = self._state.agents[event.agent_name]
417
+ # Use global_start_ts from main.log - this is authoritative
418
+ # NEVER use 0 - if global_start_ts is None, we have no valid reference
419
+ start_ts = self._state.global_start_ts
420
+ if start_ts is None:
421
+ # Fallback: try to get it again from main.log
422
+ start_ts = get_global_start_ts(self._logs_dir)
423
+ if start_ts:
424
+ self._state.global_start_ts = start_ts
425
+ log_view.set_events(
426
+ agent_state.events,
427
+ start_ts, # Can be None - LogView must handle this
428
+ )
429
+ else:
430
+ log_view.set_events([], self._state.global_start_ts)
431
+
432
+ # Update artifact list for selected agent
433
+ artifact_list = self.query_one("#artifact-list", ArtifactList)
434
+ artifact_list.set_agent(event.agent_name)
435
+
436
+ self._update_status_bar()
437
+
438
+ def on_artifact_list_file_selected(self, event: ArtifactList.FileSelected) -> None:
439
+ """Handle file selection from artifact list."""
440
+ # Switch to Files tab
441
+ tabs = self.query_one("#main-tabs", TabbedContent)
442
+ tabs.active = "files-tab"
443
+
444
+ # Show file content
445
+ file_viewer = self.query_one("#file-viewer", FileViewer)
446
+ file_viewer.show_file(event.path)
447
+
448
+ def on_search_popup_search_changed(self, event: SearchPopup.SearchChanged) -> None:
449
+ """Handle search text changes."""
450
+ self._perform_search(event.query)
451
+
452
+ def on_search_popup_next_match(self, event: SearchPopup.NextMatch) -> None:
453
+ """Jump to next search match."""
454
+ if self._search_matches:
455
+ self._current_match_index = (self._current_match_index + 1) % len(
456
+ self._search_matches
457
+ )
458
+ self._jump_to_match()
459
+
460
+ def on_search_popup_prev_match(self, event: SearchPopup.PrevMatch) -> None:
461
+ """Jump to previous search match."""
462
+ if self._search_matches:
463
+ self._current_match_index = (self._current_match_index - 1) % len(
464
+ self._search_matches
465
+ )
466
+ self._jump_to_match()
467
+
468
+ def on_search_popup_search_closed(self, event: SearchPopup.SearchClosed) -> None:
469
+ """Handle search popup closed."""
470
+ log_view = self.query_one("#log-view", LogView)
471
+ log_view.highlight_search("")
472
+ self._search_matches = []
473
+ self._current_match_index = 0
474
+
475
+ def _perform_search(self, query: str) -> None:
476
+ """Perform search on current view."""
477
+ tabs = self.query_one("#main-tabs", TabbedContent)
478
+ search_popup = self.query_one("#search-popup", SearchPopup)
479
+
480
+ if tabs.active == "logs-tab":
481
+ log_view = self.query_one("#log-view", LogView)
482
+ self._search_matches = log_view.highlight_search(query)
483
+ self._current_match_index = 0
484
+
485
+ if self._search_matches:
486
+ search_popup.update_match_count(1, len(self._search_matches))
487
+ log_view.scroll_to_event(self._search_matches[0])
488
+ else:
489
+ search_popup.update_match_count(0, 0)
490
+ else:
491
+ # TODO: Implement file content search
492
+ self._search_matches = []
493
+ search_popup.update_match_count(0, 0)
494
+
495
+ def _jump_to_match(self) -> None:
496
+ """Jump to current match index."""
497
+ if not self._search_matches:
498
+ return
499
+
500
+ search_popup = self.query_one("#search-popup", SearchPopup)
501
+ search_popup.update_match_count(
502
+ self._current_match_index + 1, len(self._search_matches)
503
+ )
504
+
505
+ tabs = self.query_one("#main-tabs", TabbedContent)
506
+ if tabs.active == "logs-tab":
507
+ log_view = self.query_one("#log-view", LogView)
508
+ log_view.scroll_to_event(self._search_matches[self._current_match_index])
509
+
510
+ def action_show_search(self) -> None:
511
+ """Show the search popup."""
512
+ search_popup = self.query_one("#search-popup", SearchPopup)
513
+ search_popup.show()
514
+
515
+ def action_hide_search(self) -> None:
516
+ """Hide the search popup."""
517
+ search_popup = self.query_one("#search-popup", SearchPopup)
518
+ if search_popup.is_visible():
519
+ search_popup.hide()
520
+
521
+ def action_expand_all(self) -> None:
522
+ """Expand all log events."""
523
+ log_view = self.query_one("#log-view", LogView)
524
+ log_view.expand_all()
525
+
526
+ def action_collapse_all(self) -> None:
527
+ """Collapse all log events."""
528
+ log_view = self.query_one("#log-view", LogView)
529
+ log_view.collapse_all()
530
+
531
+ def action_next_agent(self) -> None:
532
+ """Select next agent."""
533
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
534
+ agent_selector.action_cursor_down()
535
+
536
+ def action_prev_agent(self) -> None:
537
+ """Select previous agent."""
538
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
539
+ agent_selector.action_cursor_up()
540
+
541
+ def action_focus_sidebar(self) -> None:
542
+ """Focus the left sidebar (agent selector)."""
543
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
544
+ agent_selector.focus()
545
+
546
+ def action_focus_main(self) -> None:
547
+ """Focus the main area (current tab content)."""
548
+ tabs = self.query_one("#main-tabs", TabbedContent)
549
+ if tabs.active == "logs-tab":
550
+ log_view = self.query_one("#log-view", LogView)
551
+ log_view.focus()
552
+ else:
553
+ file_viewer = self.query_one("#file-viewer", FileViewer)
554
+ file_viewer.focus()
555
+
556
+ def action_cycle_sidebar_focus(self) -> None:
557
+ """Cycle focus between agent selector and artifact list."""
558
+ focused = self.focused
559
+ agent_selector = self.query_one("#agent-selector", AgentSelector)
560
+ artifact_list = self.query_one("#artifact-list", ArtifactList)
561
+
562
+ if focused == agent_selector:
563
+ artifact_list.focus()
564
+ elif focused == artifact_list:
565
+ agent_selector.focus()
566
+ else:
567
+ agent_selector.focus()
568
+
569
+ def action_prev_item(self) -> None:
570
+ """Navigate to previous item in focused pane."""
571
+ focused = self.focused
572
+ if isinstance(focused, AgentSelector):
573
+ focused.action_cursor_up()
574
+ elif isinstance(focused, LogView):
575
+ focused.select_prev()
576
+ elif isinstance(focused, ArtifactList):
577
+ focused.action_cursor_up()
578
+
579
+ def action_next_item(self) -> None:
580
+ """Navigate to next item in focused pane."""
581
+ focused = self.focused
582
+ if isinstance(focused, AgentSelector):
583
+ focused.action_cursor_down()
584
+ elif isinstance(focused, LogView):
585
+ focused.select_next()
586
+ elif isinstance(focused, ArtifactList):
587
+ focused.action_cursor_down()
588
+
589
+ def action_select_item(self) -> None:
590
+ """Select/expand current item."""
591
+ focused = self.focused
592
+ if isinstance(focused, LogView):
593
+ focused.toggle_selected()
594
+ elif isinstance(focused, AgentSelector):
595
+ log_view = self.query_one("#log-view", LogView)
596
+ log_view.toggle_selected()
597
+ elif isinstance(focused, ArtifactList):
598
+ # Trigger file selection via ListView's built-in selection
599
+ pass
600
+
601
+ def action_show_logs_tab(self) -> None:
602
+ """Switch to Logs tab."""
603
+ tabs = self.query_one("#main-tabs", TabbedContent)
604
+ tabs.active = "logs-tab"
605
+
606
+ def action_show_files_tab(self) -> None:
607
+ """Switch to Files tab."""
608
+ tabs = self.query_one("#main-tabs", TabbedContent)
609
+ tabs.active = "files-tab"
610
+
611
+ def action_next_match(self) -> None:
612
+ """Jump to next search match."""
613
+ search_popup = self.query_one("#search-popup", SearchPopup)
614
+ if search_popup.is_visible() and self._search_matches:
615
+ self._current_match_index = (self._current_match_index + 1) % len(
616
+ self._search_matches
617
+ )
618
+ self._jump_to_match()
619
+
620
+ def action_prev_match(self) -> None:
621
+ """Jump to previous search match."""
622
+ search_popup = self.query_one("#search-popup", SearchPopup)
623
+ if search_popup.is_visible() and self._search_matches:
624
+ self._current_match_index = (self._current_match_index - 1) % len(
625
+ self._search_matches
626
+ )
627
+ self._jump_to_match()
628
+
629
+ def action_open_file(self) -> None:
630
+ """Open the highlighted file in the system's default viewer."""
631
+ artifact_list = self.query_one("#artifact-list", ArtifactList)
632
+ artifact_list.open_highlighted()
633
+
634
+ def action_yank_log(self) -> None:
635
+ """Append selected log event content to /tmp/clip.txt."""
636
+ log_view = self.query_one("#log-view", LogView)
637
+ content = log_view.get_selected_content()
638
+
639
+ if not content:
640
+ self.notify("No event selected (use ↑↓ to select)", timeout=2)
641
+ return
642
+
643
+ # Append to clip file
644
+ clip_file = "/tmp/clip.txt"
645
+ with open(clip_file, "a") as f:
646
+ f.write(content)
647
+ f.write("\n\n---\n\n") # Separator between entries
648
+
649
+ # Count entries
650
+ try:
651
+ with open(clip_file, "r") as f:
652
+ count = f.read().count("---")
653
+ except Exception:
654
+ count = 1
655
+
656
+ preview = content[:40].replace("\n", " ") + "..."
657
+ self.notify(f"Yanked ({count}): {preview}", timeout=2)
658
+
659
+ def action_flush_clip(self) -> None:
660
+ """Clear /tmp/clip.txt."""
661
+ clip_file = "/tmp/clip.txt"
662
+ with open(clip_file, "w") as f:
663
+ pass # Empty the file
664
+ self.notify("Flushed /tmp/clip.txt", timeout=2)