webbee 0.3.33__tar.gz → 0.3.35__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.3.33 → webbee-0.3.35}/CHANGELOG.md +29 -0
- {webbee-0.3.33 → webbee-0.3.35}/PKG-INFO +1 -1
- {webbee-0.3.33 → webbee-0.3.35}/pyproject.toml +1 -1
- webbee-0.3.35/src/webbee/__init__.py +1 -0
- webbee-0.3.35/src/webbee/clipboard_read.py +154 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/home_view.py +3 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/chunker.py +24 -1
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/query.py +10 -1
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/service.py +30 -1
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/store.py +6 -1
- webbee-0.3.35/src/webbee/intel/vectors.py +95 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/watch.py +3 -1
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/mode_store.py +26 -2
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/output_pane.py +8 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/repl.py +83 -1
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/tui.py +93 -9
- {webbee-0.3.33 → webbee-0.3.35}/tests/conftest.py +18 -0
- webbee-0.3.35/tests/test_audit_2026_07_25_findings.py +479 -0
- webbee-0.3.35/tests/test_clipboard_read.py +84 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_chunker.py +3 -1
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_repl.py +26 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_tui.py +27 -9
- webbee-0.3.33/src/webbee/__init__.py +0 -1
- webbee-0.3.33/src/webbee/intel/vectors.py +0 -72
- {webbee-0.3.33 → webbee-0.3.35}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/.gitignore +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/LICENSE +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/README.md +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/install.sh +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/account.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/boot.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/cli.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/commands.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/config.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/consent.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/details.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/events.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/frames.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/home.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/http.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/reflow.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/remote.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/render.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/repo.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/selection.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/session.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/sizing.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/slots.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/steer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/stream.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/tabs.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/thread.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/tokens.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/tools.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/update.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/wallet.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/__init__.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_account.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_cli.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_clipboard.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_coding_context.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_commands.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_config.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_details.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_events.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_home.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_home_view.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_query.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_service.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_marathon.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_mode_store.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_packaging.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_reflow.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_render.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_repo.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_session.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_sessions.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_sizing.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_slots.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_steer.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_stream.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_tabs.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_thread.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_tokens.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_tools.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_update.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_urlopen.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_version.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_wallet.py +0 -0
- {webbee-0.3.33 → webbee-0.3.35}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.35
|
|
4
|
+
|
|
5
|
+
Reliability and speed of the terminal itself.
|
|
6
|
+
|
|
7
|
+
- Code intelligence no longer dies silently at startup. On many repositories it
|
|
8
|
+
failed while building its index and the terminal simply said "code intel off"
|
|
9
|
+
with no reason — semantic search and the code graph were quietly unavailable
|
|
10
|
+
for the whole session. It now builds correctly (and old broken caches are
|
|
11
|
+
rebuilt automatically).
|
|
12
|
+
- Resizing the window is smooth again: dragging a split no longer re-wraps the
|
|
13
|
+
text on every intermediate width, so the terminal stops freezing mid-drag.
|
|
14
|
+
- Ctrl+V no longer blocks the terminal while reading the clipboard, so pasting
|
|
15
|
+
a large image can't lock up the interface.
|
|
16
|
+
- Switching tabs with Shift+TAB is instant again — it no longer runs a `git`
|
|
17
|
+
command each time (which could stall if git itself hung).
|
|
18
|
+
- Building the project index skips virtualenvs and dependency caches, so a
|
|
19
|
+
large in-tree environment can no longer crowd out your own source files.
|
|
20
|
+
On this repository the full pass went from ~31s to ~1s.
|
|
21
|
+
- Fixed a rare case where a remote approval prompt could vanish before you
|
|
22
|
+
answered it.
|
|
23
|
+
|
|
24
|
+
## 0.3.34
|
|
25
|
+
|
|
26
|
+
Paste images with Ctrl+V. Copy a screenshot (or any image) and press Ctrl+V in
|
|
27
|
+
a session tab — Webbee Code uploads it and drops a reference into your prompt,
|
|
28
|
+
so the next thing you ask can be about that image. Text on the clipboard
|
|
29
|
+
pastes inline. Nothing is sent until you send your message; if image upload
|
|
30
|
+
isn't available the image is saved locally and referenced by path.
|
|
31
|
+
|
|
3
32
|
## 0.3.33
|
|
4
33
|
|
|
5
34
|
Performance.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.35"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Read the OS clipboard OUT-OF-BAND for Ctrl+V paste (W3 Wave A).
|
|
2
|
+
|
|
3
|
+
A terminal's bracketed paste is text-only — a clipboard IMAGE never reaches the
|
|
4
|
+
app that way — so an image must be pulled from the OS clipboard via the
|
|
5
|
+
platform tool. Same discipline as `clipboard.py` (copy-out): `shutil.which`-
|
|
6
|
+
gated, bounded timeout, output CAPTURED so it never inherits the dock's tty
|
|
7
|
+
(the 0.3.32 lesson: a chatty child scrambles the full-screen renderer), and it
|
|
8
|
+
NEVER raises. macOS + Linux are the verified paths; Windows is best-effort and
|
|
9
|
+
UNVERIFIED (no Windows host to test on) — it fails soft to None."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ClipboardItem:
|
|
20
|
+
kind: str # "image" | "text"
|
|
21
|
+
data: object # bytes (PNG) for image, str for text
|
|
22
|
+
name: str = "" # suggested filename for an image
|
|
23
|
+
mime: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(cmd):
|
|
27
|
+
"""Captured, bounded, never-raises subprocess. `capture_output=True` keeps
|
|
28
|
+
stdout/stderr OFF the dock's tty. Returns CompletedProcess or None."""
|
|
29
|
+
try:
|
|
30
|
+
return subprocess.run(cmd, capture_output=True, timeout=2)
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_via_tempfile(argv_for_path) -> "bytes | None":
|
|
36
|
+
"""Run a tool that WRITES a PNG to a temp path (osascript / PowerShell),
|
|
37
|
+
then read+delete it. `argv_for_path(path)` returns the argv. Never raises."""
|
|
38
|
+
import os
|
|
39
|
+
import tempfile
|
|
40
|
+
fd, path = tempfile.mkstemp(suffix=".png")
|
|
41
|
+
os.close(fd)
|
|
42
|
+
try:
|
|
43
|
+
p = _run(argv_for_path(path))
|
|
44
|
+
if p is not None and p.returncode == 0 and os.path.getsize(path) > 0:
|
|
45
|
+
with open(path, "rb") as f:
|
|
46
|
+
return f.read() or None
|
|
47
|
+
return None
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
finally:
|
|
51
|
+
try:
|
|
52
|
+
os.remove(path)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mac_image() -> "bytes | None":
|
|
58
|
+
if shutil.which("pngpaste"):
|
|
59
|
+
p = _run(["pngpaste", "-"])
|
|
60
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
61
|
+
return p.stdout
|
|
62
|
+
if not shutil.which("osascript"):
|
|
63
|
+
return None
|
|
64
|
+
# osascript returns non-zero when the clipboard holds no «class PNGf» image.
|
|
65
|
+
def _argv(path):
|
|
66
|
+
script = ('set p to (POSIX file "%s")\n'
|
|
67
|
+
'set d to (the clipboard as «class PNGf»)\n'
|
|
68
|
+
'set fh to open for access p with write permission\n'
|
|
69
|
+
'write d to fh\nclose access fh' % path)
|
|
70
|
+
return ["osascript", "-e", script]
|
|
71
|
+
return _read_via_tempfile(_argv)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _linux_image() -> "bytes | None":
|
|
75
|
+
if shutil.which("wl-paste"):
|
|
76
|
+
types = _run(["wl-paste", "--list-types"])
|
|
77
|
+
if types is not None and types.returncode == 0 and b"image/png" in (types.stdout or b""):
|
|
78
|
+
p = _run(["wl-paste", "--type", "image/png"])
|
|
79
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
80
|
+
return p.stdout
|
|
81
|
+
if shutil.which("xclip"):
|
|
82
|
+
tgt = _run(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"])
|
|
83
|
+
if tgt is not None and tgt.returncode == 0 and b"image/png" in (tgt.stdout or b""):
|
|
84
|
+
p = _run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
|
|
85
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
86
|
+
return p.stdout
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _windows_image() -> "bytes | None":
|
|
91
|
+
# UNVERIFIED (no Windows host). Best-effort; fail-soft to None.
|
|
92
|
+
if not shutil.which("powershell"):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _argv(path):
|
|
96
|
+
ps = ("Add-Type -AssemblyName System.Windows.Forms;"
|
|
97
|
+
"$i=[System.Windows.Forms.Clipboard]::GetImage();"
|
|
98
|
+
"if($i){$i.Save('%s')}" % path.replace("\\", "\\\\"))
|
|
99
|
+
return ["powershell", "-NoProfile", "-Command", ps]
|
|
100
|
+
return _read_via_tempfile(_argv)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_clipboard_image() -> "bytes | None":
|
|
104
|
+
"""Raw PNG bytes on the clipboard, or None. Platform-dispatched, captured
|
|
105
|
+
(tty-safe), never raises."""
|
|
106
|
+
try:
|
|
107
|
+
if sys.platform == "darwin":
|
|
108
|
+
return _mac_image()
|
|
109
|
+
if sys.platform == "win32":
|
|
110
|
+
return _windows_image()
|
|
111
|
+
return _linux_image()
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _text_cmd() -> "list[str] | None":
|
|
117
|
+
if sys.platform == "darwin":
|
|
118
|
+
return ["pbpaste"] if shutil.which("pbpaste") else None
|
|
119
|
+
if shutil.which("wl-paste"):
|
|
120
|
+
return ["wl-paste", "--no-newline"]
|
|
121
|
+
if shutil.which("xclip"):
|
|
122
|
+
return ["xclip", "-selection", "clipboard", "-o"]
|
|
123
|
+
if sys.platform == "win32" and shutil.which("powershell"):
|
|
124
|
+
return ["powershell", "-NoProfile", "-Command", "Get-Clipboard"]
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_clipboard_text() -> "str | None":
|
|
129
|
+
"""The clipboard's text, or None. Captured (tty-safe), never raises."""
|
|
130
|
+
cmd = _text_cmd()
|
|
131
|
+
if cmd is None:
|
|
132
|
+
return None
|
|
133
|
+
p = _run(cmd)
|
|
134
|
+
if p is not None and p.returncode == 0 and p.stdout:
|
|
135
|
+
try:
|
|
136
|
+
return p.stdout.decode("utf-8", "replace")
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_clipboard(ts: str) -> "ClipboardItem | None":
|
|
143
|
+
"""One paste's worth of clipboard content: an IMAGE if present (PNG bytes,
|
|
144
|
+
named `pasted-<ts>.png`), else TEXT, else None. `ts` is a caller-supplied
|
|
145
|
+
timestamp string for the image name (the dock has no wall-clock in a pure
|
|
146
|
+
function, so it's passed in)."""
|
|
147
|
+
img = read_clipboard_image()
|
|
148
|
+
if img:
|
|
149
|
+
return ClipboardItem(kind="image", data=img, name=f"pasted-{ts}.png",
|
|
150
|
+
mime="image/png")
|
|
151
|
+
txt = read_clipboard_text()
|
|
152
|
+
if txt:
|
|
153
|
+
return ClipboardItem(kind="text", data=txt)
|
|
154
|
+
return None
|
|
@@ -368,6 +368,9 @@ class HomeView:
|
|
|
368
368
|
def flash(self) -> str:
|
|
369
369
|
return self._out.flash()
|
|
370
370
|
|
|
371
|
+
def flash_note(self, msg: str, secs: float = 4.0) -> None:
|
|
372
|
+
self._out.flash_note(msg, secs)
|
|
373
|
+
|
|
371
374
|
def scroll(self, delta: int) -> None:
|
|
372
375
|
# Wheel (via the control) and PageUp/PageDown (tui routes here) move the
|
|
373
376
|
# top visible line; clamped so you can't scroll past the ends.
|
|
@@ -27,8 +27,31 @@ def _hash(text: str) -> str:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _mk(path: str, symbol: str, kind: str, s: int, e: int, lines: list[str]) -> Chunk:
|
|
30
|
+
"""Build one chunk. The id MUST be unique across everything chunk_index
|
|
31
|
+
yields, because it is the primary key in BOTH VectorStore._pos and
|
|
32
|
+
IntelService._chunk_hashes.
|
|
33
|
+
|
|
34
|
+
path+line-span alone is NOT unique: a symbol longer than
|
|
35
|
+
_CHUNK_MAX_LINES is split by `_windows` into windows that step by
|
|
36
|
+
(_WINDOW_LINES - _WINDOW_OVERLAP), and such a window can land on exactly
|
|
37
|
+
the span of a trailing nested symbol -- e.g. a class spanning 1..61 has
|
|
38
|
+
its last window at 51..61, and a method spanning 51..61 then produced the
|
|
39
|
+
SAME id ("path#51-61"). Two consequences, both silent:
|
|
40
|
+
* VectorStore.add took its "id already known" branch and wrote
|
|
41
|
+
_mat[pos] while _mat was still the empty (0, dim) array -> IndexError
|
|
42
|
+
-> boot.start_intel swallowed it -> code intel OFF for the session;
|
|
43
|
+
* _chunk_hashes collapsed both chunks onto one hash entry, so an
|
|
44
|
+
incremental re-embed could skip a genuinely changed chunk.
|
|
45
|
+
`kind` alone is not enough either: an outer symbol and a long nested one
|
|
46
|
+
can BOTH be windowed (kind="chunk" for each) and a window of each can
|
|
47
|
+
cover the same span -- observed on real dependency code, e.g. jinja2's
|
|
48
|
+
`Environment` vs its `__init__` both yielding "environment.py#294-353".
|
|
49
|
+
So the id carries `kind` AND `symbol`, which is what actually
|
|
50
|
+
distinguishes them. The id stays deterministic across runs -- stability
|
|
51
|
+
matters because apply_changes diffs chunk ids to decide what to re-embed.
|
|
52
|
+
"""
|
|
30
53
|
text = "\n".join(lines[s - 1:e])
|
|
31
|
-
return Chunk(id=f"{path}#{s}-{e}", symbol=symbol, kind=kind, path=path,
|
|
54
|
+
return Chunk(id=f"{path}#{s}-{e}:{kind}:{symbol}", symbol=symbol, kind=kind, path=path,
|
|
32
55
|
start_line=s, end_line=e, text=text, content_hash=_hash(text))
|
|
33
56
|
|
|
34
57
|
|
|
@@ -69,7 +69,15 @@ def _lexical_search(svc, q, kind, path_glob, items_by_id: dict) -> list[str]:
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
def _chunk_item(svc, cid: str, score: float) -> dict | None:
|
|
72
|
-
"""Map an embed-chunk id
|
|
72
|
+
"""Map an embed-chunk id back to a search item.
|
|
73
|
+
|
|
74
|
+
Id shape is '<path>#<start>-<end>:<kind>:<symbol>' (chunker._mk). The
|
|
75
|
+
':<kind>:<symbol>' tail was added 2026-07-25 to make ids unique -- see
|
|
76
|
+
chunker._mk -- so the line range is only the part BEFORE the first ':'.
|
|
77
|
+
Ids written by an older cache have no tail at all, hence the partition
|
|
78
|
+
(not a required split): both shapes parse, and a schema bump means the
|
|
79
|
+
old ones are normally gone anyway.
|
|
80
|
+
|
|
73
81
|
Title/kind = the enclosing symbol's when the graph has one covering the
|
|
74
82
|
chunk's line range, else the bare '<path>:<start>' / 'chunk' fallback --
|
|
75
83
|
a hardcoded kind="chunk" would make every vector hit invisible to a
|
|
@@ -77,6 +85,7 @@ def _chunk_item(svc, cid: str, score: float) -> dict | None:
|
|
|
77
85
|
if "#" not in cid or "-" not in cid.rpartition("#")[2]:
|
|
78
86
|
return None
|
|
79
87
|
path, _, rng = cid.rpartition("#")
|
|
88
|
+
rng = rng.partition(":")[0] # drop the ':<kind>:<symbol>' tail
|
|
80
89
|
start_s, end_s = rng.split("-", 1)
|
|
81
90
|
try:
|
|
82
91
|
start, end = int(start_s), int(end_s)
|
|
@@ -25,10 +25,39 @@ def _git_ref(root: str) -> str:
|
|
|
25
25
|
return ""
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
# Dependency / build trees are pruned from the index walk. This is not only a
|
|
29
|
+
# speed win, it is a CORRECTNESS one: `_walk` stops at `limit`, so on a repo
|
|
30
|
+
# with a big in-tree dependency directory the cap could be spent on
|
|
31
|
+
# third-party files while the project's OWN sources went unindexed.
|
|
32
|
+
#
|
|
33
|
+
# Measured on this checkout (it carries an in-tree `venv/`): 3062 files -> 111
|
|
34
|
+
# candidates, and build_index 30.0s -> well under a second.
|
|
35
|
+
#
|
|
36
|
+
# ".git" and every DOTTED directory were already pruned (covering .venv/,
|
|
37
|
+
# .tox/, .mypy_cache/, ...). These are the undotted ones that were not.
|
|
38
|
+
# Deliberately conservative -- only names that are unambiguous dependency
|
|
39
|
+
# caches. Names that a real project might legitimately use for its OWN source
|
|
40
|
+
# ("build", "dist", "target", "env", "vendor") are NOT pruned by name here: a
|
|
41
|
+
# virtualenv called "venv"/"env" is instead detected by its `pyvenv.cfg`
|
|
42
|
+
# marker below, which is a fact about the directory rather than a guess about
|
|
43
|
+
# its name.
|
|
44
|
+
_SKIP_DIRS = frozenset({"node_modules", "site-packages", "__pycache__"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_virtualenv(path: str) -> bool:
|
|
48
|
+
"""True for a real Python virtualenv root: PEP 405 puts `pyvenv.cfg` at
|
|
49
|
+
the top of every one. Checking the MARKER (not the folder name) means a
|
|
50
|
+
project module that merely happens to be called `env/` keeps being
|
|
51
|
+
indexed, while an in-tree `venv/` is skipped whatever it is named."""
|
|
52
|
+
return os.path.exists(os.path.join(path, "pyvenv.cfg"))
|
|
53
|
+
|
|
54
|
+
|
|
28
55
|
def _walk(root: str, limit: int = 20000) -> list[str]:
|
|
29
56
|
out = []
|
|
30
57
|
for dp, dns, fns in os.walk(root):
|
|
31
|
-
dns[:] = [d for d in dns
|
|
58
|
+
dns[:] = [d for d in dns
|
|
59
|
+
if d != ".git" and not d.startswith(".") and d not in _SKIP_DIRS
|
|
60
|
+
and not _is_virtualenv(os.path.join(dp, d))]
|
|
32
61
|
for fn in fns:
|
|
33
62
|
out.append(os.path.relpath(os.path.join(dp, fn), root))
|
|
34
63
|
if len(out) >= limit:
|
|
@@ -18,7 +18,12 @@ from webbee.intel.models import ProjectIndex, FileIndex, Symbol
|
|
|
18
18
|
# extra) can still import this whole module (index.json save/load, the
|
|
19
19
|
# graph plane) with no numpy on the system at all.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# 3 (2026-07-25): chunk ids gained a ":{kind}" suffix (chunker._mk) to fix a
|
|
22
|
+
# collision that silently disabled code intel. Every cached `ids` list from
|
|
23
|
+
# schema 2 holds the OLD id format, so both the index and the vector cache
|
|
24
|
+
# must be treated as a clean miss and rebuilt -- bumping the version is what
|
|
25
|
+
# makes load()/load_vectors() do exactly that.
|
|
26
|
+
SCHEMA_VERSION = 3
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
def _path(cache_dir: str, repo_key: str) -> str:
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _normalize(m: np.ndarray) -> np.ndarray:
|
|
6
|
+
n = np.linalg.norm(m, axis=1, keepdims=True)
|
|
7
|
+
n[n == 0] = 1.0
|
|
8
|
+
return (m / n).astype(np.float32)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VectorStore:
|
|
12
|
+
def __init__(self, dim: int) -> None:
|
|
13
|
+
self.dim = dim
|
|
14
|
+
self._ids: list[str] = []
|
|
15
|
+
self._pos: dict[str, int] = {}
|
|
16
|
+
self._mat = np.zeros((0, dim), dtype=np.float32)
|
|
17
|
+
|
|
18
|
+
def add(self, ids, vecs) -> None:
|
|
19
|
+
"""Insert new ids and/or overwrite the rows of ids already present.
|
|
20
|
+
|
|
21
|
+
ORDER MATTERS: appends are materialised into `_mat` FIRST, overwrites
|
|
22
|
+
are applied only afterwards. Doing it the other way round (the pre-
|
|
23
|
+
2026-07-25 shape) wrote `_mat[self._pos[_id]]` while the rows for this
|
|
24
|
+
very batch were still sitting in a pending `rows` list, so any id
|
|
25
|
+
whose position had already been assigned but not yet vstacked indexed
|
|
26
|
+
past the end of the array -- on a fresh store that is `_mat` with
|
|
27
|
+
shape (0, dim) and the write raised IndexError. `IntelService.
|
|
28
|
+
_embed_build` calls this once with the whole repo's chunk set and
|
|
29
|
+
`boot.start_intel` swallows exceptions, so that IndexError silently
|
|
30
|
+
disabled code intel for the entire session.
|
|
31
|
+
|
|
32
|
+
A duplicate id WITHIN one batch is now also handled coherently: the
|
|
33
|
+
first occurrence appends, later ones overwrite that same row (last
|
|
34
|
+
value wins) instead of exploding.
|
|
35
|
+
"""
|
|
36
|
+
vecs = np.asarray(vecs, dtype=np.float32).reshape(-1, self.dim)
|
|
37
|
+
rows = []
|
|
38
|
+
updates = [] # (row_index, vec) -- applied after the append
|
|
39
|
+
for i, _id in enumerate(ids):
|
|
40
|
+
pos = self._pos.get(_id)
|
|
41
|
+
if pos is None:
|
|
42
|
+
self._pos[_id] = len(self._ids)
|
|
43
|
+
self._ids.append(_id)
|
|
44
|
+
rows.append(vecs[i])
|
|
45
|
+
else:
|
|
46
|
+
updates.append((pos, vecs[i]))
|
|
47
|
+
if rows:
|
|
48
|
+
new_rows = np.array(rows, dtype=np.float32)
|
|
49
|
+
self._mat = (np.vstack([self._mat, new_rows])
|
|
50
|
+
if self._mat.shape[0] else new_rows)
|
|
51
|
+
if updates:
|
|
52
|
+
if not self._mat.flags.writeable:
|
|
53
|
+
# Copy-on-write: _mat may be a read-only mmap loaded straight
|
|
54
|
+
# off the vector cache (store.load_vectors' mmap_mode="r" perf
|
|
55
|
+
# win) via from_arrays. A read-only boot+search session never
|
|
56
|
+
# reaches this branch; only an incremental re-embed of an
|
|
57
|
+
# existing chunk id (same id, changed content) needs to mutate
|
|
58
|
+
# a row in place, so pay for exactly one full copy here, lazily.
|
|
59
|
+
self._mat = np.array(self._mat)
|
|
60
|
+
for pos, vec in updates:
|
|
61
|
+
self._mat[pos] = vec
|
|
62
|
+
|
|
63
|
+
def remove(self, ids) -> None:
|
|
64
|
+
drop = {i for i in ids if i in self._pos}
|
|
65
|
+
if not drop:
|
|
66
|
+
return
|
|
67
|
+
keep = [(i, _id) for i, _id in enumerate(self._ids) if _id not in drop]
|
|
68
|
+
self._mat = self._mat[[i for i, _ in keep]] if keep else np.zeros((0, self.dim), dtype=np.float32)
|
|
69
|
+
self._ids = [_id for _, _id in keep]
|
|
70
|
+
self._pos = {_id: n for n, _id in enumerate(self._ids)}
|
|
71
|
+
|
|
72
|
+
def search(self, qvec, top_n: int):
|
|
73
|
+
if not self._ids:
|
|
74
|
+
return []
|
|
75
|
+
q = np.asarray(qvec, dtype=np.float32).reshape(self.dim)
|
|
76
|
+
qn = q / (np.linalg.norm(q) or 1.0)
|
|
77
|
+
sims = _normalize(self._mat) @ qn
|
|
78
|
+
n = min(top_n, len(self._ids))
|
|
79
|
+
idx = np.argpartition(-sims, n - 1)[:n]
|
|
80
|
+
idx = idx[np.argsort(-sims[idx])]
|
|
81
|
+
return [(self._ids[i], float(sims[i])) for i in idx]
|
|
82
|
+
|
|
83
|
+
def ids(self):
|
|
84
|
+
return list(self._ids)
|
|
85
|
+
|
|
86
|
+
def to_arrays(self):
|
|
87
|
+
return list(self._ids), self._mat.copy()
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_arrays(cls, dim, ids, matrix):
|
|
91
|
+
vs = cls(dim)
|
|
92
|
+
vs._ids = list(ids)
|
|
93
|
+
vs._pos = {_id: n for n, _id in enumerate(vs._ids)}
|
|
94
|
+
vs._mat = np.asarray(matrix, dtype=np.float32).reshape(-1, dim)
|
|
95
|
+
return vs
|
|
@@ -11,7 +11,9 @@ def _ignored(path: str) -> bool:
|
|
|
11
11
|
filter works on Windows too (watchfiles yields backslash paths there --
|
|
12
12
|
unfiltered .git churn re-indexed the repo on every checkpoint)."""
|
|
13
13
|
p = path.replace(os.sep, "/").replace("\\", "/")
|
|
14
|
-
|
|
14
|
+
# `.webbee/` = our own per-workspace scratch (pasted images, gitignore) —
|
|
15
|
+
# never re-index/checkpoint on a paste (W3).
|
|
16
|
+
return "/.git/" in p or "/node_modules/" in p or "/.webbee/" in p
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
async def watch_workspace(root: str, on_change) -> None:
|
|
@@ -28,9 +28,33 @@ from webbee.repo import compute_repo_key, find_repo_root
|
|
|
28
28
|
_CACHE_DIR = os.path.expanduser("~/.cache/webbee") # test seam: monkeypatch this name
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# repo_key is derived from `git remote get-url origin` (compute_repo_key,
|
|
32
|
+
# timeout=5) -- measured ~11ms per call on a warm local repo, worst case the
|
|
33
|
+
# full 5s on a repo whose git call hangs. It is also INVARIANT for the life of
|
|
34
|
+
# the process: a workspace's git remote does not change under a running dock.
|
|
35
|
+
# Every OTHER caller already keeps that subprocess off the event loop via
|
|
36
|
+
# asyncio.to_thread (repl.py's boot paths), but save_mode is reached
|
|
37
|
+
# SYNCHRONOUSLY from the Shift-TAB key binding (tui `_cycle` ->
|
|
38
|
+
# repl.set_slot_mode -> save_mode), i.e. straight on the event loop, where it
|
|
39
|
+
# froze the dock on every single mode switch.
|
|
40
|
+
#
|
|
41
|
+
# Memoising per workspace fixes that for every switch after the first: the
|
|
42
|
+
# repeat cost is a dict lookup. The first call in a process still pays, which
|
|
43
|
+
# is acceptable because boot's own load_mode already warms this cache off-loop
|
|
44
|
+
# before any key can be pressed.
|
|
45
|
+
_KEY_CACHE: dict[str, str] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _repo_key_for(workspace: str) -> str:
|
|
49
|
+
key = _KEY_CACHE.get(workspace)
|
|
50
|
+
if key is None:
|
|
51
|
+
key = compute_repo_key(find_repo_root(workspace))
|
|
52
|
+
_KEY_CACHE[workspace] = key
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
|
|
31
56
|
def _path_for(workspace: str) -> str:
|
|
32
|
-
|
|
33
|
-
return os.path.join(_CACHE_DIR, f"mode-{repo_key}")
|
|
57
|
+
return os.path.join(_CACHE_DIR, f"mode-{_repo_key_for(workspace)}")
|
|
34
58
|
|
|
35
59
|
|
|
36
60
|
def load_mode(workspace: str) -> "str | None":
|
|
@@ -65,6 +65,7 @@ class OutputPane:
|
|
|
65
65
|
self._follow = True # stick to the tail unless the user scrolled up
|
|
66
66
|
self._sel = None # (abs_start, abs_end) during a drag → live highlight
|
|
67
67
|
self._edge_drag = 0 # +1/-1 while dragging at the bottom/top edge, else 0
|
|
68
|
+
self._resize_pending = 0 # width seen but not yet reflowed (tui._width_watch debounce)
|
|
68
69
|
self._edge_ticks = 0 # consecutive edge_tick()s since the last drag MOUSE_MOVE
|
|
69
70
|
self._plain_cache = (0, [""]) # (write-pos, ANSI-stripped lines) for select/highlight
|
|
70
71
|
self.copy_flash = "" # transient toolbar note after a copy
|
|
@@ -335,6 +336,13 @@ class OutputPane:
|
|
|
335
336
|
import time as _t
|
|
336
337
|
return self.copy_flash if _t.monotonic() < self._flash_until else ""
|
|
337
338
|
|
|
339
|
+
def flash_note(self, msg: str, secs: float = 4.0) -> None:
|
|
340
|
+
"""Show a transient toolbar note (paste progress, hints) — same channel
|
|
341
|
+
as the copy toast, reused so there's one flash mechanism."""
|
|
342
|
+
import time as _t
|
|
343
|
+
self.copy_flash = msg
|
|
344
|
+
self._flash_until = _t.monotonic() + secs
|
|
345
|
+
|
|
338
346
|
# ---- W2 Task 8: selection capture past the pane's own Window --------
|
|
339
347
|
def forward_mouse(self, ev, clamp: str = "bottom") -> bool:
|
|
340
348
|
"""Public seam neighbor windows call FIRST (via `tui._forwarding` /
|
|
@@ -200,6 +200,12 @@ def set_slot_mode(slot: SessionSlot, mode: str) -> None:
|
|
|
200
200
|
save_mode(slot.workspace, mode)
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
# Strong refs for in-flight autopilot confirms (see _on_mode). A module-level
|
|
204
|
+
# set is safe here: entries are removed by their own done-callback, and it is
|
|
205
|
+
# never read as state -- purely GC ballast.
|
|
206
|
+
_CONFIRM_TASKS: set = set()
|
|
207
|
+
|
|
208
|
+
|
|
203
209
|
def _on_mode(slot: SessionSlot, mode: str, surface: str) -> None:
|
|
204
210
|
"""Remote coding-mode request (TG/panel → gateway one-shot req_mode →
|
|
205
211
|
the pending-steer poll). AUTOPILOT SAFE ASYMMETRY (Valentin-chosen): a
|
|
@@ -225,7 +231,19 @@ def _on_mode(slot: SessionSlot, mode: str, surface: str) -> None:
|
|
|
225
231
|
set_slot_mode(slot, mode)
|
|
226
232
|
_say(slot, f"mode → {mode} [{surface}]")
|
|
227
233
|
return
|
|
228
|
-
asyncio
|
|
234
|
+
# Keep a STRONG reference until the confirm finishes: asyncio only holds a
|
|
235
|
+
# weak one, so a bare ensure_future() can be garbage-collected mid-await
|
|
236
|
+
# and the autopilot prompt would vanish silently, leaving the user's remote
|
|
237
|
+
# upgrade request unanswered with no trace in the transcript.
|
|
238
|
+
#
|
|
239
|
+
# Deliberately NOT slot.bg_tasks: everything parked there is cancelled by
|
|
240
|
+
# the slot-close/exit teardown, and this confirm must be allowed to COMPLETE
|
|
241
|
+
# (the local yes/no is the security gate for the whole upgrade -- see
|
|
242
|
+
# _confirm_autopilot). The task removes itself via the done callback, so the
|
|
243
|
+
# set cannot grow without bound.
|
|
244
|
+
_t = asyncio.ensure_future(_confirm_autopilot(slot, surface))
|
|
245
|
+
_CONFIRM_TASKS.add(_t)
|
|
246
|
+
_t.add_done_callback(_CONFIRM_TASKS.discard)
|
|
229
247
|
|
|
230
248
|
|
|
231
249
|
async def _confirm_autopilot(slot: SessionSlot, surface: str) -> None:
|
|
@@ -676,6 +694,43 @@ def _schedule_home_refill(slots: SlotManager, idx: int, fill_kwargs: dict, *,
|
|
|
676
694
|
return True
|
|
677
695
|
|
|
678
696
|
|
|
697
|
+
def _save_pasted(workspace: str, name: str, data: bytes) -> str:
|
|
698
|
+
"""Save a pasted image into `<workspace>/.webbee/pasted/<name>` (that dir
|
|
699
|
+
ships a `.gitignore` of `*` so pasted files never pollute the repo or its
|
|
700
|
+
shadow-git). Returns the saved path, or "" on any failure. Never raises."""
|
|
701
|
+
try:
|
|
702
|
+
base = os.path.join(workspace, ".webbee")
|
|
703
|
+
pdir = os.path.join(base, "pasted")
|
|
704
|
+
os.makedirs(pdir, exist_ok=True)
|
|
705
|
+
gi = os.path.join(base, ".gitignore")
|
|
706
|
+
if not os.path.exists(gi):
|
|
707
|
+
with open(gi, "w", encoding="utf-8") as f:
|
|
708
|
+
f.write("*\n")
|
|
709
|
+
path = os.path.join(pdir, name)
|
|
710
|
+
with open(path, "wb") as f:
|
|
711
|
+
f.write(data)
|
|
712
|
+
return path
|
|
713
|
+
except Exception:
|
|
714
|
+
return ""
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _extract_file_id(resp) -> str:
|
|
718
|
+
"""Pull `file_id` from a file-reader `receive_files` response. Canon: it
|
|
719
|
+
lives at `data.items[].file_id` (== `.id`), NOT `data.received` (the panel's
|
|
720
|
+
Round-3 lesson). Handles dict + object shapes; "" if absent."""
|
|
721
|
+
try:
|
|
722
|
+
data = resp.get("data") if isinstance(resp, dict) else getattr(resp, "data", None)
|
|
723
|
+
items = data.get("items") if isinstance(data, dict) else getattr(data, "items", None)
|
|
724
|
+
if items:
|
|
725
|
+
it = items[0]
|
|
726
|
+
if isinstance(it, dict):
|
|
727
|
+
return str(it.get("file_id") or it.get("id") or "")
|
|
728
|
+
return str(getattr(it, "file_id", "") or getattr(it, "id", "") or "")
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
731
|
+
return ""
|
|
732
|
+
|
|
733
|
+
|
|
679
734
|
async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None, read_line=input,
|
|
680
735
|
agent_factory=None, auth=None, account_fetcher=None,
|
|
681
736
|
sessions_client=None, intel_factory=None, shadow_factory=None) -> None:
|
|
@@ -1337,6 +1392,32 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1337
1392
|
from prompt_toolkit.application import get_app
|
|
1338
1393
|
get_app().exit()
|
|
1339
1394
|
|
|
1395
|
+
async def _on_paste(ws: str, name: str, data: bytes) -> str:
|
|
1396
|
+
# W3 Wave A: save the pasted image into the tab's workspace
|
|
1397
|
+
# then upload it through the SAME file-reader door the panel
|
|
1398
|
+
# and Telegram use; return the reference to drop into the
|
|
1399
|
+
# task. Degrades honestly on 402 / no cloud (local path +
|
|
1400
|
+
# note). Never raises.
|
|
1401
|
+
import base64
|
|
1402
|
+
saved = _save_pasted(ws, name, data)
|
|
1403
|
+
try:
|
|
1404
|
+
from imperal_mcp.client import ImperalClient
|
|
1405
|
+
client = ImperalClient(cfg, token_provider)
|
|
1406
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
1407
|
+
resp = await client.run_tool(
|
|
1408
|
+
"file-reader", "receive_files",
|
|
1409
|
+
{"files": [{"name": name, "mime_type": "image/png",
|
|
1410
|
+
"data_base64": b64}]})
|
|
1411
|
+
fid = _extract_file_id(resp)
|
|
1412
|
+
if fid:
|
|
1413
|
+
return f"📎 {name} (file_id={fid})"
|
|
1414
|
+
except Exception:
|
|
1415
|
+
pass
|
|
1416
|
+
if saved:
|
|
1417
|
+
return (f"📎 {name} — saved to {saved}; a file-reader "
|
|
1418
|
+
"plan is needed to send it to the model")
|
|
1419
|
+
return f"📎 {name} — could not upload"
|
|
1420
|
+
|
|
1340
1421
|
try:
|
|
1341
1422
|
ok = await tui.run_session(
|
|
1342
1423
|
slots=slots, on_line=_on_line, on_cycle=_cycle,
|
|
@@ -1359,6 +1440,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
1359
1440
|
spawn_poller=_spawn_slot_poller),
|
|
1360
1441
|
on_switch=lambda idx: _schedule_home_refill(slots, idx, home_fill_kwargs),
|
|
1361
1442
|
on_new=lambda: _open_new_tab(),
|
|
1443
|
+
on_paste=_on_paste,
|
|
1362
1444
|
)
|
|
1363
1445
|
finally:
|
|
1364
1446
|
_cancel_background()
|