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.
Files changed (94) hide show
  1. asher_cli-1.0.1/.github/workflows/bandit.yml +52 -0
  2. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/coverage.yml +1 -1
  3. {asher_cli-1.0.0 → asher_cli-1.0.1}/CHANGELOG.md +8 -1
  4. {asher_cli-1.0.0 → asher_cli-1.0.1}/CLAUDE.md +2 -2
  5. {asher_cli-1.0.0 → asher_cli-1.0.1}/PKG-INFO +2 -2
  6. {asher_cli-1.0.0 → asher_cli-1.0.1}/README.md +1 -1
  7. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/commands/__init__.py +5 -0
  8. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/history_view.py +62 -5
  9. {asher_cli-1.0.0 → asher_cli-1.0.1}/pyproject.toml +2 -2
  10. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_history_view.py +117 -1
  11. {asher_cli-1.0.0 → asher_cli-1.0.1}/uv.lock +1 -1
  12. {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/hooks/block-env.ps1 +0 -0
  13. {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/settings.json +0 -0
  14. {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/pylitterbot-ref/SKILL.md +0 -0
  15. {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/release/SKILL.md +0 -0
  16. {asher_cli-1.0.0 → asher_cli-1.0.1}/.claude/skills/textual/SKILL.md +0 -0
  17. {asher_cli-1.0.0 → asher_cli-1.0.1}/.env.example +0 -0
  18. {asher_cli-1.0.0 → asher_cli-1.0.1}/.githooks/pre-push +0 -0
  19. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/pull_request_template.md +0 -0
  20. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/ci.yml +0 -0
  21. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/claude-code-review.yml +0 -0
  22. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/claude.yml +0 -0
  23. {asher_cli-1.0.0 → asher_cli-1.0.1}/.github/workflows/release.yml +0 -0
  24. {asher_cli-1.0.0 → asher_cli-1.0.1}/.gitignore +0 -0
  25. {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/launch.json +0 -0
  26. {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/settings.json +0 -0
  27. {asher_cli-1.0.0 → asher_cli-1.0.1}/.vscode/tasks.json +0 -0
  28. {asher_cli-1.0.0 → asher_cli-1.0.1}/CODEOWNERS +0 -0
  29. {asher_cli-1.0.0 → asher_cli-1.0.1}/LICENSE +0 -0
  30. {asher_cli-1.0.0 → asher_cli-1.0.1}/app.py +0 -0
  31. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/__init__.py +0 -0
  32. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/__main__.py +0 -0
  33. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/activity_labels.py +0 -0
  34. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/app.py +0 -0
  35. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/auth.py +0 -0
  36. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/cats.py +0 -0
  37. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/commands/base.py +0 -0
  38. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/completion.py +0 -0
  39. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/config.py +0 -0
  40. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/connection/__init__.py +0 -0
  41. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/constants.py +0 -0
  42. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/export.py +0 -0
  43. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/faults.py +0 -0
  44. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/helpers.py +0 -0
  45. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/login_flow.py +0 -0
  46. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/mcp_bridge.py +0 -0
  47. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/mcp_config.py +0 -0
  48. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/monitoring/__init__.py +0 -0
  49. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/notifications.py +0 -0
  50. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/robot_adapters.py +0 -0
  51. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/robot_protocol.py +0 -0
  52. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/slash-commands/__init__.py +0 -0
  53. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/ui/__init__.py +0 -0
  54. {asher_cli-1.0.0 → asher_cli-1.0.1}/asher/ui/style.tcss +0 -0
  55. {asher_cli-1.0.0 → asher_cli-1.0.1}/cliff.toml +0 -0
  56. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/ROADMAP.md +0 -0
  57. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/README.md +0 -0
  58. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/cat-panel-badges.md +0 -0
  59. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/config-persistence.md +0 -0
  60. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/desktop-notifications.md +0 -0
  61. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/fault-monitoring.md +0 -0
  62. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/headless-export.md +0 -0
  63. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/history-export.md +0 -0
  64. {asher_cli-1.0.0 → asher_cli-1.0.1}/docs/roadmap-archive/tab-completion.md +0 -0
  65. {asher_cli-1.0.0 → asher_cli-1.0.1}/renovate.json +0 -0
  66. {asher_cli-1.0.0 → asher_cli-1.0.1}/requirements.txt +0 -0
  67. {asher_cli-1.0.0 → asher_cli-1.0.1}/test.py +0 -0
  68. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/__init__.py +0 -0
  69. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/conftest.py +0 -0
  70. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_activity_labels.py +0 -0
  71. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_app_pilot.py +0 -0
  72. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_auth.py +0 -0
  73. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_auth_pilot.py +0 -0
  74. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_cats.py +0 -0
  75. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_commands_pilot.py +0 -0
  76. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_completion.py +0 -0
  77. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_config.py +0 -0
  78. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_connection.py +0 -0
  79. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_connection_mixin.py +0 -0
  80. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_export.py +0 -0
  81. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_faults.py +0 -0
  82. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_lr5_commands.py +0 -0
  83. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_bridge.py +0 -0
  84. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_command.py +0 -0
  85. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_mcp_config.py +0 -0
  86. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_missing_robot_commands.py +0 -0
  87. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_monitoring.py +0 -0
  88. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_new_commands_pilot.py +0 -0
  89. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_notifications.py +0 -0
  90. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_robot_adapters.py +0 -0
  91. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_ui.py +0 -0
  92. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/test_version_command.py +0 -0
  93. {asher_cli-1.0.0 → asher_cli-1.0.1}/tests/testhelpers.py +0 -0
  94. {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@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2
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
- ## [Unreleased]
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.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 + close hints
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: 1;
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: 1;
201
+ height: 2;
169
202
  background: #161b22;
170
203
  border-top: solid #30363d;
171
204
  padding: 0 2;
172
- color: #484f58;
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
- " ↑/↓ scroll PgUp/PgDn page Home/End jump q · Esc · Enter close",
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.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.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
@@ -217,7 +217,7 @@ wheels = [
217
217
 
218
218
  [[package]]
219
219
  name = "asher-cli"
220
- version = "1.0.0"
220
+ version = "1.0.1"
221
221
  source = { editable = "." }
222
222
  dependencies = [
223
223
  { name = "keyring" },
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