[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

wip: add a component base

James Garbutt (Oct 13, 2025, 10:06 PM +0100) 5022da6f 69508f48

+175
+174
packages/core/src/component.ts
··· 1 + import type { Writable } from 'node:stream'; 2 + import { wrapAnsi } from 'fast-wrap-ansi'; 3 + import { cursor, erase } from 'sisteransi'; 4 + import { getColumns } from './utils/index.js'; 5 + 6 + export abstract class Component { 7 + #lastFrame?: string; 8 + #output: Writable; 9 + #active = false; 10 + 11 + constructor(output: Writable = process.stdout) { 12 + this.#output = output; 13 + } 14 + 15 + public abstract render(): Template; 16 + 17 + public onMount(): void { 18 + return; 19 + } 20 + 21 + public onUnmount(): void { 22 + return; 23 + } 24 + 25 + #renderDiff(from: string | undefined, to: string): void { 26 + const output = this.#output; 27 + const fromLines = from ? from.split('\n') : []; 28 + const toLines = to.split('\n'); 29 + const fromLineCount = fromLines.length; 30 + const toLineCount = toLines.length; 31 + const maxLines = Math.max(fromLineCount, toLineCount); 32 + 33 + output.write(cursor.hide); 34 + output.write(cursor.left); 35 + 36 + if (fromLineCount > 0) { 37 + output.write(cursor.up(fromLineCount)); 38 + } 39 + 40 + for (let i = 0; i < maxLines; i++) { 41 + const fromLine = fromLines[i]; 42 + const toLine = toLines[i]; 43 + 44 + if (fromLine === toLine) { 45 + // do nothing 46 + } else if (toLine === undefined) { 47 + output.write(erase.line); 48 + } else { 49 + output.write(`${toLine}\n`); 50 + } 51 + 52 + if (i < fromLineCount - 1) { 53 + output.write(cursor.down(1)); 54 + } 55 + } 56 + 57 + output.write(cursor.show); 58 + } 59 + 60 + #render(): string { 61 + const template = this.render(); 62 + 63 + if (typeof template === 'string') { 64 + return template; 65 + } 66 + 67 + let result = ''; 68 + 69 + for (const part of template) { 70 + if (typeof part === 'string') { 71 + result += part; 72 + } else if (part instanceof Component) { 73 + this.#children.add(part); 74 + result += part.#render(); 75 + } 76 + } 77 + 78 + return result; 79 + } 80 + 81 + #children: Set<Component> = new Set(); 82 + 83 + public requestUpdate(): void { 84 + if (!this.#active) { 85 + this.#active = true; 86 + this.onMount(); 87 + } 88 + 89 + const frame = this.#render(); 90 + const wrapped = wrapAnsi(frame, getColumns(this.#output), { 91 + hard: true, 92 + trim: false, 93 + }); 94 + 95 + if (wrapped === this.#lastFrame) { 96 + return; 97 + } 98 + 99 + this.#renderDiff(this.#lastFrame, wrapped); 100 + 101 + this.#lastFrame = wrapped; 102 + } 103 + 104 + public unmount(): void { 105 + if (!this.#active) { 106 + return; 107 + } 108 + this.#active = false; 109 + this.onUnmount(); 110 + } 111 + } 112 + 113 + export const term = (strings: TemplateStringsArray, ...values: unknown[]): TemplateArray => { 114 + const result: TemplateArray = []; 115 + 116 + for (let i = 0; i < strings.length; i++) { 117 + result.push(strings[i]); 118 + const value = values[i]; 119 + if (value !== undefined) { 120 + if (value instanceof Component) { 121 + result.push(value); 122 + } else { 123 + result.push(String(value)); 124 + } 125 + } 126 + } 127 + 128 + return result; 129 + }; 130 + 131 + type TemplateArray = Array<string | Component>; 132 + 133 + type Template = string | TemplateArray; 134 + 135 + export class SmileComponent extends Component { 136 + #position: number = 0; 137 + 138 + render(): Template { 139 + if (this.#position === 10) { 140 + this.#position--; 141 + } else { 142 + this.#position++; 143 + } 144 + return term`${' '.repeat(this.#position)}(͡° ͜ʖ ͡°)`; 145 + } 146 + } 147 + 148 + export class SecondsComponent extends Component { 149 + #seconds = 0; 150 + #timer?: ReturnType<typeof setInterval>; 151 + #smileComponent = new SmileComponent(); 152 + 153 + override onMount() { 154 + this.#timer = setInterval(() => { 155 + this.#seconds++; 156 + this.requestUpdate(); 157 + }, 1000); 158 + } 159 + 160 + override onUnmount() { 161 + if (this.#timer) { 162 + clearInterval(this.#timer); 163 + this.#timer = undefined; 164 + } 165 + } 166 + 167 + render(): Template { 168 + return term`| Seconds elapsed: 169 + | ${this.#seconds} 170 + | Press Ctrl+C to exit 171 + | <-- ${this.#smileComponent} --> 172 + `; 173 + } 174 + }
+1
packages/core/src/index.ts
··· 1 + export * from './component.js'; 1 2 export { default as AutocompletePrompt } from './prompts/autocomplete.js'; 2 3 export { default as ConfirmPrompt } from './prompts/confirm.js'; 3 4 export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js';