@xp266/dshtui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. package/src/vendor.ts +12 -0
@@ -0,0 +1,148 @@
1
+ import { createContext, useContext, memo } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { useLayoutEffect, useRef } from 'react'
4
+ import { Text } from 'ink'
5
+ import { colToCharIndex, textWidth } from '../core/text.ts'
6
+ import { selectedRange } from '../model/selection.ts'
7
+ import type { LineSelection } from '../model/selection.ts'
8
+ import { envelopeOverlaps, registerRowPiece } from './selection-registry.ts'
9
+ import { useOrigin } from './region.tsx'
10
+ import type { Segment } from '../core/segments.ts'
11
+ import { COLORS } from '../theme.ts'
12
+
13
+ export const SelectionContext = createContext<LineSelection | null>(null)
14
+
15
+ export interface SelectableTextProps {
16
+ y: number
17
+ col: number
18
+ text?: string
19
+ segments?: Segment[]
20
+ color?: string
21
+ bold?: boolean
22
+ inverse?: boolean
23
+ backgroundColor?: string
24
+ messageLayer?: boolean
25
+ flow?: boolean
26
+ }
27
+
28
+ interface SelectableContentProps {
29
+ content: string
30
+ segments?: Segment[]
31
+ color?: string
32
+ bold: boolean
33
+ inverse: boolean
34
+ backgroundColor?: string
35
+ sliceStart: number
36
+ sliceEnd: number
37
+ }
38
+
39
+ const SelectableContent = memo(function SelectableContent({ content, segments, color, bold, inverse, backgroundColor, sliceStart, sliceEnd }: SelectableContentProps) {
40
+ const hasSlice = sliceStart >= 0 && sliceEnd > sliceStart
41
+ if (hasSlice) {
42
+ const highlight = (
43
+ <Text backgroundColor={COLORS.selectionBg} color={COLORS.selectionFg}>
44
+ {content.slice(sliceStart, sliceEnd)}
45
+ </Text>
46
+ )
47
+ if (segments !== undefined) {
48
+ return (
49
+ <Text backgroundColor={backgroundColor}>
50
+ {renderSegments(segments, 0, sliceStart, color ?? COLORS.ink)}
51
+ {highlight}
52
+ {renderSegments(segments, sliceEnd, content.length, color ?? COLORS.ink)}
53
+ </Text>
54
+ )
55
+ }
56
+ return (
57
+ <Text backgroundColor={backgroundColor} inverse={inverse} color={color ?? COLORS.ink} bold={bold}>
58
+ {content.slice(0, sliceStart)}
59
+ {highlight}
60
+ {content.slice(sliceEnd)}
61
+ </Text>
62
+ )
63
+ }
64
+ if (segments !== undefined) {
65
+ return <Text backgroundColor={backgroundColor}>{renderSegments(segments, 0, content.length, color ?? COLORS.ink)}</Text>
66
+ }
67
+ return (
68
+ <Text backgroundColor={backgroundColor} inverse={inverse} color={color ?? COLORS.ink} bold={bold}>
69
+ {content}
70
+ </Text>
71
+ )
72
+ }, (prev, next) => (
73
+ prev.content === next.content &&
74
+ prev.segments === next.segments &&
75
+ prev.color === next.color &&
76
+ prev.bold === next.bold &&
77
+ prev.inverse === next.inverse &&
78
+ prev.backgroundColor === next.backgroundColor &&
79
+ prev.sliceStart === next.sliceStart &&
80
+ prev.sliceEnd === next.sliceEnd
81
+ ))
82
+
83
+ export const SelectableText = function SelectableText({ y, col, text, segments, color, bold = false, inverse = false, backgroundColor, messageLayer = false, flow = false }: SelectableTextProps) {
84
+ const selection = useContext(SelectionContext)
85
+ const origin = useOrigin()
86
+ const absY = origin.y + y
87
+ const absCol = origin.x + col
88
+ const content = text ?? (segments?.map(segment => segment.text).join('') ?? '')
89
+ const pieceId = useRef({})
90
+ useLayoutEffect(() => {
91
+ registerRowPiece(pieceId.current, absY, { col: absCol, text: content, ...(messageLayer ? { layer: 'message' as const } : {}) })
92
+ return () => registerRowPiece(pieceId.current, absY, null)
93
+ }, [absY, absCol, content, messageLayer])
94
+ let sliceStart = -1
95
+ let sliceEnd = -1
96
+ if (selection !== null) {
97
+ const range = selectedRange(selection, absY)
98
+ if (range !== null && (flow || envelopeOverlaps(selection, absCol, textWidth(content)))) {
99
+ const lineWidth = textWidth(content)
100
+ const start = Math.max(range.start, absCol)
101
+ const end = Math.min(range.end, absCol + lineWidth)
102
+ if (start < end) {
103
+ sliceStart = colToCharIndex(content, start - absCol)
104
+ sliceEnd = colToCharIndex(content, end - absCol)
105
+ }
106
+ }
107
+ }
108
+ return (
109
+ <SelectableContent
110
+ content={content}
111
+ segments={segments}
112
+ color={color}
113
+ bold={bold}
114
+ inverse={inverse}
115
+ backgroundColor={backgroundColor}
116
+ sliceStart={sliceStart}
117
+ sliceEnd={sliceEnd}
118
+ />
119
+ )
120
+ }
121
+
122
+ function renderSegments(segments: Segment[], start: number, end: number, baseColor?: string): ReactNode[] {
123
+ const out: ReactNode[] = []
124
+ let offset = 0
125
+ for (const segment of segments) {
126
+ const segmentStart = offset
127
+ const segmentEnd = offset + segment.text.length
128
+ offset = segmentEnd
129
+ if (segmentEnd <= start || segmentStart >= end) continue
130
+ const from = Math.max(0, start - segmentStart)
131
+ const to = Math.min(segment.text.length, end - segmentStart)
132
+ if (from >= to) continue
133
+ out.push(
134
+ <Text
135
+ key={out.length}
136
+ color={segment.style.color ?? baseColor}
137
+ bold={segment.style.bold}
138
+ italic={segment.style.italic}
139
+ underline={segment.style.underline}
140
+ strikethrough={segment.style.strike}
141
+ backgroundColor={segment.style.background}
142
+ >
143
+ {segment.text.slice(from, to)}
144
+ </Text>,
145
+ )
146
+ }
147
+ return out
148
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext, useEffect, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export const SpinnerTickContext = createContext(0)
5
+
6
+ export function useSpinnerTick(): number {
7
+ return useContext(SpinnerTickContext)
8
+ }
9
+
10
+ /**
11
+ * Owns the 10Hz busy animation tick. Keeping the tick in context instead of
12
+ * App state confines each tick to the components that consume it (spinner
13
+ * rows, status bar); the rest of the App tree does not re-render.
14
+ */
15
+ export function SpinnerTickProvider({ busy, children }: { busy: boolean; children: ReactNode }) {
16
+ const [tick, setTick] = useState(0)
17
+ useEffect(() => {
18
+ if (!busy) return
19
+ setTick(0)
20
+ const timer = setInterval(() => setTick(tick => tick + 1), 100)
21
+ return () => clearInterval(timer)
22
+ }, [busy])
23
+ return <SpinnerTickContext.Provider value={busy ? tick : 0}>{children}</SpinnerTickContext.Provider>
24
+ }
@@ -0,0 +1,31 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { hasWindowServices, subscribeWindowServices } from './window-services.ts'
3
+ import { listWindows, subscribeWindows } from './windows.ts'
4
+ import type { WindowContribution } from './windows.ts'
5
+
6
+ const EMPTY: WindowContribution[] = []
7
+ let cache: WindowContribution[] = EMPTY
8
+
9
+ function subscribe(next: () => void): () => void {
10
+ const offWindows = subscribeWindows(() => {
11
+ cache = listWindows().filter(entry => hasWindowServices(entry.required))
12
+ next()
13
+ })
14
+ const offServices = subscribeWindowServices(() => {
15
+ cache = listWindows().filter(entry => hasWindowServices(entry.required))
16
+ next()
17
+ })
18
+ cache = listWindows().filter(entry => hasWindowServices(entry.required))
19
+ return () => {
20
+ offWindows()
21
+ offServices()
22
+ }
23
+ }
24
+
25
+ function snapshot(): WindowContribution[] {
26
+ return cache
27
+ }
28
+
29
+ export function useAvailableWindows(): WindowContribution[] {
30
+ return useSyncExternalStore(subscribe, snapshot, () => EMPTY)
31
+ }
@@ -0,0 +1,84 @@
1
+ import { Box, Text } from 'ink'
2
+ import { actionPositions } from '../dialog/geometry.ts'
3
+ import { textWidth } from '../../core/text.ts'
4
+ import { SelectableText } from '../selection.tsx'
5
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
6
+ import type { ClickHit, ClickActions } from './types.ts'
7
+ import type { DialogItem } from '../dialog/items.ts'
8
+
9
+ type ActionsItem = Extract<DialogItem, { type: 'actions' }>
10
+
11
+ function paddedLabel(label: string): string {
12
+ return ` ${label} `
13
+ }
14
+
15
+ export function hitActionZone(item: ActionsItem, width: number, localX: number): 'confirm' | 'cancel' | null {
16
+ const positions = actionPositions(width, paddedLabel(item.confirmLabel), paddedLabel(item.cancelLabel))
17
+ if (localX >= positions.confirmX && localX < positions.confirmX + textWidth(paddedLabel(item.confirmLabel))) return 'confirm'
18
+ if (localX >= positions.cancelX && localX < positions.cancelX + textWidth(paddedLabel(item.cancelLabel))) return 'cancel'
19
+ return null
20
+ }
21
+
22
+ registerWidget<ActionsItem>('actions', {
23
+ selectable: true,
24
+ fullRowFocus: true,
25
+ stops: () => 2,
26
+ height() {
27
+ return 1
28
+ },
29
+ paintWidth() {
30
+ return 0
31
+ },
32
+ render({ item, focused, width, y, x, subCol }) {
33
+ const confirmLabel = paddedLabel(item.confirmLabel)
34
+ const cancelLabel = paddedLabel(item.cancelLabel)
35
+ const positions = actionPositions(width, confirmLabel, cancelLabel)
36
+ const mid = positions.cancelX - (positions.confirmX + textWidth(confirmLabel))
37
+ return (
38
+ <Box flexDirection="row">
39
+ <Text>{' '.repeat(positions.confirmX)}</Text>
40
+ <SelectableText
41
+ y={y}
42
+ col={x + positions.confirmX}
43
+ text={confirmLabel}
44
+ inverse={focused && subCol === 0}
45
+ />
46
+ <Text>{' '.repeat(Math.max(0, mid))}</Text>
47
+ <SelectableText
48
+ y={y}
49
+ col={x + positions.cancelX}
50
+ text={cancelLabel}
51
+ inverse={focused && subCol === 1}
52
+ />
53
+ </Box>
54
+ )
55
+ },
56
+ onLeftRight(item, direction, api) {
57
+ api.navigate(direction === 1 ? 'right' : 'left')
58
+ return true
59
+ },
60
+ onEnter(item, api) {
61
+ if (api.subCol === 1) item.onCancel()
62
+ else item.onConfirm()
63
+ return true
64
+ },
65
+ onClick(item, hit: ClickHit, actions: ClickActions) {
66
+ if (hit.localY !== 0) {
67
+ actions.focus(0)
68
+ return true
69
+ }
70
+ const zone = hitActionZone(item, hit.width, hit.localX)
71
+ if (zone === 'confirm') {
72
+ actions.focus(0)
73
+ item.onConfirm()
74
+ return true
75
+ }
76
+ if (zone === 'cancel') {
77
+ actions.focus(1)
78
+ item.onCancel()
79
+ return true
80
+ }
81
+ actions.focus(0)
82
+ return true
83
+ },
84
+ }, { order: BUILTIN_WIDGET_ORDER })
@@ -0,0 +1,57 @@
1
+ import { Box } from 'ink'
2
+ import { padToWidth, textWidth, truncate } from '../../core/text.ts'
3
+ import { COLORS } from '../../theme.ts'
4
+ import { SelectableText } from '../selection.tsx'
5
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
6
+ import type { PaintArgs } from './types.ts'
7
+ import type { DialogItem } from '../dialog/items.ts'
8
+
9
+ type ButtonItem = Extract<DialogItem, { type: 'button' }>
10
+
11
+ function paintButton(item: ButtonItem, paint: PaintArgs<ButtonItem>) {
12
+ const { width, y, x, focused } = paint
13
+ if (item.right !== undefined) {
14
+ const right = truncate(item.right, Math.floor(width / 2))
15
+ const leftWidth = Math.max(1, width - textWidth(right))
16
+ const label = padToWidth(truncate(item.label, leftWidth), leftWidth)
17
+ if (focused) {
18
+ return (
19
+ <Box>
20
+ <SelectableText y={y} col={x} text={label} inverse color={item.rightColor} />
21
+ <SelectableText y={y} col={x + leftWidth} text={right} inverse color={item.rightColor} />
22
+ </Box>
23
+ )
24
+ }
25
+ return (
26
+ <Box width={width} justifyContent="space-between">
27
+ <SelectableText y={y} col={x} text={item.label} />
28
+ <SelectableText y={y} col={x + width - textWidth(right)} text={right} color={item.rightColor ?? COLORS.dialogHintText} />
29
+ </Box>
30
+ )
31
+ }
32
+ if (focused) return <SelectableText y={y} col={x} text={padToWidth(truncate(item.label, width), width)} inverse />
33
+ return <SelectableText y={y} col={x} text={item.label} />
34
+ }
35
+
36
+ registerWidget<ButtonItem>('button', {
37
+ selectable: true,
38
+ height() {
39
+ return 1
40
+ },
41
+ paintWidth(item) {
42
+ return textWidth(item.label + (item.right ?? ''))
43
+ },
44
+ searchTexts(item, searchRight) {
45
+ return searchRight && item.right !== undefined ? [item.label, item.right] : [item.label]
46
+ },
47
+ render(paint) {
48
+ return paintButton(paint.item, paint)
49
+ },
50
+ onEnter(item) {
51
+ item.onPress()
52
+ return true
53
+ },
54
+ activate(item) {
55
+ item.onPress()
56
+ },
57
+ }, { order: BUILTIN_WIDGET_ORDER })
@@ -0,0 +1,58 @@
1
+ import { Box } from 'ink'
2
+ import { COLORS } from '../../theme.ts'
3
+ import { glyphs } from '../../terminal/glyphs.ts'
4
+ import { padToWidth, textWidth, truncate } from '../../core/text.ts'
5
+ import { SelectableText } from '../selection.tsx'
6
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
7
+ import type { PaintArgs } from './types.ts'
8
+ import type { DialogItem } from '../dialog/items.ts'
9
+
10
+ type CheckboxItem = Extract<DialogItem, { type: 'checkbox' }>
11
+
12
+ function paintCheckbox(item: CheckboxItem, paint: PaintArgs<CheckboxItem>) {
13
+ const { width, y, x, focused } = paint
14
+ const label = padToWidth(truncate(item.label, width - 2), width - 2)
15
+ const check = item.checked ? ` ${glyphs.tick}` : ' '
16
+ if (focused) {
17
+ return (
18
+ <Box>
19
+ <SelectableText y={y} col={x} text={label} inverse />
20
+ <SelectableText y={y} col={x + width - 2} text={check} inverse />
21
+ </Box>
22
+ )
23
+ }
24
+ return (
25
+ <Box>
26
+ <SelectableText y={y} col={x} text={label} />
27
+ {item.checked && (
28
+ <SelectableText y={y} col={x + width - 2} text={` ${glyphs.tick}`} color={COLORS.success} />
29
+ )}
30
+ </Box>
31
+ )
32
+ }
33
+
34
+ registerWidget<CheckboxItem>('checkbox', {
35
+ selectable: true,
36
+ height() {
37
+ return 1
38
+ },
39
+ paintWidth(item) {
40
+ return textWidth(item.label)
41
+ },
42
+ searchTexts(item) {
43
+ return [item.label]
44
+ },
45
+ render(paint) {
46
+ return paintCheckbox(paint.item, paint)
47
+ },
48
+ onEnter(item) {
49
+ item.onConfirm()
50
+ return true
51
+ },
52
+ onSpace(item) {
53
+ item.onToggle()
54
+ },
55
+ activate(item) {
56
+ item.onConfirm()
57
+ },
58
+ }, { order: BUILTIN_WIDGET_ORDER })
@@ -0,0 +1,36 @@
1
+ import { Box, Text } from 'ink'
2
+ import { COLORS } from '../../theme.ts'
3
+ import { textWidth } from '../../core/text.ts'
4
+ import { SelectableText } from '../selection.tsx'
5
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
6
+ import type { DialogItem } from '../dialog/items.ts'
7
+
8
+ type HeaderItem = Extract<DialogItem, { type: 'header' }>
9
+
10
+ registerWidget<HeaderItem>('header', {
11
+ selectable: false,
12
+ height(item) {
13
+ return item.leadingBlank === true ? 2 : 1
14
+ },
15
+ paintWidth(item) {
16
+ return textWidth(item.label)
17
+ },
18
+ onEnter() {
19
+ return true
20
+ },
21
+ render({ item, y, x, clip }) {
22
+ const lead = item.leadingBlank === true && clip === 0 ? 1 : 0
23
+ const label = (
24
+ <SelectableText y={y + lead} col={x} text={item.label} color={COLORS.sectionHeader} bold />
25
+ )
26
+ if (lead === 0) return label
27
+ return (
28
+ <Box flexDirection="column">
29
+ <Box height={1}>
30
+ <Text> </Text>
31
+ </Box>
32
+ {label}
33
+ </Box>
34
+ )
35
+ },
36
+ }, { order: BUILTIN_WIDGET_ORDER })
@@ -0,0 +1,8 @@
1
+ import './header.tsx'
2
+ import './actions.tsx'
3
+ import './input.tsx'
4
+ import './select.tsx'
5
+ import './button.tsx'
6
+ import './checkbox.tsx'
7
+ import './search.tsx'
8
+ import './static.tsx'
@@ -0,0 +1,76 @@
1
+ import { Box, Text } from 'ink'
2
+ import { COLORS } from '../../theme.ts'
3
+ import { locToPoint, textWidth, wrapLines } from '../../core/text.ts'
4
+ import { caretNonceBold, caretNonceColor, caretNonceText } from '../../core/caret-nonce.ts'
5
+ import { SelectableText } from '../selection.tsx'
6
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
7
+ import { INPUT_MAX_ROWS } from '../dialog/sizes.ts'
8
+ import type { DialogItem } from '../dialog/items.ts'
9
+
10
+ type InputItem = Extract<DialogItem, { type: 'input' }>
11
+
12
+ function scrollStart(lines: number, caretRow: number): number {
13
+ return Math.min(Math.max(caretRow - (INPUT_MAX_ROWS - 1), 0), Math.max(0, lines - INPUT_MAX_ROWS))
14
+ }
15
+
16
+ registerWidget<InputItem>('input', {
17
+ selectable: true,
18
+ editable: true,
19
+ height(item, width) {
20
+ return 2 + Math.min(wrapLines(item.value, width).length, INPUT_MAX_ROWS)
21
+ },
22
+ paintWidth(item) {
23
+ return textWidth(item.label)
24
+ },
25
+ caret(item, cursor, width) {
26
+ const point = locToPoint(item.value, width, cursor)
27
+ const lines = wrapLines(item.value, width).length
28
+ return { dy: 1 + point.row - scrollStart(lines, point.row), dx: point.col }
29
+ },
30
+ render({ item, focused, cursor, width, y, x, clip }) {
31
+ const lines = wrapLines(item.value, width)
32
+ const shown = Math.min(lines.length, INPUT_MAX_ROWS)
33
+ const point = cursor === undefined ? null : locToPoint(item.value, width, cursor)
34
+ const first = point === null ? 0 : scrollStart(lines.length, point.row)
35
+ const parts = []
36
+ if (clip === 0) {
37
+ parts.push(
38
+ <Box key="label" height={1}>
39
+ <SelectableText y={y} col={x} text={item.label} color={focused ? undefined : COLORS.dialogHintText} />
40
+ </Box>,
41
+ )
42
+ }
43
+ for (let index = 0; index < shown; index++) {
44
+ if (1 + index < clip) continue
45
+ const currentY = y + 1 + index - clip
46
+ parts.push(
47
+ <Box key={index} width={width} height={1} backgroundColor={COLORS.dialogInputBackground}>
48
+ <SelectableText y={currentY} col={x} text={lines[first + index]!} />
49
+ </Box>,
50
+ )
51
+ }
52
+ parts.push(
53
+ <Box key="pad" height={1}>
54
+ <Text
55
+ color={focused && cursor !== undefined ? caretNonceColor(cursor) : undefined}
56
+ bold={focused && cursor !== undefined ? caretNonceBold(cursor) : undefined}
57
+ >
58
+ {focused && cursor !== undefined ? caretNonceText(cursor) : ' '}
59
+ </Text>
60
+ </Box>,
61
+ )
62
+ return <Box flexDirection="column">{parts}</Box>
63
+ },
64
+ onLeftRight(item, direction, api) {
65
+ if (direction === -1 && api.cursor > 0) api.setCursor(api.cursor - 1)
66
+ if (direction === 1 && api.cursor < item.value.length) api.setCursor(api.cursor + 1)
67
+ return true
68
+ },
69
+ onEnter(item) {
70
+ if (item.onEnter !== undefined) {
71
+ item.onEnter()
72
+ return true
73
+ }
74
+ return false
75
+ },
76
+ }, { order: BUILTIN_WIDGET_ORDER })
@@ -0,0 +1,32 @@
1
+ import { createElement } from 'react'
2
+ import { Text } from 'ink'
3
+ import { keyedRegistry } from '../../kernel/registry.ts'
4
+ import type { DialogItem } from '../dialog/items.ts'
5
+ import type { WidgetDef } from '../../contract/index.ts'
6
+
7
+ type AnyWidgetDef = WidgetDef<DialogItem>
8
+
9
+ const widgets = keyedRegistry<AnyWidgetDef>()
10
+
11
+ function fallbackWidget(type: string): AnyWidgetDef {
12
+ return {
13
+ height: () => 1,
14
+ paintWidth: () => 24,
15
+ render: ({ item }: { item: DialogItem }) => {
16
+ const label = (item as { label?: unknown }).label
17
+ const text = typeof label === 'string' && label !== '' ? `${type}: ${label}` : `${type}: (unknown item)`
18
+ return createElement(Text, { dimColor: true }, text)
19
+ },
20
+ } as unknown as AnyWidgetDef
21
+ }
22
+
23
+ export function registerWidget<I extends { type: string }>(type: I['type'], def: WidgetDef<I>, options?: { order?: number }): () => void {
24
+ return widgets.register(type, def as unknown as AnyWidgetDef, options)
25
+ }
26
+
27
+ /** Builtin widgets register as high-order fallback layers; plugin widgets override by default. */
28
+ export const BUILTIN_WIDGET_ORDER = 500
29
+
30
+ export function widgetOf(type: DialogItem['type']): AnyWidgetDef {
31
+ return widgets.get(type) ?? fallbackWidget(type)
32
+ }
@@ -0,0 +1,59 @@
1
+ import { Box, Text } from 'ink'
2
+ import { COLORS } from '../../theme.ts'
3
+ import { caretScrollStart, padToWidth, textWidth, truncate } from '../../core/text.ts'
4
+ import { SelectableText } from '../selection.tsx'
5
+ import { caretNonceBold, caretNonceColor, caretNonceText } from '../../core/caret-nonce.ts'
6
+ import { BUILTIN_WIDGET_ORDER, registerWidget } from './registry.ts'
7
+ import type { DialogItem } from '../dialog/items.ts'
8
+
9
+ type SearchItem = Extract<DialogItem, { type: 'search' }>
10
+
11
+ export function searchTextOf(item: SearchItem): string {
12
+ return item.value === '' ? 'Search' : item.value
13
+ }
14
+
15
+ registerWidget<SearchItem>('search', {
16
+ selectable: true,
17
+ editable: true,
18
+ height() {
19
+ return 2
20
+ },
21
+ paintWidth(item) {
22
+ return textWidth(searchTextOf(item))
23
+ },
24
+ onLeftRight(item, direction, api) {
25
+ const next = Math.max(0, Math.min(item.value.length, api.cursor + direction))
26
+ if (next === api.cursor) return false
27
+ api.setCursor(next)
28
+ return true
29
+ },
30
+ render({ item, cursor, width, y, x, clip }) {
31
+ const isEmpty = item.value === ''
32
+ const start = caretScrollStart(item.value, cursor ?? item.value.length, width)
33
+ const text = isEmpty ? 'Search' : truncate(item.value.slice(start), width)
34
+ if (clip !== 0) {
35
+ return (
36
+ <Box flexDirection="column">
37
+ <Box height={1}>
38
+ <Text> </Text>
39
+ </Box>
40
+ </Box>
41
+ )
42
+ }
43
+ return (
44
+ <Box flexDirection="column">
45
+ <Box width={width} backgroundColor={COLORS.dialogInputBackground}>
46
+ <SelectableText y={y} col={x} text={padToWidth(text, width)} color={isEmpty ? COLORS.dialogHintText : undefined} />
47
+ </Box>
48
+ <Box height={1}>
49
+ <Text
50
+ color={cursor !== undefined ? caretNonceColor(cursor) : undefined}
51
+ bold={cursor !== undefined ? caretNonceBold(cursor) : undefined}
52
+ >
53
+ {cursor !== undefined ? caretNonceText(cursor) : ' '}
54
+ </Text>
55
+ </Box>
56
+ </Box>
57
+ )
58
+ },
59
+ }, { order: BUILTIN_WIDGET_ORDER })