webbee 0.3.44__tar.gz → 0.3.46__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 (129) hide show
  1. {webbee-0.3.44 → webbee-0.3.46}/CHANGELOG.md +40 -0
  2. {webbee-0.3.44 → webbee-0.3.46}/PKG-INFO +1 -1
  3. {webbee-0.3.44 → webbee-0.3.46}/pyproject.toml +1 -1
  4. webbee-0.3.46/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/render.py +51 -7
  6. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/selection.py +20 -2
  7. {webbee-0.3.44 → webbee-0.3.46}/tests/test_render.py +43 -0
  8. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tui.py +36 -0
  9. webbee-0.3.44/src/webbee/__init__.py +0 -1
  10. {webbee-0.3.44 → webbee-0.3.46}/.github/workflows/publish.yml +0 -0
  11. {webbee-0.3.44 → webbee-0.3.46}/.gitignore +0 -0
  12. {webbee-0.3.44 → webbee-0.3.46}/LICENSE +0 -0
  13. {webbee-0.3.44 → webbee-0.3.46}/README.md +0 -0
  14. {webbee-0.3.44 → webbee-0.3.46}/install.sh +0 -0
  15. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/account.py +0 -0
  16. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/active_sessions.py +0 -0
  17. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/banner_art.py +0 -0
  18. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/boot.py +0 -0
  19. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/checkpoints.py +0 -0
  20. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/cli.py +0 -0
  21. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/clipboard.py +0 -0
  22. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/clipboard_read.py +0 -0
  23. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/coding_context.py +0 -0
  24. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/commands.py +0 -0
  25. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/config.py +0 -0
  26. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/consent.py +0 -0
  27. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/details.py +0 -0
  28. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/events.py +0 -0
  29. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/frames.py +0 -0
  30. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/home.py +0 -0
  31. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/home_view.py +0 -0
  32. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/http.py +0 -0
  33. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/instance_lock.py +0 -0
  34. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/__init__.py +0 -0
  35. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/chunker.py +0 -0
  36. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/embed.py +0 -0
  37. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/graph.py +0 -0
  38. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/indexer.py +0 -0
  39. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/models.py +0 -0
  40. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/query.py +0 -0
  41. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/service.py +0 -0
  42. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/store.py +0 -0
  43. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/vectors.py +0 -0
  44. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/intel/watch.py +0 -0
  45. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/mode_store.py +0 -0
  46. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/newtab_mode.py +0 -0
  47. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/output_pane.py +0 -0
  48. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/queue_panel.py +0 -0
  49. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/reflow.py +0 -0
  50. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/remote.py +0 -0
  51. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/repl.py +0 -0
  52. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/repo.py +0 -0
  53. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/session.py +0 -0
  54. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/sessions.py +0 -0
  55. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/sizing.py +0 -0
  56. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/slots.py +0 -0
  57. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/steer.py +0 -0
  58. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/stream.py +0 -0
  59. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tab_store.py +0 -0
  60. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tabs.py +0 -0
  61. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/thread.py +0 -0
  62. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tier_store.py +0 -0
  63. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/todo_panel.py +0 -0
  64. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tokens.py +0 -0
  65. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tools.py +0 -0
  66. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/tui.py +0 -0
  67. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/update.py +0 -0
  68. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/urlopen.py +0 -0
  69. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/wallet.py +0 -0
  70. {webbee-0.3.44 → webbee-0.3.46}/src/webbee/worktrees.py +0 -0
  71. {webbee-0.3.44 → webbee-0.3.46}/tests/__init__.py +0 -0
  72. {webbee-0.3.44 → webbee-0.3.46}/tests/conftest.py +0 -0
  73. {webbee-0.3.44 → webbee-0.3.46}/tests/test_account.py +0 -0
  74. {webbee-0.3.44 → webbee-0.3.46}/tests/test_active_sessions.py +0 -0
  75. {webbee-0.3.44 → webbee-0.3.46}/tests/test_audit_2026_07_25_findings.py +0 -0
  76. {webbee-0.3.44 → webbee-0.3.46}/tests/test_checkpoints.py +0 -0
  77. {webbee-0.3.44 → webbee-0.3.46}/tests/test_cli.py +0 -0
  78. {webbee-0.3.44 → webbee-0.3.46}/tests/test_clipboard.py +0 -0
  79. {webbee-0.3.44 → webbee-0.3.46}/tests/test_clipboard_read.py +0 -0
  80. {webbee-0.3.44 → webbee-0.3.46}/tests/test_coding_context.py +0 -0
  81. {webbee-0.3.44 → webbee-0.3.46}/tests/test_commands.py +0 -0
  82. {webbee-0.3.44 → webbee-0.3.46}/tests/test_config.py +0 -0
  83. {webbee-0.3.44 → webbee-0.3.46}/tests/test_cpc_contract_stable.py +0 -0
  84. {webbee-0.3.44 → webbee-0.3.46}/tests/test_details.py +0 -0
  85. {webbee-0.3.44 → webbee-0.3.46}/tests/test_events.py +0 -0
  86. {webbee-0.3.44 → webbee-0.3.46}/tests/test_freeze_fix.py +0 -0
  87. {webbee-0.3.44 → webbee-0.3.46}/tests/test_home.py +0 -0
  88. {webbee-0.3.44 → webbee-0.3.46}/tests/test_home_view.py +0 -0
  89. {webbee-0.3.44 → webbee-0.3.46}/tests/test_instance_lock.py +0 -0
  90. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_chunker.py +0 -0
  91. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_embed.py +0 -0
  92. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_graph.py +0 -0
  93. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_indexer.py +0 -0
  94. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_no_numpy.py +0 -0
  95. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_query.py +0 -0
  96. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_service.py +0 -0
  97. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_store.py +0 -0
  98. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_vectors.py +0 -0
  99. {webbee-0.3.44 → webbee-0.3.46}/tests/test_intel_watch.py +0 -0
  100. {webbee-0.3.44 → webbee-0.3.46}/tests/test_marathon.py +0 -0
  101. {webbee-0.3.44 → webbee-0.3.46}/tests/test_mode_store.py +0 -0
  102. {webbee-0.3.44 → webbee-0.3.46}/tests/test_newtab_mode.py +0 -0
  103. {webbee-0.3.44 → webbee-0.3.46}/tests/test_packaging.py +0 -0
  104. {webbee-0.3.44 → webbee-0.3.46}/tests/test_queue_manage_0337.py +0 -0
  105. {webbee-0.3.44 → webbee-0.3.46}/tests/test_reflow.py +0 -0
  106. {webbee-0.3.44 → webbee-0.3.46}/tests/test_repl.py +0 -0
  107. {webbee-0.3.44 → webbee-0.3.46}/tests/test_repo.py +0 -0
  108. {webbee-0.3.44 → webbee-0.3.46}/tests/test_session.py +0 -0
  109. {webbee-0.3.44 → webbee-0.3.46}/tests/test_sessions.py +0 -0
  110. {webbee-0.3.44 → webbee-0.3.46}/tests/test_sizing.py +0 -0
  111. {webbee-0.3.44 → webbee-0.3.46}/tests/test_slots.py +0 -0
  112. {webbee-0.3.44 → webbee-0.3.46}/tests/test_steer.py +0 -0
  113. {webbee-0.3.44 → webbee-0.3.46}/tests/test_stream.py +0 -0
  114. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tab_restore_0337.py +0 -0
  115. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tabs.py +0 -0
  116. {webbee-0.3.44 → webbee-0.3.46}/tests/test_terminal_ux_0336.py +0 -0
  117. {webbee-0.3.44 → webbee-0.3.46}/tests/test_thread.py +0 -0
  118. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tier_store.py +0 -0
  119. {webbee-0.3.44 → webbee-0.3.46}/tests/test_todo_panel.py +0 -0
  120. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tokens.py +0 -0
  121. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tools.py +0 -0
  122. {webbee-0.3.44 → webbee-0.3.46}/tests/test_tui_hardening.py +0 -0
  123. {webbee-0.3.44 → webbee-0.3.46}/tests/test_update.py +0 -0
  124. {webbee-0.3.44 → webbee-0.3.46}/tests/test_update_badge_0340.py +0 -0
  125. {webbee-0.3.44 → webbee-0.3.46}/tests/test_urlopen.py +0 -0
  126. {webbee-0.3.44 → webbee-0.3.46}/tests/test_version.py +0 -0
  127. {webbee-0.3.44 → webbee-0.3.46}/tests/test_version_badge_corner.py +0 -0
  128. {webbee-0.3.44 → webbee-0.3.46}/tests/test_wallet.py +0 -0
  129. {webbee-0.3.44 → webbee-0.3.46}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.46
4
+
5
+ Fixed a real jitter bug in text selection: dragging to select past the
6
+ bottom (or top) of the visible output no longer makes the pane "jump
7
+ around" instead of scrolling smoothly.
8
+
9
+ - **Root cause:** a real terminal fires MOUSE_MOVE far more often than
10
+ once while the pointer sits at the viewport edge (every pixel of hand
11
+ tremor counts as a new event) -- and EVERY one of those was scrolling
12
+ the pane immediately, at the same time the dock's own ticker
13
+ (`edge_tick`) was ALSO scrolling the same pane every 0.25s to keep the
14
+ drag going while the mouse holds still. Two independent scroll sources
15
+ racing each other on the same viewport is exactly what looked like
16
+ "the window runs back and forth" while trying to select a big chunk of
17
+ text (Valentin, live 2026-07-31).
18
+ - **Fix:** the immediate scroll now fires ONLY on the actual edge
19
+ transition (the moment the drag first reaches the edge); every
20
+ following MOUSE_MOVE at the same edge just keeps the selection armed
21
+ and leaves ALL the ongoing scrolling to the ticker's one steady 0.25s
22
+ cadence -- the exact same one-source-of-truth contract the queue/todo
23
+ panels and tab bar already use for their own edge-forwarded drags, now
24
+ applied consistently everywhere in the terminal so no two panes/panels
25
+ ever fight over the same scroll.
26
+
27
+ ## 0.3.45
28
+
29
+ Consent prompts stop being an easy-to-miss detail:
30
+
31
+ - **Both the kernel consent (`? approve ...`) and the local remote-control
32
+ yes/no ask now print as a bordered card, right in the conversation, the
33
+ moment they're asked** -- the same proven Panel this terminal already
34
+ uses for the billing and login prompts, so it reflows correctly to
35
+ whatever width the terminal actually has (a phone's SSH client, a narrow
36
+ tmux pane, a full window). The question and the "how to answer"
37
+ instructions (`y/yes -> do it`, `n/no -> skip it`, or type your own
38
+ reply) now live in the SAME box -- nothing to go hunting for in a
39
+ toolbar hint several rows away. The underlying protocol is unchanged:
40
+ still a bare raw string relayed verbatim for the kernel to interpret
41
+ (ICNLI, safe-by-default) -- this is a display-only change.
42
+
3
43
  ## 0.3.44
4
44
 
5
45
  Three real Home-dashboard bugs, found and fixed:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.44
3
+ Version: 0.3.46
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.44"
3
+ version = "0.3.46"
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.46"
@@ -614,12 +614,38 @@ class RichSink:
614
614
  NEVER interpret (the kernel decides, ICNLI). When the dock is running
615
615
  the reply comes through the pinned box via an asyncio.Future (no
616
616
  blocking input on the event loop); otherwise fall back to the injected
617
- sync reader (tests / non-tty)."""
617
+ sync reader (tests / non-tty).
618
+
619
+ webbee-code-consent-card-v1: this used to be one dim, easy-to-miss
620
+ line in the feed plus a SEPARATE hint buried in the toolbar below --
621
+ several users said it wasn't obvious what was even being asked, let
622
+ alone how to answer it. Now it's a bordered card printed RIGHT IN
623
+ THE CONVERSATION, at the moment it's asked -- same proven Panel this
624
+ module already uses for the billing/login prompts, so it reflows to
625
+ whatever width the terminal actually has (phone SSH client, a narrow
626
+ tmux pane, a full-width window -- all get a readable card, not a
627
+ clipped line). The question AND the "how to answer" instructions
628
+ live in the SAME box, so there's nothing to hunt for elsewhere on
629
+ screen. The protocol contract is unchanged: still a bare raw string
630
+ relayed verbatim for the kernel to interpret (ICNLI, safe-by-default)."""
618
631
  label = _clean(f"{app_id}·{tool}" if app_id else tool)
619
- sal = _clean(_salient_arg(args))
620
- w = self.console.width
621
- self.console.print(Text.assemble((" ? approve ", "yellow"), (label, "dim"),
622
- ((" " + sal[:trunc(w, 0.4, 60)]) if sal else "", "dim")))
632
+ sal = _clean(_salient_arg(args))[:500]
633
+ body = Text()
634
+ body.append(label, style=f"bold {_ACCENT}")
635
+ if sal:
636
+ body.append("\n" + sal, style="white")
637
+ body.append("\n\n")
638
+ body.append("y", style="bold green")
639
+ body.append(" / ", style="dim")
640
+ body.append("yes", style="bold green")
641
+ body.append(" → do it ", style="dim")
642
+ body.append("n", style="bold red")
643
+ body.append(" / ", style="dim")
644
+ body.append("no", style="bold red")
645
+ body.append(" → skip it\n", style="dim")
646
+ body.append("or just type your own reply in any language — Enter to send", style="dim")
647
+ self.console.print(_pad(Panel(body, title="🔐 wants to do this — approve?",
648
+ border_style=_BEE)))
623
649
  fut = self._arm_consent(label, sal)
624
650
  if fut is None: # non-tty / no running app
625
651
  raw = self._input(" ")
@@ -643,8 +669,26 @@ class RichSink:
643
669
  all return False, so the caller keeps the current mode. This is a
644
670
  LOCAL policy decision, not a kernel consent, so the reply is
645
671
  interpreted here rather than relayed (ICNLI raw-relay applies to
646
- kernel consents only)."""
647
- self.console.print(_pad(Text("⚠ " + _clean(question), style=f"bold {_BEE}")))
672
+ kernel consents only).
673
+
674
+ webbee-code-consent-card-v1: same bordered-card treatment as
675
+ ask_consent -- a bare "⚠ ..." line was just as easy to miss as the
676
+ old one-line kernel consent was. The card reflows to the terminal's
677
+ real width on any device, and states the y/n instructions right
678
+ where the question is instead of leaving them implicit."""
679
+ body = Text()
680
+ body.append(_clean(question), style=f"bold {_BEE}")
681
+ body.append("\n\n")
682
+ body.append("y", style="bold green")
683
+ body.append(" / ", style="dim")
684
+ body.append("yes", style="bold green")
685
+ body.append(" → allow ", style="dim")
686
+ body.append("n", style="bold red")
687
+ body.append(" / ", style="dim")
688
+ body.append("no", style="bold red")
689
+ body.append(" → stay as-is (default)", style="dim")
690
+ self.console.print(_pad(Panel(body, title="⚠ another surface is asking",
691
+ border_style=_BEE)))
648
692
  fut = self._arm_consent(question, question)
649
693
  try:
650
694
  if fut is None: # non-tty / no dock → sync reader
@@ -87,11 +87,29 @@ def make_select_control(pane, FormattedTextControl, MouseEventType, MouseButton)
87
87
  return NotImplemented
88
88
  pane._edge_ticks = 0 # a fresh MOUSE_MOVE means the pointer isn't parked
89
89
  y = ev.position.y
90
+ # webbee-code-selection-scroll-jitter-v1: a REAL terminal fires
91
+ # MOUSE_MOVE far more often than once (every pixel of hand
92
+ # tremor while parked at the edge trying to select further) --
93
+ # scrolling immediately on EVERY one of those, on top of
94
+ # edge_tick() ALREADY scrolling the same pane every 0.25s once
95
+ # armed, stacked two independent scroll sources racing each
96
+ # other and made the viewport visibly jump/jitter instead of
97
+ # scrolling smoothly (Valentin, live 2026-07-31: "окно бегает
98
+ # туда сюда"). The immediate nudge now fires ONLY on the edge
99
+ # transition (armed 0 -> 1/-1) for snappy first-touch
100
+ # feedback; every following MOUSE_MOVE that's STILL at the
101
+ # same edge just re-affirms the arm (resets _edge_ticks above,
102
+ # so the runaway guard doesn't fire) and leaves the ONGOING
103
+ # scroll entirely to edge_tick()'s single steady cadence --
104
+ # exactly the contract forward_mouse() already uses for the
105
+ # neighbor panels.
90
106
  if y >= pane._view_h - 1:
91
- pane.scroll(3)
107
+ if pane._edge_drag != 1:
108
+ pane.scroll(3)
92
109
  pane._edge_drag = 1
93
110
  elif y <= 0:
94
- pane.scroll(-3)
111
+ if pane._edge_drag != -1:
112
+ pane.scroll(-3)
95
113
  pane._edge_drag = -1
96
114
  else:
97
115
  pane._edge_drag = 0
@@ -299,6 +299,49 @@ def test_consent_shows_human_summary_not_dict():
299
299
  assert "notes·delete_note" in out
300
300
 
301
301
 
302
+ # ── webbee-code-consent-card-v1: the consent card is a real, hard-to-miss
303
+ # bordered block printed IN the conversation, with the y/n instructions
304
+ # living right next to the question -- not a dim one-liner + a separate
305
+ # toolbar hint the user has to go hunting for. ────────────────────────────
306
+
307
+ def test_consent_is_a_bordered_card_with_inline_instructions():
308
+ console = Console(record=True, width=90)
309
+ s = RichSink(console=console, live_enabled=False,
310
+ input_fn=lambda p: "yes", clock=lambda: 0.0)
311
+ asyncio.run(s.ask_consent("notes", "delete_note", {"title": "Q3 budget"}))
312
+ out = console.export_text()
313
+ # a real Panel border, not a bare printed line
314
+ assert "\u256d" in out or "\u2500" in out # rounded-corner or horizontal rule glyph
315
+ assert "approve?" in out
316
+ # the how-to-answer instructions are IN the card, not somewhere else
317
+ assert "yes" in out and "no" in out
318
+ assert "do it" in out and "skip it" in out
319
+ assert "notes·delete_note" in out
320
+
321
+
322
+ def test_consent_card_reflows_to_narrow_terminal_width():
323
+ # A phone SSH client / narrow tmux pane: the card must not crash or
324
+ # silently drop the question -- Panel reflows, same as billing/login.
325
+ console = Console(record=True, width=28)
326
+ s = RichSink(console=console, live_enabled=False,
327
+ input_fn=lambda p: "y", clock=lambda: 0.0)
328
+ asyncio.run(s.ask_consent("mail", "delete", {"query": "old newsletters"}))
329
+ out = console.export_text()
330
+ assert "mail" in out and "delete" in out
331
+ assert "yes" in out and "no" in out
332
+
333
+
334
+ def test_ask_yes_no_is_also_a_bordered_card_with_instructions():
335
+ console = Console(record=True, width=90)
336
+ s = RichSink(console=console, live_enabled=False,
337
+ input_fn=lambda p: "y", clock=lambda: 0.0)
338
+ asyncio.run(s.ask_yes_no("switch to autopilot — allow? [y/n]"))
339
+ out = console.export_text()
340
+ assert "\u256d" in out or "\u2500" in out
341
+ assert "allow" in out.lower()
342
+ assert "yes" in out and "no" in out
343
+
344
+
302
345
  def test_user_echo_is_unboxed_line():
303
346
  s = _sink()
304
347
  s.user_echo("delete my Q3 note")
@@ -258,6 +258,42 @@ def test_drag_at_bottom_edge_scrolls_and_grows_selection():
258
258
  assert pane._edge_drag == 0
259
259
 
260
260
 
261
+ def test_repeated_mouse_move_at_same_edge_does_not_double_scroll():
262
+ # webbee-code-selection-scroll-jitter-v1: a real terminal fires MOUSE_MOVE
263
+ # repeatedly while the pointer sits at the SAME edge (hand tremor) --
264
+ # only the FIRST one (the 0 -> armed transition) may scroll immediately;
265
+ # every following MOUSE_MOVE at the same edge must leave the offset
266
+ # alone (still armed, still selecting) and let edge_tick() do the only
267
+ # ongoing scrolling, one steady source instead of two racing.
268
+ from prompt_toolkit.data_structures import Point
269
+ from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
270
+
271
+ from webbee.tui import OutputPane
272
+
273
+ pane = OutputPane(width=80)
274
+ pane._view_h = 10
275
+ pane._io.write("\n".join(f"line{i}" for i in range(100)))
276
+ pane._offset = 0
277
+
278
+ down = MouseEvent(position=Point(0, 5), event_type=MouseEventType.MOUSE_DOWN,
279
+ button=MouseButton.LEFT, modifiers=frozenset())
280
+ pane.control.mouse_handler(down)
281
+
282
+ move = MouseEvent(position=Point(3, 9), event_type=MouseEventType.MOUSE_MOVE,
283
+ button=MouseButton.LEFT, modifiers=frozenset())
284
+ pane.control.mouse_handler(move)
285
+ assert pane._offset == 3 and pane._edge_drag == 1 # first touch: one immediate nudge
286
+
287
+ # the pointer trembles but stays at the SAME bottom row -- must NOT scroll again.
288
+ pane.control.mouse_handler(move)
289
+ pane.control.mouse_handler(move)
290
+ assert pane._offset == 3 and pane._edge_drag == 1 # unchanged -- no double scroll
291
+
292
+ # only edge_tick() advances it further, at its own steady cadence.
293
+ pane.edge_tick()
294
+ assert pane._offset == 6
295
+
296
+
261
297
  def test_edge_drag_resets_on_mouse_up_and_top_edge_mirrors():
262
298
  from prompt_toolkit.data_structures import Point
263
299
  from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
@@ -1 +0,0 @@
1
- __version__ = "0.3.44"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes