abstractcode 0.1.0__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,656 @@
1
+ """Full-screen UI with scrollable history, fixed input, and status bar.
2
+
3
+ Uses prompt_toolkit's Application with HSplit layout to provide:
4
+ - Scrollable output/history area (mouse wheel + keyboard) with ANSI color support
5
+ - Fixed input area at bottom
6
+ - Fixed status bar showing provider/model/context info
7
+ - Command autocomplete when typing /
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import queue
13
+ import re
14
+ import threading
15
+ import time
16
+ from typing import Callable, List, Optional, Tuple
17
+
18
+ from prompt_toolkit.application import Application
19
+ from prompt_toolkit.buffer import Buffer
20
+ from prompt_toolkit.completion import Completer, Completion
21
+ from prompt_toolkit.filters import has_completions
22
+ from prompt_toolkit.history import InMemoryHistory
23
+ from prompt_toolkit.data_structures import Point
24
+ from prompt_toolkit.formatted_text import FormattedText, ANSI
25
+ from prompt_toolkit.key_binding import KeyBindings
26
+ from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
27
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
28
+ from prompt_toolkit.layout.layout import Layout
29
+ from prompt_toolkit.layout.menus import CompletionsMenu
30
+ from prompt_toolkit.styles import Style
31
+
32
+
33
+ # Command definitions: (command, description)
34
+ COMMANDS = [
35
+ ("help", "Show available commands"),
36
+ ("tools", "List available tools"),
37
+ ("status", "Show current run status"),
38
+ ("history", "Show recent conversation history"),
39
+ ("resume", "Resume the saved/attached run"),
40
+ ("clear", "Clear memory and start fresh"),
41
+ ("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
42
+ ("new", "Start fresh (alias for /clear)"),
43
+ ("reset", "Reset session (alias for /clear)"),
44
+ ("task", "Start a new task"),
45
+ ("auto-accept", "Toggle auto-accept for tools [saved]"),
46
+ ("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
47
+ ("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
48
+ ("memory", "Show current token usage breakdown"),
49
+ ("snapshot save", "Save current state as named snapshot"),
50
+ ("snapshot load", "Load snapshot by name"),
51
+ ("snapshot list", "List available snapshots"),
52
+ ("quit", "Exit"),
53
+ ("exit", "Exit"),
54
+ ("q", "Exit"),
55
+ ]
56
+
57
+
58
+ class CommandCompleter(Completer):
59
+ """Completer for / commands."""
60
+
61
+ def get_completions(self, document, complete_event):
62
+ text = document.text_before_cursor
63
+
64
+ # Only complete if starts with /
65
+ if not text.startswith("/"):
66
+ return
67
+
68
+ # Get the text after /
69
+ cmd_text = text[1:].lower()
70
+
71
+ for cmd, description in COMMANDS:
72
+ if cmd.startswith(cmd_text):
73
+ # Yield completion (what to insert, how far back to go)
74
+ yield Completion(
75
+ cmd,
76
+ start_position=-len(cmd_text),
77
+ display=f"/{cmd}",
78
+ display_meta=description,
79
+ )
80
+
81
+
82
+ class FullScreenUI:
83
+ """Full-screen chat interface with scrollable history and ANSI color support."""
84
+
85
+ def __init__(
86
+ self,
87
+ get_status_text: Callable[[], str],
88
+ on_input: Callable[[str], None],
89
+ color: bool = True,
90
+ ):
91
+ """Initialize the full-screen UI.
92
+
93
+ Args:
94
+ get_status_text: Callable that returns status bar text
95
+ on_input: Callback when user submits input
96
+ color: Enable colored output
97
+ """
98
+ self._get_status_text = get_status_text
99
+ self._on_input = on_input
100
+ self._color = color
101
+ self._running = False
102
+
103
+ # Output content storage (raw text with ANSI codes)
104
+ self._output_text: str = ""
105
+ # Scroll position (line offset from top)
106
+ self._scroll_offset: int = 0
107
+
108
+ # Thread safety for output
109
+ self._output_lock = threading.Lock()
110
+
111
+ # Cached pre-parsed output snapshot (ensures atomic consistency
112
+ # between _get_output_formatted() and _get_cursor_position())
113
+ self._cached_formatted: Optional[FormattedText] = None
114
+ self._cached_line_count: int = 0
115
+ self._cached_text_version: str = ""
116
+
117
+ # Command queue for background processing
118
+ self._command_queue: queue.Queue[Optional[str]] = queue.Queue()
119
+
120
+ # Blocking prompt support (for tool approvals)
121
+ self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
122
+
123
+ # Worker thread
124
+ self._worker_thread: Optional[threading.Thread] = None
125
+ self._shutdown = False
126
+
127
+ # Spinner state for visual feedback during processing
128
+ self._spinner_text: str = ""
129
+ self._spinner_active = False
130
+ self._spinner_frame = 0
131
+ self._spinner_thread: Optional[threading.Thread] = None
132
+ self._spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
133
+
134
+ # Prompt history (persists across prompts in this session)
135
+ self._history = InMemoryHistory()
136
+
137
+ # Input buffer with command completer and history
138
+ self._input_buffer = Buffer(
139
+ name="input",
140
+ multiline=False,
141
+ completer=CommandCompleter(),
142
+ complete_while_typing=True,
143
+ history=self._history,
144
+ )
145
+
146
+ # Build the layout
147
+ self._build_layout()
148
+ self._build_keybindings()
149
+ self._build_style()
150
+
151
+ # Create application
152
+ self._app = Application(
153
+ layout=self._layout,
154
+ key_bindings=self._kb,
155
+ style=self._style,
156
+ full_screen=True,
157
+ mouse_support=True,
158
+ erase_when_done=False,
159
+ )
160
+
161
+ def _get_output_formatted(self) -> FormattedText:
162
+ """Get formatted output text with ANSI color support (thread-safe).
163
+
164
+ Returns cached pre-parsed ANSI result to ensure consistency with
165
+ _get_cursor_position() during the same render cycle. This eliminates
166
+ race conditions where text changes between the two method calls.
167
+ """
168
+ with self._output_lock:
169
+ if self._cached_formatted is None:
170
+ # First call or cache invalidated - rebuild
171
+ self._invalidate_output_cache()
172
+ return self._cached_formatted
173
+
174
+ def _get_cursor_position(self) -> Point:
175
+ """Get cursor position for scrolling (thread-safe).
176
+
177
+ Uses cached line count to ensure consistency with _get_output_formatted()
178
+ during the same render cycle. Both methods read from the same snapshot,
179
+ eliminating race conditions between text updates and rendering.
180
+
181
+ prompt_toolkit scrolls the view to make the cursor visible.
182
+ By setting cursor to scroll_offset, we control which line is visible.
183
+ """
184
+ with self._output_lock:
185
+ if self._cached_formatted is None:
186
+ # Cache not initialized - rebuild
187
+ self._invalidate_output_cache()
188
+
189
+ # Use cached line count from same snapshot as formatted text
190
+ total_lines = self._cached_line_count
191
+
192
+ # Clamp scroll_offset to valid range [0, total_lines - 1]
193
+ # Line indices are 0-based, so max valid index is total_lines - 1
194
+ safe_offset = max(0, min(self._scroll_offset, total_lines - 1))
195
+ return Point(0, safe_offset)
196
+
197
+ def _invalidate_output_cache(self) -> None:
198
+ """Invalidate cached ANSI-parsed output (must be called under lock).
199
+
200
+ This ensures both _get_output_formatted() and _get_cursor_position()
201
+ return values from the same text snapshot, eliminating race conditions.
202
+
203
+ CRITICAL: Must be called with self._output_lock held.
204
+ """
205
+ if not self._output_text:
206
+ self._cached_formatted = FormattedText([])
207
+ self._cached_line_count = 0
208
+ self._cached_text_version = ""
209
+ return
210
+
211
+ # Parse ANSI under lock (happens once per text change)
212
+ self._cached_formatted = ANSI(self._output_text)
213
+ self._cached_line_count = self._output_text.count('\n') + 1
214
+ self._cached_text_version = self._output_text
215
+
216
+ def _build_layout(self) -> None:
217
+ """Build the HSplit layout with output, input, and status areas."""
218
+ # Output area using FormattedTextControl for ANSI color support
219
+ self._output_control = FormattedTextControl(
220
+ text=self._get_output_formatted,
221
+ focusable=True,
222
+ get_cursor_position=self._get_cursor_position,
223
+ )
224
+
225
+ output_window = Window(
226
+ content=self._output_control,
227
+ wrap_lines=True,
228
+ )
229
+
230
+ # Separator line
231
+ separator = Window(height=1, char="─", style="class:separator")
232
+
233
+ # Input area
234
+ input_window = Window(
235
+ content=BufferControl(buffer=self._input_buffer),
236
+ height=3, # Allow a few lines for input
237
+ wrap_lines=True,
238
+ )
239
+
240
+ # Input prompt label
241
+ input_label = Window(
242
+ content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
243
+ width=2,
244
+ height=1,
245
+ )
246
+
247
+ # Combine input label and input window horizontally
248
+ input_row = VSplit([input_label, input_window])
249
+
250
+ # Status bar (fixed at bottom)
251
+ status_bar = Window(
252
+ content=FormattedTextControl(self._get_status_formatted),
253
+ height=1,
254
+ style="class:status-bar",
255
+ )
256
+
257
+ # Help hint bar
258
+ help_bar = Window(
259
+ content=FormattedTextControl(
260
+ lambda: [("class:help", " Enter=submit | ↑/↓=history | PgUp/PgDn=scroll | Home/End=top/bottom | Ctrl+C=exit")]
261
+ ),
262
+ height=1,
263
+ style="class:help-bar",
264
+ )
265
+
266
+ # Stack everything vertically
267
+ body = HSplit([
268
+ output_window, # Scrollable output (takes remaining space)
269
+ separator, # Visual separator
270
+ input_row, # Input area with prompt
271
+ status_bar, # Status info
272
+ help_bar, # Help hints
273
+ ])
274
+
275
+ # Wrap in FloatContainer to show completion menu
276
+ root = FloatContainer(
277
+ content=body,
278
+ floats=[
279
+ Float(
280
+ xcursor=True,
281
+ ycursor=True,
282
+ content=CompletionsMenu(max_height=10, scroll_offset=1),
283
+ ),
284
+ ],
285
+ )
286
+
287
+ self._layout = Layout(root)
288
+ # Focus starts on input
289
+ self._layout.focus(self._input_buffer)
290
+
291
+ # Store references for later
292
+ self._output_window = output_window
293
+
294
+ def _get_status_formatted(self) -> FormattedText:
295
+ """Get formatted status text with optional spinner."""
296
+ text = self._get_status_text()
297
+
298
+ # If spinner is active, show it prominently
299
+ if self._spinner_active and self._spinner_text:
300
+ spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
301
+ return [
302
+ ("class:spinner", f" {spinner_char} "),
303
+ ("class:spinner-text", f"{self._spinner_text}"),
304
+ ("class:status-text", f" │ {text}"),
305
+ ]
306
+
307
+ return [("class:status-text", f" {text}")]
308
+
309
+ def _build_keybindings(self) -> None:
310
+ """Build key bindings."""
311
+ self._kb = KeyBindings()
312
+
313
+ # Enter = submit input (but not if completion menu is showing)
314
+ @self._kb.add("enter", filter=~has_completions)
315
+ def handle_enter(event):
316
+ text = self._input_buffer.text.strip()
317
+ if text:
318
+ # Add to history before clearing
319
+ self._history.append_string(text)
320
+ # Clear input
321
+ self._input_buffer.reset()
322
+
323
+ # If there's a pending blocking prompt, respond to it
324
+ if self._pending_blocking_prompt is not None:
325
+ self._pending_blocking_prompt.put(text)
326
+ else:
327
+ # Queue for background processing (don't exit app!)
328
+ self._command_queue.put(text)
329
+
330
+ # Trigger UI refresh
331
+ event.app.invalidate()
332
+ else:
333
+ # Empty input - if blocking prompt is waiting, show guidance
334
+ if self._pending_blocking_prompt is not None:
335
+ self.append_output(" (Please type a response and press Enter)")
336
+ event.app.invalidate()
337
+
338
+ # Enter with completions = accept completion (don't submit)
339
+ @self._kb.add("enter", filter=has_completions)
340
+ def handle_enter_completion(event):
341
+ # Accept the current completion
342
+ buff = event.app.current_buffer
343
+ if buff.complete_state:
344
+ buff.complete_state = None
345
+ # Apply the completion but don't submit
346
+ event.current_buffer.complete_state = None
347
+
348
+ # Tab = accept completion
349
+ @self._kb.add("tab", filter=has_completions)
350
+ def handle_tab_completion(event):
351
+ buff = event.app.current_buffer
352
+ if buff.complete_state:
353
+ buff.complete_state = None
354
+
355
+ # Up arrow = history previous (when no completions showing)
356
+ @self._kb.add("up", filter=~has_completions)
357
+ def history_prev(event):
358
+ event.current_buffer.history_backward()
359
+
360
+ # Down arrow = history next (when no completions showing)
361
+ @self._kb.add("down", filter=~has_completions)
362
+ def history_next(event):
363
+ event.current_buffer.history_forward()
364
+
365
+ # Up arrow with completions = navigate completions
366
+ @self._kb.add("up", filter=has_completions)
367
+ def completion_prev(event):
368
+ buff = event.app.current_buffer
369
+ if buff.complete_state:
370
+ buff.complete_previous()
371
+
372
+ # Down arrow with completions = navigate completions
373
+ @self._kb.add("down", filter=has_completions)
374
+ def completion_next(event):
375
+ buff = event.app.current_buffer
376
+ if buff.complete_state:
377
+ buff.complete_next()
378
+
379
+ # Ctrl+C = exit
380
+ @self._kb.add("c-c")
381
+ def handle_ctrl_c(event):
382
+ self._shutdown = True
383
+ self._command_queue.put(None) # Signal worker to stop
384
+ event.app.exit(result=None)
385
+
386
+ # Ctrl+D = exit (EOF)
387
+ @self._kb.add("c-d")
388
+ def handle_ctrl_d(event):
389
+ self._shutdown = True
390
+ self._command_queue.put(None) # Signal worker to stop
391
+ event.app.exit(result=None)
392
+
393
+ # Ctrl+L = clear output
394
+ @self._kb.add("c-l")
395
+ def handle_ctrl_l(event):
396
+ self.clear_output()
397
+ event.app.invalidate()
398
+
399
+ # Ctrl+Up = scroll output up
400
+ @self._kb.add("c-up")
401
+ def scroll_up(event):
402
+ self._scroll(-3)
403
+ event.app.invalidate()
404
+
405
+ # Ctrl+Down = scroll output down
406
+ @self._kb.add("c-down")
407
+ def scroll_down(event):
408
+ self._scroll(3)
409
+ event.app.invalidate()
410
+
411
+ # Page Up = scroll up more
412
+ @self._kb.add("pageup")
413
+ def page_up(event):
414
+ self._scroll(-10)
415
+ event.app.invalidate()
416
+
417
+ # Page Down = scroll down more
418
+ @self._kb.add("pagedown")
419
+ def page_down(event):
420
+ self._scroll(10)
421
+ event.app.invalidate()
422
+
423
+ # Home = scroll to top
424
+ @self._kb.add("home")
425
+ def scroll_to_top(event):
426
+ self._scroll_offset = 0
427
+ event.app.invalidate()
428
+
429
+ # End = scroll to bottom
430
+ @self._kb.add("end")
431
+ def scroll_to_end(event):
432
+ total_lines = self._get_total_lines()
433
+ self._scroll_offset = max(0, total_lines - 1)
434
+ event.app.invalidate()
435
+
436
+ # Alt+Enter = insert newline in input
437
+ @self._kb.add("escape", "enter")
438
+ def handle_alt_enter(event):
439
+ self._input_buffer.insert_text("\n")
440
+
441
+ # Ctrl+J = insert newline (Unix tradition)
442
+ @self._kb.add("c-j")
443
+ def handle_ctrl_j(event):
444
+ self._input_buffer.insert_text("\n")
445
+
446
+ def _get_total_lines(self) -> int:
447
+ """Get total number of lines in output (thread-safe)."""
448
+ with self._output_lock:
449
+ if not self._output_text:
450
+ return 0
451
+ return self._output_text.count('\n') + 1
452
+
453
+ def _scroll(self, lines: int) -> None:
454
+ """Scroll the output by N lines."""
455
+ total_lines = self._get_total_lines()
456
+ if total_lines == 0:
457
+ return
458
+ # Line indices are 0-based, so valid range is [0, total_lines - 1]
459
+ max_offset = max(0, total_lines - 1)
460
+ self._scroll_offset = max(0, min(max_offset, self._scroll_offset + lines))
461
+
462
+ def scroll_to_bottom(self) -> None:
463
+ """Scroll to show the latest content at the bottom."""
464
+ total_lines = self._get_total_lines()
465
+ # Set cursor to last valid line index (0-based)
466
+ self._scroll_offset = max(0, total_lines - 1)
467
+ if self._app and self._app.is_running:
468
+ self._app.invalidate()
469
+
470
+ def _build_style(self) -> None:
471
+ """Build the style."""
472
+ if self._color:
473
+ self._style = Style.from_dict({
474
+ "separator": "#444444",
475
+ "status-bar": "bg:#1a1a2e #888888",
476
+ "status-text": "#888888",
477
+ "help-bar": "bg:#1a1a2e #666666",
478
+ "help": "#666666 italic",
479
+ "prompt": "#00aa00 bold",
480
+ # Spinner styling
481
+ "spinner": "#00aaff bold",
482
+ "spinner-text": "#ffaa00",
483
+ # Completion menu styling
484
+ "completion-menu": "bg:#1a1a2e #cccccc",
485
+ "completion-menu.completion": "bg:#1a1a2e #cccccc",
486
+ "completion-menu.completion.current": "bg:#444444 #ffffff bold",
487
+ "completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
488
+ "completion-menu.meta.completion.current": "bg:#444444 #aaaaaa italic",
489
+ })
490
+ else:
491
+ self._style = Style.from_dict({})
492
+
493
+ def append_output(self, text: str) -> None:
494
+ """Append text to the output area (thread-safe)."""
495
+ with self._output_lock:
496
+ if self._output_text:
497
+ self._output_text += "\n" + text
498
+ else:
499
+ self._output_text = text
500
+
501
+ # Invalidate cache - pre-parse ANSI under lock to ensure
502
+ # atomic consistency between formatted text and line count
503
+ self._invalidate_output_cache()
504
+
505
+ # Auto-scroll to bottom when new content added
506
+ # Use cached line count from the snapshot we just created
507
+ self._scroll_offset = max(0, self._cached_line_count - 1)
508
+
509
+ # Trigger UI refresh (now safe - cache updated atomically)
510
+ if self._app and self._app.is_running:
511
+ self._app.invalidate()
512
+
513
+ def clear_output(self) -> None:
514
+ """Clear the output area (thread-safe)."""
515
+ with self._output_lock:
516
+ self._output_text = ""
517
+ self._invalidate_output_cache() # Clear cache atomically
518
+ self._scroll_offset = 0
519
+
520
+ if self._app and self._app.is_running:
521
+ self._app.invalidate()
522
+
523
+ def set_output(self, text: str) -> None:
524
+ """Replace all output with new text (thread-safe)."""
525
+ with self._output_lock:
526
+ self._output_text = text
527
+ self._invalidate_output_cache() # Pre-parse under lock
528
+ self._scroll_offset = 0
529
+
530
+ if self._app and self._app.is_running:
531
+ self._app.invalidate()
532
+
533
+ def _spinner_loop(self) -> None:
534
+ """Background thread that animates the spinner."""
535
+ while self._spinner_active and not self._shutdown:
536
+ self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_frames)
537
+ if self._app and self._app.is_running:
538
+ self._app.invalidate()
539
+ time.sleep(0.1) # 10 FPS animation
540
+
541
+ def set_spinner(self, text: str) -> None:
542
+ """Start the spinner with the given text (thread-safe).
543
+
544
+ Args:
545
+ text: Status text to show next to the spinner (e.g., "Generating...")
546
+ """
547
+ self._spinner_text = text
548
+ self._spinner_frame = 0
549
+
550
+ if not self._spinner_active:
551
+ self._spinner_active = True
552
+ self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
553
+ self._spinner_thread.start()
554
+ elif self._app and self._app.is_running:
555
+ self._app.invalidate()
556
+
557
+ def clear_spinner(self) -> None:
558
+ """Stop and hide the spinner (thread-safe)."""
559
+ self._spinner_active = False
560
+ self._spinner_text = ""
561
+
562
+ if self._spinner_thread:
563
+ self._spinner_thread.join(timeout=0.5)
564
+ self._spinner_thread = None
565
+
566
+ if self._app and self._app.is_running:
567
+ self._app.invalidate()
568
+
569
+ def _worker_loop(self) -> None:
570
+ """Background thread that processes commands from the queue."""
571
+ while not self._shutdown:
572
+ try:
573
+ cmd = self._command_queue.get(timeout=0.1)
574
+ except queue.Empty:
575
+ continue
576
+
577
+ if cmd is None: # Shutdown signal
578
+ break
579
+
580
+ try:
581
+ self._on_input(cmd)
582
+ except KeyboardInterrupt:
583
+ self.append_output("Interrupted.")
584
+ except Exception as e:
585
+ self.append_output(f"Error: {e}")
586
+ finally:
587
+ # Trigger UI refresh from worker thread (thread-safe)
588
+ if self._app and self._app.is_running:
589
+ self._app.invalidate()
590
+
591
+ def run_loop(self, banner: str = "") -> None:
592
+ """Run the main input loop with single Application lifecycle.
593
+
594
+ The Application stays in full-screen mode continuously. Commands are
595
+ processed by a background worker thread while the UI remains responsive.
596
+
597
+ Args:
598
+ banner: Initial text to show in output
599
+ """
600
+ if banner:
601
+ self.set_output(banner)
602
+
603
+ # Start worker thread
604
+ self._shutdown = False
605
+ self._running = True
606
+ self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
607
+ self._worker_thread.start()
608
+
609
+ try:
610
+ # Run the app ONCE - stays in full-screen until explicit exit
611
+ self._app.run()
612
+ except (EOFError, KeyboardInterrupt):
613
+ pass
614
+ finally:
615
+ # Clean shutdown
616
+ self._running = False
617
+ self._shutdown = True
618
+ self._command_queue.put(None)
619
+ if self._worker_thread:
620
+ self._worker_thread.join(timeout=2.0)
621
+
622
+ def blocking_prompt(self, message: str) -> str:
623
+ """Block worker thread until user provides input (for tool approvals).
624
+
625
+ This method is called from the worker thread when tool approval is needed.
626
+ It shows the message in output and waits for the user to respond.
627
+
628
+ Args:
629
+ message: The prompt message to show
630
+
631
+ Returns:
632
+ The user's response, or empty string on timeout
633
+ """
634
+ self.append_output(message)
635
+
636
+ response_queue: queue.Queue[str] = queue.Queue()
637
+ self._pending_blocking_prompt = response_queue
638
+
639
+ try:
640
+ return response_queue.get(timeout=300) # 5 minute timeout
641
+ except queue.Empty:
642
+ return ""
643
+ finally:
644
+ self._pending_blocking_prompt = None
645
+
646
+ def stop(self) -> None:
647
+ """Stop the run loop and exit the application."""
648
+ self._running = False
649
+ self._shutdown = True
650
+ self._command_queue.put(None)
651
+ if self._app and self._app.is_running:
652
+ self._app.exit()
653
+
654
+ def exit(self) -> None:
655
+ """Exit the application (alias for stop)."""
656
+ self.stop()