diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e8132a..77df598c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +- **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062) + - **Images and media now render inline *as the response streams*, not just after it finishes.** When the assistant emits a `MEDIA:` token (generated image, video, audio, PDF, diff, CSV, Excalidraw, etc.) mid-turn, it now appears inline in real time during streaming instead of only materializing once the turn settles — including markers that arrive split across streaming chunks. Media routes through the same escaped `/api/media` path (no XSS surface: `javascript:`/`data:`/`file:` neutralized, SVG stays an ``), remote `.webm` correctly renders as video, and normal prose streaming (including the fade effect) is unchanged when no media is present. Thanks @silent-reader-cn. (#5802) - **Docker / no-git installs now get an update notice.** Container and other non-git WebUI installs previously had no way to see that a newer release existed (the in-app updater only works on git checkouts). The Settings update panel now shows an informational notice when the running image trails the latest published release — e.g. "WebUI (v0.52.30 → v0.52.43): 13 releases available · Manual update required: run `docker pull …`, then recreate the container." It is informational only: the Force-update and Clear-lock actions are disabled/hidden for these installs and the notice can never trigger a git reset. Prerelease tags are ignored so stable installs only see stable releases. Thanks @rodboev. (#5733, #2729) diff --git a/docs/images/pr-tool-read-range-label-after.png b/docs/images/pr-tool-read-range-label-after.png new file mode 100644 index 00000000..fb911278 Binary files /dev/null and b/docs/images/pr-tool-read-range-label-after.png differ diff --git a/docs/images/pr-tool-read-range-label-before.png b/docs/images/pr-tool-read-range-label-before.png new file mode 100644 index 00000000..16ee1592 Binary files /dev/null and b/docs/images/pr-tool-read-range-label-before.png differ diff --git a/static/ui.js b/static/ui.js index fe13e0c1..d860fbec 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10738,7 +10738,7 @@ function _transparentToolSummary(tc){ // with only {workdir} or an unknown tool with {mode:"dry-run"}) must yield an // EMPTY collapsed preview rather than dumping a raw arg snippet — that keeps the // collapsed row quiet and consistent with the no-args case (#4658 review). - const target=typeof _toolVisibleTargetLabel==='function'?_toolVisibleTargetLabel(tc,{limit:160}):''; + const target=typeof _toolVisibleTargetLabel==='function'?_toolVisibleTargetLabel(tc,{limit:160,rangeFirst:true}):''; if(target) return target; return ''; } @@ -16719,6 +16719,20 @@ function _toolTargetLabel(tc){ else raw=a.cmd||a.command||a.path||a.file_path||a.file||a.uri||a.url||a.query||a.pattern||a.dir||a.task||a.name||''; return _redactToolTargetLabel(_decodeToolLabelEntities(String(raw).split('\n')[0].trim())); } +function _toolReadRangeLabel(tc){ + const name=String(tc&&tc.name||'').toLowerCase().replace(/[^a-z0-9]+/g,'_'); + if(name!=='read_file') return ''; + const args=tc&&tc.args||{}; + const offset=args.offset; + if(!Number.isSafeInteger(offset)||offset<=0) return ''; + const limit=args.limit; + if(limit===undefined) return `L${offset}`; + if(!Number.isSafeInteger(limit)||limit<=0) return ''; + if(limit===1) return `L${offset}`; + const span=limit-1; + if(offset>Number.MAX_SAFE_INTEGER-span) return ''; + return `L${offset}-${offset+span}`; +} function _toolFullCommandLabel(tc){ // Full (multi-line) shell command for the EXPANDED detail lead. Mirrors the // shell raw-extraction in _toolTargetLabel but WITHOUT the .split('\n')[0] @@ -16733,7 +16747,12 @@ function _toolVisibleTargetLabel(tc, opts){ const target=_toolTargetLabel(tc); if(!target) return ''; const kind=_toolActionKind(tc); - if(kind==='read'||kind==='write') return _shortToolLabel(_toolPathBasename(target)||target, opts.limit||112); + if(kind==='read'||kind==='write'){ + let text=_toolPathBasename(target)||target; + const range=kind==='read'?_toolReadRangeLabel(tc):''; + if(range) text=opts.rangeFirst?`${range} · ${text}`:`${text} · ${range}`; + return _shortToolLabel(text, opts.limit||112); + } if(kind==='skill'){ const suffix=_toolI18n('tool_target_skill_suffix', 'skill'); const text=target.toLowerCase().endsWith(String(suffix).toLowerCase())?target:`${target} ${suffix}`; diff --git a/tests/test_issue4658_transparent_collapsed_preview.py b/tests/test_issue4658_transparent_collapsed_preview.py index 5401ef50..e5668daf 100644 --- a/tests/test_issue4658_transparent_collapsed_preview.py +++ b/tests/test_issue4658_transparent_collapsed_preview.py @@ -39,7 +39,7 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") _FN_NAMES = [ '_toolDisplayName', '_toolActionKind', '_toolKindIcon', '_toolPathBasename', '_decodeToolLabelEntities', '_redactToolTargetLabel', '_shortToolLabel', '_toolI18n', - '_toolTargetLabel', '_toolVisibleTargetLabel', '_toolCommandTitle', '_toolQueryTitle', + '_toolTargetLabel', '_toolReadRangeLabel', '_toolVisibleTargetLabel', '_toolCommandTitle', '_toolQueryTitle', '_toolActionLabelText', '_toolArgPreviewValue', '_toolArgPreviewKeyIsHidden', '_formatToolArgPreview', '_toolResultOneLiner', '_toolCardPreviewText', '_toolCardAllowsDetail', '_toolDetailLeadLabel', '_toolDetailLeadText', '_toolShortName', '_transparentEventPreview', @@ -178,7 +178,11 @@ function previewFor(tc){ const header=card?card.querySelector('.tool-card-header'):null; const preview=header?header.querySelector('.tool-card-preview'):null; const name=header?header.querySelector('.tool-card-name'):null; - return { preview: preview?preview.textContent:null, name: name?name.textContent:null }; + return { + preview: preview?preview.textContent:null, + name: name?name.textContent:null, + actionLabel: row.dataset.toolActionLabel||null, + }; } const cases = JSON.parse(process.argv[2]); @@ -190,6 +194,13 @@ process.stdout.write(JSON.stringify(out)); CASES = { "read_file": {"name": "read_file", "args": {"path": "/home/x/api/config.py"}, "snippet": '{"content":"1|import os"}', "done": True}, + "read_file_range": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 321, "limit": 160}, "done": True}, + "read_file_offset_only": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 42}, "done": True}, + "read_file_invalid_range": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": "321", "limit": 160}, "done": True}, + "read_file_invalid_limit": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 321, "limit": 0}, "done": True}, + "read_file_negative_limit": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 321, "limit": -1}, "done": True}, + "read_file_null_limit": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 321, "limit": None}, "done": True}, + "read_file_overflow_range": {"name": "read_file", "args": {"path": "/home/x/api/hermes_constants.py", "offset": 9007199254740991, "limit": 2}, "done": True}, "terminal": {"name": "terminal", "args": {"command": "git fetch origin --quiet"}, "snippet": "275 /opt/...", "done": True}, "search_files": {"name": "search_files", "args": {"pattern": "buildToolCard", "path": "/tmp"}, "snippet": '{"total_count": 0}', "done": True}, "skill_view": {"name": "skill_view", "args": {"name": "opencode-review-agents"}, "snippet": '{"success": true}', "done": True}, @@ -226,6 +237,31 @@ def test_read_file_row_shows_file_target(results): ) +def test_read_file_row_shows_requested_line_range(results): + r = results["read_file_range"] + assert r["preview"] == "L321-480 · hermes_constants.py" + assert r["actionLabel"] == "Read hermes_constants.py · L321-480" + + +def test_read_file_offset_without_limit_shows_start_line(results): + r = results["read_file_offset_only"] + assert r["preview"] == "L42 · hermes_constants.py" + assert r["actionLabel"] == "Read hermes_constants.py · L42" + + +def test_read_file_without_trustworthy_numeric_range_keeps_old_label(results): + no_range = results["read_file"] + invalid_range = results["read_file_invalid_range"] + assert no_range["preview"] == "config.py" + assert no_range["actionLabel"] == "Read config.py" + assert invalid_range["preview"] == "hermes_constants.py" + assert invalid_range["actionLabel"] == "Read hermes_constants.py" + for key in ("read_file_invalid_limit", "read_file_negative_limit", "read_file_null_limit", "read_file_overflow_range"): + invalid_limit = results[key] + assert invalid_limit["preview"] == "hermes_constants.py" + assert invalid_limit["actionLabel"] == "Read hermes_constants.py" + + def test_terminal_row_shows_command(results): r = results["terminal"] assert r["preview"], f"collapsed terminal row must not be blank (the #4658 regression): {r}"