Compare commits

...

3 commits

Author SHA1 Message Date
ba8b0e3bf6 fix(translation): skip ThinkingBlock when reading Claude replies
- Join only content blocks that expose .text so live Anthropic calls
  no longer crash when thinking precedes the TextBlock
- Add unit coverage for thinking-then-text response shapes
2026-07-25 13:22:07 -05:00
fca1f8c1c6 fix(config): render preset menus with opaque backgrounds
- Apply solid popup styling to API endpoint preset menus
- Enforce opaque painting across supported Qt platforms
- Add regression coverage for preset menu transparency
2026-07-25 08:38:04 -05:00
33038978cc fix(update): preserve archive metadata in git checkouts
- Skip expanded archive metadata when updating Git worktrees
- Retain commit metadata for archive-only installations
- Cover repositories, linked worktrees, and packaged installs
2026-07-25 08:27:43 -05:00
6 changed files with 197 additions and 5 deletions

View file

@ -200,6 +200,61 @@ class ConfigComboBox(QComboBox):
]))
class ConfigMenu(QMenu):
"""Popup menu with the same fully opaque surface as config dropdowns."""
_BACKGROUND = QColor("#353539")
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("""
QMenu {
background-color: #353539;
color: #f2f2f2;
border: 1px solid #56565b;
padding: 2px;
}
QMenu::item {
background-color: #353539;
color: #f2f2f2;
min-height: 26px;
padding: 3px 9px;
}
QMenu::item:selected {
background-color: #007acc;
color: #ffffff;
}
""")
self._apply_opaque_background()
def _apply_opaque_background(self):
self.setWindowOpacity(1.0)
self.setAttribute(Qt.WA_TranslucentBackground, False)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QPalette.Window, self._BACKGROUND)
palette.setColor(QPalette.Base, self._BACKGROUND)
palette.setColor(QPalette.Button, self._BACKGROUND)
palette.setColor(QPalette.Text, QColor("#f2f2f2"))
palette.setColor(QPalette.ButtonText, QColor("#f2f2f2"))
palette.setColor(QPalette.Highlight, QColor("#007acc"))
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
self.setPalette(palette)
def showEvent(self, event):
self._apply_opaque_background()
super().showEvent(event)
self._apply_opaque_background()
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), self._BACKGROUND)
painter.end()
super().paintEvent(event)
class ApiKeyEditDialog(QDialog):
"""Dialog to create or overwrite a named API key (secret entered once)."""
@ -256,7 +311,7 @@ class ApiKeyEditDialog(QDialog):
endpoint_preset_btn = QToolButton()
endpoint_preset_btn.setText("Presets ▾")
endpoint_preset_btn.setPopupMode(QToolButton.InstantPopup)
endpoint_menu = QMenu(endpoint_preset_btn)
endpoint_menu = ConfigMenu(endpoint_preset_btn)
for label, url in (
("OpenAI", "https://api.openai.com/v1"),
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
@ -663,7 +718,7 @@ class ConfigTab(QWidget):
self.api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
self.api_url_preset_btn.setStyleSheet(btn_style)
api_url_menu = QMenu(self.api_url_preset_btn)
api_url_menu = ConfigMenu(self.api_url_preset_btn)
for name, url in (
("OpenAI", "https://api.openai.com/v1"),
("Claude (Anthropic)", "https://api.anthropic.com/v1"),

View file

@ -131,6 +131,20 @@ class UpdateThread(QThread):
return False
return True
@classmethod
def should_install_to_root(cls, rel: Path, root: Path) -> bool:
"""Return True when *rel* may be copied into the selected install root.
Git expands ``.git_archival.txt`` while creating a source archive. Keep
that expanded file in archive-only installs, but do not copy it back
into a live checkout where it would dirty the worktree on every update.
"""
if not cls.should_install(rel):
return False
if rel.as_posix() == ".git_archival.txt" and (root / ".git").exists():
return False
return True
@classmethod
def resolve_archive_root(cls, extract_dir: Path) -> Path:
"""Locate the single top-level folder inside an extracted archive.
@ -281,7 +295,11 @@ class UpdateThread(QThread):
install_files = [
src
for src in extracted.rglob("*")
if src.is_file() and self.should_install(src.relative_to(extracted))
if src.is_file()
and self.should_install_to_root(
src.relative_to(extracted),
root,
)
]
if not install_files:
raise RuntimeError(

View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tests for Anthropic content-block text extraction."""
import unittest
from types import SimpleNamespace
from util.translation import _anthropic_content_text
class AnthropicContentTextTests(unittest.TestCase):
def test_empty_content(self):
self.assertEqual(_anthropic_content_text(None), "")
self.assertEqual(_anthropic_content_text([]), "")
def test_text_block_only(self):
content = [SimpleNamespace(type="text", text='{"0":"Hello"}')]
self.assertEqual(_anthropic_content_text(content), '{"0":"Hello"}')
def test_skips_thinking_block_without_text(self):
# Mirrors Anthropic ThinkingBlock: has .thinking, no .text.
thinking = SimpleNamespace(type="thinking", thinking="internal notes")
text = SimpleNamespace(type="text", text='{"0":"Alice"}')
self.assertEqual(_anthropic_content_text([thinking, text]), '{"0":"Alice"}')
def test_accessing_first_block_text_would_fail(self):
thinking = SimpleNamespace(type="thinking", thinking="notes")
text = SimpleNamespace(type="text", text="ok")
content = [thinking, text]
with self.assertRaises(AttributeError):
_ = content[0].text
self.assertEqual(_anthropic_content_text(content), "ok")
if __name__ == "__main__":
unittest.main()

View file

@ -68,6 +68,51 @@ class UpdateThreadInstallFilterTests(unittest.TestCase):
with self.subTest(rel=rel):
self.assertFalse(UpdateThread.should_install(Path(rel)))
def test_preserves_archive_metadata_in_git_checkout(self):
from gui.main import UpdateThread
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
(root / ".git").mkdir()
self.assertFalse(
UpdateThread.should_install_to_root(
Path(".git_archival.txt"),
root,
)
)
self.assertTrue(
UpdateThread.should_install_to_root(Path("gui/main.py"), root)
)
def test_preserves_archive_metadata_with_git_file(self):
"""Linked worktrees and submodules use a .git file, not a directory."""
from gui.main import UpdateThread
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
(root / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8")
self.assertFalse(
UpdateThread.should_install_to_root(
Path(".git_archival.txt"),
root,
)
)
def test_installs_archive_metadata_outside_git_checkout(self):
from gui.main import UpdateThread
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
self.assertTrue(
UpdateThread.should_install_to_root(
Path(".git_archival.txt"),
root,
)
)
class UpdateThreadArchiveRootTests(unittest.TestCase):
"""Gitea zips nest files under a single top folder (repo display name)."""

View file

@ -279,6 +279,33 @@ class ConfigTabRegressionTests(unittest.TestCase):
self.assertFalse(popup.testAttribute(Qt.WA_TranslucentBackground))
tab.model_combo.hidePopup()
def test_presets_menu_is_opaque(self) -> None:
tab = self.make_tab()
window = QMainWindow()
self._windows.append(window)
window.setCentralWidget(tab)
window.resize(1280, 760)
window.show()
for _ in range(3):
self._app.processEvents()
menu = tab.api_url_preset_btn.menu()
menu.popup(
tab.api_url_preset_btn.mapToGlobal(
tab.api_url_preset_btn.rect().bottomLeft()
)
)
for _ in range(3):
self._app.processEvents()
self.assertTrue(menu.autoFillBackground())
self.assertEqual(menu.palette().color(QPalette.Window).name(), "#353539")
self.assertEqual(menu.palette().color(QPalette.Base).name(), "#353539")
self.assertEqual(menu.windowOpacity(), 1.0)
self.assertFalse(menu.testAttribute(Qt.WA_TranslucentBackground))
self.assertTrue(menu.testAttribute(Qt.WA_OpaquePaintEvent))
menu.hide()
def test_save_and_reload_round_trip_for_every_option(self) -> None:
tab = self.make_tab()
tab.disconnect_auto_save()

View file

@ -1187,7 +1187,7 @@ def fetchTranslationBatches(batches=None):
errored.append((r.custom_id, detail))
continue
msg = res.message
text = "".join(getattr(b, "text", "") or "" for b in msg.content)
text = _anthropic_content_text(msg.content)
u = msg.usage
cr = getattr(u, "cache_read_input_tokens", 0) or 0
cw = getattr(u, "cache_creation_input_tokens", 0) or 0
@ -1998,6 +1998,17 @@ def createTranslationSchema(numLines):
}
def _anthropic_content_text(content) -> str:
"""Join text from Anthropic message content blocks.
Newer Claude models often return a ThinkingBlock (no ``.text``) before the
TextBlock. Only blocks that expose ``.text`` contribute to the result.
"""
if not content:
return ""
return "".join(getattr(b, "text", "") or "" for b in content)
class _AnthropicCompat:
"""OpenAI-shaped wrapper around an Anthropic response (text + usage)."""
class _Usage:
@ -2232,7 +2243,7 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No
except Exception as e:
raise Exception(f"Anthropic API error: {e}")
_ant_text = ant_resp.content[0].text if ant_resp.content else ""
_ant_text = _anthropic_content_text(ant_resp.content)
_u = ant_resp.usage
_cr = getattr(_u, "cache_read_input_tokens", 0) or 0
_cw = getattr(_u, "cache_creation_input_tokens", 0) or 0