webbee 0.3.12__tar.gz → 0.3.13__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.12 → webbee-0.3.13}/CHANGELOG.md +10 -0
- {webbee-0.3.12 → webbee-0.3.13}/PKG-INFO +1 -1
- {webbee-0.3.12 → webbee-0.3.13}/pyproject.toml +1 -1
- webbee-0.3.13/src/webbee/__init__.py +1 -0
- webbee-0.3.13/src/webbee/queue_panel.py +101 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/render.py +32 -34
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/repl.py +1 -2
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tui.py +77 -32
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_render.py +48 -18
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_repl.py +0 -1
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_tui.py +276 -32
- webbee-0.3.12/src/webbee/__init__.py +0 -1
- {webbee-0.3.12 → webbee-0.3.13}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/.gitignore +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/LICENSE +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/README.md +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/install.sh +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/account.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/boot.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/cli.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/commands.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/config.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/consent.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/details.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/events.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/frames.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/remote.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/repo.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/session.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/sessions.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/steer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/stream.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/thread.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tokens.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tools.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/src/webbee/update.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/__init__.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_account.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_cli.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_clipboard.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_commands.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_config.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_details.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_events.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_query.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_service.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_store.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_marathon.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_packaging.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_repo.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_session.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_sessions.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_steer.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_stream.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_thread.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_tokens.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_tools.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_update.py +0 -0
- {webbee-0.3.12 → webbee-0.3.13}/tests/test_version.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.13
|
|
4
|
+
|
|
5
|
+
- The message queue is now a live panel above the input — you see exactly
|
|
6
|
+
what's waiting, press ↑ to pull the last one back for editing (it leaves
|
|
7
|
+
the queue and returns when you resend), or click one to edit it. The
|
|
8
|
+
transcript stays clean.
|
|
9
|
+
- Cleaner welcome screen — just the essentials (who you are, your plan, how
|
|
10
|
+
to start) plus a clear word on privacy: your work is never sold and never
|
|
11
|
+
used to train models, and PII is masked before it reaches the model.
|
|
12
|
+
|
|
3
13
|
## 0.3.12
|
|
4
14
|
|
|
5
15
|
- Queued messages are now visible — when you type while Webbee is working,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.13"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The LIVE pending-queue panel pinned between the output pane and the input
|
|
2
|
+
box (0.3.13 — replaces the static `⋯ queued:` scrollback echoes, which
|
|
3
|
+
scrolled away, duplicated and never updated). The builders here are PURE
|
|
4
|
+
(unit-tested without an Application); tui.run_session mounts them in a
|
|
5
|
+
ConditionalContainer that occupies ZERO rows while the queue is empty, so
|
|
6
|
+
the empty-queue dock is pixel-identical to before. Item rows carry a
|
|
7
|
+
per-fragment mouse handler (prompt_toolkit 3.0.52 3-tuple fragments,
|
|
8
|
+
verified in venv) firing on MOUSE_UP only — a PASSIVE consumer of the
|
|
9
|
+
clicks that already flow under the ?1000/?1002 button-event tracking; it
|
|
10
|
+
enables nothing at the terminal level, so the 0.3.3 mouse-flood fix
|
|
11
|
+
(?1003 any-event stays off, tui.configure_mouse_modes) is untouched.
|
|
12
|
+
Split out of tui.py to keep both files under the file-size ceiling."""
|
|
13
|
+
|
|
14
|
+
QP_MAX_ITEMS = 5 # newest items shown; deeper queues add one `… +K more` row
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def one_line(text: str, width: int) -> str:
|
|
18
|
+
"""PURE. Collapse whitespace/newlines so a queued item costs EXACTLY one
|
|
19
|
+
panel row, truncating with `…` when it would overflow `width` columns
|
|
20
|
+
(width<=0 = no truncation — headless/unknown terminal)."""
|
|
21
|
+
t = " ".join((text or "").split())
|
|
22
|
+
if width > 0 and len(t) > width:
|
|
23
|
+
t = t[:max(1, width - 1)] + "…"
|
|
24
|
+
return t
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def queue_height(pending) -> int:
|
|
28
|
+
"""PURE. Rows the panel needs: 1 header + one per SHOWN item + one
|
|
29
|
+
`… +K more` row when the queue is deeper than QP_MAX_ITEMS. 0 when empty
|
|
30
|
+
(the ConditionalContainer hides the panel then anyway). The cap keeps the
|
|
31
|
+
output pane dominant on small terminals; the toolbar's `⋯N queued`
|
|
32
|
+
segment stays the truth-teller for the full depth."""
|
|
33
|
+
n = len(pending)
|
|
34
|
+
if not n:
|
|
35
|
+
return 0
|
|
36
|
+
return 1 + min(n, QP_MAX_ITEMS) + (1 if n > QP_MAX_ITEMS else 0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pull_item(pending, buf, index: int) -> bool:
|
|
40
|
+
"""The ONE pull-to-edit implementation (serves BOTH the ↑ key — newest,
|
|
41
|
+
index len(pending)-1 — and a panel-row click — that row's index): move
|
|
42
|
+
pending[index] OUT of the queue and into the input buffer for editing,
|
|
43
|
+
cursor at the end. Guards, identical on both paths: a buffer with ANY
|
|
44
|
+
text is never clobbered, and a stale index (the queue drained between
|
|
45
|
+
render and click) is ignored. Returns True when a pull happened (the
|
|
46
|
+
caller invalidates)."""
|
|
47
|
+
if buf.text or not (0 <= index < len(pending)):
|
|
48
|
+
return False
|
|
49
|
+
item = pending[index]
|
|
50
|
+
del pending[index]
|
|
51
|
+
buf.text = item
|
|
52
|
+
buf.cursor_position = len(item)
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _item_handler(pull, index: int):
|
|
57
|
+
"""One row's mouse handler: MOUSE_UP (a click, not a drag/press) pulls
|
|
58
|
+
THAT queued item into the input via `pull(index)`; every other event
|
|
59
|
+
falls through (NotImplemented) so wheel scroll etc. keep today's
|
|
60
|
+
behavior. Mirrors OutputPane._SelectControl's event discipline."""
|
|
61
|
+
def _h(mouse_event):
|
|
62
|
+
from prompt_toolkit.mouse_events import MouseEventType
|
|
63
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
64
|
+
pull(index)
|
|
65
|
+
return None
|
|
66
|
+
return NotImplemented
|
|
67
|
+
return _h
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def queue_fragments(pending, pull=None, width: int = 0):
|
|
71
|
+
"""PURE builder: the panel as prompt_toolkit formatted text, re-invoked
|
|
72
|
+
every redraw (same live mechanics as the toolbar) so every queue
|
|
73
|
+
add/edit/drain shows at once. Layout, top→bottom = drain order (FIFO —
|
|
74
|
+
the TOP row runs next; the BOTTOM row is the newest, the one ↑ pulls,
|
|
75
|
+
sitting right above the input):
|
|
76
|
+
|
|
77
|
+
⋯ queued (N) · ↑ edit last · click to edit
|
|
78
|
+
… +K more ← only when N > QP_MAX_ITEMS (the OLDEST hide)
|
|
79
|
+
older item ← muted (qp.item)
|
|
80
|
+
newest item ← accent (qp.last)
|
|
81
|
+
|
|
82
|
+
When `pull` is given each item row is a 3-tuple fragment carrying a
|
|
83
|
+
mouse handler that pulls exactly that item (see _item_handler). Empty
|
|
84
|
+
queue → [] (the panel is hidden)."""
|
|
85
|
+
items = list(pending)
|
|
86
|
+
n = len(items)
|
|
87
|
+
if not n:
|
|
88
|
+
return []
|
|
89
|
+
frags = [("class:qp.header", f" ⋯ queued ({n})"),
|
|
90
|
+
("class:qp.item", " · ↑ edit last · click to edit")]
|
|
91
|
+
start = max(0, n - QP_MAX_ITEMS)
|
|
92
|
+
if start:
|
|
93
|
+
frags.append(("class:qp.item", f"\n … +{start} more"))
|
|
94
|
+
for i in range(start, n):
|
|
95
|
+
style = "class:qp.last" if i == n - 1 else "class:qp.item"
|
|
96
|
+
row = "\n " + one_line(items[i], width - 4 if width > 0 else 0)
|
|
97
|
+
if pull is None:
|
|
98
|
+
frags.append((style, row))
|
|
99
|
+
else:
|
|
100
|
+
frags.append((style, row, _item_handler(pull, i)))
|
|
101
|
+
return frags
|
|
@@ -44,6 +44,18 @@ _ICON = {"read_file": "📖", "grep": "🔎", "glob": "🗂️", "write_file": "
|
|
|
44
44
|
_BEE = "yellow" # bee-yellow brand accent — logo / 🐝 / notes / busy dot ONLY
|
|
45
45
|
_ACCENT = "cyan" # interactive chrome ONLY — live caret / mode / panel url
|
|
46
46
|
|
|
47
|
+
# ---- welcome copy (single source of truth for the splash + its tests) ------
|
|
48
|
+
# The privacy promise is claims-disciplined: every clause is TRUE and enforced
|
|
49
|
+
# today — user data is never sold and never used to train any model (ours or
|
|
50
|
+
# third-party; documented in the DPA), and PII is masked before tool output
|
|
51
|
+
# re-enters the model (platform-enforced and covered by the platform's own
|
|
52
|
+
# test suite). Deliberately NOT said: "we collect nothing" (sessions ARE
|
|
53
|
+
# stored so Webbee can resume your work) and "guaranteed" (we say what is
|
|
54
|
+
# enforced, not what is promised). Change the wording only with a source.
|
|
55
|
+
WELCOME_PRIVACY = "🔒 Your work stays yours — never sold, never training data."
|
|
56
|
+
WELCOME_PRIVACY_DETAIL = "PII is masked before it reaches the model."
|
|
57
|
+
WELCOME_HINT = "Type a task — Webbee runs it to completion · /help · Ctrl-D to exit"
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
def _fmt_tokens(n: int) -> str:
|
|
49
61
|
"""Compact count for the live toolbar: 900 -> '900', 2_100 -> '2.1k',
|
|
@@ -132,10 +144,13 @@ class RichSink:
|
|
|
132
144
|
|
|
133
145
|
# ---- welcome ------------------------------------------------------------
|
|
134
146
|
def welcome(self, account, cwd: str, surface: str) -> None:
|
|
135
|
-
"""One-time launch splash
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
"""One-time launch splash, trimmed to what a human actually needs:
|
|
148
|
+
the WEBBEE CODE logo (the brand), ONE identity line (who am I / plan —
|
|
149
|
+
plan status shows only when it needs attention), the privacy promise
|
|
150
|
+
(true, enforced claims only — see WELCOME_PRIVACY above), and one hint
|
|
151
|
+
line to get going. Runs BEFORE the dock starts. Clears the screen ONLY
|
|
152
|
+
in the non-pane path (the full-screen dock owns its own alternate
|
|
153
|
+
screen — clearing there would corrupt it)."""
|
|
139
154
|
if self._on_output is None:
|
|
140
155
|
self.console.clear()
|
|
141
156
|
w = self.console.width
|
|
@@ -152,29 +167,24 @@ class RichSink:
|
|
|
152
167
|
self.console.print(Text("ICNLI AI Cloud OS · Agent".center(w), style=f"bold {_ACCENT}"))
|
|
153
168
|
self.console.print(Text("· i m p e r a l . i o ·".center(w), style="dim"))
|
|
154
169
|
self.console.print()
|
|
155
|
-
rows = []
|
|
156
170
|
if account.signed_in:
|
|
157
|
-
who = account.email
|
|
158
|
-
|
|
171
|
+
who = account.email or ""
|
|
172
|
+
if account.nickname:
|
|
173
|
+
who += f" · @{account.nickname}"
|
|
159
174
|
if account.plan:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
rows.append(("Member since", account.member_since))
|
|
175
|
+
who += f" · {account.plan} plan"
|
|
176
|
+
if account.plan_status and account.plan_status != "active":
|
|
177
|
+
who += f" ({account.plan_status})"
|
|
178
|
+
label = "Signed in as "
|
|
179
|
+
pad = " " * max(0, (w - len(label) - len(who)) // 2)
|
|
180
|
+
self.console.print(Text.assemble((pad + label, "dim"), (who, "white")))
|
|
167
181
|
else:
|
|
168
|
-
|
|
169
|
-
bw = max((len(label.ljust(14) + value) for label, value in rows), default=0)
|
|
170
|
-
pad = " " * max(0, (w - bw) // 2)
|
|
171
|
-
for label, value in rows:
|
|
172
|
-
self.console.print(Text.assemble((pad + label.ljust(14), "dim"), (value, "white")))
|
|
182
|
+
self.console.print(Text("not signed in — /login".center(w), style="dim"))
|
|
173
183
|
self.console.print()
|
|
174
|
-
|
|
175
|
-
self.console.print(Text(
|
|
184
|
+
self.console.print(Text(WELCOME_PRIVACY.center(w), style="white"))
|
|
185
|
+
self.console.print(Text(WELCOME_PRIVACY_DETAIL.center(w), style="dim"))
|
|
176
186
|
self.console.print()
|
|
177
|
-
self.console.print(Text(
|
|
187
|
+
self.console.print(Text(WELCOME_HINT.center(w), style="dim"))
|
|
178
188
|
self.console.print()
|
|
179
189
|
self._nudge()
|
|
180
190
|
|
|
@@ -256,18 +266,6 @@ class RichSink:
|
|
|
256
266
|
self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
|
|
257
267
|
self._nudge()
|
|
258
268
|
|
|
259
|
-
def queued_echo(self, text: str) -> None:
|
|
260
|
-
"""Type-ahead visibility (the #1 queue fix): the line you pressed
|
|
261
|
-
Enter on while a turn was running, committed to the scrollback the
|
|
262
|
-
MOMENT it queues — so you SEE exactly what's waiting, in order.
|
|
263
|
-
Muted-but-visible (quieter than user_echo's bar, louder than dim
|
|
264
|
-
chrome). When it drains, queued_run + the normal user_echo mark it
|
|
265
|
-
as the active turn."""
|
|
266
|
-
self.console.print(_pad(Text.assemble(
|
|
267
|
-
("⋯ queued: ", f"bold {_BEE}"),
|
|
268
|
-
(_clean(text), "italic grey66"))))
|
|
269
|
-
self._nudge()
|
|
270
|
-
|
|
271
269
|
def queued_run(self, remaining: int) -> None:
|
|
272
270
|
"""The tiny lifecycle marker printed right before a drained queued
|
|
273
271
|
line starts: `▶ running queued message` (+ how many still wait) — a
|
|
@@ -325,8 +325,7 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
325
325
|
"expand": lambda i: _handle(f"/steps {i + 1}"),
|
|
326
326
|
},
|
|
327
327
|
stop_turn=lambda: agent.stop(),
|
|
328
|
-
pending=pending_queue,
|
|
329
|
-
queued_run=_sink.queued_run,
|
|
328
|
+
pending=pending_queue, queued_run=_sink.queued_run,
|
|
330
329
|
)
|
|
331
330
|
finally:
|
|
332
331
|
_cancel_background()
|
|
@@ -4,17 +4,20 @@ the very bottom and never move while the output scrolls (mouse wheel /
|
|
|
4
4
|
PageUp). `run_session` also drives step-navigation (Up/Down + Enter) over the
|
|
5
5
|
pinned box when the input is empty and no turn is running; in every other
|
|
6
6
|
state Up/Down recall submitted lines (readline-style), and Enter while a turn
|
|
7
|
-
runs QUEUES the line (Claude-Code type-ahead:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
runs QUEUES the line (Claude-Code type-ahead: shown LIVE in the queue panel
|
|
8
|
+
pinned above the input — see queue_panel.py — counted in the toolbar, run
|
|
9
|
+
after the current turn — natural completion only, a user STOP preserves the
|
|
10
|
+
queue; ↑ on an empty input pulls the newest queued line back for editing, a
|
|
11
|
+
click pulls that item; /queue lists it, /queue clear drops it; the transcript
|
|
12
|
+
stays clean — real turns only). Pure helpers (next_mode/build_toolbar/the
|
|
13
|
+
*_action functions) are unit-tested; the Application is TTY/headless-smoke
|
|
14
|
+
verified. Grounded in prompt_toolkit 3.0.52."""
|
|
13
15
|
import asyncio
|
|
14
16
|
import re
|
|
15
17
|
from collections import deque
|
|
16
18
|
|
|
17
19
|
from webbee.output_pane import OutputPane # noqa: F401 — re-exported (webbee.tui.OutputPane)
|
|
20
|
+
from webbee.queue_panel import pull_item, queue_fragments, queue_height
|
|
18
21
|
from webbee.render import _fmt_tokens
|
|
19
22
|
|
|
20
23
|
_MODES = ("default", "plan", "autopilot")
|
|
@@ -136,22 +139,21 @@ def _interrupt_action(turn: dict, is_busy, stop_turn, event) -> None:
|
|
|
136
139
|
t.cancel() # cancel the running turn; dock survives
|
|
137
140
|
|
|
138
141
|
|
|
139
|
-
def _submit_line(text: str, buf, pending, busy: bool, start
|
|
142
|
+
def _submit_line(text: str, buf, pending, busy: bool, start) -> str:
|
|
140
143
|
"""Route ONE submitted line (dependency-injected, same testing philosophy
|
|
141
144
|
as _escape_action). Whitespace never queues nor starts ("ignored"). A real
|
|
142
145
|
line is recorded into the buffer's history first (up-arrow recall), then:
|
|
143
146
|
busy → QUEUE it (Claude-Code type-ahead — the line is never erased or
|
|
144
|
-
dropped; it runs after the current turn)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
(today's normal submit).
|
|
147
|
+
dropped; it runs after the current turn) — the LIVE queue panel above the
|
|
148
|
+
input shows it at once (queue_panel.queue_fragments reads this deque every
|
|
149
|
+
redraw), NEVER a static scrollback echo (those scrolled away, duplicated
|
|
150
|
+
and went stale when edited); idle → `start(text)` (today's normal submit).
|
|
151
|
+
Returns "ignored" | "queued" | "started"."""
|
|
148
152
|
if not text.strip():
|
|
149
153
|
return "ignored"
|
|
150
154
|
buf.history.append_string(text)
|
|
151
155
|
if busy:
|
|
152
156
|
pending.append(text)
|
|
153
|
-
if queued_echo is not None:
|
|
154
|
-
queued_echo(text)
|
|
155
157
|
return "queued"
|
|
156
158
|
start(text)
|
|
157
159
|
return "started"
|
|
@@ -182,10 +184,22 @@ def _is_queue_command(text: str) -> bool:
|
|
|
182
184
|
return bool(parts) and parts[0] == "/queue"
|
|
183
185
|
|
|
184
186
|
|
|
185
|
-
def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
186
|
-
"""Up key
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
def _arrow_up_action(event, buf, sel: dict, n: int, busy: bool, pending=None) -> None:
|
|
188
|
+
"""Up key — precedence: (1) QUEUE-PULL: pending items + an EMPTY buffer
|
|
189
|
+
(busy or idle — NOT busy-gated: the queue legally survives a user STOP
|
|
190
|
+
into idle, and pulling the newest to edit is exactly what you want after
|
|
191
|
+
an Esc) pull the NEWEST queued line out of the queue into the input for
|
|
192
|
+
editing; re-submit re-queues it at the tail (busy) or runs it (idle).
|
|
193
|
+
Repeated presses walk newest→oldest, one item per press; a buffer with
|
|
194
|
+
ANY text is never clobbered (history/step-nav serve it instead).
|
|
195
|
+
(2) Step-navigation EXACTLY as before (steps + empty input + idle —
|
|
196
|
+
reachable exactly when the queue is empty, i.e. today's behavior verbatim
|
|
197
|
+
in the queue-empty world). (3) Recall an older submitted line
|
|
198
|
+
(readline-style)."""
|
|
199
|
+
if pending and not buf.text:
|
|
200
|
+
pull_item(pending, buf, len(pending) - 1) # newest — "edit the last thing I queued"
|
|
201
|
+
event.app.invalidate()
|
|
202
|
+
return
|
|
189
203
|
if n and not buf.text and not busy:
|
|
190
204
|
sel["i"] = (n - 1) if sel["i"] is None else max(0, sel["i"] - 1)
|
|
191
205
|
event.app.invalidate()
|
|
@@ -207,18 +221,21 @@ def _arrow_down_action(event, buf, sel: dict, n: int, busy: bool) -> None:
|
|
|
207
221
|
|
|
208
222
|
async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
209
223
|
is_busy, consent_pending, resolve_consent, steps_nav=None,
|
|
210
|
-
stop_turn=None, pending=None,
|
|
211
|
-
queued_run=None) -> bool:
|
|
224
|
+
stop_turn=None, pending=None, queued_run=None) -> bool:
|
|
212
225
|
"""The full-screen dock: `pane` fills the top (scrollable), a bordered input
|
|
213
226
|
box + toolbar are FIXED at the bottom. Enter either resolves a pending
|
|
214
227
|
consent reply (ICNLI: raw verbatim) or starts a turn as a BACKGROUND task
|
|
215
228
|
(the box stays fixed during it); while a turn runs, Enter QUEUES the line
|
|
216
|
-
(type-ahead —
|
|
217
|
-
toolbar shows `⋯N queued` in
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
(type-ahead — the LIVE queue panel pinned above the input shows it at
|
|
230
|
+
once and tracks every edit/drain, the toolbar shows `⋯N queued` in
|
|
231
|
+
accent, and it starts right after the current turn with a `queued_run`
|
|
232
|
+
marker — the transcript itself stays real-turns-only). ↑ on an empty
|
|
233
|
+
input pulls the newest queued line back into the box for editing (it
|
|
234
|
+
leaves the panel; re-submit re-queues/runs it); clicking a panel row
|
|
235
|
+
pulls THAT item. `pending` is the queue deque — the repl passes its OWN
|
|
236
|
+
so /queue and /queue clear (dispatched through the normal command layer)
|
|
237
|
+
see and manage the live queue; /queue itself always runs immediately,
|
|
238
|
+
even mid-turn. When `steps_nav` is given and the input is empty and no turn
|
|
222
239
|
is running, Up/Down move a step selection (toolbar shows `step k/N`) and
|
|
223
240
|
Enter expands it via `steps_nav["expand"]`; Esc clears it. In every other
|
|
224
241
|
state Up/Down recall submitted lines (readline-style history).
|
|
@@ -227,8 +244,9 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
227
244
|
try:
|
|
228
245
|
from prompt_toolkit.application import Application, get_app
|
|
229
246
|
from prompt_toolkit.buffer import Buffer
|
|
247
|
+
from prompt_toolkit.filters import Condition
|
|
230
248
|
from prompt_toolkit.key_binding import KeyBindings
|
|
231
|
-
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
249
|
+
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window
|
|
232
250
|
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
233
251
|
from prompt_toolkit.layout.processors import BeforeInput
|
|
234
252
|
from prompt_toolkit.styles import Style
|
|
@@ -300,10 +318,10 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
300
318
|
event.app.create_background_task(on_line(text))
|
|
301
319
|
return
|
|
302
320
|
# Non-empty line: record for up-arrow recall, then queue-while-busy
|
|
303
|
-
# (type-ahead —
|
|
304
|
-
# runs after the current turn) or start a turn now.
|
|
305
|
-
if _submit_line(text, buf, pending, _busy_live(), _start_turn
|
|
306
|
-
event.app.invalidate() # toolbar
|
|
321
|
+
# (type-ahead — appears AT ONCE in the live queue panel + accent
|
|
322
|
+
# toolbar depth, runs after the current turn) or start a turn now.
|
|
323
|
+
if _submit_line(text, buf, pending, _busy_live(), _start_turn) == "queued":
|
|
324
|
+
event.app.invalidate() # panel + toolbar show the new depth
|
|
307
325
|
|
|
308
326
|
@kb.add("s-tab")
|
|
309
327
|
def _cycle(event):
|
|
@@ -329,7 +347,7 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
329
347
|
|
|
330
348
|
@kb.add("up")
|
|
331
349
|
def _step_up(event):
|
|
332
|
-
_arrow_up_action(event, buf, sel, _nav_count(), _busy_live())
|
|
350
|
+
_arrow_up_action(event, buf, sel, _nav_count(), _busy_live(), pending)
|
|
333
351
|
|
|
334
352
|
@kb.add("down")
|
|
335
353
|
def _step_down(event):
|
|
@@ -376,11 +394,35 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
376
394
|
# so the mode is obvious from the input line itself, not just the toolbar.
|
|
377
395
|
return [(f"class:tb.mode.{mode_getter()}", "❯ ")]
|
|
378
396
|
|
|
397
|
+
def _pull_at(index: int) -> None:
|
|
398
|
+
"""Mouse pull (a panel row's MOUSE_UP handler, queue_panel._item_handler):
|
|
399
|
+
move the CLICKED queued item into the input for editing — the SAME
|
|
400
|
+
pull_item the ↑ key uses, arbitrary index instead of newest (never
|
|
401
|
+
clobbers a typed draft, ignores a stale index)."""
|
|
402
|
+
if pull_item(pending, buf, index):
|
|
403
|
+
get_app().invalidate()
|
|
404
|
+
|
|
405
|
+
def _queue_fragments():
|
|
406
|
+
# Live like _toolbar: re-invoked every redraw, reads the shared deque.
|
|
407
|
+
import shutil
|
|
408
|
+
return queue_fragments(pending, pull=_pull_at,
|
|
409
|
+
width=shutil.get_terminal_size((100, 24)).columns)
|
|
410
|
+
|
|
411
|
+
# The LIVE pending-queue panel — pinned BETWEEN the output pane and the
|
|
412
|
+
# input box; zero rows (hidden) while the queue is empty, so the empty
|
|
413
|
+
# state is pixel-identical to the panel-less dock. focusable=False keeps
|
|
414
|
+
# focus on the input even when a row is clicked.
|
|
415
|
+
queue_panel = ConditionalContainer(
|
|
416
|
+
content=Window(FormattedTextControl(_queue_fragments, focusable=False),
|
|
417
|
+
height=lambda: queue_height(pending),
|
|
418
|
+
always_hide_cursor=True, wrap_lines=False),
|
|
419
|
+
filter=Condition(lambda: bool(pending)))
|
|
420
|
+
|
|
379
421
|
input_win = Window(
|
|
380
422
|
BufferControl(buffer=buf, input_processors=[BeforeInput(_prompt_fragments)]),
|
|
381
423
|
height=_input_height, wrap_lines=True)
|
|
382
424
|
toolbar = Window(FormattedTextControl(_toolbar), height=1, always_hide_cursor=True)
|
|
383
|
-
root = HSplit([pane.window, Frame(input_win), toolbar])
|
|
425
|
+
root = HSplit([pane.window, queue_panel, Frame(input_win), toolbar])
|
|
384
426
|
style = Style.from_dict({
|
|
385
427
|
"frame.border": "#5f5f5f", # muted grey chrome — furniture, not focus
|
|
386
428
|
"prompt": "#00afd7 bold", # cyan ❯ — the interactive accent
|
|
@@ -392,6 +434,9 @@ async def run_session(*, pane, on_line, mode_getter, on_cycle, status,
|
|
|
392
434
|
"tb.mode.default": "#00afd7", # default — cyan
|
|
393
435
|
"tb.mode.plan": "#af87ff", # plan — purple
|
|
394
436
|
"tb.mode.autopilot": "#e8a317 bold", # autopilot — yellow (auto-approving: caution)
|
|
437
|
+
"qp.header": "#e8a317 bold", # queue-panel header — bee-yellow, pops
|
|
438
|
+
"qp.item": "#8a8a8a italic", # older queued rows — muted (echoes grey66)
|
|
439
|
+
"qp.last": "#e8a317", # newest row — the one ↑ pulls
|
|
395
440
|
})
|
|
396
441
|
app = Application(layout=Layout(root, focused_element=input_win), key_bindings=kb,
|
|
397
442
|
full_screen=True, mouse_support=True, style=style)
|
|
@@ -156,7 +156,7 @@ def test_usage_and_clear_still_work():
|
|
|
156
156
|
|
|
157
157
|
# ---- WEBBEE CODE welcome + account panel + session /cost (Chunk 1 Task 2) --
|
|
158
158
|
|
|
159
|
-
def
|
|
159
|
+
def test_welcome_full_account_essentials_only():
|
|
160
160
|
from webbee.account import Account
|
|
161
161
|
from webbee.banner_art import WEBBEE_CODE
|
|
162
162
|
s = _sink()
|
|
@@ -169,11 +169,40 @@ def test_welcome_full_account_aligned():
|
|
|
169
169
|
# assert a stable fragment of the actual logo constant renders instead.
|
|
170
170
|
assert WEBBEE_CODE.splitlines()[2] in out # ascii logo present
|
|
171
171
|
assert "i m p e r a l . i o" in out # letter-spaced brand caption, per Target welcome
|
|
172
|
-
assert "v@imperal.io" in out and "notvallium" in out
|
|
173
|
-
|
|
174
|
-
assert "explorer" in out and "Apr 2026" in out
|
|
172
|
+
assert "v@imperal.io" in out and "notvallium" in out and "pro plan" in out
|
|
173
|
+
# trimmed: vanity rows are gone, and a healthy plan doesn't shout "active"
|
|
174
|
+
assert "explorer" not in out and "Apr 2026" not in out
|
|
175
|
+
assert "renews" not in out and "active" not in out
|
|
175
176
|
assert "/help" in out
|
|
176
|
-
assert not NO_CYRILLIC.search(out)
|
|
177
|
+
assert not NO_CYRILLIC.search(out) # English UI only
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_welcome_privacy_line_and_hint():
|
|
181
|
+
from webbee.account import Account
|
|
182
|
+
from webbee.render import WELCOME_HINT, WELCOME_PRIVACY, WELCOME_PRIVACY_DETAIL
|
|
183
|
+
s = _sink()
|
|
184
|
+
s.welcome(Account(signed_in=True, email="v@imperal.io"), "/x", "terminal")
|
|
185
|
+
out = s.console.export_text()
|
|
186
|
+
# the privacy promise renders verbatim (claims-disciplined copy, see render.py)
|
|
187
|
+
assert "🔒" in out
|
|
188
|
+
assert "Your work stays yours — never sold, never training data." in out
|
|
189
|
+
assert "PII is masked before it reaches the model." in out
|
|
190
|
+
# the old verbose tip collapsed into ONE crisp hint line
|
|
191
|
+
assert "Type a task" in out and "/help" in out and "Ctrl-D" in out
|
|
192
|
+
assert "credits run low" not in out and "--once" not in out
|
|
193
|
+
# constants stay in sync with what actually renders
|
|
194
|
+
for const in (WELCOME_PRIVACY, WELCOME_PRIVACY_DETAIL, WELCOME_HINT):
|
|
195
|
+
assert const.strip() in out
|
|
196
|
+
assert not NO_CYRILLIC.search(out) # English UI only
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_welcome_plan_status_shown_only_when_abnormal():
|
|
200
|
+
from webbee.account import Account
|
|
201
|
+
s = _sink()
|
|
202
|
+
s.welcome(Account(signed_in=True, email="v@imperal.io", plan="pro",
|
|
203
|
+
plan_status="past_due"), "/x", "terminal")
|
|
204
|
+
out = s.console.export_text()
|
|
205
|
+
assert "pro plan (past_due)" in out
|
|
177
206
|
|
|
178
207
|
|
|
179
208
|
def test_welcome_signed_out_minimal():
|
|
@@ -181,7 +210,11 @@ def test_welcome_signed_out_minimal():
|
|
|
181
210
|
s = _sink()
|
|
182
211
|
s.welcome(Account(signed_in=False), "/x", "terminal")
|
|
183
212
|
out = s.console.export_text()
|
|
184
|
-
assert "i m p e r a l . i o" in out # letter-spaced brand caption, per Target welcome
|
|
213
|
+
assert "i m p e r a l . i o" in out # letter-spaced brand caption, per Target welcome
|
|
214
|
+
assert "/login" in out
|
|
215
|
+
# trust line shows for everyone, signed in or not
|
|
216
|
+
assert "never sold, never training data" in out
|
|
217
|
+
assert not NO_CYRILLIC.search(out) # English UI only
|
|
185
218
|
|
|
186
219
|
|
|
187
220
|
def test_session_credits_accumulates_across_turns():
|
|
@@ -430,7 +463,10 @@ def test_progress_and_thinking_strip_escapes():
|
|
|
430
463
|
assert "\x1b[?1003h" not in out and "pwned" not in out
|
|
431
464
|
|
|
432
465
|
|
|
433
|
-
# ──
|
|
466
|
+
# ── queued_run — the drain marker (0.3.12; the `⋯ queued:` scrollback echo was
|
|
467
|
+
# replaced by the LIVE queue panel in 0.3.13 — queue visibility now lives in
|
|
468
|
+
# webbee.queue_panel, tested in test_tui.py; the transcript stays real-turns-only
|
|
469
|
+
# plus this one-line drain provenance mark) ────────────────────────────────────
|
|
434
470
|
|
|
435
471
|
|
|
436
472
|
def _rec_sink():
|
|
@@ -441,17 +477,11 @@ def _rec_sink():
|
|
|
441
477
|
return RichSink(console=c, live_enabled=False, input_fn=lambda p: "", clock=lambda: 0.0), c
|
|
442
478
|
|
|
443
479
|
|
|
444
|
-
def
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
def test_queued_echo_strips_control_bytes():
|
|
451
|
-
s, c = _rec_sink()
|
|
452
|
-
s.queued_echo("evil\x1b[?1003htext")
|
|
453
|
-
out = c.export_text()
|
|
454
|
-
assert "\x1b[?1003h" not in out and "eviltext" in out
|
|
480
|
+
def test_sink_has_no_queued_echo_anymore():
|
|
481
|
+
# The static scrollback echo is GONE (it scrolled away, duplicated and went
|
|
482
|
+
# stale when edited) — the live panel above the input owns queue visibility.
|
|
483
|
+
s, _ = _rec_sink()
|
|
484
|
+
assert not hasattr(s, "queued_echo")
|
|
455
485
|
|
|
456
486
|
|
|
457
487
|
def test_queued_run_marker_announces_the_drain():
|
|
@@ -20,7 +20,6 @@ class FakeSink:
|
|
|
20
20
|
def abort(self): self.aborted = True
|
|
21
21
|
def welcome(self, *a, **kw): ...
|
|
22
22
|
def user_echo(self, text): self.echoed = getattr(self, "echoed", []) + [text]
|
|
23
|
-
def queued_echo(self, text): self.queued_echoes = getattr(self, "queued_echoes", []) + [text]
|
|
24
23
|
def queued_run(self, remaining): self.queued_runs = getattr(self, "queued_runs", []) + [remaining]
|
|
25
24
|
def sessions_table(self, rows): self.session_tables = getattr(self, "session_tables", []) + [rows]
|
|
26
25
|
def foreign_turn(self, surface, role, text):
|
|
@@ -633,42 +633,25 @@ def test_dock_end_to_end_type_ahead_queues_then_drains_fifo():
|
|
|
633
633
|
asyncio.run(scenario())
|
|
634
634
|
|
|
635
635
|
|
|
636
|
-
# ── 0.3.12: the queue is VISIBLE + managed
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
#
|
|
636
|
+
# ── 0.3.12: the queue is VISIBLE + managed (0.3.13: visibility moved from the
|
|
637
|
+
# static `⋯ queued:` scrollback echo — which scrolled away, duplicated and went
|
|
638
|
+
# stale — into the LIVE queue panel above the input, webbee.queue_panel; the
|
|
639
|
+
# panel section below covers it). A drain still announces itself
|
|
640
640
|
# (sink.queued_run) right before the drained line's normal ❯ user-echo, the
|
|
641
641
|
# toolbar segment is an ACCENT (tb.working, not dim), /queue // /queue clear
|
|
642
642
|
# manage the shared deque even mid-turn, and a user STOP (Esc/Ctrl-C)
|
|
643
643
|
# PRESERVES the queue — drain on natural completion only.
|
|
644
644
|
|
|
645
645
|
|
|
646
|
-
def
|
|
646
|
+
def test_whitespace_never_queues():
|
|
647
647
|
from webbee.tui import _submit_line
|
|
648
648
|
buf = _RecBuf()
|
|
649
649
|
pending = deque()
|
|
650
|
-
started
|
|
651
|
-
assert _submit_line("ship it", buf, pending, True, started.append, echoed.append) == "queued"
|
|
652
|
-
assert echoed == ["ship it"] # the #1 fix: you SEE what queued
|
|
653
|
-
assert list(pending) == ["ship it"] and started == []
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def test_idle_submit_does_not_fire_queued_echo():
|
|
657
|
-
from webbee.tui import _submit_line
|
|
658
|
-
started, echoed = [], []
|
|
659
|
-
assert _submit_line("go", _RecBuf(), deque(), False, started.append, echoed.append) == "started"
|
|
660
|
-
assert echoed == [] and started == ["go"]
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
def test_whitespace_never_queues_nor_echoes():
|
|
664
|
-
from webbee.tui import _submit_line
|
|
665
|
-
buf = _RecBuf()
|
|
666
|
-
pending = deque()
|
|
667
|
-
started, echoed = [], []
|
|
650
|
+
started = []
|
|
668
651
|
for junk in ("", " ", "\t \t"):
|
|
669
|
-
assert _submit_line(junk, buf, pending, True, started.append
|
|
670
|
-
assert _submit_line(junk, buf, pending, False, started.append
|
|
671
|
-
assert not pending and started == []
|
|
652
|
+
assert _submit_line(junk, buf, pending, True, started.append) == "ignored"
|
|
653
|
+
assert _submit_line(junk, buf, pending, False, started.append) == "ignored"
|
|
654
|
+
assert not pending and started == []
|
|
672
655
|
assert buf.history.items == [] # junk is not recallable either
|
|
673
656
|
|
|
674
657
|
|
|
@@ -738,7 +721,7 @@ def test_dock_stop_preserves_queue_then_natural_completion_drains():
|
|
|
738
721
|
|
|
739
722
|
async def scenario():
|
|
740
723
|
pane = tui.OutputPane(width=80)
|
|
741
|
-
ran,
|
|
724
|
+
ran, markers = [], []
|
|
742
725
|
gate = asyncio.Event()
|
|
743
726
|
busy = {"v": False}
|
|
744
727
|
|
|
@@ -763,12 +746,12 @@ def test_dock_stop_preserves_queue_then_natural_completion_drains():
|
|
|
763
746
|
on_cycle=lambda: None, status=status,
|
|
764
747
|
is_busy=lambda: busy["v"],
|
|
765
748
|
consent_pending=lambda: False, resolve_consent=lambda t: None,
|
|
766
|
-
pending=pend,
|
|
749
|
+
pending=pend, queued_run=markers.append))
|
|
767
750
|
await asyncio.sleep(0.05)
|
|
768
751
|
pipe.send_text("first\r")
|
|
769
752
|
await _until(lambda: ran == ["first"]) # turn 1 is running
|
|
770
753
|
pipe.send_text("queued follow-up\r") # type-ahead while busy
|
|
771
|
-
await _until(lambda:
|
|
754
|
+
await _until(lambda: list(pend) == ["queued follow-up"]) # queued AT ONCE
|
|
772
755
|
pipe.send_text("\x03") # user STOP (Ctrl-C)
|
|
773
756
|
await _until(lambda: not busy["v"])
|
|
774
757
|
await asyncio.sleep(0.1)
|
|
@@ -784,6 +767,7 @@ def test_dock_stop_preserves_queue_then_natural_completion_drains():
|
|
|
784
767
|
pipe.send_text("\x04") # Ctrl-D exit (idle)
|
|
785
768
|
ok = await asyncio.wait_for(task, 5)
|
|
786
769
|
assert ok is True
|
|
770
|
+
assert "⋯ queued" not in pane.dump() # the transcript stays CLEAN
|
|
787
771
|
|
|
788
772
|
asyncio.run(scenario())
|
|
789
773
|
|
|
@@ -809,7 +793,7 @@ def test_dock_queue_command_runs_immediately_even_while_busy():
|
|
|
809
793
|
|
|
810
794
|
async def scenario():
|
|
811
795
|
pane = tui.OutputPane(width=80)
|
|
812
|
-
ran
|
|
796
|
+
ran = []
|
|
813
797
|
gate = asyncio.Event()
|
|
814
798
|
busy = {"v": False}
|
|
815
799
|
pend = deque()
|
|
@@ -836,7 +820,7 @@ def test_dock_queue_command_runs_immediately_even_while_busy():
|
|
|
836
820
|
on_cycle=lambda: None, status=status,
|
|
837
821
|
is_busy=lambda: busy["v"],
|
|
838
822
|
consent_pending=lambda: False, resolve_consent=lambda t: None,
|
|
839
|
-
pending=pend
|
|
823
|
+
pending=pend))
|
|
840
824
|
await asyncio.sleep(0.05)
|
|
841
825
|
pipe.send_text("first\r")
|
|
842
826
|
await _until(lambda: ran == ["first"])
|
|
@@ -851,9 +835,269 @@ def test_dock_queue_command_runs_immediately_even_while_busy():
|
|
|
851
835
|
await _until(lambda: not busy["v"])
|
|
852
836
|
await asyncio.sleep(0.1)
|
|
853
837
|
assert "second" not in ran # cleared → nothing drained
|
|
854
|
-
assert echoed == ["second"] # /queue itself never echoed
|
|
855
838
|
pipe.send_text("\x04")
|
|
856
839
|
ok = await asyncio.wait_for(task, 5)
|
|
857
840
|
assert ok is True
|
|
858
841
|
|
|
859
842
|
asyncio.run(scenario())
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ── 0.3.13: the LIVE queue panel + pull-to-edit ───────────────────────────────
|
|
846
|
+
# The static `⋯ queued:` scrollback echoes are GONE (they scrolled away,
|
|
847
|
+
# duplicated, and went stale when edited). The queue now lives in a panel
|
|
848
|
+
# pinned ABOVE the input (webbee.queue_panel — pure fragment/height builders
|
|
849
|
+
# mounted in a ConditionalContainer): it updates live on every add/edit/drain,
|
|
850
|
+
# ↑ on an empty input pulls the NEWEST item back into the box for editing
|
|
851
|
+
# (re-submit re-queues it), a click pulls THAT item, and the transcript stays
|
|
852
|
+
# real-turns-only.
|
|
853
|
+
|
|
854
|
+
from webbee.queue_panel import ( # noqa: E402
|
|
855
|
+
QP_MAX_ITEMS, one_line, pull_item, queue_fragments, queue_height)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _panel_text(frags):
|
|
859
|
+
return "".join(f[1] for f in frags)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def test_empty_queue_renders_no_panel():
|
|
863
|
+
assert queue_fragments(deque()) == []
|
|
864
|
+
assert queue_height(deque()) == 0 # ConditionalContainer hides it anyway
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def test_panel_header_counts_and_orders_items_drain_first_to_newest():
|
|
868
|
+
frags = queue_fragments(deque(["first", "second"]))
|
|
869
|
+
text = _panel_text(frags)
|
|
870
|
+
assert "⋯ queued (2)" in text
|
|
871
|
+
assert text.index("first") < text.index("second") # top row drains next (FIFO)
|
|
872
|
+
assert text.count("\n") == 2 # header + exactly one row each
|
|
873
|
+
assert not NO_CYRILLIC.search(text)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_panel_newest_item_accented_older_muted():
|
|
877
|
+
frags = queue_fragments(deque(["older", "newest"]))
|
|
878
|
+
assert [f[0] for f in frags if "older" in f[1]] == ["class:qp.item"]
|
|
879
|
+
assert [f[0] for f in frags if "newest" in f[1]] == ["class:qp.last"]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def test_panel_caps_at_newest_five_with_a_more_row():
|
|
883
|
+
items = [f"item{i}" for i in range(8)]
|
|
884
|
+
frags = queue_fragments(deque(items))
|
|
885
|
+
text = _panel_text(frags)
|
|
886
|
+
assert "⋯ queued (8)" in text # header keeps the TRUE depth
|
|
887
|
+
assert "… +3 more" in text # the oldest 3 hide behind it
|
|
888
|
+
assert all(t in text for t in items[3:]) # newest 5 shown
|
|
889
|
+
assert all(t not in text for t in items[:3])
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def test_panel_height_is_header_plus_rows_capped():
|
|
893
|
+
assert queue_height(deque(["a"])) == 2 # header + 1
|
|
894
|
+
assert queue_height(deque(["a"] * QP_MAX_ITEMS)) == 6 # header + 5
|
|
895
|
+
assert queue_height(deque(["a"] * 9)) == 7 # header + 5 + more-row
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_panel_item_is_one_truncated_row():
|
|
899
|
+
frags = queue_fragments(deque(["x" * 500]), width=40)
|
|
900
|
+
rows = _panel_text(frags).split("\n")
|
|
901
|
+
assert len(rows) == 2 # a huge item still costs ONE row
|
|
902
|
+
assert rows[1].endswith("…") and len(rows[1]) <= 40
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def test_panel_multiline_item_collapses_to_one_row():
|
|
906
|
+
frags = queue_fragments(deque(["line1\nline2\tline3"]))
|
|
907
|
+
text = _panel_text(frags)
|
|
908
|
+
assert text.count("\n") == 1 and "line1 line2 line3" in text
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def test_one_line_collapses_whitespace_and_truncates():
|
|
912
|
+
assert one_line("a\n b\t c", 80) == "a b c"
|
|
913
|
+
assert one_line("abcdef", 4) == "abc…"
|
|
914
|
+
assert one_line("abcd", 4) == "abcd"
|
|
915
|
+
assert one_line("", 10) == ""
|
|
916
|
+
assert one_line("abcdef", 0) == "abcdef" # no width → no truncation
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def test_panel_without_pull_has_no_mouse_handlers():
|
|
920
|
+
assert all(len(f) == 2 for f in queue_fragments(deque(["a", "b"])))
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def test_panel_item_mouse_up_pulls_that_item_other_events_fall_through():
|
|
924
|
+
from prompt_toolkit.data_structures import Point
|
|
925
|
+
from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
|
|
926
|
+
pulls = []
|
|
927
|
+
frags = queue_fragments(deque(["a", "b", "c"]), pull=pulls.append, width=80)
|
|
928
|
+
handlers = [f[2] for f in frags if len(f) == 3]
|
|
929
|
+
assert len(handlers) == 3 # every item row is clickable
|
|
930
|
+
up = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_UP,
|
|
931
|
+
button=MouseButton.LEFT, modifiers=frozenset())
|
|
932
|
+
assert handlers[1](up) is None and pulls == [1] # the CLICKED item, by index
|
|
933
|
+
down = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_DOWN,
|
|
934
|
+
button=MouseButton.LEFT, modifiers=frozenset())
|
|
935
|
+
assert handlers[0](down) is NotImplemented # press/drag/wheel fall through
|
|
936
|
+
assert pulls == [1]
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def test_control_dispatches_click_row_to_the_matching_item():
|
|
940
|
+
# prompt_toolkit's OWN per-fragment dispatch (FormattedTextControl.mouse_handler)
|
|
941
|
+
# routes a click at row y to that row's handler — the exact path a real SGR
|
|
942
|
+
# ?1000 button report takes after decoding. Row 0 = header (no handler).
|
|
943
|
+
from prompt_toolkit.data_structures import Point
|
|
944
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
945
|
+
from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
|
|
946
|
+
pulls = []
|
|
947
|
+
pend = deque(["alpha", "beta"])
|
|
948
|
+
ctrl = FormattedTextControl(
|
|
949
|
+
lambda: queue_fragments(pend, pull=pulls.append, width=80), focusable=False)
|
|
950
|
+
ctrl.create_content(width=80, height=3) # populates self._fragments
|
|
951
|
+
ev = lambda y: MouseEvent(position=Point(4, y), event_type=MouseEventType.MOUSE_UP,
|
|
952
|
+
button=MouseButton.LEFT, modifiers=frozenset())
|
|
953
|
+
assert ctrl.mouse_handler(ev(2)) is None and pulls == [1] # row 2 = "beta"
|
|
954
|
+
assert ctrl.mouse_handler(ev(1)) is None and pulls == [1, 0] # row 1 = "alpha"
|
|
955
|
+
assert ctrl.mouse_handler(ev(0)) is NotImplemented # header: no pull
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def test_pull_item_guards_draft_and_stale_index():
|
|
959
|
+
# The ONE pull implementation behind BOTH ↑ and click: never clobbers a
|
|
960
|
+
# typed draft; a stale index (queue drained between render and click) is
|
|
961
|
+
# ignored; a valid pull moves the item out with the cursor at the end.
|
|
962
|
+
from prompt_toolkit.buffer import Buffer
|
|
963
|
+
buf = Buffer(multiline=False)
|
|
964
|
+
buf.text = "half-typed draft"
|
|
965
|
+
pend = deque(["a", "b"])
|
|
966
|
+
assert pull_item(pend, buf, 1) is False # draft protected
|
|
967
|
+
assert buf.text == "half-typed draft" and list(pend) == ["a", "b"]
|
|
968
|
+
buf.reset()
|
|
969
|
+
assert pull_item(pend, buf, 5) is False # stale index ignored
|
|
970
|
+
assert pull_item(pend, buf, -1) is False
|
|
971
|
+
assert pull_item(pend, buf, 0) is True # arbitrary index (click)
|
|
972
|
+
assert buf.text == "a" and buf.cursor_position == 1
|
|
973
|
+
assert list(pend) == ["b"]
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def test_up_arrow_pulls_newest_queued_into_empty_buffer_busy_or_idle():
|
|
977
|
+
from prompt_toolkit.buffer import Buffer
|
|
978
|
+
|
|
979
|
+
from webbee.tui import _arrow_up_action
|
|
980
|
+
for busy in (True, False): # NOT busy-gated: the queue survives Esc into idle
|
|
981
|
+
buf = Buffer(multiline=False)
|
|
982
|
+
pend = deque(["older", "newest"])
|
|
983
|
+
event = _FakeEvent()
|
|
984
|
+
sel = {"i": None}
|
|
985
|
+
_arrow_up_action(event, buf, sel, 0, busy, pend)
|
|
986
|
+
assert buf.text == "newest" # the last thing queued
|
|
987
|
+
assert buf.cursor_position == len("newest") # cursor ready at the end
|
|
988
|
+
assert list(pend) == ["older"] # it LEFT the queue
|
|
989
|
+
assert sel["i"] is None and event.app.invalidated is True
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def test_repeated_pulls_walk_newest_to_oldest():
|
|
993
|
+
from prompt_toolkit.buffer import Buffer
|
|
994
|
+
|
|
995
|
+
from webbee.tui import _arrow_up_action
|
|
996
|
+
buf = Buffer(multiline=False)
|
|
997
|
+
pend = deque(["a", "b", "c"])
|
|
998
|
+
event = _FakeEvent()
|
|
999
|
+
for expected in ("c", "b", "a"):
|
|
1000
|
+
buf.reset() # emptied (submitted/cleared)…
|
|
1001
|
+
_arrow_up_action(event, buf, {"i": None}, 0, True, pend)
|
|
1002
|
+
assert buf.text == expected # …then the next-newest pulls
|
|
1003
|
+
assert not pend
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def test_up_arrow_with_text_present_never_clobbers_the_draft():
|
|
1007
|
+
from prompt_toolkit.buffer import Buffer
|
|
1008
|
+
|
|
1009
|
+
from webbee.tui import _arrow_up_action
|
|
1010
|
+
|
|
1011
|
+
async def scenario():
|
|
1012
|
+
buf = Buffer(multiline=False)
|
|
1013
|
+
buf.history.append_string("submitted earlier")
|
|
1014
|
+
buf.text = "half-typed draft"
|
|
1015
|
+
buf.load_history_if_not_yet_loaded()
|
|
1016
|
+
await buf._load_history_task
|
|
1017
|
+
pend = deque(["queued line"])
|
|
1018
|
+
_arrow_up_action(_FakeEvent(), buf, {"i": None}, 0, True, pend)
|
|
1019
|
+
assert list(pend) == ["queued line"] # NOT pulled — buffer has text
|
|
1020
|
+
assert buf.text == "submitted earlier" # history served instead
|
|
1021
|
+
asyncio.run(scenario())
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def test_pull_queued_takes_precedence_over_step_nav():
|
|
1025
|
+
from prompt_toolkit.buffer import Buffer
|
|
1026
|
+
|
|
1027
|
+
from webbee.tui import _arrow_up_action
|
|
1028
|
+
buf = Buffer(multiline=False)
|
|
1029
|
+
pend = deque(["queued line"])
|
|
1030
|
+
sel = {"i": None}
|
|
1031
|
+
_arrow_up_action(_FakeEvent(), buf, sel, 3, False, pend) # idle + steps + queue
|
|
1032
|
+
assert buf.text == "queued line" and not pend # the queued text is fresher intent
|
|
1033
|
+
assert sel["i"] is None # steps reachable once queue empties
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def test_dock_end_to_end_panel_pull_reedit_requeue_and_clean_transcript():
|
|
1037
|
+
# The full loop on the REAL Application: queue while busy → the item is in
|
|
1038
|
+
# the PANEL (queue_fragments over the shared deque), NOT in the scrollback;
|
|
1039
|
+
# ↑ pulls it into the input (it leaves the panel); Enter re-queues it at
|
|
1040
|
+
# the tail; the natural drain announces itself (queued_run) and empties the
|
|
1041
|
+
# panel; the transcript never saw a `⋯ queued` echo.
|
|
1042
|
+
import time
|
|
1043
|
+
|
|
1044
|
+
from prompt_toolkit.application import create_app_session
|
|
1045
|
+
from prompt_toolkit.input import create_pipe_input
|
|
1046
|
+
from prompt_toolkit.output import DummyOutput
|
|
1047
|
+
|
|
1048
|
+
from webbee import tui
|
|
1049
|
+
|
|
1050
|
+
async def _until(pred, timeout=5.0):
|
|
1051
|
+
t0 = time.time()
|
|
1052
|
+
while not pred():
|
|
1053
|
+
assert time.time() - t0 < timeout, "timed out"
|
|
1054
|
+
await asyncio.sleep(0.01)
|
|
1055
|
+
|
|
1056
|
+
async def scenario():
|
|
1057
|
+
pane = tui.OutputPane(width=80)
|
|
1058
|
+
ran, markers = [], []
|
|
1059
|
+
gate = asyncio.Event()
|
|
1060
|
+
busy = {"v": False}
|
|
1061
|
+
pend = deque()
|
|
1062
|
+
|
|
1063
|
+
async def on_line(text):
|
|
1064
|
+
busy["v"] = True
|
|
1065
|
+
ran.append(text)
|
|
1066
|
+
await gate.wait()
|
|
1067
|
+
busy["v"] = False
|
|
1068
|
+
|
|
1069
|
+
def status():
|
|
1070
|
+
return {"tokens": 0, "credits": 0, "busy": busy["v"], "current": "",
|
|
1071
|
+
"elapsed": 0.0, "tools": 0, "consent": False}
|
|
1072
|
+
|
|
1073
|
+
with create_pipe_input() as pipe:
|
|
1074
|
+
with create_app_session(input=pipe, output=DummyOutput()):
|
|
1075
|
+
task = asyncio.create_task(tui.run_session(
|
|
1076
|
+
pane=pane, on_line=on_line, mode_getter=lambda: "default",
|
|
1077
|
+
on_cycle=lambda: None, status=status,
|
|
1078
|
+
is_busy=lambda: busy["v"],
|
|
1079
|
+
consent_pending=lambda: False, resolve_consent=lambda t: None,
|
|
1080
|
+
pending=pend, queued_run=markers.append))
|
|
1081
|
+
await asyncio.sleep(0.05)
|
|
1082
|
+
pipe.send_text("first\r")
|
|
1083
|
+
await _until(lambda: ran == ["first"]) # turn 1 is running
|
|
1084
|
+
pipe.send_text("second draft\r") # type-ahead → queue
|
|
1085
|
+
await _until(lambda: list(pend) == ["second draft"])
|
|
1086
|
+
assert "second draft" in _panel_text(queue_fragments(pend)) # in the PANEL
|
|
1087
|
+
assert "⋯ queued" not in pane.dump() # NOT in the scrollback
|
|
1088
|
+
pipe.send_text("\x1b[A") # ↑ pulls it to edit
|
|
1089
|
+
await _until(lambda: not pend) # it LEFT the panel
|
|
1090
|
+
pipe.send_text(" v2\r") # edit + re-submit (busy)
|
|
1091
|
+
await _until(lambda: list(pend) == ["second draft v2"]) # re-queued at tail
|
|
1092
|
+
assert ran == ["first"] # still nothing mid-turn
|
|
1093
|
+
gate.set() # natural completion
|
|
1094
|
+
await _until(lambda: ran == ["first", "second draft v2"])
|
|
1095
|
+
assert markers == [0] # drain announced itself
|
|
1096
|
+
await _until(lambda: not pend) # panel emptied
|
|
1097
|
+
await _until(lambda: not busy["v"])
|
|
1098
|
+
pipe.send_text("\x04") # Ctrl-D exit (idle)
|
|
1099
|
+
ok = await asyncio.wait_for(task, 5)
|
|
1100
|
+
assert ok is True
|
|
1101
|
+
assert "⋯ queued" not in pane.dump() # transcript stayed CLEAN
|
|
1102
|
+
|
|
1103
|
+
asyncio.run(scenario())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.12"
|
|
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
|