aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/ui.py78
-rwxr-xr-xtest_llamachat.py79
2 files changed, 151 insertions, 6 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py
index 3ea3598..7c2e113 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -16,6 +16,7 @@
import dataclasses
import datetime
import html
+import itertools
import json
import re
from pathlib import Path
@@ -27,8 +28,8 @@ from PySide6.QtGui import (
QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
)
from PySide6.QtWidgets import (
- QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
- QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
+ QApplication, QButtonGroup, QComboBox, QDialog, QDialogButtonBox,
+ QFileDialog, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
QPushButton, QRadioButton, QSplitter, QTextBrowser, QToolButton,
QVBoxLayout, QWidget,
@@ -49,6 +50,7 @@ MODE_CHAT = "chat"
# single message expand independently.
REASONING_SCHEME = "x-llamachat-reasoning:"
SEARCH_SCHEME = "x-llamachat-search:"
+COPY_SCHEME = "x-llamachat-copy:"
class ContextMeter(QWidget):
@@ -1564,9 +1566,23 @@ class ChatWindow(QMainWindow):
if target.startswith(SEARCH_SCHEME):
self._toggle(self.expanded_searches, target[len(SEARCH_SCHEME) :])
return
+ if target.startswith(COPY_SCHEME):
+ self._copy_code(target[len(COPY_SCHEME) :])
+ return
if url.scheme() in ("http", "https", "mailto"):
QDesktopServices.openUrl(url)
+ def _copy_code(self, raw: str) -> None:
+ """Copy one code block of one bubble to the clipboard."""
+ bubble_index, _, block_index = raw.partition(".")
+ try:
+ bubble = self.bubbles[int(bubble_index)]
+ block = _code_blocks(_close_fences(bubble["text"]))[int(block_index)]
+ except (ValueError, IndexError, KeyError):
+ return # a stale link from a re-render; nothing to copy
+ QApplication.clipboard().setText(block)
+ self.show_status("Code block copied.")
+
def _toggle(self, which: set[int], raw: str) -> None:
try:
index = int(raw)
@@ -1743,7 +1759,10 @@ MARKDOWN_FLAGS = (
# own. Giving those a tint is what makes a code block read as a block.
_PRE_STYLE = (
"background:rgba(127,127,127,0.18);padding:1px 6px;margin:0;"
- "font-family:monospace"
+ # Qt's own markdown stylesheet wraps p and li but not pre, and that
+ # stylesheet is dropped when only the body fragment is kept. Without
+ # this a long line widens the whole transcript instead of wrapping.
+ "font-family:monospace;white-space:pre-wrap"
)
@@ -1774,7 +1793,26 @@ def _close_fences(text: str) -> str:
return text
-def _markdown_to_fragment(text: str) -> str:
+_BLOCK_RE = re.compile(
+ r"^(?P<indent>\s{0,3})(?P<fence>`{3,}|~{3,})[^\n]*\n"
+ r"(?P<body>.*?)"
+ r"^\s{0,3}(?P=fence)`*~*[ \t]*$",
+ re.MULTILINE | re.DOTALL,
+)
+
+
+def _code_blocks(text: str) -> list[str]:
+ """The text of each fenced block, in the order they appear.
+
+ Copying needs the source, not the rendered HTML: Qt emits one <pre> per
+ line, so the block is not recoverable from the fragment. Parsing the
+ same markdown the renderer sees keeps the two in step, and _close_fences
+ has already terminated a block still streaming.
+ """
+ return [m.group("body").rstrip("\n") for m in _BLOCK_RE.finditer(text)]
+
+
+def _markdown_to_fragment(text: str, index: int | None = None) -> str:
"""Render markdown to an HTML fragment safe to splice into the page.
QTextDocument does the parsing, so there is no markdown dependency. Its
@@ -1801,10 +1839,38 @@ def _markdown_to_fragment(text: str) -> str:
# so their scheme is defused too.
fragment = fragment.replace(f'href="{REASONING_SCHEME}', 'href="blocked:')
fragment = fragment.replace(f'href="{SEARCH_SCHEME}', 'href="blocked:')
+ fragment = fragment.replace(f'href="{COPY_SCHEME}', 'href="blocked:')
# Tint code blocks, which Qt leaves unstyled. Qt emits one <pre> per
# line, so the tint has to land on every one of them to read as a block.
- return _PRE_RE.sub(_tint_pre, fragment)
+ fragment = _PRE_RE.sub(_tint_pre, fragment)
+ if index is None:
+ return fragment
+ return _add_copy_links(fragment, index)
+
+
+# The first <pre> of a run is where a copy header goes; the rest are the
+# same block's later lines. A run ends at the first tag that is not a <pre>.
+_PRE_RUN_RE = re.compile(r"(?:<pre\b[^>]*>.*?</pre>\s*)+", re.DOTALL)
+
+
+def _add_copy_links(fragment: str, index: int) -> str:
+ """Put a copy link above each code block.
+
+ Qt rich text has no widgets, so the button is an anchor in the existing
+ scheme-dispatch, the same mechanism the thinking and search toggles use.
+ """
+ counter = itertools.count()
+
+ def header(match: re.Match) -> str:
+ link = (
+ f'<div style="margin:4px 0 0 0;text-align:right">'
+ f'<a href="{COPY_SCHEME}{index}.{next(counter)}" '
+ f'style="color:palette(mid);text-decoration:none">⧉ copy</a></div>'
+ )
+ return link + match.group(0)
+
+ return _PRE_RUN_RE.sub(header, fragment)
_PRE_RE = re.compile(r"<pre(\s+style=\"([^\"]*)\")?\s*>")
@@ -1967,7 +2033,7 @@ def _bubble_html(
# Replies are markdown; what the user typed is shown as typed, so an
# attached file's contents cannot be reflowed into headings and lists.
if role == "assistant":
- body = _markdown_to_fragment(text)
+ body = _markdown_to_fragment(text, index)
else:
body = html.escape(text).replace("\n", "<br>")
diff --git a/test_llamachat.py b/test_llamachat.py
index bb5f097..7a84608 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -441,6 +441,83 @@ def test_unbalanced_backticks():
print("ok unbalanced backticks")
+def test_code_block_wrapping():
+ """A long code line wraps instead of widening the whole transcript."""
+ import os
+
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtGui import QTextDocument
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat.ui import _markdown_to_fragment
+
+ app = QApplication.instance() or QApplication([])
+ assert app is not None
+
+ # Qt's markdown stylesheet wraps p and li but not pre, and it is dropped
+ # when only the body fragment is kept, so pre has to say so itself.
+ fragment = _markdown_to_fragment("```\n" + "A" * 200 + "\n```\n")
+ assert "white-space:pre-wrap" in fragment, fragment
+
+ # The real check is the layout: no block may exceed the viewport.
+ doc = QTextDocument()
+ doc.setHtml(fragment)
+ doc.setTextWidth(300)
+ layout = doc.documentLayout()
+ widest = 0.0
+ block = doc.begin()
+ while block.isValid():
+ widest = max(widest, layout.blockBoundingRect(block).width())
+ block = block.next()
+ assert widest <= 300, widest
+ print("ok code block wrapping")
+
+
+def test_code_block_copy_links():
+ """Each fenced block gets a copy link addressing it by index."""
+ import os
+ import re as _re
+
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ from PySide6.QtWidgets import QApplication
+
+ from llamachat.ui import (
+ COPY_SCHEME, _close_fences, _code_blocks, _markdown_to_fragment,
+ )
+
+ app = QApplication.instance() or QApplication([])
+ assert app is not None
+
+ # Blocks are read from the source: Qt emits one <pre> per line, so the
+ # text is not recoverable from the rendered fragment.
+ assert _code_blocks("```py\nx=1\ny=2\n```\n") == ["x=1\ny=2"]
+ assert _code_blocks("~~~\na\n~~~\n") == ["a"]
+ assert _code_blocks("no code here") == []
+ # A longer fence can hold shorter ones without ending the block.
+ assert _code_blocks("````\n```\ninner\n```\n````\n") == ["```\ninner\n```"]
+
+ # A link per block, numbered bubble.block so several replies coexist.
+ markdown = "one\n\n```\nAAA\n```\n\ntwo\n\n```\nBBB\n```\n"
+ fragment = _markdown_to_fragment(markdown, 3)
+ assert _re.findall(r"x-llamachat-copy:([0-9.]+)", fragment) == ["3.0", "3.1"]
+
+ # The indices the links carry must select the blocks the parser found.
+ blocks = _code_blocks(_close_fences(markdown))
+ assert blocks == ["AAA", "BBB"], blocks
+
+ # A block still streaming is closed first, so it is copyable mid-reply.
+ assert _code_blocks(_close_fences("```\nhalf")) == ["half"]
+
+ # Without an index there are no links, so a bubble that cannot be
+ # addressed does not emit a link that would not resolve.
+ assert COPY_SCHEME not in _markdown_to_fragment(markdown)
+
+ # A reply cannot forge one: the scheme is defused like the others.
+ forged = _markdown_to_fragment(f"[copy]({COPY_SCHEME}0.0)", 0)
+ assert f'href="{COPY_SCHEME}' not in forged, forged
+ print("ok code block copy links")
+
+
def test_system_qt_theme_guard():
"""The plugin path is only borrowed when the Qt versions agree."""
import os
@@ -3260,4 +3337,6 @@ if __name__ == "__main__":
test_title_request()
test_needs_title()
test_title_prompt()
+ test_code_block_wrapping()
+ test_code_block_copy_links()
print("\nall checks passed")