webbee 0.3.23__tar.gz → 0.3.24__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 (107) hide show
  1. {webbee-0.3.23 → webbee-0.3.24}/CHANGELOG.md +15 -0
  2. {webbee-0.3.23 → webbee-0.3.24}/PKG-INFO +1 -1
  3. {webbee-0.3.23 → webbee-0.3.24}/pyproject.toml +1 -1
  4. webbee-0.3.24/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/repl.py +9 -1
  6. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/slots.py +4 -0
  7. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tabs.py +38 -23
  8. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tui.py +55 -25
  9. {webbee-0.3.23 → webbee-0.3.24}/tests/test_home.py +46 -0
  10. {webbee-0.3.23 → webbee-0.3.24}/tests/test_tabs.py +84 -28
  11. {webbee-0.3.23 → webbee-0.3.24}/tests/test_tui.py +146 -22
  12. webbee-0.3.23/src/webbee/__init__.py +0 -1
  13. {webbee-0.3.23 → webbee-0.3.24}/.github/workflows/publish.yml +0 -0
  14. {webbee-0.3.23 → webbee-0.3.24}/.gitignore +0 -0
  15. {webbee-0.3.23 → webbee-0.3.24}/LICENSE +0 -0
  16. {webbee-0.3.23 → webbee-0.3.24}/README.md +0 -0
  17. {webbee-0.3.23 → webbee-0.3.24}/install.sh +0 -0
  18. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/account.py +0 -0
  19. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/active_sessions.py +0 -0
  20. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/banner_art.py +0 -0
  21. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/boot.py +0 -0
  22. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/cli.py +0 -0
  24. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/coding_context.py +0 -0
  26. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/commands.py +0 -0
  27. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/config.py +0 -0
  28. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/consent.py +0 -0
  29. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/details.py +0 -0
  30. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/events.py +0 -0
  31. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/frames.py +0 -0
  32. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/home.py +0 -0
  33. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/http.py +0 -0
  34. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/__init__.py +0 -0
  35. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/chunker.py +0 -0
  36. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/embed.py +0 -0
  37. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/graph.py +0 -0
  38. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/indexer.py +0 -0
  39. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/models.py +0 -0
  40. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/query.py +0 -0
  41. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/service.py +0 -0
  42. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/store.py +0 -0
  43. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/vectors.py +0 -0
  44. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/intel/watch.py +0 -0
  45. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/mode_store.py +0 -0
  46. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/output_pane.py +0 -0
  47. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/queue_panel.py +0 -0
  48. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/reflow.py +0 -0
  49. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/remote.py +0 -0
  50. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/render.py +0 -0
  51. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/repo.py +0 -0
  52. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/selection.py +0 -0
  53. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/session.py +0 -0
  54. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/sessions.py +0 -0
  55. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/sizing.py +0 -0
  56. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/steer.py +0 -0
  57. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/stream.py +0 -0
  58. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/thread.py +0 -0
  59. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/todo_panel.py +0 -0
  60. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tokens.py +0 -0
  61. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/tools.py +0 -0
  62. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/update.py +0 -0
  63. {webbee-0.3.23 → webbee-0.3.24}/src/webbee/worktrees.py +0 -0
  64. {webbee-0.3.23 → webbee-0.3.24}/tests/__init__.py +0 -0
  65. {webbee-0.3.23 → webbee-0.3.24}/tests/conftest.py +0 -0
  66. {webbee-0.3.23 → webbee-0.3.24}/tests/test_account.py +0 -0
  67. {webbee-0.3.23 → webbee-0.3.24}/tests/test_active_sessions.py +0 -0
  68. {webbee-0.3.23 → webbee-0.3.24}/tests/test_checkpoints.py +0 -0
  69. {webbee-0.3.23 → webbee-0.3.24}/tests/test_cli.py +0 -0
  70. {webbee-0.3.23 → webbee-0.3.24}/tests/test_clipboard.py +0 -0
  71. {webbee-0.3.23 → webbee-0.3.24}/tests/test_commands.py +0 -0
  72. {webbee-0.3.23 → webbee-0.3.24}/tests/test_config.py +0 -0
  73. {webbee-0.3.23 → webbee-0.3.24}/tests/test_cpc_contract_stable.py +0 -0
  74. {webbee-0.3.23 → webbee-0.3.24}/tests/test_details.py +0 -0
  75. {webbee-0.3.23 → webbee-0.3.24}/tests/test_events.py +0 -0
  76. {webbee-0.3.23 → webbee-0.3.24}/tests/test_freeze_fix.py +0 -0
  77. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_chunker.py +0 -0
  78. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_embed.py +0 -0
  79. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_graph.py +0 -0
  80. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_indexer.py +0 -0
  81. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_no_numpy.py +0 -0
  82. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_query.py +0 -0
  83. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_service.py +0 -0
  84. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_store.py +0 -0
  85. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_vectors.py +0 -0
  86. {webbee-0.3.23 → webbee-0.3.24}/tests/test_intel_watch.py +0 -0
  87. {webbee-0.3.23 → webbee-0.3.24}/tests/test_marathon.py +0 -0
  88. {webbee-0.3.23 → webbee-0.3.24}/tests/test_mode_store.py +0 -0
  89. {webbee-0.3.23 → webbee-0.3.24}/tests/test_packaging.py +0 -0
  90. {webbee-0.3.23 → webbee-0.3.24}/tests/test_reflow.py +0 -0
  91. {webbee-0.3.23 → webbee-0.3.24}/tests/test_render.py +0 -0
  92. {webbee-0.3.23 → webbee-0.3.24}/tests/test_repl.py +0 -0
  93. {webbee-0.3.23 → webbee-0.3.24}/tests/test_repo.py +0 -0
  94. {webbee-0.3.23 → webbee-0.3.24}/tests/test_session.py +0 -0
  95. {webbee-0.3.23 → webbee-0.3.24}/tests/test_sessions.py +0 -0
  96. {webbee-0.3.23 → webbee-0.3.24}/tests/test_sizing.py +0 -0
  97. {webbee-0.3.23 → webbee-0.3.24}/tests/test_slots.py +0 -0
  98. {webbee-0.3.23 → webbee-0.3.24}/tests/test_steer.py +0 -0
  99. {webbee-0.3.23 → webbee-0.3.24}/tests/test_stream.py +0 -0
  100. {webbee-0.3.23 → webbee-0.3.24}/tests/test_thread.py +0 -0
  101. {webbee-0.3.23 → webbee-0.3.24}/tests/test_todo_panel.py +0 -0
  102. {webbee-0.3.23 → webbee-0.3.24}/tests/test_tokens.py +0 -0
  103. {webbee-0.3.23 → webbee-0.3.24}/tests/test_tools.py +0 -0
  104. {webbee-0.3.23 → webbee-0.3.24}/tests/test_tui_hardening.py +0 -0
  105. {webbee-0.3.23 → webbee-0.3.24}/tests/test_update.py +0 -0
  106. {webbee-0.3.23 → webbee-0.3.24}/tests/test_version.py +0 -0
  107. {webbee-0.3.23 → webbee-0.3.24}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.24
4
+
5
+ The tab bar got a real design: the active tab is a solid highlighted chip,
6
+ tabs are clearly separated, and spacing is uniform — plus a message typed
7
+ on Home no longer shows up twice in the new tab.
8
+
9
+ - Tabs now render as padded chips with uniform spacing, a clear dim
10
+ separator between every pair, and the active tab as a solid
11
+ bee-yellow block — unmistakable at a glance, no more squinting to find
12
+ which tab you're on.
13
+ - Typing a task on Home and landing in the new session tab no longer
14
+ double-prints your first message in that tab's transcript.
15
+ - Each tab now keeps its own input draft — switch away and back, your
16
+ unsent text is right where you left it.
17
+
3
18
  ## 0.3.23
4
19
 
5
20
  If a task on your session is waiting for your terminal — sent from the panel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.23
3
+ Version: 0.3.24
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.23"
3
+ version = "0.3.24"
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.24"
@@ -573,11 +573,19 @@ async def _home_input(text: str, *, slots: SlotManager, cfg, token_provider, mod
573
573
  ui_hooks.get("switch", slots.switch)(idx)
574
574
  if spawn_poller is not None:
575
575
  spawn_poller(new_slot)
576
- new_slot.sink.user_echo(text)
577
576
  start_turn_in = ui_hooks.get("start_turn_in")
578
577
  if start_turn_in is not None:
578
+ # 0.3.24 fix: the dock's start-turn seam threads through on_line ->
579
+ # `_handle`, which ALREADY echoes the typed line itself for every
580
+ # non-command line (the SAME canonical echo every other typed line
581
+ # in the dock gets) -- echoing here too doubled it up in the new
582
+ # tab's transcript (Valentin, live on 0.3.22/0.3.23).
579
583
  start_turn_in(new_slot, text)
580
584
  else:
585
+ # No dock: `run_turn` (repl's `_run_turn`) never echoes on its own
586
+ # (it only runs the agent turn), so this IS the one echo the typed
587
+ # line gets.
588
+ new_slot.sink.user_echo(text)
581
589
  await run_turn(new_slot, text)
582
590
 
583
591
 
@@ -27,6 +27,10 @@ class SessionSlot:
27
27
  pending: deque = field(default_factory=deque)
28
28
  turn: dict = field(default_factory=lambda: {"task": None})
29
29
  pulled: dict = field(default_factory=lambda: {"text": "", "iid": ""})
30
+ draft: str = "" # 0.3.24: per-tab unsent input -- stashed on switch-away,
31
+ # restored on switch-back (browser-tab model: each tab
32
+ # keeps its own form state); cleared on genuine submit.
33
+ draft_cursor: int = 0 # cursor position within `draft`, restored alongside it
30
34
  qp_ui: dict = field(default_factory=lambda: {"collapsed": False})
31
35
  tp_ui: dict = field(default_factory=lambda: {"collapsed": False})
32
36
  mode: str = "default"
@@ -1,17 +1,29 @@
1
- """The tab bar — THE visible piece of the browser-in-terminal (W4a Task 4).
2
- `tab_fragments` is a PURE builder, queue_panel discipline (unit-tested
3
- without an Application, no prompt_toolkit import at module top — only
4
- inside the mouse handlers): it renders ONE row, Home always first (glyph
5
- fixed ◆, label "Home", NEVER a close ✕), then every session slot numbered
6
- by its own SlotManager list index (the same index `slots.switch(idx)`/
7
- `slots.close(idx)` take), glyph from `slot.status_glyph()` (⚠/▶/○). The
8
- ACTIVE tab (Home or session) is accented (`class:tab.active`); a
9
- NON-active session tab whose glyph is gets `class:tab.alert` the
10
- consent badge Valentin sees from whichever tab he's looking at, not just
11
- the one waiting. Each tab's body is a 3-tuple fragment (MOUSE_UP ->
12
- on_switch(idx), NotImplemented otherwisewheel keeps working, same
13
- event discipline as queue_panel._item_handler); each SESSION tab's
14
- trailing ` ✕` is its OWN fragment (MOUSE_UP -> on_close(idx)). Unlike the
1
+ """The tab bar — THE visible piece of the browser-in-terminal (W4a Task 4;
2
+ chip redesign 0.3.24 Valentin: tabs were hard to notice and hard to
3
+ control, needed clear separators and uniform spacing). `tab_fragments`
4
+ is a PURE builder, queue_panel discipline (unit-tested without an
5
+ Application, no prompt_toolkit import at module top only inside the
6
+ mouse handlers): it renders ONE row of padded CHIPS, each `" {glyph}
7
+ {label} "` ONE leading + ONE trailing space baked INSIDE the styled
8
+ fragment itself, so every chip carries identical breathing room regardless
9
+ of style. Home is always first (glyph fixed ◆, label "Home", NEVER a close
10
+ ✕); every session slot is numbered by its own SlotManager list index (the
11
+ same index `slots.switch(idx)`/`slots.close(idx)` take) and shaped
12
+ `" {marker} {idx}·{label} {glyph} "``marker` (●/○) is THIS tab's own
13
+ active/inactive dot, `glyph` is `slot.status_glyph()` (⚠/▶/○). The ACTIVE
14
+ tab's chip is a SOLID bee-yellow block (`class:tab.active` background,
15
+ not just text, so it's unmistakable at a glance); a NON-active session
16
+ tab whose glyph is ⚠ gets `class:tab.alert` (yellow text, no bg — only the
17
+ active chip owns a background, so the alert never competes with it). A
18
+ dim `" │ "` (`class:tab.sep`) sits between every pair of tabs — exactly
19
+ one, never at the ends — so the eye always finds the boundary. Each tab's
20
+ body is a 3-tuple fragment (MOUSE_UP -> on_switch(idx), NotImplemented
21
+ otherwise — wheel keeps working, same event discipline as
22
+ queue_panel._item_handler); each SESSION tab's trailing `" ✕ "` is its OWN
23
+ padded fragment (MOUSE_UP -> on_close(idx)), styled `class:tab.close.
24
+ active` instead of the plain dim `class:tab.close` when its tab is the
25
+ active one — same bg as its chip, so the close glyph reads as part of the
26
+ SAME contiguous block rather than a separate dim afterthought. Unlike the
15
27
  queue/todo panels this bar is NEVER hidden — even a single slot (Home
16
28
  alone) renders it; it IS the new look."""
17
29
 
@@ -19,6 +31,8 @@ TAB_STYLE_ACTIVE = "class:tab.active"
19
31
  TAB_STYLE_IDLE = "class:tab"
20
32
  TAB_STYLE_ALERT = "class:tab.alert"
21
33
  TAB_STYLE_CLOSE = "class:tab.close"
34
+ TAB_STYLE_CLOSE_ACTIVE = "class:tab.close.active"
35
+ TAB_STYLE_SEP = "class:tab.sep"
22
36
 
23
37
  _SEP = " │ "
24
38
  _MIN_LABEL = 8
@@ -76,11 +90,11 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
76
90
  `SlotManager` (or anything shaped like one — `.slots` + `.active_idx`);
77
91
  slot 0 is always treated as Home. `width` (0 = unknown/no truncation —
78
92
  headless or pre-first-render) is divided evenly across every SESSION
79
- label after subtracting Home's own fixed text, the ` │ ` separators and
80
- each session tab's own marker/index/glyph/close overhead, then each
81
- label is `_fit` to its share. Returns [] only when `slots` has no slots
82
- at all (never happens in practice — Home always occupies index 0).
83
- `forward` (FIX6, optional — `None` preserves every existing caller's
93
+ label after subtracting Home's own fixed chip text, the ` │ ` separators
94
+ and each session tab's own marker/index/glyph/padding/close overhead,
95
+ then each label is `_fit` to its share. Returns [] only when `slots` has
96
+ no slots at all (never happens in practice — Home always occupies index
97
+ 0). `forward` (FIX6, optional — `None` preserves every existing caller's
84
98
  behavior verbatim) is threaded into EVERY mouse handler this function
85
99
  hands out (Home's own switch handler included) so a drag armed in the
86
100
  output pane just below gets first refusal on every tab-bar click."""
@@ -91,14 +105,14 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
91
105
  home = slot_list[0]
92
106
  sessions = list(enumerate(slot_list[1:], start=1))
93
107
 
94
- home_text = f"◆ {home.label or 'Home'}"
108
+ home_text = f" ◆ {home.label or 'Home'} "
95
109
  seps = len(sessions) # one separator before each session tab
96
110
 
97
111
  pieces = [] # (idx, slot, prefix, suffix, close_text)
98
112
  for idx, slot in sessions:
99
113
  marker = "●" if idx == active_idx else "○"
100
114
  glyph = slot.status_glyph()
101
- pieces.append((idx, slot, f"{marker}{idx} ", f" {glyph}", " ✕"))
115
+ pieces.append((idx, slot, f" {marker} {idx}·", f" {glyph} ", " ✕ "))
102
116
 
103
117
  budget = 0
104
118
  if width > 0 and pieces:
@@ -111,7 +125,7 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
111
125
  frags.append((home_style, home_text, _switch_handler(on_switch, 0, forward)))
112
126
 
113
127
  for idx, slot, prefix, suffix, close_text in pieces:
114
- frags.append((TAB_STYLE_IDLE, _SEP))
128
+ frags.append((TAB_STYLE_SEP, _SEP))
115
129
  is_active = idx == active_idx
116
130
  glyph = suffix.strip()
117
131
  label = slot.label or ""
@@ -120,6 +134,7 @@ def tab_fragments(slots, *, on_switch, on_close, width: int = 0, forward=None):
120
134
  style = TAB_STYLE_ACTIVE if is_active else (
121
135
  TAB_STYLE_ALERT if glyph == "⚠" else TAB_STYLE_IDLE)
122
136
  frags.append((style, f"{prefix}{label}{suffix}", _switch_handler(on_switch, idx, forward)))
123
- frags.append((TAB_STYLE_CLOSE, close_text, _close_handler(on_close, idx, forward)))
137
+ close_style = TAB_STYLE_CLOSE_ACTIVE if is_active else TAB_STYLE_CLOSE
138
+ frags.append((close_style, close_text, _close_handler(on_close, idx, forward)))
124
139
 
125
140
  return frags
@@ -432,6 +432,19 @@ def _swap_history(buf, slot) -> None:
432
432
  buf.history = slot.history
433
433
 
434
434
 
435
+ def _restore_draft(buf, slot) -> None:
436
+ """0.3.24 (per-tab drafts — the browser-tab model: each tab keeps its own
437
+ form state): `buf.reset()` must run FIRST (it clears the history-load
438
+ task state -- unchanged from before this fix), THEN the buffer's live
439
+ text/cursor are set from `slot`'s OWN stashed draft -- restoring exactly
440
+ what was mid-type in THIS tab the last time it was left, never another
441
+ tab's. `min(...)` guards a shrunk/replaced draft never leaving the
442
+ cursor past the end of the text it's now landing on."""
443
+ buf.reset()
444
+ buf.text = slot.draft
445
+ buf.cursor_position = min(slot.draft_cursor, len(buf.text))
446
+
447
+
435
448
  async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
436
449
  stop_turn=None, queued_run=None, inject=None,
437
450
  home_input=None, cancel_slot=None, ui_hooks=None,
@@ -656,6 +669,12 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
656
669
  slot = _a()
657
670
  text = _rewrap_pulled(slot.pulled, scrub_mouse_residue(buf.text))
658
671
  buf.reset()
672
+ # 0.3.24: a genuine Enter retires THIS slot's own stashed draft too
673
+ # -- otherwise the NEXT switch-away-then-back (nothing typed in
674
+ # between) would restore text that was already sent, resurrecting a
675
+ # message the user believes is long gone.
676
+ slot.draft = ""
677
+ slot.draft_cursor = 0
659
678
  cp = _sink_attr("consent_pending")
660
679
  if cp and cp():
661
680
  rc = _sink_attr("resolve_consent")
@@ -933,22 +952,28 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
933
952
  # it does, neither the history swap nor the redraw happen, so a
934
953
  # click on the active tab is a true no-op, never a crash. `prev`
935
954
  # captured BEFORE the switch (FIX7b) -- it's the slot we're LEAVING.
955
+ # 0.3.24: stashed UNCONDITIONALLY, before the switch even resolves --
956
+ # harmless on a no-op switch (prev IS the still-active slot, so this
957
+ # is just re-saving its own current text over itself).
936
958
  prev = slots.active()
959
+ prev.draft = buf.text
960
+ prev.draft_cursor = buf.cursor_position
937
961
  if slots.switch(idx):
938
- _swap_history(buf, slots.active())
939
- # FIX7b: a draft mid-type must NOT survive a switch (it belongs
940
- # to the tab you were looking at, not the one you're switching
941
- # into -- history load already re-points the buffer's recall
942
- # list; the buffer's own live TEXT needs the same reset), and
943
- # the LEAVING slot's own pulled-queue-item carry (↑ pull-to-edit,
944
- # see _rewrap_pulled) must be dropped -- otherwise a stale
945
- # steer_iid can ride along into a totally different slot's
946
- # later resubmit (the kernel's dedup ring would then wrongly
947
- # dedup a genuinely new message against a landed twin that was
948
- # never even the SAME conversation).
949
- buf.reset()
950
- prev.pulled["text"] = ""
951
- prev.pulled["iid"] = ""
962
+ entering = slots.active()
963
+ _swap_history(buf, entering)
964
+ # 0.3.24 (per-tab drafts, product decision -- was FIX7b's "drafts
965
+ # dropped on switch"): a draft mid-type belongs to the tab you
966
+ # typed it into, browser-tab style -- switching away no longer
967
+ # destroys it, it comes right back when you switch back to THIS
968
+ # tab (`_restore_draft`, which still runs the history-load
969
+ # `buf.reset()` first). The leaving slot's own pulled-queue-item
970
+ # carry ( pull-to-edit, see _rewrap_pulled) is no longer cleared
971
+ # here either -- it now travels WITH the draft on its own slot,
972
+ # so resubmitting it unedited after a round trip still dedups
973
+ # correctly against a landed twin; `_rewrap_pulled`'s one-shot
974
+ # consume (on the Enter that actually resubmits, in whichever
975
+ # slot is active then) is what retires it, not a switch.
976
+ _restore_draft(buf, entering)
952
977
  if on_switch is not None:
953
978
  on_switch(idx)
954
979
  get_app().invalidate()
@@ -961,12 +986,14 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
961
986
  # FIX7d: the SURVIVOR (post-close active) slot's own history takes
962
987
  # over the shared input buffer, exactly like any other switch — a
963
988
  # closed tab's history dies with it, so the buffer must never keep
964
- # pointing at it; the buffer's own draft is dropped too (same
965
- # discipline as `_switch_to`'s FIX7b a close ALSO changes which
966
- # tab you're looking at).
989
+ # pointing at it. 0.3.24: the buffer now loads the SURVIVOR's own
990
+ # draft (same `_restore_draft` a plain switch uses) instead of a
991
+ # bare reset -- the closed tab's own draft is simply gone with it,
992
+ # nothing to stash (it's not coming back).
967
993
  if close_active(slots, cancel_slot):
968
- _swap_history(buf, slots.active())
969
- buf.reset()
994
+ survivor = slots.active()
995
+ _swap_history(buf, survivor)
996
+ _restore_draft(buf, survivor)
970
997
  get_app().invalidate()
971
998
  return True
972
999
  return False
@@ -981,8 +1008,9 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
981
1008
  # per-tab idx of their own, so they keep meaning "close what I'm
982
1009
  # looking at" via `_close_flow`/`close_active` below.
983
1010
  if close_at(slots, idx, cancel_slot):
984
- _swap_history(buf, slots.active()) # FIX7d, same as _close_flow above
985
- buf.reset()
1011
+ survivor = slots.active()
1012
+ _swap_history(buf, survivor) # FIX7d, same as _close_flow above
1013
+ _restore_draft(buf, survivor) # 0.3.24, same as _close_flow above
986
1014
  get_app().invalidate()
987
1015
  return True
988
1016
  return False
@@ -1036,10 +1064,12 @@ async def run_session(*, slots, on_line, on_cycle, steps_nav=None,
1036
1064
  style = Style.from_dict({
1037
1065
  "frame.border": "#5f5f5f", # muted grey chrome — furniture, not focus
1038
1066
  "prompt": "#00afd7 bold", # cyan ❯ — the interactive accent
1039
- "tab": "#8a8a8a", # idle tab — dim, same family as tb.dim
1040
- "tab.active": "#e8a317 bold", # the VISIBLE tab — bee-yellow, same accent as tb.spin
1041
- "tab.alert": "#e8a317 bold reverse", # ⚠ consent waiting in a BACKGROUND tab — reversed so it pops next to tab.active
1042
- "tab.close": "#8a8a8a", # the ✕ — dim, closing is never the default action
1067
+ "tab": "#8a8a8a", # idle chip — dim text, no bg
1068
+ "tab.active": "bg:#e8a317 #1c1c1c bold", # the ACTIVE chipsolid bee-yellow bg, dark text: unmistakable
1069
+ "tab.alert": "#e8a317 bold", # ⚠ consent waiting in a BACKGROUND tab — yellow text, no bg (only the active chip owns one)
1070
+ "tab.close": "#8a8a8a", # the ✕ on a background tab — dim, closing is never the default action
1071
+ "tab.close.active": "bg:#e8a317 #1c1c1c", # the ✕ on the ACTIVE tab — same bg as its chip, reads as one contiguous block
1072
+ "tab.sep": "#3a3a3a", # the │ between tabs — dim, consistent, exactly one per pair, none at the ends
1043
1073
  "tb.dim": "#8a8a8a", # idle chrome / secondary bits — dim
1044
1074
  "tb.spin": "#e8a317 bold", # animated spinner — bee-yellow, pops
1045
1075
  "tb.working": "#e8a317", # 'working' — yellow
@@ -357,6 +357,52 @@ def test_home_input_new_slot_sink_records_echo(monkeypatch):
357
357
  assert slots.slots[1].sink.echoed == ["hello"]
358
358
 
359
359
 
360
+ def test_home_input_dock_path_echoes_exactly_once():
361
+ """Regression pin (0.3.24, Valentin live on 0.3.22/0.3.23): typing on Home
362
+ opened a new session tab whose transcript showed the first message TWICE.
363
+ Root cause was `_home_input` echoing the typed line itself AND handing off
364
+ to `ui_hooks["start_turn_in"]` (tui's `_start_turn_in` -> `_run_turn` ->
365
+ `on_line` -> repl's `_handle`), which echoes AGAIN for every non-command
366
+ typed line (`_handle`'s own `slot.sink.user_echo(line)` -- the canonical
367
+ echo every OTHER typed line in the dock already goes through). This fake
368
+ `start_turn_in` mirrors that real seam (it echoes, exactly like `_handle`
369
+ does) so the count below actually exercises the double-echo path, not
370
+ just the echo-free fallback branch `test_home_input_new_slot_sink_records_
371
+ echo` above already covers."""
372
+ slots = SlotManager()
373
+ slots.add(_home_slot(workspace="/cwd"))
374
+
375
+ async def fake_make_session_slot(cfg, tp, ws, mode, *, resources, shared_client,
376
+ agent_factory, intel_factory, shadow_factory, first):
377
+ s = _session_slot(workspace=ws)
378
+ s.sink = _EchoSink()
379
+ return s
380
+
381
+ import webbee.repl as repl_mod
382
+ orig = repl_mod._make_session_slot
383
+ repl_mod._make_session_slot = fake_make_session_slot
384
+
385
+ def fake_start_turn_in(slot, text):
386
+ # Stands in for tui._start_turn_in's REAL chain (on_line -> _handle),
387
+ # which echoes the line itself for every non-command typed line.
388
+ slot.sink.user_echo(text)
389
+
390
+ async def never_called(slot, text):
391
+ raise AssertionError("fallback run_turn must not fire when start_turn_in is wired")
392
+
393
+ try:
394
+ asyncio.run(_home_input(
395
+ "hello", slots=slots, cfg=None, token_provider=None, mode="default",
396
+ resources=WorkspaceResources(), shared_client=None, agent_factory=None,
397
+ intel_factory=None, shadow_factory=None, workspace="/cwd",
398
+ ui_hooks={"start_turn_in": fake_start_turn_in}, run_turn=never_called))
399
+ finally:
400
+ repl_mod._make_session_slot = orig
401
+
402
+ new_slot = slots.slots[1]
403
+ assert new_slot.sink.echoed == ["hello"] # exactly ONE echo -- not two
404
+
405
+
360
406
  # ---- _schedule_home_refill --------------------------------------------------
361
407
 
362
408
  def test_schedule_home_refill_ignores_non_home_idx():
@@ -1,13 +1,21 @@
1
- """tab_fragments (W4a Task 4) — the browser tab bar as a PURE fragment
2
- builder, queue_panel discipline: unit-tested without a prompt_toolkit
3
- Application. Home always first, glyph fixed ◆, never closable; session
4
- tabs carry their own switch/close mouse handlers and middle-truncate their
5
- labels to fit a live width."""
1
+ """tab_fragments (W4a Task 4; chip redesign 0.3.24) — the browser tab bar as
2
+ a PURE fragment builder, queue_panel discipline: unit-tested without a
3
+ prompt_toolkit Application. Home always first, glyph fixed ◆, never
4
+ closable; session tabs carry their own switch/close mouse handlers and
5
+ middle-truncate their labels to fit a live width. Every tab renders as a
6
+ padded CHIP (`" {glyph} {label} "` — one leading + one trailing space baked
7
+ into the styled fragment itself), separated by exactly one dim `" │ "`
8
+ between each pair (none at the ends); the ACTIVE tab's chip carries
9
+ `class:tab.active` (resolved to a solid bg in tui.py's Style dict) and its
10
+ `✕` carries `class:tab.close.active` so the whole row reads as one
11
+ contiguous block."""
6
12
  from prompt_toolkit.data_structures import Point
7
13
  from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
8
14
 
9
15
  from webbee.slots import SessionSlot, SlotManager
10
- from webbee.tabs import _fit, tab_fragments
16
+ from webbee.tabs import (TAB_STYLE_ACTIVE, TAB_STYLE_ALERT, TAB_STYLE_CLOSE,
17
+ TAB_STYLE_CLOSE_ACTIVE, TAB_STYLE_IDLE, TAB_STYLE_SEP,
18
+ _fit, tab_fragments)
11
19
 
12
20
 
13
21
  class _FakeSink:
@@ -64,33 +72,64 @@ def test_active_tab_gets_accent_and_active_marker():
64
72
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
65
73
  text = _text(frags)
66
74
  styles = {f[1]: f[0] for f in frags}
67
- assert "●1 alpha ○" in text # active session marked ●N
68
- assert "○2 beta ○" in text # inactive session marked ○N
69
- assert styles["●1 alpha ○"] == "class:tab.active"
70
- assert styles["○2 beta ○"] == "class:tab"
75
+ assert " 1·alpha ○ " in text # active session marked ●N, padded chip
76
+ assert " 2·beta ○ " in text # inactive session marked ○N, padded chip
77
+ assert styles[" 1·alpha ○ "] == TAB_STYLE_ACTIVE
78
+ assert styles[" 2·beta ○ "] == TAB_STYLE_IDLE
71
79
 
72
80
 
73
81
  def test_home_active_gets_accent_style_but_glyph_and_label_stay_fixed():
74
82
  slots = _mk_slots(("alpha", _FakeSink()), active_idx=0)
75
83
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
76
84
  home_frag = frags[0]
77
- assert home_frag[1] == "◆ Home"
78
- assert home_frag[0] == "class:tab.active"
85
+ assert home_frag[1] == " ◆ Home "
86
+ assert home_frag[0] == TAB_STYLE_ACTIVE
79
87
 
80
88
 
81
89
  def test_home_inactive_is_idle_style():
82
90
  slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
83
91
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
84
92
  home_frag = frags[0]
85
- assert home_frag[1] == "◆ Home"
86
- assert home_frag[0] == "class:tab"
93
+ assert home_frag[1] == " ◆ Home "
94
+ assert home_frag[0] == TAB_STYLE_IDLE
87
95
 
88
96
 
89
97
  def test_glyphs_come_from_status_glyph_busy_and_idle():
90
98
  slots = _mk_slots(("alpha", _FakeSink(busy=True)), ("beta", _FakeSink()), active_idx=1)
91
99
  text = _text(tab_fragments(slots, on_switch=_noop, on_close=_noop))
92
- assert "alpha ▶" in text
93
- assert "beta ○" in text
100
+ assert " ● 1·alpha ▶ " in text
101
+ assert " ○ 2·beta ○ " in text
102
+
103
+
104
+ # ── chip padding + separators (0.3.24 redesign) ─────────────────────────────
105
+
106
+ def test_every_chip_body_has_uniform_single_space_padding():
107
+ # Home + every session body fragment (never the sep/close pieces) starts
108
+ # and ends with EXACTLY one space, not zero and not two.
109
+ slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink()), active_idx=1)
110
+ frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
111
+ bodies = [f[1] for f in frags if f[0] not in (TAB_STYLE_SEP, TAB_STYLE_CLOSE, TAB_STYLE_CLOSE_ACTIVE)]
112
+ assert bodies == [" ◆ Home ", " ● 1·alpha ○ ", " ○ 2·beta ○ "]
113
+ for body in bodies:
114
+ assert body.startswith(" ") and not body.startswith(" ")
115
+ assert body.endswith(" ") and not body.endswith(" ")
116
+
117
+
118
+ def test_exactly_one_separator_between_each_pair_of_tabs_none_at_ends():
119
+ slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink()), ("gamma", _FakeSink()), active_idx=1)
120
+ frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
121
+ seps = [f for f in frags if f[0] == TAB_STYLE_SEP]
122
+ assert len(seps) == 3 # one before EACH session tab
123
+ assert all(f[1] == " │ " for f in seps)
124
+ assert frags[0][0] != TAB_STYLE_SEP # never at the very start (Home's own chip)
125
+ assert frags[-1][0] != TAB_STYLE_SEP # never at the very end (last tab's close)
126
+
127
+
128
+ def test_close_fragment_is_uniformly_padded():
129
+ slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
130
+ frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
131
+ close = [f[1] for f in frags if f[0] in (TAB_STYLE_CLOSE, TAB_STYLE_CLOSE_ACTIVE)]
132
+ assert close == [" ✕ "]
94
133
 
95
134
 
96
135
  # ── ⚠ background consent badge ──────────────────────────────────────────────
@@ -101,8 +140,20 @@ def test_alert_badge_on_background_consent_tab():
101
140
  slots = _mk_slots(("alpha", _FakeSink()), ("beta", _FakeSink(consent=True)), active_idx=1)
102
141
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
103
142
  styles = {f[1]: f[0] for f in frags}
104
- assert "○2 beta ⚠" in styles
105
- assert styles["○2 beta ⚠"] == "class:tab.alert"
143
+ assert " 2·beta ⚠ " in styles
144
+ assert styles[" 2·beta ⚠ "] == TAB_STYLE_ALERT
145
+
146
+
147
+ def test_alert_style_never_applied_to_the_active_tab_itself():
148
+ # tab.alert exists ONLY to flag a BACKGROUND tab -- an ACTIVE tab that
149
+ # happens to have a ⚠ (own pending consent) must never get tab.alert,
150
+ # since only the active chip owns a background and alert has none (it
151
+ # would silently lose its highlight).
152
+ slots = _mk_slots(("alpha", _FakeSink(consent=True)), ("beta", _FakeSink()), active_idx=1)
153
+ frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
154
+ styles = {f[1]: f[0] for f in frags}
155
+ assert styles[" ● 1·alpha ⚠ "] == TAB_STYLE_ACTIVE
156
+ assert TAB_STYLE_ALERT not in styles.values()
106
157
 
107
158
 
108
159
  def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
@@ -112,8 +163,8 @@ def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
112
163
  slots = _mk_slots(("alpha", _FakeSink(consent=True)), active_idx=1)
113
164
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
114
165
  styles = {f[1]: f[0] for f in frags}
115
- assert "●1 alpha ⚠" in styles
116
- assert styles["●1 alpha ⚠"] == "class:tab.active"
166
+ assert " 1·alpha ⚠ " in styles
167
+ assert styles[" 1·alpha ⚠ "] == TAB_STYLE_ACTIVE
117
168
 
118
169
 
119
170
  # ── Home has no close ───────────────────────────────────────────────────────
@@ -121,10 +172,10 @@ def test_active_tab_with_consent_pending_stays_active_styled_not_alert():
121
172
  def test_home_never_gets_a_close_fragment():
122
173
  slots = _mk_slots(("alpha", _FakeSink()), active_idx=1)
123
174
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
124
- assert not any(f[1].strip() == "✕" and f[0] == "class:tab.close" for f in frags[:1])
175
+ assert not any(f[1].strip() == "✕" for f in frags[:1])
125
176
  # Only ONE fragment renders before the first separator (Home's body) —
126
177
  # no trailing close piece tacked onto it.
127
- assert frags[0][1] == "◆ Home"
178
+ assert frags[0][1] == " ◆ Home "
128
179
  assert frags[1][1] == " │ " # separator, not a close glyph
129
180
 
130
181
 
@@ -165,7 +216,10 @@ def test_session_close_fragment_fires_on_close_with_its_index():
165
216
  frags = tab_fragments(slots, on_switch=_noop, on_close=hits.append)
166
217
  close_alpha = frags[3] # [home, sep, alpha, CLOSE-alpha, sep, beta, close-beta]
167
218
  close_beta = frags[6]
168
- assert close_alpha[1] == "" and close_alpha[0] == "class:tab.close"
219
+ # alpha (idx 1) IS the active tab -- its gets the bg-contiguous style;
220
+ # beta (idx 2, background) keeps the plain dim one.
221
+ assert close_alpha[1] == " ✕ " and close_alpha[0] == TAB_STYLE_CLOSE_ACTIVE
222
+ assert close_beta[1] == " ✕ " and close_beta[0] == TAB_STYLE_CLOSE
169
223
  assert _up(close_alpha[2]) is None and hits == [1]
170
224
  assert _up(close_beta[2]) is None and hits == [1, 2]
171
225
 
@@ -196,12 +250,14 @@ def test_fit_middle_truncates_and_never_shrinks_below_floor():
196
250
 
197
251
 
198
252
  def test_narrow_width_truncates_labels_to_the_floor_and_row_fits():
199
- # width=42 is chosen so the computed per-label budget lands exactly on
200
- # the 8-char floor: both long labels truncate to precisely 8 chars and
201
- # the whole row still fits inside `width` (no floor-forced overflow).
253
+ # width=52 is chosen so the computed per-label budget lands exactly on
254
+ # the 8-char floor (chip padding raises the fixed per-tab overhead from
255
+ # the pre-0.3.24 shape, so the exact width that lands on the floor
256
+ # shifted too): both long labels truncate to precisely 8 chars and the
257
+ # whole row still fits inside `width` (no floor-forced overflow).
202
258
  label1, label2 = "workspace-one-long-name", "workspace-two-long-name"
203
259
  slots = _mk_slots((label1, _FakeSink()), (label2, _FakeSink()), active_idx=1)
204
- width = 42
260
+ width = 52
205
261
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop, width=width)
206
262
  text = _text(frags)
207
263
  assert len(text) <= width
@@ -225,7 +281,7 @@ def test_single_slot_home_alone_still_renders_the_bar():
225
281
  slots = _mk_slots(active_idx=0)
226
282
  frags = tab_fragments(slots, on_switch=_noop, on_close=_noop)
227
283
  assert frags
228
- assert _text(frags) == "◆ Home"
284
+ assert _text(frags) == " ◆ Home "
229
285
 
230
286
 
231
287
  def test_empty_slot_manager_renders_nothing():
@@ -3319,12 +3319,13 @@ def test_dock_mounts_sticky_todo_panel_above_queue_panel():
3319
3319
  # a mid-turn tab switch. No tab-bar UI yet (Task 4) -- these tests switch
3320
3320
  # tabs directly via `slots.active_idx`, exactly as a future keybinding will.
3321
3321
 
3322
- def test_switching_tabs_drops_the_leaving_tabs_unsent_draft():
3323
- # FIX7b: a draft mid-type belongs to the tab you were looking at when
3324
- # you typed it, never the one you switch into -- _switch_to's
3325
- # buf.reset() must drop it (plan-mandated: drafts dropped, history
3326
- # load-task reset), not just repoint the recall history.
3327
- from prompt_toolkit.application import create_app_session
3322
+ def test_switching_tabs_preserves_the_draft_and_restores_it_on_return():
3323
+ # 0.3.24 (per-tab drafts, product decision -- supersedes FIX7b's "drafts
3324
+ # dropped on switch"): a draft mid-type belongs to the tab you typed it
3325
+ # into, browser-tab style -- it must survive a switch AWAY and come back
3326
+ # verbatim (text + cursor) on a switch BACK, while the tab you switched
3327
+ # INTO in between never sees it.
3328
+ from prompt_toolkit.application import create_app_session, get_app
3328
3329
  from prompt_toolkit.input import create_pipe_input
3329
3330
  from prompt_toolkit.output import DummyOutput
3330
3331
 
@@ -3349,14 +3350,27 @@ def test_switching_tabs_drops_the_leaving_tabs_unsent_draft():
3349
3350
  await asyncio.sleep(0.05)
3350
3351
  pipe.send_text("partial draft") # no Enter -- just a draft in progress
3351
3352
  await asyncio.sleep(0.05)
3353
+ get_app().current_buffer.cursor_position = 7 # mid-text, not just "at the end"
3352
3354
 
3353
3355
  pipe.send_text("\x1b1") # Alt+1 -- switch to B
3354
3356
  await _until(lambda: slots.active_idx == 1)
3357
+ assert get_app().current_buffer.text == "" # B never sees A's draft
3355
3358
 
3356
- pipe.send_text("\r") # Enter on an (expected) EMPTY buffer
3359
+ pipe.send_text("\r") # Enter on B's (genuinely empty) buffer
3357
3360
  await asyncio.sleep(0.1)
3358
- assert ran == [] # the draft never resubmitted as a line
3361
+ assert ran == [] # nothing resubmitted on B
3359
3362
 
3363
+ pipe.send_text("\x1b0") # Alt+0 -- switch back to A
3364
+ await _until(lambda: slots.active_idx == 0)
3365
+ assert get_app().current_buffer.text == "partial draft" # restored verbatim
3366
+ assert get_app().current_buffer.cursor_position == 7 # cursor restored too
3367
+
3368
+ # Exit via B -- idx 0 is structurally unclosable in this
3369
+ # 2-slot fixture (SlotManager.close's own Home-guard treats
3370
+ # index 0 as never closable, session or not), same escape
3371
+ # route the pre-0.3.24 version of this test used.
3372
+ pipe.send_text("\x1b1")
3373
+ await _until(lambda: slots.active_idx == 1)
3360
3374
  pipe.send_text("\x04")
3361
3375
  await _until(lambda: len(slots.slots) == 1)
3362
3376
  pipe.send_text("\x04")
@@ -3366,16 +3380,123 @@ def test_switching_tabs_drops_the_leaving_tabs_unsent_draft():
3366
3380
  asyncio.run(scenario())
3367
3381
 
3368
3382
 
3369
- def test_switch_clears_the_leaving_slots_pulled_carry_so_a_stale_iid_cannot_leak():
3370
- # FIX7b: a pulled-but-never-resubmitted queue item's steer_iid must not
3371
- # outlive a switch AWAY from its own slot -- otherwise a LATER, wholly
3372
- # unrelated line that happens to match the pulled text verbatim (typed
3373
- # after switching away and back) would wrongly inherit a STALE dedup
3374
- # iid, misleading the kernel's ring into treating a genuinely NEW
3375
- # message as a duplicate of a pull that's long gone.
3383
+ def test_home_draft_is_isolated_from_a_session_tabs_draft():
3384
+ # Companion to the round-trip test above, specifically for Home: typing
3385
+ # on Home (never submitted -- home_input owns plain text there, no
3386
+ # Enter needed for this test) must not leak into a session tab's buffer,
3387
+ # and Home's own draft must survive a round trip exactly like a session
3388
+ # tab's does.
3389
+ from prompt_toolkit.application import create_app_session, get_app
3390
+ from prompt_toolkit.input import create_pipe_input
3391
+ from prompt_toolkit.output import DummyOutput
3392
+
3393
+ from webbee import tui
3394
+ from webbee.slots import SessionSlot, SlotManager
3395
+
3396
+ async def scenario():
3397
+ home = SessionSlot(kind="home", workspace=".", label="Home",
3398
+ pane=tui.OutputPane(width=80), sink=None, agent=None)
3399
+ session = SessionSlot(kind="session", workspace=".", label="a",
3400
+ pane=tui.OutputPane(width=80), sink=_idle_sink(), agent=None)
3401
+ slots = SlotManager()
3402
+ slots.add(home)
3403
+ slots.add(session)
3404
+ slots.active_idx = 0
3405
+
3406
+ async def on_line(text, slot=None): ...
3407
+
3408
+ with create_pipe_input() as pipe:
3409
+ with create_app_session(input=pipe, output=DummyOutput()):
3410
+ task = asyncio.create_task(tui.run_session(
3411
+ slots=slots, on_line=on_line, on_cycle=lambda: None,
3412
+ home_input=lambda text: None))
3413
+ await asyncio.sleep(0.05)
3414
+ pipe.send_text("home draft")
3415
+ await asyncio.sleep(0.05)
3416
+
3417
+ pipe.send_text("\x1b1") # Alt+1 -- switch to the session tab
3418
+ await _until(lambda: slots.active_idx == 1)
3419
+ assert get_app().current_buffer.text == "" # session tab never sees Home's draft
3420
+
3421
+ pipe.send_text("session draft")
3422
+ await asyncio.sleep(0.05)
3423
+
3424
+ pipe.send_text("\x1b0") # Alt+0 -- back to Home
3425
+ await _until(lambda: slots.active_idx == 0)
3426
+ assert get_app().current_buffer.text == "home draft" # Home's OWN draft restored
3427
+
3428
+ pipe.send_text("\x1b1") # back to the session tab
3429
+ await _until(lambda: slots.active_idx == 1)
3430
+ assert get_app().current_buffer.text == "session draft" # its own draft, untouched
3431
+
3432
+ pipe.send_text("\x1b0")
3433
+ await _until(lambda: slots.active_idx == 0)
3434
+ pipe.send_text("\x04")
3435
+ ok = await asyncio.wait_for(task, 5)
3436
+ assert ok is True
3437
+
3438
+ asyncio.run(scenario())
3439
+
3440
+
3441
+ def test_submitting_a_line_clears_that_slots_draft_so_it_never_resurrects():
3442
+ # 0.3.24: a genuine submit must retire the stashed draft too -- otherwise
3443
+ # switching away and back after sending a message would restore text
3444
+ # that was already sent, silently resurrecting it in the input box.
3445
+ from prompt_toolkit.application import create_app_session, get_app
3446
+ from prompt_toolkit.input import create_pipe_input
3447
+ from prompt_toolkit.output import DummyOutput
3448
+
3449
+ from webbee import tui
3450
+ from webbee.slots import SessionSlot
3451
+
3452
+ async def scenario():
3453
+ pane_a, pane_b = tui.OutputPane(width=80), tui.OutputPane(width=80)
3454
+ ran = []
3455
+
3456
+ async def on_line(text, slot=None):
3457
+ ran.append(text)
3458
+
3459
+ slots = mk_slots(pane=pane_a, sink=_idle_sink())
3460
+ slots.add(SessionSlot(kind="session", workspace=".", label="b",
3461
+ pane=pane_b, sink=_idle_sink(), agent=None))
3462
+
3463
+ with create_pipe_input() as pipe:
3464
+ with create_app_session(input=pipe, output=DummyOutput()):
3465
+ task = asyncio.create_task(tui.run_session(
3466
+ slots=slots, on_line=on_line, on_cycle=lambda: None))
3467
+ await asyncio.sleep(0.05)
3468
+ pipe.send_text("go\r") # type + submit, idle slot -> starts a turn
3469
+ await _until(lambda: ran == ["go"])
3470
+
3471
+ pipe.send_text("\x1b1") # switch away
3472
+ await _until(lambda: slots.active_idx == 1)
3473
+ pipe.send_text("\x1b0") # ...and back
3474
+ await _until(lambda: slots.active_idx == 0)
3475
+ assert get_app().current_buffer.text == "" # NOT resurrected
3476
+
3477
+ pipe.send_text("\x1b1")
3478
+ await _until(lambda: slots.active_idx == 1)
3479
+ pipe.send_text("\x04")
3480
+ await _until(lambda: len(slots.slots) == 1)
3481
+ pipe.send_text("\x04")
3482
+ ok = await asyncio.wait_for(task, 5)
3483
+ assert ok is True
3484
+
3485
+ asyncio.run(scenario())
3486
+
3487
+
3488
+ def test_switch_preserves_the_leaving_slots_pulled_carry_so_resubmit_unchanged_still_dedups():
3489
+ # 0.3.24 (product decision, supersedes FIX7b's "pulled cleared on the
3490
+ # way out"): pulled now travels WITH the draft, on its own slot -- a
3491
+ # pulled-but-not-yet-resubmitted queue item survives a switch away and
3492
+ # back, and resubmitting it UNCHANGED after the round trip still reuses
3493
+ # its ORIGINAL steer_iid, so the kernel's dedup ring can still catch a
3494
+ # landed twin exactly as if the user had never glanced at another tab.
3495
+ # `_rewrap_pulled`'s one-shot consume still retires it -- on the Enter
3496
+ # that actually resubmits, not on the switch.
3376
3497
  from collections import deque
3377
3498
 
3378
- from prompt_toolkit.application import create_app_session
3499
+ from prompt_toolkit.application import create_app_session, get_app
3379
3500
  from prompt_toolkit.input import create_pipe_input
3380
3501
  from prompt_toolkit.output import DummyOutput
3381
3502
 
@@ -3407,14 +3528,17 @@ def test_switch_clears_the_leaving_slots_pulled_carry_so_a_stale_iid_cannot_leak
3407
3528
 
3408
3529
  pipe.send_text("\x1b1") # Alt+1 -- switch away WITHOUT resubmitting
3409
3530
  await _until(lambda: slots.active_idx == 1)
3410
- assert slot_a.pulled == {"text": "", "iid": ""} # FIX7b: cleared on the way out
3531
+ assert slot_a.pulled == {"text": "queued text", "iid": "iid-1"} # SURVIVES the switch
3532
+ assert slot_a.draft == "queued text" # draft travels with it
3411
3533
 
3412
3534
  pipe.send_text("\x1b0") # Alt+0 -- switch back to A
3413
3535
  await _until(lambda: slots.active_idx == 0)
3536
+ assert get_app().current_buffer.text == "queued text" # restored, unedited
3414
3537
 
3415
- pipe.send_text("queued text\r") # coincidentally the SAME text, typed fresh
3538
+ pipe.send_text("\r") # resubmit UNCHANGED -- no retyping
3416
3539
  await _until(lambda: ran == ["queued text"])
3417
- assert getattr(ran[0], "iid", "") == "" # NOT the stale iid-1 -- a genuinely new line
3540
+ assert getattr(ran[0], "iid", "") == "iid-1" # ORIGINAL iid preserved -- dedup intact
3541
+ assert slot_a.pulled == {"text": "", "iid": ""} # one-shot: consumed by THIS Enter, not the switch
3418
3542
 
3419
3543
  pipe.send_text("\x1b1") # switch to B -- A (idx 0) is unclosable here
3420
3544
  await _until(lambda: slots.active_idx == 1)
@@ -4016,7 +4140,7 @@ def test_tab_bar_window_is_root_hsplit_first_child():
4016
4140
  assert not isinstance(bar, ConditionalContainer) # ALWAYS visible
4017
4141
  text = "".join(f[1] for f in bar.content.text())
4018
4142
  assert "◆ Home" in text # Home always first
4019
- assert "●1 proj" in text # active session, marked ●N
4143
+ assert "● 1·proj" in text # active session, marked ●N
4020
4144
  pipe.send_text("\x04")
4021
4145
  ok = await asyncio.wait_for(task, 5)
4022
4146
  assert ok is True
@@ -4495,7 +4619,7 @@ def test_tab_bar_close_click_closes_the_clicked_background_tab_not_the_active_on
4495
4619
  frags = bar.content.text()
4496
4620
  # frags: [home, sep, a-body, a-CLOSE, sep, b-body, b-close]
4497
4621
  close_a = frags[3]
4498
- assert close_a[1] == " ✕"
4622
+ assert close_a[1] == " ✕ "
4499
4623
  ev = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_UP,
4500
4624
  button=MouseButton.LEFT, modifiers=frozenset())
4501
4625
  close_a[2](ev) # click ✕ on the BACKGROUND tab a
@@ -4546,7 +4670,7 @@ def test_tab_bar_close_click_closes_the_active_tab_when_that_is_the_one_clicked(
4546
4670
  bar = get_app().layout.container.children[0]
4547
4671
  frags = bar.content.text()
4548
4672
  close_b = frags[6]
4549
- assert close_b[1] == " ✕"
4673
+ assert close_b[1] == " ✕ "
4550
4674
  ev = MouseEvent(position=Point(0, 0), event_type=MouseEventType.MOUSE_UP,
4551
4675
  button=MouseButton.LEFT, modifiers=frozenset())
4552
4676
  close_b[2](ev) # click ✕ on the ACTIVE tab b
@@ -1 +0,0 @@
1
- __version__ = "0.3.23"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes