1/** Terminal-emulator features shared by CliKit components. */
2import {
3 detectColorLevel,
4 detectUnicodeSupport,
5} from "@alchemy.run/sigil/capabilities";
6import {
7 setClipboard,
8 setTerminalProgress,
9 stringWidth,
10 tmuxPassthrough,
11 type TerminalProgressState,
12} from "@alchemy.run/sigil/ansi";
13
14export const ANSI_RESET = "\u001B[0m";
15export const ANSI_BOLD = "\u001B[1m";
16export const ANSI_DIM = "\u001B[2m";
17
18/** Truecolor foreground escape for raw, non-layout output. */
19export const ansiFg = (hex: string) => {
20 const red = Number.parseInt(hex.slice(1, 3), 16);
21 const green = Number.parseInt(hex.slice(3, 5), 16);
22 const blue = Number.parseInt(hex.slice(5, 7), 16);
23 return `\u001B[38;2;${red};${green};${blue}m`;
24};
25
26/**
27 * The canonical color-support decision, shared by raw terminal strings and
28 * CliKit capability detection so the two can never disagree.
29 */
30export const colorsEnabled = (
31 stream: Pick<NodeJS.WriteStream, "isTTY"> = process.stdout,
32): boolean => detectColorLevel(stream) > 0;
33
34/** The canonical Unicode-support decision for raw terminal strings. */
35export const unicodeEnabled = detectUnicodeSupport;
36
37/** Environment override for child processes whose piped output returns here. */
38export const pipedColorEnv = (): Record<string, string> =>
39 colorsEnabled() ? { FORCE_COLOR: process.env.FORCE_COLOR ?? "1" } : {};
40
41export const paint = (code: string, value: string): string =>
42 colorsEnabled() ? `${code}${value}${ANSI_RESET}` : value;
43
44export const copyToClipboard = (
45 text: string,
46 stdout: Pick<NodeJS.WriteStream, "write"> = process.stdout,
47): void => {
48 let sequence = setClipboard(text);
49 if (process.env.TMUX !== undefined) {
50 sequence = tmuxPassthrough(sequence);
51 }
52 stdout.write(sequence);
53};
54
55export const setNativeProgress = (
56 state: TerminalProgressState,
57 value?: number,
58 stdout: Pick<NodeJS.WriteStream, "write" | "isTTY"> = process.stdout,
59): void => {
60 if (stdout.isTTY !== true) return;
61 let sequence = setTerminalProgress(state, value);
62 if (process.env.TMUX !== undefined) sequence = tmuxPassthrough(sequence);
63 stdout.write(sequence);
64};
65
66const truncateSegmenter = new Intl.Segmenter();
67
68/** Display-width-aware truncation (wide CJK/emoji count by rendered cells). */
69export const truncate = (value: string, width: number): string => {
70 const max = Math.max(4, width);
71 if (stringWidth(value) <= max) return value;
72 let total = 0;
73 let kept = "";
74 for (const { segment } of truncateSegmenter.segment(value)) {
75 const segmentWidth = stringWidth(segment);
76 if (total + segmentWidth > max - 1) break;
77 total += segmentWidth;
78 kept += segment;
79 }
80 return `${kept}…`;
81};