From 809931e0b4f85dd7f551108b99673279884528c2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 03:00:27 -0400 Subject: [PATCH 1/3] Draw blockquotes as greentext (draft) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V From 9a41dba4a62df429d8fad28379043b4523ed6c2a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 03:01:52 -0400 Subject: [PATCH 2/3] Draw blockquotes as greentext A quoted line keeps a visible `>` marker and renders green (GreenText replaces Rich's magenta bar element in the transcript console), and the conversation parser turns soft breaks inside a blockquote into hard breaks so every quoted source line stays its own line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V --- src/toad/markdown_links.py | 13 +++++++++++++ src/toad/markdown_preparation.py | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 91b237c63..3d17e52c9 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -161,10 +161,23 @@ def resolve(self, tokens: list[Token], context: _ProjectTokens): return result +def greentext_lines(state: StateCore) -> None: + """Each quoted source line stays its own line, as greentext.""" + depth = 0 + for token in state.tokens: + if token.tag == "blockquote": + depth += token.nesting + if depth and token.children: + for child in token.children: + if child.type == "softbreak": + child.type = "hardbreak" + + class _MarkdownParserState(local): def __init__(self): self.parser = MarkdownIt("gfm-like") self.parser.validateLink = ProjectLink.opens + self.parser.core.ruler.push("greentext_lines", greentext_lines) self.resolver = ProjectTokenRenderer() self.syntax_rules = tuple(self.parser.core.ruler.getRules("")) diff --git a/src/toad/markdown_preparation.py b/src/toad/markdown_preparation.py index f1650f80b..4e9fda167 100644 --- a/src/toad/markdown_preparation.py +++ b/src/toad/markdown_preparation.py @@ -6,8 +6,9 @@ from markdown_it.token import Token from rich.console import Console -from rich.markdown import CodeBlock, Markdown +from rich.markdown import BlockQuote, CodeBlock, Markdown from rich.padding import Padding +from rich.segment import Segment from rich.syntax import Syntax from toad.markdown_links import MessageMarkdownParser, parse_markdown_syntax @@ -24,6 +25,19 @@ def __rich_console__(self, console, options): yield Padding(self.text, 1, style=self.text.style) +class GreenText(BlockQuote): + """A quoted line keeps its `>` and reads green.""" + + style_name = "green" + + def __rich_console__(self, console, options): + marker, new_line = Segment("> ", self.style), Segment("\n") + for line in console.render_lines(self.elements, options.update(width=options.max_width - 2), style=self.style): + yield marker + yield from line + yield new_line + + class MarkdownStyleConsole(Console): """Rich builds its styled element tree; width-dependent rendering runs later.""" @@ -34,7 +48,8 @@ def render(self, renderable, options=None): def prepare(self, text, context): self.parts = [] markdown = Markdown("") - markdown.elements = {**markdown.elements, "fence": StyledCodeBlock, "code_block": StyledCodeBlock} + markdown.elements = {**markdown.elements, "fence": StyledCodeBlock, "code_block": StyledCodeBlock, + "blockquote_open": GreenText} markdown.parsed = MessageMarkdownParser(context).parse(text) for segment in markdown.__rich_console__(self, self.options): self.parts.append(segment) From d814458b54bcfc0853226fc85220e09895c73cdb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 25 Oct 2026 03:20:21 -0400 Subject: [PATCH 3/3] Quote blocks own their line breaks; greentext takes its theme role The greentext core rule walked every token of every message, counted quote depth through tag string compares and rewrote softbreak tokens by type string. It is deleted. Blockquote open/close and softbreak join the existing ProjectTokenRule family: the innermost open block (JoinedLines or QuotedLines) answers what a source newline draws as. GreenText's "green" literal is replaced by GreenTextRole, resolved through the line theme like every other transcript role. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ejYrMLnLWah8wCDDcb57V --- src/toad/line_blocks.py | 6 +++ src/toad/markdown_links.py | 55 ++++++++++++++++++++-------- src/toad/markdown_preparation.py | 10 ++++- src/toad/widgets/transcript_lines.py | 4 +- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 3c9f887cc..11a2e8927 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -114,6 +114,12 @@ class OutgoingRole(LineRole): colour = SuccessColour +class GreenTextRole(LineRole): + """A quoted line, drawn as greentext.""" + + colour = SuccessColour + + class NoticeRole(LineRole): colour = WarningColour diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 3d17e52c9..43618e003 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -4,7 +4,7 @@ import re from abc import abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import partial from pathlib import Path from threading import local @@ -77,10 +77,28 @@ def matches(self, text: str): return sorted(found) +class JoinedLines: + """Paragraph source lines join with a space, as Markdown draws them.""" + + @staticmethod + def softbreak(token: Token) -> Token: + return token + + +class QuotedLines(JoinedLines): + """Every quoted source line stays its own line, as greentext.""" + + @staticmethod + def softbreak(token: Token) -> Token: + return Token("hardbreak", "br", 0) + + @dataclass(slots=True) class _ProjectTokens: links: LinkContext linked: int = 0 + # The innermost open block decides how a source newline inside it is drawn. + lines: list[type[JoinedLines]] = field(default_factory=lambda: [JoinedLines]) class ProjectTokenRule(DeclaredFamily, affix="TokenRule"): @@ -93,10 +111,30 @@ class InlineTokenRule(ProjectTokenRule): @classmethod def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): if token.children: - token.children = renderer.resolve(token.children, _ProjectTokens(context.links)) + token.children = renderer.resolve(token.children, _ProjectTokens(context.links, lines=context.lines)) + return [token] + + +class BlockquoteOpenTokenRule(ProjectTokenRule): + @classmethod + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): + context.lines.append(QuotedLines) return [token] +class BlockquoteCloseTokenRule(ProjectTokenRule): + @classmethod + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): + context.lines.pop() + return [token] + + +class SoftbreakTokenRule(ProjectTokenRule): + @classmethod + def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): + return [context.lines[-1].softbreak(token)] + + class LinkOpenTokenRule(ProjectTokenRule): @classmethod def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _ProjectTokens): @@ -161,23 +199,10 @@ def resolve(self, tokens: list[Token], context: _ProjectTokens): return result -def greentext_lines(state: StateCore) -> None: - """Each quoted source line stays its own line, as greentext.""" - depth = 0 - for token in state.tokens: - if token.tag == "blockquote": - depth += token.nesting - if depth and token.children: - for child in token.children: - if child.type == "softbreak": - child.type = "hardbreak" - - class _MarkdownParserState(local): def __init__(self): self.parser = MarkdownIt("gfm-like") self.parser.validateLink = ProjectLink.opens - self.parser.core.ruler.push("greentext_lines", greentext_lines) self.resolver = ProjectTokenRenderer() self.syntax_rules = tuple(self.parser.core.ruler.getRules("")) diff --git a/src/toad/markdown_preparation.py b/src/toad/markdown_preparation.py index 4e9fda167..2987e483d 100644 --- a/src/toad/markdown_preparation.py +++ b/src/toad/markdown_preparation.py @@ -10,6 +10,7 @@ from rich.padding import Padding from rich.segment import Segment from rich.syntax import Syntax +from toad.line_blocks import GreenTextRole from toad.markdown_links import MessageMarkdownParser, parse_markdown_syntax @@ -26,9 +27,10 @@ def __rich_console__(self, console, options): class GreenText(BlockQuote): - """A quoted line keeps its `>` and reads green.""" + """A quoted line keeps its `>` and reads in the greentext role's colour.""" - style_name = "green" + def on_enter(self, context): + self.style = context.enter_style(context.console.line_theme.style(GreenTextRole)) def __rich_console__(self, console, options): marker, new_line = Segment("> ", self.style), Segment("\n") @@ -41,6 +43,10 @@ def __rich_console__(self, console, options): class MarkdownStyleConsole(Console): """Rich builds its styled element tree; width-dependent rendering runs later.""" + def __init__(self, line_theme, **options): + super().__init__(color_system=line_theme.color_system, **options) + self.line_theme = line_theme + def render(self, renderable, options=None): self.parts.append(renderable) return iter(()) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index c5d26db72..d454e6edf 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -729,9 +729,7 @@ def body(self, block: Body) -> LineBlock: return block.prepare(self.markdown) def markdown(self, block: Body) -> StyledBody: - console = MarkdownStyleConsole( - force_terminal=True, file=io.StringIO(), color_system=self.preparation.theme.color_system - ) + console = MarkdownStyleConsole(self.preparation.theme, force_terminal=True, file=io.StringIO()) return StyledBody(block, console.prepare(block.text, self.context)) @handles(WireBody)