Skip to content

Commit 4e66db9

Browse files
committed
fix(tui): clamp light accent contrast, neutral truncated cards, footer renders validation only
1 parent 39ab58b commit 4e66db9

6 files changed

Lines changed: 78 additions & 22 deletions

File tree

apps/pythinker-code/src/tui/components/chrome/footer.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export class FooterComponent implements Component {
272272
this.state.statusLine,
273273
);
274274
return viewModel.rows.flatMap((row) => {
275-
if (row.kind === 'composer' || row.kind === 'status') return [];
275+
if (row.kind === 'composer' || row.kind === 'status' || row.kind === 'activity') return [];
276276
return [truncateToWidth(renderLegacyRow(row), width, '…')];
277277
});
278278
}
@@ -374,21 +374,9 @@ export class FooterComponent implements Component {
374374
}
375375

376376
function renderLegacyRow(
377-
row: Exclude<
378-
FooterViewModelRow,
379-
{ readonly kind: 'composer' } | { readonly kind: 'status' }
380-
>,
377+
row: Extract<FooterViewModelRow, { readonly kind: 'validation' }>,
381378
): string {
382-
switch (row.kind) {
383-
case 'activity':
384-
return row.primary.length === 0
385-
? row.indicators.join(' ')
386-
: row.indicators.length === 0
387-
? row.primary
388-
: `${row.primary} ${row.indicators.join(' ')}`;
389-
case 'validation':
390-
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
391-
}
379+
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
392380
}
393381

394382
function hasGoalBadge(goal: AppState['goal']): boolean {

apps/pythinker-code/src/tui/components/messages/tool-call.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -660,11 +660,14 @@ export class ToolCallComponent extends Container {
660660
this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width)));
661661
const lines = super.render(width);
662662
const background =
663-
this.result === undefined && this.toolCall.truncated !== true
664-
? 'toolPendingBg'
665-
: this.result !== undefined && this.result.is_error !== true
663+
this.result === undefined
664+
? this.toolCall.truncated === true
665+
? undefined
666+
: 'toolPendingBg'
667+
: this.result.is_error !== true
666668
? 'toolSuccessBg'
667669
: 'toolErrorBg';
670+
if (background === undefined) return lines;
668671
return lines.map((line, index) =>
669672
index === 0
670673
? line

apps/pythinker-code/src/tui/utils/session-accent.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,26 @@ export function sessionAccentHex(key: string, mode: 'dark' | 'light'): string {
55
hash = Math.imul(hash, 33) + (key.codePointAt(index) ?? 0);
66
}
77

8-
return hslToHex((hash >>> 0) % 360, 0.9, mode === 'dark' ? 0.72 : 0.42);
8+
return accentHexForHue((hash >>> 0) % 360, mode);
9+
}
10+
11+
export function accentHexForHue(hue: number, mode: 'dark' | 'light'): string {
12+
if (mode === 'dark') return hslToHex(hue, 0.9, 0.72);
13+
14+
for (let step = 0; step <= 11; step++) {
15+
const accent = hslToHex(hue, 0.9, Math.max(0.2, 0.42 - step * 0.02));
16+
if (1.05 / (relativeLuminance(accent) + 0.05) >= 3) return accent;
17+
}
18+
return hslToHex(hue, 0.9, 0.2);
19+
}
20+
21+
function relativeLuminance(hex: string): number {
22+
const linear = (channel: number) =>
23+
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
24+
const red = linear(Number.parseInt(hex.slice(1, 3), 16) / 255);
25+
const green = linear(Number.parseInt(hex.slice(3, 5), 16) / 255);
26+
const blue = linear(Number.parseInt(hex.slice(5, 7), 16) / 255);
27+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
928
}
1029

1130
function hslToHex(hue: number, saturation: number, lightness: number): string {

apps/pythinker-code/test/tui/components/chrome/footer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe('FooterComponent', () => {
118118
expect(footer.render(160)).toEqual([]);
119119
});
120120

121-
it('renders activity and validation rows but suppresses shared status rows', () => {
121+
it('renders validation rows but suppresses activity and shared status rows', () => {
122122
const footer = new FooterComponent(appState);
123123
const activity = selectFooterViewModel(
124124
foldFooterEvents(createFooterState(), [
@@ -137,7 +137,7 @@ describe('FooterComponent', () => {
137137
);
138138
footer.setViewModel(activity);
139139

140-
expect(footer.render(120).map(stripAnsi)).toEqual(['⠋ Waiting for response']);
140+
expect(footer.render(120).map(stripAnsi)).toEqual([]);
141141

142142
const validation = selectFooterViewModel(
143143
foldFooterEvents(createFooterState(), [

apps/pythinker-code/test/tui/components/messages/tool-call.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,33 @@ describe('ToolCallComponent', () => {
8484
}
8585
});
8686

87+
it('does not tint a truncated tool call without a result', () => {
88+
const previousLevel = chalk.level;
89+
chalk.level = 3;
90+
const component = new ToolCallComponent(
91+
{
92+
id: 'call_truncated_tint',
93+
name: 'Read',
94+
args: { path: 'foo.ts' },
95+
truncated: true,
96+
},
97+
undefined,
98+
);
99+
100+
try {
101+
const backgrounds = [
102+
'\u001B[48;2;29;33;41m',
103+
'\u001B[48;2;20;23;27m',
104+
'\u001B[48;2;41;29;29m',
105+
];
106+
expect(
107+
component.render(40).every((line) => backgrounds.every((code) => !line.includes(code))),
108+
).toBe(true);
109+
} finally {
110+
chalk.level = previousLevel;
111+
}
112+
});
113+
87114
it('renders MCP resource tools with friendly labels, context, and counts', () => {
88115
const list = new ToolCallComponent(
89116
{

apps/pythinker-code/test/tui/utils/session-accent.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { sessionAccentHex } from '#/tui/utils/session-accent';
3+
import { accentHexForHue, sessionAccentHex } from '#/tui/utils/session-accent';
44

55
function channelSum(hex: string): number {
66
return [1, 3, 5].reduce((sum, start) => sum + Number.parseInt(hex.slice(start, start + 2), 16), 0);
77
}
88

9+
function relativeLuminance(hex: string): number {
10+
const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255);
11+
const [red, green, blue] = channels.map((channel) =>
12+
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
13+
);
14+
return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
15+
}
16+
917
describe('sessionAccentHex', () => {
1018
it('returns a stable six-digit hex color for each key', () => {
1119
const accent = sessionAccentHex('session-alpha', 'dark');
@@ -25,4 +33,15 @@ describe('sessionAccentHex', () => {
2533
channelSum(sessionAccentHex('session-alpha', 'dark')),
2634
);
2735
});
36+
37+
it('keeps every light-theme hue above the chrome contrast floor', () => {
38+
for (let hue = 0; hue < 360; hue++) {
39+
const contrast = 1.05 / (relativeLuminance(accentHexForHue(hue, 'light')) + 0.05);
40+
expect(contrast, `hue ${String(hue)}`).toBeGreaterThanOrEqual(3);
41+
}
42+
});
43+
44+
it('keeps the dark-theme hue mapping unchanged', () => {
45+
expect(accentHexForHue(60, 'dark')).toBe('#F8F877');
46+
});
2847
});

0 commit comments

Comments
 (0)