agent-toggle 0.5.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/PKG-INFO +8 -6
  2. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/README.md +7 -5
  3. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/__init__.py +1 -1
  4. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/config.py +2 -1
  5. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/cost.py +17 -6
  6. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/locales/zh_tw_cli.py +3 -3
  7. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/locales/zh_tw_config.py +3 -2
  8. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/locales/zh_tw_picker.py +2 -0
  9. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/config_menu.py +7 -5
  10. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/model.py +6 -3
  11. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/picker.py +45 -4
  12. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/update_prompt.py +3 -3
  13. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/PKG-INFO +8 -6
  14. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_cli.py +1 -1
  15. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_cli_surface.py +1 -1
  16. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_config.py +10 -2
  17. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_config_menu.py +22 -5
  18. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_cost.py +3 -2
  19. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_picker.py +60 -5
  20. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/LICENSE +0 -0
  21. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/__main__.py +0 -0
  22. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/backends/__init__.py +0 -0
  23. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/backends/flag_json.py +0 -0
  24. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/backends/mcp_json.py +0 -0
  25. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/backends/mcp_toml.py +0 -0
  26. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/backends/plugin_cli.py +0 -0
  27. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/cli.py +0 -0
  28. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/companions.py +0 -0
  29. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/doctor.py +0 -0
  30. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/fs.py +0 -0
  31. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/harnesses.py +0 -0
  32. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/help.py +0 -0
  33. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/i18n.py +0 -0
  34. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/locales/__init__.py +0 -0
  35. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/locales/zh_tw_common.py +0 -0
  36. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/mechanisms.py +0 -0
  37. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ops.py +0 -0
  38. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/output.py +0 -0
  39. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/profiles.py +0 -0
  40. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/settings.py +0 -0
  41. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/shims/claude.md.tmpl +0 -0
  42. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/shims/generic.md.tmpl +0 -0
  43. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/spinner.py +0 -0
  44. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/store.py +0 -0
  45. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/toml_check.py +0 -0
  46. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/__init__.py +0 -0
  47. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/menu.py +0 -0
  48. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/ui/theme.py +0 -0
  49. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/undo.py +0 -0
  50. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle/update_check.py +0 -0
  51. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/SOURCES.txt +0 -0
  52. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/dependency_links.txt +0 -0
  53. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/entry_points.txt +0 -0
  54. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/requires.txt +0 -0
  55. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/agent_toggle.egg-info/top_level.txt +0 -0
  56. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/pyproject.toml +0 -0
  57. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/setup.cfg +0 -0
  58. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_alias.py +0 -0
  59. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_backends.py +0 -0
  60. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_color.py +0 -0
  61. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_companions.py +0 -0
  62. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_completions.py +0 -0
  63. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_conformance.py +0 -0
  64. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_containment.py +0 -0
  65. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_crash.py +0 -0
  66. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_doctor.py +0 -0
  67. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_encoding.py +0 -0
  68. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_fs.py +0 -0
  69. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_harnesses.py +0 -0
  70. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_help.py +0 -0
  71. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_i18n.py +0 -0
  72. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_install.py +0 -0
  73. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_mechanisms.py +0 -0
  74. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_menu.py +0 -0
  75. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_platform.py +0 -0
  76. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_profiles.py +0 -0
  77. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_project.py +0 -0
  78. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_safety.py +0 -0
  79. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_secret_audit.py +0 -0
  80. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_settings.py +0 -0
  81. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_store.py +0 -0
  82. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_theme.py +0 -0
  83. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_undo.py +0 -0
  84. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_update_check.py +0 -0
  85. {agent_toggle-0.5.2 → agent_toggle-0.6.0}/tests/test_update_prompt.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/weskao/agent-toggle
@@ -159,7 +159,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
159
159
  | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
160
160
  | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
161
161
  | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
162
- | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
162
+ | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
163
163
  | `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
164
164
  | `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
165
165
  | `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
@@ -274,7 +274,8 @@ What is on screen:
274
274
  - **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
275
275
  row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
276
276
  `claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
277
- - **Detail pane** at 100 columns or wider: harness, type, state, staged, path, cost, since,
277
+ - **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
278
+ agents, commands, rules), then harness, type, state, staged, path, cost, since,
278
279
  mechanism, and what it is shared with.
279
280
  - **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
280
281
  Windows console, non-UTF-8 locales) under the tabs, always visible.
@@ -337,11 +338,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
337
338
 
338
339
  ```sh
339
340
  agent-toggle config # or: agent-toggle --config
340
- agent-toggle config --json # every setting with its value and source; the token is masked
341
+ agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
341
342
  ```
342
343
 
343
- `config` opens the settings menu: curses on a terminal, a numbered list otherwise. Each change
344
- is saved the moment you make it. On a numbered list, end of input, `q` or an empty line exits `0`
344
+ `config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
345
+ shows the package version. Each change is saved the moment you make it. On a numbered list,
346
+ end of input, `q` or an empty line exits `0`
345
347
  (safe in CI; note it reads piped digits, so an open stdin pipe waits).
346
348
 
347
349
  | group | rows |
@@ -127,7 +127,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
127
127
  | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
128
128
  | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
129
129
  | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
130
- | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
130
+ | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
131
131
  | `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
132
132
  | `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
133
133
  | `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
@@ -242,7 +242,8 @@ What is on screen:
242
242
  - **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
243
243
  row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
244
244
  `claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
245
- - **Detail pane** at 100 columns or wider: harness, type, state, staged, path, cost, since,
245
+ - **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
246
+ agents, commands, rules), then harness, type, state, staged, path, cost, since,
246
247
  mechanism, and what it is shared with.
247
248
  - **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
248
249
  Windows console, non-UTF-8 locales) under the tabs, always visible.
@@ -305,11 +306,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
305
306
 
306
307
  ```sh
307
308
  agent-toggle config # or: agent-toggle --config
308
- agent-toggle config --json # every setting with its value and source; the token is masked
309
+ agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
309
310
  ```
310
311
 
311
- `config` opens the settings menu: curses on a terminal, a numbered list otherwise. Each change
312
- is saved the moment you make it. On a numbered list, end of input, `q` or an empty line exits `0`
312
+ `config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
313
+ shows the package version. Each change is saved the moment you make it. On a numbered list,
314
+ end of input, `q` or an empty line exits `0`
313
315
  (safe in CI; note it reads piped digits, so an open stdin pipe waits).
314
316
 
315
317
  | group | rows |
@@ -1,3 +1,3 @@
1
1
  """agent-toggle: temporarily disable / re-enable AI-agent resources."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.6.0"
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  import shutil
14
14
  import subprocess
15
15
 
16
- from . import settings
16
+ from . import __version__, settings
17
17
  from .output import CliError, Result, die
18
18
 
19
19
  SERVICE = "agent-toggle"
@@ -80,6 +80,7 @@ def show(tk, store, out: Result) -> None:
80
80
  text = ("on" if value else "off") if isinstance(value, bool) else str(value)
81
81
  out.row(None, "setting", key, "show", "ok", text, value=value,
82
82
  source=settings.source(key))
83
+ out.row(None, "info", "version", "show", "ok", __version__, value=__version__)
83
84
 
84
85
 
85
86
  def need(tk, store) -> tuple[str, str]:
@@ -70,6 +70,7 @@ class Item:
70
70
  chars: int | None
71
71
  basis: str
72
72
  shared_with: tuple[str, ...] = ()
73
+ description: str = "" # frontmatter description, when the file has one
73
74
 
74
75
 
75
76
  def tokens_of(chars: int) -> int:
@@ -102,24 +103,32 @@ def parse_frontmatter(head: str) -> dict[str, str]:
102
103
 
103
104
 
104
105
  def file_estimate(type_: str, name: str, path: Path | None) -> Estimate:
105
- """skill / agent / command / rule from one file (a skill dir -> its SKILL.md)."""
106
+ """skill / agent / command / rule from one file (a skill dir -> its SKILL.md).
107
+
108
+ Returns `(tokens, chars, basis, description)`. Other estimate helpers stay
109
+ 3-tuples; `_fill` only reads the description when a 4th element is present.
110
+ """
106
111
  if path is None:
107
- return 0, None, "file not found"
112
+ return 0, None, "file not found", ""
108
113
  if type_ == "skill" and path.is_dir():
109
114
  path = path / "SKILL.md"
110
115
  try:
111
116
  if type_ == "rule":
112
- chars = len(read_head(path, RULE_CAP))
113
- return tokens_of(chars), chars, f"whole file {chars} chars"
117
+ head = read_head(path, RULE_CAP)
118
+ chars = len(head)
119
+ return (tokens_of(chars), chars, f"whole file {chars} chars",
120
+ parse_frontmatter(head).get("description", ""))
114
121
  fm = parse_frontmatter(read_head(path, HEAD_CAP))
115
122
  except OSError as e:
116
- return 0, None, f"unreadable: {e.strerror or e}"
123
+ return 0, None, f"unreadable: {e.strerror or e}", ""
117
124
  vals = {f: fm.get(f, "") for f in FIELDS[type_]}
118
125
  if not vals.get("name"):
119
126
  vals["name"] = name
120
127
  chars = sum(map(len, vals.values()))
121
128
  parts = "+".join(f for f, v in vals.items() if v)
122
- return tokens_of(chars), chars, f"{parts} {chars} chars" + ("" if fm else " (no frontmatter)")
129
+ return (tokens_of(chars), chars,
130
+ f"{parts} {chars} chars" + ("" if fm else " (no frontmatter)"),
131
+ fm.get("description", ""))
123
132
 
124
133
 
125
134
  def mcp_estimate(harness: str, tools: int | None) -> Estimate:
@@ -222,6 +231,8 @@ def plugin_estimate(root: Path) -> Estimate:
222
231
  def _fill(item: Item, est: Estimate) -> None:
223
232
  item.tokens, item.would_save = (est[0], 0) if item.enabled else (0, est[0])
224
233
  item.chars, item.basis = est[1], est[2]
234
+ if len(est) > 3: # file rows carry the frontmatter description
235
+ item.description = est[3]
225
236
 
226
237
 
227
238
  def inventory(state: dict, table: dict, warn: Callable[[str], None] = lambda m: None,
@@ -72,9 +72,9 @@ CATALOG: dict[str, str] = {
72
72
  "help.options": "選項",
73
73
  # update prompt
74
74
  "update.available": "agent-toggle %s 已推出(目前版本 %s)",
75
- "update.keys": "↑↓ 選擇 · ⏎ 確認 · q 略過",
76
- "update.keys_ascii": "上/下 選擇,Enter 確認,q 略過",
77
- "update.choose": "請選擇 1-3(Enter = 1,q = 略過):",
75
+ "update.keys": "↑↓ 選擇 · ⏎ 確認 · q/Ctrl+C 略過",
76
+ "update.keys_ascii": "上/下 選擇,Enter 確認,q/Ctrl+C 略過",
77
+ "update.choose": "請選擇 1-3(Enter = 1,q/Ctrl+C = 略過):",
78
78
  "update.now": "立即更新",
79
79
  "update.skip": "略過",
80
80
  "update.skip_detail": "下次執行再問",
@@ -46,7 +46,7 @@ CATALOG: dict[str, str] = {
46
46
  "config.interrupted": "已中斷",
47
47
  "config.key_change": "變更",
48
48
  "config.key_move": "移動",
49
- "config.key_quit": "離開",
49
+ "config.key_quit": "離開 · 自動儲存",
50
50
  "config.key_reset": "重設",
51
51
  "config.key_reset_all": "全部重設",
52
52
  "config.key_run": "變更/執行",
@@ -66,7 +66,7 @@ CATALOG: dict[str, str] = {
66
66
  "config.picker_harness": "預設工具篩選",
67
67
  "config.picker_sort": "預設排序",
68
68
  "config.picker_type": "預設類型篩選",
69
- "config.prompt": "選擇要變更的設定(1-%s,q 離開):",
69
+ "config.prompt": "選擇要變更的設定(1-%s):",
70
70
  "config.prompt_chat_id": "Telegram 聊天 ID(Enter 保留,- 清除):",
71
71
  "config.prompt_token": "Telegram 機器人權杖(隱藏輸入;Enter 保留,- 清除):",
72
72
  "config.reset_all_done": "所有設定已重設為預設值",
@@ -75,6 +75,7 @@ CATALOG: dict[str, str] = {
75
75
  "config.save_failed": "無法儲存:%s",
76
76
  "config.saved": "已儲存",
77
77
  "config.saves": "變更即時儲存",
78
+ "config.status.leave": "q/Ctrl+C 離開 · 自動儲存",
78
79
  "config.send_test": "傳送測試訊息",
79
80
  "config.shims": "安裝 shim",
80
81
  "config.sync_ci": "同步 CI 密鑰",
@@ -27,10 +27,12 @@ CATALOG: dict[str, str] = {
27
27
  "picker.empty": "沒有符合的項目 -- Ctrl-U 清除篩選,Left/Right 換 harness,t 換類型",
28
28
  "picker.status.shown": "顯示 %s / %s",
29
29
  "picker.status.dry_run": "試跑:Enter 只顯示計畫",
30
+ "picker.status.leave": "Ctrl+C 離開 · 自動儲存",
30
31
  "picker.badge.shared": "+共用",
31
32
  # detail pane
32
33
  "picker.state.live": "啟用",
33
34
  "picker.state.parked": "停用",
35
+ "picker.d.description": "說明",
34
36
  "picker.d.harness": "harness",
35
37
  "picker.d.type": "類型",
36
38
  "picker.d.state": "狀態",
@@ -326,7 +326,7 @@ def numbered(ctx: Ctx, stdin=None, stdout=None) -> int:
326
326
  while True:
327
327
  ctx.refresh()
328
328
  lw = label_width(40)
329
- say([(t("config.title", "agent-toggle config"), "title"), (f" v{__version__}", "muted")])
329
+ say([(f"{t('config.title', 'agent-toggle config')} v{__version__}", "title")])
330
330
  n = 0
331
331
  for row in ROWS:
332
332
  if row.kind == "heading":
@@ -338,7 +338,8 @@ def numbered(ctx: Ctx, stdin=None, stdout=None) -> int:
338
338
  say([(f" {n:>2}. {label}{' ' * (lw - cell_width(label))} ", "text"),
339
339
  *value_segments(ctx, row, g)])
340
340
  say([])
341
- stdout.write(theme.encodable(t("config.prompt", "Pick a setting to change (1-%s, q to quit): ",
341
+ say([(t("config.status.leave", "q/Ctrl+C to leave · auto-save"), "muted")])
342
+ stdout.write(theme.encodable(t("config.prompt", "Pick a setting to change (1-%s): ",
342
343
  len(ITEMS)), stdout))
343
344
  stdout.flush()
344
345
  choice = _read(stdin)
@@ -411,8 +412,8 @@ def screen(ctx: Ctx, cur: int, top: int, h: int, w: int, g: theme.Glyphs,
411
412
  msg: Segment = ("", "text"), editing: str | None = None) -> list[list[Segment]]:
412
413
  """The whole menu as one segment list per screen line (pure: no curses)."""
413
414
  body = max(1, h - 5)
414
- lines = [theme.title_bar(t("config.title", "agent-toggle config"),
415
- f"{t('config.saves', 'saves as you go')} v{__version__}", w, g),
415
+ lines = [theme.title_bar(f"{t('config.title', 'agent-toggle config')} v{__version__}",
416
+ t("config.saves", "saves as you go"), w, g),
416
417
  _rule(g.tl, g.tr, w, g, t("config.more", "%s more", top) if top else "")]
417
418
  lw = label_width(max(8, (w - 2) // 2 - 4))
418
419
  for i in range(top, min(len(ROWS), top + body)):
@@ -427,7 +428,8 @@ def screen(ctx: Ctx, cur: int, top: int, h: int, w: int, g: theme.Glyphs,
427
428
  lines.append(theme.key_chips([
428
429
  (arrows[0], t("config.key_move", "move")), (arrows[1], t("config.key_change", "change")),
429
430
  (arrows[2], t("config.key_run", "change/run")), ("r", t("config.key_reset", "reset")),
430
- ("R", t("config.key_reset_all", "reset all")), ("q", t("config.key_quit", "quit"))], w - 1, g))
431
+ ("R", t("config.key_reset_all", "reset all")),
432
+ ("q/Ctrl+C", t("config.key_quit", "to leave · auto-save"))], w - 1, g))
431
433
  return lines
432
434
 
433
435
 
@@ -17,17 +17,19 @@ SORTS = ("name", "cost")
17
17
 
18
18
  class Row:
19
19
  __slots__ = ("harness", "type", "name", "enabled", "staged", "tokens", "would_save",
20
- "shared", "path", "since", "mechanism", "basis")
20
+ "shared", "path", "since", "mechanism", "basis", "description")
21
21
 
22
22
  def __init__(self, harness: str, type_: str, name: str, enabled: bool,
23
23
  tokens: int = 0, would_save: int = 0, shared: tuple[str, ...] = (),
24
- path: str = "", since: str = "", mechanism: str = "", basis: str = ""):
24
+ path: str = "", since: str = "", mechanism: str = "", basis: str = "",
25
+ description: str = ""):
25
26
  self.harness, self.type, self.name = harness, type_, name
26
27
  self.enabled = enabled
27
28
  self.staged = enabled # what the user has ticked so far
28
29
  self.tokens, self.would_save, self.shared = tokens, would_save, shared
29
30
  # detail-pane facts; "" = unknown (a plugin has no path, a live row no `since`)
30
31
  self.path, self.since, self.mechanism, self.basis = path, since, mechanism, basis
32
+ self.description = description
31
33
 
32
34
  @property
33
35
  def changed(self) -> bool:
@@ -68,7 +70,8 @@ def collect(state: dict, harnesses: dict, warn=lambda m: None,
68
70
  else:
69
71
  path = e.get("parked_at") or e.get("backup") or ""
70
72
  rows.append(Row(i.harness, i.type, i.name, i.enabled, i.tokens, i.would_save,
71
- i.shared_with, str(path), str(e.get("at") or ""), str(mech), i.basis))
73
+ i.shared_with, str(path), str(e.get("at") or ""), str(mech), i.basis,
74
+ i.description))
72
75
  rows.sort(key=lambda r: (r.harness, r.type, r.name))
73
76
  return rows
74
77
 
@@ -95,6 +95,39 @@ def pad(text: str, cells: int) -> str:
95
95
  return text + " " * max(0, cells - theme.cell_width(text))
96
96
 
97
97
 
98
+ def wrap_cells(text: str, width: int) -> list[str]:
99
+ """Wrap `text` on spaces into lines of at most `width` terminal cells.
100
+
101
+ A word wider than `width` is cut. Path fields still slice by character
102
+ because a path is one token."""
103
+ width = max(1, width)
104
+ lines: list[str] = []
105
+ cur = ""
106
+ for word in text.split():
107
+ if not cur:
108
+ cur = word
109
+ elif theme.cell_width(f"{cur} {word}") <= width:
110
+ cur = f"{cur} {word}"
111
+ else:
112
+ lines.append(cur)
113
+ cur = word
114
+ if cur:
115
+ lines.append(cur)
116
+ out: list[str] = []
117
+ for line in lines or [""]:
118
+ while theme.cell_width(line) > width:
119
+ cut = theme.truncate(line, width)
120
+ if not cut or cut == line: # a single cell wider than `width`
121
+ out.append(line)
122
+ line = ""
123
+ break
124
+ out.append(cut)
125
+ line = line[len(cut):]
126
+ if line:
127
+ out.append(line)
128
+ return out or [""]
129
+
130
+
98
131
  def _sep(g: theme.Glyphs) -> str:
99
132
  return " · " if g.unicode else " | "
100
133
 
@@ -170,7 +203,7 @@ def heading(type_: str, count: int, width: int, g: theme.Glyphs) -> list[theme.S
170
203
 
171
204
  def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment]]:
172
205
  """The detail pane for the cursor row: one segment list per screen line."""
173
- lab = 11
206
+ lab = 12 # "description" is 11 cells; leave a gap
174
207
  val = max(1, width - lab)
175
208
  out = [[(theme.truncate(r.name, width, g.ellipsis), "title")], []]
176
209
 
@@ -178,6 +211,13 @@ def detail_lines(r: Row, width: int, g: theme.Glyphs) -> list[list[theme.Segment
178
211
  for n in range(0, max(1, len(text)), val): # a long path wraps
179
212
  out.append([(pad(label if n == 0 else "", lab), "muted"), (text[n:n + val], role)])
180
213
 
214
+ if r.description: # frontmatter prose, not a path
215
+ chunks = wrap_cells(r.description, val)
216
+ label = t("picker.d.description", "description")
217
+ for n, chunk in enumerate(chunks):
218
+ out.append([(pad(label if n == 0 else "", lab), "muted"), (chunk, "text")])
219
+ out.append([])
220
+
181
221
  state = t("picker.state.live", "live") if r.enabled else t("picker.state.parked", "parked")
182
222
  field(t("picker.d.harness", "harness"), r.harness, theme.harness_role(r.harness))
183
223
  field(t("picker.d.type", "type"), r.type, "type")
@@ -250,7 +290,7 @@ def draw(win, v: View, shown: list[Row], pal: theme.Palette, g: theme.Glyphs,
250
290
  if end + theme.cell_width(right) + 2 <= w:
251
291
  put(2, [(right, "muted")], w - theme.cell_width(right))
252
292
 
253
- body = max(0, h - BODY_TOP - 2) # 0 on a tiny window: status + keys only
293
+ body = max(0, h - BODY_TOP - 3) # 0 on a tiny window: footer only
254
294
  v.page = max(1, body) # draw owns the page size and scroll
255
295
  dw = min(48, w * 2 // 5) if w >= DETAIL_MIN else 0
256
296
  lw = w - dw - (1 if dw else 0)
@@ -288,14 +328,15 @@ def draw(win, v: View, shown: list[Row], pal: theme.Palette, g: theme.Glyphs,
288
328
  lw)
289
329
 
290
330
  if v.note: # last profile result, until the next key
291
- put(h - 2, [(" " + v.note, "error" if v.note.startswith("error") else "accent")])
331
+ put(h - 3, [(" " + v.note, "error" if v.note.startswith("error") else "accent")])
292
332
  else:
293
333
  status = t("picker.status.shown", "%s of %s shown", len(shown), len(v.rows))
294
334
  if shown:
295
335
  status += f"{_sep(g)}{v.cur + 1}/{len(shown)}"
296
336
  if dry_run:
297
337
  status += _sep(g) + t("picker.status.dry_run", "dry run: Enter only shows the plan")
298
- put(h - 2, [(" " + status, "muted")])
338
+ put(h - 3, [(" " + status, "muted")])
339
+ put(h - 2, [(" " + t("picker.status.leave", "Ctrl+C to leave · auto-save"), "muted")])
299
340
  put(h - 1, [(" ", "text"), *theme.key_chips(chips(v, g), w - 2, g)])
300
341
  win.refresh()
301
342
 
@@ -87,8 +87,8 @@ def _lines(found: update_check.UpdateAvailable, selected: int, color: bool,
87
87
  label += " " * (width - theme.cell_width(name))
88
88
  lines.append(" " + theme.ansi("cursor" if on else "text", label, color)
89
89
  + " " + theme.ansi("muted", detail, color))
90
- keys = (t("update.keys", "↑↓ select · ⏎ confirm · q skip") if g.unicode
91
- else t("update.keys_ascii", "up/down select, enter confirm, q skip"))
90
+ keys = (t("update.keys", "↑↓ select · ⏎ confirm · q/Ctrl+C skip") if g.unicode
91
+ else t("update.keys_ascii", "up/down select, enter confirm, q/Ctrl+C skip"))
92
92
  lines += [" " + t("update.notes", "Release notes: %s", RELEASE_NOTES % _clean(found.latest).removeprefix("v")),
93
93
  " " + theme.ansi("muted", keys, color)]
94
94
  return lines
@@ -137,7 +137,7 @@ def _posix_keys(fd: int) -> Iterator[str]:
137
137
 
138
138
  def _typed_keys(stream, out) -> Iterator[str]:
139
139
  """The numbered fallback: one typed line per answer ('' = the highlighted row)."""
140
- out.write(t("update.choose", "Choose 1-3 (Enter = 1, q = skip): "))
140
+ out.write(t("update.choose", "Choose 1-3 (Enter = 1, q/Ctrl+C = skip): "))
141
141
  out.flush()
142
142
  line = stream.readline()
143
143
  if not line:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-toggle
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Temporarily disable / re-enable AI-agent skills, agents, commands, rules, plugins and MCP servers across harnesses (Claude Code, Codex, Grok, OpenCode, OpenClaw, Copilot, Vibe, Devin, Antigravity)
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/weskao/agent-toggle
@@ -159,7 +159,7 @@ agent-toggle <command> [args] # or: python3 agent_toggle.py <command> [
159
159
  | `enable --all` | put back **every** disabled item (`--harness H` narrows it, `--project <dir>` takes only that project's) |
160
160
  | `undo` | reverse the last logged batch (`--dry-run` shows the plan) |
161
161
  | `profile save\|apply\|diff\|list` | named sets of live items; see [Profiles](#profiles) |
162
- | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting; see [Settings & config menu](#settings--config-menu) |
162
+ | `config` | settings menu (curses, else a numbered list); `config --json` lists every setting and the package version; see [Settings & config menu](#settings--config-menu) |
163
163
  | `config test\|sync-ci` | send one Telegram test message / set the GitHub CI secrets; needs the `[telegram]` extra; see [CI notifications](#ci-notifications) |
164
164
  | `help [command]` | styled help; `help ui` = `--help ui` = `ui --help`; see [Help](#help) |
165
165
  | `doctor` | check each harness layout and `state.json` against disk; exit `1` only on an `error` row; on a terminal, asks y/n per fix it can run itself |
@@ -274,7 +274,8 @@ What is on screen:
274
274
  - **Cost column**: estimated startup tokens (chars / 4, about +-25 %) with a colored bar. A parked
275
275
  row shows `(N)` in brackets, what restoring it would load. Plugins appear as rows too (via
276
276
  `claude plugin list --json`; skipped under `ui --dry-run`, which never shells out).
277
- - **Detail pane** at 100 columns or wider: harness, type, state, staged, path, cost, since,
277
+ - **Detail pane** at 100 columns or wider: the file's frontmatter `description` (skills,
278
+ agents, commands, rules), then harness, type, state, staged, path, cost, since,
278
279
  mechanism, and what it is shared with.
279
280
  - **Search bar** (🔍; plain `/` on terminals that cannot draw emoji: the Linux console, the legacy
280
281
  Windows console, non-UTF-8 locales) under the tabs, always visible.
@@ -337,11 +338,12 @@ The chrome (title, tabs, footer, help, detail labels) is translated when `langua
337
338
 
338
339
  ```sh
339
340
  agent-toggle config # or: agent-toggle --config
340
- agent-toggle config --json # every setting with its value and source; the token is masked
341
+ agent-toggle config --json # every setting with its value and source, plus the package version; the token is masked
341
342
  ```
342
343
 
343
- `config` opens the settings menu: curses on a terminal, a numbered list otherwise. Each change
344
- is saved the moment you make it. On a numbered list, end of input, `q` or an empty line exits `0`
344
+ `config` opens the settings menu: curses on a terminal, a numbered list otherwise. The title
345
+ shows the package version. Each change is saved the moment you make it. On a numbered list,
346
+ end of input, `q` or an empty line exits `0`
345
347
  (safe in CI; note it reads piped digits, so an open stdin pipe waits).
346
348
 
347
349
  | group | rows |
@@ -255,7 +255,7 @@ class UpdateCheckTest(CliCase):
255
255
  for part in ("✨ " + HINT, "› 1) Update now", "uv tool upgrade agent-toggle",
256
256
  "ask again next run", "3) Skip until next version",
257
257
  "Release notes: https://github.com/weskao/agent-toggle/releases/tag/v99.0.0",
258
- "↑↓ select · ⏎ confirm · q skip"):
258
+ "↑↓ select · ⏎ confirm · q/Ctrl+C skip"):
259
259
  self.assertIn(part, text)
260
260
 
261
261
 
@@ -307,7 +307,7 @@ class SpellingTest(CliCase):
307
307
  # v0.3.0-v0.5.0 shipped with __version__ still at 0.2.0; ahead is fine (bumped, unreleased)
308
308
  changelog = Path(__file__).resolve().parents[1] / "CHANGELOG.md"
309
309
  top = next(line for line in changelog.read_text(encoding="utf-8").splitlines()
310
- if line.startswith("## ["))
310
+ if line.startswith("## [") and "Unreleased" not in line)
311
311
  released = top[4:top.index("]")]
312
312
  as_tuple = lambda v: tuple(int(p) for p in v.split(".")) # noqa: E731
313
313
  self.assertGreaterEqual(as_tuple(__version__), as_tuple(released), top)
@@ -13,7 +13,7 @@ from unittest import mock
13
13
 
14
14
  from test_cli_surface import CliCase
15
15
 
16
- from agent_toggle import config, settings
16
+ from agent_toggle import __version__, config, settings
17
17
  from agent_toggle.ui import config_menu
18
18
 
19
19
 
@@ -130,11 +130,19 @@ class ConfigTest(CliCase):
130
130
  self.assertEqual(rc, 0)
131
131
  names = [r["name"] for r in env["results"] if r["type"] == "setting"]
132
132
  self.assertEqual(names, [k for k in settings.DEFAULTS if k != config.CHAT_KEY])
133
- sources = {r["name"]: r["source"] for r in env["results"][2:]}
133
+ sources = {r["name"]: r["source"] for r in env["results"] if r["type"] == "setting"}
134
134
  # tests/base.py sets AGENT_TOGGLE_UPDATE_CHECK=0 for the whole run
135
135
  self.assertEqual(sources.pop("update_check"), "env")
136
136
  self.assertEqual(set(sources.values()), {"default"})
137
137
 
138
+ def test_json_includes_the_package_version(self) -> None:
139
+ rc, env = self.run_json("config")
140
+ self.assertEqual(rc, 0)
141
+ row = next(r for r in env["results"] if r["name"] == "version")
142
+ self.assertEqual((row["type"], row["action"], row["status"], row["detail"]),
143
+ ("info", "show", "ok", __version__))
144
+ self.assertEqual(row["value"], __version__)
145
+
138
146
  def test_test_message_needs_both_values_and_reports_delivery(self) -> None:
139
147
  self.assertEqual(self.run_cli("config", "test")[0], 2)
140
148
  self.interactive("-100123")
@@ -106,11 +106,20 @@ class CursesMenuTest(MenuCase):
106
106
  text = win.text()
107
107
  for want in ("agent-toggle config", f"v{__version__}", "saves as you go", "General", "Harnesses",
108
108
  "Picker", "Notifications", "Tools", "Check for updates", "Sync CI secrets",
109
- "› Check for updates", "╭", "╰", "↑↓ move", "R reset all", "q quit",
109
+ "› Check for updates", "╭", "╰", "↑↓ move", "R reset all",
110
110
  "found ~/.claude", "not on this machine"):
111
111
  self.assertIn(want, text)
112
- # the help line shows the selected row's help
113
- self.assertIn("look for a newer agent-toggle", text)
112
+ self.assertNotIn("q quit", text)
113
+ keys = next(ln for ln in text.splitlines() if "↑↓ move" in ln)
114
+ self.assertIn("q/Ctrl+C to leave · auto-save", keys)
115
+ help_line = next(ln for ln in text.splitlines() if "look for a newer agent-toggle" in ln)
116
+ self.assertNotIn("q/Ctrl+C", help_line)
117
+
118
+ def test_title_puts_version_next_to_the_name(self) -> None:
119
+ win, _ = self.menu(["q"])
120
+ first = win.text().splitlines()[0]
121
+ self.assertIn(f"agent-toggle config v{__version__}", first)
122
+ self.assertLess(first.index(f"v{__version__}"), first.index("saves as you go"))
114
123
 
115
124
  def test_toggle_update_check_saves_at_once(self) -> None:
116
125
  win, _ = self.menu(["\n", "q"])
@@ -171,8 +180,11 @@ class CursesMenuTest(MenuCase):
171
180
  self.assertEqual(i18n.LANGUAGE, "zh-TW")
172
181
  self.assertEqual(self.file()["language"], "zh-TW")
173
182
  last = win.text()
174
- for want in ("繁體中文", "一般", "檢查更新", "變更即時儲存", "離開"):
183
+ for want in ("繁體中文", "一般", "檢查更新", "變更即時儲存"):
175
184
  self.assertIn(want, last)
185
+ keys = next(ln for ln in last.splitlines() if "↑↓ 移動" in ln)
186
+ self.assertIn("q/Ctrl+C 離開 · 自動儲存", keys)
187
+ self.assertNotIn("q 離開", last)
176
188
  self.assertNotIn("Check for updates", last)
177
189
 
178
190
  def test_env_override_is_tagged(self) -> None:
@@ -306,10 +318,15 @@ class NumberedTest(MenuCase):
306
318
  rc = cm.numbered(self.ctx(telegram), io.StringIO(text), out)
307
319
  return rc, out.getvalue()
308
320
 
321
+ def test_title_shows_the_package_version(self) -> None:
322
+ _, out = self.run_numbered("q\n")
323
+ self.assertIn(f"agent-toggle config v{__version__}", out.splitlines()[0])
324
+
309
325
  def test_digits_toggle_and_cycle_then_eof_quits(self) -> None:
310
326
  rc, out = self.run_numbered(f"1\n{number('Color')}\n")
311
327
  self.assertEqual(rc, 0)
312
- self.assertIn(f"Pick a setting to change (1-{len(cm.ITEMS)}, q to quit): ", out)
328
+ self.assertIn(f"Pick a setting to change (1-{len(cm.ITEMS)}): ", out)
329
+ self.assertIn("q/Ctrl+C to leave · auto-save", out)
313
330
  self.assertIn(" 1. Check for updates", out)
314
331
  self.assertIs(settings.get("update_check"), False)
315
332
  self.assertEqual(settings.get("color"), "always")
@@ -26,14 +26,15 @@ class EstimatorTest(CliCase):
26
26
 
27
27
  def test_skill_counts_name_and_description_not_the_body(self) -> None:
28
28
  tokens, chars, basis = self.est(
29
- "skill", "skills/s/SKILL.md", FM.format(n="abcd", d="x" * 96, body="B" * 50000))
29
+ "skill", "skills/s/SKILL.md", FM.format(n="abcd", d="x" * 96, body="B" * 50000))[:3]
30
30
  self.assertEqual((tokens, chars), (25, 100))
31
31
  self.assertIn("name+description 100 chars", basis)
32
32
 
33
33
  def test_folded_description_and_missing_frontmatter(self) -> None:
34
34
  folded = "---\nname: f\ndescription: >\n one two\n three\n---\n"
35
35
  self.assertEqual(self.est("skill", "skills/f/SKILL.md", folded)[1], len("f") + len("one two three"))
36
- tokens, chars, basis = self.est("skill", "skills/g/SKILL.md", "# no frontmatter", name="gname")
36
+ tokens, chars, basis = self.est(
37
+ "skill", "skills/g/SKILL.md", "# no frontmatter", name="gname")[:3]
37
38
  self.assertEqual(chars, len("gname"))
38
39
  self.assertIn("no frontmatter", basis)
39
40
 
@@ -107,6 +107,28 @@ class PickerTest(SandboxCase):
107
107
  # name (3) + description (396) = 399 chars -> 100 tokens
108
108
  self.assertEqual(rows["big"].tokens, 100)
109
109
 
110
+ def test_collect_carries_the_frontmatter_description(self) -> None:
111
+ self.write("skills/solo/SKILL.md",
112
+ "---\nname: solo\ndescription: park a skill without deleting it\n---\nbody\n")
113
+ self.write("commands/go.md", "---\ndescription: run the go helper\n---\n")
114
+ self.write("agents/helper.md", "---\nname: helper\ndescription: >\n one two\n three\n---\n")
115
+ self.write("skills/bare/SKILL.md", "# no frontmatter\n")
116
+ self.write("rules/note.md", "---\ndescription: always on note\n---\nbody\n")
117
+ park = self.tmp / "parked-skill"
118
+ park.mkdir()
119
+ (park / "SKILL.md").write_text(
120
+ "---\ndescription: parked copy of the skill\n---\n", encoding="utf-8")
121
+ state = {"disabled": {"claude:skill:parked-skill": {
122
+ "harness": "claude", "type": "skill", "name": "parked-skill",
123
+ "parked_at": str(park)}}}
124
+ rows = {r.name: r for r in model.collect(state, build(self.tmp))}
125
+ self.assertEqual(rows["solo"].description, "park a skill without deleting it")
126
+ self.assertEqual(rows["go"].description, "run the go helper")
127
+ self.assertEqual(rows["helper"].description, "one two three")
128
+ self.assertEqual(rows["bare"].description, "")
129
+ self.assertEqual(rows["note"].description, "always on note")
130
+ self.assertEqual(rows["parked-skill"].description, "parked copy of the skill")
131
+
110
132
  def test_visible_filters_and_sorts(self) -> None:
111
133
  rows = [model.Row("claude", "skill", "a", True, 5),
112
134
  model.Row("codex", "skill", "b", True, 50, shared=("opencode",)),
@@ -378,7 +400,7 @@ class KeysTest(ScreenCase):
378
400
  C = picker.curses
379
401
  _, win = self.run_keys([C.KEY_NPAGE, "\x1b"], rows=rows, size=(12, 80))
380
402
  cursor = next(line for line in win.screen if line.startswith("›"))
381
- self.assertIn("s07", cursor) # 7-line body: one page down
403
+ self.assertIn("s06", cursor) # 6-line body: one page down
382
404
  self.assertNotIn("s00", win.text())
383
405
  out, win = self.run_keys([C.KEY_END, " ", "\n"], rows=rows, size=(12, 80))
384
406
  self.assertEqual([r.name for r in out], ["last"])
@@ -401,7 +423,7 @@ class KeysTest(ScreenCase):
401
423
  def test_a_tiny_window_keeps_status_and_keys(self) -> None:
402
424
  out, win = self.run_keys([" ", "\n"], size=(5, 40))
403
425
  self.assertEqual([r.name for r in out], ["alpha"])
404
- self.assertIn("of 5 shown", win.frames[0][-2])
426
+ self.assertIn("of 5 shown", win.frames[0][-3])
405
427
  self.assertIn("Space toggle", win.frames[0][-1])
406
428
 
407
429
  def test_resize_redraws_at_the_new_size(self) -> None:
@@ -420,6 +442,38 @@ class LayoutTest(ScreenCase):
420
442
  self.assertIn("5 resources · ~6.3k tok live", win.frames[0][0])
421
443
  self.assertIn("1 staged (-1.2k tok)", win.screen[0])
422
444
 
445
+ def test_status_line_says_ctrl_c_leaves_and_auto_saves(self) -> None:
446
+ _, win = self.run_keys(["\x1b"])
447
+ self.assertEqual(win.screen[-3].strip(), "5 of 5 shown · 1/5")
448
+ self.assertEqual(win.screen[-2].strip(), "Ctrl+C to leave · auto-save")
449
+
450
+ def test_detail_pane_shows_the_description(self) -> None:
451
+ text = "park a skill without deleting it"
452
+ rows = [model.Row("claude", "skill", "solo", True, description=text)]
453
+ _, win = self.run_keys(["\x1b"], rows=rows, size=(24, 120))
454
+ pane = "\n".join(line[72:] for line in win.screen)
455
+ self.assertIn("description", pane)
456
+ self.assertIn(text, pane)
457
+ bare = [model.Row("claude", "mcp", "srv", True)]
458
+ _, win = self.run_keys(["\x1b"], rows=bare, size=(24, 120))
459
+ self.assertNotIn("description", "\n".join(line[72:] for line in win.screen))
460
+
461
+ def test_detail_pane_wraps_the_description_on_words(self) -> None:
462
+ r = model.Row("claude", "skill", "solo", True, description="alpha beta gamma delta")
463
+ lines = picker.detail_lines(r, 23, theme.ASCII)
464
+ body = ["".join(s for s, role in line if role == "text").strip() for line in lines]
465
+ self.assertIn("alpha beta", body)
466
+ self.assertIn("gamma delta", body)
467
+
468
+ def test_detail_pane_description_is_translated(self) -> None:
469
+ self.addCleanup(i18n.set_language, i18n.LANGUAGE)
470
+ i18n.set_language("zh-TW")
471
+ rows = [model.Row("claude", "skill", "solo", True, description="park a skill")]
472
+ _, win = self.run_keys(["\x1b"], rows=rows, size=(24, 120))
473
+ pane = "\n".join(line[72:] for line in win.screen)
474
+ self.assertIn("說明", pane)
475
+ self.assertIn("park a skill", pane)
476
+
423
477
  def test_detail_pane_shows_at_wide_widths_only(self) -> None:
424
478
  down = picker.curses.KEY_DOWN
425
479
  _, win = self.run_keys([down, "\x1b"], size=(24, 120))
@@ -451,7 +505,7 @@ class LayoutTest(ScreenCase):
451
505
  def test_empty_result_says_how_to_get_back(self) -> None:
452
506
  _, win = self.run_keys(["/", "q", "q", "\x1b"])
453
507
  self.assertIn("nothing matches", win.text())
454
- self.assertIn("0 of 5 shown", win.screen[-2])
508
+ self.assertIn("0 of 5 shown", win.screen[-3])
455
509
  # with the detail pane, the message is cut at the list's edge, not drawn under the pane
456
510
  _, win = self.run_keys(["/", "q", "q", "\x1b"], size=(24, 120))
457
511
  line = win.screen[3]
@@ -466,7 +520,7 @@ class LayoutTest(ScreenCase):
466
520
 
467
521
  def test_dry_run_is_announced(self) -> None:
468
522
  _, win = self.run_keys(["\x1b"], dry_run=True)
469
- self.assertIn("dry run", win.screen[-2])
523
+ self.assertIn("dry run", win.screen[-3])
470
524
 
471
525
  def test_mono_draw_uses_attributes_only(self) -> None:
472
526
  _, win = self.run_keys(["\x1b"], size=(24, 90))
@@ -510,6 +564,7 @@ class LayoutTest(ScreenCase):
510
564
  _, win = self.run_keys(["\x1b"], size=(24, 100))
511
565
  self.assertIn("技能", win.text())
512
566
  self.assertIn("全部", win.screen[1])
567
+ self.assertIn("Ctrl+C 離開 · 自動儲存", win.screen[-2])
513
568
 
514
569
 
515
570
  class SettingsTest(ScreenCase):
@@ -655,7 +710,7 @@ class ProfileKeyTest(SandboxCase):
655
710
  everything = "\n".join(win.text(i) for i in range(len(win.frames)))
656
711
  self.assertIn("profile> work", everything)
657
712
  self.assertIn("1) work", everything)
658
- self.assertIn("profile work: 1 staged", win.screen[-2])
713
+ self.assertIn("profile work: 1 staged", win.screen[-3])
659
714
  # Esc at the prompt stages nothing (even a full valid name) and the picker carries on
660
715
  self.rows[0].staged = True
661
716
  self.assertEqual(picker.loop(FakeWin(["p", *"work", "\x1b", "\n"], size=(12, 70)),
File without changes
File without changes