Skip to content

Commit 06f067b

Browse files
committed
fix(tui): show line count and preview in collapsed ReadFile cards
Collapsed reads said "Read 1 file (ctrl+o to expand)", hiding the line count and content so a successful read looked like it returned nothing. Render a line-count-aware summary with the file basename (e.g. "Read 140 lines from console.py") plus a short, cell-width-capped preview of the leading lines. Line count prefers the tool message, falls back to counting the body, and stays truthful when unknowable ("Read file content") or empty ("Read 0 lines"). Preview is ANSI-sanitized, capped to a few visual lines, width-capped per line, and skipped on narrow terminals. Expanded mode and the LLM-facing tool result are unchanged.
1 parent b3b9ac8 commit 06f067b

3 files changed

Lines changed: 185 additions & 14 deletions

File tree

src/pythinker_code/ui/shell/tool_renderers/read.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77

88
from __future__ import annotations
99

10+
import re
11+
from pathlib import PurePosixPath, PureWindowsPath
1012
from typing import Any
1113

1214
from rich.console import Group, RenderableType
1315
from rich.text import Text
1416

17+
from pythinker_code.ui.shell.components import sanitize_ansi
18+
from pythinker_code.ui.shell.components.render_utils import truncate_to_width
1519
from pythinker_code.ui.shell.tool_renderers import (
1620
ToolRenderContext,
1721
ToolRenderDefinition,
@@ -27,10 +31,16 @@
2731
pending_tool_call_header,
2832
running_spinner,
2933
shorten_path,
34+
tab_to_spaces,
3035
tool_call_header,
3136
)
37+
from pythinker_code.ui.theme import tui_rich_style
3238

3339
_TOOL_NAME = "ReadFile"
40+
# Compact collapsed preview: a few leading lines so humans/models can confirm
41+
# content was returned without dumping the file. Expanded mode shows it all.
42+
_PREVIEW_MAX_LINES = 4
43+
_LINES_READ_RE = re.compile(r"(\d+)\s+lines?\s+read")
3444

3545

3646
def _format_line_range(args: dict[str, Any]) -> Text | None:
@@ -98,6 +108,77 @@ def _friendly_error(text: str) -> str:
98108
return "Error reading file"
99109

100110

111+
def _basename(path: Any) -> str | None:
112+
"""Display basename for a read path, or ``None`` when unavailable.
113+
114+
The call row already shows the (shortened) path, so the result summary only
115+
needs the leaf name — and never a fuller path that would leak more than the
116+
call row already does. Handle both POSIX and Windows separators since the
117+
path is model-supplied text, not a resolved local path.
118+
"""
119+
raw = as_str(path)
120+
if raw is None or not raw.strip():
121+
return None
122+
name = PureWindowsPath(PurePosixPath(raw).name).name
123+
return name or None
124+
125+
126+
def _line_count(message: str | None, output_text: str) -> int | None:
127+
"""Lines read in this call: prefer the tool message, else count the body.
128+
129+
Returns ``None`` only when the count is genuinely unknowable (no message and
130+
no body) — callers must then avoid asserting a count rather than lie with 0.
131+
"""
132+
if message:
133+
match = _LINES_READ_RE.search(message)
134+
if match:
135+
return int(match.group(1))
136+
if "no lines read" in message.lower():
137+
return 0
138+
if output_text:
139+
cleaned = output_text.rstrip("\n")
140+
return cleaned.count("\n") + 1 if cleaned else 0
141+
return None
142+
143+
144+
def _summary_text(count: int | None, basename: str | None) -> str:
145+
if count is None:
146+
head = "Read file content"
147+
else:
148+
head = f"Read {count} {'line' if count == 1 else 'lines'}"
149+
if basename:
150+
head += f" from {basename}"
151+
return head
152+
153+
154+
def _preview(output_text: str, width: int) -> Text | None:
155+
"""A few leading body lines, ANSI-stripped and width-capped per line.
156+
157+
Caps both the number of visual lines (``_PREVIEW_MAX_LINES``) and each
158+
line's width so a file with very long lines can never produce giant
159+
collapsed output. Returns ``None`` when there is nothing friendly to show.
160+
"""
161+
cleaned = sanitize_ansi(output_text or "").rstrip("\n")
162+
if not cleaned:
163+
return None
164+
# Width ceiling keyed off terminal cell width, leaving room for the card
165+
# gutter so each preview line stays on a single visual row. On a terminal
166+
# too narrow to show anything useful, skip the preview entirely.
167+
limit = min(max(width - 6, 0), 200)
168+
if limit < 12:
169+
return None
170+
out = Text(style=tui_rich_style("tool_output"))
171+
for index, line in enumerate(cleaned.split("\n")[:_PREVIEW_MAX_LINES]):
172+
if index:
173+
out.append("\n")
174+
# Cell-width aware: a wide-glyph / CJK / emoji line is truncated by the
175+
# space it actually occupies, not its character count.
176+
out.append(truncate_to_width(tab_to_spaces(line), limit))
177+
out.no_wrap = True
178+
out.overflow = "ellipsis"
179+
return out if out.plain else None
180+
181+
101182
def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None:
102183
ctx.state["__suppress_generic_expand_hint__"] = True
103184
if result.is_error:
@@ -109,13 +190,24 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera
109190
if isinstance(message, str) and message.startswith("Directory listing for `"):
110191
return fg("tool_output", "Listed 1 directory")
111192

193+
# An empty string is a valid (empty-file) body — only fall back to the
194+
# flattened text when ``output`` is absent/non-string, so an empty read
195+
# never inherits unrelated metadata from ``result.text``.
112196
output = result.details.get("output")
113-
output_text = output if isinstance(output, str) and output else result.text
197+
output_text = output if isinstance(output, str) else result.text
198+
199+
count = _line_count(message if isinstance(message, str) else None, output_text)
200+
basename = _basename(ctx.args.get("path"))
201+
summary = _summary_text(count, basename)
202+
114203
if not output_text:
115-
return fg("tool_output", "Read 1 file")
204+
# Nothing to preview or expand (empty file / no body): truthful summary only.
205+
return fg("tool_output", summary)
116206

117207
if not ctx.expanded:
118-
return fg("tool_output", "Read 1 file (ctrl+o to expand)")
208+
collapsed = fg("tool_output", f"{summary} (ctrl+o to expand)")
209+
preview = _preview(output_text, ctx.width)
210+
return Group(collapsed, preview) if preview is not None else collapsed
119211

120212
start_line = 1
121213
offset = ctx.args.get("line_offset")
@@ -128,11 +220,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera
128220
start_line=start_line,
129221
style_token="tool_output",
130222
)
131-
return (
132-
Group(fg("tool_output", "Read 1 file"), body)
133-
if body.plain
134-
else fg("tool_output", "Read 1 file")
135-
)
223+
return Group(fg("tool_output", summary), body) if body.plain else fg("tool_output", summary)
136224

137225

138226
READ_RENDERER = ToolRenderDefinition(

tests/ui_and_conv/test_tui_card_tool_renderers.py

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ def test_read_renders_path_and_range():
134134
assert "⏺ Read(" in rendered
135135
assert "src/foo.py" in rendered
136136
assert ":10-39" in rendered
137-
assert "Read 1 file (ctrl+o to expand)" in rendered
137+
# Line-count-aware summary with basename, not the generic "Read 1 file".
138+
assert "Read 2 lines from foo.py (ctrl+o to expand)" in rendered
139+
assert "Read 1 file" not in rendered
138140

139141

140142
def test_read_renders_negative_offset_as_tail():
@@ -158,12 +160,93 @@ def test_read_renders_negative_offset_with_limit():
158160
assert ":tail 100 · limit 20" in rendered
159161

160162

161-
def test_read_result_matches_reference_summary_only():
163+
def test_read_collapsed_shows_count_and_capped_preview():
162164
body = "\n".join(f"line {i}" for i in range(20))
163165
rendered = _render("ReadFile", {"path": "/repo/x.py"}, output=body)
164-
assert "Read 1 file (ctrl+o to expand)" in rendered
165-
assert "line 0" not in rendered
166-
assert "more lines" not in rendered
166+
# Line-count-aware summary replaces the generic "Read 1 file".
167+
assert "Read 20 lines from x.py (ctrl+o to expand)" in rendered
168+
assert "Read 1 file" not in rendered
169+
# A short preview of leading lines is shown, but the preview is capped:
170+
# the first line appears, a line past the cap does not.
171+
assert "line 0" in rendered
172+
assert "line 19" not in rendered
173+
174+
175+
def test_read_collapsed_caps_preview_line_width_no_giant_output():
176+
# A single very long line must not blow up the collapsed card height.
177+
long_line = "x" * 500
178+
rendered = _render("ReadFile", {"path": "/repo/big.py"}, output=long_line, width=80)
179+
assert "Read 1 line from big.py (ctrl+o to expand)" in rendered
180+
# Width-capped: the full 500-char line is truncated, so total output stays small.
181+
assert "x" * 500 not in rendered
182+
assert rendered.count("\n") < 6
183+
184+
185+
def test_read_preview_skips_on_narrow_terminal():
186+
from pythinker_code.ui.shell.tool_renderers.read import _preview
187+
188+
# Too narrow to show anything useful → no preview at all.
189+
assert _preview("some content here", width=10) is None
190+
# Roomy terminal → preview present.
191+
assert _preview("some content here", width=100) is not None
192+
193+
194+
def test_read_preview_truncates_by_cell_width_not_char_count():
195+
# Wide (2-cell) glyphs must be measured by display width, so the preview
196+
# line fits the column budget instead of overflowing on char count alone.
197+
from pythinker_code.ui.shell.components.render_utils import cell_len
198+
from pythinker_code.ui.shell.tool_renderers.read import _preview
199+
200+
preview = _preview("世" * 200, width=60)
201+
assert preview is not None
202+
assert cell_len(preview.plain) <= 60
203+
204+
205+
def test_read_collapsed_uses_message_line_count():
206+
rendered = _render(
207+
"ReadFile",
208+
{"path": "/repo/src/_live_view.py", "line_offset": 1, "n_lines": 1000},
209+
details={
210+
"message": "140 lines read from file starting from line 1. Total lines in file: 320.",
211+
"output": " 1\tdef view():\n 2\t return 1",
212+
},
213+
)
214+
assert "Read 140 lines from _live_view.py (ctrl+o to expand)" in rendered
215+
216+
217+
def test_read_collapsed_falls_back_when_count_unknown():
218+
# No message and no body text → count is unknowable; never assert a fake 0.
219+
rendered = _render("ReadFile", {"path": "/repo/x.py"}, details={"message": ""})
220+
assert "Read file content" in rendered
221+
assert "Read 0 lines" not in rendered
222+
223+
224+
def test_read_collapsed_empty_file_is_truthful():
225+
rendered = _render(
226+
"ReadFile",
227+
{"path": "/repo/empty.py"},
228+
details={"message": "No lines read from file. Total lines in file: 0."},
229+
)
230+
assert "Read 0 lines from empty.py" in rendered
231+
# Nothing to expand for an empty file.
232+
assert "ctrl+o to expand" not in rendered
233+
234+
235+
def test_read_collapsed_preview_sanitizes_control_sequences():
236+
body = "\x1b[31mred\x1b[0m\x07\nsecond"
237+
rendered = _render("ReadFile", {"path": "/repo/x.py"}, output=body)
238+
assert "red" in rendered
239+
assert "\x1b" not in rendered
240+
assert "\x07" not in rendered
241+
242+
243+
def test_read_expanded_shows_full_content():
244+
body = "\n".join(f"line {i}" for i in range(20))
245+
rendered = _render("ReadFile", {"path": "/repo/x.py"}, output=body, expanded=True)
246+
assert "Read 20 lines from x.py" in rendered
247+
# Every line is present when expanded, including past the collapsed cap.
248+
assert "line 0" in rendered
249+
assert "line 19" in rendered
167250

168251

169252
def test_read_error_prefers_structured_message():

tests/ui_and_conv/test_tui_transcript_enhancements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def test_finished_expandable_tool_card_remains_available_after_flush(monkeypatch
7171
block = view._completed_expandable_tool_card()
7272
assert block is not None
7373
expanded = render_plain(block.render_expanded(), width=100)
74-
assert "Read 1 file" in expanded
74+
assert "Read 20 lines from big.py" in expanded
7575
assert "line 0" in expanded
7676
assert "line 19" in expanded
7777

0 commit comments

Comments
 (0)