anyshell 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.
anyshell/frontend.py ADDED
@@ -0,0 +1,535 @@
1
+ """An Anytext frontend for the UI-independent shell session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from os.path import commonprefix
7
+ from typing import Literal
8
+
9
+ import anyio
10
+ from anytext import events, on
11
+ from anytext.app import App, ComposeResult
12
+ from anytext.binding import Binding
13
+ from anytext.containers import Horizontal, HorizontalScroll, Vertical, VerticalScroll
14
+ from anytext.message import Message
15
+ from anytext.widgets import Footer, OptionList, RichLog, Static, TextArea
16
+ from rich.cells import cell_len
17
+ from rich.columns import Columns
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ from .editor import FileEditor
22
+ from .engine import Event, Session, is_complete
23
+ from .pager import Pager
24
+ from .presentation import (
25
+ LONG_FILE_HEADERS,
26
+ file_name,
27
+ format_value,
28
+ is_file_record,
29
+ is_long_file_record,
30
+ long_file_cells,
31
+ )
32
+
33
+
34
+ class CompletionMenu(OptionList, can_focus=False):
35
+ """Completion choices without taking focus from the command editor."""
36
+
37
+
38
+ class CompletionPopup(HorizontalScroll, can_focus=False):
39
+ """Keep long completion rows intact inside a horizontally scrollable popup."""
40
+
41
+ def compose(self) -> ComposeResult:
42
+ yield CompletionMenu()
43
+
44
+
45
+ class CommandInput(TextArea):
46
+ BINDINGS = [
47
+ Binding("up", "history_previous", show=False),
48
+ Binding("down", "history_next", show=False),
49
+ Binding("alt+up", "older_command", show=False),
50
+ Binding("alt+down", "newer_command", show=False),
51
+ Binding("enter", "submit", show=False),
52
+ Binding("shift+enter,ctrl+o", "newline", show=False),
53
+ Binding("ctrl+enter", "force_submit", show=False),
54
+ Binding("tab", "complete", show=False),
55
+ Binding("escape", "dismiss_completion", show=False),
56
+ Binding("ctrl+c", "cancel_command", show=False),
57
+ ]
58
+
59
+ async def on_key(self, event: events.Key) -> None:
60
+ # Handle editor shortcuts in the same queue as typed characters. App-level
61
+ # priority bindings could submit before earlier edits have been applied.
62
+ for binding in self.BINDINGS:
63
+ if event.key in binding.key.split(","):
64
+ event.stop()
65
+ event.prevent_default()
66
+ await self.run_action(binding.action)
67
+ return
68
+
69
+ class Submitted(Message):
70
+ def __init__(self, command_input: CommandInput, value: str) -> None:
71
+ super().__init__()
72
+ self.input = command_input
73
+ self.value = value
74
+
75
+ def __init__(self, **kwargs) -> None:
76
+ super().__init__(show_line_numbers=False, highlight_cursor_line=False, **kwargs)
77
+
78
+ @property
79
+ def value(self) -> str:
80
+ return self.text
81
+
82
+ @value.setter
83
+ def value(self, value: str) -> None:
84
+ self.text = value
85
+ self.cursor_position = len(value)
86
+
87
+ @property
88
+ def cursor_position(self) -> int:
89
+ return self.document.get_index_from_location(self.cursor_location)
90
+
91
+ @cursor_position.setter
92
+ def cursor_position(self, value: int) -> None:
93
+ self.move_cursor(self.document.get_location_from_index(value))
94
+
95
+ def resize_editor(self) -> None:
96
+ height = max(1, min(8, self.wrapped_document.height))
97
+ self.styles.height = height
98
+ if self.parent is not None:
99
+ self.parent.styles.height = height + (
100
+ 2 if self.shell.prompt_position == "bottom" else 0
101
+ )
102
+
103
+ def on_resize(self) -> None:
104
+ self.call_after_refresh(self.resize_editor)
105
+
106
+ @property
107
+ def shell(self) -> Shell:
108
+ return self.query_ancestor(Shell)
109
+
110
+ def action_history_previous(self) -> None:
111
+ if self.shell.completion_open:
112
+ self.shell.move_completion(-1)
113
+ elif "\n" in self.value:
114
+ self.action_cursor_up()
115
+ else:
116
+ self.shell.history_move(-1)
117
+
118
+ def action_history_next(self) -> None:
119
+ if self.shell.completion_open:
120
+ self.shell.move_completion(1)
121
+ elif "\n" in self.value:
122
+ self.action_cursor_down()
123
+ else:
124
+ self.shell.history_move(1)
125
+
126
+ def action_older_command(self) -> None:
127
+ self.shell.history_move(-1)
128
+
129
+ def action_newer_command(self) -> None:
130
+ self.shell.history_move(1)
131
+
132
+ async def action_complete(self) -> None:
133
+ await self.shell.complete()
134
+
135
+ def action_dismiss_completion(self) -> None:
136
+ self.shell.dismiss_completion()
137
+
138
+ async def action_submit(self) -> None:
139
+ if self.shell.completion_open:
140
+ self.shell.accept_completion()
141
+ elif not self.shell._busy:
142
+ if is_complete(self.value):
143
+ self.action_force_submit()
144
+ else:
145
+ self.action_newline()
146
+
147
+ def action_newline(self) -> None:
148
+ self.replace("\n", *self.selection, maintain_selection_offset=False)
149
+
150
+ def action_force_submit(self) -> None:
151
+ self.post_message(self.Submitted(self, self.value))
152
+
153
+ def action_cancel_command(self) -> None:
154
+ self.shell.dismiss_completion()
155
+ self.shell.cancel()
156
+
157
+
158
+ class ShellScroll(VerticalScroll, can_focus=False):
159
+ """One scrollback area containing both the transcript and the active prompt."""
160
+
161
+
162
+ class Transcript(RichLog, can_focus=False):
163
+ def __init__(self, *, inline_prompt: bool, **kwargs) -> None:
164
+ super().__init__(**kwargs)
165
+ self.inline_prompt = inline_prompt
166
+
167
+ def write(
168
+ self,
169
+ content,
170
+ width=None,
171
+ expand=False,
172
+ shrink=True,
173
+ scroll_end=None,
174
+ animate=False,
175
+ ):
176
+ follow = (
177
+ self.inline_prompt
178
+ and self.parent is not None
179
+ and self.parent.is_vertical_scroll_end
180
+ )
181
+ result = super().write(
182
+ content,
183
+ width,
184
+ expand,
185
+ shrink,
186
+ False if self.inline_prompt else scroll_end,
187
+ animate,
188
+ )
189
+ if self.inline_prompt:
190
+ self.styles.height = len(self.lines)
191
+ if follow and self.is_mounted:
192
+ self.query_ancestor(Shell)._request_prompt_scroll()
193
+ return result
194
+
195
+ def clear(self):
196
+ result = super().clear()
197
+ if self.inline_prompt:
198
+ self.styles.height = 0
199
+ return result
200
+
201
+
202
+ class Shell(Vertical):
203
+ """Embeddable shell UI. Supply a session to retain state across remounts."""
204
+
205
+ DEFAULT_CSS = """
206
+ Shell CompletionPopup {
207
+ display: none; overlay: screen; position: absolute;
208
+ constrain: inside inside; width: 40; height: auto; max-height: 10;
209
+ border: round $primary; background: $surface;
210
+ overflow-x: auto; overflow-y: hidden;
211
+ }
212
+ Shell CompletionMenu {
213
+ border: none; padding: 0; height: 1fr; min-width: 100%;
214
+ overflow-x: hidden; overflow-y: auto;
215
+ }
216
+ Shell CompletionMenu > .option-list--option { padding: 0 1; }
217
+ Shell { height: 1fr; border: round $primary; }
218
+ Shell #shell-terminal { height: 1fr; }
219
+ Shell RichLog { height: 1fr; padding: 0 1; }
220
+ Shell #command-line { height: 3; }
221
+ Shell #prompt { width: auto; padding: 1 0 0 1; }
222
+ Shell CommandInput, Shell CommandInput:focus {
223
+ width: 1fr; height: 1; border: none; padding: 0; margin: 1 0;
224
+ }
225
+ Shell #shell-scroll { height: 1fr; background: $surface; scrollbar-gutter: stable; }
226
+ Shell.inline-prompt RichLog { height: 0; min-height: 0; overflow: hidden hidden; }
227
+ Shell PagerLog, Shell.inline-prompt PagerLog {
228
+ height: 1fr; min-height: 0; padding: 0 1; overflow: auto auto;
229
+ }
230
+ Shell.inline-prompt #command-line { height: 1; }
231
+ Shell.inline-prompt #prompt { padding: 0 0 0 1; margin-right: 1; }
232
+ Shell.inline-prompt CommandInput, Shell.inline-prompt CommandInput:focus {
233
+ height: 1; border: none; padding: 0; margin: 0; background: $surface;
234
+ }
235
+ """
236
+
237
+ def __init__(
238
+ self,
239
+ session: Session | None = None,
240
+ *,
241
+ prompt_position: Literal["inline", "bottom"] = "inline",
242
+ **kwargs,
243
+ ) -> None:
244
+ if prompt_position not in {"inline", "bottom"}:
245
+ raise ValueError("prompt_position must be 'inline' or 'bottom'")
246
+ super().__init__(**kwargs)
247
+ self._prompt_position = prompt_position
248
+ self.set_class(prompt_position == "inline", "inline-prompt")
249
+ self._prompt_scroll_pending = False
250
+ self.session = session if session is not None else Session()
251
+ self._history_index = len(self.session.history)
252
+ self._draft = ""
253
+ self.output: deque[Event] = deque(maxlen=5000)
254
+ self._busy = False
255
+ self._file_rows: list[dict] = []
256
+ self._completions: list[str] = []
257
+ self._completion_source: str | None = None
258
+
259
+ @property
260
+ def prompt_position(self) -> Literal["inline", "bottom"]:
261
+ return self._prompt_position
262
+
263
+ def _compose_terminal(self) -> ComposeResult:
264
+ yield Transcript(
265
+ id="transcript",
266
+ wrap=True,
267
+ markup=False,
268
+ max_lines=5000,
269
+ min_width=1,
270
+ inline_prompt=self.prompt_position == "inline",
271
+ )
272
+ with Horizontal(id="command-line"):
273
+ yield Static(f"{self.session.cwd} ❯", id="prompt")
274
+ yield CommandInput()
275
+
276
+ def compose(self) -> ComposeResult:
277
+ with Vertical(id="shell-terminal"):
278
+ if self.prompt_position == "inline":
279
+ with ShellScroll(id="shell-scroll"):
280
+ yield from self._compose_terminal()
281
+ else:
282
+ yield from self._compose_terminal()
283
+ yield CompletionPopup()
284
+
285
+ def _request_prompt_scroll(self) -> None:
286
+ if self.prompt_position == "inline" and not self._prompt_scroll_pending:
287
+ self._prompt_scroll_pending = True
288
+ self.call_after_refresh(self._show_prompt)
289
+
290
+ def _show_prompt(self) -> None:
291
+ self._prompt_scroll_pending = False
292
+ if self.is_mounted:
293
+ self.query_one(CommandInput).scroll_visible(animate=False, immediate=True)
294
+
295
+ @on(TextArea.Changed)
296
+ def input_changed(self, event: TextArea.Changed) -> None:
297
+ if isinstance(event.text_area, CommandInput):
298
+ event.stop()
299
+ event.text_area.resize_editor()
300
+ if (
301
+ self.completion_open
302
+ and event.text_area.value != self._completion_source
303
+ ):
304
+ self.dismiss_completion()
305
+ self._request_prompt_scroll()
306
+
307
+ def on_resize(self) -> None:
308
+ self.call_after_refresh(self.query_one(CommandInput).resize_editor)
309
+ self.dismiss_completion()
310
+ self._request_prompt_scroll()
311
+
312
+ def on_mount(self) -> None:
313
+ self.query_one(CommandInput).focus()
314
+ self._request_prompt_scroll()
315
+
316
+ @on(CommandInput.Submitted)
317
+ def submitted(self, event: CommandInput.Submitted) -> None:
318
+ event.stop()
319
+ if self._busy or not event.value.strip():
320
+ return
321
+ self._busy = True
322
+ source = event.value
323
+ event.input.value = ""
324
+ self.query_one(RichLog).write(
325
+ Text(f"{self.session.cwd} ❯ {source}", style="bold cyan")
326
+ )
327
+ self.run_worker(
328
+ self._execute(source), name="shell-command", exit_on_error=False
329
+ )
330
+
331
+ async def _execute(self, source: str) -> None:
332
+ try:
333
+ await self.session.execute(source, self.receive)
334
+ finally:
335
+ self._busy = False
336
+ self._history_index = len(self.session.history)
337
+ self._draft = ""
338
+ self.query_one("#prompt", Static).update(f"{self.session.cwd} ❯")
339
+ self._request_prompt_scroll()
340
+
341
+ def _flush_file_rows(self) -> None:
342
+ if not self._file_rows:
343
+ return
344
+ if is_long_file_record(self._file_rows[0]):
345
+ table = Table(box=None, padding=(0, 2))
346
+ for header in LONG_FILE_HEADERS:
347
+ table.add_column(
348
+ header, justify="right" if header == "Bytes" else "left"
349
+ )
350
+ for row in self._file_rows:
351
+ kind, size, modified, name = long_file_cells(row)
352
+ table.add_row(
353
+ kind,
354
+ size,
355
+ modified,
356
+ Text(name, style="bold blue" if row["type"] == "directory" else ""),
357
+ )
358
+ self.query_one(RichLog).write(table)
359
+ else:
360
+ names = [
361
+ Text(
362
+ file_name(row),
363
+ style="bold blue" if row["type"] == "directory" else "",
364
+ )
365
+ for row in self._file_rows
366
+ ]
367
+ self.query_one(RichLog).write(Columns(names, padding=(0, 2)))
368
+ self._file_rows.clear()
369
+
370
+ async def receive(self, event: Event) -> None:
371
+ self.output.append(event)
372
+ transcript = self.query_one(RichLog)
373
+ if event.kind in {"edit", "page"}:
374
+ self._flush_file_rows()
375
+ view = (
376
+ FileEditor(self.session.filesystem, event.value.path)
377
+ if event.kind == "edit"
378
+ else Pager(event.value)
379
+ )
380
+ event.value.handled = True
381
+ terminal = self.query_one("#shell-terminal")
382
+ self.dismiss_completion()
383
+ terminal.display = False
384
+ terminal.disabled = True
385
+ try:
386
+ await self.mount(view)
387
+ if isinstance(view, Pager):
388
+ async with anyio.create_task_group() as group:
389
+ group.start_soon(view.load)
390
+ await view.closed.wait()
391
+ group.cancel_scope.cancel()
392
+ if view.error is not None:
393
+ raise view.error
394
+ else:
395
+ await view.closed.wait()
396
+ finally:
397
+ with anyio.CancelScope(shield=True):
398
+ await view.remove()
399
+ terminal.disabled = False
400
+ terminal.display = True
401
+ self.query_one(CommandInput).focus()
402
+ self._request_prompt_scroll()
403
+ elif event.kind == "clear":
404
+ self._file_rows.clear()
405
+ transcript.clear()
406
+ self.output.clear()
407
+ elif event.kind == "output" and is_file_record(event.value):
408
+ # Bound presentation buffering while keeping pipeline records untouched.
409
+ if self._file_rows and is_long_file_record(
410
+ self._file_rows[0]
411
+ ) != is_long_file_record(event.value):
412
+ self._flush_file_rows()
413
+ self._file_rows.append(event.value)
414
+ if len(self._file_rows) >= 128:
415
+ self._flush_file_rows()
416
+ else:
417
+ self._flush_file_rows()
418
+ if event.kind in {"output", "error"}:
419
+ transcript.write(
420
+ Text(
421
+ format_value(event.value),
422
+ style="red" if event.kind == "error" else "",
423
+ )
424
+ )
425
+
426
+ def cancel(self) -> None:
427
+ if not self.session.cancel():
428
+ self.query_one(CommandInput).value = ""
429
+
430
+ def history_move(self, delta: int) -> None:
431
+ command_input = self.query_one(CommandInput)
432
+ if self._history_index == len(self.session.history):
433
+ self._draft = command_input.value
434
+ self._history_index = max(
435
+ 0, min(len(self.session.history), self._history_index + delta)
436
+ )
437
+ command_input.value = (
438
+ self._draft
439
+ if self._history_index == len(self.session.history)
440
+ else self.session.history[self._history_index]
441
+ )
442
+ command_input.cursor_position = len(command_input.value)
443
+
444
+ async def complete(self) -> None:
445
+ command_input = self.query_one(CommandInput)
446
+ if self.completion_open:
447
+ self.move_completion(1)
448
+ return
449
+ if command_input.cursor_position != len(command_input.value):
450
+ return
451
+ candidates = await self.session.complete(command_input.value)
452
+ if len(candidates) == 1:
453
+ command_input.value = candidates[0]
454
+ command_input.cursor_position = len(command_input.value)
455
+ elif candidates:
456
+ shared = commonprefix(candidates)
457
+ if shared.startswith(command_input.value) and len(shared) > len(
458
+ command_input.value
459
+ ):
460
+ command_input.value = shared
461
+ command_input.cursor_position = len(shared)
462
+ return
463
+ self._completions = candidates
464
+ self._completion_source = command_input.value
465
+ menu = self.query_one(CompletionMenu)
466
+ menu.clear_options()
467
+ menu.add_options(Text(candidate) for candidate in candidates)
468
+ popup = self.query_one(CompletionPopup)
469
+ cursor = command_input.cursor_screen_offset
470
+ content_width = max(cell_len(candidate) for candidate in candidates) + 4
471
+ width = min(self.screen.size.width, max(16, content_width + 2))
472
+ popup.styles.width = width
473
+ menu.styles.width = max(width - 2, content_width)
474
+ height = min(10, len(candidates) + 2 + (content_width > width - 2))
475
+ popup.styles.height = height
476
+ y = cursor.y + 1
477
+ if y + height > self.content_region.bottom:
478
+ y = max(0, cursor.y - height)
479
+ popup.styles.offset = (
480
+ cursor.x - self.content_region.x,
481
+ y - self.content_region.y,
482
+ )
483
+ popup.display = True
484
+ popup.scroll_to(x=0, animate=False, force=True)
485
+ menu.highlighted = 0
486
+
487
+ @property
488
+ def completion_open(self) -> bool:
489
+ return bool(self._completions)
490
+
491
+ def dismiss_completion(self) -> None:
492
+ self._completions = []
493
+ self._completion_source = None
494
+ self.query_one(CompletionPopup).display = False
495
+
496
+ def move_completion(self, delta: int) -> None:
497
+ menu = self.query_one(CompletionMenu)
498
+ menu.highlighted = ((menu.highlighted or 0) + delta) % len(self._completions)
499
+
500
+ def accept_completion(self) -> None:
501
+ menu = self.query_one(CompletionMenu)
502
+ replacement = self._completions[menu.highlighted or 0]
503
+ self.dismiss_completion()
504
+ command_input = self.query_one(CommandInput)
505
+ command_input.value = replacement
506
+ command_input.cursor_position = len(replacement)
507
+
508
+ @on(OptionList.OptionSelected)
509
+ def completion_selected(self, event: OptionList.OptionSelected) -> None:
510
+ if isinstance(event.option_list, CompletionMenu):
511
+ event.stop()
512
+ self.accept_completion()
513
+ self.query_one(CommandInput).focus()
514
+
515
+
516
+ class ShellApp(App, inherit_bindings=False):
517
+ TITLE = "Anyshell"
518
+ BINDINGS = [Binding("ctrl+q", "quit", "Quit")]
519
+
520
+ def __init__(
521
+ self,
522
+ session: Session | None = None,
523
+ *,
524
+ prompt_position: Literal["inline", "bottom"] = "inline",
525
+ **kwargs,
526
+ ) -> None:
527
+ if prompt_position not in {"inline", "bottom"}:
528
+ raise ValueError("prompt_position must be 'inline' or 'bottom'")
529
+ super().__init__(**kwargs)
530
+ self.session = session if session is not None else Session()
531
+ self.prompt_position = prompt_position
532
+
533
+ def compose(self) -> ComposeResult:
534
+ yield Shell(self.session, prompt_position=self.prompt_position)
535
+ yield Footer()