webbee 0.4.2__tar.gz → 0.4.3__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 (140) hide show
  1. {webbee-0.4.2 → webbee-0.4.3}/CHANGELOG.md +26 -0
  2. {webbee-0.4.2 → webbee-0.4.3}/PKG-INFO +2 -4
  3. {webbee-0.4.2 → webbee-0.4.3}/README.md +1 -3
  4. {webbee-0.4.2 → webbee-0.4.3}/pyproject.toml +1 -1
  5. webbee-0.4.3/src/webbee/__init__.py +1 -0
  6. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/config.py +4 -0
  7. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/modals.py +78 -15
  8. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/native_read.py +26 -6
  9. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/session.py +28 -0
  10. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tui.py +33 -11
  11. webbee-0.4.3/tests/test_cross_loop_token.py +54 -0
  12. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tui.py +15 -0
  13. webbee-0.4.2/src/webbee/__init__.py +0 -1
  14. {webbee-0.4.2 → webbee-0.4.3}/.github/workflows/publish.yml +0 -0
  15. {webbee-0.4.2 → webbee-0.4.3}/.gitignore +0 -0
  16. {webbee-0.4.2 → webbee-0.4.3}/LICENSE +0 -0
  17. {webbee-0.4.2 → webbee-0.4.3}/install.sh +0 -0
  18. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/account.py +0 -0
  19. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/active_sessions.py +0 -0
  20. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/banner_art.py +0 -0
  21. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/boot.py +0 -0
  22. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/cli.py +0 -0
  24. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard_read.py +0 -0
  26. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard_session.py +0 -0
  27. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/coding_context.py +0 -0
  28. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/commands.py +0 -0
  29. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/consent.py +0 -0
  30. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/details.py +0 -0
  31. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/events.py +0 -0
  32. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/frames.py +0 -0
  33. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/home.py +0 -0
  34. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/home_view.py +0 -0
  35. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/http.py +0 -0
  36. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/instance_lock.py +0 -0
  37. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/__init__.py +0 -0
  38. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/chunker.py +0 -0
  39. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/contracts.py +0 -0
  40. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/embed.py +0 -0
  41. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/graph.py +0 -0
  42. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/indexer.py +0 -0
  43. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/models.py +0 -0
  44. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/query.py +0 -0
  45. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/service.py +0 -0
  46. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/store.py +0 -0
  47. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/vectors.py +0 -0
  48. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/watch.py +0 -0
  49. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/media.py +0 -0
  50. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/mode_store.py +0 -0
  51. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/native_files.py +0 -0
  52. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/newtab_mode.py +0 -0
  53. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/output_pane.py +0 -0
  54. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/queue_panel.py +0 -0
  55. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/reflow.py +0 -0
  56. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/remote.py +0 -0
  57. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/render.py +0 -0
  58. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/repl.py +0 -0
  59. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/repo.py +0 -0
  60. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/selection.py +0 -0
  61. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/sessions.py +0 -0
  62. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/sizing.py +0 -0
  63. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/slots.py +0 -0
  64. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/steer.py +0 -0
  65. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/stream.py +0 -0
  66. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tab_store.py +0 -0
  67. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tabs.py +0 -0
  68. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/thread.py +0 -0
  69. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tier_store.py +0 -0
  70. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/todo_panel.py +0 -0
  71. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tokens.py +0 -0
  72. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tools.py +0 -0
  73. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/update.py +0 -0
  74. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/urlopen.py +0 -0
  75. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/wallet.py +0 -0
  76. {webbee-0.4.2 → webbee-0.4.3}/src/webbee/worktrees.py +0 -0
  77. {webbee-0.4.2 → webbee-0.4.3}/tests/__init__.py +0 -0
  78. {webbee-0.4.2 → webbee-0.4.3}/tests/conftest.py +0 -0
  79. {webbee-0.4.2 → webbee-0.4.3}/tests/test_account.py +0 -0
  80. {webbee-0.4.2 → webbee-0.4.3}/tests/test_active_sessions.py +0 -0
  81. {webbee-0.4.2 → webbee-0.4.3}/tests/test_audit_2026_07_25_findings.py +0 -0
  82. {webbee-0.4.2 → webbee-0.4.3}/tests/test_checkpoints.py +0 -0
  83. {webbee-0.4.2 → webbee-0.4.3}/tests/test_cli.py +0 -0
  84. {webbee-0.4.2 → webbee-0.4.3}/tests/test_clipboard.py +0 -0
  85. {webbee-0.4.2 → webbee-0.4.3}/tests/test_clipboard_read.py +0 -0
  86. {webbee-0.4.2 → webbee-0.4.3}/tests/test_coding_context.py +0 -0
  87. {webbee-0.4.2 → webbee-0.4.3}/tests/test_commands.py +0 -0
  88. {webbee-0.4.2 → webbee-0.4.3}/tests/test_config.py +0 -0
  89. {webbee-0.4.2 → webbee-0.4.3}/tests/test_cpc_contract_stable.py +0 -0
  90. {webbee-0.4.2 → webbee-0.4.3}/tests/test_details.py +0 -0
  91. {webbee-0.4.2 → webbee-0.4.3}/tests/test_events.py +0 -0
  92. {webbee-0.4.2 → webbee-0.4.3}/tests/test_freeze_fix.py +0 -0
  93. {webbee-0.4.2 → webbee-0.4.3}/tests/test_home.py +0 -0
  94. {webbee-0.4.2 → webbee-0.4.3}/tests/test_home_view.py +0 -0
  95. {webbee-0.4.2 → webbee-0.4.3}/tests/test_instance_lock.py +0 -0
  96. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_chunker.py +0 -0
  97. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_contracts.py +0 -0
  98. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_embed.py +0 -0
  99. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_graph.py +0 -0
  100. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_indexer.py +0 -0
  101. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_no_numpy.py +0 -0
  102. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_query.py +0 -0
  103. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_service.py +0 -0
  104. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_store.py +0 -0
  105. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_vectors.py +0 -0
  106. {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_watch.py +0 -0
  107. {webbee-0.4.2 → webbee-0.4.3}/tests/test_marathon.py +0 -0
  108. {webbee-0.4.2 → webbee-0.4.3}/tests/test_modals_e2e.py +0 -0
  109. {webbee-0.4.2 → webbee-0.4.3}/tests/test_mode_store.py +0 -0
  110. {webbee-0.4.2 → webbee-0.4.3}/tests/test_native_files.py +0 -0
  111. {webbee-0.4.2 → webbee-0.4.3}/tests/test_newtab_mode.py +0 -0
  112. {webbee-0.4.2 → webbee-0.4.3}/tests/test_packaging.py +0 -0
  113. {webbee-0.4.2 → webbee-0.4.3}/tests/test_queue_manage_0337.py +0 -0
  114. {webbee-0.4.2 → webbee-0.4.3}/tests/test_reflow.py +0 -0
  115. {webbee-0.4.2 → webbee-0.4.3}/tests/test_render.py +0 -0
  116. {webbee-0.4.2 → webbee-0.4.3}/tests/test_repl.py +0 -0
  117. {webbee-0.4.2 → webbee-0.4.3}/tests/test_repo.py +0 -0
  118. {webbee-0.4.2 → webbee-0.4.3}/tests/test_session.py +0 -0
  119. {webbee-0.4.2 → webbee-0.4.3}/tests/test_sessions.py +0 -0
  120. {webbee-0.4.2 → webbee-0.4.3}/tests/test_sizing.py +0 -0
  121. {webbee-0.4.2 → webbee-0.4.3}/tests/test_slots.py +0 -0
  122. {webbee-0.4.2 → webbee-0.4.3}/tests/test_steer.py +0 -0
  123. {webbee-0.4.2 → webbee-0.4.3}/tests/test_stream.py +0 -0
  124. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tab_restore_0337.py +0 -0
  125. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tabs.py +0 -0
  126. {webbee-0.4.2 → webbee-0.4.3}/tests/test_terminal_ux_0336.py +0 -0
  127. {webbee-0.4.2 → webbee-0.4.3}/tests/test_thread.py +0 -0
  128. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tier_store.py +0 -0
  129. {webbee-0.4.2 → webbee-0.4.3}/tests/test_todo_panel.py +0 -0
  130. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tokens.py +0 -0
  131. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tools.py +0 -0
  132. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tools_vision_websearch.py +0 -0
  133. {webbee-0.4.2 → webbee-0.4.3}/tests/test_tui_hardening.py +0 -0
  134. {webbee-0.4.2 → webbee-0.4.3}/tests/test_update.py +0 -0
  135. {webbee-0.4.2 → webbee-0.4.3}/tests/test_update_badge_0340.py +0 -0
  136. {webbee-0.4.2 → webbee-0.4.3}/tests/test_urlopen.py +0 -0
  137. {webbee-0.4.2 → webbee-0.4.3}/tests/test_version.py +0 -0
  138. {webbee-0.4.2 → webbee-0.4.3}/tests/test_version_badge_corner.py +0 -0
  139. {webbee-0.4.2 → webbee-0.4.3}/tests/test_wallet.py +0 -0
  140. {webbee-0.4.2 → webbee-0.4.3}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3
4
+
5
+ **Webbee v0.4.3: Non-Blocking Voice STT, Cross-Event-Loop Token Bridge, Dynamic Composer Geometry & Config Hardening.**
6
+
7
+ This release makes voice notes truly seamless by decoupling modal teardown from transcription (zero TUI freezes), adds a thread-safe cross-event-loop token bridge for worker-thread file reading across Python 3.6 - 3.14+, accurately aligns multi-line composer wrap bounds to prevent prompt overflow, and eliminates filesystem path hardcoding.
8
+
9
+ ### Highlights & New Capabilities
10
+
11
+ * **Non-Blocking Background Voice STT (`webbee.modals`)**:
12
+ - The voice recording dialog now dismisses immediately upon clicking `[▶ Send]`, releasing the UI modal lock with zero delay.
13
+ - Displays a clean transient status note (`🎙️ transcribing voice note…`) in the output pane while background audio decoding executes.
14
+ - Audio transcription via `/v1/voice/stt` runs asynchronously in a worker thread and safely schedules its result back to prompt_toolkit via `loop.call_soon_threadsafe`.
15
+
16
+ * **Cross-Event-Loop Token Bridge for Native File Reads (`webbee.session`, `webbee.native_read`)**:
17
+ - Resolved `RuntimeError: Task attached to a different loop` when background worker threads invoke `read_native` with the session client.
18
+ - Implemented an `asyncio.run_coroutine_threadsafe` bridge for token provider acquisition across thread boundaries.
19
+ - Universal `_run_sync` helper supporting Python 3.6 through 3.14+ seamlessly.
20
+
21
+ * **Accurate Composer Geometry & Proactive Wrap Calculation (`webbee.tui`)**:
22
+ - Refactored `input_rows` to strictly account for `BeforeInput` prefix buttons (`📎 🎤 ⋮ ❯ `, 16 display columns) and line continuation gutters (`┊ `, 2 display columns).
23
+ - Uses true display-cell widths (`get_cwidth` / `wcwidth`) for Unicode and emojis, ensuring the multi-line input box dynamically grows the exact moment text reaches the frame border rather than hiding behind a 2-4 word scroll lag.
24
+
25
+ * **Configuration & Credential Storage Hardening (`webbee.config`)**:
26
+ - Added dedicated `voice_dir` and `credentials_file` configuration hooks to `Config` with environment overrides (`IMPERAL_VOICE_DIR`, `IMPERAL_CREDENTIALS_FILE`).
27
+ - Removed hardcoded filesystem paths across modals and voice components, respecting session configuration and OS cache directories.
28
+
3
29
  ## 0.4.2
4
30
 
5
31
  **Webbee v0.4.2: Universal `shell` Action Across Windows/macOS/Linux, Interactive TUI Modals, Cloud Voice STT & Complete Hover Engine.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
51
51
  # Webbee 🐝 — The Open-Source ICNLI Coding Agent
52
52
 
53
53
  [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
54
- [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
54
+ [![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
55
55
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
56
56
  [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
57
57
 
@@ -92,8 +92,6 @@ python3 -m venv .venv && source .venv/bin/activate
92
92
  pip install webbee
93
93
  ```
94
94
 
95
- > **Note**: Python 3.11+ is required.
96
-
97
95
  ---
98
96
 
99
97
  ## 💡 Quick Start
@@ -1,7 +1,7 @@
1
1
  # Webbee 🐝 — The Open-Source ICNLI Coding Agent
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/webbee)](https://pypi.org/project/webbee/)
4
- [![Python](https://img.shields.io/pypi/pyversions/webbee)](https://pypi.org/project/webbee/)
4
+ [![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab?logo=python&logoColor=white)](https://pypi.org/project/webbee/)
5
5
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
6
6
  [![Docs](https://img.shields.io/badge/docs-imperal.io-00afd7)](https://docs.imperal.io)
7
7
 
@@ -42,8 +42,6 @@ python3 -m venv .venv && source .venv/bin/activate
42
42
  pip install webbee
43
43
  ```
44
44
 
45
- > **Note**: Python 3.11+ is required.
46
-
47
45
  ---
48
46
 
49
47
  ## 💡 Quick Start
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = "0.4.3"
@@ -7,6 +7,8 @@ class Config:
7
7
  panel_url: str
8
8
  intel_enabled: bool = True # off switch for the on-disk index/watcher (env IMPERAL_INTEL=false)
9
9
  cache_dir: str = "" # "" -> IntelService's own default (~/.cache/webbee/intel)
10
+ voice_dir: str = "" # "" -> ~/.webbee/voice
11
+ credentials_file: str = "" # "" -> ~/.imperal/credentials.json
10
12
 
11
13
  @classmethod
12
14
  def from_env(cls) -> "Config":
@@ -15,4 +17,6 @@ class Config:
15
17
  panel_url=os.environ.get("IMPERAL_PANEL_URL", "https://panel.imperal.io").rstrip("/"),
16
18
  intel_enabled=os.environ.get("IMPERAL_INTEL", "true") != "false",
17
19
  cache_dir=os.environ.get("IMPERAL_INTEL_CACHE_DIR", ""),
20
+ voice_dir=os.environ.get("IMPERAL_VOICE_DIR", ""),
21
+ credentials_file=os.environ.get("IMPERAL_CREDENTIALS_FILE", ""),
18
22
  )
@@ -237,7 +237,7 @@ def get_system_audio_capture_args(output_path: str) -> List[str]:
237
237
  return ["ffmpeg", "-y", "-i", "default", output_path]
238
238
 
239
239
 
240
- def transcribe_audio_file(audio_path: str) -> str:
240
+ def transcribe_audio_file(audio_path: str, api_url: str = "", token: str = "") -> str:
241
241
  """Transcribe audio file via Imperal Cloud System Voice STT endpoint (/v1/voice/stt).
242
242
 
243
243
  Works on ANY OS (macOS, Linux, Windows) seamlessly without local dependencies.
@@ -249,16 +249,25 @@ def transcribe_audio_file(audio_path: str) -> str:
249
249
  try:
250
250
  import json, urllib.request, urllib.error, uuid
251
251
 
252
- cred_path = os.path.expanduser("~/.imperal/credentials.json")
253
- token = ""
254
- if os.path.exists(cred_path):
252
+ if not token:
255
253
  try:
256
- data = json.load(open(cred_path))
257
- token = data.get("access_token", "")
254
+ from webbee.config import Config
255
+ cfg = Config.from_env()
256
+ cred_path = getattr(cfg, "credentials_file", "") or os.path.expanduser("~/.imperal/credentials.json")
257
+ if os.path.exists(cred_path):
258
+ data = json.load(open(cred_path))
259
+ token = data.get("access_token", "")
258
260
  except Exception:
259
261
  pass
260
262
 
261
- api_url = os.environ.get("IMPERAL_API_URL", "https://auth.imperal.io").rstrip("/")
263
+ if not api_url:
264
+ try:
265
+ from webbee.config import Config
266
+ api_url = Config.from_env().api_url
267
+ except Exception:
268
+ api_url = os.environ.get("IMPERAL_API_URL", "https://auth.imperal.io")
269
+
270
+ api_url = api_url.rstrip("/")
262
271
  url = f"{api_url}/v1/voice/stt"
263
272
 
264
273
  with open(audio_path, "rb") as f:
@@ -367,10 +376,22 @@ def create_mic_dialog(
367
376
  on_transcribe: Callable[[str], None],
368
377
  on_send_direct: Callable[[str], None],
369
378
  on_close: Callable[[], None],
379
+ *,
380
+ api_url: str = "",
381
+ token: str = "",
382
+ voice_dir: str = "",
383
+ on_status: Optional[Callable[[str], None]] = None,
370
384
  ) -> Dialog:
371
385
  """Create Voice Recording (STT) dialog matching standard modal design with exactly 2 buttons: [▶ Send] and [✕ Cancel]."""
386
+ import threading
372
387
  start_time = time.time()
373
- voice_dir = os.path.expanduser("~/.webbee/voice")
388
+ if not voice_dir:
389
+ try:
390
+ from webbee.config import Config
391
+ cfg = Config.from_env()
392
+ voice_dir = getattr(cfg, "voice_dir", "") or os.path.expanduser("~/.webbee/voice")
393
+ except Exception:
394
+ voice_dir = os.path.expanduser("~/.webbee/voice")
374
395
  os.makedirs(voice_dir, exist_ok=True)
375
396
  voice_filename = f"voice_{int(time.time())}.wav"
376
397
  voice_path = os.path.join(voice_dir, voice_filename)
@@ -410,13 +431,55 @@ def create_mic_dialog(
410
431
  def _handle_send():
411
432
  rec_seconds = _stop_rec()
412
433
  on_close()
413
- # Direct STT via Imperal Cloud STT: transcribe audio into plain text instruction
414
- text = transcribe_audio_file(voice_path)
415
- if text and text.strip() and text.strip().lower() not in ("you", "thank you", "silence", "."):
416
- on_send_direct(text.strip())
417
- else:
418
- # If STT returns empty or silence, send informative turn
419
- on_send_direct(f"🎙️ [Voice note: {rec_seconds}s audio — {text.strip() if text else 'audio recorded'}]")
434
+
435
+ if on_status is not None:
436
+ try:
437
+ on_status("🎙️ transcribing voice note…")
438
+ except Exception:
439
+ pass
440
+
441
+ def _worker():
442
+ try:
443
+ text = transcribe_audio_file(voice_path, api_url=api_url, token=token)
444
+ def _apply():
445
+ if on_status is not None:
446
+ try:
447
+ on_status("")
448
+ except Exception:
449
+ pass
450
+ if text and text.strip() and text.strip().lower() not in ("you", "thank you", "silence", "."):
451
+ on_send_direct(text.strip())
452
+ else:
453
+ desc = text.strip() if text else "audio recorded"
454
+ on_send_direct(f"🎙️ [Voice note: {rec_seconds}s audio — {desc}]")
455
+
456
+ app = get_app_or_none()
457
+ if app is not None and hasattr(app, "loop") and app.loop is not None and app.loop.is_running():
458
+ app.loop.call_soon_threadsafe(_apply)
459
+ else:
460
+ _apply()
461
+ except Exception:
462
+ app = get_app_or_none()
463
+ def _fail():
464
+ if on_status is not None:
465
+ try:
466
+ on_status("")
467
+ except Exception:
468
+ pass
469
+ on_send_direct(f"🎙️ [Voice note: {rec_seconds}s audio — transcription unavailable]")
470
+ if app is not None and hasattr(app, "loop") and app.loop is not None and app.loop.is_running():
471
+ app.loop.call_soon_threadsafe(_fail)
472
+ else:
473
+ _fail()
474
+ finally:
475
+ try:
476
+ if os.path.exists(voice_path):
477
+ os.remove(voice_path)
478
+ except Exception:
479
+ pass
480
+
481
+ t = threading.Thread(target=_worker, daemon=True)
482
+ t.start()
420
483
 
421
484
  def _handle_cancel():
422
485
  _stop_rec()
@@ -184,9 +184,9 @@ async def read_native_async(client, abs_path: str, rel: str, *,
184
184
  if size > MAX_UPLOAD_BYTES:
185
185
  return {"ok": False, "native": True, "kind": kind,
186
186
  "content": (f"⟦ {rel} · {human_size(size)} · {kind} ⟧\n"
187
- f"Too large to read inline (limit {human_size(MAX_UPLOAD_BYTES)}). "
188
- f"Upload it in the panel's File Reader and read it by file_id, "
189
- f"or point me at a smaller export.")}
187
+ f"File size exceeds inline buffer limit ({human_size(MAX_UPLOAD_BYTES)}). "
188
+ f"To prevent memory bloat and payload limits, use windowed reading with offset/limit, "
189
+ f"inspect metadata via shell, or upload in the panel's File Reader to read by file_id.")}
190
190
 
191
191
  with open(abs_path, "rb") as f:
192
192
  data = f.read()
@@ -253,13 +253,33 @@ def _shape(item: dict, *, rel: str, kind: str, size: int, file_id: str,
253
253
  "truncated": has_more, "total_chars": total}
254
254
 
255
255
 
256
+ def _run_sync(coro):
257
+ """Run a coroutine to completion synchronously in a worker thread.
258
+
259
+ Fully compatible across Python 3.6 - 3.14+:
260
+ * Python 3.7 - 3.14+: uses standard asyncio.run().
261
+ * Python 3.6 fallback: creates a new event loop and runs until complete.
262
+ """
263
+ if hasattr(asyncio, "run"):
264
+ return asyncio.run(coro)
265
+ loop = asyncio.new_event_loop()
266
+ try:
267
+ asyncio.set_event_loop(loop)
268
+ return loop.run_until_complete(coro)
269
+ finally:
270
+ try:
271
+ loop.close()
272
+ finally:
273
+ asyncio.set_event_loop(None)
274
+
275
+
256
276
  def read_native(client_factory, abs_path: str, rel: str, *,
257
277
  offset: int = 0, limit: int = 0) -> dict:
258
278
  """Synchronous entry point for LocalToolExecutor (which runs in a worker
259
279
  thread, never on the event loop -- so a fresh loop here is safe and cannot
260
280
  deadlock the REPL).
261
281
 
262
- ``client_factory`` is a zero-arg callable returning an ImperalClient; it is
282
+ client_factory is a zero-arg callable returning an ImperalClient; it is
263
283
  a factory (not a client) so a plain local run with no cloud reach simply
264
284
  has none, and this whole path degrades to one honest sentence.
265
285
  """
@@ -275,8 +295,8 @@ def read_native(client_factory, abs_path: str, rel: str, *,
275
295
  f"which this session cannot reach right now.")}
276
296
  try:
277
297
  client = client_factory()
278
- return asyncio.run(read_native_async(client, abs_path, rel,
279
- offset=offset, limit=limit))
298
+ return _run_sync(read_native_async(client, abs_path, rel,
299
+ offset=offset, limit=limit))
280
300
  except Exception as e: # noqa: BLE001 -- a read must never crash the turn
281
301
  return {"ok": False, "native": True, "kind": kind, "content": (
282
302
  f"⟦ {rel} · {human_size(size)} · {kind} ⟧\n"
@@ -132,13 +132,41 @@ class AgentSession:
132
132
  its own event loop, so the client must be built there, not shared from
133
133
  here. Returns None when this session has no cloud config -- the read
134
134
  then degrades to one honest sentence instead of a crash.
135
+
136
+ Threadsafe Loop-Bridge (Python 3.6 - 3.14+):
137
+ token_provider is bound to the main event loop's lock. We bridge it across
138
+ thread boundaries via asyncio.run_coroutine_threadsafe so worker event loops
139
+ never raise RuntimeError: Lock is bound to a different event loop.
135
140
  """
136
141
  cfg, token_provider = self.cfg, self.token_provider
137
142
  if cfg is None or token_provider is None:
138
143
  return None
139
144
 
145
+ import asyncio
146
+
147
+ try:
148
+ main_loop = asyncio.get_running_loop()
149
+ except RuntimeError:
150
+ try:
151
+ main_loop = asyncio.get_event_loop()
152
+ except RuntimeError:
153
+ main_loop = None
154
+
140
155
  def _make():
141
156
  from imperal_mcp.client import ImperalClient
157
+
158
+ if main_loop is not None and main_loop.is_running():
159
+ async def _bridged_token_provider() -> str:
160
+ try:
161
+ current_loop = asyncio.get_running_loop()
162
+ except RuntimeError:
163
+ current_loop = None
164
+ if current_loop is main_loop:
165
+ return await token_provider()
166
+ fut = asyncio.run_coroutine_threadsafe(token_provider(), main_loop)
167
+ return await asyncio.wrap_future(fut)
168
+ return ImperalClient(cfg, _bridged_token_provider)
169
+
142
170
  return ImperalClient(cfg, token_provider)
143
171
 
144
172
  return _make
@@ -266,20 +266,30 @@ def enable_csi_u_newline() -> bool:
266
266
  return True
267
267
 
268
268
 
269
- def input_rows(text: str, cols: int, cap: int) -> int:
269
+ def input_rows(text: str, cols: int, cap: int, prompt_cols: int = 0, cont_cols: int = 0) -> int:
270
270
  """PURE row-wrap estimator behind `_input_height` (module-level so tests
271
271
  drive it directly with an injected size, mirroring repl._gate_busy).
272
- Same wrap math as before the W2 proportional-sizing pass: `cols` is the
273
- usable wrap width (frame + prompt already subtracted by the caller,
274
- floored at 10 so a tiny/misreported width never collapses every line to
275
- 1-char rows); `cap` bounds growth (was: hardcoded 10, now the caller's
276
- live `sizing.input_height_cap(rows)` — the box may grow to at most a
277
- PROPORTION of the screen, not a fixed character count)."""
272
+ Accounts for prompt prefix width (BeforeInput buttons/gutter), continuation
273
+ prefix width, explicit newlines, and true display-cell widths (CWidth / Unicode).
274
+ Floored at 10 so a tiny/misreported width never collapses lines; `cap` bounds
275
+ growth proportionally to screen height."""
278
276
  if not text:
279
277
  return 1
280
278
  cols = max(10, cols)
281
- rows = sum(max(1, -(-len(ln) // cols)) for ln in text.split("\n"))
282
- return min(cap, max(1, rows))
279
+ from prompt_toolkit.utils import get_cwidth
280
+ total_rows = 0
281
+ for idx, ln in enumerate(text.split("\n")):
282
+ ln_w = sum(get_cwidth(c) for c in ln)
283
+ p_cols = prompt_cols if idx == 0 else 0
284
+ usable_first = max(10, cols - p_cols)
285
+ usable_cont = max(10, cols - cont_cols)
286
+ if ln_w <= usable_first:
287
+ total_rows += 1
288
+ else:
289
+ rem = ln_w - usable_first
290
+ cont = -(-rem // usable_cont)
291
+ total_rows += 1 + cont
292
+ return min(cap, max(1, total_rows))
283
293
 
284
294
 
285
295
  def next_mode(mode: str) -> str:
@@ -1896,7 +1906,7 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
1896
1906
  cols, rows = sizing.get_size(get_app_or_none())
1897
1907
  ri = getattr(input_win, "render_info", None)
1898
1908
  width = getattr(ri, "window_width", None) if ri is not None else None
1899
- return input_rows(buf.text, width or (cols - 4), sizing.input_height_cap(rows))
1909
+ return input_rows(buf.text, width or (cols - 4), sizing.input_height_cap(rows), prompt_cols=16, cont_cols=2)
1900
1910
 
1901
1911
  _prompt_hover = {"target": None}
1902
1912
 
@@ -2007,8 +2017,20 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None,
2007
2017
  slot.draft_cursor = 0
2008
2018
  if t.strip():
2009
2019
  _start_turn(t)
2020
+ pane = _pane()
2021
+ def _mic_status(msg: str):
2022
+ if pane is not None and hasattr(pane, "flash_note"):
2023
+ pane.flash_note(msg, secs=10.0 if msg else 0.0)
2024
+ from prompt_toolkit.application import get_app_or_none
2025
+ app = get_app_or_none()
2026
+ if app is not None:
2027
+ app.invalidate()
2028
+
2010
2029
  _set_modal_active(create_mic_dialog(
2011
- on_transcribe=_transcribe, on_send_direct=_send_direct, on_close=_close_modal
2030
+ on_transcribe=_transcribe,
2031
+ on_send_direct=_send_direct,
2032
+ on_close=_close_modal,
2033
+ on_status=_mic_status,
2012
2034
  ))
2013
2035
  return None
2014
2036
 
@@ -0,0 +1,54 @@
1
+ import asyncio
2
+ import sys
3
+ import threading
4
+ import types
5
+ import pytest
6
+ from webbee.session import AgentSession
7
+
8
+ class FakeCfg:
9
+ api_url = "https://api.imperal.io"
10
+
11
+ try:
12
+ import imperal_mcp.client
13
+ except ImportError:
14
+ if "imperal_mcp" not in sys.modules:
15
+ pkg = types.ModuleType("imperal_mcp")
16
+ sys.modules["imperal_mcp"] = pkg
17
+ if "imperal_mcp.client" not in sys.modules:
18
+ m = types.ModuleType("imperal_mcp.client")
19
+ class FakeImperalClient:
20
+ def __init__(self, cfg, token_provider=None):
21
+ self.cfg = cfg
22
+ self._token_provider = token_provider
23
+ m.ImperalClient = FakeImperalClient
24
+ sys.modules["imperal_mcp.client"] = m
25
+ sys.modules["imperal_mcp"].client = m
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_bridge_cross_loop():
29
+ main_loop = asyncio.get_running_loop()
30
+ main_lock = asyncio.Lock()
31
+ await main_lock.acquire()
32
+
33
+ async def mock_token_provider():
34
+ async with main_lock:
35
+ return "jwt-token-verified"
36
+
37
+ session = AgentSession(FakeCfg(), mock_token_provider, ".")
38
+ factory = session._file_client_factory()
39
+ assert factory is not None
40
+
41
+ def worker():
42
+ async def work():
43
+ client = factory()
44
+ token = await client._token_provider()
45
+ return token
46
+ return asyncio.run(work())
47
+
48
+ t = threading.Thread(target=worker)
49
+ t.start()
50
+ await asyncio.sleep(0.05)
51
+ main_lock.release()
52
+ while t.is_alive():
53
+ await asyncio.sleep(0.01)
54
+ t.join()
@@ -1800,6 +1800,21 @@ def test_input_rows_floors_a_tiny_or_zero_width():
1800
1800
  assert input_rows("x" * 30, 0, 10) == 3 # cols floored at 10 -> ceil(30/10)
1801
1801
 
1802
1802
 
1803
+ def test_input_rows_accounts_for_prompt_prefix_and_cwidth():
1804
+ """Guarantees that input_rows accurately accounts for BeforeInput prompt buttons
1805
+ (16 columns) and continuation prefix (2 columns) as well as wide characters,
1806
+ preventing composer text from overflowing 2-4 words before growing."""
1807
+ from webbee.tui import input_rows
1808
+ # Inside 78-col frame, prompt takes 16 cols -> line 1 usable width is 62 cols.
1809
+ # 60 chars fit on line 1 -> 1 row
1810
+ assert input_rows("a" * 60, 78, 10, prompt_cols=16, cont_cols=2) == 1
1811
+ # 65 chars overflow line 1 by 3 chars -> wraps to 2 rows
1812
+ assert input_rows("a" * 65, 78, 10, prompt_cols=16, cont_cols=2) == 2
1813
+ # Unicode / wide characters (e.g. 35 emojis with display width 2 each = 70 cols)
1814
+ assert input_rows("🐝" * 30, 78, 10, prompt_cols=16, cont_cols=2) == 1 # 60 cols
1815
+ assert input_rows("🐝" * 35, 78, 10, prompt_cols=16, cont_cols=2) == 2 # 70 cols
1816
+
1817
+
1803
1818
  # ── P5g: Esc/Ctrl-C stop the SERVER turn, not just the local task ────────────
1804
1819
  # Previously Ctrl-C only cancelled the local asyncio task while the cloud
1805
1820
  # brain kept running server-side; Esc did nothing while busy. Both key
@@ -1 +0,0 @@
1
- __version__ = "0.4.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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