Skip to content
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,40 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Fix: tool outputs invisible on Anthropic-compatible proxies (GLM-5.2 via z.ai).**
`api.z.ai/api/anthropic` only surfaces the first content block of a multi-part
`tool_result`, so the leading `<system>` summary reached GLM-5.2 while the actual tool
payload was dropped — every Shell/ReadFile/Grep result read as a "success" summary with
no output (reproduced from a live GLM-5.2 session transcript). Tool results are now
flattened to a single text block for non-native hosts via a transport-keyed resolver
(`resolve_tool_result_mode`), while genuine `api.anthropic.com` keeps the rich
multi-part form. The same single-string mode is applied defensively to non-native
OpenAI-compatible hosts (lossless for text), most relevant to GLM served over z.ai's
OpenAI endpoint; genuine `api.openai.com` is unchanged.
- **TUI: diff cards strip terminal control sequences.** Inline file-diff bodies
(Update/Write cards, approval and pager diffs) now sanitize ANSI/control escapes
from the untrusted file and model-supplied edit content before rendering, so a
crafted edit can no longer smuggle cursor-movement or color escapes into the
terminal through a diff card. Visible text is preserved.
- **TUI: interactive resize/handoff ghosting.** Scrollback handoffs in prompt mode
now fully suppress the transient preamble (agent stream body, verb spinner, and
tips) while ``run_in_terminal`` emits permanent scrollback, so stacked
``Vibing…`` rows and duplicate tips no longer fossilize during tool transitions.
Terminal resize triggers a hard preamble invalidation and briefly hides tips
while prompt_toolkit settles at the new geometry. Handoffs defer during resize
recovery; failed emits leave scrollback queued for retry instead of dropping it.
Outermost turn end always flushes completed prose even when recovery is active,
so PTY sessions no longer stall on ``Finalizing…`` without emitting the response.
- **DiffLive streaming scroll geometry.** Non-interactive live streaming now uses
cursor-down only when the next row provably fits the visible terminal region
(frame origin + target row vs height); otherwise it falls back to newline scroll,
preventing mid-viewport overwrite when the live frame starts below the top of the
screen. Set `PYTHINKER_DIFF_LIVE_LOG` to trace DiffLive refresh/growth ticks.
- **TUI: clearer collapsed ReadFile cards.** Collapsed reads now show a line-count
summary with the file name (e.g. `Read 140 lines from console.py`) plus a short,
width-capped preview of the leading lines, instead of the generic `Read 1 file`
that made it look like no content was returned. Empty/unknown reads stay truthful
(`Read 0 lines` / `Read file content`), and expanded mode still shows the full file.
- **Cleaner terminal report rendering.** Structured ` ```report ` outputs now suppress duplicated trailing summaries, keep only artifact footers after the report, compact long finding locations, and switch large reports to a borderless dashboard layout for faster terminal scanning.
- **Unknown subagent-type recovery hints.** Invalid types still fail loudly, but
`Agent`/`RunAgents` errors now include best-effort suggestions for common
Expand Down
59 changes: 56 additions & 3 deletions src/pythinker_code/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from pythinker_code.utils.logging import logger

if TYPE_CHECKING:
from pythinker_core.contrib.chat_provider.common import ToolMessageConversion

from pythinker_code.auth.oauth import OAuthManager
from pythinker_code.config import Config, LLMModel, LLMProvider

Expand Down Expand Up @@ -61,6 +63,24 @@ def model_name(self) -> str:
# through `api.anthropic.com` (see `auth/anthropic_direct.py:ANTHROPIC_BASE_URL`).
_GENUINE_ANTHROPIC_HOSTS = frozenset({"api.anthropic.com"})

# Hosts that serve the genuine OpenAI API (as opposed to the many
# OpenAI-compatible proxies that reuse the chat-completions wire format).
_GENUINE_OPENAI_HOSTS = frozenset({"api.openai.com"})


def _normalize_host(base_url: str | None) -> str:
"""Lowercased hostname of `base_url`, or "" when absent/unparseable.

Single source of truth for the genuine-vs-proxy host checks so callers do not
re-implement URL parsing (and so trailing slashes, paths, and case never matter).
"""
if not base_url:
return ""
from urllib.parse import urlparse

return (urlparse(base_url).hostname or "").lower()


# Model-name substrings that do NOT support `tool_reference`. Haiku is the only
# known unsupported pattern in the deferred tool-search workflow.
_TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS = ("haiku",)
Expand Down Expand Up @@ -102,15 +122,42 @@ def supports_deferred_tool_search(llm: LLM | None) -> bool:
return False
# type="anthropic" is necessary but NOT sufficient — the compat proxies above
# share it. Only the genuine Anthropic host forwards the beta.
from urllib.parse import urlparse

host = (urlparse(provider.base_url).hostname or "").lower()
host = _normalize_host(provider.base_url)
if host not in _GENUINE_ANTHROPIC_HOSTS:
return False
model = llm.model_name.lower()
return not any(pat in model for pat in _TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS)


def resolve_tool_result_mode(
*, api_family: Literal["anthropic", "openai"], base_url: str | None
) -> ToolMessageConversion | None:
"""How `role="tool"` results should be serialized for a provider's transport.

The split that matters is NATIVE endpoint vs COMPATIBILITY PROXY, not which model:
genuine `api.anthropic.com` / `api.openai.com` consume structured multi-part
`tool_result` content faithfully, but the many proxies that merely speak the same
wire format often do not. z.ai/GLM (`api.z.ai/api/anthropic`) honors only the FIRST
content block of an array-form `tool_result`, so the leading `<system>` summary block
reaches the model while the actual tool OUTPUT block is silently dropped — every
Shell/ReadFile result reads as "success" with no payload (confirmed against GLM-5.2).

For non-native hosts we flatten the tool result to a single text block
(`extract_text`), which puts the whole payload in that first block. The flatten is
lossless for text and is the lowest-common-denominator shape every proxy accepts; it
drops any non-text tool-result block, which a first-block-only proxy could not deliver
anyway. Native hosts keep the rich multi-part form (so tool-result images survive).

Returns `None` to mean "native multi-part" (the provider default) and `"extract_text"`
to mean "flatten to one string". New families/modes plug in here, not in agent/tool code.
"""
host = _normalize_host(base_url)
native_hosts = _GENUINE_ANTHROPIC_HOSTS if api_family == "anthropic" else _GENUINE_OPENAI_HOSTS
if not host or host in native_hosts:
return None
return "extract_text"


def model_display_name(model_name: str | None, model: LLMModel | None = None) -> str:
if model is not None and model.display_name:
return model.display_name
Expand Down Expand Up @@ -293,6 +340,9 @@ def create_llm(
reasoning_key=reasoning_key,
default_headers=dict(provider.custom_headers) if provider.custom_headers else None,
http_client=rl_http_client,
tool_message_conversion=resolve_tool_result_mode(
api_family="openai", base_url=provider.base_url
),
)
case "openai_responses":
from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponses
Expand Down Expand Up @@ -333,6 +383,9 @@ def create_llm(
metadata={"user_id": session_id} if session_id else None,
default_headers=dict(provider.custom_headers) if provider.custom_headers else None,
http_client=rl_http_client,
tool_message_conversion=resolve_tool_result_mode(
api_family="anthropic", base_url=provider.base_url
),
)
case "google_genai" | "gemini":
from pythinker_core.contrib.chat_provider.google_genai import GoogleGenAI
Expand Down
8 changes: 8 additions & 0 deletions src/pythinker_code/ui/shell/components/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from rich.table import Table
from rich.text import Text

from pythinker_code.ui.shell.components.render_utils import sanitize_ansi
from pythinker_code.ui.shell.render_constants import (
DIFF_CONTEXT_LINES,
DIFF_LINE_NUMBER_MIN_WIDTH,
Expand Down Expand Up @@ -403,6 +404,13 @@ def render_diff(diff_text: str, *, path: str | None = None) -> RenderableType:
if not diff_text:
return Text("")

# Diff bodies carry untrusted file content and model-supplied edit text. Strip
# ANSI/control sequences before rendering so a crafted edit can't smuggle
# cursor-movement or color escapes into the terminal through the diff card.
# sanitize_ansi keeps newlines and tabs, so +/- prefix and line-number parsing
# below is unaffected.
diff_text = sanitize_ansi(diff_text)

colors = get_diff_colors()
# Added/removed rows are distinguished by background tint only; line numbers,
# +/- markers, and code content all use the terminal's default foreground
Expand Down
104 changes: 96 additions & 8 deletions src/pythinker_code/ui/shell/tool_renderers/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@

from __future__ import annotations

import re
from pathlib import PurePosixPath, PureWindowsPath
from typing import Any

from rich.console import Group, RenderableType
from rich.text import Text

from pythinker_code.ui.shell.components import sanitize_ansi
from pythinker_code.ui.shell.components.render_utils import truncate_to_width
from pythinker_code.ui.shell.tool_renderers import (
ToolRenderContext,
ToolRenderDefinition,
Expand All @@ -27,10 +31,16 @@
pending_tool_call_header,
running_spinner,
shorten_path,
tab_to_spaces,
tool_call_header,
)
from pythinker_code.ui.theme import tui_rich_style

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


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


def _basename(path: Any) -> str | None:
"""Display basename for a read path, or ``None`` when unavailable.

The call row already shows the (shortened) path, so the result summary only
needs the leaf name — and never a fuller path that would leak more than the
call row already does. Handle both POSIX and Windows separators since the
path is model-supplied text, not a resolved local path.
"""
raw = as_str(path)
if raw is None or not raw.strip():
return None
name = PureWindowsPath(PurePosixPath(raw).name).name
return name or None


def _line_count(message: str | None, output_text: str) -> int | None:
"""Lines read in this call: prefer the tool message, else count the body.

Returns ``None`` only when the count is genuinely unknowable (no message and
no body) — callers must then avoid asserting a count rather than lie with 0.
"""
if message:
match = _LINES_READ_RE.search(message)
if match:
return int(match.group(1))
if "no lines read" in message.lower():
return 0
if output_text:
cleaned = output_text.rstrip("\n")
return cleaned.count("\n") + 1 if cleaned else 0
return None


def _summary_text(count: int | None, basename: str | None) -> str:
if count is None:
head = "Read file content"
else:
head = f"Read {count} {'line' if count == 1 else 'lines'}"
if basename:
head += f" from {basename}"
return head


def _preview(output_text: str, width: int) -> Text | None:
"""A few leading body lines, ANSI-stripped and width-capped per line.

Caps both the number of visual lines (``_PREVIEW_MAX_LINES``) and each
line's width so a file with very long lines can never produce giant
collapsed output. Returns ``None`` when there is nothing friendly to show.
"""
cleaned = sanitize_ansi(output_text or "").rstrip("\n")
if not cleaned:
return None
# Width ceiling keyed off terminal cell width, leaving room for the card
# gutter so each preview line stays on a single visual row. On a terminal
# too narrow to show anything useful, skip the preview entirely.
limit = min(max(width - 6, 0), 200)
if limit < 12:
return None
out = Text(style=tui_rich_style("tool_output"))
for index, line in enumerate(cleaned.split("\n")[:_PREVIEW_MAX_LINES]):
if index:
out.append("\n")
# Cell-width aware: a wide-glyph / CJK / emoji line is truncated by the
# space it actually occupies, not its character count.
out.append(truncate_to_width(tab_to_spaces(line), limit))
out.no_wrap = True
out.overflow = "ellipsis"
return out if out.plain else None


def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None:
ctx.state["__suppress_generic_expand_hint__"] = True
if result.is_error:
Expand All @@ -109,13 +190,24 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera
if isinstance(message, str) and message.startswith("Directory listing for `"):
return fg("tool_output", "Listed 1 directory")

# An empty string is a valid (empty-file) body — only fall back to the
# flattened text when ``output`` is absent/non-string, so an empty read
# never inherits unrelated metadata from ``result.text``.
output = result.details.get("output")
output_text = output if isinstance(output, str) and output else result.text
output_text = output if isinstance(output, str) else result.text

count = _line_count(message if isinstance(message, str) else None, output_text)
basename = _basename(ctx.args.get("path"))
summary = _summary_text(count, basename)

if not output_text:
return fg("tool_output", "Read 1 file")
# Nothing to preview or expand (empty file / no body): truthful summary only.
return fg("tool_output", summary)

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

start_line = 1
offset = ctx.args.get("line_offset")
Expand All @@ -128,11 +220,7 @@ def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> Rendera
start_line=start_line,
style_token="tool_output",
)
return (
Group(fg("tool_output", "Read 1 file"), body)
if body.plain
else fg("tool_output", "Read 1 file")
)
return Group(fg("tool_output", summary), body) if body.plain else fg("tool_output", summary)


READ_RENDERER = ToolRenderDefinition(
Expand Down
Loading
Loading