riched 0.6.0__tar.gz → 0.6.2__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 (32) hide show
  1. {riched-0.6.0 → riched-0.6.2}/AGENTS.md +5 -5
  2. {riched-0.6.0 → riched-0.6.2}/PKG-INFO +10 -8
  3. {riched-0.6.0 → riched-0.6.2}/README.md +9 -7
  4. {riched-0.6.0 → riched-0.6.2}/pyproject.toml +1 -1
  5. {riched-0.6.0 → riched-0.6.2}/src/riched/app.py +15 -10
  6. {riched-0.6.0 → riched-0.6.2}/src/riched/bindings.yaml +4 -1
  7. {riched-0.6.0 → riched-0.6.2}/src/riched/formatting.py +106 -1
  8. {riched-0.6.0 → riched-0.6.2}/src/riched/keybindings.py +79 -47
  9. {riched-0.6.0 → riched-0.6.2}/src/riched/screens.py +3 -3
  10. {riched-0.6.0 → riched-0.6.2}/tests/helpers.py +7 -7
  11. {riched-0.6.0 → riched-0.6.2}/tests/test_command_palette.py +37 -2
  12. {riched-0.6.0 → riched-0.6.2}/tests/test_footer.py +7 -7
  13. {riched-0.6.0 → riched-0.6.2}/tests/test_formatting.py +114 -0
  14. {riched-0.6.0 → riched-0.6.2}/tests/test_markdown_preview.py +2 -2
  15. {riched-0.6.0 → riched-0.6.2}/uv.lock +1 -1
  16. {riched-0.6.0 → riched-0.6.2}/.gitignore +0 -0
  17. {riched-0.6.0 → riched-0.6.2}/.python-version +0 -0
  18. {riched-0.6.0 → riched-0.6.2}/src/riched/__init__.py +0 -0
  19. {riched-0.6.0 → riched-0.6.2}/src/riched/cli.py +0 -0
  20. {riched-0.6.0 → riched-0.6.2}/src/riched/editor.py +0 -0
  21. {riched-0.6.0 → riched-0.6.2}/src/riched/quick_open.py +0 -0
  22. {riched-0.6.0 → riched-0.6.2}/src/riched/settings.py +0 -0
  23. {riched-0.6.0 → riched-0.6.2}/src/riched/syntax.py +0 -0
  24. {riched-0.6.0 → riched-0.6.2}/tests/__init__.py +0 -0
  25. {riched-0.6.0 → riched-0.6.2}/tests/runner.py +0 -0
  26. {riched-0.6.0 → riched-0.6.2}/tests/test_cli.py +0 -0
  27. {riched-0.6.0 → riched-0.6.2}/tests/test_dirty_buffers.py +0 -0
  28. {riched-0.6.0 → riched-0.6.2}/tests/test_editor_shortcuts.py +0 -0
  29. {riched-0.6.0 → riched-0.6.2}/tests/test_file_io.py +0 -0
  30. {riched-0.6.0 → riched-0.6.2}/tests/test_file_tree.py +0 -0
  31. {riched-0.6.0 → riched-0.6.2}/tests/test_quick_open.py +0 -0
  32. {riched-0.6.0 → riched-0.6.2}/tests/test_syntax.py +0 -0
@@ -1,7 +1,7 @@
1
1
  # Repository Guidelines
2
2
 
3
- - check if a hotkey is bounded with Ghostty built-in hotkey before adding any new key binding, ask user for fallback hotkey in addition to the target hotkey. Add startup detection for the target hotkey, display fallback if Ghostty does not unbound the target hotkey.
4
- - riched only supports Ghostty on macOS. For every new hotkey or fallback, verify the real Ghostty/Textual terminal path, not only Textual Pilot's synthetic key name. Use `ghostty +list-keybinds --default` to check Ghostty interception, then inspect Textual's ANSI mapping with `uv run python` when the key uses Ctrl punctuation or other ambiguous terminal sequences. Add e2e coverage for both the requested human spelling and the real Textual key name when they differ, for example `ctrl+right_square_bracket` for real terminal Ctrl+]. Do not use fallbacks that collapse to `escape` or another existing semantic key; Ctrl+[ is Escape in terminal input and must not be used as a distinct fallback.
3
+ - check if a hotkey is bounded with Ghostty built-in hotkey before adding any new key binding. Add startup detection for Ghostty-conflicted hotkeys and document any alternative hotkeys as alternatives.
4
+ - riched only supports Ghostty on macOS. For every new hotkey or alternative, verify the real Ghostty/Textual terminal path, not only Textual Pilot's synthetic key name. Use `ghostty +list-keybinds --default` to check Ghostty interception, then inspect Textual's ANSI mapping with `uv run python` when the key uses Ctrl punctuation or other ambiguous terminal sequences. Add e2e coverage for both the requested human spelling and the real Textual key name when they differ, for example `ctrl+right_square_bracket` for real terminal Ctrl+]. Do not use alternatives that collapse to `escape` or another existing semantic key; Ctrl+[ is Escape in terminal input and must not be used as a distinct alternative.
5
5
 
6
6
  ## Project Structure & Module Organization
7
7
 
@@ -20,15 +20,15 @@ This repository is a uv-managed Python project for `riched`, a Textual TUI edito
20
20
 
21
21
  Use Python 3.12-compatible code with type hints, `from __future__ import annotations`, `Path`, and small focused helpers. Use 4-space indentation. Keep constants in `UPPER_SNAKE_CASE`, classes in `PascalCase`, functions and methods in `snake_case`, and private helpers prefixed with `_`. Prefer Textual widgets and events over manual terminal control. Keep comments brief and only for non-obvious behavior.
22
22
 
23
- Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays one user-facing key per binding, preferring `cmd` aliases over `super`.
23
+ Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays user-facing keys from the binding metadata, preferring `cmd` aliases over `super`.
24
24
 
25
- When changing app-level key bindings, test both file-open and no-buffer/directory-start states. For Command-key regressions, verify both Textual names (`cmd+...` and terminal-normalized `super+...`) because Pilot can pass for synthetic keys while a real terminal path or missing app state still breaks. For fallback-key regressions, verify the real Ghostty/Textual key name in addition to the displayed fallback label; Pilot can synthesize impossible keys such as `ctrl+[` even though real Ghostty/macOS terminal input arrives as `escape`. Do not patch Command-key behavior by adding Python-side hardcoded fallbacks; add aliases to `bindings.yaml` and e2e coverage instead.
25
+ When changing app-level key bindings, test both file-open and no-buffer/directory-start states. For Command-key regressions, verify both Textual names (`cmd+...` and terminal-normalized `super+...`) because Pilot can pass for synthetic keys while a real terminal path or missing app state still breaks. For alternative-key regressions, verify the real Ghostty/Textual key name in addition to the displayed alternative label; Pilot can synthesize impossible keys such as `ctrl+[` even though real Ghostty/macOS terminal input arrives as `escape`. Do not patch Command-key behavior by adding Python-side hardcoded alternatives; add aliases to `bindings.yaml` and e2e coverage instead.
26
26
 
27
27
  ## Testing Guidelines
28
28
 
29
29
  Only add end-to-end tests in `tests/`; do not create unit tests. Tests use Textual's Pilot harness and should be named `test_<behavior>`. Keep each test focused on user-visible behavior such as file open/save, dirty prompts, file tree switching, quick-open search/indexing, or syntax highlighting. Use temporary files as existing tests do.
30
30
 
31
- For hotkey e2e tests, cover the YAML-declared preferred key, terminal-normalized aliases, and fallback key. If the real terminal key name differs from the user-facing label, cover the real Textual name too. Before accepting a fallback, confirm it can be produced distinctly by Ghostty on macOS; Ctrl punctuation is especially risky because some combinations are control bytes with legacy meanings.
31
+ For hotkey e2e tests, cover the YAML-declared preferred key, terminal-normalized aliases, and any alternative keys. If the real terminal key name differs from the user-facing label, cover the real Textual name too. Before accepting an alternative, confirm it can be produced distinctly by Ghostty on macOS; Ctrl punctuation is especially risky because some combinations are control bytes with legacy meanings.
32
32
 
33
33
  ## Commit & Pull Request Guidelines
34
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riched
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: A Rich library powered editor(hence the name riched) that implements VS Code keybindings in TUI
5
5
  Project-URL: Repository, https://github.com/hoishing/riched
6
6
  Requires-Python: >=3.12
@@ -90,16 +90,18 @@ Editor shortcuts:
90
90
  The `⌥⇧F` shortcut formats the current buffer with external formatter CLIs:
91
91
 
92
92
  - Python files use `ruff`.
93
+ - TOML files use `taplo`.
94
+ - XML files use `prettier` with `@prettier/plugin-xml`.
93
95
  - Other supported source/config formats use `prettier`.
94
96
 
95
- Install both tools and make sure they are on `PATH` before using the shortcut:
97
+ Install the required tools before using the shortcut:
96
98
 
97
99
  ```sh
98
- bun add --global prettier
99
- uv tool install ruff
100
+ bun add --global prettier @prettier/plugin-xml
101
+ brew install ruff taplo
100
102
  ```
101
103
 
102
- Files unsupported by both tools are left unchanged and `riched` shows a warning.
104
+ Files unsupported by these tools are left unchanged and `riched` shows a warning.
103
105
 
104
106
  ## Limitations
105
107
 
@@ -111,9 +113,9 @@ Files unsupported by both tools are left unchanged and `riched` shows a warning.
111
113
 
112
114
  - some hotkeys are bounded by Ghostty by default. eg. `⌘Enter`, `⌘⇧Enter`, `⌘⇧V`, `⌘[`, `⌘]`, `⌘⇧P`, `⌘W`, `⌘Q`...etc
113
115
  - `riched` detects conflicted shortcuts at startup.
114
- - Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config.
116
+ - Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config, unless the command has another available alternative.
115
117
  - The key bindings popup marks conflicted shortcuts with `⚠️` and shows a Ghostty config reminder.
116
- - `⌘⇧P` always has the `F1` fallback
118
+ - `⌘⇧P` and `F1` are alternative hotkeys for the command palette.
117
119
 
118
120
  ### Unbind Ghostty default keybindings
119
121
 
@@ -134,7 +136,7 @@ keybind = super+enter=unbind
134
136
  keybind = super+shift+enter=unbind
135
137
  ```
136
138
 
137
- `⌘⇧P` already works through the `F1` fallback. To use `⌘⇧P` directly, also add:
139
+ The command palette can still be opened with `F1`. To use `⌘⇧P` directly, also add:
138
140
 
139
141
  ```conf
140
142
  keybind = super+shift+p=unbind
@@ -79,16 +79,18 @@ Editor shortcuts:
79
79
  The `⌥⇧F` shortcut formats the current buffer with external formatter CLIs:
80
80
 
81
81
  - Python files use `ruff`.
82
+ - TOML files use `taplo`.
83
+ - XML files use `prettier` with `@prettier/plugin-xml`.
82
84
  - Other supported source/config formats use `prettier`.
83
85
 
84
- Install both tools and make sure they are on `PATH` before using the shortcut:
86
+ Install the required tools before using the shortcut:
85
87
 
86
88
  ```sh
87
- bun add --global prettier
88
- uv tool install ruff
89
+ bun add --global prettier @prettier/plugin-xml
90
+ brew install ruff taplo
89
91
  ```
90
92
 
91
- Files unsupported by both tools are left unchanged and `riched` shows a warning.
93
+ Files unsupported by these tools are left unchanged and `riched` shows a warning.
92
94
 
93
95
  ## Limitations
94
96
 
@@ -100,9 +102,9 @@ Files unsupported by both tools are left unchanged and `riched` shows a warning.
100
102
 
101
103
  - some hotkeys are bounded by Ghostty by default. eg. `⌘Enter`, `⌘⇧Enter`, `⌘⇧V`, `⌘[`, `⌘]`, `⌘⇧P`, `⌘W`, `⌘Q`...etc
102
104
  - `riched` detects conflicted shortcuts at startup.
103
- - Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config.
105
+ - Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config, unless the command has another available alternative.
104
106
  - The key bindings popup marks conflicted shortcuts with `⚠️` and shows a Ghostty config reminder.
105
- - `⌘⇧P` always has the `F1` fallback
107
+ - `⌘⇧P` and `F1` are alternative hotkeys for the command palette.
106
108
 
107
109
  ### Unbind Ghostty default keybindings
108
110
 
@@ -123,7 +125,7 @@ keybind = super+enter=unbind
123
125
  keybind = super+shift+enter=unbind
124
126
  ```
125
127
 
126
- `⌘⇧P` already works through the `F1` fallback. To use `⌘⇧P` directly, also add:
128
+ The command palette can still be opened with `F1`. To use `⌘⇧P` directly, also add:
127
129
 
128
130
  ```conf
129
131
  keybind = super+shift+p=unbind
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "riched"
3
- version = "0.6.0"
3
+ version = "0.6.2"
4
4
  description = "A Rich library powered editor(hence the name riched) that implements VS Code keybindings in TUI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -30,7 +30,7 @@ from .keybindings import (
30
30
  DEFAULT_BINDINGS,
31
31
  app_binding_display_key,
32
32
  display_key_with_symbols,
33
- ghostty_app_hotkey_conflicts,
33
+ ghostty_conflicted_hotkey_triggers,
34
34
  )
35
35
  from .quick_open import (
36
36
  MAX_QUICK_OPEN_INDEX_FILES,
@@ -66,10 +66,7 @@ class RichedFooter(Footer):
66
66
  """Footer that shows app bindings and omits blocked bindings."""
67
67
 
68
68
  def _hide_binding(self, binding: Binding) -> bool:
69
- return (
70
- not self.app.get_key_display(binding)
71
- and binding.action in self.app._ghostty_app_hotkey_conflicts
72
- )
69
+ return not self.app.get_key_display(binding)
73
70
 
74
71
  def compose(self) -> ComposeResult:
75
72
  if not self._bindings_ready:
@@ -304,7 +301,9 @@ class RichedApp(App):
304
301
  self._quick_open_limited = False
305
302
  self._quick_open_generation = 0
306
303
  self._quick_open_screen: QuickOpenScreen | None = None
307
- self._ghostty_app_hotkey_conflicts = ghostty_app_hotkey_conflicts()
304
+ self._ghostty_conflicted_hotkey_triggers = (
305
+ ghostty_conflicted_hotkey_triggers()
306
+ )
308
307
 
309
308
  def _save_theme(self, theme: str) -> None:
310
309
  try:
@@ -343,7 +342,7 @@ class RichedApp(App):
343
342
  key = app_binding_display_key(
344
343
  binding.action,
345
344
  binding.key,
346
- conflicted_actions=self._ghostty_app_hotkey_conflicts,
345
+ conflicted_triggers=self._ghostty_conflicted_hotkey_triggers,
347
346
  )
348
347
  return display_key_with_symbols(key)
349
348
 
@@ -622,7 +621,9 @@ class RichedApp(App):
622
621
 
623
622
  def action_show_keys_popup(self) -> None:
624
623
  self.push_screen(
625
- KeysHelpScreen(conflicted_actions=self._ghostty_app_hotkey_conflicts)
624
+ KeysHelpScreen(
625
+ conflicted_triggers=self._ghostty_conflicted_hotkey_triggers
626
+ )
626
627
  )
627
628
 
628
629
  async def action_toggle_markdown_preview(self) -> None:
@@ -681,9 +682,13 @@ class RichedApp(App):
681
682
 
682
683
  result = format_text(self.path, editor.text)
683
684
  if result.missing_tool is not None:
685
+ detail = (
686
+ "Install it and ensure Prettier can load it."
687
+ if result.missing_tool.startswith("@")
688
+ else "Install it and ensure it is on PATH."
689
+ )
684
690
  self.notify(
685
- f"`{result.missing_tool}` is required for formatting. "
686
- "Install it and ensure it is on PATH.",
691
+ f"`{result.missing_tool}` is required for formatting. {detail}",
687
692
  severity="warning",
688
693
  )
689
694
  return
@@ -7,7 +7,9 @@ app:
7
7
  description: Command palette
8
8
  key: cmd+shift+p,shift+cmd+p,super+shift+p,shift+super+p,f1
9
9
  preferred_key: cmd+shift+p,shift+cmd+p,super+shift+p,shift+super+p
10
- fallback_key: f1
10
+ display_keys:
11
+ - cmd+shift+p
12
+ - f1
11
13
  - name: toggle_file_tree
12
14
  description: Toggle file tree
13
15
  key: cmd+b,super+b
@@ -40,6 +42,7 @@ editor:
40
42
  preferred_key: alt+shift+f,shift+alt+f
41
43
  action: format_document
42
44
  description: Format document
45
+ category: App
43
46
  - key: cmd+enter,super+enter
44
47
  preferred_key: cmd+enter,super+enter
45
48
  action: insert_line_below
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  import subprocess
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
@@ -9,6 +10,12 @@ from typing import Callable
9
10
 
10
11
  FORMAT_TIMEOUT_SECONDS = 10.0
11
12
  PYTHON_SUFFIXES = {".py", ".pyi"}
13
+ TOML_SUFFIXES = {".toml"}
14
+ XML_SUFFIXES = {".xml"}
15
+ PRETTIER_XML_PLUGIN = "@prettier/plugin-xml"
16
+ PRETTIER_XML_PLUGIN_ENTRYPOINT = Path(
17
+ "@prettier/plugin-xml/src/plugin.js"
18
+ )
12
19
 
13
20
 
14
21
  @dataclass(frozen=True)
@@ -37,6 +44,10 @@ def format_text(
37
44
  suffix = path.suffix.lower()
38
45
  if suffix in PYTHON_SUFFIXES:
39
46
  return _format_with_ruff(path, text, run_command, find_binary)
47
+ if suffix in TOML_SUFFIXES:
48
+ return _format_with_taplo(path, text, run_command, find_binary)
49
+ if suffix in XML_SUFFIXES:
50
+ return _format_with_prettier_xml(path, text, run_command, find_binary)
40
51
  return _format_with_prettier(path, text, run_command, find_binary)
41
52
 
42
53
 
@@ -79,14 +90,60 @@ def _format_with_prettier(
79
90
  )
80
91
 
81
92
 
93
+ def _format_with_taplo(
94
+ path: Path,
95
+ text: str,
96
+ run_command: RunCommand,
97
+ find_binary: FindBinary,
98
+ ) -> FormatResult:
99
+ taplo = find_binary("taplo")
100
+ if taplo is None:
101
+ return FormatResult(missing_tool="taplo")
102
+ return _run_formatter(
103
+ [taplo, "format", "--stdin-filepath", str(path), "-"],
104
+ text,
105
+ run_command,
106
+ )
107
+
108
+
109
+ def _format_with_prettier_xml(
110
+ path: Path,
111
+ text: str,
112
+ run_command: RunCommand,
113
+ find_binary: FindBinary,
114
+ ) -> FormatResult:
115
+ prettier = find_binary("prettier")
116
+ if prettier is None:
117
+ return FormatResult(missing_tool="prettier")
118
+
119
+ plugin_arg = f"--plugin={_prettier_xml_plugin_path(prettier)}"
120
+ file_info = _prettier_file_info(prettier, path, run_command, plugin_arg)
121
+ if file_info is False:
122
+ return FormatResult(unsupported=True)
123
+ if isinstance(file_info, FormatResult):
124
+ return file_info
125
+
126
+ return _run_formatter(
127
+ [prettier, plugin_arg, "--stdin-filepath", str(path)],
128
+ text,
129
+ run_command,
130
+ missing_tool=PRETTIER_XML_PLUGIN,
131
+ )
132
+
133
+
82
134
  def _prettier_file_info(
83
135
  prettier: str,
84
136
  path: Path,
85
137
  run_command: RunCommand,
138
+ plugin_arg: str | None = None,
86
139
  ) -> bool | FormatResult:
140
+ command = [prettier]
141
+ if plugin_arg is not None:
142
+ command.append(plugin_arg)
143
+ command.extend(["--file-info", str(path)])
87
144
  try:
88
145
  result = run_command(
89
- [prettier, "--file-info", str(path)],
146
+ command,
90
147
  stdout=subprocess.PIPE,
91
148
  stderr=subprocess.PIPE,
92
149
  text=True,
@@ -99,6 +156,11 @@ def _prettier_file_info(
99
156
  return FormatResult(error=f"Prettier failed: {exc}")
100
157
 
101
158
  if result.returncode != 0:
159
+ if plugin_arg is not None and _missing_node_package(
160
+ result,
161
+ PRETTIER_XML_PLUGIN,
162
+ ):
163
+ return FormatResult(missing_tool=PRETTIER_XML_PLUGIN)
102
164
  message = _command_error_message("Prettier", result)
103
165
  return FormatResult(error=message)
104
166
 
@@ -110,10 +172,39 @@ def _prettier_file_info(
110
172
  return bool(info.get("inferredParser")) and not bool(info.get("ignored"))
111
173
 
112
174
 
175
+ def _prettier_xml_plugin_path(prettier: str) -> str:
176
+ for directory in _node_module_roots(prettier):
177
+ plugin = directory / PRETTIER_XML_PLUGIN_ENTRYPOINT
178
+ if plugin.exists():
179
+ return os.fspath(plugin)
180
+ return PRETTIER_XML_PLUGIN
181
+
182
+
183
+ def _node_module_roots(prettier: str) -> list[Path]:
184
+ roots: list[Path] = []
185
+ cwd_root = Path.cwd() / "node_modules"
186
+ roots.append(cwd_root)
187
+
188
+ try:
189
+ resolved_prettier = Path(prettier).resolve()
190
+ except OSError:
191
+ resolved_prettier = Path(prettier)
192
+ for parent in resolved_prettier.parents:
193
+ if parent.name == "node_modules":
194
+ roots.append(parent)
195
+ break
196
+
197
+ bun_global = Path.home() / ".bun/install/global/node_modules"
198
+ roots.append(bun_global)
199
+ return list(dict.fromkeys(roots))
200
+
201
+
113
202
  def _run_formatter(
114
203
  command: list[str],
115
204
  text: str,
116
205
  run_command: RunCommand,
206
+ *,
207
+ missing_tool: str | None = None,
117
208
  ) -> FormatResult:
118
209
  tool = Path(command[0]).name.capitalize()
119
210
  try:
@@ -132,6 +223,8 @@ def _run_formatter(
132
223
  return FormatResult(error=f"{tool} failed: {exc}")
133
224
 
134
225
  if result.returncode != 0:
226
+ if missing_tool is not None and _missing_node_package(result, missing_tool):
227
+ return FormatResult(missing_tool=missing_tool)
135
228
  return FormatResult(error=_command_error_message(tool, result))
136
229
  return FormatResult(text=result.stdout)
137
230
 
@@ -141,3 +234,15 @@ def _command_error_message(tool: str, result: subprocess.CompletedProcess[str])
141
234
  if not detail:
142
235
  detail = f"exit code {result.returncode}"
143
236
  return f"{tool} failed: {detail}"
237
+
238
+
239
+ def _missing_node_package(
240
+ result: subprocess.CompletedProcess[str],
241
+ package: str,
242
+ ) -> bool:
243
+ detail = f"{result.stderr}\n{result.stdout}"
244
+ return package in detail and (
245
+ "Cannot find package" in detail
246
+ or "Cannot find module" in detail
247
+ or "MODULE_NOT_FOUND" in detail
248
+ )
@@ -22,6 +22,15 @@ KEY_MODIFIER_SYMBOLS = {
22
22
  "option": "⌥",
23
23
  "shift": "⇧",
24
24
  }
25
+ KEY_MODIFIER_ORDER = {
26
+ "ctrl": 0,
27
+ "control": 0,
28
+ "super": 1,
29
+ "cmd": 1,
30
+ "alt": 2,
31
+ "option": 2,
32
+ "shift": 3,
33
+ }
25
34
 
26
35
 
27
36
  @dataclass(frozen=True)
@@ -78,18 +87,23 @@ def _build_item_bindings(items: list[dict[str, Any]]) -> list[Binding]:
78
87
 
79
88
 
80
89
  def binding_help_groups(
81
- conflicted_actions: set[str] | None = None,
90
+ conflicted_triggers: set[str] | None = None,
82
91
  ) -> list[KeyBindingHelpGroup]:
83
- conflicted_actions = conflicted_actions or set()
92
+ conflicted_triggers = conflicted_triggers or set()
84
93
  groups: list[KeyBindingHelpGroup] = []
85
94
 
95
+ categorized_app_items = [
96
+ item
97
+ for item in BINDING_SPEC["editor"]
98
+ if item.get("help", True) and item.get("category") == "App"
99
+ ]
86
100
  app_rows = tuple(
87
101
  (
88
102
  _help_display_key(item),
89
103
  item.get("description") or item["name"],
90
- _help_conflicted(item, conflicted_actions),
104
+ _help_conflicted(item, conflicted_triggers),
91
105
  )
92
- for item in BINDING_SPEC["app"]["commands"]
106
+ for item in [*BINDING_SPEC["app"]["commands"], *categorized_app_items]
93
107
  )
94
108
  groups.append(KeyBindingHelpGroup("App", app_rows))
95
109
 
@@ -97,10 +111,10 @@ def binding_help_groups(
97
111
  (
98
112
  _help_display_key(item),
99
113
  item.get("description") or item["action"],
100
- _help_conflicted(item, conflicted_actions),
114
+ _help_conflicted(item, conflicted_triggers),
101
115
  )
102
116
  for item in BINDING_SPEC["editor"]
103
- if item.get("help", True)
117
+ if item.get("help", True) and item.get("category") != "App"
104
118
  )
105
119
  groups.append(KeyBindingHelpGroup("Editor", editor_rows))
106
120
 
@@ -122,40 +136,45 @@ def app_binding_display_key(
122
136
  action: str,
123
137
  key: str,
124
138
  *,
125
- conflicted_actions: set[str] | None = None,
139
+ conflicted_triggers: set[str] | None = None,
126
140
  ) -> str:
141
+ conflicted_triggers = conflicted_triggers or set()
127
142
  item = _app_command_or_none(action)
128
143
  if item is None:
129
144
  return display_key(key)
130
- if conflicted_actions is not None and action in conflicted_actions:
131
- fallback = item.get("fallback_key")
132
- if fallback:
133
- return display_key(fallback)
134
- return ""
135
- return display_key(item.get("preferred_key") or key)
145
+ for display_key_candidate in _item_display_keys(item):
146
+ display = display_key(display_key_candidate)
147
+ if not _display_key_conflicted(display, conflicted_triggers):
148
+ return display
149
+ return ""
136
150
 
137
151
 
138
152
  def _help_conflicted(
139
153
  item: dict[str, Any],
140
- conflicted_actions: set[str],
154
+ conflicted_triggers: set[str],
141
155
  ) -> bool:
142
- action = item.get("name") or item.get("action")
143
- return bool(
144
- action in conflicted_actions
145
- and item.get("preferred_key")
146
- and not item.get("fallback_key")
156
+ return any(
157
+ _display_key_conflicted(display_key(key), conflicted_triggers)
158
+ for key in _item_display_keys(item)
147
159
  )
148
160
 
149
161
 
150
162
  def _help_display_key(item: dict[str, Any]) -> str:
151
- preferred = item.get("preferred_key")
152
- fallback = item.get("fallback_key")
153
- if preferred and fallback:
154
- return (
155
- f"{display_key_with_symbols(display_key(preferred))} / "
156
- f"{display_key_with_symbols(display_key(fallback))}"
157
- )
158
- return display_key_with_symbols(display_key(item["key"]))
163
+ return " / ".join(
164
+ display_key_with_symbols(display_key(key))
165
+ for key in _item_display_keys(item)
166
+ )
167
+
168
+
169
+ def _item_display_keys(item: dict[str, Any]) -> list[str]:
170
+ display_keys = item.get("display_keys")
171
+ if isinstance(display_keys, list):
172
+ return [str(key) for key in display_keys]
173
+ return [str(item.get("preferred_key") or item["key"])]
174
+
175
+
176
+ def _display_key_conflicted(key: str, conflicted_triggers: set[str]) -> bool:
177
+ return bool(_hotkey_conflict_triggers(key) & conflicted_triggers)
159
178
 
160
179
 
161
180
  def _app_command_or_none(action: str) -> dict[str, Any] | None:
@@ -209,7 +228,7 @@ def _display_base_key(key: str) -> str:
209
228
  return base_key.title()
210
229
 
211
230
 
212
- def ghostty_app_hotkey_conflicts(
231
+ def ghostty_conflicted_hotkey_triggers(
213
232
  env: Mapping[str, str] | None = None,
214
233
  run_command: Callable[..., CompletedProcess[str]] = run,
215
234
  find_binary: Callable[[str], str | None] = which,
@@ -217,11 +236,11 @@ def ghostty_app_hotkey_conflicts(
217
236
  triggers = _ghostty_conflict_triggers()
218
237
  env = environ if env is None else env
219
238
  if env.get("TERM_PROGRAM") != "ghostty" and env.get("TERM") != "xterm-ghostty":
220
- return set(triggers.values())
239
+ return set(triggers)
221
240
 
222
241
  ghostty = _ghostty_binary(find_binary)
223
242
  if ghostty is None:
224
- return set(triggers.values())
243
+ return set(triggers)
225
244
 
226
245
  try:
227
246
  result = run_command(
@@ -233,14 +252,14 @@ def ghostty_app_hotkey_conflicts(
233
252
  check=False,
234
253
  )
235
254
  except Exception:
236
- return set(triggers.values())
255
+ return set(triggers)
237
256
  if result.returncode != 0:
238
- return set(triggers.values())
257
+ return set(triggers)
239
258
  conflicted_triggers = _ghostty_config_conflicted_triggers(
240
259
  result.stdout,
241
- set(triggers),
260
+ triggers,
242
261
  )
243
- return {triggers[trigger] for trigger in conflicted_triggers}
262
+ return conflicted_triggers
244
263
 
245
264
 
246
265
  def ghostty_markdown_preview_hotkey_conflicted(
@@ -248,7 +267,7 @@ def ghostty_markdown_preview_hotkey_conflicted(
248
267
  run_command: Callable[..., CompletedProcess[str]] = run,
249
268
  find_binary: Callable[[str], str | None] = which,
250
269
  ) -> bool:
251
- return "toggle_markdown_preview" in ghostty_app_hotkey_conflicts(
270
+ return "super+shift+v" in ghostty_conflicted_hotkey_triggers(
252
271
  env,
253
272
  run_command,
254
273
  find_binary,
@@ -262,25 +281,38 @@ def _ghostty_binary(find_binary: Callable[[str], str | None]) -> str | None:
262
281
  return find_binary("ghostty")
263
282
 
264
283
 
265
- def _ghostty_conflict_triggers() -> dict[str, str]:
266
- triggers: dict[str, str] = {}
284
+ def _ghostty_conflict_triggers() -> set[str]:
285
+ triggers: set[str] = set()
267
286
  for item in [*APP_COMMANDS, *BINDING_SPEC["editor"]]:
268
287
  preferred_key = item.get("preferred_key")
269
288
  if not preferred_key:
270
289
  continue
271
- action = item.get("name") or item.get("action")
272
- if not action:
273
- continue
274
290
  for candidate in preferred_key.split(","):
275
- trigger = candidate.strip()
276
- if "cmd" in trigger:
277
- continue
278
- if "super" in trigger or "alt" in trigger:
279
- triggers[trigger] = action
280
- break
291
+ triggers.update(_hotkey_conflict_triggers(candidate.strip()))
281
292
  return triggers
282
293
 
283
294
 
295
+ def _hotkey_conflict_triggers(key: str) -> set[str]:
296
+ parts = key.split("+")
297
+ if "cmd" in parts:
298
+ return {
299
+ _canonical_hotkey_trigger(
300
+ "+".join("super" if part == "cmd" else part for part in parts)
301
+ )
302
+ }
303
+ if "super" in parts or "alt" in parts:
304
+ return {_canonical_hotkey_trigger(key)}
305
+ return set()
306
+
307
+
308
+ def _canonical_hotkey_trigger(key: str) -> str:
309
+ parts = key.split("+")
310
+ modifiers = [part for part in parts[:-1] if part in KEY_MODIFIER_ORDER]
311
+ base_parts = [part for part in parts if part not in KEY_MODIFIER_ORDER]
312
+ ordered_modifiers = sorted(modifiers, key=lambda part: KEY_MODIFIER_ORDER[part])
313
+ return "+".join([*ordered_modifiers, *base_parts])
314
+
315
+
284
316
  def _ghostty_config_conflicted_triggers(
285
317
  config: str,
286
318
  triggers: set[str],
@@ -296,7 +328,7 @@ def _ghostty_config_conflicted_triggers(
296
328
  if key.strip() != "keybind":
297
329
  continue
298
330
  trigger, _, action = value.partition("=")
299
- trigger = trigger.strip()
331
+ trigger = _canonical_hotkey_trigger(trigger.strip())
300
332
  if trigger not in triggers:
301
333
  continue
302
334
  if action.strip() != "unbind":
@@ -354,16 +354,16 @@ class KeysHelpScreen(_DismissOnCloseScreen, ModalScreen[None]):
354
354
  }
355
355
  """
356
356
 
357
- def __init__(self, conflicted_actions: set[str] | None = None) -> None:
357
+ def __init__(self, conflicted_triggers: set[str] | None = None) -> None:
358
358
  super().__init__()
359
- self._conflicted_actions = conflicted_actions or set()
359
+ self._conflicted_triggers = conflicted_triggers or set()
360
360
 
361
361
  def compose(self) -> ComposeResult:
362
362
  has_warnings = False
363
363
  with Vertical(id="dialog"):
364
364
  yield Label("Key Bindings")
365
365
  with Vertical(id="bindings-list"):
366
- for group in binding_help_groups(self._conflicted_actions):
366
+ for group in binding_help_groups(self._conflicted_triggers):
367
367
  with Vertical(classes="binding-group"):
368
368
  yield Static(group.title, classes="binding-title")
369
369
  for key, description, conflicted in group.rows:
@@ -46,7 +46,7 @@ def _make_app(
46
46
  path: Path,
47
47
  root: Path | None = None,
48
48
  markdown_preview_hotkey_conflicted: bool | None = None,
49
- ghostty_app_hotkey_conflicts: set[str] | None = None,
49
+ ghostty_conflicted_hotkey_triggers: set[str] | None = None,
50
50
  ):
51
51
  cls = type(
52
52
  "ConfiguredRichedApp",
@@ -54,15 +54,15 @@ def _make_app(
54
54
  {"BINDINGS": mod.build_bindings()},
55
55
  )
56
56
  app = cls(path, root or path.parent)
57
- if ghostty_app_hotkey_conflicts is not None:
58
- app._ghostty_app_hotkey_conflicts = ghostty_app_hotkey_conflicts
57
+ if ghostty_conflicted_hotkey_triggers is not None:
58
+ app._ghostty_conflicted_hotkey_triggers = ghostty_conflicted_hotkey_triggers
59
59
  if markdown_preview_hotkey_conflicted is not None:
60
- conflicts = set(app._ghostty_app_hotkey_conflicts)
60
+ conflicts = set(app._ghostty_conflicted_hotkey_triggers)
61
61
  if markdown_preview_hotkey_conflicted:
62
- conflicts.add("toggle_markdown_preview")
62
+ conflicts.add("super+shift+v")
63
63
  else:
64
- conflicts.discard("toggle_markdown_preview")
65
- app._ghostty_app_hotkey_conflicts = conflicts
64
+ conflicts.discard("super+shift+v")
65
+ app._ghostty_conflicted_hotkey_triggers = conflicts
66
66
  return app
67
67
 
68
68
 
@@ -17,6 +17,16 @@ def _system_commands(app) -> dict[str, object]:
17
17
  return {command.title: command for command in app.get_system_commands(app.screen)}
18
18
 
19
19
 
20
+ def _key_help_groups(app) -> dict[str, list[tuple[str, str]]]:
21
+ return {
22
+ group.children[0].content: [
23
+ (row.children[0].content, row.children[1].content)
24
+ for row in group.query(".binding-row")
25
+ ]
26
+ for group in app.screen.query(".binding-group")
27
+ }
28
+
29
+
20
30
  async def test_command_palette_button_is_hidden() -> None:
21
31
  app = _palette_app()
22
32
  async with app.run_test() as pilot:
@@ -72,7 +82,7 @@ async def test_command_palette_items_are_sorted_alphabetically() -> None:
72
82
 
73
83
 
74
84
  async def test_keys_help_includes_command_palette_binding() -> None:
75
- app = _palette_app(ghostty_app_hotkey_conflicts=set())
85
+ app = _palette_app(ghostty_conflicted_hotkey_triggers=set())
76
86
  async with app.run_test() as pilot:
77
87
  await pilot.pause()
78
88
 
@@ -93,8 +103,33 @@ async def test_keys_help_includes_command_palette_binding() -> None:
93
103
  )
94
104
 
95
105
 
106
+ async def test_keys_help_shows_format_document_under_app() -> None:
107
+ app = _palette_app(ghostty_conflicted_hotkey_triggers=set())
108
+ async with app.run_test() as pilot:
109
+ await pilot.pause()
110
+
111
+ app.action_show_keys_popup()
112
+ await pilot.pause()
113
+
114
+ groups = _key_help_groups(app)
115
+ assert ("⌥⇧F", "Format document") in groups["App"]
116
+ assert ("⌥⇧F", "Format document") not in groups["Editor"]
117
+
118
+
119
+ async def test_keys_help_warns_for_conflicted_command_palette_alternative() -> None:
120
+ app = _palette_app(ghostty_conflicted_hotkey_triggers={"super+shift+p"})
121
+ async with app.run_test() as pilot:
122
+ await pilot.pause()
123
+
124
+ app.action_show_keys_popup()
125
+ await pilot.pause()
126
+
127
+ rows = _key_help_rows(app)
128
+ assert ("⚠️ ⌘⇧P / F1", "Command palette") in rows
129
+
130
+
96
131
  async def test_keys_help_warns_for_ghostty_conflicted_format_document() -> None:
97
- app = _palette_app(ghostty_app_hotkey_conflicts={"format_document"})
132
+ app = _palette_app(ghostty_conflicted_hotkey_triggers={"alt+shift+f"})
98
133
  async with app.run_test() as pilot:
99
134
  await pilot.pause()
100
135
 
@@ -5,7 +5,7 @@ from subprocess import CompletedProcess
5
5
  from textual.widgets import Footer
6
6
 
7
7
  from .helpers import _file_app, _footer_labels, _temporary_env
8
- from riched.keybindings import ghostty_app_hotkey_conflicts
8
+ from riched.keybindings import ghostty_conflicted_hotkey_triggers
9
9
 
10
10
 
11
11
  def _footer_app(**kwargs):
@@ -14,7 +14,7 @@ def _footer_app(**kwargs):
14
14
 
15
15
  def _conflicts_from_config(*lines: str) -> set[str]:
16
16
  config = "\n".join(lines)
17
- return ghostty_app_hotkey_conflicts(
17
+ return ghostty_conflicted_hotkey_triggers(
18
18
  env={"TERM_PROGRAM": "ghostty"},
19
19
  run_command=lambda *args, **kwargs: CompletedProcess(
20
20
  args[0],
@@ -27,7 +27,7 @@ def _conflicts_from_config(*lines: str) -> set[str]:
27
27
 
28
28
 
29
29
  async def test_footer_uses_macos_modifier_symbols_with_preferred_markdown_preview() -> None:
30
- app = _footer_app(ghostty_app_hotkey_conflicts=set())
30
+ app = _footer_app(ghostty_conflicted_hotkey_triggers=set())
31
31
  async with app.run_test() as pilot:
32
32
  await pilot.pause()
33
33
  footer = app.query_one(Footer)
@@ -47,8 +47,8 @@ async def test_footer_uses_macos_modifier_symbols_with_preferred_markdown_previe
47
47
  assert not footer.query(".-command-palette")
48
48
 
49
49
 
50
- async def test_footer_uses_command_palette_fallback_for_ghostty_conflict() -> None:
51
- app = _footer_app(ghostty_app_hotkey_conflicts={"command_palette"})
50
+ async def test_footer_uses_command_palette_alternative_for_ghostty_conflict() -> None:
51
+ app = _footer_app(ghostty_conflicted_hotkey_triggers={"super+shift+p"})
52
52
  async with app.run_test() as pilot:
53
53
  await pilot.pause()
54
54
  footer = app.query_one(Footer)
@@ -58,7 +58,7 @@ async def test_footer_uses_command_palette_fallback_for_ghostty_conflict() -> No
58
58
 
59
59
  async def test_footer_uses_command_palette_preferred_when_ghostty_unbound() -> None:
60
60
  app = _footer_app(
61
- ghostty_app_hotkey_conflicts=_conflicts_from_config(
61
+ ghostty_conflicted_hotkey_triggers=_conflicts_from_config(
62
62
  "keybind = super+shift+,=reload_config",
63
63
  "keybind = super+shift+v=toggle_quick_terminal",
64
64
  )
@@ -95,7 +95,7 @@ async def test_footer_hides_markdown_preview_outside_ghostty() -> None:
95
95
 
96
96
  async def test_footer_uses_markdown_preview_preferred_when_ghostty_unbound() -> None:
97
97
  app = _footer_app(
98
- ghostty_app_hotkey_conflicts=_conflicts_from_config(
98
+ ghostty_conflicted_hotkey_triggers=_conflicts_from_config(
99
99
  "keybind = super+shift+,=reload_config",
100
100
  "keybind = super+shift+v=unbind",
101
101
  )
@@ -28,8 +28,22 @@ exit 2
28
28
  _write_executable(
29
29
  bin_dir / "prettier",
30
30
  """#!/bin/sh
31
+ plugin=
32
+ case "$1" in
33
+ --plugin=*)
34
+ plugin=xml
35
+ shift
36
+ ;;
37
+ esac
31
38
  if [ "$1" = "--file-info" ]; then
32
39
  case "$2" in
40
+ *.xml)
41
+ if [ "$plugin" = "xml" ]; then
42
+ printf '{ "ignored": false, "inferredParser": "xml" }\\n'
43
+ else
44
+ printf '{ "ignored": false, "inferredParser": null }\\n'
45
+ fi
46
+ ;;
33
47
  *.json|*.js) printf '{ "ignored": false, "inferredParser": "json" }\\n' ;;
34
48
  *) printf '{ "ignored": false, "inferredParser": null }\\n' ;;
35
49
  esac
@@ -41,10 +55,42 @@ if [ "$1" = "--stdin-filepath" ]; then
41
55
  exit 2
42
56
  fi
43
57
  cat >/dev/null
58
+ if [ "$plugin" = "xml" ]; then
59
+ printf '<root>\\n <child />\\n</root>\\n'
60
+ exit 0
61
+ fi
44
62
  printf '{ "a": 1 }\\n'
45
63
  exit 0
46
64
  fi
47
65
  exit 2
66
+ """,
67
+ )
68
+ _write_executable(
69
+ bin_dir / "taplo",
70
+ """#!/bin/sh
71
+ if [ "$1" = "format" ]; then
72
+ cat >/dev/null
73
+ printf 'name = "riched"\\n'
74
+ exit 0
75
+ fi
76
+ exit 2
77
+ """,
78
+ )
79
+ return bin_dir
80
+
81
+
82
+ def _missing_xml_plugin_bin(tmp: Path) -> Path:
83
+ bin_dir = _formatter_bin(tmp)
84
+ _write_executable(
85
+ bin_dir / "prettier",
86
+ """#!/bin/sh
87
+ case "$1" in
88
+ --plugin=*)
89
+ printf "Cannot find package '@prettier/plugin-xml'\\n" >&2
90
+ exit 1
91
+ ;;
92
+ esac
93
+ exit 2
48
94
  """,
49
95
  )
50
96
  return bin_dir
@@ -75,6 +121,30 @@ async def test_format_document_uses_prettier_for_supported_files() -> None:
75
121
  assert path.read_text() == '{"a":1}'
76
122
 
77
123
 
124
+ async def test_format_document_uses_taplo_for_toml() -> None:
125
+ tmp, path, app = _file_app("config.toml", 'name="riched"')
126
+ bin_dir = _formatter_bin(tmp)
127
+ with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
128
+ async with app.run_test() as pilot:
129
+ await pilot.pause()
130
+ editor = _editor(app)
131
+ await _press(pilot, "alt+shift+f")
132
+ assert editor.text == 'name = "riched"\n'
133
+ assert path.read_text() == 'name="riched"'
134
+
135
+
136
+ async def test_format_document_uses_prettier_xml_plugin_for_xml() -> None:
137
+ tmp, path, app = _file_app("document.xml", "<root><child/></root>")
138
+ bin_dir = _formatter_bin(tmp)
139
+ with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
140
+ async with app.run_test() as pilot:
141
+ await pilot.pause()
142
+ editor = _editor(app)
143
+ await _press(pilot, "alt+shift+f")
144
+ assert editor.text == "<root>\n <child />\n</root>\n"
145
+ assert path.read_text() == "<root><child/></root>"
146
+
147
+
78
148
  async def test_format_document_unsupported_file_notifies_without_change() -> None:
79
149
  tmp, _, app = _file_app("notes.txt", "plain text")
80
150
  bin_dir = _formatter_bin(tmp)
@@ -115,6 +185,50 @@ async def test_format_document_missing_formatter_notifies_without_change() -> No
115
185
  ]
116
186
 
117
187
 
188
+ async def test_format_document_missing_taplo_notifies_without_change() -> None:
189
+ tmp, _, app = _file_app("config.toml", 'name="riched"')
190
+ empty_bin = tmp / "empty-bin"
191
+ empty_bin.mkdir()
192
+ notifications: list[tuple[str, str | None]] = []
193
+ app.notify = lambda message, **kwargs: notifications.append(
194
+ (str(message), kwargs.get("severity"))
195
+ )
196
+ with _temporary_env(PATH=str(empty_bin)):
197
+ async with app.run_test() as pilot:
198
+ await pilot.pause()
199
+ editor = _editor(app)
200
+ await _press(pilot, "alt+shift+f")
201
+ assert editor.text == 'name="riched"'
202
+ assert notifications == [
203
+ (
204
+ "`taplo` is required for formatting. Install it and ensure it is on PATH.",
205
+ "warning",
206
+ )
207
+ ]
208
+
209
+
210
+ async def test_format_document_missing_xml_plugin_notifies_without_change() -> None:
211
+ tmp, _, app = _file_app("document.xml", "<root><child/></root>")
212
+ bin_dir = _missing_xml_plugin_bin(tmp)
213
+ notifications: list[tuple[str, str | None]] = []
214
+ app.notify = lambda message, **kwargs: notifications.append(
215
+ (str(message), kwargs.get("severity"))
216
+ )
217
+ with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
218
+ async with app.run_test() as pilot:
219
+ await pilot.pause()
220
+ editor = _editor(app)
221
+ await _press(pilot, "alt+shift+f")
222
+ assert editor.text == "<root><child/></root>"
223
+ assert notifications == [
224
+ (
225
+ "`@prettier/plugin-xml` is required for formatting. "
226
+ "Install it and ensure Prettier can load it.",
227
+ "warning",
228
+ )
229
+ ]
230
+
231
+
118
232
  async def test_format_document_formatter_failure_notifies_without_change() -> None:
119
233
  tmp, _, app = _file_app("format-fail.js", "const value={a:1}")
120
234
  bin_dir = _formatter_bin(tmp)
@@ -118,7 +118,7 @@ async def test_keys_help_includes_markdown_preview_binding() -> None:
118
118
  "README.md",
119
119
  "# Title",
120
120
  root_is_tmp=True,
121
- ghostty_app_hotkey_conflicts=set(),
121
+ ghostty_conflicted_hotkey_triggers=set(),
122
122
  )
123
123
  async with app.run_test() as pilot:
124
124
  await pilot.pause()
@@ -135,7 +135,7 @@ async def test_keys_help_warns_for_ghostty_conflicted_markdown_preview() -> None
135
135
  "README.md",
136
136
  "# Title",
137
137
  root_is_tmp=True,
138
- ghostty_app_hotkey_conflicts={"toggle_markdown_preview"},
138
+ ghostty_conflicted_hotkey_triggers={"super+shift+v"},
139
139
  )
140
140
  async with app.run_test() as pilot:
141
141
  await pilot.pause()
@@ -131,7 +131,7 @@ wheels = [
131
131
 
132
132
  [[package]]
133
133
  name = "riched"
134
- version = "0.6.0"
134
+ version = "0.6.2"
135
135
  source = { editable = "." }
136
136
  dependencies = [
137
137
  { name = "pyyaml" },
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