mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 02:15:32 +00:00
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
This commit is contained in:
1 parent
eff030a9d8
commit
0c6bf4b02c
21 files changed
+876
-109
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -495,4 +495,4 @@ export function runTUI() {
|
||||
// Clear the terminal before starting the TUI
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
render(<App />);
|
||||
}
|
||||
}
|
||||
@@ -124,4 +124,4 @@ export const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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> = {}): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
flexMode: 'full',
|
||||
...overrides,
|
||||
powerline: {
|
||||
...DEFAULT_SETTINGS.powerline,
|
||||
...(overrides.powerline ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
widgets: WidgetItem[],
|
||||
options: { settings?: Partial<Settings>; 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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+16
-93
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,4 +134,4 @@ export function canDetectTerminalWidth(): boolean {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
|
||||
['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()],
|
||||
|
||||
@@ -156,4 +156,4 @@ export class BlockTimerWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -76,4 +76,4 @@ export class ContextPercentageWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -76,4 +76,4 @@ export class ContextPercentageUsableWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <LinkEditor {...props} />;
|
||||
}
|
||||
|
||||
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<WidgetEditorProps> = ({ 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 (
|
||||
<Box flexDirection='column'>
|
||||
<Text>
|
||||
{prompt}
|
||||
{activeValue.slice(0, activeCursor)}
|
||||
<Text backgroundColor='gray' color='black'>{activeValue[activeCursor] ?? ' '}</Text>
|
||||
{activeValue.slice(activeCursor + 1)}
|
||||
</Text>
|
||||
{isUrlMode ? (
|
||||
<Text dimColor>
|
||||
Current text:
|
||||
{' '}
|
||||
{textInput.trim() || '(uses URL)'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
Current URL:
|
||||
{' '}
|
||||
{urlInput.trim() || '(none)'}
|
||||
</Text>
|
||||
)}
|
||||
{showInvalidUrlWarning && (
|
||||
<Text color='yellow'>URL must begin with http:// or https://</Text>
|
||||
)}
|
||||
<Text dimColor>←→ move cursor, Enter save, ESC cancel</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -155,4 +155,4 @@ export class ResetTimerWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,4 @@ export class SessionUsageWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,4 @@ export class WeeklyUsageWidget implements Widget {
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> | 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);
|
||||
});
|
||||
});
|
||||
@@ -25,4 +25,5 @@ export { SessionNameWidget } from './SessionName';
|
||||
export { SessionUsageWidget } from './SessionUsage';
|
||||
export { WeeklyUsageWidget } from './WeeklyUsage';
|
||||
export { ResetTimerWidget } from './ResetTimer';
|
||||
export { ContextBarWidget } from './ContextBar';
|
||||
export { ContextBarWidget } from './ContextBar';
|
||||
export { LinkWidget } from './Link';
|
||||
Reference in new issue
Block a user