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/__init__.py +27 -0
- anyshell/__main__.py +74 -0
- anyshell/commands.py +395 -0
- anyshell/editor.py +122 -0
- anyshell/engine.py +422 -0
- anyshell/filesystem.py +349 -0
- anyshell/frontend.py +535 -0
- anyshell/pager.py +203 -0
- anyshell/presentation.py +55 -0
- anyshell/py.typed +0 -0
- anyshell-0.1.0.dist-info/METADATA +337 -0
- anyshell-0.1.0.dist-info/RECORD +15 -0
- anyshell-0.1.0.dist-info/WHEEL +4 -0
- anyshell-0.1.0.dist-info/entry_points.txt +3 -0
- anyshell-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|