1ch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,15 @@
2
2
 
3
3
  **Terminal UI components for React.** For those who'd rather be in the terminal.
4
4
 
5
- Every UI framework treats the browser as a canvas of infinite resolution. This one doesn't. The smallest unit is one character cell. A button is `[ OK ]`. A progress bar is `████░░░░`. A table border is `│` and `─` and `┼`.
5
+ ![1ch dashboard example](https://raw.githubusercontent.com/twoabove/1ch/main/demo/assets/demo.png)
6
6
 
7
- The web primitives that make it work are almost embarrassingly simple: a monospace font, `white-space: pre`, and `ch` units. That's the entire rendering engine.
7
+ The smallest unit is one character cell. A button is `[ OK ]`. A progress bar is `████░░░░`. A table border is `│` and `─` and `┼`. The whole thing is a grid.
8
+
9
+ ```bash
10
+ npm install 1ch react react-dom
11
+ ```
12
+
13
+ ## What it looks like
8
14
 
9
15
  ```tsx
10
16
  import { TermUI, TBox, TVStack, TTable, TBar } from "1ch";
@@ -19,9 +25,9 @@ function Dashboard() {
19
25
  <TBar label="MEM" value={4.2} max={8} />
20
26
  <TTable
21
27
  columns={[
22
- { key: "service", header: "Service", width: 20 },
23
- { key: "status", header: "Status", width: 10 },
24
- { key: "uptime", header: "Uptime", width: 12 },
28
+ { key: "service", width: 20 },
29
+ { key: "status", width: 10 },
30
+ { key: "uptime", width: 12 },
25
31
  ]}
26
32
  data={[
27
33
  { service: "api-gateway", status: "UP", uptime: "14d 3h" },
@@ -36,70 +42,93 @@ function Dashboard() {
36
42
  }
37
43
  ```
38
44
 
39
- ## Why
45
+ <!-- TODO: Add screenshot of the above rendering -->
40
46
 
41
- - **Dense.** Every character is content or structure. Nothing is decorative. You can always tell where one element ends and another begins because the grid enforces it.
42
- - **React-native.** Not a wrapper around a terminal emulator. Custom React reconciler that turns a component tree into character grids. React handles lifecycle, hooks, state. The reconciler handles layout.
43
- - **Responsive.** All layout is lazy - functions of width, not pre-computed blocks. Resize the container and everything reflows.
44
- - **Themeable.** Full token system: palette, semantic, syntax, markdown, component colors. Light/dark/system. Runtime-switchable. End-user JSON theming with validation.
45
- - **Three input formats.** Render React components, or feed it markdown, HTML, or JSON and get terminal layouts back.
47
+ ## Why this exists
46
48
 
47
- ## Installation
49
+ Most UI components are styled `<div>`s pretending to be something else. Terminal UIs skip the pretense - every character is either content or structure, and the grid makes boundaries obvious.
48
50
 
49
- ```bash
50
- npm install 1ch react react-dom
51
- ```
51
+ Under the hood, 1ch uses a custom React reconciler that turns your component tree into character grids. React handles lifecycle, hooks, state. The reconciler handles layout. Resize the container and everything reflows.
52
52
 
53
53
  ## Components
54
54
 
55
- React components that render through a custom reconciler. No DOM elements - they produce `LayoutFn` internally.
55
+ **Layout** - `TVStack`, `THStack`, `TBox`, `TSeparator`, `TBlank`, `TLine`, `TSpan`
56
56
 
57
- ### Layout
57
+ **Data** - `TTable`, `TTree`, `TList`, `TCode`, `TDiff`, `TBar`, `TSpark`
58
58
 
59
- | Component | What it does |
60
- | ------------ | ---------------------------------------------------- |
61
- | `TVStack` | Vertical stack with optional `gap` |
62
- | `THStack` | Horizontal stack with optional `gap` and `widths` |
63
- | `TBox` | Bordered box with optional `title` and `borderColor` |
64
- | `TSeparator` | Horizontal line |
65
- | `TBlank` | Empty line |
66
- | `TLine` | Single-line container |
67
- | `TSpan` | Inline styled text |
59
+ **Interactive** - `TTabs`, `TButton`, `TStatusbar`
68
60
 
69
- ### Data
61
+ **Documents** - `TMarkdown`, `THtml`, `TJson`
70
62
 
71
- | Component | What it does |
72
- | --------- | ---------------------------------------------------------- |
73
- | `TTable` | Bordered table with column widths, wrapping, header colors |
74
- | `TTree` | Nested tree view with `├─` and `└─` branch characters |
75
- | `TList` | Bulleted or numbered list |
76
- | `TCode` | Syntax-highlighted code block in a box |
77
- | `TDiff` | Colored diff output |
78
- | `TBar` | Progress bar (`████░░░░`) |
79
- | `TSpark` | Sparkline (`▁▂▃▄▅▆▇█`) |
63
+ The document components accept raw strings. Feed `TMarkdown` a markdown string, `THtml` an HTML string, or `TJson` a JSON object, and you get terminal-styled output back.
80
64
 
81
- ### Interactive
65
+ ## A more complete example
82
66
 
83
- | Component | What it does |
84
- | ------------ | ---------------------------------- |
85
- | `TTabs` | Clickable tab bar with `onSelect` |
86
- | `TButton` | Clickable styled button |
87
- | `TStatusbar` | Footer bar with left/right content |
67
+ ```tsx
68
+ import { TermUI, TBox, TCode, TTabs, TTab, TTable, TBar, TVStack } from "1ch";
69
+ import "1ch/style.css";
88
70
 
89
- ### Documents
71
+ function App() {
72
+ const [tab, setTab] = useState(0);
90
73
 
91
- | Component | What it does |
92
- | ----------- | ------------------------------------------ |
93
- | `TMarkdown` | Renders markdown string as terminal layout |
94
- | `THtml` | Renders HTML string as terminal layout |
95
- | `TJson` | Renders structured JSON as terminal layout |
74
+ return (
75
+ <TermUI width={80}>
76
+ <TTabs active={tab} onSelect={setTab}>
77
+ <TTab name="Overview">
78
+ <TBox title="Status">
79
+ <TVStack>
80
+ <TBar label="CPU" value={73} max={100} />
81
+ <TBar label="MEM" value={4.2} max={8} />
82
+ </TVStack>
83
+ </TBox>
84
+ </TTab>
96
85
 
97
- ## Imperative API
86
+ <TTab name="Logs">
87
+ <TTable
88
+ columns={[
89
+ { key: "time", width: 12 },
90
+ { key: "level", width: 8 },
91
+ { key: "message", width: 40 },
92
+ ]}
93
+ data={[
94
+ {
95
+ time: "14:03:21",
96
+ level: "INFO",
97
+ message: "Server started on :3000",
98
+ },
99
+ {
100
+ time: "14:03:22",
101
+ level: "INFO",
102
+ message: "Connected to database",
103
+ },
104
+ { time: "14:05:01", level: "WARN", message: "Slow query: 2.3s" },
105
+ ]}
106
+ />
107
+ </TTab>
98
108
 
99
- If you don't want React components, every layout primitive has a function equivalent that returns a `LayoutFn`:
109
+ <TTab name="Config">
110
+ <TCode
111
+ code={`export default {
112
+ port: 3000,
113
+ database: "postgres://localhost/app",
114
+ cache: { ttl: 300, max: 1000 },
115
+ };`}
116
+ language="typescript"
117
+ title="config.ts"
118
+ />
119
+ </TTab>
120
+ </TTabs>
121
+ </TermUI>
122
+ );
123
+ }
124
+ ```
125
+
126
+ ## Don't want React? Skip it.
127
+
128
+ Every component has an imperative equivalent that returns a layout function. Call it with a width and get a character grid back.
100
129
 
101
130
  ```typescript
102
- import { box, vstack, table, code, bar, hstack } from "1ch";
131
+ import { box, vstack, table, code, bar } from "1ch";
103
132
 
104
133
  const layout = vstack(
105
134
  box(
@@ -121,57 +150,12 @@ const layout = vstack(
121
150
  code(`console.log("hello")`, { language: "javascript" })
122
151
  );
123
152
 
124
- // Materialize at any width
125
- const block: Block = layout(80);
126
- ```
127
-
128
- ### Available builders
129
-
130
- **Layout:** `box`, `hstack`, `vstack`, `separator`, `blank`, `lines`
131
-
132
- **Data:** `table`, `tree`, `list`, `code`, `diff`
133
-
134
- **Widgets:** `tabs`, `statusbar`, `badge`, `bar`, `spark`
135
-
136
- **Text:** `pad`, `hl`, `padLine`
137
-
138
- ## Document Pipeline
139
-
140
- Feed it markdown, HTML, or JSON. Get terminal layouts back.
141
-
142
- ```tsx
143
- import { TermDocument } from "1ch";
144
-
145
- // Markdown
146
- <TermDocument
147
- source={{ format: "markdown", value: "# Hello\n\nSome **bold** text." }}
148
- width={60}
149
- />
150
-
151
- // HTML
152
- <TermDocument
153
- source={{ format: "html", value: "<h1>Hello</h1><p>Some text.</p>" }}
154
- width={60}
155
- />
156
-
157
- // Or use the functions directly
158
- import { fromMarkdown, fromHtml, fromJson } from "1ch";
159
-
160
- const layout = fromMarkdown("# Hello\n\n- one\n- two", { theme });
161
153
  const block = layout(80);
162
154
  ```
163
155
 
164
- ### HTML support
156
+ ## Theming
165
157
 
166
- Supported tags: `h1`-`h6`, `p`, `ul`, `ol`, `li`, `table` (with `thead`/`tbody`/`tr`/`th`/`td`), `blockquote`, `hr`, `pre`, `div`, `section`, `article`, `main`, `header`, `footer`, `nav`.
167
-
168
- `<term>` acts as an optional root - if present, only its subtree renders.
169
-
170
- Color attributes: `color`, `data-color`, `marker-color`, `header-color`, `border-color`, `text-color`. Values can be theme keys (`yellow`, `cyan`) or CSS colors (`#56b6c2`).
171
-
172
- ## Theme System
173
-
174
- Two built-in themes (dark and light), runtime-switchable, fully customizable via JSON.
158
+ Dark and light themes built in, and are switchable at runtime. You can override everything via JSON - palette, semantic colors, syntax highlighting, markdown rendering, component tokens. Use `parseThemeSpec()` to check the JSON before applying it.
175
159
 
176
160
  ```tsx
177
161
  import {
@@ -184,96 +168,14 @@ import {
184
168
  const parsed = parseThemeSpec(userThemeJson);
185
169
  const spec = parsed.ok ? parsed.theme : defaultThemeSpec;
186
170
 
187
- function App() {
188
- return (
189
- <TermThemeProvider initialTheme={spec} initialMode="system">
190
- <TermUI width={80}>{/* components inherit the theme */}</TermUI>
191
- </TermThemeProvider>
192
- );
193
- }
194
- ```
195
-
196
- A `ThemeSpec` defines both light and dark modes. Each mode has:
197
-
198
- - **Palette** - 10 ANSI-like colors (black, red, green, yellow, blue, magenta, cyan, white, brightBlack, brightWhite)
199
- - **Semantic** - UI intent (border, info, success, warn, danger, frameBg, frameFg, etc.)
200
- - **Syntax** - Code highlighting (keyword, string, number, comment, function, type, punctuation)
201
- - **Markdown** - Document rendering (headings h1-h6, paragraph, lists, tables, quotes, code)
202
- - **Components** - Widget colors (tabs, statusbar, badge, button)
203
-
204
- Use `validateThemeSpec()` to check user-provided JSON before applying it.
205
-
206
- ## Hooks
207
-
208
- ```typescript
209
- import { useSpinner, useTick, useTermWidth, useStreamingText } from "1ch";
210
-
211
- // Animated braille spinner
212
- const spinner = useSpinner(80);
213
-
214
- // Interval counter for animations
215
- const tick = useTick(500);
216
-
217
- // Measure container width in character units (uses ResizeObserver)
218
- const ref = useRef(null);
219
- const width = useTermWidth(ref, 80);
220
-
221
- // Streaming text with lerp (detects appends, doesn't restart)
222
- const displayed = useStreamingText(streamingText, 0.08);
171
+ <TermThemeProvider initialTheme={spec} initialMode="system">
172
+ <TermUI width={80}>{/* components inherit the theme */}</TermUI>
173
+ </TermThemeProvider>;
223
174
  ```
224
175
 
225
- ## Putting It Together
226
-
227
- ```tsx
228
- import { TermUI, TBox, TCode, TTabs, TTab, TTable, TBar, TVStack } from "1ch";
229
- import "1ch/style.css";
230
-
231
- function App() {
232
- const [tab, setTab] = useState(0);
233
-
234
- return (
235
- <TermUI width={80}>
236
- <TTabs active={tab} onSelect={setTab}>
237
- <TTab name="Overview">
238
- <TBox title="Status">
239
- <TVStack>
240
- <TBar label="CPU" value={73} max={100} />
241
- <TBar label="MEM" value={4.2} max={8} />
242
- </TVStack>
243
- </TBox>
244
- </TTab>
245
-
246
- <TTab name="Logs">
247
- <TTable
248
- columns={[
249
- { key: "time", header: "Time", width: 12 },
250
- { key: "level", header: "Level", width: 8 },
251
- { key: "message", header: "Message", width: 40 },
252
- ]}
253
- data={[
254
- { time: "14:03:21", level: "INFO", message: "Server started on :3000" },
255
- { time: "14:03:22", level: "INFO", message: "Connected to database" },
256
- { time: "14:05:01", level: "WARN", message: "Slow query: 2.3s" },
257
- ]}
258
- />
259
- </TTab>
176
+ ## Docs
260
177
 
261
- <TTab name="Config">
262
- <TCode
263
- code={`export default {
264
- port: 3000,
265
- database: "postgres://localhost/app",
266
- cache: { ttl: 300, max: 1000 },
267
- };`}
268
- language="typescript"
269
- title="config.ts"
270
- />
271
- </TTab>
272
- </TTabs>
273
- </TermUI>
274
- );
275
- }
276
- ```
178
+ Full API reference, theme spec details, hooks (`useSpinner`, `useTick`, `useTermWidth`, `useStreamingText`), and the document pipeline - all in the [docs](1ch.app) (coming soon).
277
179
 
278
180
  ## License
279
181
 
package/dist/index.d.ts CHANGED
@@ -131,10 +131,16 @@ declare function bar(val: number, max: number, w: number): string;
131
131
  declare function spark(data: number[], w: number): string;
132
132
  declare function padLine(line: Line, w: number): Line;
133
133
 
134
+ type BoxAction = {
135
+ label: string;
136
+ onClick: () => void;
137
+ color?: string;
138
+ };
134
139
  declare function lines(content: Line[] | ((w: number) => Line[])): LayoutFn;
135
140
  declare function box(content: LayoutFn, opts?: {
136
141
  title?: string;
137
142
  borderColor?: string;
143
+ action?: BoxAction;
138
144
  }): LayoutFn;
139
145
  declare function hstack(children: LayoutFn[], opts?: {
140
146
  gap?: number;
@@ -263,6 +269,8 @@ declare function code(source: string, opts?: {
263
269
  title?: string;
264
270
  borderColor?: string;
265
271
  theme?: Theme;
272
+ copyable?: boolean;
273
+ action?: BoxAction;
266
274
  }): LayoutFn;
267
275
  declare function diff(value: string, opts?: {
268
276
  addColor?: string;
@@ -397,6 +405,7 @@ type TBoxProps = BaseProps & {
397
405
  title?: string;
398
406
  borderColor?: string;
399
407
  gap?: number;
408
+ action?: BoxAction;
400
409
  };
401
410
  type TSeparatorProps = {
402
411
  color?: string;
@@ -478,6 +487,8 @@ type TCodeProps = {
478
487
  language?: string;
479
488
  title?: string;
480
489
  borderColor?: string;
490
+ action?: BoxAction;
491
+ copyable?: boolean;
481
492
  };
482
493
  type TDiffProps = {
483
494
  value: string;
@@ -520,10 +531,11 @@ declare function THStack({ children, gap, widths }: THStackProps): react.ReactEl
520
531
  gap: number | undefined;
521
532
  widths: number[] | undefined;
522
533
  }, string | react.JSXElementConstructor<any>>;
523
- declare function TBox({ children, title, borderColor, gap }: TBoxProps): react.ReactElement<{
534
+ declare function TBox({ children, title, borderColor, gap, action }: TBoxProps): react.ReactElement<{
524
535
  title: string | undefined;
525
536
  borderColor: string | undefined;
526
537
  gap: number | undefined;
538
+ action: BoxAction | undefined;
527
539
  }, string | react.JSXElementConstructor<any>>;
528
540
  declare function TSeparator({ color }: TSeparatorProps): react.ReactElement<{
529
541
  color: string | undefined;
@@ -595,11 +607,13 @@ declare function TTree({ data, color, branchColor, leafColor }: TTreeProps): rea
595
607
  branchColor: string | undefined;
596
608
  leafColor: string | undefined;
597
609
  }, string | react.JSXElementConstructor<any>>;
598
- declare function TCode({ code, language, title, borderColor }: TCodeProps): react.ReactElement<{
610
+ declare function TCode({ code, language, title, borderColor, action, copyable }: TCodeProps): react.ReactElement<{
599
611
  code: string;
600
612
  language: string | undefined;
601
613
  title: string | undefined;
602
614
  borderColor: string | undefined;
615
+ action: BoxAction | undefined;
616
+ copyable: boolean | undefined;
603
617
  }, string | react.JSXElementConstructor<any>>;
604
618
  declare function TDiff({ value, addColor, removeColor, metaColor }: TDiffProps): react.ReactElement<{
605
619
  value: string;
@@ -636,4 +650,4 @@ declare function useTick(ms?: number): number;
636
650
  declare function useTermWidth(ref: RefObject<HTMLElement | null>, fallback?: number): number;
637
651
  declare function useStreamingText(text: string, lerp?: number): string;
638
652
 
639
- export { type Block, type Column, type DocumentSource, type JsonNode, type LayoutFn, type Line, type MarkdownThemeOverrides, type RenderTermOptions, type Segment, type Style, TBar, type TBarProps, TBlank, TBox, type TBoxProps, TButton, type TButtonProps, TCode, type TCodeProps, TDiff, type TDiffProps, THStack, type THStackProps, THtml, type THtmlProps, TJson, type TJsonProps, TLayout, type TLayoutProps, TLine, type TLineProps, TList, type TListProps, TMarkdown, type TMarkdownProps, TRaw, type TRawProps, TSeparator, type TSeparatorProps, TSpan, type TSpanProps, TSpark, type TSparkProps, TStatusbar, type TStatusbarProps, TTab, type TTabProps, TTable, type TTableProps, TTabs, type TTabsProps, TTree, type TTreeProps, TVStack, type TVStackProps, TermDocument, type TermDocumentProps, type TermThemeContextValue, TermThemeProvider, type TermThemeProviderProps, TermUI, type TermUIProps, type Theme, type ThemeComponents, type ThemeMarkdown, type ThemeMode, type ThemeModeTokens, type ThemePalette, type ThemePreference, type ThemeSemantic, type ThemeSpec, type ThemeSyntax, type ThemeValidationIssue, type ThemeValidationResult, type Token, type TokenType, type TreeNode, badge, bar, blank, box, builtinThemes, code, dark, defaultThemeSpec, diff, fromDocument, fromHtml, fromJson, fromJsonNode, fromMarkdown, hl, hstack, isTableLine, light, lines, list, pad, padLine, parseThemeSpec, preserveTableTag, renderTermReact, resolveTheme, resolveThemeMode, separator, spark, statusbar, table, tabs, themeToCssVars, tokenColor, tokenize, tree, useSpinner, useStreamingText, useTermTheme, useTermWidth, useTick, validateThemeSpec, vstack };
653
+ export { type Block, type BoxAction, type Column, type DocumentSource, type JsonNode, type LayoutFn, type Line, type MarkdownThemeOverrides, type RenderTermOptions, type Segment, type Style, TBar, type TBarProps, TBlank, TBox, type TBoxProps, TButton, type TButtonProps, TCode, type TCodeProps, TDiff, type TDiffProps, THStack, type THStackProps, THtml, type THtmlProps, TJson, type TJsonProps, TLayout, type TLayoutProps, TLine, type TLineProps, TList, type TListProps, TMarkdown, type TMarkdownProps, TRaw, type TRawProps, TSeparator, type TSeparatorProps, TSpan, type TSpanProps, TSpark, type TSparkProps, TStatusbar, type TStatusbarProps, TTab, type TTabProps, TTable, type TTableProps, TTabs, type TTabsProps, TTree, type TTreeProps, TVStack, type TVStackProps, TermDocument, type TermDocumentProps, type TermThemeContextValue, TermThemeProvider, type TermThemeProviderProps, TermUI, type TermUIProps, type Theme, type ThemeComponents, type ThemeMarkdown, type ThemeMode, type ThemeModeTokens, type ThemePalette, type ThemePreference, type ThemeSemantic, type ThemeSpec, type ThemeSyntax, type ThemeValidationIssue, type ThemeValidationResult, type Token, type TokenType, type TreeNode, badge, bar, blank, box, builtinThemes, code, dark, defaultThemeSpec, diff, fromDocument, fromHtml, fromJson, fromJsonNode, fromMarkdown, hl, hstack, isTableLine, light, lines, list, pad, padLine, parseThemeSpec, preserveTableTag, renderTermReact, resolveTheme, resolveThemeMode, separator, spark, statusbar, table, tabs, themeToCssVars, tokenColor, tokenize, tree, useSpinner, useStreamingText, useTermTheme, useTermWidth, useTick, validateThemeSpec, vstack };
package/dist/index.js CHANGED
@@ -113,20 +113,27 @@ function wrapText(text, w) {
113
113
  }
114
114
 
115
115
  // src/termui/layout.ts
116
- function borderTop(w, title, color) {
116
+ function borderTop(w, opts) {
117
117
  const width = normalizedWidth(w);
118
118
  if (width <= 0) return [];
119
- if (width === 1) return [{ text: "\u250C", color, noSelect: true }];
119
+ if (width === 1) return [{ text: "\u250C", color: opts?.color, noSelect: true }];
120
+ const { title, color, action } = opts ?? {};
121
+ const innerWidth = width - 2;
122
+ const titleBudget = innerWidth - (action ? action.label.length + 2 : 0) - 4;
123
+ let titleStr = "";
120
124
  if (title) {
121
- const maxTitle = Math.max(0, width - 6);
122
- const safeTitle = title.length > maxTitle ? title.slice(0, maxTitle) : title;
123
- const titleStr = "\u2500 " + safeTitle + " ";
124
- const remaining = Math.max(0, width - 2 - titleStr.length);
125
- return [
126
- { text: "\u250C" + titleStr + hl(remaining) + "\u2510", color, noSelect: true }
127
- ];
125
+ const safeTitle = title.length > titleBudget ? title.slice(0, Math.max(0, titleBudget)) : title;
126
+ titleStr = safeTitle.length > 0 ? "\u2500 " + safeTitle + " " : "";
128
127
  }
129
- return [{ text: "\u250C" + hl(width - 2) + "\u2510", color, noSelect: true }];
128
+ const fillWidth = Math.max(0, innerWidth - titleStr.length - (action ? action.label.length + 2 : 0));
129
+ if (!action) {
130
+ return [{ text: "\u250C" + titleStr + hl(fillWidth) + "\u2510", color, noSelect: true }];
131
+ }
132
+ return [
133
+ { text: "\u250C" + titleStr + hl(fillWidth), color, noSelect: true },
134
+ { text: " " + action.label + " ", color: action.color ?? color, onClick: action.onClick, noSelect: true },
135
+ { text: "\u2510", color, noSelect: true }
136
+ ];
130
137
  }
131
138
  function borderBottom(w, color) {
132
139
  const width = normalizedWidth(w);
@@ -148,7 +155,7 @@ function box(content, opts) {
148
155
  const inner = width - 2;
149
156
  const contentBlock = content(inner);
150
157
  const result = [];
151
- result.push(borderTop(width, opts?.title, bc));
158
+ result.push(borderTop(width, { title: opts?.title, color: bc, action: opts?.action }));
152
159
  for (const line of contentBlock) {
153
160
  result.push([
154
161
  { text: "\u2502", color: bc, noSelect: true },
@@ -1023,6 +1030,7 @@ function tokenColor(type, theme) {
1023
1030
  function code(source, opts) {
1024
1031
  const t = opts?.theme ?? dark;
1025
1032
  const tokens = tokenize(source, opts?.language);
1033
+ const copyAction = opts?.copyable !== false ? { label: "cp", onClick: () => navigator.clipboard.writeText(source), color: t.semantic.frameMuted } : void 0;
1026
1034
  return box(
1027
1035
  lines(
1028
1036
  tokens.length > 0 ? tokens.map(
@@ -1031,7 +1039,8 @@ function code(source, opts) {
1031
1039
  ),
1032
1040
  {
1033
1041
  title: opts?.title ?? (opts?.language ? `code:${opts.language}` : "code"),
1034
- borderColor: opts?.borderColor ?? t.markdown.codeBorder
1042
+ borderColor: opts?.borderColor ?? t.markdown.codeBorder,
1043
+ action: opts?.action ?? copyAction
1035
1044
  }
1036
1045
  );
1037
1046
  }
@@ -1386,16 +1395,23 @@ function renderMarkdownBlock(block, theme, overrides) {
1386
1395
  };
1387
1396
  case "separator":
1388
1397
  return separator(overrides?.separator ?? md.separator);
1389
- case "code":
1398
+ case "code": {
1399
+ const codeSource = block.lines.join("\n");
1390
1400
  return box(
1391
1401
  lines(
1392
1402
  block.lines.length > 0 ? block.lines.map((line) => [{ text: line || " ", color: overrides?.codeText ?? md.codeText }]) : [[{ text: " ", color: overrides?.codeEmpty ?? md.codeEmpty, dim: true }]]
1393
1403
  ),
1394
1404
  {
1395
1405
  title: block.language ? `code:${block.language}` : "code",
1396
- borderColor: overrides?.codeBorder ?? md.codeBorder
1406
+ borderColor: overrides?.codeBorder ?? md.codeBorder,
1407
+ action: {
1408
+ label: "cp",
1409
+ onClick: () => navigator.clipboard.writeText(codeSource),
1410
+ color: theme.semantic.frameMuted
1411
+ }
1397
1412
  }
1398
1413
  );
1414
+ }
1399
1415
  }
1400
1416
  }
1401
1417
  function fromMarkdown(markdown, opts) {
@@ -2068,6 +2084,13 @@ function asRecordArray(value) {
2068
2084
  }
2069
2085
  return out;
2070
2086
  }
2087
+ function asBoxAction(value) {
2088
+ if (!isRecord4(value)) return void 0;
2089
+ const label = asString(value.label);
2090
+ const onClick = typeof value.onClick === "function" ? value.onClick : void 0;
2091
+ if (!label || !onClick) return void 0;
2092
+ return { label, onClick, color: asString(value.color) };
2093
+ }
2071
2094
  function isMarkdownOverrides(value) {
2072
2095
  if (!isRecord4(value)) return false;
2073
2096
  if (value.headingMarker !== void 0 && typeof value.headingMarker !== "string") return false;
@@ -2368,7 +2391,8 @@ function compileTermNode(node, theme, mdOverrides) {
2368
2391
  const gap = asNumber(p.gap) ?? 0;
2369
2392
  return box(stackWithGap(ch, gap), {
2370
2393
  title: asString(p.title),
2371
- borderColor: asString(p.borderColor) ?? theme.semantic.border
2394
+ borderColor: asString(p.borderColor) ?? theme.semantic.border,
2395
+ action: asBoxAction(p.action)
2372
2396
  });
2373
2397
  }
2374
2398
  case "t-separator":
@@ -2447,7 +2471,9 @@ function compileTermNode(node, theme, mdOverrides) {
2447
2471
  language: asString(p.language),
2448
2472
  title: asString(p.title),
2449
2473
  borderColor: asString(p.borderColor) ?? theme.markdown.codeBorder,
2450
- theme
2474
+ theme,
2475
+ copyable: asBoolean(p.copyable),
2476
+ action: asBoxAction(p.action)
2451
2477
  });
2452
2478
  case "t-diff":
2453
2479
  return diff(asString(p.value) ?? "", {
@@ -3021,8 +3047,8 @@ function TVStack({ children, gap }) {
3021
3047
  function THStack({ children, gap, widths }) {
3022
3048
  return createElement("t-hstack", { gap, widths }, children);
3023
3049
  }
3024
- function TBox({ children, title, borderColor, gap }) {
3025
- return createElement("t-box", { title, borderColor, gap }, children);
3050
+ function TBox({ children, title, borderColor, gap, action }) {
3051
+ return createElement("t-box", { title, borderColor, gap, action }, children);
3026
3052
  }
3027
3053
  function TSeparator({ color }) {
3028
3054
  return createElement("t-separator", { color });
@@ -3103,8 +3129,8 @@ function TLayout({ layout }) {
3103
3129
  function TTree({ data, color, branchColor, leafColor }) {
3104
3130
  return createElement("t-tree", { data, color, branchColor, leafColor });
3105
3131
  }
3106
- function TCode({ code: code2, language, title, borderColor }) {
3107
- return createElement("t-code", { code: code2, language, title, borderColor });
3132
+ function TCode({ code: code2, language, title, borderColor, action, copyable }) {
3133
+ return createElement("t-code", { code: code2, language, title, borderColor, action, copyable });
3108
3134
  }
3109
3135
  function TDiff({ value, addColor, removeColor, metaColor }) {
3110
3136
  return createElement("t-diff", { value, addColor, removeColor, metaColor });