Skip to content

Commit fb8a0ea

Browse files
committed
Merge feat/tui-streaming-phase0 into main
2 parents 9e1edb4 + 9081f3f commit fb8a0ea

55 files changed

Lines changed: 2338 additions & 408 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,24 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Tool header highlights.** Read/Write/Edit/Grep and similar tool-call subjects
19+
now use the brand periwinkle `accent` token instead of cyan `info`; line ranges
20+
stay on the yellow `warning` token.
21+
- **pythinker-x theme port.** Diff palette, 32 bundled syntax theme names, Catppuccin
22+
Frappe/Macchiato styles, and `/theme code` syntax picker aligned with the Pythinker-X TUI.
23+
- **TUI inline code color.** Inline `` `code` `` highlights and the `pythinker-ansi`
24+
syntax theme now use brand periwinkle/accent and blue ANSI roles instead of cyan.
25+
- **TUI transcript spacing.** User prompts leave one blank row before the agent stream
26+
starts; finished tool cards and flushed agent paragraphs leave a trailing blank row
27+
before the next block (Bash/Read output → next ⏺ paragraph, etc.).
28+
- **Welcome banner colors.** Branch uses light neutral grey; model name uses the muted
29+
yellow warning token.
1830
- **TUI theme package.** Centralize dark/light palettes, prompt classes, and Rich/PTK
1931
adapters in `ui/theme/` with `/theme current|doctor|tokens` inspection commands.
32+
- **TUI diff markers.** Inline diff rows now leave a space after `+`/`-` markers so
33+
`@`-prefixed lines (e.g. CSS `@keyframes`) do not run together with the sign.
34+
- **Composing block spacing.** Staged agent paragraphs keep one blank row before the
35+
Composing activity line while the stream is still live.
2036
- **Slash input UX.** Prefix-highlight skills and plugins while typing; ghost-complete
2137
and highlight fixed subcommands such as `/theme current`.
2238
- **TUI streaming smoothness (Phase 0).** Coalesce Rich Live repaints to a 25 Hz frame budget,
@@ -27,6 +43,11 @@ GitHub Releases page; `0.8.0` is the new starting line.
2743
(go-to-definition, find-references, hover, symbols, call hierarchy) with session-scoped
2844
server lifecycle, passive diagnostics injected after file edits, and plugin-based server
2945
discovery/recommendation — no bundled language-server binaries.
46+
- **Token activity card.** `/usage daily|weekly|cumulative` (and the bare `/usage` default
47+
when no provider adapter is configured) now render a 52-week × 7-day heatmap of
48+
total tokens consumed each day, with a `Lifetime · Peak · Streak · Longest task` summary
49+
line and a footer that lets the user switch between daily/weekly/cumulative views. Data is
50+
read from the local session wire files; the per-provider adapter behavior is unchanged.
3051

3152
## 0.47.0 (2026-06-16)
3253

docs/public/install.sh

Lines changed: 194 additions & 59 deletions
Large diffs are not rendered by default.

scripts/install-native.sh

Lines changed: 195 additions & 60 deletions
Large diffs are not rendered by default.

src/pythinker_code/ui/shell/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,11 +2402,9 @@ def _value_style_for_label(label: str, level: WelcomeInfoItem.Level) -> str:
24022402
if label == "Session":
24032403
return tokens.dim or "grey39"
24042404
if label == "Model":
2405-
return f"bold {tokens.text}" if tokens.text else "bold bright_white"
2405+
return tokens.warning or "#EAB85F"
24062406
if label == "Branch":
2407-
from pythinker_code.ui.theme import get_statusline_colors
2408-
2409-
return get_statusline_colors().branch.removeprefix("fg:")
2407+
return tokens.thinking_text or "grey70"
24102408
if label == "Auto-save":
24112409
return tokens.muted or "grey50"
24122410
return level.value

src/pythinker_code/ui/shell/components/diff.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,28 +313,28 @@ def _newline() -> None:
313313
_replace_tabs(acontent),
314314
)
315315
_newline()
316-
row = Text(f"{rln} -", style=removed_sign)
316+
row = Text(f"{rln} - ", style=removed_sign)
317317
# Underlay the row tint so word-level highlight spans stay on top.
318318
rem_inner.stylize_before(removed_body)
319319
row.append_text(rem_inner)
320320
out.append_text(row)
321321
_newline()
322-
row = Text(f"{aln} +", style=added_sign)
322+
row = Text(f"{aln} + ", style=added_sign)
323323
add_inner.stylize_before(added_body)
324324
row.append_text(add_inner)
325325
out.append_text(row)
326326
else:
327327
for ln, content in removed_block:
328328
_newline()
329-
out.append(f"{ln} -", style=removed_sign)
329+
out.append(f"{ln} - ", style=removed_sign)
330330
out.append(_replace_tabs(content), style=removed_body)
331331
for ln, content in added_block:
332332
_newline()
333-
out.append(f"{ln} +", style=added_sign)
333+
out.append(f"{ln} + ", style=added_sign)
334334
out.append(_replace_tabs(content), style=added_body)
335335
elif prefix == "+":
336336
_newline()
337-
out.append(f"{line_num} +", style=added_sign)
337+
out.append(f"{line_num} + ", style=added_sign)
338338
out.append(_replace_tabs(content), style=added_body)
339339
i += 1
340340
else:

src/pythinker_code/ui/shell/components/markdown.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,8 @@ class PythinkerMarkdown(Markdown):
792792
"table_open": _ReportTableElement,
793793
}
794794

795-
def __init__(self, markup: str, *args: Any, **kwargs: Any) -> None:
795+
def __init__(self, markup: str, *args: Any, report: bool = False, **kwargs: Any) -> None:
796+
self._report_mode = report
796797
safe_markup = sanitize_ansi(markup)
797798
unwrapped_markup = _unwrap_fenced_markdown_tables(safe_markup)
798799
repaired_markup = _repair_crammed_markdown_tables(unwrapped_markup)
@@ -801,7 +802,13 @@ def __init__(self, markup: str, *args: Any, **kwargs: Any) -> None:
801802
super().__init__(_simplify_markdown_report_icons(loosened_markup), *args, **kwargs)
802803

803804
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
804-
overrides = _markdown_style_overrides()
805+
from pythinker_code.ui.theme.adapters.markdown import report_markdown_style_overrides
806+
807+
overrides = (
808+
report_markdown_style_overrides()
809+
if self._report_mode
810+
else _markdown_style_overrides()
811+
)
805812
with console.use_theme(Theme(overrides, inherit=True)):
806813
yield from super().__rich_console__(console, options)
807814

@@ -814,6 +821,13 @@ def pythinker_markdown(text: str, *, code_theme: str | None = None) -> Pythinker
814821
return PythinkerMarkdown(text, code_theme=code_theme)
815822

816823

824+
def pythinker_report_markdown(
825+
text: str, *, code_theme: str | None = None, style: str | RichStyle = "none"
826+
) -> PythinkerMarkdown:
827+
"""Report-body markdown: only H1 headings render bold; everything else is regular weight."""
828+
return PythinkerMarkdown(text, code_theme=code_theme, style=style, report=True)
829+
830+
817831
# ---------------------------------------------------------------------------
818832
# Streaming boundary helper
819833
# ---------------------------------------------------------------------------

src/pythinker_code/ui/shell/components/report.py

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@
3434
from rich.table import Table
3535
from rich.text import Text
3636

37-
from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown, pythinker_markdown
37+
from pythinker_code.ui.shell.components.markdown import pythinker_markdown, pythinker_report_markdown
38+
from pythinker_code.ui.shell.glyphs import REPORT_FILE_MARKER
3839
from pythinker_code.ui.shell.spacing import REPORT_PANEL_PADDING
39-
from pythinker_code.ui.theme import ThemeName, tui_rich_style
40+
from pythinker_code.ui.theme import ThemeName, get_tui_tokens, tui_rich_style
4041

4142
_log = logging.getLogger(__name__)
4243

@@ -56,14 +57,14 @@
5657
_SEVERITY_ORDER: tuple[Severity, ...] = get_args(Severity)
5758
_SEVERITY_SET = frozenset(_SEVERITY_ORDER)
5859

59-
# severity -> (token name, bold). Muted theme tokens only; critical is the one
60-
# emphasis (bold) so the eye lands on it without a brighter colour.
60+
# severity -> (token name, bold). Report panels keep body text regular weight;
61+
# only the panel title (H1 equivalent) uses bold.
6162
_SEVERITY_TOKEN: dict[Severity, tuple[str, bool]] = {
62-
"critical": ("error", True),
63+
"critical": ("error", False),
6364
"high": ("error", False),
6465
"medium": ("warning", False),
6566
"low": ("accent", False),
66-
"info": ("muted", False),
67+
"info": ("activity_spinner", False),
6768
}
6869

6970
_DOT = "●"
@@ -251,19 +252,19 @@ def _render_report_prose(text: str, *, theme: ThemeName | None = None) -> Render
251252

252253
rows: list[RenderableType] = []
253254
if report.preamble.strip():
254-
rows.append(pythinker_markdown(report.preamble))
255+
rows.append(pythinker_report_markdown(report.preamble))
255256

256257
body_style = tui_rich_style("text", theme=theme)
257258
for section in report.sections:
258259
if rows:
259260
rows.append(Text(""))
260-
# Use a lower-level Markdown heading so inline code / links inside labels
261-
# keep the standard muted-blue highlight without promoting every report
262-
# subsection to the muted-yellow H1 treatment.
263-
rows.append(PythinkerMarkdown(f"### {section.title}"))
261+
rows.append(pythinker_report_markdown(f"# {section.title}"))
264262
if section.body.strip():
265263
rows.append(
266-
Padding(PythinkerMarkdown(section.body.strip(), style=body_style), (0, 0, 0, 2))
264+
Padding(
265+
pythinker_report_markdown(section.body.strip(), style=body_style),
266+
(0, 0, 0, 2),
267+
)
267268
)
268269

269270
return Group(*rows)
@@ -282,24 +283,50 @@ def _severity_style(severity: Severity, theme: ThemeName | None) -> RichStyle:
282283
return style + RichStyle(bold=True) if bold else style
283284

284285

286+
def _strong_style(theme: ThemeName | None) -> RichStyle:
287+
return tui_rich_style("tool_title", theme=theme) + RichStyle(bold=True)
288+
289+
290+
def _primary_style(theme: ThemeName | None) -> RichStyle:
291+
return tui_rich_style("text", theme=theme)
292+
293+
294+
def _secondary_style(theme: ThemeName | None) -> RichStyle:
295+
return tui_rich_style("secondary", theme=theme)
296+
297+
298+
def _muted_style(theme: ThemeName | None) -> RichStyle:
299+
return tui_rich_style("muted", theme=theme)
300+
301+
285302
def _summary_line(counts: dict[Severity, int], theme: ThemeName | None) -> Text:
286303
line = Text()
304+
pill_bg = get_tui_tokens(theme).tool_pending_bg
305+
bg = RichStyle(bgcolor=pill_bg)
287306
first = True
288307
for severity in _SEVERITY_ORDER:
289308
count = counts[severity]
290309
if not count:
291310
continue
292311
if not first:
293-
line.append(" ")
312+
line.append(" ")
294313
first = False
295-
line.append(f"{_DOT} ", style=_severity_style(severity, theme))
296-
line.append(f"{count} {severity}", style=tui_rich_style("text", theme=theme))
314+
line.append(f" {_DOT} ", style=_severity_style(severity, theme) + bg)
315+
line.append(f"{count} {severity} ", style=_primary_style(theme) + bg)
297316
if not counts["critical"] and not counts["high"]:
298-
prefix = " " if not first else ""
299-
line.append(f"{prefix}no critical or high", style=tui_rich_style("muted", theme=theme))
317+
prefix = " " if not first else ""
318+
line.append(f"{prefix}no critical or high", style=_secondary_style(theme))
300319
return line
301320

302321

322+
def _render_section_header(severity: Severity, theme: ThemeName | None) -> Group:
323+
border = tui_rich_style("border", theme=theme)
324+
return Group(
325+
Text(severity.capitalize(), style=tui_rich_style("tool_title", theme=theme)),
326+
Rule(style=border, characters="─"),
327+
)
328+
329+
303330
def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> RenderableType:
304331
rows: list[RenderableType] = []
305332

@@ -313,22 +340,29 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab
313340
title.add_column(overflow="fold")
314341
title.add_row(
315342
Text(_DOT, style=_severity_style(finding.severity, theme)),
316-
Text(finding.title, style=tui_rich_style("border", theme=theme) + RichStyle(bold=True)),
343+
Text(finding.title, style=_primary_style(theme)),
317344
)
318345
rows.append(title)
319346

320347
if finding.location:
321-
# Keep wrapped file paths in the same hanging-indent column. A raw
322-
# leading-space Text only indents the first physical line after Rich
323-
# wraps, which makes long locations drift left inside wide reports.
324-
rows.append(
325-
Padding(Text(finding.location, style=tui_rich_style("dim", theme=theme)), (0, 0, 0, 2))
348+
rows.append(Text(""))
349+
muted = _muted_style(theme)
350+
location = Table.grid(padding=0)
351+
location.add_column(width=2, no_wrap=True)
352+
location.add_column(overflow="fold")
353+
location.add_row(
354+
Text(REPORT_FILE_MARKER, style=muted),
355+
Text(finding.location, style=muted),
326356
)
357+
rows.append(location)
327358

328359
if finding.body.strip():
329-
body_style = tui_rich_style("text", theme=theme)
360+
body_style = _primary_style(theme)
330361
rows.append(
331-
Padding(PythinkerMarkdown(finding.body.strip(), style=body_style), (0, 0, 0, 2))
362+
Padding(
363+
pythinker_report_markdown(finding.body.strip(), style=body_style),
364+
(0, 0, 0, 2),
365+
)
332366
)
333367

334368
return Group(*rows)
@@ -337,20 +371,20 @@ def _render_finding(finding: ReportFinding, theme: ThemeName | None) -> Renderab
337371
def render_report(report: Report, *, theme: ThemeName | None = None) -> RenderableType:
338372
"""Render *report* as a padded, syntax-friendly Rich report panel."""
339373
counts = _counts(report.findings)
340-
border = tui_rich_style("border_muted", theme=theme)
374+
border = tui_rich_style("border", theme=theme)
341375
blank = Text("")
342376

343377
rows: list[RenderableType] = []
344378
if report.scope:
345-
rows += [Text(report.scope, style=tui_rich_style("dim", theme=theme)), blank]
379+
rows += [Text(report.scope, style=_secondary_style(theme)), blank]
346380
rows.append(_summary_line(counts, theme))
347381

348382
for severity in _SEVERITY_ORDER:
349383
group = [f for f in report.findings if f.severity == severity]
350384
if not group:
351385
continue
352386
rows.append(blank)
353-
rows.append(Rule(f" {severity.capitalize()} ", align="left", style=border, characters="─"))
387+
rows.append(_render_section_header(severity, theme))
354388
for finding in group:
355389
rows.append(blank)
356390
rows.append(_render_finding(finding, theme))
@@ -359,10 +393,10 @@ def render_report(report: Report, *, theme: ThemeName | None = None) -> Renderab
359393
rows += [
360394
blank,
361395
Rule(style=border, characters="─"),
362-
Text(report.note, style=tui_rich_style("muted", theme=theme)),
396+
Text(report.note, style=_secondary_style(theme)),
363397
]
364398

365-
title = Text(report.title, style=tui_rich_style("warning", theme=theme) + RichStyle(bold=True))
399+
title = Text(report.title, style=_strong_style(theme))
366400
return Panel(
367401
Group(*rows),
368402
title=title,

src/pythinker_code/ui/shell/echo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measure
3434
return Measurement.get(console, options, self._block)
3535

3636
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
37-
yield from console.render(Group(BLANK_ROW, self._block), options)
37+
# Leading blank separates from prior scrollback; trailing blank gives one
38+
# row of breathing room before the agent stream starts in the Live area.
39+
yield from console.render(Group(BLANK_ROW, self._block, BLANK_ROW), options)
3840

3941

4042
def render_user_echo(message: Message) -> RenderableType:

src/pythinker_code/ui/shell/glyphs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
#: inline view, interactive dialog body, pager, and ``prompt_other_input``.
6565
#: ASCII mode falls back to plain ``?`` for legacy terminals.
6666
QUESTION_MARKER: Final = "?" if _ASCII_GLYPHS else "❓"
67+
#: Report finding location rows (file path + line refs).
68+
REPORT_FILE_MARKER: Final = "+" if _ASCII_GLYPHS else "⌁"
6769

6870
__all__ = [
6971
"SPINNER_FRAMES",
@@ -82,4 +84,5 @@
8284
"TRANSCRIPT_TOOL_GUTTER",
8385
"LIST_BULLET",
8486
"QUESTION_MARKER",
87+
"REPORT_FILE_MARKER",
8588
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
5+
from pythinker_code.ui.shell.selector import SelectorConfig, SelectorItem, run_selector
6+
7+
8+
def _build_code_theme_config(
9+
current_theme: str,
10+
available_themes: list[str],
11+
on_preview: Callable[[str], None] | None = None,
12+
*,
13+
theme_matches_current: Callable[[str, str], bool] | None = None,
14+
) -> SelectorConfig[str]:
15+
def _default_match(theme: str, configured: str) -> bool:
16+
return theme == configured
17+
18+
matches = theme_matches_current or _default_match
19+
return SelectorConfig(
20+
title="Select syntax theme",
21+
items=[
22+
SelectorItem(
23+
value=theme,
24+
label=theme,
25+
is_current=matches(theme, current_theme),
26+
)
27+
for theme in available_themes
28+
],
29+
on_change=on_preview,
30+
)
31+
32+
33+
async def run_code_theme_selector(
34+
current_theme: str,
35+
available_themes: list[str],
36+
on_preview: Callable[[str], None] | None = None,
37+
*,
38+
theme_matches_current: Callable[[str, str], bool] | None = None,
39+
) -> str | None:
40+
return await run_selector(
41+
_build_code_theme_config(
42+
current_theme,
43+
available_themes,
44+
on_preview,
45+
theme_matches_current=theme_matches_current,
46+
)
47+
)

0 commit comments

Comments
 (0)