fix(tui): theme-aware welcome tip line for light-theme legibility
The welcome 'Tip:' line hardcoded #b48cff (label) and #9ccfff (body)
with an additional \x1b[2m dim on the body, ignoring the active theme.
On any light theme this dropped the body to ~1.5:1 contrast on a white
background (WCAG AA needs >=4.5:1), making the line effectively
invisible. Switching between light variants did not help because the
colors were not theme-derived.
renderWelcomeTip in packages/coding-agent/src/modes/components/welcome.ts
now paints the label through theme.fg('customMessageLabel', ...) and the
body through theme.fg('muted', ...), drops the manual dim, and wraps the
whole line with theme.italic(...). Both tokens are tuned per theme, so
the line stays vivid on dark backgrounds and readable on light ones
(e.g. light theme's customMessageLabel #7e57c2 = 5.21:1 on white).
A regression test pins the contract: dark/light themes must produce
different bytes for the same tip, and no manual \x1b[2m may remain.
Fixes #3337
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
- Fixed llama.cpp discovery to prefer per-model `/v1/models` `meta.n_ctx`/`meta.n_ctx_train` values, refresh selected models after lazy load, and bypass fresh-cache reuse so server restarts update context windows. ([#3310](https://github.com/can1357/oh-my-pi/issues/3310))
|
||||
- Fixed `task.maxConcurrency: 0` serializing subagent spawns instead of running them unbounded. The settings UI labels `0` as "Unlimited", but the session-scoped spawn `Semaphore` clamped `max` via `Math.max(1, max)`, so the second subagent body in a batch always waited for the first to release the seat. The constructor now treats `max <= 0` (and any non-finite input) as unbounded via `Number.POSITIVE_INFINITY`, matching the eval `parallel()`/`pipeline()` worker-pool semantics ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).
|
||||
- Fixed MCP tool calls forwarding empty optional placeholder arguments (`""` and `{}`) to `tools/call`; optional placeholders are now omitted while required fields and meaningful falsy values are preserved. ([#3302](https://github.com/can1357/oh-my-pi/issues/3302))
|
||||
- Fixed the welcome `Tip:` line rendering with hardcoded `#b48cff` / `#9ccfff` pastels plus a manual `\x1b[2m` dim, so any light theme dropped the body to ~1.5:1 contrast (well under WCAG AA). `renderWelcomeTip` in `packages/coding-agent/src/modes/components/welcome.ts` now paints the label through `theme.fg("customMessageLabel", …)` and the body through `theme.fg("muted", …)` (no manual dim), so the line tracks the active theme and stays legible on light backgrounds. ([#3337](https://github.com/can1357/oh-my-pi/issues/3337))
|
||||
|
||||
## [16.1.16] - 2026-06-23
|
||||
|
||||
|
||||
@@ -95,24 +95,23 @@ export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): stri
|
||||
const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
|
||||
if (wrappedBody.length === 0) return [];
|
||||
|
||||
const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
|
||||
const purple = Bun.color("#b48cff", encoding) ?? "";
|
||||
const lightBlue = Bun.color("#9ccfff", encoding) ?? "";
|
||||
const italic = "\x1b[3m";
|
||||
const dim = "\x1b[2m";
|
||||
const reset = "\x1b[0m";
|
||||
// Pull both colors from the active theme so the line stays readable on light
|
||||
// themes; the previous hardcoded `#b48cff` / `#9ccfff` pastels (plus a manual
|
||||
// `\x1b[2m` dim on the body) dropped to ~1.5:1 contrast on a white background.
|
||||
const continuationIndent = padding(labelWidth);
|
||||
const styledLabel = theme.fg("customMessageLabel", label);
|
||||
|
||||
const lines = wrappedBody.map((line, index) =>
|
||||
index === 0
|
||||
? ` ${italic}${purple}${label}${dim}${lightBlue}${line}${reset}`
|
||||
: ` ${italic}${continuationIndent}${dim}${lightBlue}${line}${reset}`,
|
||||
);
|
||||
const lines = wrappedBody.map((line, index) => {
|
||||
const styledBody = theme.fg("muted", line);
|
||||
const content = index === 0 ? `${styledLabel}${styledBody}` : `${continuationIndent}${styledBody}`;
|
||||
return ` ${theme.italic(content)}`;
|
||||
});
|
||||
|
||||
if (isNew) {
|
||||
// Append the rainbow tag to the final body line when it fits within the
|
||||
// box; otherwise drop it onto its own indented continuation line so the
|
||||
// styled glyphs never overflow or reflow the wrapped body.
|
||||
const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
|
||||
const tag = renderNewTag(phase, encoding);
|
||||
const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator
|
||||
const lastLine = lines[lines.length - 1];
|
||||
@@ -393,8 +392,8 @@ export class WelcomeComponent implements Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the per-instance tip line: a purple "Tip:" label followed by the
|
||||
* tip body in dimmed light blue, the whole line italicized. Returns `[]`
|
||||
* Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
|
||||
* label followed by a `muted` body, the whole line italicized. Returns `[]`
|
||||
* when no tip is available or the box is too narrow to be useful.
|
||||
*/
|
||||
#renderTip(boxWidth: number): string[] {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { renderWelcomeTip } from "@oh-my-pi/pi-coding-agent/modes/components/welcome";
|
||||
import { initTheme, setTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
|
||||
describe("renderWelcomeTip", () => {
|
||||
beforeAll(async () => {
|
||||
await initTheme(false);
|
||||
});
|
||||
|
||||
it("wraps long tips under the label instead of truncating", () => {
|
||||
const tip = "Next time you see spaghetti try creating a TTSR rule that prevents this pattern before it spreads";
|
||||
const width = 44;
|
||||
@@ -57,4 +61,33 @@ describe("renderWelcomeTip", () => {
|
||||
expect(plain).not.toContain("NEW!");
|
||||
expect(plain).toContain("Tip: Plain old tip");
|
||||
});
|
||||
|
||||
it("derives label and body colors from the active theme, with no manual dim layer", async () => {
|
||||
// Regression for #3337: hardcoded #b48cff/#9ccfff plus a manual `\x1b[2m`
|
||||
// dropped the body to ~1.5:1 contrast on any light-theme background.
|
||||
await setTheme("dark");
|
||||
const darkLabelAnsi = theme.getFgAnsi("customMessageLabel");
|
||||
const darkMutedAnsi = theme.getFgAnsi("muted");
|
||||
const dark = renderWelcomeTip("Welcome aboard friend", 60).join("\n");
|
||||
|
||||
await setTheme("light");
|
||||
const lightLabelAnsi = theme.getFgAnsi("customMessageLabel");
|
||||
const lightMutedAnsi = theme.getFgAnsi("muted");
|
||||
const light = renderWelcomeTip("Welcome aboard friend", 60).join("\n");
|
||||
|
||||
// Each theme paints with its own tokens.
|
||||
expect(dark).toContain(darkLabelAnsi);
|
||||
expect(dark).toContain(darkMutedAnsi);
|
||||
expect(light).toContain(lightLabelAnsi);
|
||||
expect(light).toContain(lightMutedAnsi);
|
||||
|
||||
// Switching themes must change the emitted bytes — the previous bug
|
||||
// produced byte-identical output for both.
|
||||
expect(dark).not.toBe(light);
|
||||
|
||||
// The manual `\x1b[2m` dim is gone; muted/label tokens carry their own
|
||||
// theme-tuned luminance.
|
||||
expect(dark).not.toContain("\x1b[2m");
|
||||
expect(light).not.toContain("\x1b[2m");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user