rubino-agent 0.5.2.1 → 0.5.2.2

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +56 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/design/bg-shell-pty-port.md +88 -0
  7. data/docs/design/bg-shell-review-refinements.md +65 -0
  8. data/docs/design/bg-shell-ux.md +130 -0
  9. data/docs/tools.md +3 -12
  10. data/lib/rubino/agent/iteration_budget.rb +13 -0
  11. data/lib/rubino/agent/loop.rb +43 -5
  12. data/lib/rubino/agent/prompts/build.txt +3 -5
  13. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  14. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  15. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  16. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  17. data/lib/rubino/agent/runner.rb +55 -12
  18. data/lib/rubino/agent/tool_executor.rb +1 -1
  19. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  20. data/lib/rubino/cli/chat_command.rb +119 -17
  21. data/lib/rubino/cli/commands.rb +5 -0
  22. data/lib/rubino/commands/handlers/agents.rb +27 -18
  23. data/lib/rubino/commands/handlers/status.rb +6 -3
  24. data/lib/rubino/config/configuration.rb +25 -8
  25. data/lib/rubino/config/defaults.rb +15 -13
  26. data/lib/rubino/context/prompt_assembler.rb +89 -1
  27. data/lib/rubino/context/summary_builder.rb +0 -22
  28. data/lib/rubino/interaction/events.rb +2 -2
  29. data/lib/rubino/interaction/lifecycle.rb +54 -20
  30. data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
  31. data/lib/rubino/security/redactor.rb +1 -1
  32. data/lib/rubino/session/message.rb +12 -0
  33. data/lib/rubino/tools/background_tasks.rb +107 -12
  34. data/lib/rubino/tools/base.rb +1 -1
  35. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  36. data/lib/rubino/tools/registry.rb +21 -72
  37. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  38. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  39. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  40. data/lib/rubino/tools/shell_registry.rb +178 -38
  41. data/lib/rubino/tools/shell_tool.rb +45 -5
  42. data/lib/rubino/tools/task_result_tool.rb +4 -1
  43. data/lib/rubino/tools/task_tool.rb +74 -11
  44. data/lib/rubino/tools/vision_tool.rb +1 -1
  45. data/lib/rubino/ui/agent_menu.rb +8 -2
  46. data/lib/rubino/ui/api.rb +11 -0
  47. data/lib/rubino/ui/bottom_composer.rb +24 -11
  48. data/lib/rubino/ui/cli.rb +254 -15
  49. data/lib/rubino/ui/markdown_renderer.rb +4 -1
  50. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  51. data/lib/rubino/ui/streaming_markdown.rb +67 -12
  52. data/lib/rubino/ui/subagent_cards.rb +8 -7
  53. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  54. data/lib/rubino/update_check.rb +10 -2
  55. data/lib/rubino/version.rb +1 -1
  56. metadata +14 -6
  57. data/AGENTS.md +0 -97
  58. data/docs/agents.md +0 -216
  59. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  60. data/lib/rubino/tools/summarize_file_tool.rb +0 -194
@@ -1120,9 +1120,10 @@ module Rubino
1120
1120
  end
1121
1121
  rows, caret_row, caret_col = visible_input_rows
1122
1122
  status = status_row
1123
- # Rows drawn BELOW the input, top→bottom: the subagent panel (one calm
1124
- # representation of the running children) then the status footer. A fresh
1125
- # array so appending the status never mutates the panel's own rows.
1123
+ # Rows drawn BELOW the input, top→bottom: the "⏳ queued:" type-ahead
1124
+ # indicators, then the subagent panel (one calm representation of the
1125
+ # running children), then the status footer. A fresh array so appending
1126
+ # the status never mutates the panel's own rows.
1126
1127
  below_rows = below_input_rows
1127
1128
  below_rows += [status] if status
1128
1129
 
@@ -1535,12 +1536,16 @@ module Rubino
1535
1536
  # [committed lines] ← only when +committed+ is given; scroll into
1536
1537
  # scrollback and stay there
1537
1538
  # [live rows] ← cards, completion menu, transient announce,
1538
- # "⏳ queued:" indicators, streamed partial
1539
- # redrawn in place every frame (do NOT scroll)
1539
+ # streamed partial redrawn in place every
1540
+ # frame (do NOT scroll)
1540
1541
  # [input block] ← "▍❯ " + buffer (the rail leads every row),
1541
1542
  # wrapped over up to @max_input_rows visual
1542
1543
  # rows; the cursor parks at the caret's
1543
1544
  # row/column
1545
+ # [queued + panel] ← "⏳ queued:" type-ahead indicators, then the
1546
+ # subagent panel/switcher — drawn BELOW the input
1547
+ # so a pending line sits next to the prompt it was
1548
+ # typed at, not up in the streamed-output area
1544
1549
  # [status bar] ← the dim model + context line (when set/fits)
1545
1550
  #
1546
1551
  # The +buffer+ is redrawn on every frame, so it can never be lost across
@@ -1584,18 +1589,26 @@ module Rubino
1584
1589
  # The live rows for this frame, top → bottom: the subagent cards; the
1585
1590
  # completion menu (a navigable list redrawn in place each frame, so it
1586
1591
  # never scrolls or smears); the TRANSIENT announcement (mode confirmation
1587
- # — one row, never committed, D2/D3); the EXPLICITLY-queued "⏳ queued:"
1588
- # indicators (removed, and the item committed as a normal message, when
1589
- # its turn runs); and the streamed partial (one row per line, capped, so
1590
- # a rolling markdown tail can't push the prompt off-screen, #127).
1592
+ # — one row, never committed, D2/D3); and the streamed partial (one row
1593
+ # per line, capped, so a rolling markdown tail can't push the prompt
1594
+ # off-screen, #127). The "⏳ queued:" indicators are NOT here they draw
1595
+ # BELOW the input (see #below_input_rows) so a pending line sits next to
1596
+ # the prompt it was typed at, not up in the streamed-output area.
1591
1597
  def live_rows
1592
1598
  rows = menu_rows
1593
1599
  rows << @announce unless @announce.empty?
1594
- rows.concat(@queued.rows)
1595
1600
  rows.concat(partial_rows)
1596
1601
  rows
1597
1602
  end
1598
1603
 
1604
+ # Rows drawn BELOW the input line, top → bottom: the "⏳ queued:" type-ahead
1605
+ # indicators (the user's pending lines — kept next to the input they were
1606
+ # typed at), then the subagent panel / switcher. The status footer is
1607
+ # appended by the caller (#draw_input).
1608
+ def below_input_rows
1609
+ @queued.rows + subagent_panel_rows
1610
+ end
1611
+
1599
1612
  # The single subagent panel, drawn BELOW the input (see Composer::SubagentPanel).
1600
1613
  #
1601
1614
  # While ATTACHED to a sub (#main_render_suppressed?) the parent's idle
@@ -1610,7 +1623,7 @@ module Rubino
1610
1623
  # - otherwise: a single COMPACT line listing the running subs with the
1611
1624
  # focused one marked, plus the "↓ to switch" hint, so the other subs
1612
1625
  # are visible at a glance and ↓ opens the picker to jump.
1613
- def below_input_rows
1626
+ def subagent_panel_rows
1614
1627
  attached = @focused_agent_id != :main
1615
1628
  return @agent_menu.rows(@cols) if attached && @agent_menu.open?
1616
1629
  return attached_switcher_rows if attached
data/lib/rubino/ui/cli.rb CHANGED
@@ -25,6 +25,10 @@ module Rubino
25
25
  # ◆ approval required
26
26
  # ┄ low-priority metadata
27
27
  class CLI < PrinterBase
28
+ # The REPL host installs a per-turn render lambda here so the `ctx` gauge
29
+ # climbs live during a turn (#608e); see @live_status_provider.
30
+ attr_writer :live_status_provider
31
+
28
32
  # Page size tty-prompt paginates a select menu at (its Paginator's
29
33
  # DEFAULT_PAGE_SIZE) — the count of menu rows visible at once, used to wipe
30
34
  # a cancelled picker's frame (#219).
@@ -95,6 +99,11 @@ module Rubino
95
99
  @turn_started_at = nil
96
100
  @turn_tool_count = 0
97
101
  @turn_tok_chars = 0
102
+ # Optional cheap render lambda (set by the REPL host per turn) that maps a
103
+ # LIVE in-flight token estimate to a status-bar line, so the `ctx ~Xk/…`
104
+ # gauge climbs DURING a turn instead of freezing until it ends (#608e).
105
+ # nil off the interactive REPL (one-shot/API/tests) — the bar is unchanged.
106
+ @live_status_provider = nil
98
107
  @thinking_started_at = nil
99
108
  @reasoning_buffer = +""
100
109
  # :full-mode LIVE reasoning stream state. @reasoning_md splits the streamed
@@ -113,6 +122,19 @@ module Rubino
113
122
  # the footer (the frozen tail stays above the prompt). Touched only under
114
123
  # @status_mutex.
115
124
  @last_stream_at = nil
125
+ # Transient live-region repaint COALESCING (#freeze): a fast model streams
126
+ # deltas hundreds of times a second and each one repainted the live tail
127
+ # in full (clear + redraw, wrapped in synchronized-output) — rubino's CPU
128
+ # stays low but the volume of cursor-churn ANSI floods the terminal, which
129
+ # is what reads as a "freeze" (worst in the :full reasoning aside). We cap
130
+ # transient repaints to ~LIVE_FRAME_HZ: a too-soon frame is stored as
131
+ # @live_pending and the LATEST one is flushed by the ticker thread (≤ one
132
+ # STATUS_TICK later) or the next delta. Clearing frames bypass the cap so
133
+ # the tail tears down instantly on commit/turn-end. Guarded by its own
134
+ # mutex (paint comes from the delta thread AND the ticker thread).
135
+ @live_mutex = Mutex.new
136
+ @live_pending = nil # latest coalesced frame awaiting flush, or nil
137
+ @live_painted_at = nil # monotonic time of the last actual emit
116
138
  # The last retained reasoning block (committed/collapsed), revealable via
117
139
  # ctrl-o even after the answer has streamed. Reset per turn.
118
140
  @last_reasoning = nil
@@ -499,10 +521,18 @@ module Rubino
499
521
  # tool-iteration ceiling and is asking for more. Reuses the same unified
500
522
  # #approval_menu component, but the vocabulary is GRANT/DENY budget — there
501
523
  # is no "always" (no command to allowlist; budget is a one-shot grant). The
502
- # caller maps :grant→continue and :deny/:summarize→summarize.
524
+ # caller maps :grant→continue, :later→snooze (leave parked), :summarize→summarize.
525
+ #
526
+ # "Decide later" sits BETWEEN grant and summarize on purpose (#586): the
527
+ # default highlight is the safe "Grant", and a stray ↓+Enter — the exact
528
+ # gesture used to open+attach in the subagent picker — lands on the
529
+ # non-destructive "Decide later", never on "Summarize now". The destructive
530
+ # option needs a deliberate ↓↓, so an auto-popped budget modal can't
531
+ # force-summarize a child by a mis-aimed picker keystroke.
503
532
  def subagent_budget_choice
504
533
  approval_menu("grant more budget?", [
505
534
  ["Grant more iterations", :grant],
535
+ ["Decide later", :later],
506
536
  ["Summarize now", :summarize]
507
537
  ])
508
538
  end
@@ -943,6 +973,21 @@ module Rubino
943
973
  nil
944
974
  end
945
975
 
976
+ # Repaints the persistent status bar with the live in-flight token estimate
977
+ # so `ctx ~Xk/…` climbs during a turn (#608e). The provider (installed by
978
+ # the REPL host for the duration of a turn) maps the estimate → a bar line;
979
+ # @turn_tok_chars/4 is the SAME chars/4 estimate the facet's `~N tok` uses.
980
+ # No-op off the interactive REPL (provider nil) or between turns. Cosmetic:
981
+ # a paint failure must never disturb the turn.
982
+ def refresh_live_ctx_bar
983
+ return unless @turn_active && @live_status_provider
984
+
985
+ line = @live_status_provider.call(@turn_tok_chars / 4)
986
+ BottomComposer.current&.set_status(line) if line
987
+ rescue StandardError
988
+ nil
989
+ end
990
+
946
991
  def subagent_cards
947
992
  @subagent_cards ||= SubagentCards.new(pastel: @pastel)
948
993
  end
@@ -1055,8 +1100,11 @@ module Rubino
1055
1100
  # Each rendered line is rubino-built with its own per-token SGR, off a
1056
1101
  # source already sanitize_terminal'd in #render_markdown_block before
1057
1102
  # parse. PATH 2 (#emit_styled) keeps that SGR and strips any residual
1058
- # danger byte.
1059
- render_markdown_block(text).each { |line| emit_styled("#{MD_MARGIN}#{line}") }
1103
+ # danger byte. Emit the whole block in ONE write (lines joined by "\n",
1104
+ # which #emit_styled preserves) so it commits as a SINGLE frame instead of
1105
+ # one full live-region repaint per line — the #FREEZE storm on long blocks.
1106
+ lines = render_markdown_block(text).map { |line| "#{MD_MARGIN}#{line}" }
1107
+ emit_styled(lines.join("\n")) unless lines.empty?
1060
1108
  end
1061
1109
 
1062
1110
  # A markdown string -> Array<String> of ANSI-styled lines (no indent).
@@ -1134,6 +1182,23 @@ module Rubino
1134
1182
  text = chunk[:text].to_s
1135
1183
  return if text.empty?
1136
1184
 
1185
+ # A tool call is starting to stream (#608). The NAME arrives once on
1186
+ # :tool_preparing — open the tool card NOW, at the start of the call, so
1187
+ # the timeline shows the tool being invoked (NOT a "preparing" label in
1188
+ # the footer: the footer stays a pure token meter). The argument FRAGMENTS
1189
+ # then arrive on :tool_args — count them toward the live token meter (so a
1190
+ # long `write` shows the count climbing instead of freezing) and stream
1191
+ # them into the card as the live params. Neither enters the answer buffer.
1192
+ if type == :tool_preparing
1193
+ tool_params_begin(text)
1194
+ return
1195
+ end
1196
+ if type == :tool_args
1197
+ @turn_tok_chars += text.length if @turn_active
1198
+ tool_params_feed(text)
1199
+ return
1200
+ end
1201
+
1137
1202
  @turn_tok_chars += text.length if @turn_active
1138
1203
 
1139
1204
  # Reasoning deltas are handled by #handle_thinking_delta: ALWAYS buffered
@@ -1254,6 +1319,9 @@ module Rubino
1254
1319
  @turn_started_at = monotonic_now
1255
1320
  @turn_tool_count = 0
1256
1321
  @turn_tok_chars = 0
1322
+ # Streaming-params card state (#608): no tool call is mid-stream yet.
1323
+ @tool_params_open = nil
1324
+ @tool_params_stream = nil
1257
1325
  # Fresh turn: silence clock unarmed (#21).
1258
1326
  @status_mutex.synchronize { @last_stream_at = nil }
1259
1327
  # Per-turn tally of plain "Approve once" choices by tool — drives the
@@ -1370,7 +1438,72 @@ module Rubino
1370
1438
  # repaints in place via CR + clear-line;
1371
1439
  # * a pipe hosts nothing — raw escapes must not leak into the cooked
1372
1440
  # output (#56).
1441
+ # Transient live repaints are capped to this many per second (coalesced).
1442
+ # ~20 fps is smooth to the eye but turns a fast model's hundreds-of-deltas-
1443
+ # per-second repaint storm into at most 20 frames/s of terminal output —
1444
+ # the fix for the streaming "freeze" (the terminal, not rubino, was the
1445
+ # bottleneck). The trailing frame is flushed by the ticker (#flush_pending_live).
1446
+ LIVE_FRAME_HZ = 20.0
1447
+ LIVE_FRAME_INTERVAL = 1.0 / LIVE_FRAME_HZ
1448
+
1449
+ # Coalescing front door for the transient live region (tail / partial table
1450
+ # / reasoning aside). A CLEARING frame (nil/empty — a commit/teardown) is
1451
+ # painted immediately and cancels any pending frame, so the row never
1452
+ # lingers. A content frame paints immediately if at least LIVE_FRAME_INTERVAL
1453
+ # has passed since the last emit; otherwise it is stored as @live_pending and
1454
+ # the LATEST stored frame is flushed by the ticker thread (#flush_pending_live)
1455
+ # or superseded by the next delta — bounding repaints regardless of token rate.
1373
1456
  def paint_live(frame)
1457
+ emit = false
1458
+ to_emit = nil
1459
+ @live_mutex.synchronize do
1460
+ clearing = frame.nil? || frame.to_s.empty?
1461
+ now = monotonic_now
1462
+ # Coalesce ONLY a content frame that arrives within the interval, and
1463
+ # ONLY while the ticker is running to flush the trailing one. A clearing
1464
+ # (teardown) frame is never withheld and RESETS the window so the next
1465
+ # content frame — often painted in the SAME delta right after a tail
1466
+ # clear — emits at once.
1467
+ if !clearing && throttle_live? && @live_painted_at &&
1468
+ (now - @live_painted_at) < LIVE_FRAME_INTERVAL
1469
+ @live_pending = frame # ticker/next delta paints the latest
1470
+ else
1471
+ @live_pending = nil
1472
+ @live_painted_at = clearing ? nil : now
1473
+ to_emit = frame
1474
+ emit = true
1475
+ end
1476
+ end
1477
+ emit_live_frame(to_emit) if emit
1478
+ end
1479
+
1480
+ # Coalesce live repaints only while the turn ticker thread is alive — it
1481
+ # provides the trailing-edge flush (#flush_pending_live). Outside a turn
1482
+ # (tests, the cooked /probe wait, plain non-TTY paths) there is no flusher,
1483
+ # so paint every frame immediately to preserve exact legacy behavior.
1484
+ def throttle_live?
1485
+ @thinking_thread&.alive? || false
1486
+ end
1487
+
1488
+ # Paint the latest coalesced frame if the interval has elapsed. Called by the
1489
+ # ticker thread every STATUS_TICK so a burst that stops mid-stream still
1490
+ # settles to its final tail within ~one tick (no stale rows during a pause).
1491
+ # The actual terminal write happens OUTSIDE @live_mutex (its own seam mutex),
1492
+ # mirroring #refresh_live_cards' un-nested-locks discipline.
1493
+ def flush_pending_live
1494
+ emit = nil
1495
+ @live_mutex.synchronize do
1496
+ return if @live_pending.nil?
1497
+ return if @live_painted_at && (monotonic_now - @live_painted_at) < LIVE_FRAME_INTERVAL
1498
+
1499
+ emit = @live_pending
1500
+ @live_pending = nil
1501
+ @live_painted_at = monotonic_now
1502
+ end
1503
+ emit_live_frame(emit)
1504
+ end
1505
+
1506
+ def emit_live_frame(frame)
1374
1507
  # The $stdout proxy belongs to the MAIN turn (the main thread swaps it in);
1375
1508
  # only the main CLI may write through it. A background subagent's CLI runs
1376
1509
  # on its own thread where the GLOBAL $stdout is the main's proxy (or real
@@ -1496,6 +1629,17 @@ module Rubino
1496
1629
  # contract #confirm uses before the approval card.
1497
1630
  def tool_started(name, arguments: nil, at: nil, call_id: nil)
1498
1631
  record_subagent_tool_started(name, arguments)
1632
+ # Streaming path: the card was already opened at the START of the call
1633
+ # (#tool_params_begin) and its params streamed live. Commit the held
1634
+ # params tail and DON'T redraw the header — just relabel the status row.
1635
+ if @tool_params_open == name
1636
+ tool_params_flush
1637
+ return delegation_started(arguments, call_id) if name == "task"
1638
+
1639
+ status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active
1640
+ return
1641
+ end
1642
+
1499
1643
  finalize_stream
1500
1644
  return delegation_started(arguments, call_id) if name == "task"
1501
1645
 
@@ -1507,6 +1651,67 @@ module Rubino
1507
1651
  status_show(name, phase: :tool, hint: status_hint(arguments)) if @turn_active
1508
1652
  end
1509
1653
 
1654
+ # The streaming tool call just started (its NAME arrived, #608). Open the
1655
+ # tool card at the START — before the arguments finish — so the user sees
1656
+ # the invocation and its params stream in, rather than a frozen footer.
1657
+ # #tool_started reconciles against @tool_params_open so the `● name` header
1658
+ # is drawn exactly once. No-op off-turn or if this call is already open.
1659
+ def tool_params_begin(name)
1660
+ return unless @turn_active
1661
+ return if @tool_params_open == name
1662
+ # `task` (subagent delegation) has a bespoke card (#delegation_started);
1663
+ # don't pre-open a generic `● task` that would clash with it.
1664
+ return if name == "task"
1665
+
1666
+ # Close any open answer block first (the normal #tool_started path does
1667
+ # this via #finalize_stream) so the card never opens under a live tail.
1668
+ finalize_stream
1669
+ activity_started(name)
1670
+ @tool_params_open = name
1671
+ @tool_params_stream = ToolArgsStream.new
1672
+ # Keep the animated facet ALIVE for the whole argument stream (#608d).
1673
+ # A large `write` streams its content for MINUTES past the 30-line preview
1674
+ # cap; without a live status the screen sits silent (measured: ~38s of
1675
+ # dead UI on a 100-line file) and reads as a freeze. status_text already
1676
+ # renders the "writing" phase with the ~N-tok meter climbing — it just
1677
+ # needs the facet visible. Mirrors the SAME facet+tool_chunk pairing the
1678
+ # tool-EXECUTION phase (#tool_started) already uses, so the params scroll
1679
+ # above an animated `tool · Ns · ~N tok` footer instead of nothing.
1680
+ status_show(name, phase: :tool) if @turn_active
1681
+ end
1682
+
1683
+ # An argument fragment of the in-flight call: decode the JSON arg VALUES and
1684
+ # render the complete lines unlocked so far under the card (the partial last
1685
+ # line is held by the decoder until its newline lands), reusing the
1686
+ # head-N-lines preview collapse. Token counting happens in #stream.
1687
+ def tool_params_feed(fragment)
1688
+ return unless @tool_params_stream
1689
+
1690
+ lines = @tool_params_stream.feed(fragment)
1691
+ # Stream the params IN FULL (no 30-line preview cap): a `write`/`edit`'s
1692
+ # content is exactly what the user wants to watch land, line by line, as
1693
+ # the model generates it (#608d). The cap is for collapsing a finished
1694
+ # tool's OUTPUT in the transcript — not for hiding the file being authored
1695
+ # right now. Streaming every line also keeps the screen alive the whole
1696
+ # time, so a long write never sits silent.
1697
+ tool_chunk(@tool_params_open, lines, full: true) unless lines.empty?
1698
+ end
1699
+
1700
+ # Flush the held params tail and close the streaming-params state. Called
1701
+ # when the tool actually starts (args complete) and at turn end so the last
1702
+ # line is never lost.
1703
+ def tool_params_flush
1704
+ stream = @tool_params_stream
1705
+ @tool_params_stream = nil
1706
+ @tool_params_open = nil
1707
+ return unless stream
1708
+
1709
+ tail = stream.flush
1710
+ # Full (uncapped) to match the live params stream (#608d) — the final
1711
+ # partial line is part of the content the user is watching land.
1712
+ tool_chunk(@activity_name, "#{tail}\n", full: true) unless tail.empty?
1713
+ end
1714
+
1510
1715
  # DISPLAY-ONLY collapse (P2): the transcript shows the head few lines of
1511
1716
  # a tool's output plus a `… +N lines (full output → context)` marker —
1512
1717
  # the FULL output still goes to the model/context unchanged. Governed by
@@ -1536,7 +1741,7 @@ module Rubino
1536
1741
  # accumulated across chunks. Lines past the preview budget are counted
1537
1742
  # silently; #activity_finished flushes the `… +N lines` marker right
1538
1743
  # before the close row.
1539
- def tool_chunk(_name, chunk, kind: :plain)
1744
+ def tool_chunk(_name, chunk, kind: :plain, full: false)
1540
1745
  record_subagent_tool_output(chunk)
1541
1746
  return if chunk.nil? || chunk.to_s.empty?
1542
1747
 
@@ -1549,9 +1754,12 @@ module Rubino
1549
1754
  return
1550
1755
  end
1551
1756
 
1757
+ # +full+ (live `write`/`edit` params, #608d) shows EVERY line — the user
1758
+ # is watching the file being authored, not reviewing a finished dump.
1552
1759
  limit = tool_preview_limit
1553
- unless limit.positive?
1760
+ if full || !limit.positive?
1554
1761
  write_body_lines(chunk.to_s) { |chomped| @pastel.dim(chomped) }
1762
+ @last_block = :tool
1555
1763
  return
1556
1764
  end
1557
1765
 
@@ -1740,8 +1948,7 @@ module Rubino
1740
1948
  # Short human labels for the post-turn inline jobs the status row tracks.
1741
1949
  JOB_STATUS_LABELS = {
1742
1950
  "ExtractMemoryJob" => "memory",
1743
- "DistillSkillJob" => "skills",
1744
- "SummarizeSessionJob" => "summary"
1951
+ "DistillSkillJob" => "skills"
1745
1952
  }.freeze
1746
1953
 
1747
1954
  def job_enqueued(type)
@@ -2028,6 +2235,7 @@ module Rubino
2028
2235
  # Width left for body text after the 2-space margin; a small floor keeps
2029
2236
  # a very narrow terminal from looping on a 1-col field.
2030
2237
  budget = [terminal_cols - 1 - BODY_MARGIN.length, 4].max
2238
+ rows = []
2031
2239
  Util::Output.sanitize_terminal(text).each_line do |line|
2032
2240
  chomped = line.chomp
2033
2241
  # HARD-WRAP a long no-break token inside the output body instead of
@@ -2038,11 +2246,15 @@ module Rubino
2038
2246
  # continuation lines hang-indent under the first.
2039
2247
  wrap_tail_row(chomped, budget).each do |row|
2040
2248
  rendered = style ? style.call(row) : row
2041
- # +text+ was sanitize_terminal'd above; the style block adds rubino's
2042
- # own SGR → PATH 2 (#emit_styled) keeps that colour, strips danger.
2043
- emit_styled("#{BODY_MARGIN}#{rendered}")
2249
+ rows << "#{BODY_MARGIN}#{rendered}"
2044
2250
  end
2045
2251
  end
2252
+ # +text+ was sanitize_terminal'd above; the style block adds rubino's own
2253
+ # SGR → PATH 2 (#emit_styled) keeps that colour, strips danger. Emit the
2254
+ # whole captured body in ONE write so a large tool output (a multi-hundred-
2255
+ # line `read`) commits as a SINGLE frame, not one full repaint per line
2256
+ # — the storm that wedged tmux on "read many files" (#FREEZE).
2257
+ emit_styled(rows.join("\n")) unless rows.empty?
2046
2258
  end
2047
2259
 
2048
2260
  # COMPOSE-TIME span defang: neutralizes an UNTRUSTED span (a tool metric, a
@@ -2125,7 +2337,7 @@ module Rubino
2125
2337
  end
2126
2338
  end
2127
2339
 
2128
- # display.live_markdown — opt-in formatted live region (default false).
2340
+ # display.live_markdown — formatted live region (default true, defaults.rb).
2129
2341
  def live_markdown?
2130
2342
  Rubino.configuration.display_live_markdown?
2131
2343
  end
@@ -2151,7 +2363,12 @@ module Rubino
2151
2363
  # the styled rows carry only rubino's own SGR — they must NOT pass through
2152
2364
  # #margined_tail again (that would caret-escape our own escapes).
2153
2365
  def live_markdown_lines(stream_md)
2154
- raw = stream_md.tail
2366
+ # #live_source, not #tail: render only a bounded trailing window of the
2367
+ # in-flight block (with the fence opener preserved), not the WHOLE
2368
+ # growing block on every delta — re-parsing the full block per token was
2369
+ # O(N²) and froze the stream on long ``` code/file dumps. The visible
2370
+ # last-LIVE_TAIL_ROWS rows are unchanged.
2371
+ raw = stream_md.live_source
2155
2372
  return [] if raw.nil? || raw.empty?
2156
2373
 
2157
2374
  repaired = MarkdownRepair.close_open_spans(raw, fence: stream_md.open_fence)
@@ -2572,12 +2789,30 @@ module Rubino
2572
2789
  # Outside @status_mutex (set_subagent_cards takes the composer's own
2573
2790
  # render mutex; keeping the locks un-nested avoids any ordering risk).
2574
2791
  refresh_live_cards if (i % 10).zero?
2792
+ # Repaint the persistent `ctx ~Xk/…` bar ~1/s with the live in-flight
2793
+ # token estimate so it climbs during the turn (#608e) instead of
2794
+ # sitting frozen until the turn ends. ~1 Hz (not per delta) keeps the
2795
+ # full bar redraw cheap. Outside @status_mutex (set_status takes the
2796
+ # composer's own render mutex; keeps the locks un-nested).
2797
+ refresh_live_ctx_bar if (i % 10).zero?
2798
+ # Trailing-edge flush for the coalesced live region: if a burst of
2799
+ # deltas left a frame pending (repaints capped to LIVE_FRAME_HZ), paint
2800
+ # the latest one now so a stream that paused never shows a stale tail.
2801
+ # Outside @status_mutex (paint takes the composer's own render mutex).
2802
+ flush_pending_live
2575
2803
  i += 1
2576
2804
  sleep STATUS_TICK
2577
2805
  end
2578
- rescue StandardError
2806
+ rescue StandardError => e
2579
2807
  # The animation is cosmetic — a repaint failure must never break the
2580
- # turn. Stop quietly.
2808
+ # turn. But NEVER swallow it silently: a programming slip here (e.g. a
2809
+ # private-method call) used to kill this thread on tick one, freezing the
2810
+ # spinner for the whole turn while the wait looked like a hang (#FREEZE).
2811
+ # Log it (to the file logger, not the raw-mode $stdout) so it is
2812
+ # diagnosable instead of invisible.
2813
+ Rubino.logger.error(event: "ui.status_ticker.died",
2814
+ error: "#{e.class}: #{e.message}",
2815
+ at: e.backtrace&.first)
2581
2816
  end
2582
2817
  end
2583
2818
 
@@ -2625,10 +2860,14 @@ module Rubino
2625
2860
  if s[:phase] == :thinking
2626
2861
  parts << "#{(now - (@turn_started_at || s[:phase_started_at])).to_i}s"
2627
2862
  parts << "#{@turn_tool_count} tool#{"s" if @turn_tool_count != 1}" if @turn_tool_count.positive?
2628
- parts << "~#{format_status_tokens(@turn_tok_chars / 4)} tok" if @turn_tok_chars >= 4
2629
2863
  else
2630
2864
  parts << "#{(now - s[:phase_started_at]).to_i}s"
2631
2865
  end
2866
+ # The live token meter rides EVERY phase (#608), not just thinking: while a
2867
+ # long `write`'s params stream, the facet resurfaces labelled "writing" and
2868
+ # must still show the count climbing — that's the whole point of counting
2869
+ # the tool-arg fragments. Estimate, hence the leading ~.
2870
+ parts << "~#{format_status_tokens(@turn_tok_chars / 4)} tok" if @turn_tok_chars >= 4
2632
2871
  text = parts.join(" · ")
2633
2872
  budget = [terminal_cols, 80].min - FACET_TRACK_CELLS - 2
2634
2873
  text.length > budget ? "#{text[0, budget - 1]}…" : text
@@ -247,7 +247,10 @@ module Rubino
247
247
  # raw "##" would otherwise show through verbatim. A leading bar gives a
248
248
  # subtle visual cue without leaking markdown syntax.
249
249
  body = inline_tokens(el.children, style)
250
- wrap_lines(tokens_to_lines([["▌ ", style]] + body), hang: 2)
250
+ rendered = wrap_lines(tokens_to_lines([["▌ ", style]] + body), hang: 2)
251
+ # Big headings (H1/H2) get breathing room above and below so they
252
+ # visually break the prose instead of sitting glued to surrounding text.
253
+ level <= 2 ? [[]] + rendered + [[]] : rendered
251
254
  end
252
255
 
253
256
  # Raw (un-wrapped) paragraph lines. Wrapping is applied by the CALLER
@@ -133,19 +133,34 @@ module Rubino
133
133
  return if str.nil? || str.empty?
134
134
 
135
135
  @partial << str
136
- commit_complete_lines
137
- render_partial
136
+ committed = commit_complete_lines
137
+ # print_above ALREADY cleared the partial row and repainted the prompt for
138
+ # each line it committed; when nothing is left over, a second set_partial("")
139
+ # would repaint the very same empty row — the double-frame-per-line that
140
+ # helped wedge tmux during reasoning/tool streaming (#FREEZE). Only render
141
+ # the partial when there's a non-empty remainder, or when no line committed
142
+ # (a partial growing in place still needs its live row redrawn).
143
+ render_partial unless committed && @partial.empty?
138
144
  end
139
145
 
140
146
  def commit_complete_lines
141
- while (idx = @partial.index("\n"))
142
- line = @partial[0...idx]
143
- @partial = @partial[(idx + 1)..] || +""
144
- # A committed line is a finished row; embedded "\r" (e.g. the CLI's
145
- # in-place clear before a streamed chunk) is preserved so print_above's
146
- # clear-line semantics still apply.
147
- @composer.print_above(line)
148
- end
147
+ nl = @partial.rindex("\n")
148
+ return false unless nl
149
+
150
+ # Commit the WHOLE run of finished lines in ONE frame. print_above
151
+ # LiveRegion#commit splits the embedded "\n" into CRLF-terminated rows
152
+ # that each scroll into scrollback, so a 500-line tool result (a big
153
+ # `read`, a long rendered markdown block) lands with a SINGLE live-region
154
+ # clear+redraw instead of one full repaint PER line — the storm that
155
+ # wedged tmux on "read many files / write file" (#FREEZE). Embedded "\r"
156
+ # (the CLI's in-place clear before a streamed chunk) is preserved.
157
+ # Strip ONLY the final "\n" (not a preceding "\r"): the per-line path this
158
+ # replaces excluded just the newline and kept any in-place-clear "\r", and
159
+ # LiveRegion#commit re-adds the row terminators.
160
+ block = @partial[0...nl]
161
+ @partial = @partial[(nl + 1)..] || +""
162
+ @composer.print_above(block)
163
+ true
149
164
  end
150
165
 
151
166
  # Show the in-progress (un-newlined) line above the composer without