@valyrianjs/terminal 0.2.1 → 0.2.2

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.
Files changed (80) hide show
  1. package/dist/ansi.d.ts.map +1 -1
  2. package/dist/ansi.js +12 -14
  3. package/dist/ansi.js.map +1 -1
  4. package/dist/events.d.ts.map +1 -1
  5. package/dist/events.js +4 -0
  6. package/dist/events.js.map +1 -1
  7. package/dist/frame-style.d.ts +7 -0
  8. package/dist/frame-style.d.ts.map +1 -0
  9. package/dist/frame-style.js +27 -0
  10. package/dist/frame-style.js.map +1 -0
  11. package/dist/layout.d.ts +5 -1
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +53 -23
  14. package/dist/layout.js.map +1 -1
  15. package/dist/mouse.d.ts.map +1 -1
  16. package/dist/mouse.js +8 -1
  17. package/dist/mouse.js.map +1 -1
  18. package/dist/render.d.ts.map +1 -1
  19. package/dist/render.js +87 -48
  20. package/dist/render.js.map +1 -1
  21. package/dist/session.d.ts.map +1 -1
  22. package/dist/session.js +2 -0
  23. package/dist/session.js.map +1 -1
  24. package/dist/text.d.ts +7 -0
  25. package/dist/text.d.ts.map +1 -1
  26. package/dist/text.js +114 -0
  27. package/dist/text.js.map +1 -1
  28. package/dist/types.d.ts +3 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/docs/api-reference.md +6 -3
  31. package/docs/cookbook.md +1 -1
  32. package/docs/interaction-model.md +5 -5
  33. package/docs/primitive-gallery.md +4 -4
  34. package/examples/basic.tsx +22 -0
  35. package/examples/cli.tsx +55 -0
  36. package/examples/demo.tsx +98 -0
  37. package/examples/docs/background-fill.tsx +107 -0
  38. package/examples/docs/component-composition.tsx +140 -0
  39. package/examples/docs/cursor.tsx +121 -0
  40. package/examples/docs/employees-list.tsx +138 -0
  41. package/examples/docs/hello.tsx +98 -0
  42. package/examples/docs/interactive-note.tsx +111 -0
  43. package/examples/docs/module-api-dashboard.tsx +307 -0
  44. package/examples/docs/module-flux-store.tsx +181 -0
  45. package/examples/docs/module-form-workflow.tsx +339 -0
  46. package/examples/docs/module-forms.tsx +218 -0
  47. package/examples/docs/module-money.tsx +175 -0
  48. package/examples/docs/module-native-store.tsx +188 -0
  49. package/examples/docs/module-pulses.tsx +142 -0
  50. package/examples/docs/module-query.tsx +209 -0
  51. package/examples/docs/module-request.tsx +194 -0
  52. package/examples/docs/module-state-workbench.tsx +283 -0
  53. package/examples/docs/module-tasks.tsx +223 -0
  54. package/examples/docs/module-translate.tsx +194 -0
  55. package/examples/docs/module-utils.tsx +168 -0
  56. package/examples/docs/module-valyrian-core.tsx +159 -0
  57. package/examples/docs/pizza-builder.tsx +463 -0
  58. package/examples/docs/primitive-activity-console.tsx +113 -0
  59. package/examples/docs/primitive-command-panel.tsx +186 -0
  60. package/examples/docs/primitive-data-explorer.tsx +155 -0
  61. package/examples/docs/primitive-input-workbench.tsx +128 -0
  62. package/examples/docs/primitive-layout-shell.tsx +115 -0
  63. package/examples/docs/responsive-split.tsx +186 -0
  64. package/examples/docs/style-system.tsx +209 -0
  65. package/examples/docs/theme-colors.tsx +225 -0
  66. package/examples/docs/virtualized-list-workbench.tsx +232 -0
  67. package/examples/opencode-dogfood-app.tsx +215 -0
  68. package/examples/opencode-dogfood-lifecycle.tsx +194 -0
  69. package/examples/opencode-dogfood.tsx +11 -0
  70. package/llms-full.txt +16 -13
  71. package/package.json +3 -2
  72. package/src/ansi.ts +12 -14
  73. package/src/events.ts +2 -0
  74. package/src/frame-style.ts +36 -0
  75. package/src/layout.ts +57 -24
  76. package/src/mouse.ts +10 -1
  77. package/src/render.ts +92 -48
  78. package/src/session.ts +2 -0
  79. package/src/text.ts +148 -0
  80. package/src/types.ts +3 -0
package/llms-full.txt CHANGED
@@ -666,7 +666,7 @@ console.log(session.output());
666
666
  session.destroy();
667
667
  ```
668
668
 
669
- For large lists, add `virtualized` and place the list in a bounded region such as a filled pane or split. The virtualized workbench shows the normal uncontrolled API with `itemKey`, the `children` callback, event observation, and internal active, selected, and viewport state: [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx). Run it with `bun examples/docs/virtualized-list-workbench.tsx`, move active rows with `J/K` or `Up`/`Down`, jump with `PageUp`, `PageDown`, `Home`, or `End`, select with `Enter`, and quit with `Ctrl+C`.
669
+ For large lists, add `virtualized` and place the list in a bounded region such as a filled pane or split. The virtualized workbench demonstrates the uncontrolled List API with `itemKey`, the `children` callback, event payloads, active row, selected row, and viewport values: [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx). Shift+Up and Shift+Down are available for custom app actions. The List does not reorder items by default. Run it with `bun examples/docs/virtualized-list-workbench.tsx`, move active rows with `J/K` or `Up`/`Down`, jump with `PageUp`, `PageDown`, `Home`, or `End`, select with `Enter`, and quit with `Ctrl+C`.
670
670
 
671
671
  ## Local state to Valyrian state module bridge
672
672
 
@@ -1144,12 +1144,12 @@ Use `Editor` for notes, descriptions, and longer messages.
1144
1144
 
1145
1145
  **What it is:** A multi-line editable field.
1146
1146
  **Use it for:** notes, descriptions, and longer messages.
1147
- **Core props:** `id`, `value`, `placeholder`, `height`, `onchange`, `oninput`, `onsubmit`, `oncancel`.
1147
+ **Core props:** `id`, `value`, `placeholder`, `width`, `height`, `fill`, `onchange`, `oninput`, `onsubmit`, `oncancel`.
1148
1148
 
1149
1149
  **Minimal example:**
1150
1150
 
1151
1151
  ```tsx
1152
- <Editor id="details" height={4} value={details} onchange={(event) => { details = event.value; }} />
1152
+ <Editor id="details" fill height={4} value={details} onchange={(event) => { details = event.value; }} />
1153
1153
  ```
1154
1154
 
1155
1155
  **Complete demo:** [`examples/docs/primitive-input-workbench.tsx`](../examples/docs/primitive-input-workbench.tsx). Run it with `bun examples/docs/primitive-input-workbench.tsx` and quit with `Ctrl+C`.
@@ -1196,7 +1196,7 @@ Use `List` for menus, choosers, command lists, and item browsers.
1196
1196
 
1197
1197
  **What it is:** A focusable collection control with selection and press events.
1198
1198
  **Use it for:** menus, choosers, command lists, and item browsers.
1199
- **Core props:** `id`, `items`, `children` callback, `renderItem`, `itemKey`, `virtualized`, `showActive`, `onchange`, `onviewportchange`, `onpress`, `ondoublepress`, `oncontextpress`. `List` owns its active row, selected row, and viewport state internally; observe those values from event payloads.
1199
+ **Core props:** `id`, `items`, `children` callback, `renderItem`, `itemKey`, `virtualized`, `showActive`, `onchange`, `onviewportchange`, `onpress`, `ondoublepress`, `oncontextpress`. `List` manages its active row, selected row, and viewport state. Read those values from event payloads.
1200
1200
 
1201
1201
  **Minimal example:**
1202
1202
 
@@ -1208,7 +1208,7 @@ Use `List` for menus, choosers, command lists, and item browsers.
1208
1208
 
1209
1209
  **Complete demo:** [`examples/docs/primitive-data-explorer.tsx`](../examples/docs/primitive-data-explorer.tsx). Run it with `bun examples/docs/primitive-data-explorer.tsx` and quit with `Ctrl+C`.
1210
1210
 
1211
- **Virtualized demo:** [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx). Run it with `bun examples/docs/virtualized-list-workbench.tsx`, move active rows with `J/K` or `Up`/`Down`, jump with `PageUp`, `PageDown`, `Home`, or `End`, select with `Enter`, and quit with `Ctrl+C`.
1211
+ **Virtualized demo:** [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx). Run it with `bun examples/docs/virtualized-list-workbench.tsx`, move active rows with `J/K` or `Up`/`Down`, jump with `PageUp`, `PageDown`, `Home`, or `End`, select with `Enter`, and quit with `Ctrl+C`. `Shift+Up` and `Shift+Down` are shown as app-defined commands, not built-in reorder behavior.
1212
1212
 
1213
1213
  ### `Table`
1214
1214
 
@@ -1359,7 +1359,7 @@ This guide explains how interactive primitives receive input and report events.
1359
1359
 
1360
1360
  ## Input events
1361
1361
 
1362
- Keyboard dispatch uses normalized key names such as `ENTER`, `TAB`, `SHIFT_TAB`, `LEFT`, `RIGHT`, `CTRL_V`, and single-character strings. When a session is connected to `stdin`, terminal input is parsed and routed to the focused primitive.
1362
+ Keyboard dispatch uses normalized key names such as `ENTER`, `TAB`, `SHIFT_TAB`, `LEFT`, `RIGHT`, `SHIFT_UP`, `SHIFT_DOWN`, `CTRL_V`, and single-character strings. When a session is connected to `stdin`, terminal input is parsed and routed to the focused primitive.
1363
1363
 
1364
1364
  Pasted text is treated as text input for the focused primitive. Do not treat pasted strings or key sequences as terminal control instructions inside rendered content.
1365
1365
 
@@ -1399,7 +1399,7 @@ Main handlers:
1399
1399
 
1400
1400
  ### `Editor`
1401
1401
 
1402
- `Editor` is a multiline editing primitive.
1402
+ `Editor` is a multiline editing primitive. Use `height` for a bounded multiline viewport, `width` for a fixed frame width, and `fill` when the editor should behave like a textarea surface that consumes available width and height. Explicit dimensions win over `fill`; without those props, existing content-sized rendering stays intact.
1403
1403
 
1404
1404
  Supported behavior includes:
1405
1405
 
@@ -1440,13 +1440,13 @@ A quick second primary mouse press keeps the normal activation behavior and also
1440
1440
 
1441
1441
  ### `List`
1442
1442
 
1443
- `List` models active row movement, optional visible selection, viewport scroll, and activation. In virtualized mode the component receives the full `items` array and owns the viewport; wheel input moves the viewport, while keyboard list commands move the active row and emit `change`. A JSX `children` callback receives `(item, ctx)` with `ctx.active`, `ctx.selected`, `ctx.index`, `ctx.key`, and `ctx.viewportIndex`; it takes precedence over `renderItem`. Use `itemKey` when rows have stable identities across reorder or filtering.
1443
+ `List` models active row movement, optional visible selection, viewport scroll, and activation. In virtualized mode, pass the full `items` array; the list shows the visible viewport, wheel input scrolls it, and keyboard list commands move the active row and emit `change`. A JSX `children` callback receives `(item, ctx)` with `ctx.active`, `ctx.selected`, `ctx.index`, `ctx.key`, and `ctx.viewportIndex`; it takes precedence over `renderItem`. Use `itemKey` when rows have stable identities across reorder or filtering.
1444
1444
 
1445
1445
  Supported behavior includes:
1446
1446
 
1447
1447
  - `UP` and `DOWN` move the active row and emit `onchange`
1448
1448
  - `PageUp`, `PageDown`, `Home`, and `End` jump the active row and keep it visible
1449
- - `LEFT` and `RIGHT` have no default list command, so applications can bind them
1449
+ - `LEFT`, `RIGHT`, `SHIFT_UP`, and `SHIFT_DOWN` have no default list command, so applications can bind them
1450
1450
  - `ENTER` selects and activates the active row
1451
1451
  - mouse click selects and activates the target row
1452
1452
  - mouse hover reports row details when mouse input is connected
@@ -1465,7 +1465,7 @@ Main handlers:
1465
1465
  - `oncapturestart`
1466
1466
  - `oncaptureend`
1467
1467
 
1468
- `pointerCapture` lets a list keep drag interaction even when the pointer leaves the initial hitbox.
1468
+ `pointerCapture` lets a list keep drag interaction even when the pointer leaves the initial hitbox. If your app needs reordering, priority changes, or other modified-arrow behavior, bind `SHIFT_UP` or `SHIFT_DOWN` through `keymap.bindings` and handle the custom command in `keymap.onCommand`. The list will not mutate item order by default.
1469
1469
 
1470
1470
  ### `ScrollView`
1471
1471
 
@@ -5044,7 +5044,7 @@ Use `style`, `styles`, and `state` for normal component styling:
5044
5044
  - `styles` maps visual states to dot path recipes or inline style objects.
5045
5045
  - `state` declares app-owned states such as `loading`, `warning`, `success`, `muted`, `expanded`, or `dropTarget`.
5046
5046
 
5047
- Renderer-owned states come from the runtime when the renderer has the fact itself. Focus, hover, selection, current rows, and pointer capture fall in this group when the primitive supports them. App-owned state should describe product state that the app already knows. Do not mark a ready button as `loading` or an editable field as `readonly`; that teaches a lie to the UI and then the UI repeats it with confidence.
5047
+ Renderer-owned states come from the runtime when the renderer has the fact itself. Focus, hover, selection, current rows, and pointer capture fall in this group when the primitive supports them. App-owned state should describe product state that the app already knows. Do not use app-owned states for conditions that are not true. For example, do not mark a ready button as `loading` or an editable field as `readonly`.
5048
5048
 
5049
5049
  Precedence: the renderer starts with the automatic `<element>.base` recipe when the primitive has one, applies the instance `style`, then applies `styles` entries for current states in stable visual-state order. Geometry fields such as supported padding and border come from the automatic base recipe plus the instance `style` so layout, hitboxes, and cursors stay stable across focus, hover, and pressed state changes. State styles affect the rendered spans after layout; later current states override earlier visual fields when they set the same property. Inline style objects and dot path recipes use the same merge path after resolution.
5050
5050
 
@@ -5053,6 +5053,7 @@ Renderer notes:
5053
5053
  - Raw ANSI escape control is not the normal public styling API. Use semantic styles and let `ansiOutput()` serialize frames.
5054
5054
  - Layout margin is not supported; use padding, `gap`, `Fixed`, `Split`, or explicit text spacing.
5055
5055
  - The renderer owns terminal frame spans, clipping, focus, and selection; the app owns business state and labels.
5056
+ - When a styled public primitive resolves a final frame, its background spans cover that final frame, including cells introduced by layout composition, clipping, or fill. Child backgrounds cover their cells and parent backgrounds resume after child spans close in ANSI output.
5056
5057
 
5057
5058
  Run `bun examples/docs/style-system.tsx`, try `Tab`, `Enter`, and `W`, and quit with `Ctrl+C`; or open [`examples/docs/style-system.tsx`](../examples/docs/style-system.tsx). Run `bun examples/docs/theme-colors.tsx`, press `Tab`, and quit with `Ctrl+C`; or open [`examples/docs/theme-colors.tsx`](../examples/docs/theme-colors.tsx).
5058
5059
 
@@ -5233,7 +5234,9 @@ Props:
5233
5234
  - `focusable?: boolean`
5234
5235
  - `value?: string`
5235
5236
  - `placeholder?: string`
5237
+ - `width?: number`
5236
5238
  - `height?: number`
5239
+ - `fill?: boolean`
5237
5240
  - shared visual props: `style`, `styles`, `state`
5238
5241
  - `onchange?(event)`
5239
5242
  - `oninput?(event)`
@@ -5246,7 +5249,7 @@ Payloads:
5246
5249
  - `TerminalEditorSubmitEventPayload` - `{ type: "submit", id, value }`
5247
5250
  - `TerminalEditorCancelEventPayload` - `{ type: "cancel", id, value }`
5248
5251
 
5249
- Supports multiline editing, submit, cancel, cursor movement, paste, and deletion. Cursor columns use JavaScript string columns rather than a complete terminal-width model.
5252
+ Supports multiline editing, submit, cancel, cursor movement, paste, and deletion. Without `fill`, `width`, or `height`, `Editor` keeps content-sized rendering. `height` constrains the multiline viewport, `width` fixes the final frame width, and `fill` uses the available render context for both axes. Explicit `width` and `height` win over `fill`; for example, `fill` plus `height` fills only the width. Use `Editor` for textarea-like surfaces; `Input` remains the single-line primitive. Cursor columns use JavaScript string columns rather than a complete terminal-width model.
5250
5253
 
5251
5254
  ### `Button`
5252
5255
 
@@ -5303,7 +5306,7 @@ Payloads:
5303
5306
  - `TerminalListPointerEventPayload<T>` - `{ type: "hover" | "rowenter" | "rowleave", id, row, index, key?, value, x, y }`
5304
5307
  - `TerminalCaptureEventPayload` - `{ type: "capturestart" | "captureend", id, source, row, x, y }`
5305
5308
 
5306
- Supports internal active row movement, internal selection, activation, double press on the same row, context press for the target row, hover payloads, optional wrapping, optional virtualization, viewport scroll, and pointer capture. `Up`/`Down`, `PageUp`, `PageDown`, `Home`, and `End` are built-in list navigation keys; `Left` and `Right` stay available for application bindings. Keyboard navigation moves the active row and emits `change`; `Enter` selects and activates the active row. Pointer click selects and activates the clicked row. A quick second primary click on the same row emits `doublepress` without a second `press`. The default renderer displays the active row as `list.current`; when the selected row differs from the active row, it displays the selected row as `list.selected`. A JSX `children` callback is the recommended item renderer and wins over `renderItem` when both are present. `children` receives `ctx.active` and `ctx.selected` separately. `renderItem` remains as a compatibility alias with the legacy `(item, index)` signature. `wrap` wraps long default-rendered item text to the list width. Virtualized lists receive the full `items` array and calculate the visible viewport internally; use `itemKey` for stable event identity when items can reorder. Observe the resulting active row, selected row, and viewport through the list event payloads. Run [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx) with `bun examples/docs/virtualized-list-workbench.tsx` to inspect those values from events in one bounded split layout.
5309
+ Supports active row movement, selection, activation, double press on the same row, context press for the target row, hover payloads, optional wrapping, optional virtualization, viewport scroll, and pointer capture. `Up`/`Down`, `PageUp`, `PageDown`, `Home`, and `End` are built-in list navigation keys; `Left`, `Right`, `Shift+Up`, and `Shift+Down` stay available for application bindings. The library never reorders or mutates list items from modified arrow keys by default; priority changes and reordering belong in app-defined commands. Keyboard navigation moves the active row and emits `change`; `Enter` selects and activates the active row. Pointer click selects and activates the clicked row. A quick second primary click on the same row emits `doublepress` without a second `press`. The default renderer displays the active row as `list.current`; when the selected row differs from the active row, it displays the selected row as `list.selected`. A JSX `children` callback is the recommended item renderer and wins over `renderItem` when both are present. `children` receives `ctx.active` and `ctx.selected` separately. `renderItem` remains as a compatibility alias with the legacy `(item, index)` signature. `wrap` wraps long default-rendered item text to the list width. Virtualized lists receive the full `items` array and render the visible viewport; use `itemKey` for stable event identity when items can reorder. Observe the resulting active row, selected row, and viewport through the list event payloads. Run [`examples/docs/virtualized-list-workbench.tsx`](../examples/docs/virtualized-list-workbench.tsx) with `bun examples/docs/virtualized-list-workbench.tsx` to inspect those values from events in one bounded split layout.
5307
5310
 
5308
5311
  ### `ScrollView`
5309
5312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valyrianjs/terminal",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Terminal adapter for valyrian.js",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "dist",
25
25
  "docs",
26
+ "examples",
26
27
  "src",
27
28
  "!docs/local-demo.md",
28
29
  "!docs/plans",
@@ -32,7 +33,7 @@
32
33
  "llms-full.txt"
33
34
  ],
34
35
  "scripts": {
35
- "test": "bun test --max-concurrency=1",
36
+ "test": "bun run build && bun test --max-concurrency=1",
36
37
  "demo:dogfood": "bun examples/opencode-dogfood.tsx",
37
38
  "llms:full": "bun scripts/generate-llms-full.ts",
38
39
  "replay:ansi": "bun scripts/replay-ansi.ts",
package/src/ansi.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { terminalCellWidth, terminalGraphemes } from "./text.js";
1
2
  import { resolveTerminalStyle, resolveTerminalStyleToken } from "./theme.js";
2
3
  import type { CursorPosition, TerminalFrame, TerminalStyleSpan, TerminalTheme } from "./types.js";
3
4
 
@@ -100,19 +101,18 @@ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, the
100
101
  const rowSpans = lineSpans(spans, y);
101
102
  let activeSpanIndex = 0;
102
103
 
103
- for (let i = 0; i < line.length; i += 1) {
104
+ for (const grapheme of terminalGraphemes(line)) {
104
105
  while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
105
106
  output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
106
107
  activeSpanIndex += 1;
107
108
  }
108
109
 
109
- const char = line[i];
110
- if (char === "|") {
110
+ if (grapheme === "|") {
111
111
  continue;
112
112
  }
113
113
 
114
- output += char;
115
- visibleColumn += 1;
114
+ output += grapheme;
115
+ visibleColumn += terminalCellWidth(grapheme);
116
116
 
117
117
  let closedSpan = false;
118
118
  for (const span of rowSpans) {
@@ -129,7 +129,7 @@ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, the
129
129
  closedSpan = true;
130
130
  }
131
131
  }
132
- if (closedSpan && visibleColumn <= line.length && line[visibleColumn - 1] === " ") {
132
+ if (closedSpan && visibleColumn <= terminalCellWidth(line)) {
133
133
  for (const span of rowSpans) {
134
134
  if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
135
135
  output += spanAnsiOpen(span, theme);
@@ -174,22 +174,20 @@ function formatPlainLine(line: string, spans: TerminalStyleSpan[], y: number, th
174
174
  && !token?.plainSuffix
175
175
  && span.x1 === 1
176
176
  && y === firstFallbackFocusY
177
- && span.x2 <= line.length + 1
178
177
  && !line.trimStart().startsWith(">")
179
178
  && !line.includes("|")
180
179
  && !line.includes("[>");
181
180
  return {
182
181
  ...span,
183
- x2: fallbackFocusMarker ? Math.min(span.x2, line.trimEnd().length + 1) : span.x2,
182
+ x2: fallbackFocusMarker ? Math.min(span.x2, terminalCellWidth(line.trimEnd()) + 1) : span.x2,
184
183
  plainPrefix: token?.plainPrefix ?? span.style?.plainPrefix ?? (fallbackFocusMarker ? ">" : ""),
185
184
  plainSuffix: token?.plainSuffix ?? span.style?.plainSuffix ?? (fallbackFocusMarker ? "<" : "")
186
185
  };
187
186
  }).filter((span) => Boolean(span.plainPrefix || span.plainSuffix));
188
187
 
189
- for (let i = 0; i < line.length; i += 1) {
190
- const char = line[i];
191
- if (char === "|") {
192
- output += char;
188
+ for (const grapheme of terminalGraphemes(line)) {
189
+ if (grapheme === "|") {
190
+ output += grapheme;
193
191
  continue;
194
192
  }
195
193
 
@@ -199,8 +197,8 @@ function formatPlainLine(line: string, spans: TerminalStyleSpan[], y: number, th
199
197
  }
200
198
  }
201
199
 
202
- output += char;
203
- visibleColumn += 1;
200
+ output += grapheme;
201
+ visibleColumn += terminalCellWidth(grapheme);
204
202
 
205
203
  for (const span of rowSpans) {
206
204
  if (span.x2 === visibleColumn) {
package/src/events.ts CHANGED
@@ -146,6 +146,8 @@ export function parseTerminalKey(chunk: string | Uint8Array) {
146
146
  if (value === "\u001b[B") return "DOWN";
147
147
  if (value === "\u001b[C") return "RIGHT";
148
148
  if (value === "\u001b[D") return "LEFT";
149
+ if (value === "\u001b[1;2A") return "SHIFT_UP";
150
+ if (value === "\u001b[1;2B") return "SHIFT_DOWN";
149
151
  if (value === "\u001b[1;2C") return "SHIFT_RIGHT";
150
152
  if (value === "\u001b[1;2D") return "SHIFT_LEFT";
151
153
  if (value === "\u001b[1;3C" || value === "\u001bf") return "ALT_RIGHT";
@@ -0,0 +1,36 @@
1
+ import type { TerminalStyleSpan } from "./types.js";
2
+
3
+ const FULL_FRAME_SPAN = Symbol("valyrian.terminal.fullFrameSpan");
4
+ const FULL_ROW_SPAN = Symbol("valyrian.terminal.fullRowSpan");
5
+
6
+ type InternalFullFrameSpan = TerminalStyleSpan & { [FULL_FRAME_SPAN]?: true };
7
+ type InternalFullRowSpan = TerminalStyleSpan & { [FULL_ROW_SPAN]?: true };
8
+
9
+ export function markFullFrameSpan(span: TerminalStyleSpan): TerminalStyleSpan {
10
+ Object.defineProperty(span, FULL_FRAME_SPAN, { value: true, enumerable: false, configurable: true });
11
+ return span;
12
+ }
13
+
14
+ export function isFullFrameSpan(span: TerminalStyleSpan) {
15
+ return (span as InternalFullFrameSpan)[FULL_FRAME_SPAN] === true;
16
+ }
17
+
18
+ export function markFullRowSpan(span: TerminalStyleSpan): TerminalStyleSpan {
19
+ Object.defineProperty(span, FULL_ROW_SPAN, { value: true, enumerable: false, configurable: true });
20
+ return span;
21
+ }
22
+
23
+ export function isFullRowSpan(span: TerminalStyleSpan) {
24
+ return (span as InternalFullRowSpan)[FULL_ROW_SPAN] === true;
25
+ }
26
+
27
+ export function cloneStyleSpan(span: TerminalStyleSpan, patch: Partial<TerminalStyleSpan>): TerminalStyleSpan {
28
+ const next = { ...span, ...patch };
29
+ if (isFullFrameSpan(span)) {
30
+ markFullFrameSpan(next);
31
+ }
32
+ if (isFullRowSpan(span)) {
33
+ markFullRowSpan(next);
34
+ }
35
+ return next;
36
+ }
package/src/layout.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { cloneStyleSpan, isFullFrameSpan, isFullRowSpan } from "./frame-style.js";
2
+ import { dropTerminalCells, padEndTerminalCells, sliceTerminalCells, terminalCellWidth } from "./text.js";
1
3
  import type { CursorPosition, TerminalFrame, TerminalHitbox, TerminalStyleSpan } from "./types.js";
2
4
 
3
5
  function repeat(char: string, count: number) {
@@ -9,7 +11,7 @@ export function createFrame(lines: string[], hitboxes: TerminalHitbox[] = [], cu
9
11
  }
10
12
 
11
13
  export function getFrameWidth(frame: TerminalFrame) {
12
- return frame.lines.reduce((max, line) => Math.max(max, line.length), 0);
14
+ return frame.lines.reduce((max, line) => Math.max(max, terminalCellWidth(line)), 0);
13
15
  }
14
16
 
15
17
  export function getFrameHeight(frame: TerminalFrame) {
@@ -29,12 +31,12 @@ export function shiftFrame(frame: TerminalFrame, dx: number, dy: number): Termin
29
31
  contentY: typeof box.contentY === "number" ? box.contentY + dy : undefined
30
32
  })),
31
33
  cursor: frame.cursor ? { x: frame.cursor.x + dx, y: frame.cursor.y + dy } : null,
32
- spans: frame.spans.map((span) => ({ ...span, x1: span.x1 + dx, x2: span.x2 + dx, y: span.y + dy }))
34
+ spans: frame.spans.map((span) => cloneStyleSpan(span, { x1: span.x1 + dx, x2: span.x2 + dx, y: span.y + dy }))
33
35
  };
34
36
  }
35
37
 
36
38
  function normalizeLines(frame: TerminalFrame, width: number, height: number) {
37
- const lines = frame.lines.map((line) => line.padEnd(width, " "));
39
+ const lines = frame.lines.map((line) => padEndTerminalCells(line, width));
38
40
  while (lines.length < height) {
39
41
  lines.push(repeat(" ", width));
40
42
  }
@@ -57,15 +59,15 @@ export function mergeVertical(frames: TerminalFrame[], options: { gap?: number }
57
59
 
58
60
  for (let i = 0; i < filtered.length; i += 1) {
59
61
  const frame = filtered[i];
60
- const normalized = normalizeLines(frame, width, getFrameHeight(frame));
61
- lines.push(...normalized);
62
- const shifted = shiftFrame(frame, 0, rowOffset);
62
+ const normalizedFrame = fitFrame(frame, width, getFrameHeight(frame), { expandFullFrameSpans: true });
63
+ lines.push(...normalizedFrame.lines);
64
+ const shifted = shiftFrame(normalizedFrame, 0, rowOffset);
63
65
  hitboxes.push(...shifted.hitboxes);
64
66
  spans.push(...shifted.spans);
65
67
  if (!cursor && frame.cursor) {
66
68
  cursor = { x: frame.cursor.x, y: frame.cursor.y + rowOffset };
67
69
  }
68
- rowOffset += normalized.length;
70
+ rowOffset += normalizedFrame.lines.length;
69
71
  if (i < filtered.length - 1 && gap > 0) {
70
72
  for (let j = 0; j < gap; j += 1) {
71
73
  lines.push(repeat(" ", width));
@@ -87,9 +89,8 @@ export function mergeHorizontal(frames: TerminalFrame[], options: { gap?: number
87
89
  const widths = filtered.map(getFrameWidth);
88
90
  const height = filtered.reduce((max, frame) => Math.max(max, getFrameHeight(frame)), 0);
89
91
  const normalizedFrames = filtered.map((frame, index) => ({
90
- frame,
91
- width: widths[index],
92
- lines: normalizeLines(frame, widths[index], height)
92
+ frame: fitFrame(frame, widths[index], height, { expandFullFrameSpans: true }),
93
+ width: widths[index]
93
94
  }));
94
95
  const gapText = repeat(" ", gap);
95
96
  const lines = new Array<string>(height).fill("");
@@ -101,7 +102,7 @@ export function mergeHorizontal(frames: TerminalFrame[], options: { gap?: number
101
102
  for (let index = 0; index < normalizedFrames.length; index += 1) {
102
103
  const part = normalizedFrames[index];
103
104
  for (let row = 0; row < height; row += 1) {
104
- lines[row] += part.lines[row];
105
+ lines[row] += part.frame.lines[row];
105
106
  if (index < normalizedFrames.length - 1) {
106
107
  lines[row] += gapText;
107
108
  }
@@ -125,20 +126,20 @@ export function padFrame(frame: TerminalFrame, padding: number) {
125
126
  }
126
127
 
127
128
  const width = getFrameWidth(frame);
128
- const middle = frame.lines.map((line) => `${repeat(" ", amount)}${line.padEnd(width, " ")}${repeat(" ", amount)}`);
129
+ const middle = frame.lines.map((line) => `${repeat(" ", amount)}${padEndTerminalCells(line, width)}${repeat(" ", amount)}`);
129
130
  const blank = repeat(" ", width + amount * 2);
130
131
  const lines = [...new Array<string>(amount).fill(blank), ...middle, ...new Array<string>(amount).fill(blank)];
131
132
  const shifted = shiftFrame(frame, amount, amount);
132
133
  return createFrame(lines, shifted.hitboxes, frame.cursor ? { x: frame.cursor.x + amount, y: frame.cursor.y + amount } : null, shifted.spans);
133
134
  }
134
135
 
135
- export function fitFrame(frame: TerminalFrame, width?: number, height?: number) {
136
+ export function fitFrame(frame: TerminalFrame, width?: number, height?: number, options: { expandFullFrameSpans?: boolean } = {}) {
136
137
  const nextWidth = Math.max(getFrameWidth(frame), Number(width || 0));
137
138
  const nextHeight = Math.max(getFrameHeight(frame), Number(height || 0));
138
139
  if (nextWidth === getFrameWidth(frame) && nextHeight === getFrameHeight(frame)) {
139
140
  return frame;
140
141
  }
141
- return createFrame(normalizeLines(frame, nextWidth, nextHeight), frame.hitboxes, frame.cursor, frame.spans);
142
+ return constrainFrame(frame, { width: nextWidth, height: nextHeight, expandFullFrameSpans: options.expandFullFrameSpans });
142
143
  }
143
144
 
144
145
  function constraintSize(value: number | undefined, fallback: number) {
@@ -160,13 +161,13 @@ function clamp(value: number, min: number, max: number) {
160
161
  return Math.min(max, Math.max(min, value));
161
162
  }
162
163
 
163
- export function constrainFrame(frame: TerminalFrame, options: { width?: number; height?: number } = {}): TerminalFrame {
164
+ export function constrainFrame(frame: TerminalFrame, options: { width?: number; height?: number; expandFullFrameSpans?: boolean; expandFullRowSpans?: boolean } = {}): TerminalFrame {
164
165
  const width = constraintSize(options.width, getFrameWidth(frame));
165
166
  const height = constraintSize(options.height, getFrameHeight(frame));
166
167
  const lines: string[] = [];
167
168
 
168
169
  for (let row = 0; row < height; row += 1) {
169
- lines.push((frame.lines[row] || "").slice(0, width).padEnd(width, " "));
170
+ lines.push(padEndTerminalCells(sliceTerminalCells(frame.lines[row] || "", width), width));
170
171
  }
171
172
 
172
173
  const hitboxes = frame.hitboxes
@@ -184,8 +185,26 @@ export function constrainFrame(frame: TerminalFrame, options: { width?: number;
184
185
  ? { x: frame.cursor.x, y: frame.cursor.y }
185
186
  : null;
186
187
 
188
+ const originalWidth = getFrameWidth(frame);
189
+ const originalHeight = getFrameHeight(frame);
187
190
  const spanRightEdge = width + 1;
188
191
  const spans = frame.spans.flatMap((span) => {
192
+ const coversCurrentFrame = options.expandFullFrameSpans === true && isFullFrameSpan(span) && span.x1 === 1 && span.x2 === originalWidth + 1;
193
+ if (coversCurrentFrame) {
194
+ if (width <= 0 || span.y < 1 || span.y > Math.min(originalHeight, height)) {
195
+ return [];
196
+ }
197
+ return [cloneStyleSpan(span, { x1: 1, x2: spanRightEdge })];
198
+ }
199
+
200
+ const coversCurrentRow = (options.expandFullFrameSpans === true || options.expandFullRowSpans === true) && isFullRowSpan(span) && span.x1 === 1 && span.x2 === originalWidth + 1;
201
+ if (coversCurrentRow) {
202
+ if (width <= 0 || span.y < 1 || span.y > Math.min(originalHeight, height)) {
203
+ return [];
204
+ }
205
+ return [cloneStyleSpan(span, { x1: 1, x2: spanRightEdge })];
206
+ }
207
+
189
208
  if (width <= 0 || span.y < 1 || span.y > height || span.x2 <= 1 || span.x1 >= spanRightEdge) {
190
209
  return [];
191
210
  }
@@ -196,9 +215,22 @@ export function constrainFrame(frame: TerminalFrame, options: { width?: number;
196
215
  return [];
197
216
  }
198
217
 
199
- return [{ ...span, x1: clippedX1, x2: clippedX2 }];
218
+ return [cloneStyleSpan(span, { x1: clippedX1, x2: clippedX2 })];
200
219
  });
201
220
 
221
+ const fullFrameSpans = options.expandFullFrameSpans === true
222
+ ? frame.spans.filter((span) => isFullFrameSpan(span) && span.x1 === 1 && span.x2 === originalWidth + 1)
223
+ : [];
224
+ const firstFullFrameRow = fullFrameSpans.reduce<number | null>((first, span) => first === null ? span.y : Math.min(first, span.y), null);
225
+ if (firstFullFrameRow !== null) {
226
+ const templates = fullFrameSpans.filter((span) => span.y === firstFullFrameRow);
227
+ for (let y = originalHeight + 1; y <= height; y += 1) {
228
+ for (const template of templates) {
229
+ spans.push(cloneStyleSpan(template, { x1: 1, x2: spanRightEdge, y }));
230
+ }
231
+ }
232
+ }
233
+
202
234
  return createFrame(lines, hitboxes, cursor, spans);
203
235
  }
204
236
 
@@ -234,16 +266,20 @@ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, option
234
266
 
235
267
  const baseLine = lines[baseRow];
236
268
  const start = x - 1;
237
- if (start >= baseLine.length) {
269
+ if (start >= terminalCellWidth(baseLine)) {
238
270
  continue;
239
271
  }
240
272
 
241
- const visibleWidth = Math.min(constrainedOverlay.lines[row].length, baseLine.length - start);
273
+ const visibleWidth = Math.min(terminalCellWidth(constrainedOverlay.lines[row]), terminalCellWidth(baseLine) - start);
242
274
  if (visibleWidth <= 0) {
243
275
  continue;
244
276
  }
245
277
 
246
- lines[baseRow] = `${baseLine.slice(0, start)}${constrainedOverlay.lines[row].slice(0, visibleWidth)}${baseLine.slice(start + visibleWidth)}`;
278
+ const prefix = sliceTerminalCells(baseLine, start);
279
+ const suffix = dropTerminalCells(baseLine, start + visibleWidth);
280
+ const insert = sliceTerminalCells(constrainedOverlay.lines[row], visibleWidth);
281
+ const removedWidth = terminalCellWidth(baseLine) - terminalCellWidth(prefix) - terminalCellWidth(suffix);
282
+ lines[baseRow] = `${prefix}${padEndTerminalCells(insert, removedWidth)}${suffix}`;
247
283
  }
248
284
 
249
285
  const shiftedOverlay = shiftFrame(constrainedOverlay, x - 1, y - 1);
@@ -271,10 +307,7 @@ export function cropFrame(frame: TerminalFrame, offset: number, height: number)
271
307
  }));
272
308
  const spans = frame.spans
273
309
  .filter((span) => span.y > start && span.y <= start + size)
274
- .map((span) => ({
275
- ...span,
276
- y: span.y - start
277
- }));
310
+ .map((span) => cloneStyleSpan(span, { y: span.y - start }));
278
311
  const cursor = frame.cursor && frame.cursor.y > start && frame.cursor.y <= start + size
279
312
  ? { x: frame.cursor.x, y: frame.cursor.y - start }
280
313
  : null;
package/src/mouse.ts CHANGED
@@ -79,6 +79,15 @@ export function cursorFromHitbox(hitbox: TerminalHitbox, x: number) {
79
79
  if (typeof hitbox.textStartX !== "number") {
80
80
  return 0;
81
81
  }
82
+
82
83
  const textLength = Number(hitbox.textLength || 0);
83
- return Math.max(0, Math.min(textLength, x - hitbox.textStartX));
84
+ const cellOffset = Math.max(0, x - hitbox.textStartX);
85
+ if (Array.isArray(hitbox.textCellToStringIndex) && hitbox.textCellToStringIndex.length > 0) {
86
+ const index = hitbox.textCellToStringIndex[Math.min(cellOffset, hitbox.textCellToStringIndex.length - 1)];
87
+ if (typeof index === "number" && Number.isFinite(index)) {
88
+ return Math.max(0, Math.min(textLength, index));
89
+ }
90
+ }
91
+
92
+ return Math.max(0, Math.min(textLength, cellOffset));
84
93
  }