@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.
- package/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- 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,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 })
|