Skip to content

Commit 59de1ef

Browse files
Kasper Jungeclaude
authored andcommitted
feat: improve peek UX with scrollbar, thinking traces, and clearer hints
Add scrollbar to full-screen peek, show thinking blocks and full text in the activity feed, display full tool args for Agent/ToolSearch, show context token count as "ctx N", and add persistent Shift+P hint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0636f32 commit 59de1ef

2 files changed

Lines changed: 115 additions & 65 deletions

File tree

src/ralphify/_console_emitter.py

Lines changed: 103 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,30 @@ def _shorten_path(path: str, max_len: int = 48) -> str:
144144
return "…/" + tail[-(max_len - 2) :]
145145

146146

147+
def _format_params(tool_input: dict[str, Any], keys: list[str]) -> str:
148+
"""Format specified tool parameters as ``key: value`` pairs."""
149+
parts = []
150+
for key in keys:
151+
val = tool_input.get(key)
152+
if val is not None:
153+
parts.append(f"{key}: {val}")
154+
return " · ".join(parts) if parts else ""
155+
156+
147157
_TOOL_ARG_EXTRACTORS: dict[str, Callable[[dict[str, Any]], str]] = {
148158
"Read": lambda i: _shorten_path(i.get("file_path", "")),
149159
"Write": lambda i: _shorten_path(i.get("file_path", "")),
150160
"Edit": lambda i: _shorten_path(i.get("file_path", "")),
151161
"MultiEdit": lambda i: _shorten_path(i.get("file_path", "")),
152162
"Glob": lambda i: i.get("pattern", ""),
153163
"Grep": lambda i: i.get("pattern", ""),
154-
"Bash": lambda i: _truncate(i.get("command", ""), 80),
155-
"Task": lambda i: _truncate(i.get("description", i.get("prompt", "")), 80),
164+
"Bash": lambda i: i.get("command", ""),
165+
"Task": lambda i: _format_params(i, ["description", "prompt"]),
156166
"WebFetch": lambda i: i.get("url", ""),
157167
"WebSearch": lambda i: i.get("query", ""),
158168
"TodoWrite": lambda i: f"{len(i.get('todos', []))} todos",
169+
"Agent": lambda i: _format_params(i, ["description", "prompt"]),
170+
"ToolSearch": lambda i: _format_params(i, ["query", "max_results"]),
159171
}
160172

161173

@@ -300,41 +312,31 @@ def set_peek_visible(self, visible: bool) -> None:
300312

301313
# ── Stream-json processing ───────────────────────────────────────
302314

303-
def apply(self, raw: dict[str, Any]) -> str | None:
315+
def apply(self, raw: dict[str, Any]) -> None:
304316
"""Update panel state from a parsed stream-json dict.
305317
306-
Returns the scroll-line markup string (or ``None``). The line is
307-
also appended to the internal scroll buffer so it renders inside
308-
the Live region.
318+
Each handler appends its own scroll lines to the buffer so that
319+
multi-line blocks (thinking, long text) can produce several rows.
309320
"""
310321
event_type = raw.get("type")
311322

312323
if event_type == "system" and raw.get("subtype") == "init":
313324
self._model = raw.get("model", "")
314-
return None
315-
316-
scroll_line: str | None = None
317-
318-
if event_type == "assistant":
319-
scroll_line = self._apply_assistant(raw)
325+
elif event_type == "assistant":
326+
self._apply_assistant(raw)
320327
elif event_type == "user":
321-
scroll_line = self._apply_user(raw)
328+
self._apply_user(raw)
322329
elif event_type == "rate_limit_event":
323330
info = raw.get("rate_limit_info", {})
324331
status = info.get("status", "")
325332
resets = info.get("resetsAt", "")
326-
scroll_line = (
333+
self.add_scroll_line(
327334
f"[bold {_brand.PEACH}]⚠ rate limit:[/]"
328335
f" [dim]{escape_markup(str(status))}"
329336
f", resets {escape_markup(str(resets))}[/]"
330337
)
331338

332-
if scroll_line is not None:
333-
self.add_scroll_line(scroll_line)
334-
335-
return scroll_line
336-
337-
def _apply_assistant(self, raw: dict[str, Any]) -> str | None:
339+
def _apply_assistant(self, raw: dict[str, Any]) -> None:
338340
msg = raw.get("message", {})
339341

340342
# Update token counts from usage
@@ -348,21 +350,30 @@ def _apply_assistant(self, raw: dict[str, Any]) -> str | None:
348350

349351
content = msg.get("content", [])
350352
if not isinstance(content, list):
351-
return None
353+
return
352354

353-
scroll_line: str | None = None
354355
for block in content:
355356
if not isinstance(block, dict):
356357
continue
357358
block_type = block.get("type")
358359

359-
if block_type == "text":
360+
if block_type == "thinking":
361+
text = block.get("thinking", "")
362+
if text:
363+
for tline in text.split("\n"):
364+
self.add_scroll_line(
365+
f"[dim italic]{escape_markup(tline)}[/]"
366+
)
367+
368+
elif block_type == "text":
360369
text = block.get("text", "")
361-
preview = _truncate(text.replace("\n", " "), 100)
362-
if preview:
363-
scroll_line = (
364-
f"[italic {_brand.LAVENDER}]“{escape_markup(preview)}”[/]"
365-
)
370+
if text:
371+
for tline in text.split("\n"):
372+
if tline.strip():
373+
self.add_scroll_line(
374+
f"[italic {_brand.LAVENDER}]"
375+
f"\"{escape_markup(tline)}\"[/]"
376+
)
366377

367378
elif block_type == "tool_use":
368379
name = block.get("name", "?")
@@ -387,40 +398,40 @@ def _apply_assistant(self, raw: dict[str, Any]) -> str | None:
387398
else:
388399
name_col = f"{name} "
389400
if arg:
390-
scroll_line = (
401+
self.add_scroll_line(
391402
f"[bold {color}]{escape_markup(name_col)}[/]"
392403
f"[dim]{escape_markup(arg)}[/]"
393404
)
394405
else:
395-
scroll_line = f"[bold {color}]{escape_markup(name)}[/]"
396-
397-
return scroll_line
406+
self.add_scroll_line(
407+
f"[bold {color}]{escape_markup(name)}[/]"
408+
)
398409

399-
def _apply_user(self, raw: dict[str, Any]) -> str | None:
410+
def _apply_user(self, raw: dict[str, Any]) -> None:
400411
msg = raw.get("message", {})
401412
content = msg.get("content", [])
402413
if not isinstance(content, list):
403-
return None
414+
return
404415
for block in content:
405416
if not isinstance(block, dict):
406417
continue
407418
if block.get("type") == "tool_result" and block.get("is_error"):
408419
snippet = _truncate(str(block.get("content", "")), 100)
409-
return (
420+
self.add_scroll_line(
410421
f"[bold {_brand.DEEP_ORANGE}]{_ICON_FAILURE} tool error:[/]"
411422
f" [dim]{escape_markup(snippet)}[/]"
412423
)
413-
return None
424+
return
414425

415426
def _format_tokens(self) -> str:
416-
"""Format token counts as compact ↑in ↓out string."""
427+
"""Format token counts as compact ctx/out string."""
417428
parts: list[str] = []
418429
total_in = self._input_tokens
419430
if total_in > 0:
420-
parts.append(f"{self._format_count(total_in)}")
431+
parts.append(f"ctx {self._format_count(total_in)}")
421432
if self._output_tokens > 0:
422-
parts.append(f"{self._format_count(self._output_tokens)}")
423-
return " ".join(parts)
433+
parts.append(f"out {self._format_count(self._output_tokens)}")
434+
return " · ".join(parts)
424435

425436
@staticmethod
426437
def _format_count(n: int) -> str:
@@ -466,7 +477,7 @@ def _build_subtitle(self) -> Text | None:
466477
return sub
467478

468479
def _build_footer(self) -> Table:
469-
"""Bottom row of the panel: spinner + tool counts."""
480+
"""Bottom row of the panel: spinner + tool counts + peek hint."""
470481
summary = Text(no_wrap=True, overflow="ellipsis")
471482
if self._tool_count > 0:
472483
summary.append(
@@ -480,10 +491,13 @@ def _build_footer(self) -> Table:
480491
else:
481492
summary.append("waiting for first tool call…", style="dim italic")
482493

494+
hint = Text("Shift+P full screen", style="dim", no_wrap=True)
495+
483496
grid = Table.grid(expand=True)
484497
grid.add_column(width=2, no_wrap=True)
485498
grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
486-
grid.add_row(self._spinner, summary)
499+
grid.add_column(no_wrap=True, justify="right")
500+
grid.add_row(self._spinner, summary, hint)
487501
return grid
488502

489503
def _build_body(self) -> Group:
@@ -601,7 +615,8 @@ def _build_header(self, total: int, visible: int) -> Text:
601615
def _build_footer(self) -> Text:
602616
hint = Text(no_wrap=True, overflow="ellipsis")
603617
hint.append(
604-
" ↑/k up · ↓/j down · b/space page · g/G top/bottom · q/", style="dim"
618+
" ↑/k up · ↓/j down · b page up · space page down · g/G top/bottom · q/",
619+
style="dim",
605620
)
606621
hint.append(FULLSCREEN_PEEK_KEY, style=f"bold {_brand.PURPLE}")
607622
hint.append(" exit ", style="dim")
@@ -627,21 +642,52 @@ def __rich_console__(
627642
start = max(0, end - visible)
628643
window = lines[start:end]
629644

645+
# Scrollbar metrics
646+
show_scrollbar = total > visible
647+
thumb_start = 0
648+
thumb_size = visible
649+
if show_scrollbar:
650+
thumb_size = max(1, visible * visible // total)
651+
max_off_val = max(total - visible, 1)
652+
frac = 1.0 - (self._offset / max_off_val)
653+
track_space = visible - thumb_size
654+
thumb_start = int(frac * track_space)
655+
630656
rows: list[Any] = []
631657
rows.append(self._build_header(total, visible))
632658
rows.append(Text(""))
633-
if window:
634-
for line in window:
659+
660+
# Content area with optional scrollbar column
661+
content = Table.grid(expand=True)
662+
content.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
663+
if show_scrollbar:
664+
content.add_column(width=1, no_wrap=True)
665+
666+
for i in range(visible):
667+
if i < len(window):
668+
line = window[i]
635669
line.no_wrap = True
636670
line.overflow = "ellipsis"
637-
rows.append(line)
638-
else:
671+
else:
672+
line = Text("")
673+
if show_scrollbar:
674+
in_thumb = thumb_start <= i < thumb_start + thumb_size
675+
bar = Text(
676+
"█" if in_thumb else "│",
677+
style=_brand.PURPLE if in_thumb else "dim",
678+
)
679+
content.add_row(line, bar)
680+
else:
681+
content.add_row(line)
682+
683+
if not window and not show_scrollbar:
684+
# Replace the empty grid with a waiting message
639685
rows.append(Text(" (waiting for activity…)", style="dim italic"))
640-
# Pad the body so the footer hugs the bottom border even when
641-
# the buffer is shorter than the viewport.
642-
padding = visible - len(window)
643-
for _ in range(max(0, padding)):
644-
rows.append(Text(""))
686+
for _ in range(max(0, visible - 1)):
687+
rows.append(Text(""))
688+
else:
689+
rows.append(content)
690+
645691
rows.append(Text(""))
646692
rows.append(self._build_footer())
647693

@@ -1145,10 +1191,14 @@ def _build_footer(self) -> Table:
11451191
summary.append(" of agent output", style="dim")
11461192
else:
11471193
summary.append("waiting for agent output…", style="dim italic")
1194+
1195+
hint = Text("Shift+P full screen", style="dim", no_wrap=True)
1196+
11481197
grid = Table.grid(expand=True)
11491198
grid.add_column(width=2, no_wrap=True)
11501199
grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
1151-
grid.add_row(self._spinner, summary)
1200+
grid.add_column(no_wrap=True, justify="right")
1201+
grid.add_row(self._spinner, summary, hint)
11521202
return grid
11531203

11541204
def _build_body(self) -> Group:

tests/test_console_emitter.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -587,8 +587,8 @@ def test_assistant_text_scroll_line(self):
587587
assert any("fix the bug" in line.plain for line in panel._scroll_lines)
588588
emitter._stop_live()
589589

590-
def test_thinking_does_not_scroll(self):
591-
"""Thinking events update the panel status but don't produce scroll output."""
590+
def test_thinking_produces_scroll_lines(self):
591+
"""Thinking blocks appear in the scroll buffer as dim italic text."""
592592
emitter, console = self._make_structured_emitter()
593593
emitter.emit(_make_event(EventType.ITERATION_STARTED, iteration=1))
594594
emitter.emit(
@@ -605,7 +605,8 @@ def test_thinking_does_not_scroll(self):
605605
)
606606
panel = emitter._iteration_panel
607607
assert panel is not None
608-
assert len(panel._scroll_lines) == 0
608+
assert len(panel._scroll_lines) == 1
609+
assert "let me think..." in panel._scroll_lines[0].plain
609610
emitter._stop_live()
610611

611612
def test_rate_limit_scroll_line(self):
@@ -1348,7 +1349,7 @@ def test_panel_renders_elapsed(self):
13481349

13491350
def test_apply_tool_use_updates_counters(self):
13501351
panel = _IterationPanel()
1351-
result = panel.apply(
1352+
panel.apply(
13521353
{
13531354
"type": "assistant",
13541355
"message": {
@@ -1362,17 +1363,16 @@ def test_apply_tool_use_updates_counters(self):
13621363
},
13631364
}
13641365
)
1365-
assert result is not None
1366-
assert "Read" in result
13671366
assert panel._tool_count == 1
13681367
assert panel._tool_categories.get("read") == 1
1368+
assert len(panel._scroll_lines) == 1
1369+
assert "Read" in panel._scroll_lines[0].plain
13691370

13701371
def test_apply_system_init_sets_model(self):
13711372
panel = _IterationPanel()
1372-
result = panel.apply(
1373+
panel.apply(
13731374
{"type": "system", "subtype": "init", "model": "claude-opus-4-6"}
13741375
)
1375-
assert result is None
13761376
assert panel._model == "claude-opus-4-6"
13771377

13781378
def test_apply_usage_updates_tokens(self):
@@ -1401,15 +1401,15 @@ def test_apply_unknown_type_returns_none(self):
14011401
def test_format_tokens_does_not_double_count_cached_input(self):
14021402
"""The Anthropic API's input_tokens already includes cache_read_input_tokens
14031403
as a subset. _format_tokens must not add them again — that would inflate
1404-
the displayed total (e.g. 1000 input + 800 cached → wrong 1.8k instead
1405-
of correct 1.0k)."""
1404+
the displayed total (e.g. 1000 input + 800 cached → wrong ctx 1.8k instead
1405+
of correct ctx 1.0k)."""
14061406
panel = _IterationPanel()
14071407
panel._input_tokens = 1000
14081408
panel._cache_read_tokens = 800
14091409
panel._output_tokens = 200
14101410
result = panel._format_tokens()
1411-
assert "1.0k" in result, (
1412-
f"Expected 1.0k (input_tokens already includes cache), got: {result!r}"
1411+
assert "ctx 1.0k" in result, (
1412+
f"Expected ctx 1.0k (input_tokens already includes cache), got: {result!r}"
14131413
)
14141414

14151415
def test_format_count(self):

0 commit comments

Comments
 (0)