webbee 0.3.40__tar.gz → 0.3.41__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 (130) hide show
  1. {webbee-0.3.40 → webbee-0.3.41}/CHANGELOG.md +25 -0
  2. {webbee-0.3.40 → webbee-0.3.41}/PKG-INFO +1 -1
  3. {webbee-0.3.40 → webbee-0.3.41}/pyproject.toml +1 -1
  4. webbee-0.3.41/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/output_pane.py +8 -0
  6. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/render.py +27 -0
  7. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/selection.py +10 -0
  8. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tui.py +55 -4
  9. {webbee-0.3.40 → webbee-0.3.41}/tests/test_render.py +52 -0
  10. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tui.py +110 -0
  11. webbee-0.3.40/src/webbee/__init__.py +0 -1
  12. {webbee-0.3.40 → webbee-0.3.41}/.github/workflows/publish.yml +0 -0
  13. {webbee-0.3.40 → webbee-0.3.41}/.gitignore +0 -0
  14. {webbee-0.3.40 → webbee-0.3.41}/LICENSE +0 -0
  15. {webbee-0.3.40 → webbee-0.3.41}/README.md +0 -0
  16. {webbee-0.3.40 → webbee-0.3.41}/docs/superpowers/plans/2026-07-21-interactive-home-dashboard.md +0 -0
  17. {webbee-0.3.40 → webbee-0.3.41}/install.sh +0 -0
  18. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/account.py +0 -0
  19. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/active_sessions.py +0 -0
  20. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/banner_art.py +0 -0
  21. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/boot.py +0 -0
  22. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/cli.py +0 -0
  24. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/clipboard_read.py +0 -0
  26. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/coding_context.py +0 -0
  27. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/commands.py +0 -0
  28. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/config.py +0 -0
  29. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/consent.py +0 -0
  30. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/details.py +0 -0
  31. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/events.py +0 -0
  32. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/frames.py +0 -0
  33. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/home.py +0 -0
  34. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/home_view.py +0 -0
  35. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/http.py +0 -0
  36. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/instance_lock.py +0 -0
  37. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/__init__.py +0 -0
  38. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/chunker.py +0 -0
  39. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/embed.py +0 -0
  40. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/graph.py +0 -0
  41. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/indexer.py +0 -0
  42. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/models.py +0 -0
  43. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/query.py +0 -0
  44. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/service.py +0 -0
  45. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/store.py +0 -0
  46. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/vectors.py +0 -0
  47. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/intel/watch.py +0 -0
  48. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/mode_store.py +0 -0
  49. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/newtab_mode.py +0 -0
  50. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/queue_panel.py +0 -0
  51. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/reflow.py +0 -0
  52. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/remote.py +0 -0
  53. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/repl.py +0 -0
  54. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/repo.py +0 -0
  55. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/session.py +0 -0
  56. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/sessions.py +0 -0
  57. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/sizing.py +0 -0
  58. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/slots.py +0 -0
  59. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/steer.py +0 -0
  60. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/stream.py +0 -0
  61. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tab_store.py +0 -0
  62. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tabs.py +0 -0
  63. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/thread.py +0 -0
  64. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tier_store.py +0 -0
  65. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/todo_panel.py +0 -0
  66. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tokens.py +0 -0
  67. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/tools.py +0 -0
  68. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/update.py +0 -0
  69. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/urlopen.py +0 -0
  70. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/wallet.py +0 -0
  71. {webbee-0.3.40 → webbee-0.3.41}/src/webbee/worktrees.py +0 -0
  72. {webbee-0.3.40 → webbee-0.3.41}/tests/__init__.py +0 -0
  73. {webbee-0.3.40 → webbee-0.3.41}/tests/conftest.py +0 -0
  74. {webbee-0.3.40 → webbee-0.3.41}/tests/test_account.py +0 -0
  75. {webbee-0.3.40 → webbee-0.3.41}/tests/test_active_sessions.py +0 -0
  76. {webbee-0.3.40 → webbee-0.3.41}/tests/test_audit_2026_07_25_findings.py +0 -0
  77. {webbee-0.3.40 → webbee-0.3.41}/tests/test_checkpoints.py +0 -0
  78. {webbee-0.3.40 → webbee-0.3.41}/tests/test_cli.py +0 -0
  79. {webbee-0.3.40 → webbee-0.3.41}/tests/test_clipboard.py +0 -0
  80. {webbee-0.3.40 → webbee-0.3.41}/tests/test_clipboard_read.py +0 -0
  81. {webbee-0.3.40 → webbee-0.3.41}/tests/test_coding_context.py +0 -0
  82. {webbee-0.3.40 → webbee-0.3.41}/tests/test_commands.py +0 -0
  83. {webbee-0.3.40 → webbee-0.3.41}/tests/test_config.py +0 -0
  84. {webbee-0.3.40 → webbee-0.3.41}/tests/test_cpc_contract_stable.py +0 -0
  85. {webbee-0.3.40 → webbee-0.3.41}/tests/test_details.py +0 -0
  86. {webbee-0.3.40 → webbee-0.3.41}/tests/test_events.py +0 -0
  87. {webbee-0.3.40 → webbee-0.3.41}/tests/test_freeze_fix.py +0 -0
  88. {webbee-0.3.40 → webbee-0.3.41}/tests/test_home.py +0 -0
  89. {webbee-0.3.40 → webbee-0.3.41}/tests/test_home_view.py +0 -0
  90. {webbee-0.3.40 → webbee-0.3.41}/tests/test_instance_lock.py +0 -0
  91. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_chunker.py +0 -0
  92. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_embed.py +0 -0
  93. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_graph.py +0 -0
  94. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_indexer.py +0 -0
  95. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_no_numpy.py +0 -0
  96. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_query.py +0 -0
  97. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_service.py +0 -0
  98. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_store.py +0 -0
  99. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_vectors.py +0 -0
  100. {webbee-0.3.40 → webbee-0.3.41}/tests/test_intel_watch.py +0 -0
  101. {webbee-0.3.40 → webbee-0.3.41}/tests/test_marathon.py +0 -0
  102. {webbee-0.3.40 → webbee-0.3.41}/tests/test_mode_store.py +0 -0
  103. {webbee-0.3.40 → webbee-0.3.41}/tests/test_newtab_mode.py +0 -0
  104. {webbee-0.3.40 → webbee-0.3.41}/tests/test_packaging.py +0 -0
  105. {webbee-0.3.40 → webbee-0.3.41}/tests/test_queue_manage_0337.py +0 -0
  106. {webbee-0.3.40 → webbee-0.3.41}/tests/test_reflow.py +0 -0
  107. {webbee-0.3.40 → webbee-0.3.41}/tests/test_repl.py +0 -0
  108. {webbee-0.3.40 → webbee-0.3.41}/tests/test_repo.py +0 -0
  109. {webbee-0.3.40 → webbee-0.3.41}/tests/test_session.py +0 -0
  110. {webbee-0.3.40 → webbee-0.3.41}/tests/test_sessions.py +0 -0
  111. {webbee-0.3.40 → webbee-0.3.41}/tests/test_sizing.py +0 -0
  112. {webbee-0.3.40 → webbee-0.3.41}/tests/test_slots.py +0 -0
  113. {webbee-0.3.40 → webbee-0.3.41}/tests/test_steer.py +0 -0
  114. {webbee-0.3.40 → webbee-0.3.41}/tests/test_stream.py +0 -0
  115. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tab_restore_0337.py +0 -0
  116. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tabs.py +0 -0
  117. {webbee-0.3.40 → webbee-0.3.41}/tests/test_terminal_ux_0336.py +0 -0
  118. {webbee-0.3.40 → webbee-0.3.41}/tests/test_thread.py +0 -0
  119. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tier_store.py +0 -0
  120. {webbee-0.3.40 → webbee-0.3.41}/tests/test_todo_panel.py +0 -0
  121. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tokens.py +0 -0
  122. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tools.py +0 -0
  123. {webbee-0.3.40 → webbee-0.3.41}/tests/test_tui_hardening.py +0 -0
  124. {webbee-0.3.40 → webbee-0.3.41}/tests/test_update.py +0 -0
  125. {webbee-0.3.40 → webbee-0.3.41}/tests/test_update_badge_0340.py +0 -0
  126. {webbee-0.3.40 → webbee-0.3.41}/tests/test_urlopen.py +0 -0
  127. {webbee-0.3.40 → webbee-0.3.41}/tests/test_version.py +0 -0
  128. {webbee-0.3.40 → webbee-0.3.41}/tests/test_version_badge_corner.py +0 -0
  129. {webbee-0.3.40 → webbee-0.3.41}/tests/test_wallet.py +0 -0
  130. {webbee-0.3.40 → webbee-0.3.41}/tests/test_worktrees.py +0 -0
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.41
4
+
5
+ The model tier is always visible now (not just via `/model`), and clicking a
6
+ tool line in the transcript expands it right there -- no keyboard needed.
7
+
8
+ - **The active model tier is now shown right in the toolbar**, next to
9
+ `mode:`, both idle and mid-turn -- it used to be visible only by running
10
+ `/model` with no argument or catching the flash right after a Ctrl+B
11
+ cycle. Silent (nothing shown) when no tier has been chosen, so it never
12
+ claims a value you didn't pick.
13
+ - **Click any finished tool line (bash, write_file, read_file, ...) in the
14
+ transcript to expand it** -- the exact same compact, bordered detail card
15
+ `/steps N` (or Up/Down + Enter) already gave you, one gesture shorter.
16
+ Clicking a banner, a progress line, or blank space is a harmless no-op --
17
+ nothing to expand there. Busy mid-turn is also a no-op (there's no
18
+ finished-turn detail to show yet). This reuses the existing `/steps`
19
+ machinery end to end -- it is not a new UI surface, never covers the
20
+ screen, and nothing needed to be kept in sync twice.
21
+ - Right-click paste (from 0.3.39) is confirmed working end-to-end on the
22
+ underlying mouse-report protocol; if it doesn't react in your terminal,
23
+ check that terminal's own mouse-reporting setting -- some terminal
24
+ emulators (notably macOS Terminal.app) never forward a right-click to the
25
+ program running inside them at all, by design, regardless of what an app
26
+ requests.
27
+
3
28
  ## 0.3.40
4
29
 
5
30
  The bottom-right version badge is now real everywhere, and Home stopped
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.40
3
+ Version: 0.3.41
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.40"
3
+ version = "0.3.41"
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.41"
@@ -49,6 +49,14 @@ class OutputPane:
49
49
  pane_records.clear()
50
50
  pane._reset_buffer() # StringIO + caches reset (Task 3 builds on it)
51
51
 
52
+ def record_count(self) -> int:
53
+ """webbee-code-click-to-expand-v1: how many print records exist so
54
+ far -- RichSink.tool_result reads this RIGHT AFTER printing a step's
55
+ line to tag that exact record as \"step N\", so a later mouse click
56
+ on that line can look the step back up (see render.py's
57
+ `_STEP_RECORDS` / tui.py's click handler)."""
58
+ return len(pane_records)
59
+
52
60
  self._records = pane_records
53
61
  self._record_lines: list = [] # per-record NEW-line count, prefix-summable (reflow.py)
54
62
  self._ring_base_lines = 0 # buffer content lines BEFORE ring record 0 (deque
@@ -167,6 +167,13 @@ class RichSink:
167
167
  self._pending = ("", "")
168
168
  self._consent = None # asyncio.Future while awaiting a reply
169
169
  self._consent_summary = ""
170
+ # webbee-code-click-to-expand-v1: maps a printed record index (pane's
171
+ # RecordingConsole ring position, 0-based) -> 1-based step number for
172
+ # THIS turn, so a mouse click on a tool_result line can look its own
173
+ # step back up without any new wire protocol -- begin_turn resets both
174
+ # every turn, exactly like self.steps itself resets in session.py.
175
+ self._step_index = 0
176
+ self._step_records: dict = {}
170
177
  # Cross-surface items already queued in the RUNNING kernel session
171
178
  # (full-queue-layer K1: task_queued/task_dequeued frames). The dock's
172
179
  # queue panel reads THIS list object every redraw — mutate in place
@@ -271,6 +278,11 @@ class RichSink:
271
278
  self._reconnect_since = None
272
279
  self._turn_failed = False
273
280
  self._parked = False
281
+ # webbee-code-click-to-expand-v1: fresh per-turn -- step numbering
282
+ # (and thus which printed record maps to which step) restarts with
283
+ # every turn, exactly like self.steps itself does in session.py.
284
+ self._step_index = 0
285
+ self._step_records = {}
274
286
  self.console.print() # breathing room between the user's message and the response
275
287
  self._nudge()
276
288
 
@@ -580,8 +592,23 @@ class RichSink:
580
592
  (_clean(summary)[:trunc(w, 0.35, 50)], "dim"),
581
593
  ))
582
594
  self._pending = ("", "")
595
+ # webbee-code-click-to-expand-v1: tag the record JUST printed as
596
+ # "step N" (1-based, matching /steps' own numbering) -- record_count()
597
+ # already reflects this print (Rich prints synchronously), so the
598
+ # index this line landed at is exactly len(pane_records) - 1.
599
+ self._step_index += 1
600
+ rc = getattr(self.console, "record_count", None)
601
+ if callable(rc):
602
+ self._step_records[rc() - 1] = self._step_index
583
603
  self._nudge()
584
604
 
605
+ def step_for_record(self, record_idx: int) -> "int | None":
606
+ """webbee-code-click-to-expand-v1: 1-based step number that produced
607
+ `record_idx` (a pane RecordingConsole ring position), or None if that
608
+ record isn't a tool_result line (e.g. a progress/thinking/banner
609
+ line -- nothing to expand there, same as clicking blank space)."""
610
+ return self._step_records.get(record_idx)
611
+
585
612
  async def ask_consent(self, app_id: str, tool: str, args: dict) -> str:
586
613
  """Ask for consent and return the user's RAW reply (trimmed only) —
587
614
  NEVER interpret (the kernel decides, ICNLI). When the dock is running
@@ -111,6 +111,16 @@ def make_select_control(pane, FormattedTextControl, MouseEventType, MouseButton)
111
111
  up_abs = (ev.position.y + pane._offset, ev.position.x)
112
112
  if down_abs is not None and down_abs != up_abs:
113
113
  pane._copy_selection(down_abs, up_abs)
114
+ elif down_abs is not None:
115
+ # webbee-code-click-to-expand-v1: a PLAIN click (press and
116
+ # release at the SAME absolute cell -- no drag, nothing to
117
+ # copy) on a transcript line asks "what happened here?" --
118
+ # same one-hook/getattr-guarded shape as on_middle_paste/
119
+ # on_right_paste above, so a pane with no hook wired
120
+ # (tests, no dock) stays a harmless no-op.
121
+ hook = getattr(pane, "on_line_click", None)
122
+ if hook is not None:
123
+ hook(down_abs[0])
114
124
  pane._sel = None
115
125
  pane._invalidate() # clear the highlight (colours restored)
116
126
  return None
@@ -223,7 +223,8 @@ def next_tier(tier: str) -> str:
223
223
  def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
224
224
  current: str = "", elapsed: float = 0.0, tools: int = 0,
225
225
  consent: bool = False, queued: int = 0,
226
- reconnecting: int = 0, width: int = 0, live: str = "") -> list:
226
+ reconnecting: int = 0, width: int = 0, live: str = "",
227
+ tier: str = "") -> list:
227
228
  """The status line under the pinned input box, as prompt_toolkit formatted
228
229
  text (per-segment styled). Four states: consent (awaiting a reply),
229
230
  reconnecting (the stream transport is down mid-turn — honest, not a fake
@@ -249,14 +250,27 @@ def build_toolbar(mode: str, tokens: int, credits: int, *, busy: bool = False,
249
250
  frags = [("class:tb.spin", f" {spin} "), ("class:tb.working", "working")]
250
251
  if current:
251
252
  frags += [("class:tb.dim", " · "), ("class:tb.action", current)]
253
+ # webbee-code-model-tier-toolbar-v1: keep the tier visible mid-turn too
254
+ # (a short "· smart" tag, NOT the full "model: " label used at idle --
255
+ # busy is already the densest row, this is the cheapest legible form).
256
+ if tier:
257
+ frags += [("class:tb.dim", " · "), ("class:tb.mode.default", tier)]
252
258
  frags.append(("class:tb.dim",
253
259
  f" · {elapsed:.0f}s · {tools} · {_fmt_tokens(tokens)} tok"))
254
260
  frags += q
255
261
  frags.append(("class:tb.dim", " · Esc/Ctrl-C to stop"))
256
262
  return frags
257
263
  frags = [("class:tb.dim", " mode: "),
258
- (f"class:tb.mode.{mode}", mode),
259
- ("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits this session"),
264
+ (f"class:tb.mode.{mode}", mode)]
265
+ # webbee-code-model-tier-toolbar-v1: the tier was only ever visible via
266
+ # `/model` (no arg) or right after a Ctrl+B cycle flash -- ask once, then
267
+ # forget, exactly the visibility gap the mode segment right next to it
268
+ # never had. "" (unset -> server admin default) stays silent rather than
269
+ # claim a tier name nobody chose (I-DIAGNOSIS-GROUNDED spirit: never
270
+ # invent a value the user didn't pick and the server didn't confirm).
271
+ if tier:
272
+ frags += [("class:tb.dim", " · model: "), ("class:tb.mode.default", tier)]
273
+ frags += [("class:tb.dim", f" · {_fmt_tokens(tokens)} tok · {_fmt_tokens(credits)} credits this session"),
260
274
  *q]
261
275
  # 0.3.37: the PERSISTENT live-session indicator (active_sessions.
262
276
  # session_indicator). Empty string = nothing running = say nothing, so an
@@ -846,6 +860,8 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None, steps_nav
846
860
  p.on_middle_paste = _middle_paste
847
861
  if getattr(p, "on_right_paste", None) is None:
848
862
  p.on_right_paste = _right_paste
863
+ if getattr(p, "on_line_click", None) is None:
864
+ p.on_line_click = _line_click
849
865
  return p
850
866
 
851
867
  def _sink_attr(name, default=None):
@@ -1174,6 +1190,40 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None, steps_nav
1174
1190
 
1175
1191
  get_app().create_background_task(_do_right_paste())
1176
1192
 
1193
+ def _line_click(abs_line: int) -> None:
1194
+ """webbee-code-click-to-expand-v1: a PLAIN click on a transcript line
1195
+ (see selection.py's `on_line_click` hook) asks \"what happened here?\"
1196
+ without touching the keyboard at all -- the exact same reveal /steps
1197
+ N (and Up/Down + Enter) already show, just one gesture shorter. A
1198
+ click resolves to a RECORD via the pane's own reflow-anchoring math
1199
+ (`_record_at_line`, already used for reflow + scroll-anchoring), then
1200
+ to a step number via the sink's `step_for_record` (set right after
1201
+ each tool_result print, see render.py) -- so a click on a banner,
1202
+ a progress line, or blank space (no step there) is a harmless no-op,
1203
+ never an error. Busy mid-turn is ALSO a no-op (steps_nav reflects the
1204
+ LAST finished turn only -- clicking during a live one would expand
1205
+ stale data), same gate Enter's own steps_nav path already uses.
1206
+ Compact by construction: dispatches through steps_nav[\"expand\"],
1207
+ the SAME `/steps N` -> step_detail bordered panel Enter already
1208
+ shows -- never a new UI surface, never full-screen, nothing to
1209
+ keep in sync twice."""
1210
+ if not steps_nav or _busy_live():
1211
+ return
1212
+ slot = _a()
1213
+ sink = slot.sink
1214
+ step_fn = getattr(sink, "step_for_record", None) if sink is not None else None
1215
+ if step_fn is None:
1216
+ return
1217
+ pane = _pane()
1218
+ record_idx = pane._record_at_line(abs_line)
1219
+ step_no = step_fn(record_idx)
1220
+ if not step_no:
1221
+ return
1222
+ event = get_app_or_none()
1223
+ if event is None:
1224
+ return
1225
+ event.create_background_task(steps_nav["expand"](step_no - 1, slot))
1226
+
1177
1227
  @kb.add("c-v")
1178
1228
  def _paste_key(event):
1179
1229
  # 0.3.34 (W3 Wave A): Ctrl-V pastes the OS clipboard. An IMAGE is read
@@ -1367,7 +1417,8 @@ async def run_session(*, slots, on_line, on_cycle, on_tier_cycle=None, steps_nav
1367
1417
  queued=len(slot.pending) + len(_sink_attr("remote_pending", [])),
1368
1418
  reconnecting=st.get("reconnecting", 0),
1369
1419
  width=_toolbar_fit_width(),
1370
- live=str((live or {}).get("text") or ""))
1420
+ live=str((live or {}).get("text") or ""),
1421
+ tier=getattr(slot, "model_tier", "") or "")
1371
1422
  else:
1372
1423
  # Home has no sink of its OWN, but it is the dock's summary
1373
1424
  # page: show the SESSION TOTAL across every open tab (0.3.36)
@@ -960,3 +960,55 @@ def test_tool_diff_caps_very_long_diffs_without_crashing():
960
960
  out = s.console.export_text()
961
961
  assert "line 0" in out
962
962
  assert "truncated" in out.lower()
963
+
964
+
965
+ # --------------------------------------------------------------------------
966
+ # webbee-code-click-to-expand-v1: `/steps N` (and Up/Down + Enter) already
967
+ # let you expand any step of the last turn -- a mouse click on the printed
968
+ # tool_result line itself is the natural next step. RichSink tags the exact
969
+ # printed RECORD as "step N" the instant it prints the line, so a later
970
+ # click can resolve record -> step with no new wire protocol at all.
971
+ # --------------------------------------------------------------------------
972
+ def test_step_for_record_maps_the_printed_line_to_its_step_number():
973
+ from webbee.output_pane import OutputPane
974
+ pane = OutputPane(width=80)
975
+ s = RichSink(console=pane.console, live_enabled=False,
976
+ input_fn=lambda p: "", clock=lambda: 0.0, on_output=pane.notify)
977
+ s.begin_turn()
978
+ s.tool_start("bash", {"command": "ls"})
979
+ s.tool_result("bash", True, "done") # step 1 -> some record index
980
+ s.tool_start("read_file", {"path": "a.py"})
981
+ s.tool_result("read_file", True, "42 lines") # step 2 -> a later record index
982
+
983
+ # Whatever record each line landed at, step_for_record must resolve it
984
+ # back to the RIGHT step -- never the other one, never None.
985
+ recs = s._step_records
986
+ assert len(recs) == 2
987
+ ordered = sorted(recs.items()) # [(rec_idx, step_no), ...]
988
+ assert ordered[0][1] == 1
989
+ assert ordered[1][1] == 2
990
+ for rec_idx, step_no in recs.items():
991
+ assert s.step_for_record(rec_idx) == step_no
992
+
993
+
994
+ def test_step_for_record_is_none_for_a_non_step_line():
995
+ s = _sink()
996
+ s.begin_turn()
997
+ s.tool_start("bash", {"command": "ls"})
998
+ s.tool_result("bash", True, "done")
999
+ assert s.step_for_record(999) is None # nothing ever printed there
1000
+
1001
+
1002
+ def test_step_records_reset_on_a_new_turn():
1003
+ from webbee.output_pane import OutputPane
1004
+ pane = OutputPane(width=80)
1005
+ s = RichSink(console=pane.console, live_enabled=False,
1006
+ input_fn=lambda p: "", clock=lambda: 0.0, on_output=pane.notify)
1007
+ s.begin_turn()
1008
+ s.tool_start("bash", {"command": "ls"})
1009
+ s.tool_result("bash", True, "done")
1010
+ assert len(s._step_records) == 1
1011
+ s.end_turn("ok")
1012
+ s.begin_turn() # a fresh turn, fresh numbering
1013
+ assert s._step_records == {}
1014
+ assert s._step_index == 0
@@ -5273,3 +5273,113 @@ def test_ticker_busy_includes_turn_flash_and_edge_drag():
5273
5273
  def flash(self):
5274
5274
  return ""
5275
5275
  assert _ticker_busy(_Slots(_Bare()), idle) is False
5276
+
5277
+
5278
+ # --------------------------------------------------------------------------
5279
+ # webbee-code-model-tier-toolbar-v1: the chosen model tier was only ever
5280
+ # visible via `/model` (no arg) or a Ctrl+B cycle flash -- now it sits right
5281
+ # next to `mode:` in the idle toolbar (always visible, not on-demand), and as
5282
+ # a short tag mid-turn too.
5283
+ # --------------------------------------------------------------------------
5284
+ def test_idle_toolbar_shows_the_model_tier_when_set():
5285
+ t = _txt(build_toolbar("default", 0, 0.0, tier="supersmart"))
5286
+ assert "model: supersmart" in t
5287
+
5288
+
5289
+ def test_idle_toolbar_says_nothing_about_tier_when_unset():
5290
+ t = _txt(build_toolbar("default", 0, 0.0, tier=""))
5291
+ assert "model:" not in t
5292
+
5293
+
5294
+ def test_busy_toolbar_shows_a_short_tier_tag():
5295
+ t = _txt(build_toolbar("default", 0, 0.0, busy=True, elapsed=1.0, tier="ultrasmart"))
5296
+ assert "ultrasmart" in t
5297
+
5298
+
5299
+ def test_busy_toolbar_says_nothing_about_tier_when_unset():
5300
+ t = _txt(build_toolbar("default", 0, 0.0, busy=True, elapsed=1.0, tier=""))
5301
+ assert "ultrasmart" not in t and "supersmart" not in t and "smart" not in t
5302
+
5303
+
5304
+ # --------------------------------------------------------------------------
5305
+ # webbee-code-click-to-expand-v1: a plain mouse click on a printed tool_result
5306
+ # line expands that step's detail card -- the SAME reveal /steps N (and
5307
+ # Up/Down + Enter) already give, one gesture shorter, never a new surface.
5308
+ # --------------------------------------------------------------------------
5309
+ def test_clicking_a_tool_result_line_expands_its_step():
5310
+ from prompt_toolkit.application import create_app_session, get_app
5311
+ from prompt_toolkit.data_structures import Point
5312
+ from prompt_toolkit.input import create_pipe_input
5313
+ from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType
5314
+ from prompt_toolkit.output import DummyOutput
5315
+
5316
+ from webbee import tui
5317
+ from webbee.render import RichSink
5318
+
5319
+ async def scenario():
5320
+ pane = tui.OutputPane(width=80)
5321
+ sink = RichSink(console=pane.console, live_enabled=False,
5322
+ input_fn=lambda p: "", clock=lambda: 0.0, on_output=pane.notify)
5323
+ sink.begin_turn()
5324
+ sink.tool_start("bash", {"command": "ls"})
5325
+ sink.tool_result("bash", True, "done") # this is step 1
5326
+ sink.end_turn("ok")
5327
+
5328
+ slots = mk_slots(pane=pane, sink=sink)
5329
+ expanded = []
5330
+
5331
+ async def _expand(idx, slot):
5332
+ expanded.append(idx)
5333
+
5334
+ async def on_line(text, slot=None): ...
5335
+
5336
+ with create_pipe_input() as pipe:
5337
+ with create_app_session(input=pipe, output=DummyOutput()):
5338
+ task = asyncio.create_task(tui.run_session(
5339
+ slots=slots, on_line=on_line, on_cycle=lambda: None,
5340
+ steps_nav={"expand": _expand, "count": lambda: 1}))
5341
+ await asyncio.sleep(0.05)
5342
+ # begin_turn prints a leading blank line (breathing room)
5343
+ # before the tool_result line itself -- so the step's line is
5344
+ # absolute line 1, not 0, in a freshly-booted pane at offset 0.
5345
+ pane.on_line_click(1)
5346
+ await asyncio.sleep(0.02)
5347
+ assert expanded == [0] # step 1 -> steps_nav index 0
5348
+ pipe.send_text("\x04")
5349
+ ok = await asyncio.wait_for(task, 5)
5350
+ assert ok is True
5351
+
5352
+ asyncio.run(scenario())
5353
+
5354
+
5355
+ def test_clicking_a_non_step_line_is_a_harmless_noop():
5356
+ from prompt_toolkit.application import create_app_session, get_app
5357
+ from prompt_toolkit.input import create_pipe_input
5358
+ from prompt_toolkit.output import DummyOutput
5359
+
5360
+ from webbee import tui
5361
+
5362
+ async def scenario():
5363
+ pane = tui.OutputPane(width=80)
5364
+ slots = mk_slots(pane=pane, sink=_idle_sink())
5365
+ expanded = []
5366
+
5367
+ async def _expand(idx, slot):
5368
+ expanded.append(idx)
5369
+
5370
+ async def on_line(text, slot=None): ...
5371
+
5372
+ with create_pipe_input() as pipe:
5373
+ with create_app_session(input=pipe, output=DummyOutput()):
5374
+ task = asyncio.create_task(tui.run_session(
5375
+ slots=slots, on_line=on_line, on_cycle=lambda: None,
5376
+ steps_nav={"expand": _expand, "count": lambda: 0}))
5377
+ await asyncio.sleep(0.05)
5378
+ pane.on_line_click(0) # nothing was ever printed/tagged here
5379
+ await asyncio.sleep(0.02)
5380
+ assert expanded == []
5381
+ pipe.send_text("\x04")
5382
+ ok = await asyncio.wait_for(task, 5)
5383
+ assert ok is True
5384
+
5385
+ asyncio.run(scenario())
@@ -1 +0,0 @@
1
- __version__ = "0.3.40"
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