@valyrianjs/terminal 0.2.0 → 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 (102) hide show
  1. package/dist/ansi.d.ts +2 -0
  2. package/dist/ansi.d.ts.map +1 -1
  3. package/dist/ansi.js +23 -13
  4. package/dist/ansi.js.map +1 -1
  5. package/dist/events.d.ts.map +1 -1
  6. package/dist/events.js +10 -2
  7. package/dist/events.js.map +1 -1
  8. package/dist/frame-style.d.ts +7 -0
  9. package/dist/frame-style.d.ts.map +1 -0
  10. package/dist/frame-style.js +27 -0
  11. package/dist/frame-style.js.map +1 -0
  12. package/dist/keymap.d.ts.map +1 -1
  13. package/dist/keymap.js +4 -2
  14. package/dist/keymap.js.map +1 -1
  15. package/dist/layout.d.ts +5 -1
  16. package/dist/layout.d.ts.map +1 -1
  17. package/dist/layout.js +55 -24
  18. package/dist/layout.js.map +1 -1
  19. package/dist/mouse.d.ts +6 -0
  20. package/dist/mouse.d.ts.map +1 -1
  21. package/dist/mouse.js +38 -17
  22. package/dist/mouse.js.map +1 -1
  23. package/dist/primitives.d.ts.map +1 -1
  24. package/dist/primitives.js +8 -1
  25. package/dist/primitives.js.map +1 -1
  26. package/dist/render.d.ts.map +1 -1
  27. package/dist/render.js +266 -70
  28. package/dist/render.js.map +1 -1
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +13 -5
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/session.d.ts.map +1 -1
  33. package/dist/session.js +325 -83
  34. package/dist/session.js.map +1 -1
  35. package/dist/text.d.ts +7 -0
  36. package/dist/text.d.ts.map +1 -1
  37. package/dist/text.js +114 -0
  38. package/dist/text.js.map +1 -1
  39. package/dist/theme.d.ts.map +1 -1
  40. package/dist/theme.js +3 -0
  41. package/dist/theme.js.map +1 -1
  42. package/dist/tree.d.ts.map +1 -1
  43. package/dist/tree.js +18 -4
  44. package/dist/tree.js.map +1 -1
  45. package/dist/types.d.ts +41 -4
  46. package/dist/types.d.ts.map +1 -1
  47. package/docs/api-reference.md +18 -8
  48. package/docs/cookbook.md +1 -1
  49. package/docs/interaction-model.md +10 -8
  50. package/docs/primitive-gallery.md +9 -5
  51. package/examples/basic.tsx +22 -0
  52. package/examples/cli.tsx +55 -0
  53. package/examples/demo.tsx +98 -0
  54. package/examples/docs/background-fill.tsx +107 -0
  55. package/examples/docs/component-composition.tsx +140 -0
  56. package/examples/docs/cursor.tsx +121 -0
  57. package/examples/docs/employees-list.tsx +138 -0
  58. package/examples/docs/hello.tsx +98 -0
  59. package/examples/docs/interactive-note.tsx +111 -0
  60. package/examples/docs/module-api-dashboard.tsx +307 -0
  61. package/examples/docs/module-flux-store.tsx +181 -0
  62. package/examples/docs/module-form-workflow.tsx +339 -0
  63. package/examples/docs/module-forms.tsx +218 -0
  64. package/examples/docs/module-money.tsx +175 -0
  65. package/examples/docs/module-native-store.tsx +188 -0
  66. package/examples/docs/module-pulses.tsx +142 -0
  67. package/examples/docs/module-query.tsx +209 -0
  68. package/examples/docs/module-request.tsx +194 -0
  69. package/examples/docs/module-state-workbench.tsx +283 -0
  70. package/examples/docs/module-tasks.tsx +223 -0
  71. package/examples/docs/module-translate.tsx +194 -0
  72. package/examples/docs/module-utils.tsx +168 -0
  73. package/examples/docs/module-valyrian-core.tsx +159 -0
  74. package/examples/docs/pizza-builder.tsx +463 -0
  75. package/examples/docs/primitive-activity-console.tsx +113 -0
  76. package/examples/docs/primitive-command-panel.tsx +186 -0
  77. package/examples/docs/primitive-data-explorer.tsx +155 -0
  78. package/examples/docs/primitive-input-workbench.tsx +128 -0
  79. package/examples/docs/primitive-layout-shell.tsx +115 -0
  80. package/examples/docs/responsive-split.tsx +186 -0
  81. package/examples/docs/style-system.tsx +209 -0
  82. package/examples/docs/theme-colors.tsx +225 -0
  83. package/examples/docs/virtualized-list-workbench.tsx +232 -0
  84. package/examples/opencode-dogfood-app.tsx +215 -0
  85. package/examples/opencode-dogfood-lifecycle.tsx +194 -0
  86. package/examples/opencode-dogfood.tsx +11 -0
  87. package/llms-full.txt +38 -22
  88. package/package.json +3 -2
  89. package/src/ansi.ts +23 -13
  90. package/src/events.ts +6 -2
  91. package/src/frame-style.ts +36 -0
  92. package/src/keymap.ts +4 -2
  93. package/src/layout.ts +59 -25
  94. package/src/mouse.ts +41 -16
  95. package/src/primitives.ts +8 -1
  96. package/src/render.ts +286 -71
  97. package/src/runtime.ts +13 -5
  98. package/src/session.ts +343 -79
  99. package/src/text.ts +148 -0
  100. package/src/theme.ts +3 -0
  101. package/src/tree.ts +19 -4
  102. package/src/types.ts +48 -3
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.
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,16 +1196,20 @@ 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`, `renderItem`, `virtualized`, `onchange`, `onpress`, `ondoublepress`, `oncontextpress`.
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
 
1203
1203
  ```tsx
1204
- <List id="menu" items={["Inbox", "Done"]} onchange={(event) => { selected = event.value; }} />
1204
+ <List id="menu" items={["Inbox", "Done"]} itemKey={(item) => item}>
1205
+ {(item, ctx) => `${ctx.active ? "› " : " "}${item}`}
1206
+ </List>
1205
1207
  ```
1206
1208
 
1207
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`.
1208
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`. `Shift+Up` and `Shift+Down` are shown as app-defined commands, not built-in reorder behavior.
1212
+
1209
1213
  ### `Table`
1210
1214
 
1211
1215
  Use `Table` for compact data detail and aligned text pairs.
@@ -1300,7 +1304,7 @@ Use `Overlay` for command panels, dialogs, palettes, and focused chooser surface
1300
1304
 
1301
1305
  **What it is:** A positioned layer over the current frame.
1302
1306
  **Use it for:** command panels, dialogs, palettes, and focused chooser surfaces.
1303
- **Core props:** `id`, `margin`, `trapFocus`, `style`. `margin` is required and accepts a number, a percentage string, or `{ x, y }` with number or percentage-string values. When `Overlay` is the root passed to `renderTerminal()`, pass exact `{ cols, rows }` as the second argument so margin can resolve against terminal dimensions.
1307
+ **Core props:** `id`, `margin`, `trapFocus`, `style`. `margin` is required and accepts a number, a percentage string, or `{ x, y }` with number or percentage-string values. When `Overlay` is the root passed to `renderTerminal()`, pass exact `{ cols, rows }` as the second argument so margin can resolve against terminal dimensions. Later sibling overlays paint and receive pointer/focus routing above earlier overlays. Overlay background styles cover their full assigned surface, not just text cells.
1304
1308
 
1305
1309
  **Minimal example:**
1306
1310
 
@@ -1355,7 +1359,7 @@ This guide explains how interactive primitives receive input and report events.
1355
1359
 
1356
1360
  ## Input events
1357
1361
 
1358
- 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.
1359
1363
 
1360
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.
1361
1365
 
@@ -1395,7 +1399,7 @@ Main handlers:
1395
1399
 
1396
1400
  ### `Editor`
1397
1401
 
1398
- `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.
1399
1403
 
1400
1404
  Supported behavior includes:
1401
1405
 
@@ -1436,15 +1440,17 @@ A quick second primary mouse press keeps the normal activation behavior and also
1436
1440
 
1437
1441
  ### `List`
1438
1442
 
1439
- `List` models row selection and activation.
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.
1440
1444
 
1441
1445
  Supported behavior includes:
1442
1446
 
1443
- - `UP` and `LEFT` move selection up
1444
- - `DOWN` and `RIGHT` move selection down
1445
- - `ENTER` activates the selected row
1447
+ - `UP` and `DOWN` move the active row and emit `onchange`
1448
+ - `PageUp`, `PageDown`, `Home`, and `End` jump the active row and keep it visible
1449
+ - `LEFT`, `RIGHT`, `SHIFT_UP`, and `SHIFT_DOWN` have no default list command, so applications can bind them
1450
+ - `ENTER` selects and activates the active row
1451
+ - mouse click selects and activates the target row
1446
1452
  - mouse hover reports row details when mouse input is connected
1447
- - quick primary mouse presses on the same row emit `ondoublepress`
1453
+ - quick primary mouse clicks on the same row emit `ondoublepress` without a second `onpress`
1448
1454
  - secondary mouse presses emit `oncontextpress` for the target row
1449
1455
 
1450
1456
  Main handlers:
@@ -1459,7 +1465,7 @@ Main handlers:
1459
1465
  - `oncapturestart`
1460
1466
  - `oncaptureend`
1461
1467
 
1462
- `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.
1463
1469
 
1464
1470
  ### `ScrollView`
1465
1471
 
@@ -5038,7 +5044,7 @@ Use `style`, `styles`, and `state` for normal component styling:
5038
5044
  - `styles` maps visual states to dot path recipes or inline style objects.
5039
5045
  - `state` declares app-owned states such as `loading`, `warning`, `success`, `muted`, `expanded`, or `dropTarget`.
5040
5046
 
5041
- 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`.
5042
5048
 
5043
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.
5044
5050
 
@@ -5047,6 +5053,7 @@ Renderer notes:
5047
5053
  - Raw ANSI escape control is not the normal public styling API. Use semantic styles and let `ansiOutput()` serialize frames.
5048
5054
  - Layout margin is not supported; use padding, `gap`, `Fixed`, `Split`, or explicit text spacing.
5049
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.
5050
5057
 
5051
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).
5052
5059
 
@@ -5172,7 +5179,7 @@ Props:
5172
5179
 
5173
5180
  Draws a clipped region over the current frame. `margin` sets the distance from the overlay to the frame edges; numbers use cells and percentage strings such as `"10%"` resolve per axis. Use `{ x, y }` when the two axes need different margins. `trapFocus` keeps traversal inside the overlay while it is active.
5174
5181
 
5175
- Overlay calculates its size from the available frame, stays centered by its margins, and keeps app routing and pane movement in your app layer.
5182
+ Overlay calculates its size from the available frame, stays centered by its margins, and keeps app routing and pane movement in your app layer. Direct sibling overlays use source order: later overlays paint above earlier overlays, receive pointer hits first, and lead trapped focus traversal. Overlay styles paint the full assigned surface, including empty cells behind child controls.
5176
5183
 
5177
5184
  ### `FocusScope`
5178
5185
 
@@ -5227,7 +5234,9 @@ Props:
5227
5234
  - `focusable?: boolean`
5228
5235
  - `value?: string`
5229
5236
  - `placeholder?: string`
5237
+ - `width?: number`
5230
5238
  - `height?: number`
5239
+ - `fill?: boolean`
5231
5240
  - shared visual props: `style`, `styles`, `state`
5232
5241
  - `onchange?(event)`
5233
5242
  - `oninput?(event)`
@@ -5240,7 +5249,7 @@ Payloads:
5240
5249
  - `TerminalEditorSubmitEventPayload` - `{ type: "submit", id, value }`
5241
5250
  - `TerminalEditorCancelEventPayload` - `{ type: "cancel", id, value }`
5242
5251
 
5243
- 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.
5244
5253
 
5245
5254
  ### `Button`
5246
5255
 
@@ -5269,11 +5278,17 @@ Props:
5269
5278
  - `pointerCapture?: boolean`
5270
5279
  - `items?: T[]`
5271
5280
  - `virtualized?: boolean`
5281
+ - `height?: number`
5272
5282
  - `itemHeight?: 1`
5273
5283
  - `overscan?: number`
5284
+ - `wrap?: boolean`
5285
+ - `itemKey?(item, index): string | number`
5286
+ - `showActive?: boolean`
5274
5287
  - shared visual props: `style`, `styles`, `state`
5275
- - `renderItem?(item, index): string`
5288
+ - `children?(item, ctx): any`
5289
+ - `renderItem?(item, index): any`
5276
5290
  - `onchange?(event)`
5291
+ - `onviewportchange?(event)`
5277
5292
  - `onpress?(event)`
5278
5293
  - `ondoublepress?(event)`
5279
5294
  - `oncontextpress?(event)`
@@ -5285,12 +5300,13 @@ Props:
5285
5300
 
5286
5301
  Payloads:
5287
5302
 
5288
- - `TerminalListChangeEventPayload<T>` - `{ type: "change", id, index, value }`
5289
- - `TerminalListPressEventPayload<T>` - `{ type: "press" | "doublepress" | "contextpress", id, index, value }`
5290
- - `TerminalListPointerEventPayload<T>` - `{ type: "hover" | "rowenter" | "rowleave", id, row, index, value, x, y }`
5303
+ - `TerminalListChangeEventPayload<T>` - `{ type: "change", id, index, key?, value, activeIndex, selectedIndex, viewportOffset, viewportRows }`
5304
+ - `TerminalListViewportChangeEventPayload` - `{ type: "viewportchange", id, offset, rows, activeIndex, selectedIndex, viewportOffset, viewportRows }`
5305
+ - `TerminalListPressEventPayload<T>` - `{ type: "press" | "doublepress" | "contextpress", id, index, key?, value, activeIndex, selectedIndex, viewportOffset, viewportRows }`
5306
+ - `TerminalListPointerEventPayload<T>` - `{ type: "hover" | "rowenter" | "rowleave", id, row, index, key?, value, x, y }`
5291
5307
  - `TerminalCaptureEventPayload` - `{ type: "capturestart" | "captureend", id, source, row, x, y }`
5292
5308
 
5293
- Supports selection, activation, double press on the same row, context press for the target row, hover payloads, optional virtualization, and pointer capture.
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.
5294
5310
 
5295
5311
  ### `ScrollView`
5296
5312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valyrianjs/terminal",
3
- "version": "0.2.0",
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
 
@@ -5,6 +6,8 @@ export const ANSI_ENTER_ALTERNATE_SCREEN = "\u001b[?1049h";
5
6
  export const ANSI_EXIT_ALTERNATE_SCREEN = "\u001b[?1049l";
6
7
  export const ANSI_HIDE_CURSOR = "\u001b[?25l";
7
8
  export const ANSI_SHOW_CURSOR = "\u001b[?25h";
9
+ export const ANSI_ENABLE_MOUSE_REPORTING = "\u001b[?1000h\u001b[?1002h\u001b[?1006h";
10
+ export const ANSI_DISABLE_MOUSE_REPORTING = "\u001b[?1002l\u001b[?1000l\u001b[?1006l";
8
11
 
9
12
  type AnsiFrameOptions = {
10
13
  showCursor?: boolean;
@@ -98,30 +101,39 @@ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, the
98
101
  const rowSpans = lineSpans(spans, y);
99
102
  let activeSpanIndex = 0;
100
103
 
101
- for (let i = 0; i < line.length; i += 1) {
104
+ for (const grapheme of terminalGraphemes(line)) {
102
105
  while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
103
106
  output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
104
107
  activeSpanIndex += 1;
105
108
  }
106
109
 
107
- const char = line[i];
108
- if (char === "|") {
110
+ if (grapheme === "|") {
109
111
  continue;
110
112
  }
111
113
 
112
- output += char;
113
- visibleColumn += 1;
114
+ output += grapheme;
115
+ visibleColumn += terminalCellWidth(grapheme);
114
116
 
117
+ let closedSpan = false;
115
118
  for (const span of rowSpans) {
116
119
  if (span.x2 === visibleColumn) {
117
120
  if (span.kind !== "focus") {
118
121
  output += spanAnsiClose(span, theme);
122
+ closedSpan = true;
119
123
  }
120
124
  }
121
125
  }
122
126
  for (const span of rowSpans) {
123
127
  if (span.x2 === visibleColumn && span.kind === "focus") {
124
128
  output += spanAnsiClose(span, theme);
129
+ closedSpan = true;
130
+ }
131
+ }
132
+ if (closedSpan && visibleColumn <= terminalCellWidth(line)) {
133
+ for (const span of rowSpans) {
134
+ if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
135
+ output += spanAnsiOpen(span, theme);
136
+ }
125
137
  }
126
138
  }
127
139
  }
@@ -162,22 +174,20 @@ function formatPlainLine(line: string, spans: TerminalStyleSpan[], y: number, th
162
174
  && !token?.plainSuffix
163
175
  && span.x1 === 1
164
176
  && y === firstFallbackFocusY
165
- && span.x2 <= line.length + 1
166
177
  && !line.trimStart().startsWith(">")
167
178
  && !line.includes("|")
168
179
  && !line.includes("[>");
169
180
  return {
170
181
  ...span,
171
- 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,
172
183
  plainPrefix: token?.plainPrefix ?? span.style?.plainPrefix ?? (fallbackFocusMarker ? ">" : ""),
173
184
  plainSuffix: token?.plainSuffix ?? span.style?.plainSuffix ?? (fallbackFocusMarker ? "<" : "")
174
185
  };
175
186
  }).filter((span) => Boolean(span.plainPrefix || span.plainSuffix));
176
187
 
177
- for (let i = 0; i < line.length; i += 1) {
178
- const char = line[i];
179
- if (char === "|") {
180
- output += char;
188
+ for (const grapheme of terminalGraphemes(line)) {
189
+ if (grapheme === "|") {
190
+ output += grapheme;
181
191
  continue;
182
192
  }
183
193
 
@@ -187,8 +197,8 @@ function formatPlainLine(line: string, spans: TerminalStyleSpan[], y: number, th
187
197
  }
188
198
  }
189
199
 
190
- output += char;
191
- visibleColumn += 1;
200
+ output += grapheme;
201
+ visibleColumn += terminalCellWidth(grapheme);
192
202
 
193
203
  for (const span of rowSpans) {
194
204
  if (span.x2 === visibleColumn) {
package/src/events.ts CHANGED
@@ -146,14 +146,18 @@ 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";
152
154
  if (value === "\u001b[1;3D" || value === "\u001bb") return "ALT_LEFT";
153
155
  if (value === "\u001b[3~") return "DELETE";
156
+ if (value === "\u001b[5~") return "PAGEUP";
157
+ if (value === "\u001b[6~") return "PAGEDOWN";
154
158
  if (value === "\u0008" || value === "\u007f") return "BACKSPACE";
155
- if (value === "\u001b[H") return "HOME";
156
- if (value === "\u001b[F") return "END";
159
+ if (value === "\u001b[H" || value === "\u001b[1~") return "HOME";
160
+ if (value === "\u001b[F" || value === "\u001b[4~") return "END";
157
161
  if (value === "\u0001") return "CTRL_A";
158
162
  if (value === "\u0003") return "CTRL_C";
159
163
  if (value === "\u000b") return "CTRL_K";
@@ -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/keymap.ts CHANGED
@@ -32,9 +32,11 @@ export const defaultTerminalKeyBindings: TerminalKeyBinding[] = [
32
32
  { key: "ENTER", command: { id: "button.press" }, scope: "button", when: { focusedTag: "terminal-button" } },
33
33
  { key: "SPACE", command: { id: "button.press" }, scope: "button", when: { focusedTag: "terminal-button" } },
34
34
  { key: "UP", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
35
- { key: "LEFT", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
36
35
  { key: "DOWN", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
37
- { key: "RIGHT", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
36
+ { key: "PAGEUP", command: { id: "list.pageUp" }, scope: "list", when: { focusedTag: "terminal-list" } },
37
+ { key: "PAGEDOWN", command: { id: "list.pageDown" }, scope: "list", when: { focusedTag: "terminal-list" } },
38
+ { key: "HOME", command: { id: "list.home" }, scope: "list", when: { focusedTag: "terminal-list" } },
39
+ { key: "END", command: { id: "list.end" }, scope: "list", when: { focusedTag: "terminal-list" } },
38
40
  { key: "ENTER", command: { id: "list.press" }, scope: "list", when: { focusedTag: "terminal-list" } },
39
41
  { key: "UP", command: { id: "scroll.up" }, scope: "scroll", when: { focusedTag: "terminal-scroll" } },
40
42
  { key: "DOWN", command: { id: "scroll.down" }, scope: "scroll", when: { focusedTag: "terminal-scroll" } }
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) {
@@ -25,15 +27,16 @@ export function shiftFrame(frame: TerminalFrame, dx: number, dy: number): Termin
25
27
  x2: box.x2 + dx,
26
28
  y1: box.y1 + dy,
27
29
  y2: box.y2 + dy,
28
- textStartX: typeof box.textStartX === "number" ? box.textStartX + dx : undefined
30
+ textStartX: typeof box.textStartX === "number" ? box.textStartX + dx : undefined,
31
+ contentY: typeof box.contentY === "number" ? box.contentY + dy : undefined
29
32
  })),
30
33
  cursor: frame.cursor ? { x: frame.cursor.x + dx, y: frame.cursor.y + dy } : null,
31
- 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 }))
32
35
  };
33
36
  }
34
37
 
35
38
  function normalizeLines(frame: TerminalFrame, width: number, height: number) {
36
- const lines = frame.lines.map((line) => line.padEnd(width, " "));
39
+ const lines = frame.lines.map((line) => padEndTerminalCells(line, width));
37
40
  while (lines.length < height) {
38
41
  lines.push(repeat(" ", width));
39
42
  }
@@ -56,15 +59,15 @@ export function mergeVertical(frames: TerminalFrame[], options: { gap?: number }
56
59
 
57
60
  for (let i = 0; i < filtered.length; i += 1) {
58
61
  const frame = filtered[i];
59
- const normalized = normalizeLines(frame, width, getFrameHeight(frame));
60
- lines.push(...normalized);
61
- 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);
62
65
  hitboxes.push(...shifted.hitboxes);
63
66
  spans.push(...shifted.spans);
64
67
  if (!cursor && frame.cursor) {
65
68
  cursor = { x: frame.cursor.x, y: frame.cursor.y + rowOffset };
66
69
  }
67
- rowOffset += normalized.length;
70
+ rowOffset += normalizedFrame.lines.length;
68
71
  if (i < filtered.length - 1 && gap > 0) {
69
72
  for (let j = 0; j < gap; j += 1) {
70
73
  lines.push(repeat(" ", width));
@@ -86,9 +89,8 @@ export function mergeHorizontal(frames: TerminalFrame[], options: { gap?: number
86
89
  const widths = filtered.map(getFrameWidth);
87
90
  const height = filtered.reduce((max, frame) => Math.max(max, getFrameHeight(frame)), 0);
88
91
  const normalizedFrames = filtered.map((frame, index) => ({
89
- frame,
90
- width: widths[index],
91
- lines: normalizeLines(frame, widths[index], height)
92
+ frame: fitFrame(frame, widths[index], height, { expandFullFrameSpans: true }),
93
+ width: widths[index]
92
94
  }));
93
95
  const gapText = repeat(" ", gap);
94
96
  const lines = new Array<string>(height).fill("");
@@ -100,7 +102,7 @@ export function mergeHorizontal(frames: TerminalFrame[], options: { gap?: number
100
102
  for (let index = 0; index < normalizedFrames.length; index += 1) {
101
103
  const part = normalizedFrames[index];
102
104
  for (let row = 0; row < height; row += 1) {
103
- lines[row] += part.lines[row];
105
+ lines[row] += part.frame.lines[row];
104
106
  if (index < normalizedFrames.length - 1) {
105
107
  lines[row] += gapText;
106
108
  }
@@ -124,20 +126,20 @@ export function padFrame(frame: TerminalFrame, padding: number) {
124
126
  }
125
127
 
126
128
  const width = getFrameWidth(frame);
127
- 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)}`);
128
130
  const blank = repeat(" ", width + amount * 2);
129
131
  const lines = [...new Array<string>(amount).fill(blank), ...middle, ...new Array<string>(amount).fill(blank)];
130
132
  const shifted = shiftFrame(frame, amount, amount);
131
133
  return createFrame(lines, shifted.hitboxes, frame.cursor ? { x: frame.cursor.x + amount, y: frame.cursor.y + amount } : null, shifted.spans);
132
134
  }
133
135
 
134
- export function fitFrame(frame: TerminalFrame, width?: number, height?: number) {
136
+ export function fitFrame(frame: TerminalFrame, width?: number, height?: number, options: { expandFullFrameSpans?: boolean } = {}) {
135
137
  const nextWidth = Math.max(getFrameWidth(frame), Number(width || 0));
136
138
  const nextHeight = Math.max(getFrameHeight(frame), Number(height || 0));
137
139
  if (nextWidth === getFrameWidth(frame) && nextHeight === getFrameHeight(frame)) {
138
140
  return frame;
139
141
  }
140
- return createFrame(normalizeLines(frame, nextWidth, nextHeight), frame.hitboxes, frame.cursor, frame.spans);
142
+ return constrainFrame(frame, { width: nextWidth, height: nextHeight, expandFullFrameSpans: options.expandFullFrameSpans });
141
143
  }
142
144
 
143
145
  function constraintSize(value: number | undefined, fallback: number) {
@@ -159,13 +161,13 @@ function clamp(value: number, min: number, max: number) {
159
161
  return Math.min(max, Math.max(min, value));
160
162
  }
161
163
 
162
- 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 {
163
165
  const width = constraintSize(options.width, getFrameWidth(frame));
164
166
  const height = constraintSize(options.height, getFrameHeight(frame));
165
167
  const lines: string[] = [];
166
168
 
167
169
  for (let row = 0; row < height; row += 1) {
168
- lines.push((frame.lines[row] || "").slice(0, width).padEnd(width, " "));
170
+ lines.push(padEndTerminalCells(sliceTerminalCells(frame.lines[row] || "", width), width));
169
171
  }
170
172
 
171
173
  const hitboxes = frame.hitboxes
@@ -183,8 +185,26 @@ export function constrainFrame(frame: TerminalFrame, options: { width?: number;
183
185
  ? { x: frame.cursor.x, y: frame.cursor.y }
184
186
  : null;
185
187
 
188
+ const originalWidth = getFrameWidth(frame);
189
+ const originalHeight = getFrameHeight(frame);
186
190
  const spanRightEdge = width + 1;
187
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
+
188
208
  if (width <= 0 || span.y < 1 || span.y > height || span.x2 <= 1 || span.x1 >= spanRightEdge) {
189
209
  return [];
190
210
  }
@@ -195,9 +215,22 @@ export function constrainFrame(frame: TerminalFrame, options: { width?: number;
195
215
  return [];
196
216
  }
197
217
 
198
- return [{ ...span, x1: clippedX1, x2: clippedX2 }];
218
+ return [cloneStyleSpan(span, { x1: clippedX1, x2: clippedX2 })];
199
219
  });
200
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
+
201
234
  return createFrame(lines, hitboxes, cursor, spans);
202
235
  }
203
236
 
@@ -233,16 +266,20 @@ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, option
233
266
 
234
267
  const baseLine = lines[baseRow];
235
268
  const start = x - 1;
236
- if (start >= baseLine.length) {
269
+ if (start >= terminalCellWidth(baseLine)) {
237
270
  continue;
238
271
  }
239
272
 
240
- const visibleWidth = Math.min(constrainedOverlay.lines[row].length, baseLine.length - start);
273
+ const visibleWidth = Math.min(terminalCellWidth(constrainedOverlay.lines[row]), terminalCellWidth(baseLine) - start);
241
274
  if (visibleWidth <= 0) {
242
275
  continue;
243
276
  }
244
277
 
245
- 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}`;
246
283
  }
247
284
 
248
285
  const shiftedOverlay = shiftFrame(constrainedOverlay, x - 1, y - 1);
@@ -270,10 +307,7 @@ export function cropFrame(frame: TerminalFrame, offset: number, height: number)
270
307
  }));
271
308
  const spans = frame.spans
272
309
  .filter((span) => span.y > start && span.y <= start + size)
273
- .map((span) => ({
274
- ...span,
275
- y: span.y - start
276
- }));
310
+ .map((span) => cloneStyleSpan(span, { y: span.y - start }));
277
311
  const cursor = frame.cursor && frame.cursor.y > start && frame.cursor.y <= start + size
278
312
  ? { x: frame.cursor.x, y: frame.cursor.y - start }
279
313
  : null;