From 04e2a75995e10fd366b03aaf9a7cc2d8fcd0617d Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Wed, 27 May 2026 11:18:02 -0700 Subject: [PATCH] girl mode improveemnts --- app/page.tsx | 53 +++++++++++++++++++--------------- app/rendered-markdown.tsx | 17 +++++++---- app/share/[id]/fork-button.tsx | 3 +- app/share/[id]/page.tsx | 11 +++---- lib/markdown.ts | 12 ++++++-- lib/ui-labels.ts | 8 +++++ tests/unit.test.ts | 42 +++++++++++++++++++++++---- 7 files changed, 103 insertions(+), 43 deletions(-) create mode 100644 lib/ui-labels.ts diff --git a/app/page.tsx b/app/page.tsx index 4c9ae3d..d34a88b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; import { CUSTOM_FONT_ID, DEFAULT_FONT_ID, FONTS, getFontFamily, isFontId, normalizeCustomFontFamily, type FontId } from '@/lib/fonts'; import { DEFAULT_MODEL_ID, getModelLabel, MODELS, normalizeModelId, type ModelId } from '@/lib/models'; import { splitMessageFollowups, type Role, type Image, type Pdf, type Message } from '@/lib/chat'; +import { formatUiButtonLabel, getMessageLabel } from '@/lib/ui-labels'; import { LIMITS } from '@/lib/validation'; type PendingFile = { name: string; content: string }; // text/code files @@ -246,7 +247,7 @@ export default function Home() { const [pendingImages, setPendingImages] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); const [pendingPdfs, setPendingPdfs] = useState([]); - const [shareLabel, setShareLabel] = useState('[SHARE]'); + const [shareLabel, setShareLabel] = useState('SHARE'); const [showScrollBtn, setShowScrollBtn] = useState(false); const [copiedMsgIndex, setCopiedMsgIndex] = useState(null); const [editingIndex, setEditingIndex] = useState(null); @@ -766,7 +767,7 @@ export default function Home() { }; const handleShare = async () => { - setShareLabel('[SHARING…]'); + setShareLabel('SHARING…'); try { const res = await fetch('/api/shares', { method: 'POST', @@ -779,18 +780,18 @@ export default function Home() { status: res.status, requestId: res.headers.get('x-request-id'), }); - setShareLabel('[TOO LARGE]'); + setShareLabel('TOO LARGE'); setTimeout(() => alert(error), 0); } else { const { id } = await res.json(); await navigator.clipboard.writeText(`${window.location.origin}/share/${id}`); - setShareLabel('[COPIED!]'); + setShareLabel('COPIED!'); } } catch { logClientEvent('share.create_failed', 'error'); - setShareLabel('[ERROR]'); + setShareLabel('ERROR'); } - setTimeout(() => setShareLabel('[SHARE]'), 3000); + setTimeout(() => setShareLabel('SHARE'), 3000); }; const handleModelChange = (m: string) => { @@ -1302,6 +1303,8 @@ export default function Home() { URL.revokeObjectURL(url); }; + const buttonLabel = (label: string) => formatUiButtonLabel(label, girlMode); + return (
@@ -1312,15 +1315,15 @@ export default function Home() {
- - + + {messages.length > 0 && !streaming && ( - + )} {messages.length > 0 && ( - + )} - +
@@ -1379,7 +1382,7 @@ export default function Home() { {messages.length > 0 && !streaming && (
- +
)}
@@ -1434,7 +1437,7 @@ export default function Home() {
- {msg.role === 'assistant' ? '[OUTPUT]' : '[INPUT]'} + {getMessageLabel(msg.role, girlMode)} {editingIndex === null && (
{msg.role === 'assistant' && ( @@ -1444,14 +1447,14 @@ export default function Home() { aria-label="Copy output as markdown" onClick={() => copyMessage(splitMessageFollowups(msg.content).content, i)} > - {copiedMsgIndex === i ? '[COPIED!]' : '[COPY]'} + {buttonLabel(copiedMsgIndex === i ? 'COPIED!' : 'COPY')} )} {msg.role === 'assistant' && !streaming && i === messages.length - 1 && ( - + )} {msg.role === 'user' && !streaming && ( - + )}
)} @@ -1475,8 +1478,8 @@ export default function Home() { autoFocus />
- - + +
) : ( @@ -1484,10 +1487,11 @@ export default function Home() { ? - : msg.content && + : msg.content && )}
@@ -1498,7 +1502,7 @@ export default function Home() {
- [OUTPUT] + {getMessageLabel('assistant', girlMode)}
{streamingContent @@ -1506,6 +1510,7 @@ export default function Home() { html={streamingHtml || undefined} text={streamingContent} className="content" + girlMode={girlMode} followupsEnabled /> : @@ -1579,18 +1584,18 @@ export default function Home() { style={{ display: 'none' }} />
- - + +
{streaming ? ( - + ) : ( )}
diff --git a/app/rendered-markdown.tsx b/app/rendered-markdown.tsx index cb8abed..9889ff3 100644 --- a/app/rendered-markdown.tsx +++ b/app/rendered-markdown.tsx @@ -3,17 +3,20 @@ import { memo, useMemo } from 'react'; import { renderMarkdown } from '@/lib/markdown'; import { splitMessageFollowups } from '@/lib/chat'; +import { formatUiButtonLabel } from '@/lib/ui-labels'; function RenderedMarkdown({ text, html, className, + girlMode = false, followupsEnabled = false, onFollowup, }: { text?: string; html?: string; className?: string; + girlMode?: boolean; followupsEnabled?: boolean; onFollowup?: (followup: string) => void; }) { @@ -21,10 +24,12 @@ function RenderedMarkdown({ () => followupsEnabled ? splitMessageFollowups(text ?? '') : { content: text ?? '', followups: [] }, [followupsEnabled, text], ); - const preferClientRender = followupsEnabled && text !== undefined; + const copyLabel = formatUiButtonLabel('COPY', girlMode); + const copiedLabel = formatUiButtonLabel('COPIED!', girlMode); + const preferClientRender = text !== undefined && (followupsEnabled || girlMode); const rendered = useMemo( - () => preferClientRender ? renderMarkdown(content) : html ?? renderMarkdown(content), - [preferClientRender, html, content], + () => preferClientRender ? renderMarkdown(content, copyLabel) : html ?? renderMarkdown(content, copyLabel), + [preferClientRender, html, content, copyLabel], ); const handleClick = async (event: React.MouseEvent) => { @@ -33,9 +38,9 @@ function RenderedMarkdown({ const code = button.nextElementSibling?.querySelector('code')?.textContent ?? ''; if (!code) return; await navigator.clipboard.writeText(code); - button.textContent = '[COPIED!]'; + button.textContent = copiedLabel; window.setTimeout(() => { - if (button.isConnected) button.textContent = '[COPY]'; + if (button.isConnected) button.textContent = copyLabel; }, 2000); }; @@ -44,7 +49,7 @@ function RenderedMarkdown({
{onFollowup && followups.length > 0 && (
-
[FOLLOW-UPS]
+
{formatUiButtonLabel('FOLLOW-UPS', girlMode)}
{followups.map((followup, index) => ( ; + return ; } diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 975d159..c720c8f 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -7,6 +7,7 @@ import ForkButton from './fork-button'; import logger from '@/lib/log'; import type { Message } from '@/lib/chat'; import { getSharedChat } from '@/lib/share'; +import { formatUiButtonLabel, getMessageLabel } from '@/lib/ui-labels'; import { isShareId } from '@/lib/validation'; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { @@ -58,7 +59,7 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri {share.model} · {date}
- {sessionResult && [BACK]} + {sessionResult && {formatUiButtonLabel('BACK', share.girl_mode)}} {sessionResult ? - + : sign-in unavailable } @@ -85,7 +86,7 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
- {msg.role === 'assistant' ? '[OUTPUT]' : '[INPUT]'} + {getMessageLabel(msg.role, share.girl_mode)}
{msg.role === 'user' && >} @@ -98,8 +99,8 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
)} {msg.role === 'assistant' - ? - : msg.content && + ? + : msg.content && }
diff --git a/lib/markdown.ts b/lib/markdown.ts index 6c6118e..a7d96cf 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -33,11 +33,19 @@ marked.use({ }, }); -export function renderMarkdown(text: string): string { +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function renderMarkdown(text: string, copyLabel = '[COPY]'): string { const html = marked.parse(text, { async: false }) as string; // Wrap each
 in a .code-block and inject a [COPY] button
+  const safeCopyLabel = escapeHtml(copyLabel);
   return html
     .replace(/
/g,
-      `
`)
+      `
`)
     .replace(/<\/pre>/g, '
'); } diff --git a/lib/ui-labels.ts b/lib/ui-labels.ts new file mode 100644 index 0000000..db8aa66 --- /dev/null +++ b/lib/ui-labels.ts @@ -0,0 +1,8 @@ +export function formatUiButtonLabel(label: string, girlMode: boolean): string { + return girlMode ? label : `[${label}]`; +} + +export function getMessageLabel(role: string, girlMode: boolean): string { + if (girlMode) return role === 'assistant' ? 'Gippidy' : 'You'; + return role === 'assistant' ? '[OUTPUT]' : '[INPUT]'; +} diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 6a3592d..c046bbb 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -396,8 +396,8 @@ test('followup UI wiring hides XML in assistant messages and submits the selecte 'RenderedMarkdown should explicitly opt into followup parsing for assistant messages', ); assert.ok( - renderedMarkdownSource.includes('const preferClientRender = followupsEnabled && text !== undefined;'), - 'followup rendering should prefer client-side markdown from raw text when followups are enabled', + renderedMarkdownSource.includes('const preferClientRender = text !== undefined && (followupsEnabled || girlMode);'), + 'followup rendering should prefer client-side markdown from raw text when followups are enabled, and also when Girl Mode needs different UI labels', ); assert.ok( css.includes('.followup-button'), @@ -465,8 +465,8 @@ test('history-loaded chats persist across refreshes and clear correctly', () => source.includes('chatStateVersionRef.current += 1;') && source.includes('abortControllerRef.current?.abort();') && source.includes(' { e.preventDefault(); startFreshChat(); }}>GIPPIDY') && - source.includes(''), - 'the logo and [CLEAR] should use the same fresh-chat path so explicit new-chat actions clear persisted selection and invalidate stale in-flight work', + source.includes(""), + 'the logo and clear action should use the same fresh-chat path so explicit new-chat actions clear persisted selection and invalidate stale in-flight work', ); assert.ok( source.includes("logClientEvent('history.restore_fetch_failed'"), @@ -955,7 +955,7 @@ test('privacy boundaries keep normal chats encrypted and make sharing an explici ); assert.ok( pageSource.includes('{messages.length > 0 && !streaming && (') && - pageSource.includes(''), + pageSource.includes(""), 'sharing should remain an explicit user action from an existing chat, not an automatic side effect', ); assert.ok( @@ -1264,6 +1264,38 @@ test('Girl Mode defaults the system prompt to the chatty bestie preset', () => { ); }); +test('Girl Mode uses friendlier UI labels without bracketed button chrome', () => { + const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + const sharePageSource = readFileSync(join(import.meta.dirname, '../app/share/[id]/page.tsx'), 'utf8'); + const forkButtonSource = readFileSync(join(import.meta.dirname, '../app/share/[id]/fork-button.tsx'), 'utf8'); + const markdownSource = readFileSync(join(import.meta.dirname, '../app/rendered-markdown.tsx'), 'utf8'); + const helperSource = readFileSync(join(import.meta.dirname, '../lib/ui-labels.ts'), 'utf8'); + + assert.ok( + helperSource.includes("return girlMode ? label : `[${label}]`;") && + helperSource.includes("if (girlMode) return role === 'assistant' ? 'Gippidy' : 'You';"), + 'shared label helpers should remove bracket chrome in Girl Mode and rename message labels to You/Gippidy', + ); + assert.ok( + pageSource.includes("const buttonLabel = (label: string) => formatUiButtonLabel(label, girlMode);") && + pageSource.includes('{buttonLabel(\'SETTINGS\')}') && + pageSource.includes('{buttonLabel(\'SEND\')}') && + pageSource.includes('{getMessageLabel(msg.role, girlMode)}'), + 'the live chat UI should route button text and message labels through the Girl Mode label helpers', + ); + assert.ok( + markdownSource.includes("const copyLabel = formatUiButtonLabel('COPY', girlMode);") && + markdownSource.includes("const copiedLabel = formatUiButtonLabel('COPIED!', girlMode);"), + 'markdown code-copy buttons should also drop bracketed labels in Girl Mode', + ); + assert.ok( + sharePageSource.includes("formatUiButtonLabel('BACK', share.girl_mode)") && + sharePageSource.includes("getMessageLabel(msg.role, share.girl_mode)") && + forkButtonSource.includes("formatUiButtonLabel('FORK — CONTINUE', girlMode)"), + 'shared chat views should inherit the same Girl Mode button and message-label copy', + ); +}); + test('shared chats preserve girl mode for viewing and fork restore', () => { const pageSource = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); const sharePageSource = readFileSync(join(import.meta.dirname, '../app/share/[id]/page.tsx'), 'utf8'); -- 2.51.2