77
88from __future__ import annotations
99
10+ import re
11+ from pathlib import PurePosixPath , PureWindowsPath
1012from typing import Any
1113
1214from rich .console import Group , RenderableType
1315from 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
1519from pythinker_code .ui .shell .tool_renderers import (
1620 ToolRenderContext ,
1721 ToolRenderDefinition ,
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
3646def _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+
101182def _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
138226READ_RENDERER = ToolRenderDefinition (
0 commit comments