anyshell 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → anyshell-0.2.0}/PKG-INFO +72 -7
- {anyshell-0.1.0 → anyshell-0.2.0}/README.md +71 -6
- {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/app.py +1 -1
- {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/browser.js +18 -4
- {anyshell-0.1.0 → anyshell-0.2.0}/examples/browser/index.html +5 -4
- {anyshell-0.1.0 → anyshell-0.2.0}/pyproject.toml +1 -1
- {anyshell-0.1.0 → anyshell-0.2.0}/pyproject.toml.orig +1 -1
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/__init__.py +2 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/__main__.py +13 -1
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/commands.py +81 -27
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/editor.py +22 -7
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/engine.py +141 -26
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/filesystem.py +16 -6
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/frontend.py +185 -13
- anyshell-0.2.0/src/anyshell/log.py +51 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/pager.py +88 -15
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/shell.test.cjs +113 -14
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_editor.py +27 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_engine.py +50 -0
- anyshell-0.2.0/tests/test_frontend.py +593 -0
- anyshell-0.2.0/tests/test_jobs.py +194 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_multiline.py +65 -1
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_options.py +34 -2
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_pager.py +199 -10
- anyshell-0.1.0/tests/test_frontend.py +0 -297
- {anyshell-0.1.0 → anyshell-0.2.0}/LICENSE +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/presentation.py +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/src/anyshell/py.typed +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/package-lock.json +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/browser/package.json +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/conftest.py +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_find.py +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tests/test_transfers.py +0 -0
- {anyshell-0.1.0 → anyshell-0.2.0}/tools/build_browser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: anyshell
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An async Python command shell with an optional Anytext frontend
|
|
5
5
|
Author: David Brochart
|
|
6
6
|
Author-email: David Brochart <david.brochart@gmail.com>
|
|
@@ -44,6 +44,14 @@ editor, run `uv run --extra anytext anyshell --prompt-position bottom` or constr
|
|
|
44
44
|
|
|
45
45
|
The widget provides a scrollable transcript, Up/Down history, Tab completion for
|
|
46
46
|
command names, options, and unquoted paths, Ctrl+C cancellation, and Ctrl+Q to quit the demo.
|
|
47
|
+
Drag across command output to select text. Ctrl+C copies selected output when no
|
|
48
|
+
foreground command is running; Ctrl+Shift+C copies it while a command is running.
|
|
49
|
+
Mouse selection and copying also work in the pager. Browser copying uses the
|
|
50
|
+
browser clipboard when permission is available.
|
|
51
|
+
|
|
52
|
+
The prompt is replaced by a blank cursor line while a foreground command runs
|
|
53
|
+
and returns when it finishes
|
|
54
|
+
or is cancelled with Ctrl+C. Background commands return the prompt immediately.
|
|
47
55
|
Tab first extends the shared prefix when several completions match. If no more
|
|
48
56
|
characters can be filled in, Tab opens a menu beside the cursor. Use Up/Down or
|
|
49
57
|
Tab to select, Enter to insert, and Escape to dismiss; typing also dismisses it.
|
|
@@ -65,7 +73,8 @@ cat notes.txt |
|
|
|
65
73
|
count
|
|
66
74
|
```
|
|
67
75
|
|
|
68
|
-
Unquoted newlines separate commands, which execute in order.
|
|
76
|
+
Unquoted newlines and semicolons separate commands, which execute in order.
|
|
77
|
+
For example, `sleep 1; sleep 1` waits twice in sequence. A backslash followed
|
|
69
78
|
by a newline joins lines without inserting a character. Newlines inside quotes
|
|
70
79
|
remain part of the argument. All commands are parsed and validated before any
|
|
71
80
|
runs; a runtime failure does not prevent later commands from running, and the
|
|
@@ -114,14 +123,58 @@ is `None`. `ls -a` (`--all`) includes hidden entries, and flags combine as `-la`
|
|
|
114
123
|
`touch PATH...` creates missing files or updates modification times without
|
|
115
124
|
changing existing contents. Existing directories can also be touched.
|
|
116
125
|
`rm PATH...` removes files, continuing with remaining paths if one fails.
|
|
126
|
+
`rm -r DIRECTORY...` removes directory trees, including hidden entries.
|
|
127
|
+
`rm -f` ignores missing paths and accepts no operands; combine the flags as
|
|
128
|
+
`rm -rf DIRECTORY...`. Force does not suppress other errors. Recursive removal
|
|
129
|
+
does not follow symbolic links and cannot remove the workspace root.
|
|
117
130
|
`rmdir DIRECTORY...` removes empty directories and rejects files and nonempty
|
|
118
131
|
directories; its completion suggests only directories.
|
|
119
132
|
|
|
120
133
|
This is an initial shell language: quoting, multiline commands, and pipes are supported. Bash scripts,
|
|
121
|
-
redirection, environment expansion, glob expansion, process execution,
|
|
122
|
-
|
|
134
|
+
redirection, environment expansion, glob expansion, process execution, foregrounding or suspending jobs, and persistent history
|
|
135
|
+
are not implemented. Commands are trusted Python
|
|
123
136
|
code, not a sandbox. Browser filesystem changes are in-memory and reset on reload.
|
|
124
137
|
|
|
138
|
+
## Background commands
|
|
139
|
+
|
|
140
|
+
Append `&` to run a command or pipeline in the background and return to the prompt:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
sleep 30 &
|
|
144
|
+
cat notes.txt | grep Python &
|
|
145
|
+
jobs
|
|
146
|
+
wait 1
|
|
147
|
+
kill 1
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`jobs` lists job IDs and states. `wait ID` waits for a job; `wait` waits for all
|
|
151
|
+
jobs still in the job list. `kill ID` cancels a job cooperatively. Ctrl+C cancels the
|
|
152
|
+
foreground command (including `wait`), leaving background jobs running. Job
|
|
153
|
+
output appears in the transcript, with launch and completion notifications.
|
|
154
|
+
Completed jobs are shown once by `jobs`, then removed from the job list.
|
|
155
|
+
Job IDs are not reused during the session.
|
|
156
|
+
|
|
157
|
+
An unquoted `&` also separates commands: `sleep 30 & echo ready`. Quote or escape
|
|
158
|
+
it to pass a literal ampersand. `&&` is not supported. Each job inherits the
|
|
159
|
+
working directory and command registry at launch and shares the filesystem;
|
|
160
|
+
changing directory in one job does not affect the prompt or other jobs.
|
|
161
|
+
Interactive commands such as `edit`, `more`, and `less` cannot run in the
|
|
162
|
+
background. Closing the shell cancels unfinished jobs. Headless `-c` execution
|
|
163
|
+
waits for background jobs before exiting; its exit status is the last submitted
|
|
164
|
+
foreground command's status (or zero for a background launch).
|
|
165
|
+
|
|
166
|
+
Engine callers provide a lifetime for background tasks explicitly:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
async with session.background():
|
|
170
|
+
await session.execute("sleep 1 &", receive)
|
|
171
|
+
await session.wait_jobs()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Leaving this context cancels unfinished jobs. `Session.jobs` maps IDs to `Job`
|
|
175
|
+
objects with `source`, `result`, and `done`; launch and completion events have
|
|
176
|
+
kind `job`. Background completion does not change `Session.last_status`.
|
|
177
|
+
|
|
125
178
|
## Find files
|
|
126
179
|
|
|
127
180
|
```sh
|
|
@@ -147,7 +200,8 @@ directory, or absolute when the starting path is absolute), and `type` (`file`,
|
|
|
147
200
|
are listed without following them. Errors are reported while traversal continues
|
|
148
201
|
with other entries and starting paths; any error makes the command fail.
|
|
149
202
|
Custom filesystem implementations provide `entry_type()` without following the
|
|
150
|
-
final symbolic link, alongside the existing directory-listing methods.
|
|
203
|
+
final symbolic link, alongside the existing directory-listing methods. Filesystems supporting recursive
|
|
204
|
+
removal also accept `remove(path, recursive=True)`.
|
|
151
205
|
|
|
152
206
|
## Move and copy files
|
|
153
207
|
|
|
@@ -184,6 +238,8 @@ and Home/End jumps to the beginning/end. `q`, Ctrl+Q, or Escape closes the pager
|
|
|
184
238
|
Mouse scrolling also works. Shortcuts appear in the footer.
|
|
185
239
|
|
|
186
240
|
The pager accepts files or pipeline input, formatting Python values for display.
|
|
241
|
+
File arguments are opened and checked before the pager appears; missing or
|
|
242
|
+
unreadable files report an error directly in the shell.
|
|
187
243
|
It streams input into scrollback; closing it stops consuming the pipeline.
|
|
188
244
|
Headless execution reports that an interactive frontend is required. Other
|
|
189
245
|
frontends can handle the `page` event's `PageRequest`, consume its `lines`, mark
|
|
@@ -246,6 +302,9 @@ async def main():
|
|
|
246
302
|
anyio.run(main)
|
|
247
303
|
```
|
|
248
304
|
|
|
305
|
+
Help uses the current transcript or terminal width. Engine callers can set
|
|
306
|
+
`Session.execute(..., output_width=100)` when supplying their own frontend.
|
|
307
|
+
|
|
249
308
|
Commands use Cyclopts to parse typed Python parameters. `--help` (or `-h`)
|
|
250
309
|
and `help COMMAND` write help to the transcript. Invalid arguments produce shell
|
|
251
310
|
errors without exiting the app. All pipeline arguments are validated before any
|
|
@@ -288,6 +347,12 @@ session, so the engine and UI can evolve independently.
|
|
|
288
347
|
|
|
289
348
|
## Browser demo
|
|
290
349
|
|
|
350
|
+
The deployment workflow publishes the demo to https://davidbrochart.github.io/anyshell/.
|
|
351
|
+
GitHub Actions builds and deploys it from `main` using
|
|
352
|
+
`.github/workflows/pages.yml`; it can also be deployed manually from Actions.
|
|
353
|
+
|
|
354
|
+
To run it locally:
|
|
355
|
+
|
|
291
356
|
```sh
|
|
292
357
|
uv run python tools/build_browser.py
|
|
293
358
|
uv run python -m http.server --directory .cache/browser-site 8000
|
|
@@ -328,8 +393,8 @@ Set `project.version` in `pyproject.toml`, update the lockfile with `uv lock`, a
|
|
|
328
393
|
commit the release changes. Push a matching version tag, without a `v` prefix:
|
|
329
394
|
|
|
330
395
|
```sh
|
|
331
|
-
git tag 0.
|
|
332
|
-
git push origin 0.
|
|
396
|
+
git tag 0.2.0
|
|
397
|
+
git push origin 0.2.0
|
|
333
398
|
```
|
|
334
399
|
|
|
335
400
|
The `release.yml` workflow checks that the tag matches the package version,
|
|
@@ -27,6 +27,14 @@ editor, run `uv run --extra anytext anyshell --prompt-position bottom` or constr
|
|
|
27
27
|
|
|
28
28
|
The widget provides a scrollable transcript, Up/Down history, Tab completion for
|
|
29
29
|
command names, options, and unquoted paths, Ctrl+C cancellation, and Ctrl+Q to quit the demo.
|
|
30
|
+
Drag across command output to select text. Ctrl+C copies selected output when no
|
|
31
|
+
foreground command is running; Ctrl+Shift+C copies it while a command is running.
|
|
32
|
+
Mouse selection and copying also work in the pager. Browser copying uses the
|
|
33
|
+
browser clipboard when permission is available.
|
|
34
|
+
|
|
35
|
+
The prompt is replaced by a blank cursor line while a foreground command runs
|
|
36
|
+
and returns when it finishes
|
|
37
|
+
or is cancelled with Ctrl+C. Background commands return the prompt immediately.
|
|
30
38
|
Tab first extends the shared prefix when several completions match. If no more
|
|
31
39
|
characters can be filled in, Tab opens a menu beside the cursor. Use Up/Down or
|
|
32
40
|
Tab to select, Enter to insert, and Escape to dismiss; typing also dismisses it.
|
|
@@ -48,7 +56,8 @@ cat notes.txt |
|
|
|
48
56
|
count
|
|
49
57
|
```
|
|
50
58
|
|
|
51
|
-
Unquoted newlines separate commands, which execute in order.
|
|
59
|
+
Unquoted newlines and semicolons separate commands, which execute in order.
|
|
60
|
+
For example, `sleep 1; sleep 1` waits twice in sequence. A backslash followed
|
|
52
61
|
by a newline joins lines without inserting a character. Newlines inside quotes
|
|
53
62
|
remain part of the argument. All commands are parsed and validated before any
|
|
54
63
|
runs; a runtime failure does not prevent later commands from running, and the
|
|
@@ -97,14 +106,58 @@ is `None`. `ls -a` (`--all`) includes hidden entries, and flags combine as `-la`
|
|
|
97
106
|
`touch PATH...` creates missing files or updates modification times without
|
|
98
107
|
changing existing contents. Existing directories can also be touched.
|
|
99
108
|
`rm PATH...` removes files, continuing with remaining paths if one fails.
|
|
109
|
+
`rm -r DIRECTORY...` removes directory trees, including hidden entries.
|
|
110
|
+
`rm -f` ignores missing paths and accepts no operands; combine the flags as
|
|
111
|
+
`rm -rf DIRECTORY...`. Force does not suppress other errors. Recursive removal
|
|
112
|
+
does not follow symbolic links and cannot remove the workspace root.
|
|
100
113
|
`rmdir DIRECTORY...` removes empty directories and rejects files and nonempty
|
|
101
114
|
directories; its completion suggests only directories.
|
|
102
115
|
|
|
103
116
|
This is an initial shell language: quoting, multiline commands, and pipes are supported. Bash scripts,
|
|
104
|
-
redirection, environment expansion, glob expansion, process execution,
|
|
105
|
-
|
|
117
|
+
redirection, environment expansion, glob expansion, process execution, foregrounding or suspending jobs, and persistent history
|
|
118
|
+
are not implemented. Commands are trusted Python
|
|
106
119
|
code, not a sandbox. Browser filesystem changes are in-memory and reset on reload.
|
|
107
120
|
|
|
121
|
+
## Background commands
|
|
122
|
+
|
|
123
|
+
Append `&` to run a command or pipeline in the background and return to the prompt:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
sleep 30 &
|
|
127
|
+
cat notes.txt | grep Python &
|
|
128
|
+
jobs
|
|
129
|
+
wait 1
|
|
130
|
+
kill 1
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`jobs` lists job IDs and states. `wait ID` waits for a job; `wait` waits for all
|
|
134
|
+
jobs still in the job list. `kill ID` cancels a job cooperatively. Ctrl+C cancels the
|
|
135
|
+
foreground command (including `wait`), leaving background jobs running. Job
|
|
136
|
+
output appears in the transcript, with launch and completion notifications.
|
|
137
|
+
Completed jobs are shown once by `jobs`, then removed from the job list.
|
|
138
|
+
Job IDs are not reused during the session.
|
|
139
|
+
|
|
140
|
+
An unquoted `&` also separates commands: `sleep 30 & echo ready`. Quote or escape
|
|
141
|
+
it to pass a literal ampersand. `&&` is not supported. Each job inherits the
|
|
142
|
+
working directory and command registry at launch and shares the filesystem;
|
|
143
|
+
changing directory in one job does not affect the prompt or other jobs.
|
|
144
|
+
Interactive commands such as `edit`, `more`, and `less` cannot run in the
|
|
145
|
+
background. Closing the shell cancels unfinished jobs. Headless `-c` execution
|
|
146
|
+
waits for background jobs before exiting; its exit status is the last submitted
|
|
147
|
+
foreground command's status (or zero for a background launch).
|
|
148
|
+
|
|
149
|
+
Engine callers provide a lifetime for background tasks explicitly:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
async with session.background():
|
|
153
|
+
await session.execute("sleep 1 &", receive)
|
|
154
|
+
await session.wait_jobs()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Leaving this context cancels unfinished jobs. `Session.jobs` maps IDs to `Job`
|
|
158
|
+
objects with `source`, `result`, and `done`; launch and completion events have
|
|
159
|
+
kind `job`. Background completion does not change `Session.last_status`.
|
|
160
|
+
|
|
108
161
|
## Find files
|
|
109
162
|
|
|
110
163
|
```sh
|
|
@@ -130,7 +183,8 @@ directory, or absolute when the starting path is absolute), and `type` (`file`,
|
|
|
130
183
|
are listed without following them. Errors are reported while traversal continues
|
|
131
184
|
with other entries and starting paths; any error makes the command fail.
|
|
132
185
|
Custom filesystem implementations provide `entry_type()` without following the
|
|
133
|
-
final symbolic link, alongside the existing directory-listing methods.
|
|
186
|
+
final symbolic link, alongside the existing directory-listing methods. Filesystems supporting recursive
|
|
187
|
+
removal also accept `remove(path, recursive=True)`.
|
|
134
188
|
|
|
135
189
|
## Move and copy files
|
|
136
190
|
|
|
@@ -167,6 +221,8 @@ and Home/End jumps to the beginning/end. `q`, Ctrl+Q, or Escape closes the pager
|
|
|
167
221
|
Mouse scrolling also works. Shortcuts appear in the footer.
|
|
168
222
|
|
|
169
223
|
The pager accepts files or pipeline input, formatting Python values for display.
|
|
224
|
+
File arguments are opened and checked before the pager appears; missing or
|
|
225
|
+
unreadable files report an error directly in the shell.
|
|
170
226
|
It streams input into scrollback; closing it stops consuming the pipeline.
|
|
171
227
|
Headless execution reports that an interactive frontend is required. Other
|
|
172
228
|
frontends can handle the `page` event's `PageRequest`, consume its `lines`, mark
|
|
@@ -229,6 +285,9 @@ async def main():
|
|
|
229
285
|
anyio.run(main)
|
|
230
286
|
```
|
|
231
287
|
|
|
288
|
+
Help uses the current transcript or terminal width. Engine callers can set
|
|
289
|
+
`Session.execute(..., output_width=100)` when supplying their own frontend.
|
|
290
|
+
|
|
232
291
|
Commands use Cyclopts to parse typed Python parameters. `--help` (or `-h`)
|
|
233
292
|
and `help COMMAND` write help to the transcript. Invalid arguments produce shell
|
|
234
293
|
errors without exiting the app. All pipeline arguments are validated before any
|
|
@@ -271,6 +330,12 @@ session, so the engine and UI can evolve independently.
|
|
|
271
330
|
|
|
272
331
|
## Browser demo
|
|
273
332
|
|
|
333
|
+
The deployment workflow publishes the demo to https://davidbrochart.github.io/anyshell/.
|
|
334
|
+
GitHub Actions builds and deploys it from `main` using
|
|
335
|
+
`.github/workflows/pages.yml`; it can also be deployed manually from Actions.
|
|
336
|
+
|
|
337
|
+
To run it locally:
|
|
338
|
+
|
|
274
339
|
```sh
|
|
275
340
|
uv run python tools/build_browser.py
|
|
276
341
|
uv run python -m http.server --directory .cache/browser-site 8000
|
|
@@ -311,8 +376,8 @@ Set `project.version` in `pyproject.toml`, update the lockfile with `uv lock`, a
|
|
|
311
376
|
commit the release changes. Push a matching version tag, without a `v` prefix:
|
|
312
377
|
|
|
313
378
|
```sh
|
|
314
|
-
git tag 0.
|
|
315
|
-
git push origin 0.
|
|
379
|
+
git tag 0.2.0
|
|
380
|
+
git push origin 0.2.0
|
|
316
381
|
```
|
|
317
382
|
|
|
318
383
|
The `release.yml` workflow checks that the tag matches the package version,
|
|
@@ -53,7 +53,7 @@ class BrowserShell(ShellApp):
|
|
|
53
53
|
session = Session(
|
|
54
54
|
MemoryFileSystem(
|
|
55
55
|
{
|
|
56
|
-
"readme.txt": "Welcome to
|
|
56
|
+
"readme.txt": "Welcome to anyshell!\n",
|
|
57
57
|
"notes/fruits.txt": "pear\napple\nbanana\napricot\n",
|
|
58
58
|
}
|
|
59
59
|
)
|
|
@@ -16,15 +16,29 @@ const proxies = [];
|
|
|
16
16
|
try {
|
|
17
17
|
const terminal = new Terminal({ cursorBlink: true, fontSize: 15,
|
|
18
18
|
fontFamily: "ui-monospace, monospace", allowProposedApi: true,
|
|
19
|
-
theme: { background: "#0c0e14" } });
|
|
19
|
+
customGlyphs: true, scrollback: 0, theme: { background: "#0c0e14" } });
|
|
20
20
|
terminal.loadAddon(new Unicode11Addon.Unicode11Addon());
|
|
21
21
|
terminal.unicode.activeVersion = "11";
|
|
22
22
|
const fit = new FitAddon.FitAddon();
|
|
23
23
|
terminal.loadAddon(fit);
|
|
24
|
-
|
|
24
|
+
// Fit against an unpadded inner element, not the bordered outer container.
|
|
25
|
+
const terminalScreen = document.querySelector("#terminal-screen");
|
|
26
|
+
terminal.open(terminalScreen);
|
|
27
|
+
// Draw block and box glyphs as shapes so font metrics cannot leave row seams.
|
|
28
|
+
terminal.loadAddon(new CanvasAddon.CanvasAddon());
|
|
29
|
+
// Forward the shell's terminal clipboard writes to the browser clipboard.
|
|
30
|
+
disposables.push(terminal.parser.registerOscHandler(52, (data) => {
|
|
31
|
+
const encoded = data.slice(data.indexOf(";") + 1);
|
|
32
|
+
if (encoded === "?" || !navigator.clipboard?.writeText) return false;
|
|
33
|
+
try {
|
|
34
|
+
const text = new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0)));
|
|
35
|
+
navigator.clipboard.writeText(text).catch(() => {});
|
|
36
|
+
return true;
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}));
|
|
25
39
|
fit.fit();
|
|
26
40
|
observer = new ResizeObserver(() => fit.fit());
|
|
27
|
-
observer.observe(
|
|
41
|
+
observer.observe(terminalScreen);
|
|
28
42
|
const { loadPyodide } = await import("https://cdn.jsdelivr.net/pyodide/v314.0.7/full/pyodide.mjs");
|
|
29
43
|
const pyodide = await loadPyodide();
|
|
30
44
|
window.anyshellDemo = { pyodide, terminal };
|
|
@@ -43,7 +57,7 @@ try {
|
|
|
43
57
|
pyodide.globals.set("browser_ready", () => {
|
|
44
58
|
ready = true;
|
|
45
59
|
resize(terminal.cols, terminal.rows);
|
|
46
|
-
status.textContent = "Running
|
|
60
|
+
status.textContent = "Running";
|
|
47
61
|
terminal.focus();
|
|
48
62
|
});
|
|
49
63
|
const source = await fetch("app.py", { cache: "no-store" });
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
.controls { display: flex; align-items: center; gap: 8px; }
|
|
16
16
|
button, select { color: inherit; background: #252b39; border: 1px solid #46516a; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
|
|
17
17
|
#status { color: #a9c6ff; min-height: 20px; }
|
|
18
|
-
#
|
|
18
|
+
header, #status { flex-shrink: 0; }
|
|
19
|
+
#terminal { display: flex; flex: 1; min-height: 0; padding: 12px; background: #0c0e14; border: 1px solid #343d50; border-radius: 8px; overflow: hidden; }
|
|
20
|
+
#terminal-screen { flex: 1; min-width: 0; min-height: 0; }
|
|
19
21
|
#error { white-space: pre-wrap; overflow: auto; color: #ffb4b4; margin: 0; max-height: 30vh; }
|
|
20
|
-
footer { color: #a9b3c4; font-size: 13px; }
|
|
21
22
|
</style>
|
|
22
23
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
|
|
23
24
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
|
24
25
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.8.0/lib/addon-unicode11.js"></script>
|
|
26
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-canvas@0.7.0/lib/addon-canvas.js"></script>
|
|
25
27
|
</head>
|
|
26
28
|
<body>
|
|
27
29
|
<main>
|
|
@@ -30,9 +32,8 @@
|
|
|
30
32
|
<div class="controls"><label for="prompt-position">Prompt</label><select id="prompt-position"><option value="inline">Inline</option><option value="bottom">Bottom</option></select><button id="reload" type="button">Reload</button></div>
|
|
31
33
|
</header>
|
|
32
34
|
<div id="status" role="status">Loading Python…</div>
|
|
33
|
-
<div id="terminal" aria-label="Anyshell terminal"></div>
|
|
35
|
+
<div id="terminal" aria-label="Anyshell terminal"><div id="terminal-screen"></div></div>
|
|
34
36
|
<pre id="error" role="alert" hidden></pre>
|
|
35
|
-
<footer>Try help, cat readme.txt, or ls | select name | sort. Tab completes; Up/Down recalls history. Ctrl+C cancels; Ctrl+Q quits. Files reset on reload.</footer>
|
|
36
37
|
</main>
|
|
37
38
|
<script type="module" src="browser.js"></script>
|
|
38
39
|
</body>
|
|
@@ -5,6 +5,7 @@ from .engine import (
|
|
|
5
5
|
Context,
|
|
6
6
|
EditRequest,
|
|
7
7
|
Event,
|
|
8
|
+
Job,
|
|
8
9
|
PageRequest,
|
|
9
10
|
Result,
|
|
10
11
|
Session,
|
|
@@ -18,6 +19,7 @@ __all__ = [
|
|
|
18
19
|
"EditRequest",
|
|
19
20
|
"Event",
|
|
20
21
|
"FileSystem",
|
|
22
|
+
"Job",
|
|
21
23
|
"LocalFileSystem",
|
|
22
24
|
"MemoryFileSystem",
|
|
23
25
|
"PageRequest",
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from shutil import get_terminal_size
|
|
8
9
|
|
|
9
10
|
import anyio
|
|
10
11
|
|
|
@@ -41,6 +42,12 @@ def main() -> None:
|
|
|
41
42
|
|
|
42
43
|
async def emit(event: Event) -> None:
|
|
43
44
|
nonlocal long_header
|
|
45
|
+
if event.kind == "job":
|
|
46
|
+
job = event.value
|
|
47
|
+
state = (
|
|
48
|
+
"running" if job.result is None else f"done ({job.result.status})"
|
|
49
|
+
)
|
|
50
|
+
print(f"[{job.id}] {state} {job.source}", file=sys.stderr)
|
|
44
51
|
if event.kind in {"output", "error"}:
|
|
45
52
|
if event.kind == "output" and is_long_file_record(event.value):
|
|
46
53
|
if not long_header:
|
|
@@ -54,7 +61,12 @@ def main() -> None:
|
|
|
54
61
|
)
|
|
55
62
|
|
|
56
63
|
async def execute() -> int:
|
|
57
|
-
|
|
64
|
+
async with session.background():
|
|
65
|
+
result = await session.execute(
|
|
66
|
+
args.command, emit, output_width=get_terminal_size((88, 24)).columns
|
|
67
|
+
)
|
|
68
|
+
await session.wait_jobs()
|
|
69
|
+
return result.status
|
|
58
70
|
|
|
59
71
|
try:
|
|
60
72
|
status = anyio.run(execute, backend=args.backend)
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import math
|
|
7
7
|
import posixpath
|
|
8
8
|
import re
|
|
9
|
-
from contextlib import aclosing
|
|
9
|
+
from contextlib import ExitStack, aclosing
|
|
10
10
|
from fnmatch import fnmatchcase
|
|
11
11
|
from typing import Annotated, Literal
|
|
12
12
|
|
|
@@ -40,7 +40,9 @@ async def help_command(command: str | None = None, *, ctx: Context, stdout):
|
|
|
40
40
|
if command is not None:
|
|
41
41
|
if command not in ctx.session.commands:
|
|
42
42
|
raise ShellError(f"command not found: {command}")
|
|
43
|
-
for line in
|
|
43
|
+
for line in (
|
|
44
|
+
ctx.session.commands[command].help_text(width=ctx.output_width).splitlines()
|
|
45
|
+
):
|
|
44
46
|
await stdout.send(line)
|
|
45
47
|
else:
|
|
46
48
|
for name, entry in sorted(ctx.session.commands.items()):
|
|
@@ -289,18 +291,35 @@ async def write(path: str, *text: str, ctx: Context):
|
|
|
289
291
|
ctx.fs.write_text(ctx.resolve(path), " ".join(text) + "\n")
|
|
290
292
|
|
|
291
293
|
|
|
292
|
-
@builtin("rm", "Remove files
|
|
293
|
-
async def rm(
|
|
294
|
+
@builtin("rm", "Remove files, or directory trees with -r.", standalone=True)
|
|
295
|
+
async def rm(
|
|
296
|
+
*paths: str,
|
|
297
|
+
recursive: Annotated[
|
|
298
|
+
bool,
|
|
299
|
+
Parameter(name=["--recursive", "-r", "-R"], help="Remove directory trees."),
|
|
300
|
+
] = False,
|
|
301
|
+
force: Annotated[
|
|
302
|
+
bool,
|
|
303
|
+
Parameter(name=["--force", "-f"], help="Ignore missing paths and operands."),
|
|
304
|
+
] = False,
|
|
305
|
+
ctx: Context,
|
|
306
|
+
):
|
|
307
|
+
if not paths and not force:
|
|
308
|
+
raise ShellError("missing path")
|
|
294
309
|
errors = []
|
|
295
|
-
for name in
|
|
310
|
+
for name in paths:
|
|
296
311
|
try:
|
|
297
|
-
|
|
312
|
+
if recursive:
|
|
313
|
+
ctx.fs.remove(ctx.resolve(name), recursive=True)
|
|
314
|
+
else:
|
|
315
|
+
ctx.fs.remove(ctx.resolve(name))
|
|
298
316
|
except IsADirectoryError:
|
|
299
317
|
errors.append(
|
|
300
318
|
f"{name!r} is a directory; use rmdir to remove an empty directory"
|
|
301
319
|
)
|
|
302
320
|
except FileNotFoundError:
|
|
303
|
-
|
|
321
|
+
if not force:
|
|
322
|
+
errors.append(f"{name!r}: no such file")
|
|
304
323
|
except OSError as error:
|
|
305
324
|
errors.append(f"{name!r}: {error}")
|
|
306
325
|
await anyio.lowlevel.checkpoint()
|
|
@@ -354,24 +373,36 @@ async def less(*files: str, ctx: Context, stdin):
|
|
|
354
373
|
async def _page(files: tuple[str, ...], ctx: Context, stdin, *, searchable: bool):
|
|
355
374
|
from .presentation import format_value
|
|
356
375
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
376
|
+
with ExitStack() as stack:
|
|
377
|
+
sources = []
|
|
378
|
+
for name in files:
|
|
379
|
+
source = iter(ctx.fs.read_lines(ctx.resolve(name)))
|
|
380
|
+
if (close := getattr(source, "close", None)) is not None:
|
|
381
|
+
stack.callback(close)
|
|
382
|
+
# Opening and reading once catches missing paths, permissions and
|
|
383
|
+
# initial decoding errors before a frontend can display the pager.
|
|
384
|
+
sources.append((next(source, None), source))
|
|
385
|
+
|
|
386
|
+
async def lines():
|
|
387
|
+
if files:
|
|
388
|
+
for first, source in sources:
|
|
389
|
+
if first is not None:
|
|
390
|
+
yield first
|
|
391
|
+
for line in source:
|
|
392
|
+
yield line
|
|
393
|
+
else:
|
|
394
|
+
async for value in stdin:
|
|
395
|
+
yield format_value(value)
|
|
396
|
+
|
|
397
|
+
async with aclosing(lines()) as stream:
|
|
398
|
+
request = PageRequest(
|
|
399
|
+
" · ".join(files) if files else "Pipeline output",
|
|
400
|
+
stream,
|
|
401
|
+
searchable=searchable,
|
|
402
|
+
)
|
|
403
|
+
await ctx.emit(Event("page", request))
|
|
404
|
+
if not request.handled:
|
|
405
|
+
raise ShellError("an interactive frontend is required to page text")
|
|
375
406
|
|
|
376
407
|
|
|
377
408
|
@builtin("edit", "Open a text file in the interactive editor.", standalone=True)
|
|
@@ -388,8 +419,31 @@ async def clear(*, ctx: Context):
|
|
|
388
419
|
|
|
389
420
|
|
|
390
421
|
@builtin("sleep", "Wait asynchronously; Ctrl+C cancels.")
|
|
391
|
-
async def sleep(seconds: float
|
|
422
|
+
async def sleep(seconds: float):
|
|
392
423
|
if not math.isfinite(seconds) or seconds < 0:
|
|
393
424
|
raise ShellError("seconds must be finite and nonnegative")
|
|
394
425
|
await anyio.sleep(seconds)
|
|
395
|
-
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@builtin("jobs", "List background jobs.")
|
|
429
|
+
async def jobs(*, ctx: Context, stdout):
|
|
430
|
+
for job in list(ctx.session.jobs.values()):
|
|
431
|
+
completed = job.result is not None
|
|
432
|
+
state = f"done ({job.result.status})" if completed else "running"
|
|
433
|
+
await stdout.send(f"[{job.id}] {state} {job.source}")
|
|
434
|
+
if completed:
|
|
435
|
+
ctx.session.jobs.pop(job.id, None)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@builtin("wait", "Wait for one or all background jobs.", standalone=True)
|
|
439
|
+
async def wait(job: int | None = None, *, ctx: Context):
|
|
440
|
+
status = await ctx.session.wait_jobs(job)
|
|
441
|
+
if status:
|
|
442
|
+
raise ShellError(f"background job exited with status {status}")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@builtin("kill", "Cancel a background job.", standalone=True)
|
|
446
|
+
async def kill(job: int, *, ctx: Context):
|
|
447
|
+
entry = ctx.session.get_job(job)
|
|
448
|
+
if entry.scope is not None:
|
|
449
|
+
entry.scope.cancel()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Interactive file editor for the optional Anytext frontend."""
|
|
2
2
|
|
|
3
3
|
import anyio
|
|
4
|
-
from anytext import on
|
|
4
|
+
from anytext import events, on
|
|
5
5
|
from anytext.app import ComposeResult
|
|
6
6
|
from anytext.binding import Binding
|
|
7
7
|
from anytext.containers import Vertical
|
|
@@ -12,15 +12,30 @@ from rich.text import Text
|
|
|
12
12
|
from .filesystem import FileSystem
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
class FileTextArea(TextArea):
|
|
16
|
+
"""Consume editor shortcuts in order with edits, before app-wide bindings."""
|
|
17
|
+
|
|
18
|
+
async def on_key(self, event: events.Key) -> None:
|
|
19
|
+
editor = self.query_ancestor(FileEditor)
|
|
20
|
+
for binding in editor.BINDINGS:
|
|
21
|
+
if event.key in binding.key.split(",") and editor.check_action(
|
|
22
|
+
binding.action, ()
|
|
23
|
+
):
|
|
24
|
+
event.stop()
|
|
25
|
+
event.prevent_default()
|
|
26
|
+
await editor.run_action(binding.action)
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
class FileEditor(Vertical):
|
|
16
31
|
"""Edit one UTF-8 file without starting a process or leaving the app."""
|
|
17
32
|
|
|
18
33
|
BINDINGS = [
|
|
19
|
-
Binding("ctrl+s", "save", "Save"
|
|
20
|
-
Binding("ctrl+q", "close_editor", "Close"
|
|
21
|
-
Binding("ctrl+q", "confirm_discard", "Discard and close"
|
|
22
|
-
Binding("escape", "keep_editing", "Keep editing"
|
|
23
|
-
Binding("ctrl+d", "discard", show=False
|
|
34
|
+
Binding("ctrl+s", "save", "Save"),
|
|
35
|
+
Binding("ctrl+q", "close_editor", "Close"),
|
|
36
|
+
Binding("ctrl+q", "confirm_discard", "Discard and close"),
|
|
37
|
+
Binding("escape", "keep_editing", "Keep editing"),
|
|
38
|
+
Binding("ctrl+d", "discard", show=False),
|
|
24
39
|
]
|
|
25
40
|
_confirming_close = var(False, bindings=True)
|
|
26
41
|
|
|
@@ -52,7 +67,7 @@ class FileEditor(Vertical):
|
|
|
52
67
|
def compose(self) -> ComposeResult:
|
|
53
68
|
with Vertical(id="editor-window"):
|
|
54
69
|
yield Static(Text(self.path), id="editor-title")
|
|
55
|
-
yield
|
|
70
|
+
yield FileTextArea(self._original, show_line_numbers=True, id="file-text")
|
|
56
71
|
|
|
57
72
|
def on_mount(self) -> None:
|
|
58
73
|
area = self.query_one(TextArea)
|