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.
- {webbee-0.4.2 → webbee-0.4.3}/CHANGELOG.md +26 -0
- {webbee-0.4.2 → webbee-0.4.3}/PKG-INFO +2 -4
- {webbee-0.4.2 → webbee-0.4.3}/README.md +1 -3
- {webbee-0.4.2 → webbee-0.4.3}/pyproject.toml +1 -1
- webbee-0.4.3/src/webbee/__init__.py +1 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/config.py +4 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/modals.py +78 -15
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/native_read.py +26 -6
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/session.py +28 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tui.py +33 -11
- webbee-0.4.3/tests/test_cross_loop_token.py +54 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tui.py +15 -0
- webbee-0.4.2/src/webbee/__init__.py +0 -1
- {webbee-0.4.2 → webbee-0.4.3}/.github/workflows/publish.yml +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/.gitignore +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/LICENSE +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/install.sh +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/account.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/active_sessions.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/banner_art.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/boot.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/checkpoints.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/cli.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/coding_context.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/commands.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/consent.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/details.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/events.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/frames.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/home.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/home_view.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/http.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/instance_lock.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/contracts.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/embed.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/graph.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/models.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/query.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/service.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/intel/watch.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/media.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/mode_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/native_files.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/output_pane.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/queue_panel.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/reflow.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/remote.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/render.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/repl.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/repo.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/selection.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/sessions.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/sizing.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/slots.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/steer.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/stream.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tab_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tabs.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/thread.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tier_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/todo_panel.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tokens.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/tools.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/update.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/urlopen.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/wallet.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/src/webbee/worktrees.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/__init__.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/conftest.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_account.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_active_sessions.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_checkpoints.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_cli.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_clipboard.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_clipboard_read.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_coding_context.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_commands.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_config.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_details.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_events.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_freeze_fix.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_home.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_home_view.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_instance_lock.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_chunker.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_contracts.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_embed.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_graph.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_indexer.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_query.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_service.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_vectors.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_intel_watch.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_marathon.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_modals_e2e.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_mode_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_native_files.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_newtab_mode.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_packaging.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_reflow.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_render.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_repl.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_repo.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_session.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_sessions.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_sizing.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_slots.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_steer.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_stream.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tabs.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_thread.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tier_store.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_todo_panel.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tokens.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tools.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tools_vision_websearch.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_tui_hardening.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_update.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_urlopen.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_version.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.4.2 → webbee-0.4.3}/tests/test_wallet.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/webbee/)
|
|
54
|
-
[](https://pypi.org/project/webbee/)
|
|
55
55
|
[](LICENSE)
|
|
56
56
|
[](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
|
[](https://pypi.org/project/webbee/)
|
|
4
|
-
[](https://pypi.org/project/webbee/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](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
|
|
@@ -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
|
-
|
|
253
|
-
token = ""
|
|
254
|
-
if os.path.exists(cred_path):
|
|
252
|
+
if not token:
|
|
255
253
|
try:
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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"
|
|
188
|
-
f"
|
|
189
|
-
f"or
|
|
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
|
-
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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,
|
|
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
|
|
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
|