kapsel-cli 0.6.8__tar.gz → 0.7.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 (131) hide show
  1. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/PKG-INFO +1 -1
  2. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/__init__.py +1 -1
  3. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/cli.py +12 -11
  4. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/carapace_installer.py +9 -15
  5. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/dispatcher.py +11 -0
  6. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/runner.py +103 -8
  7. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/executor.py +11 -1
  8. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/update_check.py +3 -12
  9. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/prompt.py +6 -16
  10. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/PKG-INFO +1 -1
  11. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/SOURCES.txt +5 -1
  12. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/pyproject.toml +1 -1
  13. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_carapace_installer.py +5 -5
  14. kapsel_cli-0.7.2/tests/test_ctrl_b_roaming.py +69 -0
  15. kapsel_cli-0.7.2/tests/test_kp_enhanced.py +111 -0
  16. kapsel_cli-0.7.2/tests/test_multiline_passthrough.py +83 -0
  17. kapsel_cli-0.7.2/tests/test_plugin_install_batch.py +95 -0
  18. kapsel_cli-0.7.2/tests/test_plugin_kssh.py +91 -0
  19. kapsel_cli-0.6.8/tests/test_kssh_input_bar.py +0 -92
  20. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/README.md +0 -0
  21. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/__main__.py +0 -0
  22. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/__init__.py +0 -0
  23. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/carapace_engine.py +0 -0
  24. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/completer.py +0 -0
  25. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/__init__.py +0 -0
  26. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/__init__.py +0 -0
  27. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/add.py +0 -0
  28. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/completion.py +0 -0
  29. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/config.py +0 -0
  30. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/datadir.py +0 -0
  31. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/dir_nav.py +0 -0
  32. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/help.py +0 -0
  33. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/language.py +0 -0
  34. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/plugin_switch.py +0 -0
  35. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/restart.py +0 -0
  36. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/search.py +0 -0
  37. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/status.py +0 -0
  38. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/toggle.py +0 -0
  39. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/upgrade.py +0 -0
  40. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/registry.py +0 -0
  41. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/spec_manager.py +0 -0
  42. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/__init__.py +0 -0
  43. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/__init__.py +0 -0
  44. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/model.py +0 -0
  45. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/registry.py +0 -0
  46. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/detector.py +0 -0
  47. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/engine.py +0 -0
  48. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/i18n.py +0 -0
  49. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/__init__.py +0 -0
  50. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/base.py +0 -0
  51. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/catalog.py +0 -0
  52. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/context.py +0 -0
  53. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/fetcher.py +0 -0
  54. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/hooks.py +0 -0
  55. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/manager.py +0 -0
  56. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/__init__.py +0 -0
  57. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/installer.py +0 -0
  58. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/registry.py +0 -0
  59. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/i18n.py +0 -0
  60. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
  61. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/LC_MESSAGES/kapsel.po +0 -0
  62. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/help.yaml +0 -0
  63. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/tips.yaml +0 -0
  64. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/en/help.yaml +0 -0
  65. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/en/tips.yaml +0 -0
  66. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
  67. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/LC_MESSAGES/kapsel.po +0 -0
  68. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/help.yaml +0 -0
  69. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/tips.yaml +0 -0
  70. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
  71. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.po +0 -0
  72. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/help.yaml +0 -0
  73. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/tips.yaml +0 -0
  74. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
  75. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.po +0 -0
  76. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/help.yaml +0 -0
  77. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/tips.yaml +0 -0
  78. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/kapsel.pot +0 -0
  79. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
  80. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.po +0 -0
  81. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/help.yaml +0 -0
  82. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/tips.yaml +0 -0
  83. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
  84. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +0 -0
  85. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/help.yaml +0 -0
  86. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/tips.yaml +0 -0
  87. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/__init__.py +0 -0
  88. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/config.py +0 -0
  89. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/history.py +0 -0
  90. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/logger.py +0 -0
  91. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/migrate.py +0 -0
  92. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/__init__.py +0 -0
  93. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/banner.py +0 -0
  94. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/card.py +0 -0
  95. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/theme.py +0 -0
  96. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/tips.py +0 -0
  97. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/viewport.py +0 -0
  98. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/dependency_links.txt +0 -0
  99. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/entry_points.txt +0 -0
  100. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/requires.txt +0 -0
  101. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/top_level.txt +0 -0
  102. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/setup.cfg +0 -0
  103. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_add_concurrent.py +0 -0
  104. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_ai_spec_sanitizer.py +0 -0
  105. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_block_system.py +0 -0
  106. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_config.py +0 -0
  107. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_dir_nav.py +0 -0
  108. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_history.py +0 -0
  109. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_i18n.py +0 -0
  110. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_ai.py +0 -0
  111. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_alias.py +0 -0
  112. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_autopilot.py +0 -0
  113. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_backup.py +0 -0
  114. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_commit.py +0 -0
  115. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_fetcher.py +0 -0
  116. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_help.py +0 -0
  117. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_init.py +0 -0
  118. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_install.py +0 -0
  119. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_lang.py +0 -0
  120. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_mend.py +0 -0
  121. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_portal.py +0 -0
  122. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_preview.py +0 -0
  123. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_queue.py +0 -0
  124. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_template.py +0 -0
  125. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_trans.py +0 -0
  126. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_restart_and_pypi.py +0 -0
  127. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_search_and_completions.py +0 -0
  128. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_spec_manager.py +0 -0
  129. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_toggle.py +0 -0
  130. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_tools_registry.py +0 -0
  131. {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_upgrade.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kapsel-cli
3
- Version: 0.6.8
3
+ Version: 0.7.2
4
4
  Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
5
5
  Author-email: MrEiu <k648888@vip.qq.com>
6
6
  License: MIT
@@ -3,4 +3,4 @@ Kapsel: Cross-platform adaptive smart terminal capsule.
3
3
  Wrap complexity, expose simplicity.
4
4
  """
5
5
 
6
- __version__ = "0.6.8"
6
+ __version__ = "0.7.2"
@@ -102,17 +102,18 @@ def main(args: Optional[List[str]] = None) -> int:
102
102
  if not stripped:
103
103
  continue
104
104
 
105
- normalized = " ".join(stripped.lower().split())
106
- if normalized in ("exit", "quit", "bye"):
107
- engine.history_mgr.record_command(command=user_input, mode="builtin")
108
- os.environ.pop("KAPSEL_ACTIVE", None)
109
- console.print("[dim]Exited Kapsel.[/]")
110
- break
111
-
112
- if normalized in ("restart", "kapsel restart", "reload"):
113
- engine.history_mgr.record_command(command=user_input, mode="builtin")
114
- from kapsel.completion.kps.builtins.restart import handle_restart
115
- return handle_restart(console=console)
105
+ if "\n" not in stripped:
106
+ normalized = " ".join(stripped.lower().split())
107
+ if normalized in ("exit", "quit", "bye"):
108
+ engine.history_mgr.record_command(command=user_input, mode="builtin")
109
+ os.environ.pop("KAPSEL_ACTIVE", None)
110
+ console.print("[dim]Exited Kapsel.[/]")
111
+ break
112
+
113
+ if normalized in ("restart", "kapsel restart", "reload"):
114
+ engine.history_mgr.record_command(command=user_input, mode="builtin")
115
+ from kapsel.completion.kps.builtins.restart import handle_restart
116
+ return handle_restart(console=console)
116
117
 
117
118
  try:
118
119
  result = engine.dispatch(user_input)
@@ -192,9 +192,10 @@ def install_carapace(
192
192
 
193
193
  def ensure_carapace_installed(console: Optional[Console] = None) -> bool:
194
194
  """
195
- Auto-bootstraps Carapace completion engine on first launch if not already available.
196
- Zero-overhead (0ms) on subsequent launches when already installed.
197
- Gracefully falls back to basic completion if offline, preventing any startup crash.
195
+ Checks if Carapace completion engine is available.
196
+ If not installed, prints a lightweight non-blocking notice and enters immediately.
197
+ Never blocks terminal startup with network downloads.
198
+ Users can install it anytime on demand using 'kapsel setup-completion'.
198
199
  """
199
200
  from kapsel.completion.carapace_engine import get_carapace_engine
200
201
 
@@ -203,16 +204,9 @@ def ensure_carapace_installed(console: Optional[Console] = None) -> bool:
203
204
  return True
204
205
 
205
206
  con = console or Console(legacy_windows=False)
206
- con.print("[bold #00f0ff]🎯 First Launch: Auto-initializing Carapace completion engine (1,000+ tools)...[/]")
207
-
208
- try:
209
- success = install_carapace(console=con, force=False)
210
- return success
211
- except Exception as e:
212
- logger.warning(f"Failed to auto-bootstrap Carapace: {e}")
213
- con.print(
214
- f"[yellow]Notice: Could not automatically setup Carapace ({e}). "
215
- f"Continuing in basic autocompletion mode.[/]\n"
216
- )
217
- return False
207
+ con.print(
208
+ "[dim]ℹ Notice: Carapace completion engine not detected. Running in standard autocompletion mode.[/]\n"
209
+ "[dim] (Optional: run '[bold cyan]kapsel setup-completion[/]' to enable 1,000+ tool completions)[/]\n"
210
+ )
211
+ return False
218
212
 
@@ -133,6 +133,7 @@ def _handle_kp_command(
133
133
  con.print("[dim]Executes multiple commands or pasted blocks asynchronously in the background.[/]\n")
134
134
  con.print("[bold white]Usage:[/]")
135
135
  con.print(" [bold #a855f7]kp <commands...>[/] Execute commands sequentially in background (atomic, non-blocking)")
136
+ con.print(" [bold #a855f7]kp -s <commands...>[/] Execute commands sequentially in background (explicit sequential)")
136
137
  con.print(" [bold #a855f7]kp -c <commands...>[/] Execute commands concurrently in background parallel tracks")
137
138
  con.print(" [bold #a855f7]kp attach [id][/] Attach to running block as interactive sub-terminal (二级终端)")
138
139
  con.print(" [bold #a855f7]kp status[/] List session blocks, running status, PID, and exit codes")
@@ -140,6 +141,7 @@ def _handle_kp_command(
140
141
  con.print(" [bold #a855f7]kp stop <id>[/] Terminate a running background block\n")
141
142
  con.print("[bold white]Examples:[/]")
142
143
  con.print(" kp npm run dev (Runs dev server in background, prompt stays clean)")
144
+ con.print(" kp -s git pull\\npnpm build (Runs tasks sequentially in background)")
143
145
  con.print(" kp -c pnpm build:app\\npnpm build:docs (Runs build tasks concurrently in parallel)\n")
144
146
  return 0
145
147
 
@@ -262,6 +264,15 @@ def _handle_kp_command(
262
264
  elif sub.startswith("-c\n") or sub.startswith("--concurrent\n"):
263
265
  is_concurrent = True
264
266
  body = sub.split("\n", 1)[1]
267
+ elif sub.startswith("-s ") or sub.startswith("--sequential "):
268
+ is_concurrent = False
269
+ body = sub.split(" ", 1)[1]
270
+ elif sub == "-s" or sub == "--sequential":
271
+ con.print("[bold #f43f5e]Error:[/] Please provide commands to execute with 'kp -s'.\n")
272
+ return 1
273
+ elif sub.startswith("-s\n") or sub.startswith("--sequential\n"):
274
+ is_concurrent = False
275
+ body = sub.split("\n", 1)[1]
265
276
 
266
277
  commands = split_commands(body)
267
278
  if not commands:
@@ -20,19 +20,114 @@ from kapsel.core.block.model import BlockStatus, CommandBlock
20
20
  from kapsel.core.block.registry import get_block_registry
21
21
 
22
22
 
23
+ def _normalize_continuation_spaces(text: str) -> str:
24
+ """Replaces unquoted newlines (from continuation lines) with spaces and collapses indentation."""
25
+ if "\n" not in text:
26
+ return text
27
+
28
+ result = []
29
+ in_single = False
30
+ in_double = False
31
+ i = 0
32
+ while i < len(text):
33
+ ch = text[i]
34
+ if (ch == "\\" or ch == "`") and not in_single and i + 1 < len(text):
35
+ result.append(ch)
36
+ result.append(text[i + 1])
37
+ i += 2
38
+ continue
39
+ if ch == "'" and not in_double:
40
+ in_single = not in_single
41
+ result.append(ch)
42
+ elif ch == '"' and not in_single:
43
+ in_double = not in_double
44
+ result.append(ch)
45
+ elif ch == "\n" and not in_single and not in_double:
46
+ if result and result[-1] != " ":
47
+ result.append(" ")
48
+ while i + 1 < len(text) and text[i + 1] in (" ", "\t"):
49
+ i += 1
50
+ else:
51
+ result.append(ch)
52
+ i += 1
53
+
54
+ return "".join(result).strip()
55
+
56
+
23
57
  def split_commands(raw_text: str) -> List[str]:
24
58
  """
25
- Parses multi-line or delimited text into individual commands,
26
- ignoring empty lines and pure comments.
59
+ Parses multi-line or delimited text into individual executable commands.
60
+ - Preserves newlines within unclosed quotes ('', "").
61
+ - Handles line continuation characters ('\\' in POSIX/Bash, '`' in PowerShell)
62
+ by joining them into a single command with continuation characters stripped.
63
+ - Handles trailing pipeline ('|') and logical ('&&', '||') continuations.
64
+ - Ignores empty whitespace lines.
65
+ - Does NOT filter '#' lines (leaves comments/commands intact for the shell).
27
66
  """
28
- lines = raw_text.strip().splitlines()
29
- result: List[str] = []
67
+ if not raw_text or not raw_text.strip():
68
+ return []
69
+
70
+ lines = raw_text.splitlines()
71
+ commands: List[str] = []
72
+ current_parts: List[str] = []
73
+ in_single_quote = False
74
+ in_double_quote = False
75
+
30
76
  for line in lines:
31
- cleaned = line.strip()
32
- if not cleaned or cleaned.startswith("#"):
77
+ stripped = line.strip()
78
+ if not stripped and not current_parts and not in_single_quote and not in_double_quote:
79
+ continue
80
+
81
+ # Count quotes in this line, ignoring escaped quotes
82
+ i = 0
83
+ while i < len(line):
84
+ ch = line[i]
85
+ if (ch == "\\" or ch == "`") and not in_single_quote and i + 1 < len(line):
86
+ i += 2
87
+ continue
88
+ if ch == "'" and not in_double_quote:
89
+ in_single_quote = not in_single_quote
90
+ elif ch == '"' and not in_single_quote:
91
+ in_double_quote = not in_double_quote
92
+ i += 1
93
+
94
+ # If currently inside an open quote, line continues unconditionally
95
+ if in_single_quote or in_double_quote:
96
+ current_parts.append(line)
33
97
  continue
34
- result.append(cleaned)
35
- return result
98
+
99
+ # Outside quotes: check if line ends with a continuation token
100
+ r_stripped = line.rstrip()
101
+ is_continuation = False
102
+ continuation_clean = line
103
+
104
+ if r_stripped.endswith("\\"):
105
+ is_continuation = True
106
+ continuation_clean = r_stripped[:-1].rstrip()
107
+ elif r_stripped.endswith("`"):
108
+ is_continuation = True
109
+ continuation_clean = r_stripped[:-1].rstrip()
110
+ elif r_stripped.endswith("|") or r_stripped.endswith("&&") or r_stripped.endswith("||"):
111
+ is_continuation = True
112
+ continuation_clean = r_stripped
113
+
114
+ if is_continuation:
115
+ current_parts.append(continuation_clean)
116
+ else:
117
+ current_parts.append(line)
118
+ full_cmd = "\n".join(current_parts).strip()
119
+ full_cmd = _normalize_continuation_spaces(full_cmd)
120
+ if full_cmd:
121
+ commands.append(full_cmd)
122
+ current_parts = []
123
+
124
+ if current_parts:
125
+ leftover = "\n".join(current_parts).strip()
126
+ leftover = _normalize_continuation_spaces(leftover)
127
+ if leftover:
128
+ commands.append(leftover)
129
+
130
+ return commands
36
131
 
37
132
 
38
133
  def _run_single_command_process(cmd: str, cwd: str) -> Tuple[int, str, int]:
@@ -81,6 +81,11 @@ class CommandExecutor:
81
81
 
82
82
  def _handle_builtin(self, cmd: str) -> Optional[ExecutionSummary]:
83
83
  """Intercepts built-ins like cd, clear, cls."""
84
+ # Multi-line commands/scripts must bypass single-line built-in interception
85
+ # to prevent cmd.split() from collapsing lines into space-separated arguments.
86
+ if "\n" in cmd:
87
+ return None
88
+
84
89
  parts = cmd.split()
85
90
  if not parts:
86
91
  return None
@@ -220,7 +225,12 @@ class CommandExecutor:
220
225
  args = [exe, "-NoLogo", "-Command", cmd]
221
226
  elif shell == "cmd":
222
227
  exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "cmd.exe"
223
- args = [exe, "/c", cmd]
228
+ effective_cmd = (
229
+ " & ".join(line.strip() for line in cmd.splitlines() if line.strip())
230
+ if "\n" in cmd
231
+ else cmd
232
+ )
233
+ args = [exe, "/c", effective_cmd]
224
234
  else:
225
235
  # Unix shells (bash, zsh, fish, sh)
226
236
  exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "/bin/sh"
@@ -125,16 +125,7 @@ def get_pypi_upgrade_notice() -> Optional[str]:
125
125
  return latest_ver
126
126
  return None
127
127
 
128
- # If no cache exists, launch a quick worker thread with a fast join limit
129
- worker = threading.Thread(target=_background_fetch_worker, daemon=True)
130
- worker.start()
131
- worker.join(timeout=0.6)
132
-
133
- # Re-check cache after fast join
134
- cached_after = _read_cached_info()
135
- if cached_after is not None:
136
- latest_ver = str(cached_after.get("latest_version", ""))
137
- if parse_semver(latest_ver) > parse_semver(CURRENT_KAPSEL_VERSION):
138
- return latest_ver
139
-
128
+ # If no cache exists, launch worker asynchronously in the background without blocking startup
129
+ threading.Thread(target=_background_fetch_worker, daemon=True).start()
140
130
  return None
131
+
@@ -187,12 +187,13 @@ class BlockRoamingController:
187
187
 
188
188
  def enter_roaming(self, buffer) -> None:
189
189
  blocks = self.get_blocks()
190
+ self.is_active = True
191
+ self.saved_buffer_text = buffer.text
190
192
  if not blocks:
193
+ self.current_index = -1
191
194
  self.status_msg = "No command blocks in session history."
192
195
  return
193
196
 
194
- self.is_active = True
195
- self.saved_buffer_text = buffer.text
196
197
  self.current_index = len(blocks) - 1
197
198
  self.status_msg = ""
198
199
  self._sync_buffer_to_block(buffer)
@@ -467,21 +468,10 @@ def create_key_bindings(
467
468
  @kb.add("c-b")
468
469
  def _(event: KeyPressEvent) -> None:
469
470
  """
470
- Ctrl+B: Always opens Block Navigator / Roaming,
471
- allowing the user to roam across all blocks even if blocks are running in background.
472
- Pressing Enter on a running block inside the navigator enters the live sub-terminal.
471
+ Ctrl+B: Toggles inline Block Roaming Mode directly in the prompt line,
472
+ without launching a disruptive full-screen viewport.
473
473
  """
474
- from prompt_toolkit.application.run_in_terminal import in_terminal
475
- from kapsel.ui.viewport import run_block_viewport_async
476
-
477
- async def _launch() -> None:
478
- async with in_terminal():
479
- cmd = await run_block_viewport_async()
480
- if cmd is not None:
481
- event.current_buffer.text = cmd
482
- event.current_buffer.cursor_position = len(cmd)
483
-
484
- event.app.create_background_task(_launch())
474
+ roam_ctrl.toggle(event.current_buffer)
485
475
 
486
476
  @kb.add("up", filter=is_roaming)
487
477
  @kb.add("k", filter=is_roaming)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kapsel-cli
3
- Version: 0.6.8
3
+ Version: 0.7.2
4
4
  Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
5
5
  Author-email: MrEiu <k648888@vip.qq.com>
6
6
  License: MIT
@@ -96,10 +96,12 @@ tests/test_ai_spec_sanitizer.py
96
96
  tests/test_block_system.py
97
97
  tests/test_carapace_installer.py
98
98
  tests/test_config.py
99
+ tests/test_ctrl_b_roaming.py
99
100
  tests/test_dir_nav.py
100
101
  tests/test_history.py
101
102
  tests/test_i18n.py
102
- tests/test_kssh_input_bar.py
103
+ tests/test_kp_enhanced.py
104
+ tests/test_multiline_passthrough.py
103
105
  tests/test_plugin_ai.py
104
106
  tests/test_plugin_alias.py
105
107
  tests/test_plugin_autopilot.py
@@ -109,6 +111,8 @@ tests/test_plugin_fetcher.py
109
111
  tests/test_plugin_help.py
110
112
  tests/test_plugin_init.py
111
113
  tests/test_plugin_install.py
114
+ tests/test_plugin_install_batch.py
115
+ tests/test_plugin_kssh.py
112
116
  tests/test_plugin_lang.py
113
117
  tests/test_plugin_mend.py
114
118
  tests/test_plugin_portal.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kapsel-cli"
7
- version = "0.6.8"
7
+ version = "0.7.2"
8
8
  description = "Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -45,16 +45,16 @@ def test_ensure_carapace_installed_when_available():
45
45
  mock_install.assert_not_called()
46
46
 
47
47
 
48
- def test_ensure_carapace_installed_auto_bootstraps():
49
- """Verifies that ensure_carapace_installed triggers install_carapace on first run."""
48
+ def test_ensure_carapace_installed_when_missing_does_not_block():
49
+ """Verifies that ensure_carapace_installed does not trigger blocking network install when missing."""
50
50
  mock_engine = MagicMock()
51
51
  mock_engine.is_available.return_value = False
52
52
 
53
53
  with patch("kapsel.completion.carapace_engine.get_carapace_engine", return_value=mock_engine), \
54
- patch("kapsel.completion.carapace_installer.install_carapace", return_value=True) as mock_install:
54
+ patch("kapsel.completion.carapace_installer.install_carapace") as mock_install:
55
55
  res = ensure_carapace_installed()
56
- assert res is True
57
- mock_install.assert_called_once_with(console=mock_install.call_args[1]["console"], force=False)
56
+ assert res is False
57
+ mock_install.assert_not_called()
58
58
 
59
59
 
60
60
  def test_kps_registry_has_setup_completion():
@@ -0,0 +1,69 @@
1
+ """
2
+ Unit tests for Ctrl+B inline Block Roaming Mode.
3
+ Ensures Ctrl+B toggles inline roaming toolbar rather than launching full-screen viewport.
4
+ """
5
+
6
+ from unittest.mock import MagicMock
7
+ import pytest
8
+
9
+ from kapsel.core.block.model import BlockStatus, CommandBlock
10
+ from kapsel.core.block.registry import get_block_registry
11
+ from kapsel.storage.config import KapselConfig
12
+ from kapsel.ui.prompt import BlockRoamingController, create_key_bindings
13
+
14
+
15
+ def test_roam_ctrl_toggle_enters_and_exits():
16
+ """Toggling roam_ctrl activates inline roaming and then cleanly exits and restores buffer."""
17
+ reg = get_block_registry()
18
+ reg.clear_for_test() if hasattr(reg, "clear_for_test") else None
19
+
20
+ # Register a sample block
21
+ reg.add_block(command="echo test_block", exit_code=0, duration_ms=10)
22
+
23
+ roam = BlockRoamingController()
24
+ dummy_buffer = MagicMock()
25
+ dummy_buffer.text = "typing something"
26
+ dummy_buffer.cursor_position = len(dummy_buffer.text)
27
+
28
+ # 1. Toggle ON: enters roaming mode
29
+ roam.toggle(dummy_buffer)
30
+ assert roam.is_active is True
31
+ assert roam.saved_buffer_text == "typing something"
32
+ assert dummy_buffer.text == "echo test_block"
33
+
34
+ # 2. Toggle OFF: exits roaming mode and restores buffer text
35
+ roam.toggle(dummy_buffer)
36
+ assert roam.is_active is False
37
+ assert dummy_buffer.text == "typing something"
38
+
39
+
40
+ def test_ctrl_b_keybinding_triggers_toggle():
41
+ """Ctrl+B keybinding must trigger roam_ctrl.toggle without launching full-screen viewport."""
42
+ config = KapselConfig()
43
+ roam = BlockRoamingController()
44
+ kb = create_key_bindings(config, roaming=roam)
45
+
46
+ # Find the c-b binding
47
+ cb_binding = None
48
+ for binding in kb.bindings:
49
+ # Check if keys contain 'c-b'
50
+ keys_str = [str(k) for k in binding.keys]
51
+ if any("c-b" in k or k == "c-b" or "c-b" in str(binding.keys) for k in keys_str):
52
+ cb_binding = binding
53
+ break
54
+
55
+ assert cb_binding is not None, "Ctrl+B key binding should exist"
56
+
57
+ # Simulate pressing Ctrl+B
58
+ event = MagicMock()
59
+ event.current_buffer = MagicMock()
60
+ event.current_buffer.text = "current input"
61
+
62
+ # Roaming should initially be inactive
63
+ assert roam.is_active is False
64
+
65
+ # Call the c-b handler
66
+ cb_binding.call(event)
67
+
68
+ # Roaming should now be active directly in line (no full-screen launch)
69
+ assert roam.is_active is True
@@ -0,0 +1,111 @@
1
+ """
2
+ Unit tests for enhanced kp block runner:
3
+ - Robust command splitting (line continuations, unclosed quotes, keeping # lines)
4
+ - Concurrent (-c) vs Sequential (-s) block dispatching
5
+ """
6
+
7
+ import pytest
8
+ from kapsel.core.block.runner import split_commands
9
+ from kapsel.completion.kps.dispatcher import dispatch_kps
10
+
11
+
12
+ def test_split_commands_retains_hash_lines():
13
+ """Lines starting with # must NOT be filtered out, leaving them for the shell."""
14
+ text = "echo hello\n# this is a comment or root command\necho world"
15
+ cmds = split_commands(text)
16
+ assert cmds == ["echo hello", "# this is a comment or root command", "echo world"]
17
+
18
+
19
+ def test_split_commands_backslash_continuation():
20
+ """POSIX/Bash trailing backslash joins lines and removes the backslash."""
21
+ text = """docker run -d \\
22
+ --name web \\
23
+ -p 80:80 \\
24
+ nginx"""
25
+ cmds = split_commands(text)
26
+ assert len(cmds) == 1
27
+ assert cmds[0] == "docker run -d --name web -p 80:80 nginx"
28
+
29
+
30
+ def test_split_commands_powershell_backtick_continuation():
31
+ """PowerShell trailing backtick joins lines and removes the backtick."""
32
+ text = """Get-ChildItem `
33
+ -Filter *.py `
34
+ -Recurse"""
35
+ cmds = split_commands(text)
36
+ assert len(cmds) == 1
37
+ assert cmds[0] == "Get-ChildItem -Filter *.py -Recurse"
38
+
39
+
40
+ def test_split_commands_pipeline_and_logical_continuation():
41
+ """Trailing |, &&, and || lines automatically join with the subsequent line."""
42
+ text = """cat file.txt |
43
+ grep foo &&
44
+ wc -l"""
45
+ cmds = split_commands(text)
46
+ assert len(cmds) == 1
47
+ assert cmds[0] == "cat file.txt | grep foo && wc -l"
48
+
49
+
50
+ def test_split_commands_multiline_quotes_preserved():
51
+ """Newlines inside unclosed quotes are preserved as string data."""
52
+ text = """git commit -m "first line
53
+ second line"
54
+ git push"""
55
+ cmds = split_commands(text)
56
+ assert len(cmds) == 2
57
+ assert cmds[0] == 'git commit -m "first line\nsecond line"'
58
+ assert cmds[1] == "git push"
59
+
60
+
61
+ def test_dispatch_kp_sequential_flag(monkeypatch):
62
+ """'kp -s <cmd>' must dispatch with is_concurrent=False."""
63
+ captured = {}
64
+
65
+ def mock_async(commands, is_concurrent, **kwargs):
66
+ captured["commands"] = commands
67
+ captured["is_concurrent"] = is_concurrent
68
+
69
+ monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
70
+ code = dispatch_kps("kp -s echo 1\necho 2")
71
+ assert code == 0
72
+ assert captured["is_concurrent"] is False
73
+ assert captured["commands"] == ["echo 1", "echo 2"]
74
+
75
+
76
+ def test_dispatch_kp_concurrent_flag(monkeypatch):
77
+ """'kp -c <cmd>' must dispatch with is_concurrent=True."""
78
+ captured = {}
79
+
80
+ def mock_async(commands, is_concurrent, **kwargs):
81
+ captured["commands"] = commands
82
+ captured["is_concurrent"] = is_concurrent
83
+
84
+ monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
85
+ code = dispatch_kps("kp -c echo 1\necho 2")
86
+ assert code == 0
87
+ assert captured["is_concurrent"] is True
88
+ assert captured["commands"] == ["echo 1", "echo 2"]
89
+
90
+
91
+ def test_dispatch_kp_default_sequential(monkeypatch):
92
+ """'kp <cmd>' without flag defaults to is_concurrent=False."""
93
+ captured = {}
94
+
95
+ def mock_async(commands, is_concurrent, **kwargs):
96
+ captured["commands"] = commands
97
+ captured["is_concurrent"] = is_concurrent
98
+
99
+ monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
100
+ code = dispatch_kps("kp echo 1\necho 2")
101
+ assert code == 0
102
+ assert captured["is_concurrent"] is False
103
+ assert captured["commands"] == ["echo 1", "echo 2"]
104
+
105
+
106
+ def test_dispatch_kp_empty_flag_errors():
107
+ """'kp -s' or 'kp -c' alone with no commands returns error code 1."""
108
+ assert dispatch_kps("kp -s") == 1
109
+ assert dispatch_kps("kp -c") == 1
110
+ assert dispatch_kps("kp --sequential") == 1
111
+ assert dispatch_kps("kp --concurrent") == 1
@@ -0,0 +1,83 @@
1
+ """
2
+ Unit tests for multi-line command pass-through and built-in isolation in Kapsel.
3
+ Ensures multi-line commands are not broken or space-collapsed by single-line built-in handlers.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ import pytest
9
+
10
+ from kapsel.core.executor import CommandExecutor, ExecutionSummary
11
+
12
+
13
+ def test_builtin_single_line_cd_intercepted():
14
+ """Single-line 'cd' command must be intercepted by _handle_builtin."""
15
+ executor = CommandExecutor(shell_name="pwsh")
16
+ res = executor._handle_builtin("cd .")
17
+ assert res is not None
18
+ assert isinstance(res, ExecutionSummary)
19
+ assert res.is_builtin is True
20
+
21
+
22
+ def test_builtin_multiline_bypasses_interception():
23
+ """
24
+ Multi-line pasted commands (e.g. containing newlines) must bypass _handle_builtin
25
+ even if the first line starts with 'cd' or another builtin, preventing cmd.split()
26
+ from collapsing subsequent lines into space-delimited arguments.
27
+ """
28
+ executor = CommandExecutor(shell_name="pwsh")
29
+ multiline_cmd = "cd mydir\nnpm install\nnpm run dev"
30
+ res = executor._handle_builtin(multiline_cmd)
31
+ assert res is None
32
+
33
+
34
+ def test_builtin_touch_multiline_bypasses_interception():
35
+ """Multi-line text starting with touch must not be collapsed by touch handler."""
36
+ executor = CommandExecutor(shell_name="pwsh")
37
+ multiline_touch = "touch file.txt\necho hello"
38
+ res = executor._handle_builtin(multiline_touch)
39
+ assert res is None
40
+
41
+
42
+ def test_multiline_cmd_exe_formatting(monkeypatch):
43
+ """
44
+ In cmd.exe shell, multi-line commands should have non-empty lines joined
45
+ with ' & ' so cmd.exe /c executes them sequentially instead of truncating at newline.
46
+ """
47
+ executor = CommandExecutor(shell_name="cmd")
48
+ captured_args = []
49
+
50
+ def mock_run(args, **kwargs):
51
+ captured_args.extend(args)
52
+ class DummyProc:
53
+ returncode = 0
54
+ return DummyProc()
55
+
56
+ monkeypatch.setattr("subprocess.run", mock_run)
57
+ executor._run_in_host_shell("echo 1\necho 2\n\necho 3")
58
+
59
+ assert "cmd.exe" in captured_args[0]
60
+ assert captured_args[1] == "/c"
61
+ assert captured_args[2] == "echo 1 & echo 2 & echo 3"
62
+
63
+
64
+ def test_multiline_pwsh_raw_passthrough(monkeypatch):
65
+ """
66
+ In PowerShell or POSIX shells, multi-line scripts should pass through intact
67
+ as raw text to preserve native multi-line syntax (loops, here-docs, etc.).
68
+ """
69
+ executor = CommandExecutor(shell_name="pwsh")
70
+ captured_args = []
71
+
72
+ def mock_run(args, **kwargs):
73
+ captured_args.extend(args)
74
+ class DummyProc:
75
+ returncode = 0
76
+ return DummyProc()
77
+
78
+ monkeypatch.setattr("subprocess.run", mock_run)
79
+ raw_script = "Write-Host 'line1'\nWrite-Host 'line2'"
80
+ executor._run_in_host_shell(raw_script)
81
+
82
+ assert "-Command" in captured_args
83
+ assert captured_args[-1] == raw_script