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.
Files changed (86) hide show
  1. {webbee-0.3.12 → webbee-0.3.13}/CHANGELOG.md +10 -0
  2. {webbee-0.3.12 → webbee-0.3.13}/PKG-INFO +1 -1
  3. {webbee-0.3.12 → webbee-0.3.13}/pyproject.toml +1 -1
  4. webbee-0.3.13/src/webbee/__init__.py +1 -0
  5. webbee-0.3.13/src/webbee/queue_panel.py +101 -0
  6. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/render.py +32 -34
  7. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/repl.py +1 -2
  8. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tui.py +77 -32
  9. {webbee-0.3.12 → webbee-0.3.13}/tests/test_render.py +48 -18
  10. {webbee-0.3.12 → webbee-0.3.13}/tests/test_repl.py +0 -1
  11. {webbee-0.3.12 → webbee-0.3.13}/tests/test_tui.py +276 -32
  12. webbee-0.3.12/src/webbee/__init__.py +0 -1
  13. {webbee-0.3.12 → webbee-0.3.13}/.github/workflows/publish.yml +0 -0
  14. {webbee-0.3.12 → webbee-0.3.13}/.gitignore +0 -0
  15. {webbee-0.3.12 → webbee-0.3.13}/LICENSE +0 -0
  16. {webbee-0.3.12 → webbee-0.3.13}/README.md +0 -0
  17. {webbee-0.3.12 → webbee-0.3.13}/install.sh +0 -0
  18. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/account.py +0 -0
  19. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/banner_art.py +0 -0
  20. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/boot.py +0 -0
  21. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/checkpoints.py +0 -0
  22. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/cli.py +0 -0
  23. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/clipboard.py +0 -0
  24. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/coding_context.py +0 -0
  25. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/commands.py +0 -0
  26. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/config.py +0 -0
  27. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/consent.py +0 -0
  28. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/details.py +0 -0
  29. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/events.py +0 -0
  30. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/frames.py +0 -0
  31. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/__init__.py +0 -0
  32. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/chunker.py +0 -0
  33. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/embed.py +0 -0
  34. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/graph.py +0 -0
  35. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/indexer.py +0 -0
  36. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/models.py +0 -0
  37. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/query.py +0 -0
  38. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/service.py +0 -0
  39. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/store.py +0 -0
  40. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/vectors.py +0 -0
  41. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/intel/watch.py +0 -0
  42. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/output_pane.py +0 -0
  43. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/remote.py +0 -0
  44. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/repo.py +0 -0
  45. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/session.py +0 -0
  46. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/sessions.py +0 -0
  47. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/steer.py +0 -0
  48. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/stream.py +0 -0
  49. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/thread.py +0 -0
  50. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tokens.py +0 -0
  51. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/tools.py +0 -0
  52. {webbee-0.3.12 → webbee-0.3.13}/src/webbee/update.py +0 -0
  53. {webbee-0.3.12 → webbee-0.3.13}/tests/__init__.py +0 -0
  54. {webbee-0.3.12 → webbee-0.3.13}/tests/test_account.py +0 -0
  55. {webbee-0.3.12 → webbee-0.3.13}/tests/test_checkpoints.py +0 -0
  56. {webbee-0.3.12 → webbee-0.3.13}/tests/test_cli.py +0 -0
  57. {webbee-0.3.12 → webbee-0.3.13}/tests/test_clipboard.py +0 -0
  58. {webbee-0.3.12 → webbee-0.3.13}/tests/test_commands.py +0 -0
  59. {webbee-0.3.12 → webbee-0.3.13}/tests/test_config.py +0 -0
  60. {webbee-0.3.12 → webbee-0.3.13}/tests/test_cpc_contract_stable.py +0 -0
  61. {webbee-0.3.12 → webbee-0.3.13}/tests/test_details.py +0 -0
  62. {webbee-0.3.12 → webbee-0.3.13}/tests/test_events.py +0 -0
  63. {webbee-0.3.12 → webbee-0.3.13}/tests/test_freeze_fix.py +0 -0
  64. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_chunker.py +0 -0
  65. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_embed.py +0 -0
  66. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_graph.py +0 -0
  67. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_indexer.py +0 -0
  68. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_no_numpy.py +0 -0
  69. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_query.py +0 -0
  70. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_service.py +0 -0
  71. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_store.py +0 -0
  72. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_vectors.py +0 -0
  73. {webbee-0.3.12 → webbee-0.3.13}/tests/test_intel_watch.py +0 -0
  74. {webbee-0.3.12 → webbee-0.3.13}/tests/test_marathon.py +0 -0
  75. {webbee-0.3.12 → webbee-0.3.13}/tests/test_packaging.py +0 -0
  76. {webbee-0.3.12 → webbee-0.3.13}/tests/test_repo.py +0 -0
  77. {webbee-0.3.12 → webbee-0.3.13}/tests/test_session.py +0 -0
  78. {webbee-0.3.12 → webbee-0.3.13}/tests/test_sessions.py +0 -0
  79. {webbee-0.3.12 → webbee-0.3.13}/tests/test_steer.py +0 -0
  80. {webbee-0.3.12 → webbee-0.3.13}/tests/test_stream.py +0 -0
  81. {webbee-0.3.12 → webbee-0.3.13}/tests/test_thread.py +0 -0
  82. {webbee-0.3.12 → webbee-0.3.13}/tests/test_tokens.py +0 -0
  83. {webbee-0.3.12 → webbee-0.3.13}/tests/test_tools.py +0 -0
  84. {webbee-0.3.12 → webbee-0.3.13}/tests/test_tui_hardening.py +0 -0
  85. {webbee-0.3.12 → webbee-0.3.13}/tests/test_update.py +0 -0
  86. {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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.12"
3
+ version = "0.3.13"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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: a centered WEBBEE CODE logo + imperal.io + an
136
- honest account panel (who/plan/tier/member-since). Runs BEFORE the dock
137
- starts. Clears the screen ONLY in the non-pane path (the full-screen
138
- dock owns its own alternate screen clearing there would corrupt it)."""
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 + (f" · @{account.nickname}" if account.nickname else "")
158
- rows.append(("Signed in as", who))
171
+ who = account.email or ""
172
+ if account.nickname:
173
+ who += f" · @{account.nickname}"
159
174
  if account.plan:
160
- plan = account.plan + (f" · {account.plan_status}" if account.plan_status else "")
161
- plan += (f" · renews {account.plan_renews}" if account.plan_renews else "")
162
- rows.append(("Plan", plan))
163
- if account.dev_tier:
164
- rows.append(("Developer", f"{account.dev_tier} tier"))
165
- if account.member_since:
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
- rows.append(("", "not signed in — /login"))
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
- tip = "Type a task and Webbee runs it to completion · pauses if credits run low · --once = single turn"
175
- self.console.print(Text(tip.center(w), style="dim"))
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("/help · Ctrl-D to exit".center(w), style="dim"))
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, queued_echo=_sink.queued_echo,
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: echoed AT ONCE as `⋯ queued:`,
8
- counted in the toolbar, run after the current turn natural completion only,
9
- a user STOP preserves the queue; /queue lists it, /queue clear drops it).
10
- Pure helpers (next_mode/build_toolbar/the *_action functions) are
11
- unit-tested; the Application is TTY/headless-smoke verified. Grounded in
12
- prompt_toolkit 3.0.52."""
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, queued_echo=None) -> str:
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) and echo it into the transcript
145
- via `queued_echo` (sink.queued_echo, EXACTLY once per queue action the
146
- queue must be visible, never a silent deque); idle → `start(text)`
147
- (today's normal submit). Returns "ignored" | "queued" | "started"."""
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. Step-navigation EXACTLY as before when steps exist + input is
187
- empty + idle; in EVERY other state (busy, or text present, or no steps)
188
- recall an older submitted line into the buffer (readline-style)."""
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, queued_echo=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 — `queued_echo` commits it to the transcript at once, the
217
- toolbar shows `⋯N queued` in accent, and it starts right after the current
218
- turn with a `queued_run` marker). `pending` is the queue deque the repl
219
- passes its OWN so /queue and /queue clear (dispatched through the normal
220
- command layer) see and manage the live queue; /queue itself always runs
221
- immediately, even mid-turn. When `steps_nav` is given and the input is empty and no turn
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 — echoed AT ONCE as `⋯ queued:` + accent toolbar depth,
304
- # runs after the current turn) or start a turn now.
305
- if _submit_line(text, buf, pending, _busy_live(), _start_turn, queued_echo) == "queued":
306
- event.app.invalidate() # toolbar shows the new depth
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 test_welcome_full_account_aligned():
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
- assert "pro" in out.lower() and "active" in out
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 and "/login" in out
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
- # ── queued_echo / queued_run — the visible type-ahead queue (0.3.12) ──────────
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 test_queued_echo_renders_the_text_as_a_transcript_line():
445
- s, c = _rec_sink()
446
- s.queued_echo("deploy the fix")
447
- assert "⋯ queued: deploy the fix" in c.export_text()
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
- # 0.3.11 queued mechanically but silently (only a dim toolbar count). Now:
638
- # queueing echoes `⋯ queued: <text>` into the transcript AT ONCE
639
- # (sink.queued_echo, exactly once per queue action), a drain announces itself
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 test_queue_echo_fires_exactly_once_with_the_text():
646
+ def test_whitespace_never_queues():
647
647
  from webbee.tui import _submit_line
648
648
  buf = _RecBuf()
649
649
  pending = deque()
650
- started, echoed = [], []
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, echoed.append) == "ignored"
670
- assert _submit_line(junk, buf, pending, False, started.append, echoed.append) == "ignored"
671
- assert not pending and started == [] and echoed == []
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, echoed, markers = [], [], []
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, queued_echo=echoed.append, queued_run=markers.append))
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: echoed == ["queued follow-up"]) # echoed AT ONCE
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, echoed = [], []
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, queued_echo=echoed.append))
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