asher-cli 1.0.0__tar.gz → 1.0.1__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.
- asher_cli-1.0.1/.github/workflows/bandit.yml +52 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/coverage.yml +1 -1
- {asher_cli-1.0.0 → asher_cli-1.0.1}/CHANGELOG.md +8 -1
- {asher_cli-1.0.0 → asher_cli-1.0.1}/CLAUDE.md +2 -2
- {asher_cli-1.0.0 → asher_cli-1.0.1}/PKG-INFO +2 -2
- {asher_cli-1.0.0 → asher_cli-1.0.1}/README.md +1 -1
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/commands/__init__.py +5 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/history_view.py +62 -5
- {asher_cli-1.0.0 → asher_cli-1.0.1}/pyproject.toml +2 -2
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_history_view.py +117 -1
- {asher_cli-1.0.0 → asher_cli-1.0.1}/uv.lock +1 -1
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/hooks/block-env.ps1 +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/settings.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/release/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/textual/SKILL.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.env.example +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.githooks/pre-push +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/pull_request_template.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/ci.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/claude-code-review.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/claude.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/release.yml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.gitignore +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/launch.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/settings.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/tasks.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/CODEOWNERS +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/LICENSE +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/app.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/__main__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/activity_labels.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/app.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/auth.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/cats.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/commands/base.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/completion.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/connection/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/constants.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/export.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/faults.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/helpers.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/login_flow.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/mcp_bridge.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/mcp_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/monitoring/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/notifications.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/robot_adapters.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/robot_protocol.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/slash-commands/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/ui/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/ui/style.tcss +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/cliff.toml +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/ROADMAP.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/README.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/cat-panel-badges.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/config-persistence.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/desktop-notifications.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/fault-monitoring.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/headless-export.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/history-export.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/tab-completion.md +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/renovate.json +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/requirements.txt +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/test.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/__init__.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/conftest.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_activity_labels.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_app_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_auth.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_auth_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_cats.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_commands_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_completion.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_connection.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_connection_mixin.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_export.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_faults.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_lr5_commands.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_bridge.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_command.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_config.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_missing_robot_commands.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_monitoring.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_new_commands_pilot.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_notifications.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_robot_adapters.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_ui.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_version_command.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/testhelpers.py +0 -0
- {asher_cli-1.0.0 → asher_cli-1.0.1}/watchrun.py +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# This workflow uses actions that are not certified by GitHub.
|
|
2
|
+
# They are provided by a third-party and are governed by
|
|
3
|
+
# separate terms of service, privacy policy, and support
|
|
4
|
+
# documentation.
|
|
5
|
+
|
|
6
|
+
# Bandit is a security linter designed to find common security issues in Python code.
|
|
7
|
+
# This action will run Bandit on your codebase.
|
|
8
|
+
# The results of the scan will be found under the Security tab of your repository.
|
|
9
|
+
|
|
10
|
+
# https://github.com/marketplace/actions/bandit-scan is ISC licensed, by abirismyname
|
|
11
|
+
# https://pypi.org/project/bandit/ is Apache v2.0 licensed, by PyCQA
|
|
12
|
+
|
|
13
|
+
name: Bandit
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [ "main", "release/*" ]
|
|
17
|
+
pull_request:
|
|
18
|
+
# The branches below must be a subset of the branches above
|
|
19
|
+
branches: [ "main" ]
|
|
20
|
+
schedule:
|
|
21
|
+
- cron: '32 0 * * 1'
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
bandit:
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read # for actions/checkout to fetch code
|
|
27
|
+
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
|
28
|
+
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
|
29
|
+
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
33
|
+
- name: Bandit Scan
|
|
34
|
+
uses: shundor/python-bandit-scan@ab1d87dfccc5a0ffab88be3aaac6ffe35c10d6cd
|
|
35
|
+
with: # optional arguments
|
|
36
|
+
# exit with 0, even with results found
|
|
37
|
+
exit_zero: true # optional, default is DEFAULT
|
|
38
|
+
# Github token of the repository (automatically created by Github)
|
|
39
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information.
|
|
40
|
+
# File or directory to run bandit on
|
|
41
|
+
# path: # optional, default is .
|
|
42
|
+
# Report only issues of a given severity level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything)
|
|
43
|
+
# level: # optional, default is UNDEFINED
|
|
44
|
+
# Report only issues of a given confidence level or higher. Can be LOW, MEDIUM or HIGH. Default is UNDEFINED (everything)
|
|
45
|
+
# confidence: # optional, default is UNDEFINED
|
|
46
|
+
# comma-separated list of paths (glob patterns supported) to exclude from scan (note that these are in addition to the excluded paths provided in the config file) (default: .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg)
|
|
47
|
+
# excluded_paths: # optional, default is DEFAULT
|
|
48
|
+
# comma-separated list of test IDs to skip
|
|
49
|
+
# skips: # optional, default is DEFAULT
|
|
50
|
+
# path to a .bandit file that supplies command line arguments
|
|
51
|
+
# ini_path: # optional, default is DEFAULT
|
|
52
|
+
|
|
@@ -18,7 +18,7 @@ jobs:
|
|
|
18
18
|
- name: Run tests with coverage
|
|
19
19
|
run: uv run pytest tests/ --cov=asher --cov-report=lcov --cov-report=term-missing
|
|
20
20
|
- name: Upload to Coveralls
|
|
21
|
-
uses: coverallsapp/github-action@
|
|
21
|
+
uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2
|
|
22
22
|
with:
|
|
23
23
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
24
24
|
path-to-lcov: coverage.lcov
|
|
@@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
Generated by [git-cliff](https://git-cliff.org) from conventional commits.
|
|
9
9
|
|
|
10
|
-
## [
|
|
10
|
+
## [1.0.1] - 2026-08-04
|
|
11
|
+
|
|
12
|
+
### 🚀 Features
|
|
13
|
+
|
|
14
|
+
- *(history)* Copy-to-clipboard + discoverable footer hints
|
|
15
|
+
|
|
16
|
+
## [1.0.0] - 2026-08-02
|
|
11
17
|
|
|
12
18
|
### 🚀 Features
|
|
13
19
|
|
|
@@ -18,6 +24,7 @@ Generated by [git-cliff](https://git-cliff.org) from conventional commits.
|
|
|
18
24
|
|
|
19
25
|
### 📚 Documentation
|
|
20
26
|
|
|
27
|
+
- *(changelog)* Regenerate for next release, trim duplicate README list
|
|
21
28
|
- *(roadmap)* Archive completed sections to docs/roadmap-archive/
|
|
22
29
|
|
|
23
30
|
### 🔧 Miscellaneous
|
|
@@ -46,7 +46,7 @@ asher/
|
|
|
46
46
|
mcp_config.py Claude Desktop config read/write for the /mcp slash command
|
|
47
47
|
mcp_bridge.py asher-mcp-launch console script — keyring-backed pylitterbot MCP launcher
|
|
48
48
|
faults.py check_faults(robot) — model-scoped safety/component fault detection (status enum + per-model attr allowlist incl. LR4 USB power fault; hopper never a fault)
|
|
49
|
-
history_view.py HistoryScreen (ModalScreen) + format_history_rows() — scrollable activity-history pager pushed by the `history` command
|
|
49
|
+
history_view.py HistoryScreen (ModalScreen) + format_history_rows()/format_history_text() — scrollable activity-history pager pushed by the `history` command; `c` copies the full history (plain text) to the clipboard via action_copy_all()
|
|
50
50
|
export.py shared activity-history CSV core + headless export path: build_history_csv(), resolve_dest(), resolve_robot(), _run_headless_export(), ExportError — no Textual imports; both the TUI `export` command and `asher --export` call build_history_csv()
|
|
51
51
|
completion.py pure helpers for command completion: slash popup (slash_matches, enter_completes, render_completion) + inline ghost text (CommandSuggester) — fed by _registry, no Textual imports except the Suggester base class
|
|
52
52
|
__main__.py main() entry point
|
|
@@ -75,7 +75,7 @@ tests/
|
|
|
75
75
|
test_mcp_bridge.py mcp_bridge launcher credential/subprocess handling
|
|
76
76
|
test_mcp_command.py /mcp slash command dispatch
|
|
77
77
|
test_faults.py check_faults() — safety statuses, attribute faults, graceful degradation
|
|
78
|
-
test_history_view.py format_history_rows() + HistoryScreen structure + Pilot push/dismiss
|
|
78
|
+
test_history_view.py format_history_rows()/format_history_text() + HistoryScreen structure, copy-all + Pilot push/dismiss
|
|
79
79
|
test_export.py build_history_csv/resolve_dest/resolve_robot/parse_days (pure) + headless _run_headless_export (no Pilot, mocks _connect_headless)
|
|
80
80
|
test_completion.py slash_matches/enter_completes/render_completion (pure) + Pilot overlay visibility/navigation/accept
|
|
81
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: asher-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API
|
|
5
5
|
Project-URL: Homepage, https://github.com/karanshukla/asher-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/karanshukla/asher-cli
|
|
@@ -70,7 +70,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
|
|
|
70
70
|
- Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
|
|
71
71
|
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
|
|
72
72
|
- Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
|
|
73
|
-
- Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `q`/`Esc`/`Enter` to close
|
|
73
|
+
- Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
|
|
74
74
|
- Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
|
|
75
75
|
- Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/version`, `/mcp on|off|status`, `/exit`
|
|
76
76
|
- Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
|
|
@@ -19,7 +19,7 @@ A Claude Code-style terminal dashboard for monitoring and controlling Litter Rob
|
|
|
19
19
|
- Real-time cycling indicator with elapsed time (`⟳ Cycling M:SS`)
|
|
20
20
|
- Fault & safety monitoring — model-scoped in-panel alerts for cat detected, pinch, motor/position/gas faults (LR5: bonnet/laser/drawer); press `d` to dismiss
|
|
21
21
|
- Cat panel with mode label + status badges (status chip, lock, night light, sleep, wait time) under the art
|
|
22
|
-
- Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `q`/`Esc`/`Enter` to close
|
|
22
|
+
- Scrollable activity-history pager — `history [count|all]` opens a full-screen, paginated view (arrow keys, `Page Up`/`Page Down`, `Home`/`End`); `c` copies the whole history to the clipboard; `q`/`Esc`/`Enter` to close
|
|
23
23
|
- Commands: `clean`, `status`, `info`, `lock`, `unlock`, `sleep`, `wake`, `night-light on|off|auto`, `night-light-brightness`, `panel-brightness <low|medium|high>` (LR4/LR5), `wait-time`, `power on|off`, `rename`, `insight`, `sleep-schedule`, plus LR5 extras (`privacy`, `volume`, `camera-audio`, `drawer-reset`), `history [count|all]`, `export [days|month]`, `help`, `quit`
|
|
24
24
|
- Slash commands for app management: `/login`, `/logout`, `/robots`, `/robot <index|name>`, `/pets`, `/pet <index|name>`, `/cat on|off|colour <hex>`, `/refresh [seconds|off]`, `/config`, `/notify on|off|sound on|off|test`, `/version`, `/mcp on|off|status`, `/exit`
|
|
25
25
|
- Slash-command tab completion — type `/` and a Claude Code-style overlay lists matching commands; `↑`/`↓` to move, `Tab` or `Enter` to accept, `Esc` to dismiss
|
|
@@ -1522,6 +1522,11 @@ class CommandsMixin:
|
|
|
1522
1522
|
return False
|
|
1523
1523
|
|
|
1524
1524
|
def on_key(self, event) -> None: # type: ignore[override]
|
|
1525
|
+
# A pushed modal (history pager, login modal, …) owns its keys. The base
|
|
1526
|
+
# screen's `focused` still points at #cmd-input under a modal, so
|
|
1527
|
+
# has_focus alone would let us hijack arrows/special keys from the overlay.
|
|
1528
|
+
if len(self.screen_stack) > 1: # type: ignore[attr-defined]
|
|
1529
|
+
return
|
|
1525
1530
|
cmd_input = self.query_one("#cmd-input", Input) # type: ignore[attr-defined]
|
|
1526
1531
|
if not cmd_input.has_focus:
|
|
1527
1532
|
return
|
|
@@ -13,7 +13,7 @@ Layout (top to bottom):
|
|
|
13
13
|
* summary line — a compact event-type breakdown (cleans, cat visits, …)
|
|
14
14
|
* column header — pinned ``TIME`` / ``EVENT`` row aligned with the data
|
|
15
15
|
* scroll area — one styled row per event, newest first, fixed-width timestamps
|
|
16
|
-
* footer — navigation
|
|
16
|
+
* footer — navigation, copy, and close hints (``c`` copies the whole history to the clipboard)
|
|
17
17
|
|
|
18
18
|
Timestamps are rendered at a fixed width so the event column lines up
|
|
19
19
|
vertically regardless of an event's age — every event shows its date.
|
|
@@ -47,6 +47,13 @@ _TS_WIDTH = 11
|
|
|
47
47
|
# raw strings still fall into "other" rather than mislabeling.
|
|
48
48
|
_ALERT_KEYWORDS = ("offline", "fault", "pinch", "power", "motor", "timing")
|
|
49
49
|
|
|
50
|
+
# Footer hint line shown at the bottom of the pager and restored after the
|
|
51
|
+
# post-copy confirmation flash.
|
|
52
|
+
_FOOTER_HINT = (
|
|
53
|
+
" \u2191/\u2193 scroll PgUp/PgDn page Home/End jump "
|
|
54
|
+
"c copy q \u00b7 Esc \u00b7 Enter close"
|
|
55
|
+
)
|
|
56
|
+
|
|
50
57
|
|
|
51
58
|
def _localize(ts: object) -> datetime | None:
|
|
52
59
|
"""Coerce an activity timestamp to a local-tz-aware datetime, or ``None``."""
|
|
@@ -85,6 +92,32 @@ def format_history_rows(acts: list[Activity], pets: list[Any] | None = None) ->
|
|
|
85
92
|
return rows
|
|
86
93
|
|
|
87
94
|
|
|
95
|
+
def format_history_text(
|
|
96
|
+
acts: list[Activity], pets: list[Any] | None = None, robot_name: str = ""
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Plain-text rendering of the history, for clipboard copying.
|
|
99
|
+
|
|
100
|
+
Self-contained: title line, event-type summary, column header, and one row
|
|
101
|
+
per event (newest first) — mirrors what the on-screen pager shows so a pasted
|
|
102
|
+
copy reads the same as the view.
|
|
103
|
+
"""
|
|
104
|
+
count = len(acts)
|
|
105
|
+
lines = [
|
|
106
|
+
f"Activity history \u2014 {robot_name} {_plural(count, 'event')} {_date_range(acts)}"
|
|
107
|
+
]
|
|
108
|
+
if not count:
|
|
109
|
+
lines.append("No activity history available.")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
lines.append(_summarize(acts, pets))
|
|
112
|
+
lines.append("")
|
|
113
|
+
lines.append(f" {'TIME'.ljust(_TS_WIDTH)} EVENT")
|
|
114
|
+
for act in acts:
|
|
115
|
+
ts_str = _ts_str(_localize(getattr(act, "timestamp", None)))
|
|
116
|
+
label, _ = format_activity(act, pets)
|
|
117
|
+
lines.append(f" {ts_str.ljust(_TS_WIDTH)} {label}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
88
121
|
def _category(label: str) -> str:
|
|
89
122
|
"""Bucket a translated label into a short summary name."""
|
|
90
123
|
low = label.lower()
|
|
@@ -147,7 +180,7 @@ class HistoryScreen(ModalScreen[None]):
|
|
|
147
180
|
|
|
148
181
|
#history-meta {
|
|
149
182
|
dock: top;
|
|
150
|
-
height:
|
|
183
|
+
height: 2;
|
|
151
184
|
background: #161b22;
|
|
152
185
|
border-bottom: solid #30363d;
|
|
153
186
|
padding: 0 2;
|
|
@@ -165,11 +198,11 @@ class HistoryScreen(ModalScreen[None]):
|
|
|
165
198
|
|
|
166
199
|
#history-footer {
|
|
167
200
|
dock: bottom;
|
|
168
|
-
height:
|
|
201
|
+
height: 2;
|
|
169
202
|
background: #161b22;
|
|
170
203
|
border-top: solid #30363d;
|
|
171
204
|
padding: 0 2;
|
|
172
|
-
color: #
|
|
205
|
+
color: #c9d1d9;
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
#history-scroll {
|
|
@@ -188,6 +221,7 @@ class HistoryScreen(ModalScreen[None]):
|
|
|
188
221
|
|
|
189
222
|
BINDINGS = [
|
|
190
223
|
Binding("escape,q,enter", "dismiss", "Close", show=False, priority=True),
|
|
224
|
+
Binding("c", "copy_all", "Copy all", show=False, priority=True),
|
|
191
225
|
]
|
|
192
226
|
|
|
193
227
|
def __init__(self, acts: list[Activity], pets: list[Any] | None, robot_name: str) -> None:
|
|
@@ -195,6 +229,7 @@ class HistoryScreen(ModalScreen[None]):
|
|
|
195
229
|
self._acts = acts
|
|
196
230
|
self._pets = pets
|
|
197
231
|
self._robot_name = robot_name
|
|
232
|
+
self._restore_timer: Any = None
|
|
198
233
|
|
|
199
234
|
def compose(self) -> ComposeResult:
|
|
200
235
|
count = len(self._acts)
|
|
@@ -209,13 +244,35 @@ class HistoryScreen(ModalScreen[None]):
|
|
|
209
244
|
else:
|
|
210
245
|
yield Static("No activity history available for this robot.", id="history-empty")
|
|
211
246
|
yield Static(
|
|
212
|
-
|
|
247
|
+
_FOOTER_HINT,
|
|
213
248
|
id="history-footer",
|
|
214
249
|
)
|
|
215
250
|
|
|
216
251
|
def on_mount(self) -> None:
|
|
217
252
|
self.query_one("#history-scroll", ScrollableContainer).focus()
|
|
218
253
|
|
|
254
|
+
def action_copy_all(self) -> None:
|
|
255
|
+
"""Copy the full history (plain text) to the OS clipboard.
|
|
256
|
+
|
|
257
|
+
Flashes the footer with a confirmation, then restores the hint line.
|
|
258
|
+
``copy_to_clipboard`` uses OSC52 under the hood and silently no-ops on
|
|
259
|
+
terminals that don't support it, so we optimistically report success.
|
|
260
|
+
"""
|
|
261
|
+
text = format_history_text(self._acts, self._pets, self._robot_name)
|
|
262
|
+
try:
|
|
263
|
+
self.app.copy_to_clipboard(text)
|
|
264
|
+
except Exception:
|
|
265
|
+
message = " Clipboard unavailable in this terminal"
|
|
266
|
+
else:
|
|
267
|
+
message = f" ✓ Copied {_plural(len(self._acts), 'event')} to clipboard"
|
|
268
|
+
self.query_one("#history-footer", Static).update(message)
|
|
269
|
+
if self._restore_timer is not None:
|
|
270
|
+
self._restore_timer.stop()
|
|
271
|
+
self._restore_timer = self.set_timer(1.5, self._restore_footer)
|
|
272
|
+
|
|
273
|
+
def _restore_footer(self) -> None:
|
|
274
|
+
self.query_one("#history-footer", Static).update(_FOOTER_HINT)
|
|
275
|
+
|
|
219
276
|
def _title_line(self, count: int) -> str:
|
|
220
277
|
return f" Activity history — {self._robot_name} {_plural(count, 'event')} {_date_range(self._acts)}"
|
|
221
278
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "asher-cli"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.1"
|
|
4
4
|
description = "Terminal dashboard for Litter Robot (LR3/LR4/LR5) via the Whisker cloud API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -100,7 +100,7 @@ exclude = ["asher/slash-commands"]
|
|
|
100
100
|
# ── bump-my-version ───────────────────────────────────────────────────────────
|
|
101
101
|
|
|
102
102
|
[tool.bumpversion]
|
|
103
|
-
current_version = "1.0.
|
|
103
|
+
current_version = "1.0.1"
|
|
104
104
|
commit = true
|
|
105
105
|
tag = true
|
|
106
106
|
tag_name = "v{new_version}"
|
|
@@ -21,7 +21,7 @@ from textual.app import App
|
|
|
21
21
|
from textual.containers import ScrollableContainer
|
|
22
22
|
from textual.widgets import Static
|
|
23
23
|
|
|
24
|
-
from asher.history_view import HistoryScreen, format_history_rows
|
|
24
|
+
from asher.history_view import HistoryScreen, format_history_rows, format_history_text
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _act(action: Any, *, timestamp: datetime | None = None) -> SimpleNamespace:
|
|
@@ -112,6 +112,35 @@ class TestFormatHistoryRows:
|
|
|
112
112
|
assert format_history_rows([]) == []
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
# ── format_history_text (clipboard payload) ─────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestFormatHistoryText:
|
|
119
|
+
def test_plain_text_includes_title_summary_and_rows(self):
|
|
120
|
+
acts = [
|
|
121
|
+
_act(
|
|
122
|
+
LitterBoxStatus.CLEAN_CYCLE_COMPLETE,
|
|
123
|
+
timestamp=datetime(2026, 6, 14, 14, 0, tzinfo=timezone.utc),
|
|
124
|
+
),
|
|
125
|
+
_act(
|
|
126
|
+
LitterBoxStatus.READY, timestamp=datetime(2026, 6, 14, 10, 0, tzinfo=timezone.utc)
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
text = format_history_text(acts, None, "TestBot")
|
|
130
|
+
assert "Activity history" in text
|
|
131
|
+
assert "TestBot" in text
|
|
132
|
+
assert "2 events" in text
|
|
133
|
+
assert "TIME" in text and "EVENT" in text
|
|
134
|
+
assert "Clean cycle complete" in text
|
|
135
|
+
assert "Ready" in text
|
|
136
|
+
assert "\n" in text
|
|
137
|
+
|
|
138
|
+
def test_empty_history_text(self):
|
|
139
|
+
text = format_history_text([], None, "TestBot")
|
|
140
|
+
assert "0 events" in text
|
|
141
|
+
assert "No activity history" in text
|
|
142
|
+
|
|
143
|
+
|
|
115
144
|
# ── HistoryScreen ────────────────────────────────────────────────────────────
|
|
116
145
|
|
|
117
146
|
|
|
@@ -236,6 +265,44 @@ async def test_history_screen_enter_dismisses():
|
|
|
236
265
|
assert app.screen is not screen
|
|
237
266
|
|
|
238
267
|
|
|
268
|
+
@pytest.mark.asyncio
|
|
269
|
+
async def test_history_screen_c_copies_all_events_to_clipboard(monkeypatch):
|
|
270
|
+
"""`c` copies the full history to the clipboard and flashes the footer."""
|
|
271
|
+
captured: dict[str, str] = {}
|
|
272
|
+
monkeypatch.setattr(
|
|
273
|
+
App,
|
|
274
|
+
"copy_to_clipboard",
|
|
275
|
+
lambda self, text: captured.setdefault("text", text),
|
|
276
|
+
)
|
|
277
|
+
screen = _screen()
|
|
278
|
+
app = _ShellApp(screen)
|
|
279
|
+
async with app.run_test() as pilot:
|
|
280
|
+
await pilot.pause()
|
|
281
|
+
await pilot.press("c")
|
|
282
|
+
await pilot.pause()
|
|
283
|
+
# The whole history was sent to the clipboard.
|
|
284
|
+
assert "TestBot" in captured["text"]
|
|
285
|
+
assert "Clean cycle complete" in captured["text"]
|
|
286
|
+
# Footer flashes a confirmation.
|
|
287
|
+
assert "Copied" in str(screen.query_one("#history-footer", Static).render())
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.asyncio
|
|
291
|
+
async def test_history_screen_copy_footer_restores_after_flash(monkeypatch):
|
|
292
|
+
monkeypatch.setattr(App, "copy_to_clipboard", lambda self, text: None)
|
|
293
|
+
screen = _screen()
|
|
294
|
+
app = _ShellApp(screen)
|
|
295
|
+
async with app.run_test() as pilot:
|
|
296
|
+
await pilot.pause()
|
|
297
|
+
await pilot.press("c")
|
|
298
|
+
await pilot.pause()
|
|
299
|
+
# Advance past the 1.5s restore timer.
|
|
300
|
+
await pilot.pause(delay=1.6)
|
|
301
|
+
footer = str(screen.query_one("#history-footer", Static).render())
|
|
302
|
+
assert "Copied" not in footer
|
|
303
|
+
assert "scroll" in footer # back to the hint line
|
|
304
|
+
|
|
305
|
+
|
|
239
306
|
@pytest.mark.asyncio
|
|
240
307
|
async def test_history_command_pushes_screen_with_default_limit():
|
|
241
308
|
"""End-to-end: typing `history` fetches with the default and pushes the pager."""
|
|
@@ -274,3 +341,52 @@ async def test_history_command_pushes_screen_with_default_limit():
|
|
|
274
341
|
title = app.screen.query_one("#history-title", Static)
|
|
275
342
|
assert "TestBot" in str(title.render())
|
|
276
343
|
assert "1 event" in str(title.render())
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@pytest.mark.asyncio
|
|
347
|
+
async def test_history_screen_arrow_keys_scroll_through_asher_app():
|
|
348
|
+
"""Regression: AsherApp.on_key used to hijack ↑/↓ for command-history nav even
|
|
349
|
+
while the history pager modal was open (the base screen's `focused` still
|
|
350
|
+
pointed at #cmd-input, so a has_focus guard lied). Arrow keys must scroll the
|
|
351
|
+
pager instead.
|
|
352
|
+
"""
|
|
353
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
354
|
+
|
|
355
|
+
from asher.app import AsherApp
|
|
356
|
+
from asher.robot_adapters import LR3Adapter
|
|
357
|
+
|
|
358
|
+
robot = MagicMock()
|
|
359
|
+
robot.name = "TestBot"
|
|
360
|
+
robot.is_online = True
|
|
361
|
+
robot.status = MagicMock(value="Ready")
|
|
362
|
+
robot.last_seen = datetime.now(timezone.utc)
|
|
363
|
+
robot.get_activity_history = AsyncMock(
|
|
364
|
+
return_value=[
|
|
365
|
+
SimpleNamespace(
|
|
366
|
+
timestamp=datetime(2026, 7, i % 28 + 1, 12, 0, tzinfo=timezone.utc),
|
|
367
|
+
action=LitterBoxStatus.CLEAN_CYCLE_COMPLETE,
|
|
368
|
+
)
|
|
369
|
+
for i in range(40)
|
|
370
|
+
]
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
app = AsherApp()
|
|
374
|
+
app._robot = robot
|
|
375
|
+
app._adapter = LR3Adapter(robot)
|
|
376
|
+
app._account = MagicMock()
|
|
377
|
+
app._account.disconnect = AsyncMock()
|
|
378
|
+
app._pets = []
|
|
379
|
+
app._connect_worker = lambda **kwargs: None # type: ignore[method-assign]
|
|
380
|
+
|
|
381
|
+
async with app.run_test(size=(80, 24)) as pilot:
|
|
382
|
+
await pilot.pause()
|
|
383
|
+
await pilot.click("#cmd-input")
|
|
384
|
+
await pilot.press("h", "i", "s", "t", "o", "r", "y")
|
|
385
|
+
await pilot.press("enter")
|
|
386
|
+
await pilot.pause()
|
|
387
|
+
scroll = app.screen.query_one("#history-scroll", ScrollableContainer)
|
|
388
|
+
before = scroll.scroll_y
|
|
389
|
+
await pilot.press("down", "down", "down")
|
|
390
|
+
await pilot.pause()
|
|
391
|
+
# Arrow keys reached the pager, not the app's command-history nav.
|
|
392
|
+
assert scroll.scroll_y > before
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|