@trendr/core 0.2.8 → 0.2.10

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/index.js CHANGED
@@ -21,12 +21,14 @@ export { ScrollBox } from './src/scroll-box.js'
21
21
  export { SplitPane } from './src/split-pane.js'
22
22
  export { MillerNav } from './src/miller-nav.js'
23
23
  export { MenuBar } from './src/menubar.js'
24
+ export { Menu } from './src/menu.js'
24
25
  export { Task } from './src/task.js'
25
26
  export { Shimmer } from './src/shimmer.js'
26
27
  export { useFocus, useFocusTrap } from './src/focus.js'
27
28
  export { useHotkey } from './src/hotkey.js'
28
29
  export { useToast } from './src/toast.js'
29
30
  export { Fragment } from './src/element.js'
31
+ export { Scrollback } from './src/scrollback.js'
30
32
  export { altScreen, exitAltScreen, showCursor, hideCursor, clearScreen, clearLine } from './src/ansi.js'
31
33
  export { animated, useAnimated, spring, ease, decay } from './src/animation.js'
32
34
  export { linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic, easeOutBounce } from './src/animation.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "pick-list": "node esbuild.config.js && node dist/pick-list.js",
48
48
  "table-demo": "node esbuild.config.js && node dist/table-demo.js",
49
49
  "menubar": "node esbuild.config.js && node dist/menubar.js",
50
- "scrollback-demo": "node esbuild.config.js && node dist/scrollback-demo.js",
50
+ "inline-chat": "node esbuild.config.js && node dist/inline-chat.js",
51
51
  "demo": "node esbuild.config.js && node demo/server.js",
52
52
  "demo:build": "node esbuild.config.js"
53
53
  },
package/src/ansi.js CHANGED
@@ -10,6 +10,7 @@ export const hideCursor = `${ESC}?25l`
10
10
  export const showCursor = `${ESC}?25h`
11
11
  export const clearScreen = `${ESC}2J`
12
12
  export const clearLine = `${ESC}2K`
13
+ export const clearDown = `${ESC}0J`
13
14
  export const altScreen = `${ESC}?1049h`
14
15
  export const exitAltScreen = `${ESC}?1049l`
15
16
  export const sgrReset = `${ESC}0m`
@@ -43,14 +44,25 @@ const NAMED_COLORS = {
43
44
  brightBlue: 12, brightMagenta: 13, brightCyan: 14, brightWhite: 15,
44
45
  }
45
46
 
47
+ function expandHex(color) {
48
+ if (color.length === 7) return color
49
+ if (color.length === 4) {
50
+ const r = color[1], g = color[2], b = color[3]
51
+ return `#${r}${r}${g}${g}${b}${b}`
52
+ }
53
+ return null
54
+ }
55
+
46
56
  function parseColor(color, offset) {
47
57
  if (color == null) return null
48
58
  if (typeof color === 'number') return `${offset};5;${color}`
49
59
  if (color in NAMED_COLORS) return `${offset};5;${NAMED_COLORS[color]}`
50
- if (color.startsWith('#') && color.length === 7) {
51
- const r = parseInt(color.slice(1, 3), 16)
52
- const g = parseInt(color.slice(3, 5), 16)
53
- const b = parseInt(color.slice(5, 7), 16)
60
+ if (color.charCodeAt(0) === 35) {
61
+ const hex = expandHex(color)
62
+ if (!hex) return null
63
+ const r = parseInt(hex.slice(1, 3), 16)
64
+ const g = parseInt(hex.slice(3, 5), 16)
65
+ const b = parseInt(hex.slice(5, 7), 16)
54
66
  return `${offset};2;${r};${g};${b}`
55
67
  }
56
68
  return null
package/src/diff.js CHANGED
@@ -56,6 +56,15 @@ function writeSgr(fg, bg, attrs) {
56
56
  buf[pos++] = 0x6d
57
57
  }
58
58
 
59
+ function expandHex(color) {
60
+ if (color.length === 7) return color
61
+ if (color.length === 4) {
62
+ const r = color[1], g = color[2], b = color[3]
63
+ return `#${r}${r}${g}${g}${b}${b}`
64
+ }
65
+ return null
66
+ }
67
+
59
68
  function writeColor(color, offset) {
60
69
  if (typeof color === 'number' || color in NAMED_COLORS) {
61
70
  const idx = typeof color === 'number' ? color : NAMED_COLORS[color]
@@ -65,10 +74,12 @@ function writeColor(color, offset) {
65
74
  buf[pos++] = 53 // '5'
66
75
  buf[pos++] = 0x3b
67
76
  writeNum(idx)
68
- } else if (color.charCodeAt(0) === 35 && color.length === 7) {
69
- const r = parseInt(color.slice(1, 3), 16)
70
- const g = parseInt(color.slice(3, 5), 16)
71
- const b = parseInt(color.slice(5, 7), 16)
77
+ } else if (color.charCodeAt(0) === 35) {
78
+ const hex = expandHex(color)
79
+ if (!hex) return
80
+ const r = parseInt(hex.slice(1, 3), 16)
81
+ const g = parseInt(hex.slice(3, 5), 16)
82
+ const b = parseInt(hex.slice(5, 7), 16)
72
83
  buf[pos++] = 0x3b
73
84
  writeNum(offset)
74
85
  buf[pos++] = 0x3b
package/src/focus.js CHANGED
@@ -1,28 +1,14 @@
1
- import { createSignalRaw, onCleanup } from './signal.js'
1
+ import { createSignalRaw } from './signal.js'
2
2
  import { registerHook } from './renderer.js'
3
3
  import { useInput } from './hooks.js'
4
4
 
5
- let focusTrapStack = []
6
-
5
+ // while active, keeps tab/shift-tab from propagating past the trapping
6
+ // component to focus managers registered before it. handlers fire
7
+ // innermost-first, so any useFocus registered deeper in the tree handles the
8
+ // tab first; this handler then stops propagation so shallower managers don't
9
+ // also react. when traps nest, the innermost active one fires first and halts
10
+ // the rest, so it becomes the effective boundary
7
11
  export function useFocusTrap(active) {
8
- const state = registerHook(() => {
9
- const id = Symbol()
10
- onCleanup(() => {
11
- const idx = focusTrapStack.indexOf(id)
12
- if (idx >= 0) focusTrapStack.splice(idx, 1)
13
- })
14
- return { id, wasActive: false }
15
- })
16
-
17
- if (active && !state.wasActive) {
18
- focusTrapStack.push(state.id)
19
- state.wasActive = true
20
- } else if (!active && state.wasActive) {
21
- const idx = focusTrapStack.indexOf(state.id)
22
- if (idx >= 0) focusTrapStack.splice(idx, 1)
23
- state.wasActive = false
24
- }
25
-
26
12
  useInput((event) => {
27
13
  if (!active) return
28
14
  if (event.key === 'tab' || event.key === 'shift-tab') {
@@ -122,7 +108,6 @@ export function useFocus({ initial, cycle = 'tab' } = {}) {
122
108
  const cur = state.current()
123
109
 
124
110
  if (state.stack.length > 0) return
125
- if (focusTrapStack.length > 0) return
126
111
 
127
112
  if (cycle === 'tab' && (key === 'tab' || key === 'shift-tab')) {
128
113
  const idx = findTopLevel(cur)
package/src/input.js CHANGED
@@ -52,6 +52,31 @@ export function parseMouse(raw) {
52
52
  return { type: 'mouse', action, button: buttonName, x, y }
53
53
  }
54
54
 
55
+ const MODIFY_OTHER_RE = /^\x1b\[27;(\d+);(\d+)~$/
56
+ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u$/
57
+
58
+ function codeToKey(code) {
59
+ if (code === 13 || code === 10) return 'return'
60
+ if (code === 9) return 'tab'
61
+ if (code === 27) return 'escape'
62
+ if (code === 32) return 'space'
63
+ if (code === 127 || code === 8) return 'backspace'
64
+ return String.fromCodePoint(code)
65
+ }
66
+
67
+ // modifier param is 1-based with a bitmask in the low bits: shift=1, alt=2,
68
+ // ctrl=4 (so plain=1, shift=2, ctrl=5, etc)
69
+ function modifiedKey(code, mod, raw) {
70
+ const bits = Math.max(0, mod - 1)
71
+ return {
72
+ key: codeToKey(code),
73
+ ctrl: (bits & 4) !== 0,
74
+ meta: (bits & 2) !== 0,
75
+ shift: (bits & 1) !== 0,
76
+ raw,
77
+ }
78
+ }
79
+
55
80
  export function parseKey(data) {
56
81
  const raw = typeof data === 'string' ? data : data.toString()
57
82
 
@@ -59,6 +84,12 @@ export function parseKey(data) {
59
84
  return { key: SPECIAL_KEYS[raw], ctrl: false, meta: false, shift: false, raw }
60
85
  }
61
86
 
87
+ let m = MODIFY_OTHER_RE.exec(raw)
88
+ if (m) return modifiedKey(parseInt(m[2], 10), parseInt(m[1], 10), raw)
89
+
90
+ m = CSI_U_RE.exec(raw)
91
+ if (m) return modifiedKey(parseInt(m[1], 10), m[2] ? parseInt(m[2], 10) : 1, raw)
92
+
62
93
  if (raw.length === 1) {
63
94
  const code = raw.charCodeAt(0)
64
95
 
@@ -90,21 +121,12 @@ export function splitKeys(data) {
90
121
  while (i < raw.length) {
91
122
  if (raw[i] === '\x1b') {
92
123
  if (i + 1 < raw.length && raw[i + 1] === '[') {
124
+ // generic csi: optional private marker, any number of ;-separated
125
+ // numeric params, then a final byte. covers arrows, mouse (\x1b[<..M),
126
+ // csi-u (\x1b[13;2u) and modifyOtherKeys (\x1b[27;2;13~)
93
127
  let j = i + 2
94
- // sgr mouse: \x1b[<Cb;Cx;CyM or m
95
- if (j < raw.length && raw[j] === '<') {
96
- j++
97
- while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';')) j++
98
- if (j < raw.length) j++
99
- keys.push(raw.slice(i, j))
100
- i = j
101
- continue
102
- }
103
- while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
104
- if (j < raw.length && raw[j] === ';') {
105
- j++
106
- while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
107
- }
128
+ if (j < raw.length && (raw[j] === '<' || raw[j] === '?')) j++
129
+ while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';' || raw[j] === ':')) j++
108
130
  if (j < raw.length) j++
109
131
  keys.push(raw.slice(i, j))
110
132
  i = j
package/src/layout.js CHANGED
@@ -246,6 +246,10 @@ function layoutFlex(children, ctx) {
246
246
  }
247
247
  }
248
248
 
249
+ export function intrinsicHeight(node, availW, availH) {
250
+ return measureChild(node, childStyle(node), false, availW, availH).height
251
+ }
252
+
249
253
  function measureChild(child, cs, isRow, availW, availH) {
250
254
  const leaf = getLeaf(child)
251
255
  if (!leaf) return { width: 0, height: 0 }
package/src/menu.js ADDED
@@ -0,0 +1,87 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useTheme } from './hooks.js'
4
+ import { List } from './list.js'
5
+
6
+ // a windowed single-select menu. shows at most maxVisible rows and scrolls
7
+ // with scrolloff as the active item moves. it carries no text input of its
8
+ // own, so it pairs with an external composer: render it after that input and,
9
+ // while focused, it intercepts up/down/enter before the input sees them
10
+ export function Menu({
11
+ items,
12
+ selected: selectedProp,
13
+ onSelect,
14
+ onSubmit,
15
+ onCancel,
16
+ focused = true,
17
+ maxVisible = 5,
18
+ scrolloff = 2,
19
+ itemHeight = 1,
20
+ gap = 0,
21
+ renderItem,
22
+ arrow = '›',
23
+ }) {
24
+ const { accent = 'cyan' } = useTheme()
25
+ const [internal, setInternal] = createSignal(0)
26
+
27
+ const len = items.length
28
+ const raw = selectedProp ?? internal()
29
+ const selected = Math.min(Math.max(0, raw), Math.max(0, len - 1))
30
+ const setSelected = onSelect ?? setInternal
31
+
32
+ if (selected !== raw && len > 0) setSelected(selected)
33
+
34
+ useInput((event) => {
35
+ if (!focused || len === 0) return
36
+ const { key, ctrl, shift, meta } = event
37
+
38
+ if (key === 'up' || (ctrl && key === 'p')) {
39
+ setSelected(Math.max(0, selected - 1))
40
+ event.stopPropagation()
41
+ } else if (key === 'down' || (ctrl && key === 'n')) {
42
+ setSelected(Math.min(len - 1, selected + 1))
43
+ event.stopPropagation()
44
+ } else if (key === 'return' && !shift && !meta) {
45
+ if (onSubmit) onSubmit(items[selected], selected)
46
+ event.stopPropagation()
47
+ } else if (key === 'escape') {
48
+ if (onCancel) {
49
+ onCancel()
50
+ event.stopPropagation()
51
+ }
52
+ }
53
+ })
54
+
55
+ if (len === 0) return jsx('box', { style: { height: 0 } })
56
+
57
+ const visible = Math.min(maxVisible, len)
58
+ const height = visible * itemHeight + Math.max(0, visible - 1) * gap
59
+
60
+ const defaultRender = (item, { active }) => {
61
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
62
+ return jsx('box', {
63
+ style: { flexDirection: 'row' },
64
+ children: [
65
+ jsx('text', { style: { color: active ? accent : null }, children: active ? `${arrow} ` : ' ' }),
66
+ jsx('text', { style: { color: active ? accent : 'gray' }, children: label }),
67
+ ],
68
+ })
69
+ }
70
+
71
+ const render = renderItem ?? defaultRender
72
+
73
+ return jsx('box', {
74
+ style: { height, minHeight: height },
75
+ children: jsx(List, {
76
+ items,
77
+ selected,
78
+ onSelect: setSelected,
79
+ focused: false,
80
+ interactive: false,
81
+ itemHeight,
82
+ gap,
83
+ scrolloff,
84
+ renderItem: (item, ctx) => render(item, { active: ctx.selected, index: ctx.index }),
85
+ }),
86
+ })
87
+ }
package/src/renderer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createBuffer, clearBuffer, fillRect, writeText, dimBuffer, blitRect } from './buffer.js'
2
2
  import { diff } from './diff.js'
3
- import { computeLayout, resolveBorderEdges } from './layout.js'
3
+ import { bufferToLines } from './serialize.js'
4
+ import { computeLayout, resolveBorderEdges, intrinsicHeight } from './layout.js'
4
5
  import { Fragment } from './element.js'
5
6
  import { createScheduler } from './scheduler.js'
6
7
  import { createInputHandler } from './input.js'
@@ -606,7 +607,40 @@ function resolveForFrame(element, parent, instances, counters, visited, scope) {
606
607
  return node
607
608
  }
608
609
 
609
- export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb, altScreen = true } = {}) {
610
+ function findScrollback(node) {
611
+ if (!node) return null
612
+ if (typeof node.type === 'function' && node.type.__scrollback) return node
613
+ if (node._resolved) {
614
+ const found = findScrollback(node._resolved)
615
+ if (found) return found
616
+ }
617
+ if (node._resolvedChildren) {
618
+ for (const child of node._resolvedChildren) {
619
+ const found = findScrollback(child)
620
+ if (found) return found
621
+ }
622
+ }
623
+ return null
624
+ }
625
+
626
+ // render a one-shot element (a committed scrollback item) to ANSI lines. it
627
+ // gets its own throwaway instance map so it never pollutes the live tree's
628
+ // cache, and scopes are disposed immediately since committed content is frozen
629
+ function renderElementToLines(element, width) {
630
+ if (element == null) return []
631
+ const instances = new Map()
632
+ const counters = new Map()
633
+ const tree = resolveForFrame(element, null, instances, counters, null, '')
634
+ const h = Math.max(1, intrinsicHeight(tree, width, 100000))
635
+ computeLayout(tree, { x: 0, y: 0, width, height: h })
636
+ const buf = createBuffer(width, h)
637
+ paintTree(tree, buf, null, null, null)
638
+ const lines = bufferToLines(buf)
639
+ for (const inst of instances.values()) disposeScope(inst.scope)
640
+ return lines
641
+ }
642
+
643
+ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb, altScreen = true, inline = false } = {}) {
610
644
  const out = stream ?? process.stdout
611
645
  const inp = stdin ?? process.stdin
612
646
 
@@ -626,6 +660,104 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
626
660
  let forceFullPaint = false
627
661
  let prevHadOverlays = false
628
662
 
663
+ // inline mode state: how many scrollback items have been committed, how many
664
+ // lines the live region occupied last emit, and that emit's text for skipping
665
+ let flushedCount = 0
666
+ let prevLiveLines = 0
667
+ let prevLiveText = null
668
+
669
+ // mirror per-instance layout (incl. scroll contentHeight/childHeights) back
670
+ // onto each instance so useLayout() consumers like List/Menu can window.
671
+ // returns whether anything changed, so the caller can re-resolve once and let
672
+ // those components observe the freshly measured values
673
+ function syncInstanceLayouts() {
674
+ let changed = false
675
+ for (const inst of instances.values()) {
676
+ const rect = inst.node?._availableRect ?? inst.node?._layout
677
+ if (!rect) continue
678
+ const ch = findScrollContentHeight(inst.node)
679
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
680
+ const p = inst.layout
681
+ if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch) changed = true
682
+ p.x = rect.x
683
+ p.y = rect.y
684
+ p.width = rect.width
685
+ p.height = rect.height
686
+ p.contentHeight = ch
687
+ p.childHeights = findScrollChildHeights(inst.node)
688
+ }
689
+ return changed
690
+ }
691
+
692
+ function inlineFrame() {
693
+ const prevCtx = activeContext
694
+ activeContext = ctx
695
+ overlays = []
696
+
697
+ const counters = new Map()
698
+ const visited = new Set()
699
+ const element = { type: rootComponent, props: {}, key: null }
700
+ let tree = resolveForFrame(element, null, instances, counters, visited, '')
701
+
702
+ const sb = findScrollback(tree)
703
+ const items = sb?.props?.items ?? []
704
+ const renderItem = sb?.props?.render
705
+
706
+ let committed = ''
707
+ if (renderItem && items.length > flushedCount) {
708
+ for (let i = flushedCount; i < items.length; i++) {
709
+ const lines = renderElementToLines(renderItem(items[i], i), width)
710
+ for (const ln of lines) committed += ln + '\r\n'
711
+ }
712
+ }
713
+ // items can only grow while mounted; a shrink means the app reset its log,
714
+ // which we can't un-print, so just resync the counter
715
+ flushedCount = items.length
716
+
717
+ let liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
718
+ computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
719
+
720
+ if (syncInstanceLayouts()) {
721
+ counters.clear()
722
+ visited.clear()
723
+ tree = resolveForFrame(element, null, instances, counters, visited, '')
724
+ liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
725
+ computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
726
+ syncInstanceLayouts()
727
+ }
728
+
729
+ for (const [key, inst] of instances) {
730
+ if (!visited.has(key)) {
731
+ disposeScope(inst.scope)
732
+ instances.delete(key)
733
+ }
734
+ }
735
+
736
+ const liveBuf = createBuffer(width, liveHeight)
737
+ paintTree(tree, liveBuf, null, null, null)
738
+ const liveLines = bufferToLines(liveBuf)
739
+ lastInlineBuf = liveBuf
740
+
741
+ activeContext = prevCtx
742
+
743
+ const liveText = liveLines.join('\r\n')
744
+ if (!committed && liveText === prevLiveText) return
745
+
746
+ let out_ = ''
747
+ if (prevLiveLines > 0) {
748
+ out_ += '\r'
749
+ if (prevLiveLines > 1) out_ += ansi.moveUp(prevLiveLines - 1)
750
+ out_ += ansi.clearDown
751
+ }
752
+ out_ += committed + liveText
753
+
754
+ out.write(ansi.hideCursor + out_)
755
+ prevLiveLines = liveLines.length
756
+ prevLiveText = liveText
757
+ }
758
+
759
+ let lastInlineBuf = null
760
+
629
761
  function frame() {
630
762
  const prevCtx = activeContext
631
763
  activeContext = ctx
@@ -741,20 +873,37 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
741
873
 
742
874
  const scheduler = createScheduler({
743
875
  fps: 60,
744
- onFrame: frame,
876
+ onFrame: inline ? inlineFrame : frame,
745
877
  })
746
878
 
747
879
  setSchedulerHook(scheduler.requestFrame)
748
880
 
749
- out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
881
+ // inline mode stays on the main screen buffer so native scrollback survives:
882
+ // no alt screen, no clear, no mouse capture (so the terminal handles scroll
883
+ // and text selection itself)
884
+ if (inline) {
885
+ out.write(ansi.hideCursor + (title ? ansi.setTitle(title) : ''))
886
+ } else {
887
+ out.write((altScreen ? ansi.altScreen : '') + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
888
+ }
750
889
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
751
890
 
752
- frame()
891
+ if (inline) inlineFrame()
892
+ else frame()
753
893
  scheduler.requestFrame()
754
894
 
755
895
  const onResize = () => {
756
896
  width = out.columns ?? 80
757
897
  height = out.rows ?? 24
898
+ if (inline) {
899
+ // the terminal reflows committed history on its own; drop our live-region
900
+ // geometry and repaint it fresh below wherever the cursor landed
901
+ prevLiveLines = 0
902
+ prevLiveText = null
903
+ out.write('\r\n')
904
+ scheduler.forceFrame()
905
+ return
906
+ }
758
907
  prev = createBuffer(width, height)
759
908
  curr = createBuffer(width, height)
760
909
  out.write(ansi.clearScreen)
@@ -786,7 +935,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
786
935
  }
787
936
  instances.clear()
788
937
 
789
- out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
938
+ if (inline) {
939
+ out.write(ansi.sgrReset + ansi.showCursor + '\r\n')
940
+ } else {
941
+ out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + (altScreen ? ansi.exitAltScreen : ansi.moveTo(height, 1) + '\n'))
942
+ }
790
943
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
791
944
  activeContext = null
792
945
  setSchedulerHook(null)
@@ -799,6 +952,11 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
799
952
  process.on('exit', onExit)
800
953
 
801
954
  function repaint() {
955
+ if (inline) {
956
+ prevLiveText = null
957
+ scheduler.forceFrame()
958
+ return
959
+ }
802
960
  prev = createBuffer(width, height)
803
961
  curr = createBuffer(width, height)
804
962
  forceFullPaint = true
@@ -808,5 +966,5 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
808
966
 
809
967
  ctx.repaint = repaint
810
968
 
811
- return { unmount, repaint, getBuffer: () => prev }
969
+ return { unmount, repaint, getBuffer: () => (inline ? lastInlineBuf : prev) }
812
970
  }
@@ -0,0 +1,9 @@
1
+ // marker component for inline mode. everything inside Scrollback is the
2
+ // committed transcript: append-only items that get printed once into native
3
+ // terminal scrollback and never touched again. the renderer reads its props
4
+ // directly during the inline frame, so the component itself renders nothing
5
+ export function Scrollback() {
6
+ return null
7
+ }
8
+
9
+ Scrollback.__scrollback = true
@@ -0,0 +1,46 @@
1
+ import { sgr } from './ansi.js'
2
+
3
+ const RESET = '\x1b[0m'
4
+
5
+ function isBlank(cell) {
6
+ return cell.ch === ' ' && cell.fg == null && cell.bg == null && cell.attrs === 0
7
+ }
8
+
9
+ // turn a cell buffer into one ANSI string per row, trimming trailing blank
10
+ // cells so short lines don't carry filler. used by inline mode to emit both
11
+ // committed scrollback items and the live region
12
+ export function bufferToLines(buf) {
13
+ const { width, height, cells } = buf
14
+ const lines = new Array(height)
15
+
16
+ for (let y = 0; y < height; y++) {
17
+ const row = y * width
18
+
19
+ let last = -1
20
+ for (let x = 0; x < width; x++) {
21
+ const c = cells[row + x]
22
+ if (c.ch !== '' && !isBlank(c)) last = x
23
+ }
24
+
25
+ let line = ''
26
+ let curFg, curBg, curAttrs
27
+ let open = false
28
+
29
+ for (let x = 0; x <= last; x++) {
30
+ const c = cells[row + x]
31
+ if (c.ch === '') continue
32
+ if (!open || c.fg !== curFg || c.bg !== curBg || c.attrs !== curAttrs) {
33
+ line += sgr(c.fg, c.bg, c.attrs)
34
+ curFg = c.fg
35
+ curBg = c.bg
36
+ curAttrs = c.attrs
37
+ open = true
38
+ }
39
+ line += c.ch
40
+ }
41
+
42
+ lines[y] = open ? line + RESET : line
43
+ }
44
+
45
+ return lines
46
+ }
package/src/text-area.js CHANGED
@@ -119,7 +119,7 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
119
119
 
120
120
  resetBlink()
121
121
 
122
- const { key, raw, ctrl, meta } = event
122
+ const { key, raw, ctrl, meta, shift } = event
123
123
  const v = value()
124
124
  const c = cursor()
125
125
 
@@ -131,10 +131,10 @@ export function TextArea({ onSubmit, onCancel, onChange, onKeyDown, placeholder,
131
131
  }
132
132
 
133
133
  const isSubmitKey = submitOnEnter
134
- ? (key === 'return' && !meta)
134
+ ? (key === 'return' && !meta && !shift)
135
135
  : (meta && key === '\r')
136
136
  const isNewlineKey = submitOnEnter
137
- ? (meta && key === '\r')
137
+ ? ((shift && key === 'return') || (meta && key === '\r'))
138
138
  : (key === 'return')
139
139
 
140
140
  if (isSubmitKey) {