Skip to content

feat(theme): add /theme command with markdown rendering fixes#80

Merged
remarkablemark merged 13 commits into
masterfrom
feat/theme
May 16, 2026
Merged

feat(theme): add /theme command with markdown rendering fixes#80
remarkablemark merged 13 commits into
masterfrom
feat/theme

Conversation

@remarkablemark
Copy link
Copy Markdown
Member

@remarkablemark remarkablemark commented May 16, 2026

What is the motivation for this pull request?

Adds a /theme command for switching the UI color theme, along with several fixes to markdown rendering and component structure.

What is the current behavior?

  • No theme switching support
  • Assistant streaming text was not rendered through markdown
  • Markdown component applied its own color policy instead of deferring to marked-terminal
  • Streamed content was reflowing already-stable lines

What is the new behavior?

  • /theme command allows runtime theme switching
  • Streaming assistant text goes through markdown rendering
  • Color policy moved to the caller; marked-terminal owns coloring
  • Stable streamed content no longer reflows
  • Message split into its own file
  • SelectPrompt split into a directory structure
  • Props tidied across components
  • messageAssistant color token removed (no longer needed)

plan.md

Checklist:

Implement `/theme` as a persistent, interactive theme picker with live preview. Theme selection should affect explicit app UI colors, `CodeBlock` Shiki highlighting, and markdown rendering. For ambiguous families, expose explicit light/dark variants in the picker and map them directly in Shiki, while `marked-terminal` continues using its single family theme.

Behavior:

- `/theme` opens a picker
- moving through options previews the theme immediately
- `Enter` saves
- `Esc` restores the previously active theme without saving

Key Changes:

- Extend `Config` with `theme: ThemeId`, defaulting to `github-dark`.
- Add a central typed theme registry with explicit presets:
  - `github-light`
  - `github-dark`
  - `nord`
  - `dracula`
  - `solarized-light`
  - `solarized-dark`
- Each preset definition should include:
  - Shiki theme name
  - `marked-terminal` theme name
  - semantic UI colors for accent, border, secondary text, error, approval/warning, message-role accents, and footer mode colors
- Add `/theme` to slash-command discovery.
- Add a `ThemeSettings` screen with live preview state.
- Wire `App` to manage:
  - persisted config theme
  - transient preview theme while the picker is open
- Replace hard-coded explicit colors with semantic theme tokens in:
  - header
  - footer
  - messages
  - search/settings screens
  - approval/error UI
  - code block border/text styling
- Update markdown rendering so it receives the active theme mapping instead of fixed `gitHub`.
- Update `CodeBlock` so Shiki receives the active theme instead of fixed `github-light`.
- Update `CodeBlock` highlight cache keys to include theme.

Theme Mapping:

Shiki should use exact preset names:

- `github-light` -> `github-light`
- `github-dark` -> `github-dark`
- `nord` -> `nord`
- `dracula` -> `dracula`
- `solarized-light` -> `solarized-light`
- `solarized-dark` -> `solarized-dark`

`marked-terminal` should use family mapping:

- `github-light` -> `gitHub`
- `github-dark` -> `gitHub`
- `nord` -> `nord`
- `dracula` -> `dracula`
- `solarized-light` -> `solarized`
- `solarized-dark` -> `solarized`

The app UI palette should still differ between `*-light` and `*-dark` presets even when `marked-terminal` uses the same family theme.

Public Interfaces:

- `Config` gains `theme: ThemeId`
- New exported `ThemeId` and theme registry/types
- `renderMarkdown` becomes theme-aware
- `CodeBlock` highlight/prewarm helpers accept or derive the active theme
- `ThemeSettings` needs preview and confirm/cancel callbacks

Test Plan:

- Config tests:
  - default load returns `github-dark`
  - theme persists across save/load
  - legacy config without theme still loads safely
- Slash command tests:
  - `/theme` appears in suggestions
  - submitting `/theme` opens the picker
- App tests:
  - preview applies while navigating
  - `Enter` saves
  - `Esc` restores the original theme and does not save
- Theme screen tests:
  - all presets render
  - current theme is shown
  - navigation triggers preview updates
- Markdown/rendering tests:
  - `marked-terminal` receives the mapped family theme
- Code block tests:
  - Shiki receives the exact preset theme
  - cache keys include theme
  - switching themes re-renders correctly
- UI color tests:
  - explicit colors come from theme semantics
  - no light/dark-unsafe `black` usage remains for normal content

Assumptions:

- v1 uses built-in presets only.
- `/theme <name>` inline parsing is out of scope.
- Theme is global config, not per session.
- Terminal background remains user-controlled.
- No auto light/dark detection in v1; explicit `*-light` and `*-dark` presets remove that ambiguity.
The bug was that one unfinished inline marker, like an open `**`,
caused the entire remaining streamed suffix to fall back to plain text.

In practice that meant later lines in the same in-flight assistant
message stopped rendering as markdown even when they were already
complete.

The fix limits the plain-text fallback to the affected line and lets
later lines continue through the markdown renderer during streaming.
Right now, `stickyHeightRef` prevents the streaming frame from shrinking.
That helps with upward jumps, but it does not stop content above the
cursor from being rewrapped when new tokens arrive.

Fix: Freeze all completed lines/blocks and only re-render the live tail.

- Split streaming assistant text into `stablePrefix` and `activeSuffix`.
- Treat anything before the last incomplete paragraph/list item/code
  fence as committed-for-layout.
- Render `stablePrefix` once as markdown.
- Only run `splitStreamingInlineContent()` and markdown reflow on
  `activeSuffix`.

Why this helps:

- Most layout shift comes from `renderMarkdown()` being rerun on the
  whole text segment every chunk in `src/components/Messages/layout.ts`
  and `src/components/Messages/Message.tsx`.
- If only the tail can change, earlier wrapped lines stop moving.

Update: Changed the TUI streaming path to freeze completed lines and only
treat the last line as the live tail.

The tradeoff is intentional: once a line has ended with `\n`, it no longer
gets reinterpreted on later chunks. That reduces layout shift because
earlier wrapped lines stop moving, but it also means an unmatched
markdown opener on a completed line can remain visible until commit.
Bug:

- `marked-terminal` emits ANSI sequences for parts of the markdown line,
  including foreground resets like `\x1B[39m`.
- The outer Ink `<Text color={messageColor}>` is trying to make the whole
  assistant message cyan.
- On the first rendered line, ANSI resets from `marked-terminal` override
  that outer color and drop text back to the terminal default foreground,
  which looks black in your screenshot.
- When the long line wraps, the continuation text is no longer covered
  the same way by those inline ANSI style segments, so Ink’s outer cyan
  color shows through on the wrapped lines.

Fix:

- Stop applying `color={messageColor}` to markdown text and let
  `marked-terminal` own all coloring.
- If `renderMarkdown()` returns ANSI-styled output from `marked-terminal`,
  the component no longer applies an outer Ink `color` prop
- If the rendered output has no ANSI styles, the parent `color` still
  applies as before

That avoids the conflict where `marked-terminal` resets the foreground on
the first line and Ink’s parent color leaks differently onto wrapped lines,
which is what produced the black first line and cyan continuation lines.
In `src/components/Messages/Message.tsx`, assistant markdown no longer
passes `color={messageColor}` into `src/components/Markdown/Markdown.tsx`.
That means markdown output is now styled solely by `marked-terminal` and
its theme, while user/system/plain streaming text still keeps the role
color behavior.

Removed the ANSI-detection workaround from `src/components/Markdown/Markdown.tsx`.
…ering

Changed the TUI so all streaming assistant text goes through markdown
rendering.

In `src/components/Messages/Message.tsx`, the live assistant text path
no longer splits into plain fallback fragments. Streaming assistant text
segments now render as markdown end-to-end, and the streaming height
calculation uses that same single markdown path.

That should eliminate the color changes caused by switching between plain
`Text` and markdown during the stream.
@remarkablemark remarkablemark changed the title feat(theme): add /theme command with markdown rendering fixes feat(theme): add /theme command with markdown rendering fixes May 16, 2026
@remarkablemark remarkablemark self-assigned this May 16, 2026
@remarkablemark remarkablemark added bug Something isn't working enhancement New feature or request labels May 16, 2026
The CI failure was in the test, not the wrap logic in `ThemeSettings`.

`src/components/ThemeSettings.test.tsx:257` was asserting after calling a manually captured
`useInput` handler. Under the current React 19 + Ink/Vitest setup, that bypasses Ink’s normal
input/render cycle, so `setSelectedIndex(...)` never flushes in the test. The only recorded
`onPreview` call is the mount effect, which is why CI saw `github-light` instead of the wrapped
`solarized-dark`.

Changed `src/components/ThemeSettings.test.tsx` to use Ink’s real stdin path with
`stdin.write(KEY.UP|DOWN|ENTER|ESCAPE|CTRL_C)` instead of mocking `useInput`. That makes the
tests exercise the component the same way the app does and removes the stale/racy behavior.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/components/App.tsx 100.00% <100.00%> (ø)
src/components/Chat/Chat.tsx 100.00% <100.00%> (ø)
src/components/CodeBlock/CodeBlock.tsx 100.00% <100.00%> (ø)
src/components/Footer.tsx 100.00% <100.00%> (ø)
src/components/Header.tsx 100.00% <100.00%> (ø)
src/components/Markdown/Markdown.tsx 100.00% <100.00%> (ø)
src/components/Markdown/render.ts 100.00% <100.00%> (ø)
src/components/Messages/Message.tsx 100.00% <100.00%> (ø)
src/components/Messages/Messages.tsx 100.00% <100.00%> (ø)
src/components/Messages/streaming.ts 100.00% <100.00%> (ø)
... and 13 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@remarkablemark remarkablemark enabled auto-merge May 16, 2026 21:09
@remarkablemark remarkablemark merged commit 3eb8a66 into master May 16, 2026
16 checks passed
@remarkablemark remarkablemark deleted the feat/theme branch May 16, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant