From 0c6bf4b02c6d373a98d2930935927b277e9c4c1d Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Mon, 2 Mar 2026 23:59:14 -0500 Subject: [PATCH] feat: add Link widget and fix ANSI/OSC truncation calculation - add a new Link widget with OSC8 rendering, preview parity, and raw mode behavior - fix truncation and visible-width calculation to handle ANSI/OSC sequences safely - bump package version to 2.1.3 - update README recent updates and widget documentation Resolves #188 --- README.md | 12 +- package.json | 2 +- src/ccstatusline.ts | 3 +- src/tui/App.tsx | 2 +- src/tui/components/MainMenu.tsx | 2 +- src/utils/__tests__/renderer-ansi.test.ts | 193 ++++++++++++++++ src/utils/__tests__/widgets.test.ts | 5 +- src/utils/ansi.ts | 261 ++++++++++++++++++++++ src/utils/renderer.ts | 109 ++------- src/utils/terminal.ts | 2 +- src/utils/widgets.ts | 1 + src/widgets/BlockTimer.ts | 2 +- src/widgets/ContextPercentage.ts | 2 +- src/widgets/ContextPercentageUsable.ts | 2 +- src/widgets/CustomCommand.tsx | 5 +- src/widgets/Link.tsx | 221 ++++++++++++++++++ src/widgets/ResetTimer.ts | 2 +- src/widgets/SessionUsage.ts | 2 +- src/widgets/WeeklyUsage.ts | 2 +- src/widgets/__tests__/Link.test.ts | 152 +++++++++++++ src/widgets/index.ts | 3 +- 21 files changed, 876 insertions(+), 109 deletions(-) create mode 100644 src/utils/__tests__/renderer-ansi.test.ts create mode 100644 src/utils/ansi.ts create mode 100644 src/widgets/Link.tsx create mode 100644 src/widgets/__tests__/Link.test.ts diff --git a/README.md b/README.md index 1263453..b952f53 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,12 @@ ## 🆕 Recent Updates -### v2.1.0 - v2.1.1 - Usage widgets and reliability fixes +### v2.1.0 - v2.1.3 - Usage widgets, links, and reliability fixes - **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets. - **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts. - **🪟 Windows empty file bug fix (v2.1.1)** - Fixed a Windows issue that could create an empty `c:\dev\null` file. +- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support. ### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements @@ -436,6 +437,7 @@ bun run example - **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G") - **Custom Text** - Add your own custom text to the status line - **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code) +- **Link** - Add clickable terminal hyperlinks (OSC 8) with configurable URL and display text - **Separator** - Visual divider between widgets (customizable: |, -, comma, space; available when Powerline mode is off and no default separator is configured) - **Flex Separator** - Expands to fill available space (available when Powerline mode is off) @@ -523,6 +525,7 @@ Widget-specific shortcuts: - **Block Timer**: `p` cycle display mode (time/full bar/short bar) - **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path - **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors +- **Link**: `u` URL, `e` link text --- @@ -554,6 +557,12 @@ Execute shell commands and display their output dynamically: > 💡 **Tip:** Custom commands can be other Claude Code compatible status line formatters! They receive the same JSON via stdin that ccstatusline receives from Claude Code, allowing you to chain or combine multiple status line tools. +#### Link Widget +Create clickable links in terminals that support OSC 8 hyperlinks: +- `metadata.url` - target URL (http/https) +- `metadata.text` - optional display text (defaults to URL) +- Falls back to plain text when URL is missing or unsupported + --- ### 🔗 Integration Example: ccusage @@ -572,6 +581,7 @@ Execute shell commands and display their output dynamically: ### ✂️ Smart Truncation When terminal width is detected, status lines automatically truncate with ellipsis (...) if they exceed the available width, preventing line wrapping. +Truncation is ANSI/OSC-aware, so preserved color output and OSC 8 hyperlinks remain well-formed. --- diff --git a/package.json b/package.json index 423cdd6..c0aeb74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.1.2", + "version": "2.1.3", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index fc6cd05..79818d5 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -6,6 +6,7 @@ import type { TokenMetrics } from './types'; import type { RenderContext } from './types/RenderContext'; import type { StatusJSON } from './types/StatusJSON'; import { StatusJSONSchema } from './types/StatusJSON'; +import { getVisibleText } from './utils/ansi'; import { updateColorMap } from './utils/colors'; import { loadSettings, @@ -116,7 +117,7 @@ async function renderMultipleLines(data: StatusJSON) { // Only output the line if it has content (not just ANSI codes) // Strip ANSI codes to check if there's actual text - const strippedLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + const strippedLine = getVisibleText(line).trim(); if (strippedLine.length > 0) { // Count separators used in this line (widgets - 1, excluding merged widgets) const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index f70145a..04dd385 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -495,4 +495,4 @@ export function runTUI() { // Clear the terminal before starting the TUI process.stdout.write('\x1b[2J\x1b[H'); render(); -} +} \ No newline at end of file diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx index 3173092..ceaa342 100644 --- a/src/tui/components/MainMenu.tsx +++ b/src/tui/components/MainMenu.tsx @@ -124,4 +124,4 @@ export const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, )} ); -}; +}; \ No newline at end of file diff --git a/src/utils/__tests__/renderer-ansi.test.ts b/src/utils/__tests__/renderer-ansi.test.ts new file mode 100644 index 0000000..ad0d1fc --- /dev/null +++ b/src/utils/__tests__/renderer-ansi.test.ts @@ -0,0 +1,193 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + DEFAULT_SETTINGS, + type Settings +} from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { + getVisibleText, + getVisibleWidth, + truncateStyledText +} from '../ansi'; +import { + calculateMaxWidthsFromPreRendered, + preRenderAllWidgets, + renderStatusLine +} from '../renderer'; + +const OSC8_OPEN = '\x1b]8;;https://example.com/docs\x1b\\'; +const OSC8_CLOSE = '\x1b]8;;\x1b\\'; +const OSC8_OPEN_WITH_PARAMS = '\x1b]8;id=abc;https://example.com/docs\x1b\\'; +const OSC8_CLOSE_WITH_PARAMS = '\x1b]8;id=abc;\x1b\\'; +const BEL_OSC8_OPEN = '\x1b]8;;https://example.com/docs\x07'; +const BEL_OSC8_CLOSE = '\x1b]8;;\x07'; + +function createSettings(overrides: Partial = {}): Settings { + return { + ...DEFAULT_SETTINGS, + flexMode: 'full', + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function renderLine( + widgets: WidgetItem[], + options: { settings?: Partial; terminalWidth?: number } = {} +): string { + const settings = createSettings(options.settings); + const context: RenderContext = { + isPreview: false, + terminalWidth: options.terminalWidth + }; + + const preRenderedLines = preRenderAllWidgets([widgets], settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + const preRenderedWidgets = preRenderedLines[0] ?? []; + + return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths); +} + +describe('renderer ANSI/OSC handling', () => { + it('treats OSC 8 wrappers as non-visible text for width calculations', () => { + const text = `A ${OSC8_OPEN}click${OSC8_CLOSE} B`; + expect(getVisibleText(text)).toBe('A click B'); + expect(getVisibleWidth(text)).toBe(getVisibleWidth('A click B')); + }); + + it('closes open OSC 8 hyperlinks when truncating styled text', () => { + const text = `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`; + const truncated = truncateStyledText(text, 10, { ellipsis: true }); + + expect(truncated.endsWith('...')).toBe(true); + expect(truncated).toContain(OSC8_CLOSE); + expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(10); + }); + + it('keeps OSC 8 links well-formed in normal renderer truncation', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}` + } + ]; + + const line = renderLine(widgets, { terminalWidth: 12 }); + expect(line.endsWith('...')).toBe(true); + expect(line).toContain(OSC8_CLOSE); + expect(getVisibleWidth(line)).toBeLessThanOrEqual(12); + }); + + it('keeps OSC 8 links well-formed in powerline truncation', () => { + const widgets: WidgetItem[] = [ + { + id: 'w1', + type: 'custom-text', + customText: `${OSC8_OPEN}very-long-link-text${OSC8_CLOSE}`, + color: 'white', + backgroundColor: 'bgBlue' + }, + { + id: 'w2', + type: 'custom-text', + customText: 'tail', + color: 'white', + backgroundColor: 'bgGreen' + } + ]; + + const line = renderLine(widgets, { + terminalWidth: 16, + settings: { + powerline: { + ...DEFAULT_SETTINGS.powerline, + enabled: true, + separators: ['\uE0B0'], + separatorInvertBackground: [false] + } + } + }); + + expect(line).toContain(OSC8_CLOSE); + expect(getVisibleWidth(line)).toBeLessThanOrEqual(16); + }); + + it('truncates custom-command preserveColors output without breaking OSC 8', () => { + const widget: WidgetItem = { + id: 'cmd1', + type: 'custom-command', + preserveColors: true, + maxWidth: 6 + }; + const settings = createSettings(); + const context: RenderContext = { + isPreview: false, + terminalWidth: 200 + }; + const content = `${OSC8_OPEN}abcdefghij${OSC8_CLOSE}`; + const preRenderedWidgets = [{ + content, + plainLength: getVisibleWidth(content), + widget + }]; + + const line = renderStatusLine([widget], settings, context, preRenderedWidgets, []); + expect(line).toContain(OSC8_CLOSE); + expect(getVisibleWidth(line)).toBe(6); + }); + + it('uses visible width for flex separator alignment when OSC 8 text is present', () => { + const widgets: WidgetItem[] = [ + { + id: 'left', + type: 'custom-text', + customText: `${OSC8_OPEN}A${OSC8_CLOSE}` + }, + { + id: 'flex', + type: 'flex-separator' + }, + { + id: 'right', + type: 'custom-text', + customText: 'B' + } + ]; + + const line = renderLine(widgets, { terminalWidth: 26 }); + const visible = getVisibleText(line); + + expect(visible.startsWith('A')).toBe(true); + expect(visible.endsWith('B')).toBe(true); + expect(visible.includes('...')).toBe(false); + expect(getVisibleWidth(line)).toBe(20); + }); + + it('handles BEL-terminated OSC 8 sequences during truncation', () => { + const line = `${BEL_OSC8_OPEN}bel-link-text${BEL_OSC8_CLOSE}`; + const truncated = truncateStyledText(line, 8, { ellipsis: true }); + + expect(truncated.endsWith('...')).toBe(true); + expect(truncated).toContain(BEL_OSC8_CLOSE); + expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8); + }); + + it('handles OSC 8 sequences with params during truncation', () => { + const line = `${OSC8_OPEN_WITH_PARAMS}param-link-text${OSC8_CLOSE_WITH_PARAMS}`; + const truncated = truncateStyledText(line, 8, { ellipsis: true }); + + expect(truncated.endsWith('...')).toBe(true); + expect(truncated).toContain(OSC8_CLOSE); + expect(getVisibleWidth(truncated)).toBeLessThanOrEqual(8); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 019ad09..1a677f2 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -27,11 +27,14 @@ describe('widget catalog', () => { const model = catalog.find(entry => entry.type === 'model'); const separator = catalog.find(entry => entry.type === 'separator'); + const link = catalog.find(entry => entry.type === 'link'); expect(model?.displayName).toBe('Model'); expect(model?.category).toBe('Core'); expect(separator?.displayName).toBe('Separator'); expect(separator?.category).toBe('Layout'); + expect(link?.displayName).toBe('Link'); + expect(link?.category).toBe('Custom'); }); it('hides manual separator when default separator is configured', () => { @@ -128,4 +131,4 @@ describe('widget catalog filtering', () => { const results = filterWidgetCatalog(rankingCatalog, 'All', 'git'); expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']); }); -}); +}); \ No newline at end of file diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts new file mode 100644 index 0000000..9c0ea13 --- /dev/null +++ b/src/utils/ansi.ts @@ -0,0 +1,261 @@ +import stringWidth from 'string-width'; + +const ESC = '\x1b'; +const BEL = '\x07'; +const C1_CSI = '\x9b'; +const C1_OSC = '\x9d'; +const ST = '\x9c'; + +const SGR_REGEX = /\x1b\[[0-9;]*m/g; + +type Osc8Action = 'open' | 'close'; +type OscTerminator = 'bel' | 'st'; + +interface ParsedEscapeSequence { + nextIndex: number; + sequence: string; + osc8Action?: Osc8Action; + osc8Terminator?: OscTerminator; +} + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function parseCsi(input: string, start: number, bodyStart: number): ParsedEscapeSequence { + let index = bodyStart; + while (index < input.length) { + const codePoint = input.charCodeAt(index); + if (isCsiFinalByte(codePoint)) { + const end = index + 1; + return { + nextIndex: end, + sequence: input.slice(start, end) + }; + } + index++; + } + + return { + nextIndex: input.length, + sequence: input.slice(start) + }; +} + +function getOsc8Action(body: string): Osc8Action | undefined { + if (!body.startsWith('8;')) { + return undefined; + } + + const urlStart = body.indexOf(';', 2); + if (urlStart === -1) { + return undefined; + } + + const url = body.slice(urlStart + 1); + return url.length > 0 ? 'open' : 'close'; +} + +function parseOsc( + input: string, + start: number, + bodyStart: number +): ParsedEscapeSequence { + let index = bodyStart; + + while (index < input.length) { + const current = input[index]; + if (!current) { + break; + } + + if (current === BEL) { + const end = index + 1; + const body = input.slice(bodyStart, index); + return { + nextIndex: end, + sequence: input.slice(start, end), + osc8Action: getOsc8Action(body), + osc8Terminator: 'bel' + }; + } + + if (current === ST) { + const end = index + 1; + const body = input.slice(bodyStart, index); + return { + nextIndex: end, + sequence: input.slice(start, end), + osc8Action: getOsc8Action(body), + osc8Terminator: 'st' + }; + } + + if (current === ESC && input[index + 1] === '\\') { + const end = index + 2; + const body = input.slice(bodyStart, index); + return { + nextIndex: end, + sequence: input.slice(start, end), + osc8Action: getOsc8Action(body), + osc8Terminator: 'st' + }; + } + + index++; + } + + return { + nextIndex: input.length, + sequence: input.slice(start) + }; +} + +function parseEscapeSequence(input: string, index: number): ParsedEscapeSequence | null { + const current = input[index]; + if (!current) { + return null; + } + + if (current === ESC) { + const next = input[index + 1]; + if (next === '[') { + return parseCsi(input, index, index + 2); + } + if (next === ']') { + return parseOsc(input, index, index + 2); + } + if (next) { + return { + nextIndex: index + 2, + sequence: input.slice(index, index + 2) + }; + } + return { + nextIndex: input.length, + sequence: current + }; + } + + if (current === C1_CSI) { + return parseCsi(input, index, index + 1); + } + + if (current === C1_OSC) { + return parseOsc(input, index, index + 1); + } + + return null; +} + +function getOsc8CloseSequence(terminator: OscTerminator): string { + if (terminator === 'bel') { + return `${ESC}]8;;${BEL}`; + } + return `${ESC}]8;;${ESC}\\`; +} + +export function stripSgrCodes(text: string): string { + return text.replace(SGR_REGEX, ''); +} + +export function getVisibleText(text: string): string { + let result = ''; + let index = 0; + + while (index < text.length) { + const escape = parseEscapeSequence(text, index); + if (escape) { + index = escape.nextIndex; + continue; + } + + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + break; + } + + const character = String.fromCodePoint(codePoint); + result += character; + index += character.length; + } + + return result; +} + +export function getVisibleWidth(text: string): number { + return stringWidth(getVisibleText(text)); +} + +interface TruncateOptions { ellipsis?: boolean } + +export function truncateStyledText( + text: string, + maxWidth: number, + options: TruncateOptions = {} +): string { + if (maxWidth <= 0) { + return ''; + } + + if (getVisibleWidth(text) <= maxWidth) { + return text; + } + + const addEllipsis = options.ellipsis ?? true; + const ellipsis = addEllipsis ? '...' : ''; + const ellipsisWidth = addEllipsis ? stringWidth(ellipsis) : 0; + + if (addEllipsis && maxWidth <= ellipsisWidth) { + return '.'.repeat(maxWidth); + } + + const targetWidth = Math.max(0, maxWidth - ellipsisWidth); + let output = ''; + let currentWidth = 0; + let index = 0; + let didTruncate = false; + let openOsc8Terminator: OscTerminator | null = null; + + while (index < text.length) { + const escape = parseEscapeSequence(text, index); + if (escape) { + output += escape.sequence; + index = escape.nextIndex; + + if (escape.osc8Action === 'open') { + openOsc8Terminator = escape.osc8Terminator ?? 'st'; + } else if (escape.osc8Action === 'close') { + openOsc8Terminator = null; + } + continue; + } + + const codePoint = text.codePointAt(index); + if (codePoint === undefined) { + break; + } + + const character = String.fromCodePoint(codePoint); + const charWidth = stringWidth(character); + + if (currentWidth + charWidth > targetWidth) { + didTruncate = true; + break; + } + + output += character; + currentWidth += charWidth; + index += character.length; + } + + if (!didTruncate) { + return text; + } + + if (openOsc8Terminator) { + output += getOsc8CloseSequence(openOsc8Terminator); + } + + return output + ellipsis; +} \ No newline at end of file diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index c77ef59..bdc5584 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -1,8 +1,4 @@ import chalk from 'chalk'; -import stringWidth from 'string-width'; - -// ANSI escape sequence for stripping color codes -const ANSI_REGEX = new RegExp(`\\x1b\\[[0-9;]*m`, 'g'); import type { RenderContext, @@ -11,6 +7,11 @@ import type { import { getColorLevelString } from '../types/ColorLevel'; import type { Settings } from '../types/Settings'; +import { + getVisibleWidth, + stripSgrCodes, + truncateStyledText +} from './ansi'; import { applyColors, bgToFg, @@ -161,7 +162,7 @@ function renderPowerlineStatusLine( if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none' && widget.type === 'custom-command' && widget.preserveColors) { // Strip ANSI color codes when override is active - widgetText = widgetText.replace(ANSI_REGEX, ''); + widgetText = stripSgrCodes(widgetText); } // Check if padding should be omitted due to no-padding merge @@ -231,13 +232,13 @@ function renderPowerlineStatusLine( const maxWidth = preCalculatedMaxWidths[alignmentPos]; if (maxWidth !== undefined) { // Calculate combined width if this widget merges with following ones - let combinedLength = stringWidth(element.content.replace(ANSI_REGEX, '')); + let combinedLength = getVisibleWidth(element.content); let j = i; while (j < widgetElements.length - 1 && widgetElements[j]?.widget.merge) { j++; const nextElement = widgetElements[j]; if (nextElement) { - combinedLength += stringWidth(nextElement.content.replace(ANSI_REGEX, '')); + combinedLength += getVisibleWidth(nextElement.content); } } @@ -438,33 +439,9 @@ function renderPowerlineStatusLine( // Handle truncation if terminal width is known if (terminalWidth && terminalWidth > 0) { - const plainLength = result.replace(ANSI_REGEX, '').length; + const plainLength = getVisibleWidth(result); if (plainLength > terminalWidth) { - // Truncate to terminal width - let truncated = ''; - let currentLength = 0; - let inAnsiCode = false; - - for (const char of result) { - if (char === '\x1b') { - inAnsiCode = true; - truncated += char; - } else if (inAnsiCode) { - truncated += char; - if (char === 'm') { - inAnsiCode = false; - } - } else { - if (currentLength < terminalWidth - 3) { - truncated += char; - currentLength++; - } else { - truncated += '...'; - break; - } - } - } - result = truncated; + result = truncateStyledText(result, terminalWidth, { ellipsis: true }); } } @@ -529,7 +506,7 @@ export function preRenderAllWidgets( // Store the rendered content without padding (padding is applied later) // Use stringWidth to properly calculate Unicode character display width - const plainLength = stringWidth(widgetText.replace(ANSI_REGEX, '')); + const plainLength = getVisibleWidth(widgetText); preRenderedLine.push({ content: widgetText, plainLength, @@ -776,35 +753,9 @@ export function renderStatusLine( // Handle max width truncation for commands with ANSI codes let finalOutput = widgetText; if (widget.maxWidth && widget.maxWidth > 0) { - const plainLength = widgetText.replace(ANSI_REGEX, '').length; + const plainLength = getVisibleWidth(widgetText); if (plainLength > widget.maxWidth) { - // Truncate while preserving ANSI codes - let truncated = ''; - let currentLength = 0; - let inAnsiCode = false; - let ansiBuffer = ''; - - for (const char of widgetText) { - if (char === '\x1b') { - inAnsiCode = true; - ansiBuffer = char; - } else if (inAnsiCode) { - ansiBuffer += char; - if (char === 'm') { - truncated += ansiBuffer; - inAnsiCode = false; - ansiBuffer = ''; - } - } else { - if (currentLength < widget.maxWidth) { - truncated += char; - currentLength++; - } else { - break; - } - } - } - finalOutput = truncated; + finalOutput = truncateStyledText(widgetText, widget.maxWidth, { ellipsis: false }); } } // Preserve original colors from command output @@ -925,7 +876,7 @@ export function renderStatusLine( // Calculate total length of all non-flex content const partLengths = parts.map((part) => { const joined = part.join(''); - return joined.replace(ANSI_REGEX, '').length; + return getVisibleWidth(joined); }); const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0); @@ -964,38 +915,10 @@ export function renderStatusLine( const maxWidth = terminalWidth ?? detectedWidth; if (maxWidth && maxWidth > 0) { // Remove ANSI escape codes to get actual length - const plainLength = statusLine.replace(ANSI_REGEX, '').length; + const plainLength = getVisibleWidth(statusLine); if (plainLength > maxWidth) { - // Need to truncate - preserve ANSI codes while truncating - let truncated = ''; - let currentLength = 0; - let inAnsiCode = false; - let ansiBuffer = ''; - const targetLength = context.isPreview ? maxWidth - 3 : maxWidth - 3; // Reserve 3 chars for ellipsis - - for (const char of statusLine) { - if (char === '\x1b') { - inAnsiCode = true; - ansiBuffer = char; - } else if (inAnsiCode) { - ansiBuffer += char; - if (char === 'm') { - truncated += ansiBuffer; - inAnsiCode = false; - ansiBuffer = ''; - } - } else { - if (currentLength < targetLength) { - truncated += char; - currentLength++; - } else { - break; - } - } - } - - statusLine = truncated + '...'; + statusLine = truncateStyledText(statusLine, maxWidth, { ellipsis: true }); } } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index ddc5919..738ff91 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -134,4 +134,4 @@ export function canDetectTerminalWidth(): boolean { } catch { return false; } -} +} \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index fd6a43e..6a2c4c7 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -28,6 +28,7 @@ const widgetRegistry = new Map([ ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], + ['link', new widgets.LinkWidget()], ['claude-session-id', new widgets.ClaudeSessionIdWidget()], ['session-name', new widgets.SessionNameWidget()], ['free-memory', new widgets.FreeMemoryWidget()], diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 0c5ef2c..69c92c3 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -156,4 +156,4 @@ export class BlockTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 440d479..99ae77c 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -76,4 +76,4 @@ export class ContextPercentageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 5d7f2db..a408b7f 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -76,4 +76,4 @@ export class ContextPercentageUsableWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index cde2d5b..278f0e5 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -15,6 +15,7 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { getVisibleText } from '../utils/ansi'; import { shouldInsertInput } from '../utils/input-guards'; export class CustomCommandWidget implements Widget { @@ -70,8 +71,8 @@ export class CustomCommandWidget implements Widget { // Strip ANSI codes if preserveColors is false if (!item.preserveColors) { - // Strip all ANSI escape sequences - output = output.replace(/\x1b\[[0-9;]*m/g, ''); + // Strip ANSI/OSC escape sequences and keep only visible text + output = getVisibleText(output); } if (item.maxWidth && output.length > item.maxWidth) { diff --git a/src/widgets/Link.tsx b/src/widgets/Link.tsx new file mode 100644 index 0000000..416ec1a --- /dev/null +++ b/src/widgets/Link.tsx @@ -0,0 +1,221 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; + +function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function toEditorMetadata(widget: WidgetItem): { url: string; text: string } { + const url = widget.metadata?.url ?? ''; + const text = widget.metadata?.text ?? ''; + return { url, text }; +} + +function buildMetadata(widget: WidgetItem, urlValue: string, textValue: string): WidgetItem { + const metadata = { ...(widget.metadata ?? {}) }; + const trimmedUrl = urlValue.trim(); + const trimmedText = textValue.trim(); + + if (trimmedUrl.length > 0) { + metadata.url = trimmedUrl; + } else { + delete metadata.url; + } + + if (trimmedText.length > 0) { + metadata.text = trimmedText; + } else { + delete metadata.text; + } + + if (Object.keys(metadata).length === 0) { + const { metadata, ...rest } = widget; + void metadata; // Intentionally unused + return rest; + } + + return { + ...widget, + metadata + }; +} + +function renderOsc8Link(url: string, text: string): string { + return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; +} + +function getLinkLabel(item: WidgetItem): { url: string; label: string } { + const url = item.metadata?.url?.trim() ?? ''; + const metadataText = item.metadata?.text?.trim(); + const label = metadataText && metadataText.length > 0 + ? metadataText + : (url.length > 0 ? url : 'no url'); + + return { url, label }; +} + +function withEmojiPrefix(label: string, rawValue?: boolean): string { + return rawValue ? label : `🔗 ${label}`; +} + +export class LinkWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Displays a clickable terminal hyperlink using OSC 8'; } + getDisplayName(): string { return 'Link'; } + getCategory(): string { return 'Custom'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const { url, label } = getLinkLabel(item); + const metadataText = item.metadata?.text?.trim(); + const hasCustomText = Boolean(metadataText && metadataText.length > 0); + const text = withEmojiPrefix(label, item.rawValue); + const shortUrl = hasCustomText && url.length > 0 + ? (url.length > 28 ? `${url.substring(0, 25)}...` : url) + : null; + + return { + displayText: `${this.getDisplayName()} (${text})`, + modifierText: shortUrl ? `(${shortUrl})` : undefined + }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; + void context; + + const { url, label } = getLinkLabel(item); + const displayText = withEmojiPrefix(label, item.rawValue); + + if (!url || !isValidHttpUrl(url)) { + return displayText; + } + + return renderOsc8Link(url, displayText); + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'u', label: '(u)rl', action: 'edit-url' }, + { key: 'e', label: '(e)dit text', action: 'edit-text' } + ]; + } + + renderEditor(props: WidgetEditorProps): React.ReactElement { + return ; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { + void item; + return true; + } +} + +type LinkEditorMode = 'url' | 'text'; + +function getEditorMode(action?: string): LinkEditorMode { + if (action === 'edit-url') { + return 'url'; + } + return 'text'; +} + +const LinkEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { + const initial = toEditorMetadata(widget); + const mode = getEditorMode(action); + + const [urlInput, setUrlInput] = useState(initial.url); + const [urlCursorPos, setUrlCursorPos] = useState(initial.url.length); + const [textInput, setTextInput] = useState(initial.text); + const [textCursorPos, setTextCursorPos] = useState(initial.text.length); + + const isUrlMode = mode === 'url'; + const activeValue = isUrlMode ? urlInput : textInput; + const activeCursor = isUrlMode ? urlCursorPos : textCursorPos; + + const updateActiveValue = (value: string, cursor: number) => { + if (isUrlMode) { + setUrlInput(value); + setUrlCursorPos(cursor); + } else { + setTextInput(value); + setTextCursorPos(cursor); + } + }; + + useInput((input, key) => { + if (key.return) { + onComplete(buildMetadata(widget, urlInput, textInput)); + } else if (key.escape) { + onCancel(); + } else if (key.leftArrow) { + updateActiveValue(activeValue, Math.max(0, activeCursor - 1)); + } else if (key.rightArrow) { + updateActiveValue(activeValue, Math.min(activeValue.length, activeCursor + 1)); + } else if (key.backspace) { + if (activeCursor > 0) { + const value = activeValue.slice(0, activeCursor - 1) + activeValue.slice(activeCursor); + updateActiveValue(value, activeCursor - 1); + } + } else if (key.delete) { + if (activeCursor < activeValue.length) { + const value = activeValue.slice(0, activeCursor) + activeValue.slice(activeCursor + 1); + updateActiveValue(value, activeCursor); + } + } else if (shouldInsertInput(input, key)) { + const value = activeValue.slice(0, activeCursor) + input + activeValue.slice(activeCursor); + updateActiveValue(value, activeCursor + input.length); + } + }); + + const showInvalidUrlWarning = isUrlMode && urlInput.trim().length > 0 && !isValidHttpUrl(urlInput.trim()); + const prompt = isUrlMode ? 'Enter URL (http/https): ' : 'Enter link text (blank uses URL): '; + + return ( + + + {prompt} + {activeValue.slice(0, activeCursor)} + {activeValue[activeCursor] ?? ' '} + {activeValue.slice(activeCursor + 1)} + + {isUrlMode ? ( + + Current text: + {' '} + {textInput.trim() || '(uses URL)'} + + ) : ( + + Current URL: + {' '} + {urlInput.trim() || '(none)'} + + )} + {showInvalidUrlWarning && ( + URL must begin with http:// or https:// + )} + ←→ move cursor, Enter save, ESC cancel + + ); +}; \ No newline at end of file diff --git a/src/widgets/ResetTimer.ts b/src/widgets/ResetTimer.ts index 2566a50..6373e3f 100644 --- a/src/widgets/ResetTimer.ts +++ b/src/widgets/ResetTimer.ts @@ -155,4 +155,4 @@ export class ResetTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index a003414..37a51f8 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -136,4 +136,4 @@ export class SessionUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 7baf6ec..0bf7d94 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -136,4 +136,4 @@ export class WeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } -} +} \ No newline at end of file diff --git a/src/widgets/__tests__/Link.test.ts b/src/widgets/__tests__/Link.test.ts new file mode 100644 index 0000000..1e4c774 --- /dev/null +++ b/src/widgets/__tests__/Link.test.ts @@ -0,0 +1,152 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { LinkWidget } from '../Link'; + +function renderLink( + metadata: Record | undefined, + isPreview = false, + rawValue = false +): string | null { + const widget = new LinkWidget(); + const item: WidgetItem = { + id: 'link', + type: 'link', + metadata, + rawValue + }; + const context: RenderContext = { isPreview }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('LinkWidget', () => { + it('renders OSC 8 hyperlink for valid http URL', () => { + const result = renderLink({ + url: 'https://example.com/docs', + text: 'Docs' + }); + + expect(result).toBe('\x1b]8;;https://example.com/docs\x1b\\🔗 Docs\x1b]8;;\x1b\\'); + }); + + it('uses URL as display text when metadata.text is missing', () => { + const result = renderLink({ url: 'https://example.com/docs' }); + + expect(result).toBe('\x1b]8;;https://example.com/docs\x1b\\🔗 https://example.com/docs\x1b]8;;\x1b\\'); + }); + + it('falls back to plain text for non-http URL schemes', () => { + const result = renderLink({ + url: 'file:///tmp/report.txt', + text: 'Report' + }); + + expect(result).toBe('🔗 Report'); + }); + + it('shows default placeholder when URL and text are missing', () => { + const result = renderLink(undefined); + + expect(result).toBe('🔗 no url'); + }); + + it('renders preview text exactly like final visible output when unconfigured', () => { + const result = renderLink(undefined, true); + expect(result).toBe('🔗 no url'); + }); + + it('renders preview text exactly like final visible output when configured', () => { + const result = renderLink({ + url: 'https://example.com/docs', + text: 'Docs' + }, true); + expect(result).toBe('\x1b]8;;https://example.com/docs\x1b\\🔗 Docs\x1b]8;;\x1b\\'); + }); + + it('renders preview hyperlink in raw mode with emoji hidden', () => { + const result = renderLink({ + url: 'https://example.com/docs', + text: 'Docs' + }, true, true); + expect(result).toBe('\x1b]8;;https://example.com/docs\x1b\\Docs\x1b]8;;\x1b\\'); + }); + + it('hides emoji in raw mode while preserving hyperlink behavior', () => { + const result = renderLink({ + url: 'https://example.com/docs', + text: 'Docs' + }, false, true); + + expect(result).toBe('\x1b]8;;https://example.com/docs\x1b\\Docs\x1b]8;;\x1b\\'); + }); + + it('shows raw placeholder without emoji when unconfigured', () => { + const result = renderLink(undefined, false, true); + expect(result).toBe('no url'); + }); + + it('exposes edit URL and edit text keybinds', () => { + const widget = new LinkWidget(); + const keybinds = widget.getCustomKeybinds(); + + expect(keybinds).toEqual([ + { key: 'u', label: '(u)rl', action: 'edit-url' }, + { key: 'e', label: '(e)dit text', action: 'edit-text' } + ]); + }); + + it('returns editor display with text and short url', () => { + const widget = new LinkWidget(); + const display = widget.getEditorDisplay({ + id: 'link', + type: 'link', + metadata: { + url: 'https://example.com/docs', + text: 'Docs' + } + }); + + expect(display.displayText).toBe('Link (🔗 Docs)'); + expect(display.modifierText).toBe('(https://example.com/docs)'); + }); + + it('omits duplicate no-url modifier when unconfigured in raw mode', () => { + const widget = new LinkWidget(); + const display = widget.getEditorDisplay({ + id: 'link', + type: 'link', + rawValue: true + }); + + expect(display.displayText).toBe('Link (no url)'); + expect(display.modifierText).toBeUndefined(); + }); + + it('omits duplicate url modifier when raw mode label is the URL', () => { + const widget = new LinkWidget(); + const display = widget.getEditorDisplay({ + id: 'link', + type: 'link', + rawValue: true, + metadata: { url: 'https://google.com' } + }); + + expect(display.displayText).toBe('Link (https://google.com)'); + expect(display.modifierText).toBeUndefined(); + }); + + it('supports colors and raw value mode', () => { + const widget = new LinkWidget(); + const item: WidgetItem = { id: 'link', type: 'link' }; + + expect(widget.supportsColors(item)).toBe(true); + expect(widget.supportsRawValue()).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 78e71c2..bf44bae 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -25,4 +25,5 @@ export { SessionNameWidget } from './SessionName'; export { SessionUsageWidget } from './SessionUsage'; export { WeeklyUsageWidget } from './WeeklyUsage'; export { ResetTimerWidget } from './ResetTimer'; -export { ContextBarWidget } from './ContextBar'; \ No newline at end of file +export { ContextBarWidget } from './ContextBar'; +export { LinkWidget } from './Link'; \ No newline at end of file