|
|
@@ -560,17 +560,19 @@ def result_bits(evt):
|
|
|
INLINE_RESULT_LIMIT = 220
|
|
|
|
|
|
|
|
|
-def result_view(evt):
|
|
|
- """PORT of resultView -> (inline, bits_text, body_text): a
|
|
|
- short body renders inline under its metric bits; a long one
|
|
|
- collapses behind the expander summary."""
|
|
|
+def result_view(evt, attached=False):
|
|
|
+ """PORT of resultView -> (inline, bits_text, body_text): a short
|
|
|
+ body renders inline under its metric bits; a long one collapses
|
|
|
+ behind the expander summary - UNLESS it rides attached to its
|
|
|
+ call, where the output is always visible (CSS clamps oversized
|
|
|
+ bodies)."""
|
|
|
out, err, note, extras, no_output = result_output(evt)
|
|
|
parts = [part for part in (out, err, note, extras) if part]
|
|
|
if no_output:
|
|
|
parts = ["(no output)"]
|
|
|
body_text = "\n".join(parts).strip()
|
|
|
- return len(body_text) <= INLINE_RESULT_LIMIT, \
|
|
|
- " \u00b7 ".join(result_bits(evt)), body_text
|
|
|
+ inline = attached or len(body_text) <= INLINE_RESULT_LIMIT
|
|
|
+ return inline, " \u00b7 ".join(result_bits(evt)), body_text
|
|
|
|
|
|
|
|
|
def result_summary(evt):
|
|
|
@@ -637,20 +639,33 @@ def tailored_result_text(tool, extras):
|
|
|
meta = []
|
|
|
if str_text(extras.get("path")):
|
|
|
meta.append(str_text(extras.get("path")))
|
|
|
- if isinstance(extras.get("line_start"), int) \
|
|
|
- and isinstance(extras.get("total_lines"), int):
|
|
|
- end = extras.get("line_end")
|
|
|
- if not isinstance(end, int):
|
|
|
- end = extras["total_lines"]
|
|
|
+ if isinstance(extras.get("total_lines"), int):
|
|
|
+ start = extras.get("line_start") \
|
|
|
+ if isinstance(extras.get("line_start"), int) else 1
|
|
|
+ end = extras.get("line_end") \
|
|
|
+ if isinstance(extras.get("line_end"), int) else extras["total_lines"]
|
|
|
meta.append("lines %d-%d of %d"
|
|
|
- % (extras["line_start"], end, extras["total_lines"]))
|
|
|
- if isinstance(extras.get("bytes"), int):
|
|
|
- meta.append(fmt_bytes(extras["bytes"]))
|
|
|
+ % (start, end, extras["total_lines"]))
|
|
|
+ if isinstance(extras.get("bytes"), int):
|
|
|
+ meta.append(fmt_bytes(extras["bytes"]))
|
|
|
+ else:
|
|
|
+ if str_text(extras.get("representation")):
|
|
|
+ meta.append(str_text(extras.get("representation")))
|
|
|
+ if isinstance(extras.get("bytes_read"), int):
|
|
|
+ window = fmt_bytes(extras["bytes_read"])
|
|
|
+ if isinstance(extras.get("total_bytes"), int):
|
|
|
+ window += " of " + fmt_bytes(extras["total_bytes"])
|
|
|
+ meta.append(window)
|
|
|
+ if isinstance(extras.get("offset"), int) and extras["offset"] > 0:
|
|
|
+ meta.append("at " + fmt_bytes(extras["offset"]))
|
|
|
if extras.get("truncated"):
|
|
|
meta.append("truncated")
|
|
|
parts = [" \u00b7 ".join(meta)]
|
|
|
if str_text(extras.get("content")):
|
|
|
- parts.append(str_text(extras["content"]))
|
|
|
+ # The content rides behind an expander: a read is
|
|
|
+ # reference material, not the action's output.
|
|
|
+ parts.append("[fold] content \u00b7 "
|
|
|
+ + fmt_bytes(len(str_text(extras.get("content")))))
|
|
|
return "\n".join(parts)
|
|
|
if tool == "replace_in_file" and isinstance(extras.get("replacements"), int):
|
|
|
count = extras["replacements"]
|
|
|
@@ -775,7 +790,8 @@ FOLDABLE_TOOLS = {"update_progress": "progress", "add_comment": "comment",
|
|
|
def quiet_result(evt):
|
|
|
"""PORT of quietResult: an ack-shaped success with nothing to
|
|
|
say - ok, no error/note/timeout, no output, extras only true/
|
|
|
- false/empty-string."""
|
|
|
+ false/empty-string/row pointers (_id integers whose content
|
|
|
+ renders elsewhere)."""
|
|
|
envelope = result_envelope(evt)
|
|
|
if envelope.get("ok") is False:
|
|
|
return False
|
|
|
@@ -787,11 +803,13 @@ def quiet_result(evt):
|
|
|
return False
|
|
|
if str_text(envelope.get("stdout")) or str_text(envelope.get("stderr")):
|
|
|
return False
|
|
|
- for value in result_extras(envelope).values():
|
|
|
+ for key, value in result_extras(envelope).items():
|
|
|
if value is True or value is False:
|
|
|
continue
|
|
|
if isinstance(value, str) and value == "":
|
|
|
continue
|
|
|
+ if isinstance(value, int) and key.endswith("_id"):
|
|
|
+ continue
|
|
|
return False
|
|
|
return True
|
|
|
|
|
|
@@ -912,6 +930,38 @@ def diff_rows(old_text, new_text):
|
|
|
return rows
|
|
|
|
|
|
|
|
|
+FILE_LANGUAGES = {".vala": "Vala", ".vapi": "Vapi", ".c": "C", ".h": "C",
|
|
|
+ ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
|
|
+ ".html": "HTML", ".css": "CSS", ".json": "JSON",
|
|
|
+ ".md": "Markdown", ".sh": "Shell", ".bash": "Shell",
|
|
|
+ ".xml": "XML", ".yml": "YAML", ".yaml": "YAML",
|
|
|
+ ".sql": "SQL", ".toml": "TOML", ".ini": "INI",
|
|
|
+ ".cfg": "Config", ".diff": "Diff", ".patch": "Diff",
|
|
|
+ ".rst": "reST", ".txt": "Text"}
|
|
|
+
|
|
|
+
|
|
|
+def language_of(path):
|
|
|
+ """PORT of languageOf: the extension chip, with meson.build and
|
|
|
+ makefile as whole-name matches; '' when nothing matches."""
|
|
|
+ lower = path.lower()
|
|
|
+ name = lower[lower.rfind("/") + 1:]
|
|
|
+ if name == "meson.build":
|
|
|
+ return "Meson"
|
|
|
+ if name == "makefile":
|
|
|
+ return "Make"
|
|
|
+ dot = name.rfind(".")
|
|
|
+ if dot < 1 or dot == len(name) - 1:
|
|
|
+ return ""
|
|
|
+ return FILE_LANGUAGES.get(name[dot:], "")
|
|
|
+
|
|
|
+
|
|
|
+def content_bits(content):
|
|
|
+ """PORT of contentBits: line count and byte size."""
|
|
|
+ lines = content.count("\n") + 1
|
|
|
+ return ["%d line%s" % (lines, "" if lines == 1 else "s"),
|
|
|
+ fmt_bytes(len(content))]
|
|
|
+
|
|
|
+
|
|
|
def tailored_blocks(tool, args):
|
|
|
"""PORT of tailoredCallBody -> the bespoke blocks, or None to
|
|
|
fall back to the generic JSON view (fields missing - e.g. a
|
|
|
@@ -941,12 +991,19 @@ def tailored_blocks(tool, args):
|
|
|
if isinstance(args.get("limit"), int):
|
|
|
bits.append("%d lines" % args["limit"])
|
|
|
return [head("read_file " + path, bits)]
|
|
|
- if tool in ("write_file", "overwrite_file"):
|
|
|
+ if tool in ("write_file", "overwrite_file", "temp_doc"):
|
|
|
content = str_text(args.get("content"))
|
|
|
- if not path or not content:
|
|
|
+ if not content:
|
|
|
+ return None
|
|
|
+ if tool == "temp_doc":
|
|
|
+ return [head("temp_doc", content_bits(content)), ("pre", content)]
|
|
|
+ if not path:
|
|
|
return None
|
|
|
- return [head(tool + " " + path, [fmt_bytes(len(content))]),
|
|
|
- ("fold", "content \u00b7 " + fmt_bytes(len(content)))]
|
|
|
+ more = content_bits(content)
|
|
|
+ language = language_of(path)
|
|
|
+ if language:
|
|
|
+ more.insert(0, language)
|
|
|
+ return [head(tool + " " + path, more), ("pre", content)]
|
|
|
if tool == "replace_in_file":
|
|
|
old = str_text(args.get("old"))
|
|
|
if not path or not old:
|
|
|
@@ -1125,10 +1182,17 @@ class ToolCallRenderTests(unittest.TestCase):
|
|
|
self.assertFalse([t for t in diff_texts if t.startswith("- ")],
|
|
|
"an unchanged line must never render as a removal")
|
|
|
|
|
|
- def test_write_file_folds_its_content(self):
|
|
|
+ def test_write_file_renders_language_lines_and_content(self):
|
|
|
blocks = tool_call_render(self.evt(FIXTURE_WRITE_CALL))
|
|
|
- self.assertEqual(blocks[0], ("head", "write_file notes.md \u00b7 24 B"))
|
|
|
- self.assertEqual(blocks[1], ("fold", "content \u00b7 24 B"))
|
|
|
+ self.assertEqual(blocks[0],
|
|
|
+ ("head", "write_file notes.md \u00b7 Markdown \u00b7 3 lines \u00b7 24 B"))
|
|
|
+ self.assertEqual(blocks[1], ("pre", "# Notes\n- first\n- second"))
|
|
|
+
|
|
|
+ def test_meson_build_gets_its_language(self):
|
|
|
+ self.assertEqual(language_of("sub/meson.build"), "Meson")
|
|
|
+ self.assertEqual(language_of("src/app.vala"), "Vala")
|
|
|
+ self.assertEqual(language_of("Makefile".lower()), "Make")
|
|
|
+ self.assertEqual(language_of("data/bin"), "")
|
|
|
|
|
|
def test_truncated_args_fall_back_to_the_raw_view(self):
|
|
|
blocks = tool_call_render(self.evt(FIXTURE_TRUNCATED_ARGS))
|
|
|
@@ -1286,6 +1350,21 @@ class QuietFoldingTests(unittest.TestCase):
|
|
|
self.assertEqual(
|
|
|
folded_quiet_calls(progress_trio(result_turn=9999)), set())
|
|
|
|
|
|
+ def test_add_comment_folds_behind_its_comment_event(self):
|
|
|
+ """The add_comment result carries a comment_id row pointer -
|
|
|
+ quiet - and the comment event between carries the body."""
|
|
|
+ rows = [
|
|
|
+ {"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
|
|
|
+ "turn": 60, "tool": "add_comment",
|
|
|
+ "args": json.dumps({"body": "Merged and pushed."})}},
|
|
|
+ {"id": 2, "kind": "comment", "session_id": 201, "actor": "model",
|
|
|
+ "payload": {"body": "Merged and pushed."}},
|
|
|
+ {"id": 3, "kind": "tool_result", "session_id": 201, "payload": {
|
|
|
+ "tool": "add_comment",
|
|
|
+ "result": json.dumps({"ok": True, "comment_id": 234})}},
|
|
|
+ ]
|
|
|
+ self.assertEqual(folded_quiet_calls(rows), {0, 2})
|
|
|
+
|
|
|
def test_failed_ack_unfolds_the_pair(self):
|
|
|
self.assertEqual(folded_quiet_calls(progress_trio(FAILED_ACK)), set())
|
|
|
|
|
|
@@ -1346,6 +1425,22 @@ class ResultAttachmentTests(unittest.TestCase):
|
|
|
self.assertEqual(calls_by_id, {20: 21})
|
|
|
self.assertEqual(rides_by_id, {21: True})
|
|
|
|
|
|
+ def test_long_command_output_is_visible_when_attached(self):
|
|
|
+ """A command's output must be VISIBLE riding with its call -
|
|
|
+ never behind a click - even past the inline limit; CSS
|
|
|
+ clamps the overflow."""
|
|
|
+ big_output = "line %d\n" * 60 % tuple(range(60))
|
|
|
+ evt = {"kind": "tool_result", "payload": {
|
|
|
+ "tool": "run_command",
|
|
|
+ "result": json.dumps({"ok": True, "exit_code": 0,
|
|
|
+ "stdout": big_output, "stderr": "",
|
|
|
+ "duration_ms": 5000})}}
|
|
|
+ standalone, _, body = result_view(evt)
|
|
|
+ attached, _, attached_body = result_view(evt, attached=True)
|
|
|
+ self.assertFalse(standalone, "long standalone results collapse")
|
|
|
+ self.assertTrue(attached, "attached results are always inline")
|
|
|
+ self.assertEqual(attached_body, body)
|
|
|
+
|
|
|
def test_folded_pair_attaches_nothing(self):
|
|
|
calls_by_id, rides_by_id = attach_pairs(
|
|
|
progress_trio(), folded_quiet_calls(progress_trio()))
|
|
|
@@ -1450,7 +1545,18 @@ class ToolResultRenderTests(unittest.TestCase):
|
|
|
out, err, note, extras, no_output = result_output(self.evt(
|
|
|
{"tool": "read_file", "result": inner}))
|
|
|
self.assertIn("src/main.vala \u00b7 lines 100-160 of 540 \u00b7 4.1 KiB", extras)
|
|
|
- self.assertIn("int main() {", extras)
|
|
|
+ self.assertIn("[fold] content \u00b7 12 B", extras)
|
|
|
+
|
|
|
+ def test_read_binary_result_renders_the_window(self):
|
|
|
+ inner = json.dumps({"ok": True, "path": "model.bin",
|
|
|
+ "representation": "hex", "total_bytes": 1024,
|
|
|
+ "offset": 256, "bytes_read": 512,
|
|
|
+ "truncated": False,
|
|
|
+ "content": "00000100 7f 45 4c 46"})
|
|
|
+ out, err, note, extras, no_output = result_output(self.evt(
|
|
|
+ {"tool": "read_binary", "result": inner}))
|
|
|
+ self.assertIn("model.bin \u00b7 hex \u00b7 512 B of 1.0 KiB \u00b7 at 256 B", extras)
|
|
|
+ self.assertIn("[fold] content", extras)
|
|
|
|
|
|
def test_write_file_result_states_the_write(self):
|
|
|
out, err, note, extras, no_output = result_output(self.evt(
|