termino
~/docs/custom/textarea

Textareacustom

Multiline text input with cursor

~/custom/textarea
live
$ termino textarea — focus and type
type something…
cursor 0:0

##API

A small editor state machine: `lines: string[]` plus a `{ row, col }` cursor. Character keys insert via `key.sequence`, named keys (backspace, return, arrows) edit. Renders at most `rows` lines — no scrollbar yet.

properties
proptypedefaultdescription
defaultValuestring""Initial text
onChange(value: string) => void-Fired with the full text on every edit
rowsnumber3Max visible lines
placeholderstring""Shown when empty and unfocused
focusedbooleantrueGate for keyboard handling
cursorFg / cursorBgstring"#16161e" / "#7dcfff"Cursor cell colors

##Keymap

~/keymap
charsInsert at cursor (key.sequence)
returnSplit line
backspace / deleteDelete left / right
↑ ↓ ← →Move cursor
home / endLine start / end

##Notes

  • The cursor renders as an inverted cell mid-line, or a filled block at end-of-line.
  • Controlled `value` is not (yet) supported — pair with onChange if you need it.

##Source

Real implementation — this is the code that runs in your terminal.

tsxtextarea.tsx
1/* eslint-disable react/no-array-index-key -- These renderers draw a fixed
2 terminal grid: a child's identity *is* its row and column, and the grid
3 never reorders, so the index is the stable key rather than a stand-in
4 for one. */
5
6import { createElement as h, useState } from "react";
7import { useKeyboard } from "@opentui/react";
8import type { KeyEvent } from "@opentui/core";
9
10export interface TextareaProps {
11 defaultValue?: string;
12 onChange?: (value: string) => void;
13 rows?: number;
14 placeholder?: string;
15 focused?: boolean;
16 fg?: string;
17 placeholderFg?: string;
18 cursorFg?: string;
19 cursorBg?: string;
20}
21
22/**
23 * The character a key typed, or null if it typed nothing. Printable keys
24 * arrive with `key.name` set to the character itself (e.g. "q"), so the test
25 * is on the sequence rather than on `key.name` being undefined.
26 */
27function printableChar(key: KeyEvent): string | null {
28 const seq = key.sequence;
29 if (!seq || seq.length !== 1 || seq < " " || key.ctrl || key.meta) return null;
30 return seq;
31}
32
33export function Textarea({
34 defaultValue = "",
35 onChange,
36 rows = 3,
37 placeholder = "",
38 focused = true,
39 fg = "#c0caf5",
40 placeholderFg = "#565f89",
41 cursorFg = "#16161e",
42 cursorBg = "#7dcfff",
43}: Readonly<TextareaProps>) {
44 const [lines, setLines] = useState<string[]>(() =>
45 defaultValue.split("\n").slice(0, rows),
46 );
47 const [pos, setPos] = useState({ row: 0, col: 0 });
48
49 const clampCol = (row: number, col: number) => {
50 const line = lines[row] ?? "";
51 return Math.max(0, Math.min(col, line.length));
52 };
53
54 const emit = (next: string[]) => {
55 setLines(next);
56 onChange?.(next.join("\n"));
57 };
58
59 const insert = (text: string) => {
60 const next = lines.slice();
61 const line = next[pos.row] ?? "";
62 next[pos.row] = line.slice(0, pos.col) + text + line.slice(pos.col);
63 setPos({ row: pos.row, col: pos.col + text.length });
64 emit(next);
65 };
66
67 /** Splits the current line at the caret. Once the text fills the visible
68 * rows the topmost line scrolls off rather than the box growing. */
69 const breakLine = () => {
70 const next = lines.slice();
71 next.splice(pos.row + 1, 0, (next[pos.row] ?? "").slice(pos.col));
72 next[pos.row] = (next[pos.row] ?? "").slice(0, pos.col);
73 if (next.length >= rows && rows > 0) {
74 next.shift();
75 setPos({ row: rows - 1, col: 0 });
76 } else {
77 setPos({ row: Math.min(pos.row + 1, next.length - 1), col: 0 });
78 }
79 emit(next);
80 };
81
82 /** Deletes the character before the caret, joining onto the line above when
83 * the caret is already at the start of a line. */
84 const deleteBackward = () => {
85 const next = lines.slice();
86 if (pos.col > 0) {
87 const line = next[pos.row] ?? "";
88 next[pos.row] = line.slice(0, pos.col - 1) + line.slice(pos.col);
89 setPos({ row: pos.row, col: pos.col - 1 });
90 emit(next);
91 return;
92 }
93 if (pos.row === 0) return;
94 const prev = next[pos.row - 1] ?? "";
95 next[pos.row - 1] = prev + (next[pos.row] ?? "");
96 next.splice(pos.row, 1);
97 setPos({ row: pos.row - 1, col: prev.length });
98 emit(next);
99 };
100
101 /** Deletes the character under the caret, pulling up the line below when
102 * the caret is at the end of a line. */
103 const deleteForward = () => {
104 const next = lines.slice();
105 const line = next[pos.row] ?? "";
106 if (pos.col < line.length) {
107 next[pos.row] = line.slice(0, pos.col) + line.slice(pos.col + 1);
108 emit(next);
109 return;
110 }
111 if (pos.row >= next.length - 1) return;
112 next[pos.row] = line + (next[pos.row + 1] ?? "");
113 next.splice(pos.row + 1, 1);
114 emit(next);
115 };
116
117 /** Caret movement. Returns true when the key was a movement key. */
118 const moveCaret = (name: string | undefined): boolean => {
119 switch (name) {
120 case "left":
121 if (pos.col > 0) setPos((p) => ({ row: p.row, col: p.col - 1 }));
122 else if (pos.row > 0) {
123 setPos({ row: pos.row - 1, col: (lines[pos.row - 1] ?? "").length });
124 }
125 return true;
126 case "right":
127 if (pos.col < (lines[pos.row] ?? "").length) {
128 setPos((p) => ({ row: p.row, col: p.col + 1 }));
129 } else if (pos.row < lines.length - 1) setPos({ row: pos.row + 1, col: 0 });
130 return true;
131 case "up":
132 setPos((p) => ({ row: Math.max(0, p.row - 1), col: clampCol(p.row - 1, p.col) }));
133 return true;
134 case "down":
135 setPos((p) => ({
136 row: Math.min(lines.length - 1, p.row + 1),
137 col: clampCol(p.row + 1, p.col),
138 }));
139 return true;
140 case "home":
141 setPos((p) => ({ row: p.row, col: 0 }));
142 return true;
143 case "end":
144 setPos((p) => ({ row: p.row, col: (lines[p.row] ?? "").length }));
145 return true;
146 default:
147 return false;
148 }
149 };
150
151 const handleKey = (key: KeyEvent) => {
152 if (moveCaret(key.name)) return;
153 if (key.name === "return") return breakLine();
154 if (key.name === "backspace") return deleteBackward();
155 if (key.name === "delete") return deleteForward();
156 if (key.name === "space") return insert(" ");
157 const typed = printableChar(key);
158 if (typed) insert(typed);
159 };
160
161 useKeyboard((key) => {
162 if (!focused) return;
163 handleKey(key);
164 });
165
166 const visible = lines.slice(0, rows);
167 const empty = visible.every((l) => l === "");
168
169 return h(
170 "box",
171 { flexDirection: "column" },
172 visible.map((line, ri) => {
173 const display = empty && ri === 0 && !focused ? placeholder : line;
174 const isPlaceholder = empty && ri === 0 && !focused;
175 const col = ri === pos.row ? pos.col : display.length;
176 const clamped = Math.min(col, display.length);
177 const before = display.slice(0, clamped);
178 const at = display[clamped] ?? "";
179 const after = display.slice(clamped + 1);
180 const showCursor = focused && ri === pos.row;
181
182 return h(
183 "box",
184 { key: ri, flexDirection: "row" },
185 h(
186 "text",
187 { fg: isPlaceholder ? placeholderFg : fg },
188 before,
189 ),
190 showCursor
191 ? h(
192 "text",
193 {
194 fg: at ? cursorBg : cursorFg,
195 bg: at ? cursorBg : undefined,
196 },
197 at || "█",
198 )
199 : h(
200 "text",
201 { fg: isPlaceholder ? placeholderFg : fg },
202 at,
203 ),
204 h(
205 "text",
206 { fg: isPlaceholder ? placeholderFg : fg },
207 after,
208 ),
209 );
210 }),
211 );
212}
213