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.
- checksums.yaml +4 -4
- data/.dockerignore +15 -0
- data/CHANGELOG.md +56 -0
- data/Dockerfile +56 -0
- data/agent.md +112 -0
- data/docs/design/bg-shell-pty-port.md +88 -0
- data/docs/design/bg-shell-review-refinements.md +65 -0
- data/docs/design/bg-shell-ux.md +130 -0
- data/docs/tools.md +3 -12
- data/lib/rubino/agent/iteration_budget.rb +13 -0
- data/lib/rubino/agent/loop.rb +43 -5
- data/lib/rubino/agent/prompts/build.txt +3 -5
- data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
- data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
- data/lib/rubino/agent/runner.rb +55 -12
- data/lib/rubino/agent/tool_executor.rb +1 -1
- data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
- data/lib/rubino/cli/chat_command.rb +119 -17
- data/lib/rubino/cli/commands.rb +5 -0
- data/lib/rubino/commands/handlers/agents.rb +27 -18
- data/lib/rubino/commands/handlers/status.rb +6 -3
- data/lib/rubino/config/configuration.rb +25 -8
- data/lib/rubino/config/defaults.rb +15 -13
- data/lib/rubino/context/prompt_assembler.rb +89 -1
- data/lib/rubino/context/summary_builder.rb +0 -22
- data/lib/rubino/interaction/events.rb +2 -2
- data/lib/rubino/interaction/lifecycle.rb +54 -20
- data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
- data/lib/rubino/security/redactor.rb +1 -1
- data/lib/rubino/session/message.rb +12 -0
- data/lib/rubino/tools/background_tasks.rb +107 -12
- data/lib/rubino/tools/base.rb +1 -1
- data/lib/rubino/tools/read_attachment_tool.rb +52 -54
- data/lib/rubino/tools/registry.rb +21 -72
- data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
- data/lib/rubino/tools/shell_input_tool.rb +1 -1
- data/lib/rubino/tools/shell_kill_tool.rb +4 -4
- data/lib/rubino/tools/shell_registry.rb +178 -38
- data/lib/rubino/tools/shell_tool.rb +45 -5
- data/lib/rubino/tools/task_result_tool.rb +4 -1
- data/lib/rubino/tools/task_tool.rb +74 -11
- data/lib/rubino/tools/vision_tool.rb +1 -1
- data/lib/rubino/ui/agent_menu.rb +8 -2
- data/lib/rubino/ui/api.rb +11 -0
- data/lib/rubino/ui/bottom_composer.rb +24 -11
- data/lib/rubino/ui/cli.rb +254 -15
- data/lib/rubino/ui/markdown_renderer.rb +4 -1
- data/lib/rubino/ui/stdout_proxy.rb +25 -10
- data/lib/rubino/ui/streaming_markdown.rb +67 -12
- data/lib/rubino/ui/subagent_cards.rb +8 -7
- data/lib/rubino/ui/tool_args_stream.rb +143 -0
- data/lib/rubino/update_check.rb +10 -2
- data/lib/rubino/version.rb +1 -1
- metadata +14 -6
- data/AGENTS.md +0 -97
- data/docs/agents.md +0 -216
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
- 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
|
|
1124
|
-
#
|
|
1125
|
-
#
|
|
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
|
-
#
|
|
1539
|
-
#
|
|
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
|
|
1588
|
-
#
|
|
1589
|
-
#
|
|
1590
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|