diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bb391b8..d56a5888d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Show trailing output in tool error briefs when commands fail ## 1.46.0 (2026-05-28) - Shell: Support styled text in welcome tips diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index c1150c27a..b64899e8b 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Show trailing output in tool error briefs when commands fail ## 1.46.0 (2026-05-28) - Shell: Support styled text in welcome tips diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 92ed9b85a..c927fe5cc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Shell:工具执行失败时显示完整错误信息 ## 1.46.0 (2026-05-28) - Shell:欢迎提示支持样式化文本 diff --git a/src/kimi_cli/acp/tools.py b/src/kimi_cli/acp/tools.py index 055c9edb3..c9c6aaa8e 100644 --- a/src/kimi_cli/acp/tools.py +++ b/src/kimi_cli/acp/tools.py @@ -142,20 +142,22 @@ async def __call__(self, params: ShellParams) -> ToolReturnValue: else "" ) + tail = builder.tail() + tail_md = f"\n{tail}" if tail else "" if timed_out: return builder.error( f"Command killed by timeout ({timeout_label}){truncated_note}", - brief=f"Killed by timeout ({timeout_label})", + brief=f"Killed by timeout ({timeout_label}){tail_md}", ) if exit_signal: return builder.error( f"Command terminated by signal: {exit_signal}.{truncated_note}", - brief=f"Signal: {exit_signal}", + brief=f"Signal: {exit_signal}{tail_md}", ) if exit_code not in (None, 0): return builder.error( f"Command failed with exit code: {exit_code}.{truncated_note}", - brief=f"Failed with exit code: {exit_code}", + brief=f"Failed with exit code: {exit_code}{tail_md}", ) return builder.ok(f"Command executed successfully.{truncated_note}") finally: diff --git a/src/kimi_cli/tools/shell/__init__.py b/src/kimi_cli/tools/shell/__init__.py index 790f425db..94d2056f2 100644 --- a/src/kimi_cli/tools/shell/__init__.py +++ b/src/kimi_cli/tools/shell/__init__.py @@ -117,9 +117,13 @@ def stderr_cb(line: bytes): if exitcode == 0: return builder.ok("Command executed successfully.") else: + brief = f"Failed with exit code: {exitcode}" + tail = builder.tail() + if tail: + brief += f"\n{tail}" return builder.error( f"Command failed with exit code: {exitcode}.", - brief=f"Failed with exit code: {exitcode}", + brief=brief, ) except TimeoutError: return builder.error( diff --git a/src/kimi_cli/tools/utils.py b/src/kimi_cli/tools/utils.py index 8427703a2..dc79a8120 100644 --- a/src/kimi_cli/tools/utils.py +++ b/src/kimi_cli/tools/utils.py @@ -127,6 +127,26 @@ def write(self, text: str) -> int: return chars_written + def tail(self, max_lines: int = 5, max_line_len: int = 200) -> str: + """Return the last non-empty lines from the buffer, joined with newlines. + + Useful for surfacing actionable error context (stderr) in tool result briefs. + """ + collected: list[str] = [] + for chunk in reversed(self._buffer): + for line in reversed(chunk.splitlines()): + stripped = line.rstrip() + if not stripped.strip(): + continue + if len(stripped) > max_line_len: + stripped = stripped[:max_line_len] + "..." + collected.append(stripped) + if len(collected) >= max_lines: + break + if len(collected) >= max_lines: + break + return "\n".join(reversed(collected)) + def display(self, *blocks: DisplayBlock) -> None: """Add display blocks to the tool result.""" self._display.extend(blocks) diff --git a/src/kimi_cli/ui/shell/visualize/_blocks.py b/src/kimi_cli/ui/shell/visualize/_blocks.py index c840af6b3..6e57039b2 100644 --- a/src/kimi_cli/ui/shell/visualize/_blocks.py +++ b/src/kimi_cli/ui/shell/visualize/_blocks.py @@ -518,7 +518,7 @@ def _compose(self) -> RenderableType: elif isinstance(block, BriefDisplayBlock): style = "grey50" if not self._result.is_error else "dark_red" if block.text: - lines.append(Markdown(block.text, style=style)) + lines.append(Text(block.text.rstrip("\n"), style=style)) idx += 1 elif isinstance(block, TodoDisplayBlock): markdown = self._render_todo_markdown(block) diff --git a/tests/utils/test_result_builder.py b/tests/utils/test_result_builder.py index 34f87ade9..7d456ba6d 100644 --- a/tests/utils/test_result_builder.py +++ b/tests/utils/test_result_builder.py @@ -155,3 +155,41 @@ def test_empty_write(): assert written == 0 assert builder.n_chars == 0 assert not builder.is_full + + +def test_tail_empty(): + builder = ToolResultBuilder() + assert builder.tail() == "" + + +def test_tail_basic(): + builder = ToolResultBuilder() + builder.write("first line\nsecond line\nthird line\n") + assert builder.tail() == "first line\nsecond line\nthird line" + + +def test_tail_skips_blank_lines(): + builder = ToolResultBuilder() + builder.write("real error\n\n \n") + assert builder.tail() == "real error" + + +def test_tail_respects_max_lines(): + builder = ToolResultBuilder() + builder.write("\n".join(f"line {i}" for i in range(10)) + "\n") + assert builder.tail(max_lines=3) == "line 7\nline 8\nline 9" + + +def test_tail_truncates_long_line(): + builder = ToolResultBuilder() + builder.write("x" * 500 + "\n") + tail = builder.tail(max_line_len=100) + assert tail.endswith("...") + assert len(tail) == 103 + + +def test_tail_handles_multiple_writes(): + builder = ToolResultBuilder() + builder.write("stdout chunk\n") + builder.write("stderr: permission denied\n") + assert builder.tail(max_lines=2) == "stdout chunk\nstderr: permission denied"