@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,386 @@
|
|
|
1
|
+
import { Box, Text, useStdout } from 'ink'
|
|
2
|
+
import { createContext, useContext } from 'react'
|
|
3
|
+
import { useCaret } from '../hooks/use-caret.ts'
|
|
4
|
+
import type { ReactNode, Ref } from 'react'
|
|
5
|
+
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
|
|
6
|
+
import { COLORS } from '../../theme.ts'
|
|
7
|
+
import { writeCursorShape } from '../../terminal/cursor-shape.ts'
|
|
8
|
+
import { textWidth, colToCharIndex, caretScrollStart, truncate } from '../../core/text.ts'
|
|
9
|
+
import { SelectableText } from '../selection.tsx'
|
|
10
|
+
import { Region } from '../region.tsx'
|
|
11
|
+
import { renderRow } from './dialog-item.tsx'
|
|
12
|
+
import { adjustScroll, hitRowIndex, rowHeight, rowTopOffset, wrapFooter, wrapStatusLines } from './geometry.ts'
|
|
13
|
+
import type { DialogFooterLine } from './geometry.ts'
|
|
14
|
+
import { ERROR_MAX_ROWS } from './sizes.ts'
|
|
15
|
+
import { asTextItem, clampFocus, filterRowsWithHeaders, focusedItem, isSelectableRow, selectableSpan, snapRow } from './items.ts'
|
|
16
|
+
import type { DialogFocus, DialogItem, DialogRow } from './items.ts'
|
|
17
|
+
import { applyNavigation, useDialogInput } from './use-dialog-input.ts'
|
|
18
|
+
import type { DialogLiveState } from './use-dialog-input.ts'
|
|
19
|
+
import { widgetOf } from '../widgets/registry.ts'
|
|
20
|
+
import type { ClickActions, ClickHit } from '../widgets/types.ts'
|
|
21
|
+
import type { WindowHandle } from '../../contract/index.ts'
|
|
22
|
+
|
|
23
|
+
export * from './items.ts'
|
|
24
|
+
export * from './geometry.ts'
|
|
25
|
+
|
|
26
|
+
export interface DialogProps {
|
|
27
|
+
width: number
|
|
28
|
+
maxHeight: number
|
|
29
|
+
title?: string
|
|
30
|
+
rows: DialogRow[]
|
|
31
|
+
footer?: DialogFooterLine[]
|
|
32
|
+
errors?: DialogFooterLine[]
|
|
33
|
+
onClose: () => void
|
|
34
|
+
search?: boolean
|
|
35
|
+
searchRight?: boolean
|
|
36
|
+
centerScroll?: boolean
|
|
37
|
+
onCtrlA?(focused: DialogItem | undefined): boolean
|
|
38
|
+
onCtrlD?(focused: DialogItem | undefined): boolean
|
|
39
|
+
onCtrlE?(focused: DialogItem | undefined): boolean
|
|
40
|
+
onActivity?(): void
|
|
41
|
+
ref?: Ref<DialogHandle>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DialogHandle = WindowHandle
|
|
45
|
+
|
|
46
|
+
export const CloseGuardContext = createContext(false)
|
|
47
|
+
|
|
48
|
+
export function Dialog({
|
|
49
|
+
width,
|
|
50
|
+
maxHeight,
|
|
51
|
+
title,
|
|
52
|
+
rows,
|
|
53
|
+
footer,
|
|
54
|
+
errors,
|
|
55
|
+
onClose,
|
|
56
|
+
search = false,
|
|
57
|
+
searchRight = false,
|
|
58
|
+
centerScroll = false,
|
|
59
|
+
onCtrlA,
|
|
60
|
+
onCtrlD,
|
|
61
|
+
onCtrlE,
|
|
62
|
+
onActivity,
|
|
63
|
+
ref,
|
|
64
|
+
}: DialogProps) {
|
|
65
|
+
const { stdout } = useStdout()
|
|
66
|
+
const { setCursorPosition } = useCaret()
|
|
67
|
+
const closeGuarded = useContext(CloseGuardContext)
|
|
68
|
+
const columns = stdout?.columns ?? 80
|
|
69
|
+
const totalRows = stdout?.rows ?? 24
|
|
70
|
+
const [scrollTop, setScrollTop] = useState(0)
|
|
71
|
+
const [cursor, setCursor] = useState(0)
|
|
72
|
+
const cursorSyncRef = useRef<{ row: number; col: number; type: string | undefined }>({ row: -1, col: -1, type: undefined })
|
|
73
|
+
const [searchValue, setSearchValue] = useState('')
|
|
74
|
+
const [carouselPress, setCarouselPress] = useState<{ row: number; side: 'left' | 'right' } | null>(null)
|
|
75
|
+
const carouselPressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
76
|
+
const [errorScrollTop, setErrorScrollTop] = useState(0)
|
|
77
|
+
const handleSearch = (value: string) => {
|
|
78
|
+
setSearchValue(value)
|
|
79
|
+
setScrollTop(0)
|
|
80
|
+
}
|
|
81
|
+
const searchRow: DialogRow = { items: [{ type: 'search', value: searchValue, onChange: handleSearch }] }
|
|
82
|
+
const filteredRows = search ? filterRowsWithHeaders(rows, searchValue, searchRight) : rows
|
|
83
|
+
const displayRows = search ? [searchRow, ...filteredRows] : rows
|
|
84
|
+
const contentRows = search ? filteredRows : rows
|
|
85
|
+
const [focus, setFocus] = useState<DialogFocus>(() => ({ row: snapRow(search ? [searchRow, ...rows] : rows, search ? 1 : 0), col: 0 }))
|
|
86
|
+
const widthCells = width <= 1 ? Math.floor(columns * width) : width
|
|
87
|
+
const windowWidth = Math.min(Math.max(1, widthCells), columns)
|
|
88
|
+
const contentWidth = Math.max(1, windowWidth - 4)
|
|
89
|
+
const fixedHeight = search ? rowHeight(searchRow, contentWidth) : 0
|
|
90
|
+
const focusMinRow = search ? 1 : 0
|
|
91
|
+
const focusMaxRow = Math.max(0, displayRows.length - 1)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setFocus(current => {
|
|
94
|
+
const next = clampFocus(displayRows, current, focusMinRow, focusMaxRow)
|
|
95
|
+
return next.row === current.row && next.col === current.col ? current : next
|
|
96
|
+
})
|
|
97
|
+
}, [displayRows, focusMinRow, focusMaxRow])
|
|
98
|
+
const errorKey = (errors ?? []).map(line => line.text).join('\n')
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setErrorScrollTop(0)
|
|
101
|
+
}, [errorKey])
|
|
102
|
+
const titleLines = title === undefined ? 0 : 2
|
|
103
|
+
const footerRows = wrapFooter(footer, contentWidth)
|
|
104
|
+
const extraHeight = footerRows.length > 0 ? 1 + footerRows.length : 0
|
|
105
|
+
const desired = displayRows.reduce((sum, row) => sum + rowHeight(row, contentWidth), 0) + titleLines + extraHeight + 2
|
|
106
|
+
const maxRows = Math.max(1, maxHeight <= 1 ? Math.floor(totalRows * maxHeight) : maxHeight)
|
|
107
|
+
const windowHeight = Math.min(desired, maxRows, totalRows)
|
|
108
|
+
const contentHeight = Math.max(1, windowHeight - 2 - titleLines - extraHeight)
|
|
109
|
+
const viewportHeight = Math.max(1, contentHeight - fixedHeight)
|
|
110
|
+
const top = Math.max(0, Math.floor((totalRows - windowHeight) / 2))
|
|
111
|
+
const left = Math.max(0, Math.floor((columns - windowWidth) / 2))
|
|
112
|
+
const frameLeft = 2
|
|
113
|
+
const errorRows = wrapStatusLines(errors, contentWidth)
|
|
114
|
+
const availableBelow = Math.max(0, totalRows - (top + windowHeight))
|
|
115
|
+
const errorVisible = Math.max(0, Math.min(ERROR_MAX_ROWS, errorRows.length, availableBelow - 1))
|
|
116
|
+
const maxErrorScroll = Math.max(0, errorRows.length - errorVisible)
|
|
117
|
+
const errorScroll = Math.min(errorScrollTop, maxErrorScroll)
|
|
118
|
+
const headerBottom = 1 + titleLines
|
|
119
|
+
const contentTop = headerBottom + fixedHeight
|
|
120
|
+
const safeFocus: DialogFocus = {
|
|
121
|
+
row: Math.min(Math.max(focus.row, 0), focusMaxRow),
|
|
122
|
+
col: Math.min(Math.max(focus.col, 0), selectableSpan(displayRows[focus.row])),
|
|
123
|
+
}
|
|
124
|
+
const contentRowsHeight = contentRows.reduce((sum, row) => sum + rowHeight(row, contentWidth), 0)
|
|
125
|
+
const maxScroll = Math.max(0, contentRowsHeight - viewportHeight)
|
|
126
|
+
const scroll = adjustScroll(contentRows, { row: Math.max(0, safeFocus.row - (search ? 1 : 0)), col: 0 }, Math.min(scrollTop, maxScroll), viewportHeight, contentWidth)
|
|
127
|
+
const live = useRef<DialogLiveState>({
|
|
128
|
+
rows: displayRows,
|
|
129
|
+
contentRows,
|
|
130
|
+
focus,
|
|
131
|
+
scrollTop: scroll,
|
|
132
|
+
cursor,
|
|
133
|
+
value: '',
|
|
134
|
+
searchValue,
|
|
135
|
+
viewportHeight,
|
|
136
|
+
focusMinRow,
|
|
137
|
+
focusMaxRow,
|
|
138
|
+
contentWidth,
|
|
139
|
+
top,
|
|
140
|
+
windowHeight,
|
|
141
|
+
})
|
|
142
|
+
live.current.rows = displayRows
|
|
143
|
+
live.current.contentRows = contentRows
|
|
144
|
+
live.current.focus = focus
|
|
145
|
+
live.current.scrollTop = scroll
|
|
146
|
+
live.current.searchValue = searchValue
|
|
147
|
+
live.current.viewportHeight = viewportHeight
|
|
148
|
+
live.current.focusMinRow = focusMinRow
|
|
149
|
+
live.current.focusMaxRow = focusMaxRow
|
|
150
|
+
live.current.contentWidth = contentWidth
|
|
151
|
+
live.current.top = top
|
|
152
|
+
live.current.windowHeight = windowHeight
|
|
153
|
+
const errorLive = useRef({ top: 0, height: 0, maxScroll: 0 })
|
|
154
|
+
errorLive.current = { top: top + windowHeight, height: errorVisible + 1, maxScroll: maxErrorScroll }
|
|
155
|
+
const current = focusedItem(displayRows[safeFocus.row], safeFocus.col)
|
|
156
|
+
const currentText = current === undefined ? null : asTextItem(current)
|
|
157
|
+
live.current.value = currentText?.value ?? ''
|
|
158
|
+
const cursorSynced = cursorSyncRef.current.row === safeFocus.row
|
|
159
|
+
&& cursorSyncRef.current.col === safeFocus.col
|
|
160
|
+
&& cursorSyncRef.current.type === current?.type
|
|
161
|
+
const effectiveCursor = cursorSynced || currentText === null || currentText.type === 'search'
|
|
162
|
+
? cursor
|
|
163
|
+
: currentText.value.length
|
|
164
|
+
live.current.cursor = effectiveCursor
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
cursorSyncRef.current = { row: safeFocus.row, col: safeFocus.col, type: current?.type }
|
|
167
|
+
if (currentText !== null && currentText.type !== 'search') {
|
|
168
|
+
setCursor(currentText.value.length)
|
|
169
|
+
}
|
|
170
|
+
}, [current?.type, safeFocus.row, safeFocus.col, displayRows.length])
|
|
171
|
+
const editingFormInput = current !== undefined && current.type !== 'search' && asTextItem(current) !== null
|
|
172
|
+
if (search && !editingFormInput) {
|
|
173
|
+
const caret = Math.min(Math.max(0, cursor), searchValue.length)
|
|
174
|
+
const start = caretScrollStart(searchValue, caret, contentWidth)
|
|
175
|
+
setCursorPosition({
|
|
176
|
+
x: left + frameLeft + textWidth(searchValue.slice(start, caret)),
|
|
177
|
+
y: top + headerBottom,
|
|
178
|
+
})
|
|
179
|
+
} else {
|
|
180
|
+
const caretOffset = current === undefined ? undefined : widgetOf(current.type).caret?.(current, effectiveCursor, contentWidth)
|
|
181
|
+
if (caretOffset === undefined) {
|
|
182
|
+
setCursorPosition(undefined)
|
|
183
|
+
} else {
|
|
184
|
+
const contentIndex = Math.max(0, safeFocus.row - (search ? 1 : 0))
|
|
185
|
+
const rowOffset = rowTopOffset(contentRows, contentIndex, contentWidth)
|
|
186
|
+
setCursorPosition({
|
|
187
|
+
x: left + frameLeft + caretOffset.dx,
|
|
188
|
+
y: top + contentTop + rowOffset + caretOffset.dy - scroll,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (search && !editingFormInput) {
|
|
194
|
+
writeCursorShape('beam')
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
const beam = current !== undefined && widgetOf(current.type).caret !== undefined
|
|
198
|
+
writeCursorShape(beam ? 'beam' : 'block')
|
|
199
|
+
}, [current?.type, safeFocus.row, safeFocus.col])
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
return () => {
|
|
202
|
+
writeCursorShape('reset')
|
|
203
|
+
clearTimeout(carouselPressTimer.current)
|
|
204
|
+
}
|
|
205
|
+
}, [])
|
|
206
|
+
useDialogInput({
|
|
207
|
+
live,
|
|
208
|
+
search,
|
|
209
|
+
closeGuarded,
|
|
210
|
+
centerScroll,
|
|
211
|
+
onClose,
|
|
212
|
+
onCtrlA,
|
|
213
|
+
onCtrlD,
|
|
214
|
+
onCtrlE,
|
|
215
|
+
onActivity,
|
|
216
|
+
requestSearch: handleSearch,
|
|
217
|
+
setFocus,
|
|
218
|
+
setScrollTop,
|
|
219
|
+
setCursor,
|
|
220
|
+
})
|
|
221
|
+
const flashCarousel = (row: number, side: 'left' | 'right') => {
|
|
222
|
+
setCarouselPress({ row, side })
|
|
223
|
+
clearTimeout(carouselPressTimer.current)
|
|
224
|
+
carouselPressTimer.current = setTimeout(() => setCarouselPress(null), 120)
|
|
225
|
+
}
|
|
226
|
+
useImperativeHandle(ref, () => ({
|
|
227
|
+
wheelAt(y, dir) {
|
|
228
|
+
onActivity?.()
|
|
229
|
+
if (y < live.current.top || y >= live.current.top + live.current.windowHeight) {
|
|
230
|
+
const error = errorLive.current
|
|
231
|
+
if (error.height > 1 && y >= error.top && y < error.top + error.height) {
|
|
232
|
+
setErrorScrollTop(current => Math.max(0, Math.min(current + dir, error.maxScroll)))
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
applyNavigation(live.current, dir === -1 ? 'up' : 'down', search, { setFocus, setScrollTop }, centerScroll)
|
|
238
|
+
return true
|
|
239
|
+
},
|
|
240
|
+
clickAt(y, x) {
|
|
241
|
+
onActivity?.()
|
|
242
|
+
if (y < top || y >= top + windowHeight || x < left || x >= left + windowWidth) return
|
|
243
|
+
if (search) {
|
|
244
|
+
const searchTop = top + headerBottom
|
|
245
|
+
if (y >= searchTop && y < searchTop + fixedHeight) {
|
|
246
|
+
const clickCaret = safeFocus.row === 0 ? Math.min(cursor, searchValue.length) : searchValue.length
|
|
247
|
+
const start = caretScrollStart(searchValue, clickCaret, contentWidth)
|
|
248
|
+
const visible = truncate(searchValue.slice(start), contentWidth)
|
|
249
|
+
const col = Math.max(0, Math.min(x - (left + frameLeft), textWidth(visible)))
|
|
250
|
+
setFocus({ row: 0, col: 0 })
|
|
251
|
+
setCursor(Math.min(searchValue.length, start + colToCharIndex(visible, col)))
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const rowIndex = hitRowIndex(y, top + fixedHeight, titleLines, contentRows, scroll, contentWidth)
|
|
256
|
+
if (rowIndex === null) return
|
|
257
|
+
const rowSpec = contentRows[rowIndex]
|
|
258
|
+
if (!isSelectableRow(rowSpec)) return
|
|
259
|
+
const displayRow = Math.min(Math.max(rowIndex + (search ? 1 : 0), focusMinRow), focusMaxRow)
|
|
260
|
+
const rowOffset = rowTopOffset(contentRows, rowIndex, contentWidth)
|
|
261
|
+
const hit: ClickHit = {
|
|
262
|
+
localX: x - (left + frameLeft),
|
|
263
|
+
localY: y - (top + contentTop + rowOffset - scroll),
|
|
264
|
+
width: contentWidth,
|
|
265
|
+
}
|
|
266
|
+
const clickActions: ClickActions = {
|
|
267
|
+
focus: col => setFocus({ row: displayRow, col }),
|
|
268
|
+
flash: side => flashCarousel(rowIndex, side),
|
|
269
|
+
}
|
|
270
|
+
for (const item of rowSpec?.items ?? []) {
|
|
271
|
+
if (widgetOf(item.type).onClick?.(item, hit, clickActions) === true) return
|
|
272
|
+
}
|
|
273
|
+
if (safeFocus.row === displayRow && rowSpec !== undefined) {
|
|
274
|
+
const first = rowSpec.items[0]
|
|
275
|
+
if (first !== undefined) {
|
|
276
|
+
const activate = widgetOf(first.type).activate
|
|
277
|
+
if (activate !== undefined) {
|
|
278
|
+
activate(first)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
setFocus({ row: displayRow, col: 0 })
|
|
284
|
+
const contentRow = Math.max(0, Math.min(rowIndex, contentRows.length - 1))
|
|
285
|
+
setScrollTop(adjustScroll(contentRows, { row: contentRow, col: 0 }, scroll, viewportHeight, contentWidth))
|
|
286
|
+
},
|
|
287
|
+
}))
|
|
288
|
+
const visibleRows: ReactNode[] = []
|
|
289
|
+
let offset = 0
|
|
290
|
+
for (let i = 0; i < contentRows.length; i++) {
|
|
291
|
+
const height = rowHeight(contentRows[i]!, contentWidth)
|
|
292
|
+
if (offset + height <= scroll) {
|
|
293
|
+
offset += height
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (offset >= scroll + viewportHeight) break
|
|
297
|
+
const clip = Math.max(0, scroll - offset)
|
|
298
|
+
const rel = Math.max(0, offset - scroll)
|
|
299
|
+
const baseY = contentTop + rel
|
|
300
|
+
const focused = safeFocus.row === i + (search ? 1 : 0)
|
|
301
|
+
visibleRows.push(
|
|
302
|
+
<Box key={`row-${i}`} position="absolute" top={rel} left={0} width={contentWidth}>
|
|
303
|
+
{renderRow(contentRows[i], focused, contentWidth, baseY, frameLeft, carouselPress?.row === i ? carouselPress.side : null, focused ? safeFocus.col : 0, clip, focused ? effectiveCursor : undefined)}
|
|
304
|
+
</Box>,
|
|
305
|
+
)
|
|
306
|
+
offset += height
|
|
307
|
+
}
|
|
308
|
+
return (
|
|
309
|
+
<Region x={left} y={top}>
|
|
310
|
+
<Box
|
|
311
|
+
position="absolute"
|
|
312
|
+
top={top}
|
|
313
|
+
left={left}
|
|
314
|
+
width={windowWidth}
|
|
315
|
+
height={windowHeight}
|
|
316
|
+
flexDirection="column"
|
|
317
|
+
backgroundColor={COLORS.dialogBackground}
|
|
318
|
+
paddingLeft={2}
|
|
319
|
+
paddingRight={2}
|
|
320
|
+
paddingTop={1}
|
|
321
|
+
paddingBottom={1}
|
|
322
|
+
>
|
|
323
|
+
{title !== undefined && (
|
|
324
|
+
<>
|
|
325
|
+
<Box justifyContent="center">
|
|
326
|
+
<SelectableText y={1} col={frameLeft + Math.max(0, Math.floor((contentWidth - textWidth(title)) / 2))} text={title} />
|
|
327
|
+
</Box>
|
|
328
|
+
<Box height={1}>
|
|
329
|
+
<Text> </Text>
|
|
330
|
+
</Box>
|
|
331
|
+
</>
|
|
332
|
+
)}
|
|
333
|
+
<Box flexDirection="column">
|
|
334
|
+
{search && (
|
|
335
|
+
<Box flexDirection="column">
|
|
336
|
+
{renderRow(searchRow, safeFocus.row === 0, contentWidth, headerBottom, frameLeft, null, 0, 0, cursor)}
|
|
337
|
+
</Box>
|
|
338
|
+
)}
|
|
339
|
+
<Box flexDirection="column" height={viewportHeight} overflow="hidden">
|
|
340
|
+
{visibleRows}
|
|
341
|
+
</Box>
|
|
342
|
+
</Box>
|
|
343
|
+
{footerRows.length > 0 && (
|
|
344
|
+
<>
|
|
345
|
+
<Box height={1}>
|
|
346
|
+
<Text> </Text>
|
|
347
|
+
</Box>
|
|
348
|
+
{footerRows.map((line, index) => (
|
|
349
|
+
<SelectableText
|
|
350
|
+
key={index}
|
|
351
|
+
y={windowHeight - footerRows.length + index - 1}
|
|
352
|
+
col={frameLeft}
|
|
353
|
+
text={line.text}
|
|
354
|
+
color={line.color}
|
|
355
|
+
/>
|
|
356
|
+
))}
|
|
357
|
+
</>
|
|
358
|
+
)}
|
|
359
|
+
</Box>
|
|
360
|
+
{errorVisible > 0 && (
|
|
361
|
+
<Box
|
|
362
|
+
position="absolute"
|
|
363
|
+
top={top + windowHeight}
|
|
364
|
+
left={left}
|
|
365
|
+
width={windowWidth}
|
|
366
|
+
height={errorVisible + 1}
|
|
367
|
+
flexDirection="column"
|
|
368
|
+
backgroundColor={COLORS.dialogBackground}
|
|
369
|
+
paddingLeft={2}
|
|
370
|
+
paddingRight={2}
|
|
371
|
+
paddingBottom={1}
|
|
372
|
+
>
|
|
373
|
+
{errorRows.slice(errorScroll, errorScroll + errorVisible).map((line, index) => (
|
|
374
|
+
<SelectableText
|
|
375
|
+
key={errorScroll + index}
|
|
376
|
+
y={windowHeight + index}
|
|
377
|
+
col={frameLeft}
|
|
378
|
+
text={line.text}
|
|
379
|
+
color={line.color}
|
|
380
|
+
/>
|
|
381
|
+
))}
|
|
382
|
+
</Box>
|
|
383
|
+
)}
|
|
384
|
+
</Region>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Ref } from 'react'
|
|
2
|
+
import { errorLine } from './status-lines.ts'
|
|
3
|
+
import type { EffortSummary } from '../../chat/efforts.ts'
|
|
4
|
+
import { useAsyncAction } from '../hooks/use-async-action.ts'
|
|
5
|
+
import { ListDialog } from './list-dialog.tsx'
|
|
6
|
+
import { DIALOG_WIDTH_MEDIUM, DIALOG_MAX_HEIGHT } from './sizes.ts'
|
|
7
|
+
import type { DialogHandle, DialogFooterLine } from './dialog.tsx'
|
|
8
|
+
|
|
9
|
+
export interface EffortsApi {
|
|
10
|
+
listEfforts(): Promise<EffortSummary[]>
|
|
11
|
+
currentEffort(): string | undefined
|
|
12
|
+
selectEffort(id: string): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EffortDialogProps {
|
|
16
|
+
api: EffortsApi
|
|
17
|
+
onClose: () => void
|
|
18
|
+
ref?: Ref<DialogHandle>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function EffortDialog({ api, onClose, ref }: EffortDialogProps) {
|
|
23
|
+
const { error, run } = useAsyncAction()
|
|
24
|
+
const current = api.currentEffort()
|
|
25
|
+
const selectEffort = async (id: string) => {
|
|
26
|
+
const result = await run(() => api.selectEffort(id))
|
|
27
|
+
if (!result.ok) return
|
|
28
|
+
onClose()
|
|
29
|
+
}
|
|
30
|
+
const footerLines: DialogFooterLine[] = error === null ? [] : [errorLine(error)]
|
|
31
|
+
return (
|
|
32
|
+
<ListDialog
|
|
33
|
+
ref={ref}
|
|
34
|
+
title="reasoning effort"
|
|
35
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
36
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
37
|
+
load={api.listEfforts}
|
|
38
|
+
labelOf={effort => effort.name}
|
|
39
|
+
rightOf={effort => (effort.id === current ? 'current' : undefined)}
|
|
40
|
+
onSelect={effort => void selectEffort(effort.id)}
|
|
41
|
+
onClose={onClose}
|
|
42
|
+
errors={footerLines}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { textWidth, truncate, wrapLines } from '../../core/text.ts'
|
|
2
|
+
import { glyphs } from '../../terminal/glyphs.ts'
|
|
3
|
+
import type { DialogFocus, DialogRow } from './items.ts'
|
|
4
|
+
import { isSelectableRow } from './items.ts'
|
|
5
|
+
import { widgetOf } from '../widgets/registry.ts'
|
|
6
|
+
|
|
7
|
+
export const CAROUSEL_BUTTON_WIDTH = 3
|
|
8
|
+
|
|
9
|
+
const SELECT_BLOCK_WIDTH_RATIO = 0.6
|
|
10
|
+
const ACTION_EDGE_RATIO = 0.2
|
|
11
|
+
|
|
12
|
+
export interface DialogFooterLine {
|
|
13
|
+
text: string
|
|
14
|
+
color?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface FooterRenderLine {
|
|
18
|
+
text: string
|
|
19
|
+
color?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FOOTER_MAX_LINES_PER_ENTRY = 2
|
|
23
|
+
|
|
24
|
+
function wrapFooter(footer: DialogFooterLine[] | undefined, width: number): FooterRenderLine[] {
|
|
25
|
+
if (footer === undefined) return []
|
|
26
|
+
const rows: FooterRenderLine[] = []
|
|
27
|
+
for (const line of footer) {
|
|
28
|
+
if (line.text === '') continue
|
|
29
|
+
const wrapped = wrapLines(line.text, width)
|
|
30
|
+
const capped = wrapped.slice(0, FOOTER_MAX_LINES_PER_ENTRY)
|
|
31
|
+
if (wrapped.length > FOOTER_MAX_LINES_PER_ENTRY) {
|
|
32
|
+
capped[1] = truncate(capped[1] ?? '', Math.max(1, width - glyphs.ellipsis.length)) + glyphs.ellipsis
|
|
33
|
+
}
|
|
34
|
+
for (const text of capped) rows.push({ text, color: line.color })
|
|
35
|
+
}
|
|
36
|
+
return rows
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { wrapFooter }
|
|
40
|
+
|
|
41
|
+
export function wrapStatusLines(lines: DialogFooterLine[] | undefined, width: number): FooterRenderLine[] {
|
|
42
|
+
if (lines === undefined) return []
|
|
43
|
+
const rows: FooterRenderLine[] = []
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (line.text === '') continue
|
|
46
|
+
for (const text of wrapLines(line.text, width)) rows.push({ text, color: line.color })
|
|
47
|
+
}
|
|
48
|
+
return rows
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rowHeight(row: DialogRow, width: number): number {
|
|
52
|
+
let height = 1
|
|
53
|
+
for (const item of row.items) {
|
|
54
|
+
const itemHeight = widgetOf(item.type).height(item, width)
|
|
55
|
+
if (itemHeight > height) height = itemHeight
|
|
56
|
+
}
|
|
57
|
+
return height
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rowTopOffset(rows: DialogRow[], rowIndex: number, width: number): number {
|
|
61
|
+
let offset = 0
|
|
62
|
+
for (let i = 0; i < rowIndex; i++) offset += rowHeight(rows[i]!, width)
|
|
63
|
+
return offset
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rowBlockSpan(rows: DialogRow[], rowIndex: number, width: number): { top: number; bottom: number } {
|
|
67
|
+
const top = rowTopOffset(rows, rowIndex, width)
|
|
68
|
+
let blockTop = top
|
|
69
|
+
let bottom = top + rowHeight(rows[rowIndex] ?? { items: [] }, width) - 1
|
|
70
|
+
for (let i = rowIndex - 1; i >= 0 && !isSelectableRow(rows[i]); i--) blockTop -= rowHeight(rows[i]!, width)
|
|
71
|
+
for (let i = rowIndex + 1; i < rows.length && !isSelectableRow(rows[i]); i++) bottom += rowHeight(rows[i]!, width)
|
|
72
|
+
return { top: blockTop, bottom }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function adjustScroll(rows: DialogRow[], focus: DialogFocus, scrollTop: number, contentHeight: number, width: number): number {
|
|
76
|
+
const { top, bottom } = rowBlockSpan(rows, focus.row, width)
|
|
77
|
+
if (top < scrollTop) return top
|
|
78
|
+
if (bottom >= scrollTop + contentHeight) return Math.max(0, bottom - contentHeight + 1)
|
|
79
|
+
return scrollTop
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function listCenterScroll(rows: DialogRow[], focusRow: number, viewportHeight: number, width: number, direction: 'up' | 'down'): number {
|
|
83
|
+
const total = rows.reduce((sum, row) => sum + rowHeight(row, width), 0)
|
|
84
|
+
const maxScroll = Math.max(0, total - viewportHeight)
|
|
85
|
+
const anchor = direction === 'down' ? Math.floor(viewportHeight / 2) : Math.floor((viewportHeight - 1) / 2)
|
|
86
|
+
const top = rowTopOffset(rows, focusRow, width)
|
|
87
|
+
return Math.max(0, Math.min(top - anchor, maxScroll))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function hitRowIndex(y: number, top: number, titleLines: number, rows: DialogRow[], scrollTop = 0, width = Number.POSITIVE_INFINITY): number | null {
|
|
91
|
+
const localY = y - top - 1 - titleLines + scrollTop
|
|
92
|
+
let offset = 0
|
|
93
|
+
for (let i = 0; i < rows.length; i++) {
|
|
94
|
+
const height = rowHeight(rows[i]!, width)
|
|
95
|
+
if (localY >= offset && localY < offset + height) return i
|
|
96
|
+
offset += height
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface SelectBlock {
|
|
102
|
+
blockWidth: number
|
|
103
|
+
blockStart: number
|
|
104
|
+
text: string
|
|
105
|
+
leftPad: number
|
|
106
|
+
fullLeftPad: number
|
|
107
|
+
cursorX: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function selectBlock(contentWidth: number, value: string): SelectBlock {
|
|
111
|
+
const blockWidth = Math.max(3, Math.floor(contentWidth * SELECT_BLOCK_WIDTH_RATIO))
|
|
112
|
+
const innerWidth = Math.max(1, blockWidth - CAROUSEL_BUTTON_WIDTH * 2)
|
|
113
|
+
const text = truncate(value || '(none)', innerWidth)
|
|
114
|
+
const padding = innerWidth - textWidth(text)
|
|
115
|
+
const fullPadding = blockWidth - textWidth(text)
|
|
116
|
+
return {
|
|
117
|
+
blockWidth,
|
|
118
|
+
blockStart: contentWidth - blockWidth,
|
|
119
|
+
text,
|
|
120
|
+
leftPad: Math.floor(padding / 2),
|
|
121
|
+
fullLeftPad: Math.floor(fullPadding / 2),
|
|
122
|
+
cursorX: CAROUSEL_BUTTON_WIDTH + Math.floor(padding / 2) + textWidth(text),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function actionPositions(contentWidth: number, confirmLabel: string, cancelLabel: string): { confirmX: number; cancelX: number } {
|
|
127
|
+
const edge = Math.floor(contentWidth * ACTION_EDGE_RATIO)
|
|
128
|
+
const confirmX = Math.min(edge, Math.max(0, contentWidth - textWidth(confirmLabel)))
|
|
129
|
+
const cancelX = Math.max(confirmX + textWidth(confirmLabel), contentWidth - edge - textWidth(cancelLabel))
|
|
130
|
+
return { confirmX, cancelX }
|
|
131
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { widgetOf } from '../widgets/registry.ts'
|
|
2
|
+
import type { DialogItemKinds } from '../../contract/index.ts'
|
|
3
|
+
|
|
4
|
+
export interface DialogRow {
|
|
5
|
+
items: DialogItem[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type PluginDialogItem = DialogItemKinds[keyof DialogItemKinds]
|
|
9
|
+
|
|
10
|
+
export type DialogItem =
|
|
11
|
+
| { type: 'input'; label: string; value: string; onChange: (value: string) => void; onEnter?: () => void }
|
|
12
|
+
| { type: 'select'; label: string; value: string; options: string[]; onChange: (value: string) => void; onEnter?: () => void; spaced?: boolean }
|
|
13
|
+
| { type: 'search'; value: string; onChange: (value: string) => void }
|
|
14
|
+
| { type: 'button'; label: string; right?: string; rightColor?: string; onPress: () => void }
|
|
15
|
+
| { type: 'checkbox'; label: string; checked: boolean; onToggle: () => void; onConfirm: () => void }
|
|
16
|
+
| { type: 'header'; label: string; leadingBlank?: boolean }
|
|
17
|
+
| { type: 'static'; label: string }
|
|
18
|
+
| { type: 'actions'; confirmLabel: string; cancelLabel: string; onConfirm: () => void; onCancel: () => void }
|
|
19
|
+
| { type: string; data?: unknown }
|
|
20
|
+
| PluginDialogItem
|
|
21
|
+
|
|
22
|
+
export interface DialogFocus {
|
|
23
|
+
row: number
|
|
24
|
+
col: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isSelectableRow(row: DialogRow | undefined): boolean {
|
|
28
|
+
if (row === undefined) return false
|
|
29
|
+
return row.items.some(item => widgetOf(item.type).selectable === true)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function selectableSpan(row: DialogRow | undefined): number {
|
|
33
|
+
if (row === undefined) return 0
|
|
34
|
+
let stops = 0
|
|
35
|
+
for (const item of row.items) {
|
|
36
|
+
stops += widgetOf(item.type).stops?.(item) ?? 1
|
|
37
|
+
}
|
|
38
|
+
return Math.max(0, stops - 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function snapRow(rows: DialogRow[], row: number): number {
|
|
42
|
+
const count = rows.length
|
|
43
|
+
if (count === 0) return 0
|
|
44
|
+
const index = Math.min(Math.max(row, 0), count - 1)
|
|
45
|
+
if (isSelectableRow(rows[index])) return index
|
|
46
|
+
for (let down = index + 1; down < count; down++) {
|
|
47
|
+
if (isSelectableRow(rows[down])) return down
|
|
48
|
+
}
|
|
49
|
+
for (let up = index - 1; up >= 0; up--) {
|
|
50
|
+
if (isSelectableRow(rows[up])) return up
|
|
51
|
+
}
|
|
52
|
+
return index
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stepRow(rows: DialogRow[], from: number, delta: -1 | 1): number {
|
|
56
|
+
let row = from + delta
|
|
57
|
+
while (row >= 0 && row < rows.length && !isSelectableRow(rows[row])) row += delta
|
|
58
|
+
return row >= 0 && row < rows.length ? row : from
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function focusedItem(row: DialogRow | undefined, col: number): DialogItem | undefined {
|
|
62
|
+
if (row === undefined) return undefined
|
|
63
|
+
const only = row.items.length === 1 ? row.items[0] : undefined
|
|
64
|
+
if (only !== undefined && widgetOf(only.type).fullRowFocus === true) return only
|
|
65
|
+
return row.items[col]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { focusedItem }
|
|
69
|
+
|
|
70
|
+
export function moveFocus(rows: DialogRow[], focus: DialogFocus, key: 'up' | 'down' | 'left' | 'right'): DialogFocus {
|
|
71
|
+
switch (key) {
|
|
72
|
+
case 'up':
|
|
73
|
+
return { row: stepRow(rows, focus.row, -1), col: 0 }
|
|
74
|
+
case 'down':
|
|
75
|
+
return { row: stepRow(rows, focus.row, 1), col: 0 }
|
|
76
|
+
case 'left':
|
|
77
|
+
return { row: focus.row, col: Math.max(0, focus.col - 1) }
|
|
78
|
+
case 'right': {
|
|
79
|
+
const max = selectableSpan(rows[focus.row])
|
|
80
|
+
return { row: focus.row, col: Math.max(0, Math.min(max, focus.col + 1)) }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clampFocus(rows: DialogRow[], focus: DialogFocus, minRow: number, maxRow: number): DialogFocus {
|
|
86
|
+
const row = snapRow(rows, Math.min(Math.max(focus.row, minRow), maxRow))
|
|
87
|
+
return { row, col: Math.min(focus.col, selectableSpan(rows[row])) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type TextItem = Extract<DialogItem, { type: 'input' }> | Extract<DialogItem, { type: 'search' }>
|
|
91
|
+
|
|
92
|
+
export function asTextItem(item: DialogItem): TextItem | null {
|
|
93
|
+
return widgetOf(item.type).editable === true ? item as TextItem : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function filterRowsWithHeaders(rows: DialogRow[], query: string, searchRight: boolean): DialogRow[] {
|
|
97
|
+
const q = query.trim().toLowerCase()
|
|
98
|
+
if (q === '') return rows
|
|
99
|
+
const matches = (item: DialogItem): boolean => {
|
|
100
|
+
const texts = widgetOf(item.type).searchTexts?.(item, searchRight)
|
|
101
|
+
if (texts === undefined) return false
|
|
102
|
+
return texts.some(text => text.toLowerCase().includes(q))
|
|
103
|
+
}
|
|
104
|
+
const out: DialogRow[] = []
|
|
105
|
+
let pendingHeader: DialogRow | undefined
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (!isSelectableRow(row)) {
|
|
108
|
+
pendingHeader = row
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!row.items.some(matches)) continue
|
|
112
|
+
if (pendingHeader !== undefined) {
|
|
113
|
+
out.push(pendingHeader)
|
|
114
|
+
pendingHeader = undefined
|
|
115
|
+
}
|
|
116
|
+
out.push(row)
|
|
117
|
+
}
|
|
118
|
+
const first = out[0]
|
|
119
|
+
if (first !== undefined && first.items.length === 1 && first.items[0]?.type === 'header') {
|
|
120
|
+
const header = first.items[0] as { type: 'header'; label: string; leadingBlank?: boolean }
|
|
121
|
+
out[0] = { items: [{ ...header, leadingBlank: false }] }
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|