Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions src/commands/careers/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildHelpText } from '../../lib/help-text';
import { outputError, outputResult } from '../../lib/output';
import { cancelAndExit, pickItem } from '../../lib/prompts';
import { withSpinner } from '../../lib/spinner';
import { truncate } from '../../lib/truncate';
import { isInteractive } from '../../lib/tty';
import { COUNTRY_NAMES } from './countries';
import { type Career, type CareerField, careerPickerConfig } from './utils';
Expand Down Expand Up @@ -334,7 +335,7 @@ async function confirmSubmission(
? 'No'
: value
: value;
return `${label}: ${truncate(display, 80)}`;
return `${label}: ${truncate(display.replace(/\s+/g, ' ').trim(), 80)}`;
});
lines.push(`Resume: ${basename(resumePath)}`);

Expand All @@ -346,13 +347,6 @@ async function confirmSubmission(
}
}

function truncate(value: string, max: number): string {
const singleLine = value.replace(/\s+/g, ' ').trim();
return singleLine.length > max
? `${singleLine.slice(0, max - 3)}...`
: singleLine;
}

function readResume(
resumePath: string,
globalOpts: GlobalOpts,
Expand Down
6 changes: 3 additions & 3 deletions src/commands/emails/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
printPaginationHint,
} from '../../lib/pagination';
import { renderTable } from '../../lib/table';
import { truncate } from '../../lib/truncate';

type SentEmail = {
id: string;
Expand All @@ -22,9 +23,8 @@ type SentEmail = {
function renderSentEmailsTable(emails: SentEmail[]): string {
const rows = emails.map((e) => {
const to = e.to.join(', ');
const toStr = to.length > 40 ? `${to.slice(0, 37)}...` : to;
const subject =
e.subject.length > 50 ? `${e.subject.slice(0, 47)}...` : e.subject;
const toStr = truncate(to, 40);
const subject = truncate(e.subject, 50);
return [e.from, toStr, subject, e.last_event ?? '—', e.created_at, e.id];
});
return renderTable(
Expand Down
6 changes: 2 additions & 4 deletions src/commands/emails/receiving/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runGet } from '../../../lib/actions';
import type { GlobalOpts } from '../../../lib/client';
import { buildHelpText } from '../../../lib/help-text';
import { pickId } from '../../../lib/prompts';
import { truncate } from '../../../lib/truncate';
import { receivedEmailPickerConfig } from './utils';

export const getReceivingCommand = new Command('get')
Expand Down Expand Up @@ -40,10 +41,7 @@ export const getReceivingCommand = new Command('get')
console.log(`Files: ${data.attachments.length} attachment(s)`);
}
if (data.text) {
const snippet =
data.text.length > 200
? `${data.text.slice(0, 197)}...`
: data.text;
const snippet = truncate(data.text, 200);
console.log(`${snippet}`);
} else if (data.html) {
console.log(
Expand Down
4 changes: 2 additions & 2 deletions src/commands/emails/receiving/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildHelpText } from '../../../lib/help-text';
import { errorMessage, outputError } from '../../../lib/output';
import { safeTerminalText } from '../../../lib/safe-terminal-text';
import { createSpinner } from '../../../lib/spinner';
import { truncate } from '../../../lib/truncate';
import { isInteractive } from '../../../lib/tty';
import { withRetry } from '../../../lib/with-retry';
import { type BoundedSet, createBoundedSet } from '../../../utils/bounded-set';
Expand All @@ -29,8 +30,7 @@ const displayEmail = (email: ListReceivingEmail, jsonMode: boolean): void => {
const to = email.to.map(safeTerminalText).join(', ');
const ts = pc.dim(`[${timestamp()}]`);
const rawSubject = safeTerminalText(email.subject);
const subject =
rawSubject.length > 50 ? `${rawSubject.slice(0, 47)}...` : rawSubject;
const subject = truncate(rawSubject, 50);
const from = safeTerminalText(email.from);
const id = safeTerminalText(email.id);
process.stderr.write(
Expand Down
6 changes: 3 additions & 3 deletions src/commands/emails/receiving/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ListReceivingEmail } from 'resend';
import type { PickerConfig } from '../../../lib/prompts';
import { renderTable } from '../../../lib/table';
import { truncate } from '../../../lib/truncate';

export const receivedEmailPickerConfig: PickerConfig<{
id: string;
Expand Down Expand Up @@ -34,9 +35,8 @@ export function renderReceivingEmailsTable(
): string {
const rows = emails.map((e) => {
const to = e.to.join(', ');
const toStr = to.length > 40 ? `${to.slice(0, 37)}...` : to;
const subject =
e.subject.length > 50 ? `${e.subject.slice(0, 47)}...` : e.subject;
const toStr = truncate(to, 40);
const subject = truncate(e.subject, 50);
return [e.from, toStr, subject, e.created_at, e.id];
});
return renderTable(
Expand Down
6 changes: 2 additions & 4 deletions src/commands/templates/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { pickId } from '../../lib/prompts';
import { truncate } from '../../lib/truncate';
import { templatePickerConfig } from './utils';

export const getTemplateCommand = new Command('get')
Expand Down Expand Up @@ -45,10 +46,7 @@ export const getTemplateCommand = new Command('get')
console.log(`Reply-To: ${data.reply_to.join(', ')}`);
}
if (data.html) {
const snippet =
data.html.length > 200
? `${data.html.slice(0, 197)}...`
: data.html;
const snippet = truncate(data.html, 200);
console.log(`HTML: ${snippet}`);
}
if (data.variables?.length) {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/webhooks/events/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from 'resend';
import type { PickerConfig } from '../../../lib/prompts';
import { renderTable } from '../../../lib/table';
import { truncate } from '../../../lib/truncate';

export function webhookEventPickerConfig(
webhookId: string,
Expand Down Expand Up @@ -40,7 +41,7 @@ export function renderWebhookEventAttemptsTable(
return [
String(a.http_status_code),
a.sent_at,
response.length > 60 ? `${response.slice(0, 57)}...` : response,
truncate(response, 60),
a.id,
];
});
Expand Down
5 changes: 2 additions & 3 deletions src/commands/webhooks/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Webhook, WebhookEvent } from 'resend';
import type { PickerConfig } from '../../lib/prompts';
import { renderTable } from '../../lib/table';
import { truncate } from '../../lib/truncate';

export const ALL_WEBHOOK_EVENTS: WebhookEvent[] = [
'email.sent',
Expand Down Expand Up @@ -49,9 +50,7 @@ export const webhookPickerConfig: PickerConfig<{
export function renderWebhooksTable(webhooks: Webhook[]): string {
const rows = webhooks.map((w) => {
const eventsStr = (w.events ?? []).join(', ');
const events =
eventsStr.length > 60 ? `${eventsStr.slice(0, 57)}...` : eventsStr;
return [w.endpoint, events, w.status, w.id];
return [w.endpoint, truncate(eventsStr, 60), w.status, w.id];
});
return renderTable(
['Endpoint', 'Events', 'Status', 'ID'],
Expand Down
30 changes: 30 additions & 0 deletions src/lib/truncate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const ELLIPSIS = '...';

const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

/**
* Shorten `value` to at most `max` UTF-16 code units, ending in "..." when it
* is cut. Cuts on whole visible characters (graphemes), so emoji built from
* several characters (families, flags, skin tones) are kept or dropped whole
* instead of leaving a stray joiner, half a flag or a lone surrogate.
*
* When `max` is not larger than the ellipsis there is no room for it, so the
* value is hard-cut with `slice(0, max)`.
*/
export function truncate(value: string, max: number): string {
if (value.length <= max) {
return value;
}
if (max <= ELLIPSIS.length) {
return value.slice(0, max);
}
const budget = max - ELLIPSIS.length;
let end = 0;
for (const { segment } of segmenter.segment(value)) {
if (end + segment.length > budget) {
break;
}
end += segment.length;
}
return `${value.slice(0, end)}${ELLIPSIS}`;
}
24 changes: 24 additions & 0 deletions tests/commands/emails/receiving/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
vi,
} from 'vitest';
import { listReceivingCommand } from '../../../../src/commands/emails/receiving/list';
import { renderReceivingEmailsTable } from '../../../../src/commands/emails/receiving/utils';
import {
captureTestEnv,
expectExit1,
Expand All @@ -17,6 +18,9 @@ import {
setupOutputSpies,
} from '../../../helpers';

const LONE_SURROGATE =
/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;

const mockList = vi.fn(async () => ({
data: {
object: 'list' as const,
Expand Down Expand Up @@ -159,4 +163,24 @@ describe('emails receiving list command', () => {
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('list_error');
});

it('does not leave a lone surrogate when truncating an emoji subject', () => {
const subject = `${'a'.repeat(46)}😀${'b'.repeat(10)}`;
const table = renderReceivingEmailsTable([
{
id: 'rcv_1',
to: ['inbox@example.com'],
from: 'sender@example.com',
subject,
created_at: '2026-02-18 12:00:00+00',
message_id: '<m@example.com>',
bcc: null,
cc: null,
reply_to: null,
attachments: [],
},
]);
expect(table).not.toMatch(LONE_SURROGATE);
expect(table).toContain(`${'a'.repeat(46)}...`);
});
});
63 changes: 63 additions & 0 deletions tests/lib/truncate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { truncate } from '../../src/lib/truncate';

const LONE_SURROGATE =
/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;

describe('truncate', () => {
it('returns the value unchanged when it fits', () => {
expect(truncate('hello', 5)).toBe('hello');
expect(truncate('', 5)).toBe('');
});

it('cuts to max characters including the ellipsis', () => {
const out = truncate('abcdefghij', 8);
expect(out).toBe('abcde...');
expect(out).toHaveLength(8);
});

it('does not split a surrogate pair at the cut point', () => {
// 46 characters, then an emoji whose high surrogate lands at index 46.
const value = `${'a'.repeat(46)}😀${'b'.repeat(10)}`;
const out = truncate(value, 50);
expect(out).toBe(`${'a'.repeat(46)}...`);
expect(out).not.toMatch(LONE_SURROGATE);
});

it('keeps a whole emoji that ends exactly at the cut point', () => {
const value = `${'a'.repeat(45)}😀${'b'.repeat(10)}`;
expect(truncate(value, 50)).toBe(`${'a'.repeat(45)}😀...`);
});

it('hard-cuts without an ellipsis when max is not larger than it', () => {
expect(truncate('abcdef', 2)).toBe('ab');
expect(truncate('abcdef', 3)).toBe('abc');
expect(truncate('abcdef', 0)).toBe('');
expect(truncate('abcdef', 4)).toBe('a...');
});

describe.each([
['a family emoji', '👨‍👩‍👧'],
['a flag', '🇺🇸'],
['a skin-tone emoji', '👍🏽'],
])('with %s at the cut point', (_name, emoji) => {
it.each([
// The emoji straddles the cut at max 50 (budget 47), at every offset
// from fully inside the budget to fully outside it.
...Array.from(
{ length: emoji.length + 1 },
(_, i) => 47 - emoji.length + i,
),
])('keeps it whole or drops it whole with %i leading characters', (lead) => {
const value = `${'a'.repeat(lead)}${emoji}${'b'.repeat(20)}`;
const out = truncate(value, 50);
expect(out).not.toMatch(LONE_SURROGATE);
expect(out.endsWith('...')).toBe(true);
const body = out.slice(0, -3);
const fits = lead + emoji.length <= 47;
expect(body).toBe(
fits ? `${'a'.repeat(lead)}${emoji}` : 'a'.repeat(lead),
);
});
});
});
Loading