@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,352 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { pointerHandlerEntries, type PointerSession } from '../pointer/registry.ts'
|
|
3
|
+
import type { PointerHandlerContribution } from '../../contract/index.ts'
|
|
4
|
+
import { createBuiltinPointerHandler, type BuiltinPointerHandler } from '../pointer/builtins.ts'
|
|
5
|
+
import type { RefObject } from 'react'
|
|
6
|
+
import type { ScreenCapture } from '../../terminal/screen.ts'
|
|
7
|
+
import { createMouseController } from '../../terminal/mouse.ts'
|
|
8
|
+
import { inputContentContains, hintSpan, scrollbarColumn } from '../layout-service.ts'
|
|
9
|
+
import { clampFocusRow, toScreenSelection } from '../../model/selection.ts'
|
|
10
|
+
import type { LineSelection } from '../../model/selection.ts'
|
|
11
|
+
import type { ScrollSnapshot } from './use-scroll.ts'
|
|
12
|
+
import { rowInfoAt, rowCount, scrollbarGeometry } from '../message/layout.ts'
|
|
13
|
+
import { POINTER_BUILTIN_ORDER } from '../pointer/registry.ts'
|
|
14
|
+
import type { InputBarHandle } from '../input/input-bar.tsx'
|
|
15
|
+
import type { PanelPointerHandle } from '../panels/approval-panel.tsx'
|
|
16
|
+
import type { CommandHintState } from '../input/commands.ts'
|
|
17
|
+
import type { Message } from '../../model/message.ts'
|
|
18
|
+
|
|
19
|
+
const DRAG_SCROLL_INTERVAL_MS = 15
|
|
20
|
+
|
|
21
|
+
export interface HintRegion {
|
|
22
|
+
top: number
|
|
23
|
+
bottom: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hintRegion(rows: number, hint: CommandHintState | null, dialogOpen: boolean, inputHeight: number): HintRegion | null {
|
|
27
|
+
return hintSpan(rows, inputHeight, hint?.commands.length ?? 0, dialogOpen)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MouseSelectionOptions {
|
|
31
|
+
messages: Message[]
|
|
32
|
+
columns: number
|
|
33
|
+
rows: number
|
|
34
|
+
scrollTop: number
|
|
35
|
+
messageHeight: number
|
|
36
|
+
inputHeight: number
|
|
37
|
+
dialogOpen: boolean
|
|
38
|
+
hint: CommandHintState | null
|
|
39
|
+
screen?: ScreenCapture
|
|
40
|
+
inputHandle?: RefObject<InputBarHandle | null>
|
|
41
|
+
panelActive?: boolean
|
|
42
|
+
panelHandle?: RefObject<PanelPointerHandle | null>
|
|
43
|
+
getScroll(): ScrollSnapshot
|
|
44
|
+
onHintPress?(x: number, y: number): void
|
|
45
|
+
onHintDragStart?(x: number, y: number): void
|
|
46
|
+
onHintDragMove?(x: number, y: number): void
|
|
47
|
+
onHintRelease?(x: number, y: number, dragged: boolean): void
|
|
48
|
+
onDialogWheel?(y: number, dir: -1 | 1): boolean
|
|
49
|
+
onScroll(next: number): void
|
|
50
|
+
onToggleMessage(id: string): void
|
|
51
|
+
onDialogClick(y: number, x: number): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MouseSelectionState {
|
|
55
|
+
selection: LineSelection | null
|
|
56
|
+
messageAreaSelection: LineSelection | null
|
|
57
|
+
chromeSelection: LineSelection | null
|
|
58
|
+
setSelection(next: LineSelection | null | ((current: LineSelection | null) => LineSelection | null)): void
|
|
59
|
+
clearSelection(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useMouseSelection(options: MouseSelectionOptions): MouseSelectionState {
|
|
63
|
+
const { messages, columns, rows, scrollTop, messageHeight, inputHeight, dialogOpen, hint, screen, inputHandle, panelActive = false, panelHandle, getScroll, onHintPress, onHintDragStart, onHintDragMove, onHintRelease, onDialogWheel, onScroll, onToggleMessage, onDialogClick } = options
|
|
64
|
+
const [selection, setSelection] = useState<LineSelection | null>(null)
|
|
65
|
+
const messagesRef = useRef(messages)
|
|
66
|
+
const widthRef = useRef(columns)
|
|
67
|
+
const rowsRef = useRef(rows)
|
|
68
|
+
const scrollTopRef = useRef(scrollTop)
|
|
69
|
+
const messageHeightRef = useRef(messageHeight)
|
|
70
|
+
const inputHeightRef = useRef(inputHeight)
|
|
71
|
+
const dialogOpenRef = useRef(dialogOpen)
|
|
72
|
+
const hintStateRef = useRef<CommandHintState | null>(null)
|
|
73
|
+
const screenRef = useRef<ScreenCapture | undefined>(screen)
|
|
74
|
+
const inputHandleRef = useRef(inputHandle)
|
|
75
|
+
const panelActiveRef = useRef(panelActive)
|
|
76
|
+
const panelHandleRef = useRef(panelHandle)
|
|
77
|
+
const getScrollRef = useRef(getScroll)
|
|
78
|
+
const onHintPressRef = useRef(onHintPress)
|
|
79
|
+
const onHintDragStartRef = useRef(onHintDragStart)
|
|
80
|
+
const onHintDragMoveRef = useRef(onHintDragMove)
|
|
81
|
+
const onHintReleaseRef = useRef(onHintRelease)
|
|
82
|
+
const onDialogWheelRef = useRef(onDialogWheel)
|
|
83
|
+
const clickCandidateRef = useRef<{ messageId: string; y: number; x: number; moved: boolean } | null>(null)
|
|
84
|
+
const dialogClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
|
|
85
|
+
const panelClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
|
|
86
|
+
const inputClickCandidateRef = useRef<{ x: number; y: number } | null>(null)
|
|
87
|
+
const onScrollRef = useRef(onScroll)
|
|
88
|
+
const onToggleMessageRef = useRef(onToggleMessage)
|
|
89
|
+
const onDialogClickRef = useRef(onDialogClick)
|
|
90
|
+
const selectionRef = useRef<LineSelection | null>(null)
|
|
91
|
+
const autoScrollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
92
|
+
const autoScrollDirRef = useRef<-1 | 1>(1)
|
|
93
|
+
const pointerSessionRef = useRef({ inMessageArea: false })
|
|
94
|
+
const scrollbarSessionRef = useRef<{ grabOffset: number } | null>(null)
|
|
95
|
+
messagesRef.current = messages
|
|
96
|
+
widthRef.current = columns
|
|
97
|
+
rowsRef.current = rows
|
|
98
|
+
scrollTopRef.current = scrollTop
|
|
99
|
+
messageHeightRef.current = messageHeight
|
|
100
|
+
inputHeightRef.current = inputHeight
|
|
101
|
+
dialogOpenRef.current = dialogOpen
|
|
102
|
+
hintStateRef.current = hint
|
|
103
|
+
screenRef.current = screen
|
|
104
|
+
inputHandleRef.current = inputHandle
|
|
105
|
+
panelActiveRef.current = panelActive
|
|
106
|
+
panelHandleRef.current = panelHandle
|
|
107
|
+
getScrollRef.current = getScroll
|
|
108
|
+
onHintPressRef.current = onHintPress
|
|
109
|
+
onHintDragStartRef.current = onHintDragStart
|
|
110
|
+
onHintDragMoveRef.current = onHintDragMove
|
|
111
|
+
onHintReleaseRef.current = onHintRelease
|
|
112
|
+
onDialogWheelRef.current = onDialogWheel
|
|
113
|
+
onScrollRef.current = onScroll
|
|
114
|
+
onToggleMessageRef.current = onToggleMessage
|
|
115
|
+
onDialogClickRef.current = onDialogClick
|
|
116
|
+
selectionRef.current = selection
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const stopDragScroll = (): void => {
|
|
119
|
+
if (autoScrollTimerRef.current !== null) {
|
|
120
|
+
clearInterval(autoScrollTimerRef.current)
|
|
121
|
+
autoScrollTimerRef.current = null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const extendSelectionTo = (row: number): void => {
|
|
125
|
+
setSelection(current => current === null || !current.inMessage
|
|
126
|
+
? current
|
|
127
|
+
: { ...current, focusRow: Math.max(0, row) })
|
|
128
|
+
}
|
|
129
|
+
const startDragScroll = (dir: -1 | 1): void => {
|
|
130
|
+
if (autoScrollTimerRef.current !== null && autoScrollDirRef.current === dir) return
|
|
131
|
+
stopDragScroll()
|
|
132
|
+
autoScrollDirRef.current = dir
|
|
133
|
+
autoScrollTimerRef.current = setInterval(() => {
|
|
134
|
+
const scrollTop = scrollTopRef.current
|
|
135
|
+
const totalRows = rowCount(messagesRef.current, widthRef.current)
|
|
136
|
+
const maxScroll = Math.max(0, totalRows - messageHeightRef.current)
|
|
137
|
+
if ((dir === -1 && scrollTop <= 0) || (dir === 1 && scrollTop >= maxScroll)) {
|
|
138
|
+
stopDragScroll()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const next = scrollTop + dir
|
|
142
|
+
onScrollRef.current(next)
|
|
143
|
+
extendSelectionTo(dir === 1 ? next + messageHeightRef.current - 1 : next)
|
|
144
|
+
}, DRAG_SCROLL_INTERVAL_MS)
|
|
145
|
+
}
|
|
146
|
+
const updateDragScroll = (eventY: number): void => {
|
|
147
|
+
if (!pointerSessionRef.current.inMessageArea || dialogOpenRef.current) {
|
|
148
|
+
stopDragScroll()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
let dir: -1 | 1 | 0 = 0
|
|
152
|
+
if (eventY <= 0) dir = -1
|
|
153
|
+
else if (eventY >= messageHeightRef.current - 1) dir = 1
|
|
154
|
+
if (dir === 0) stopDragScroll()
|
|
155
|
+
else startDragScroll(dir)
|
|
156
|
+
}
|
|
157
|
+
const inInputContent = (y: number): boolean =>
|
|
158
|
+
inputContentContains(y, rowsRef.current, inputHeightRef.current)
|
|
159
|
+
const scrollbarInfo = (): { geom: { top: number; height: number }; maxScroll: number; travel: number } | null => {
|
|
160
|
+
if (dialogOpenRef.current) return null
|
|
161
|
+
const mh = messageHeightRef.current
|
|
162
|
+
if (mh < 1) return null
|
|
163
|
+
const totalRows = rowCount(messagesRef.current, widthRef.current)
|
|
164
|
+
if (totalRows <= mh) return null
|
|
165
|
+
const geom = scrollbarGeometry(totalRows, mh, scrollTopRef.current)
|
|
166
|
+
if (geom === null) return null
|
|
167
|
+
return { geom, maxScroll: totalRows - mh, travel: Math.max(1, mh - geom.height) }
|
|
168
|
+
}
|
|
169
|
+
const onScrollbarDown = (x: number, y: number): boolean => {
|
|
170
|
+
if (x !== scrollbarColumn(widthRef.current) || y >= messageHeightRef.current) return false
|
|
171
|
+
const info = scrollbarInfo()
|
|
172
|
+
if (info === null) return false
|
|
173
|
+
stopDragScroll()
|
|
174
|
+
setSelection(null)
|
|
175
|
+
let grabOffset: number
|
|
176
|
+
if (y >= info.geom.top && y < info.geom.top + info.geom.height) {
|
|
177
|
+
grabOffset = y - info.geom.top
|
|
178
|
+
} else {
|
|
179
|
+
const centered = Math.max(0, Math.min(info.maxScroll,
|
|
180
|
+
Math.round(((y - info.geom.height / 2) / info.travel) * info.maxScroll)))
|
|
181
|
+
onScrollRef.current(centered)
|
|
182
|
+
grabOffset = y - Math.round((info.travel * centered) / info.maxScroll)
|
|
183
|
+
}
|
|
184
|
+
scrollbarSessionRef.current = { grabOffset }
|
|
185
|
+
pointerSessionRef.current = { inMessageArea: false }
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
const onScrollbarDrag = (y: number): void => {
|
|
189
|
+
const session = scrollbarSessionRef.current
|
|
190
|
+
if (session === null) return
|
|
191
|
+
const info = scrollbarInfo()
|
|
192
|
+
if (info === null) {
|
|
193
|
+
scrollbarSessionRef.current = null
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const thumbTop = Math.max(0, Math.min(info.travel, y - session.grabOffset))
|
|
197
|
+
const target = Math.max(0, Math.min(info.maxScroll, Math.round((thumbTop / info.travel) * info.maxScroll)))
|
|
198
|
+
onScrollRef.current(target)
|
|
199
|
+
}
|
|
200
|
+
const inMessageArea = (screenRow: number): boolean => {
|
|
201
|
+
if (screenRow >= messageHeightRef.current) return false
|
|
202
|
+
if (dialogOpenRef.current) return false
|
|
203
|
+
const region = hintRegion(rowsRef.current, hintStateRef.current, dialogOpenRef.current, inputHeightRef.current)
|
|
204
|
+
if (region !== null && screenRow >= region.top) return false
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
const toContentRow = (screenRow: number) =>
|
|
208
|
+
inMessageArea(screenRow) ? screenRow + scrollTopRef.current : screenRow
|
|
209
|
+
const activeHintRegion = () => hintRegion(rowsRef.current, hintStateRef.current, dialogOpenRef.current, inputHeightRef.current)
|
|
210
|
+
const focusRowFor = (current: LineSelection, eventY: number): number => {
|
|
211
|
+
if (!current.inMessage) {
|
|
212
|
+
const region = activeHintRegion()
|
|
213
|
+
if (region !== null && current.anchorRow >= region.top && current.anchorRow <= region.bottom) {
|
|
214
|
+
return Math.max(region.top, Math.min(eventY, region.bottom))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return clampFocusRow(current.inMessage, eventY, scrollTopRef.current, messageHeightRef.current, rowsRef.current, dialogOpenRef.current)
|
|
218
|
+
}
|
|
219
|
+
const uiContext = () => ({
|
|
220
|
+
dialogOpen: dialogOpenRef.current,
|
|
221
|
+
panelActive: panelActiveRef.current,
|
|
222
|
+
hintRegion: activeHintRegion(),
|
|
223
|
+
rows: rowsRef.current,
|
|
224
|
+
columns: widthRef.current,
|
|
225
|
+
messageHeight: messageHeightRef.current,
|
|
226
|
+
inputHeight: inputHeightRef.current,
|
|
227
|
+
scrollTop: scrollTopRef.current,
|
|
228
|
+
getScroll: getScrollRef.current,
|
|
229
|
+
})
|
|
230
|
+
const session: PointerSession = {
|
|
231
|
+
getSelection: () => selectionRef.current,
|
|
232
|
+
setSelection: next => setSelection(next),
|
|
233
|
+
scroll: next => onScrollRef.current(next),
|
|
234
|
+
autoScroll: direction => {
|
|
235
|
+
if (direction === 0) stopDragScroll()
|
|
236
|
+
else startDragScroll(direction)
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
const builtinDeps = {
|
|
240
|
+
inInputContent,
|
|
241
|
+
inMessageArea,
|
|
242
|
+
toContentRow,
|
|
243
|
+
activeHintRegion,
|
|
244
|
+
focusRowFor,
|
|
245
|
+
clampMessageFocus: (anchorInMessage: boolean, eventY: number) =>
|
|
246
|
+
anchorInMessage
|
|
247
|
+
? eventY + scrollTopRef.current
|
|
248
|
+
: clampFocusRow(false, eventY, scrollTopRef.current, messageHeightRef.current, rowsRef.current, dialogOpenRef.current),
|
|
249
|
+
setPointerArea: (value: boolean) => {
|
|
250
|
+
pointerSessionRef.current = { inMessageArea: value }
|
|
251
|
+
},
|
|
252
|
+
onScrollbarDown,
|
|
253
|
+
onScrollbarDrag,
|
|
254
|
+
stopDragScroll,
|
|
255
|
+
updateDragScroll,
|
|
256
|
+
setSelection,
|
|
257
|
+
getSelection: () => selectionRef.current,
|
|
258
|
+
rowHasText: (y: number) => screenRef.current?.rowHasText(y) ?? false,
|
|
259
|
+
rowInfoAt: (y: number) => {
|
|
260
|
+
const contentRow = toContentRow(y)
|
|
261
|
+
return rowInfoAt(messagesRef.current, widthRef.current, contentRow)
|
|
262
|
+
},
|
|
263
|
+
inputClickAt: (y: number, x: number) => inputHandleRef.current?.current?.clickAt(y, x),
|
|
264
|
+
panelClickAt: (y: number, x: number) => panelHandleRef.current?.current?.clickAt(y, x),
|
|
265
|
+
dialogWheel: (y: number, dir: -1 | 1) => onDialogWheelRef.current?.(y, dir) === true,
|
|
266
|
+
panelWheel: (dir: -1 | 1) => panelHandleRef.current?.current?.wheel(dir) === true,
|
|
267
|
+
hintWheel: (dir: -1 | 1) => inputHandleRef.current?.current?.hintWheel(dir),
|
|
268
|
+
inputWheel: (dir: -1 | 1) => inputHandleRef.current?.current?.wheel(dir),
|
|
269
|
+
hintPress: (x: number, y: number) => onHintPressRef.current?.(x, y),
|
|
270
|
+
hintDragStart: (x: number, y: number) => onHintDragStartRef.current?.(x, y),
|
|
271
|
+
hintDragMove: (x: number, y: number) => onHintDragMoveRef.current?.(x, y),
|
|
272
|
+
hintRelease: (x: number, y: number, dragged: boolean) => onHintReleaseRef.current?.(x, y, dragged),
|
|
273
|
+
onScroll: (next: number) => onScrollRef.current(next),
|
|
274
|
+
toggleMessage: (id: string) => onToggleMessageRef.current(id),
|
|
275
|
+
dialogClick: (y: number, x: number) => onDialogClickRef.current(y, x),
|
|
276
|
+
}
|
|
277
|
+
const builtinHandler: BuiltinPointerHandler = createBuiltinPointerHandler(builtinDeps)
|
|
278
|
+
let ownerId: string | null = null
|
|
279
|
+
const controller = createMouseController(rawEvent => {
|
|
280
|
+
const frame = { event: rawEvent, session, ui: uiContext() }
|
|
281
|
+
// The builtin composite participates in the same dispatch chain as
|
|
282
|
+
// plugin handlers, sorted by order (builtin at 100, plugin default 300).
|
|
283
|
+
const entries = [
|
|
284
|
+
{ key: builtinHandler.id, order: POINTER_BUILTIN_ORDER, value: builtinHandler as PointerHandlerContribution },
|
|
285
|
+
...pointerHandlerEntries(),
|
|
286
|
+
].sort((a, b) => a.order - b.order)
|
|
287
|
+
const entryFor = (id: string | null) => entries.find(entry => entry.key === id)
|
|
288
|
+
switch (rawEvent.type) {
|
|
289
|
+
case 'down': {
|
|
290
|
+
if (rawEvent.button !== 0) return
|
|
291
|
+
builtinHandler.beginDown()
|
|
292
|
+
ownerId = null
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
if (entry.value.onDown(frame) === true) {
|
|
295
|
+
ownerId = entry.key
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Dead click on unclaimed space (blank rows, padding): drop any
|
|
300
|
+
// lingering selection, matching native text-editor behavior.
|
|
301
|
+
setSelection(null)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
case 'drag': {
|
|
305
|
+
entryFor(ownerId)?.value.onDrag?.(frame)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
case 'up': {
|
|
309
|
+
stopDragScroll()
|
|
310
|
+
pointerSessionRef.current = { inMessageArea: false }
|
|
311
|
+
entryFor(ownerId)?.value.onUp?.(frame)
|
|
312
|
+
ownerId = null
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
case 'scroll': {
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
if (entry.value.onWheel?.(frame) === true) return
|
|
318
|
+
}
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
default:
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
controller.enable()
|
|
326
|
+
return () => {
|
|
327
|
+
controller.disable()
|
|
328
|
+
stopDragScroll()
|
|
329
|
+
}
|
|
330
|
+
}, [])
|
|
331
|
+
const screenSelection = useMemo(
|
|
332
|
+
() => toScreenSelection(selection, scrollTop, messageHeight),
|
|
333
|
+
[selection, scrollTop, messageHeight],
|
|
334
|
+
)
|
|
335
|
+
const messageAreaSelection = useMemo(
|
|
336
|
+
() => (selection !== null && selection.inMessage ? screenSelection : null),
|
|
337
|
+
[selection, screenSelection],
|
|
338
|
+
)
|
|
339
|
+
const chromeSelection = useMemo(
|
|
340
|
+
() => (selection !== null && !selection.inMessage ? screenSelection : null),
|
|
341
|
+
[selection, screenSelection],
|
|
342
|
+
)
|
|
343
|
+
return {
|
|
344
|
+
selection,
|
|
345
|
+
messageAreaSelection,
|
|
346
|
+
chromeSelection,
|
|
347
|
+
setSelection,
|
|
348
|
+
clearSelection() {
|
|
349
|
+
setSelection(null)
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useReducer, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ScrollSnapshot {
|
|
4
|
+
top: number
|
|
5
|
+
maxScroll: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScrollState {
|
|
9
|
+
scrollTop: number
|
|
10
|
+
getScroll(): ScrollSnapshot
|
|
11
|
+
applyScroll(next: number): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The effective scroll offset is derived during render: when pinned to the
|
|
16
|
+
* bottom the offset is simply maxScroll, so incoming messages shift the view
|
|
17
|
+
* without a second render pass (the previous layout-effect bump rendered the
|
|
18
|
+
* whole App twice per streaming frame). Only explicit scroll commands commit
|
|
19
|
+
* and re-render.
|
|
20
|
+
*/
|
|
21
|
+
export function useScroll(total: number, messageHeight: number): ScrollState {
|
|
22
|
+
const maxScroll = Math.max(0, total - messageHeight)
|
|
23
|
+
const [, bump] = useReducer((count: number) => count + 1, 0)
|
|
24
|
+
const scrollTopRef = useRef(0)
|
|
25
|
+
const stickToBottomRef = useRef(true)
|
|
26
|
+
const maxScrollRef = useRef(maxScroll)
|
|
27
|
+
maxScrollRef.current = maxScroll
|
|
28
|
+
if (stickToBottomRef.current) {
|
|
29
|
+
scrollTopRef.current = maxScroll
|
|
30
|
+
} else if (scrollTopRef.current > maxScroll) {
|
|
31
|
+
scrollTopRef.current = maxScroll
|
|
32
|
+
stickToBottomRef.current = scrollTopRef.current >= maxScroll
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const commit = useCallback((next: number, rerender: boolean): void => {
|
|
36
|
+
const clamped = Math.max(0, Math.min(maxScrollRef.current, next))
|
|
37
|
+
scrollTopRef.current = clamped
|
|
38
|
+
stickToBottomRef.current = clamped >= maxScrollRef.current
|
|
39
|
+
if (rerender) bump()
|
|
40
|
+
}, [bump])
|
|
41
|
+
|
|
42
|
+
const applyScroll = useCallback((next: number): void => {
|
|
43
|
+
commit(next === Infinity ? maxScrollRef.current : next, true)
|
|
44
|
+
}, [commit])
|
|
45
|
+
|
|
46
|
+
const getScroll = useCallback((): ScrollSnapshot => ({ top: scrollTopRef.current, maxScroll: maxScrollRef.current }), [])
|
|
47
|
+
|
|
48
|
+
return { scrollTop: scrollTopRef.current, getScroll, applyScroll }
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useStdout } from 'ink'
|
|
3
|
+
|
|
4
|
+
export function useTerminalSize(): { columns: number; rows: number } {
|
|
5
|
+
const { stdout } = useStdout()
|
|
6
|
+
const [columns, setColumns] = useState(stdout?.columns ?? 80)
|
|
7
|
+
const [rows, setRows] = useState(stdout?.rows ?? 24)
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!stdout) return
|
|
10
|
+
const onResize = () => {
|
|
11
|
+
setColumns(stdout.columns)
|
|
12
|
+
setRows(stdout.rows)
|
|
13
|
+
}
|
|
14
|
+
stdout.on('resize', onResize)
|
|
15
|
+
return () => {
|
|
16
|
+
stdout.off('resize', onResize)
|
|
17
|
+
}
|
|
18
|
+
}, [stdout])
|
|
19
|
+
return { columns, rows }
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
3
|
+
import { matchHintEntries } from '../chrome/hint-service.ts'
|
|
4
|
+
import type { CommandDef } from '../../contract/index.ts'
|
|
5
|
+
|
|
6
|
+
export type { CommandDef } from '../../contract/index.ts'
|
|
7
|
+
|
|
8
|
+
export type CommandId = string
|
|
9
|
+
|
|
10
|
+
export interface CommandHintItem {
|
|
11
|
+
command: string
|
|
12
|
+
description: string
|
|
13
|
+
hint?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const registry = keyedRegistry<CommandDef>()
|
|
17
|
+
|
|
18
|
+
export const COMMANDS: CommandDef[] = []
|
|
19
|
+
|
|
20
|
+
let version = 0
|
|
21
|
+
|
|
22
|
+
function refresh(): void {
|
|
23
|
+
version += 1
|
|
24
|
+
COMMANDS.length = 0
|
|
25
|
+
COMMANDS.push(...registry.values())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
registry.register('new', { id: 'new', command: '/new', description: 'Start a new conversation in current directory' })
|
|
29
|
+
registry.register('todo', { id: 'todo', command: '/todo', description: 'Show the current task list' })
|
|
30
|
+
refresh()
|
|
31
|
+
registry.subscribe(refresh)
|
|
32
|
+
|
|
33
|
+
export function registerCommand(def: CommandDef): () => void {
|
|
34
|
+
return registry.register(def.id, def, { order: def.order })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function subscribeCommands(listener: () => void): () => void {
|
|
38
|
+
return registry.subscribe(listener)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useCommandVersion(): number {
|
|
42
|
+
return useSyncExternalStore(subscribeCommands, () => version, () => version)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchCommand(text: string): CommandDef | undefined {
|
|
46
|
+
return registry.values().find(command => command.command === text)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type CommandAvailability = (command: CommandDef) => boolean
|
|
50
|
+
|
|
51
|
+
export function matchAvailableCommand(text: string, isAvailable?: CommandAvailability, extra?: readonly CommandDef[]): CommandDef | undefined {
|
|
52
|
+
const token = text.split(/\s+/, 1)[0] ?? ''
|
|
53
|
+
const command = [...registry.values(), ...extra ?? []].find(entry => entry.command === token)
|
|
54
|
+
if (command === undefined) return undefined
|
|
55
|
+
if (isAvailable !== undefined && !isAvailable(command)) return undefined
|
|
56
|
+
return command
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function commandArgHints(name: string): readonly string[] | undefined {
|
|
60
|
+
const def = registry.values().find(candidate => candidate.command === `/${name}`)
|
|
61
|
+
return def?.args
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mergeCommandEntries(
|
|
65
|
+
local: CommandDef[],
|
|
66
|
+
remote: readonly { name: string; description: string; hint?: string }[],
|
|
67
|
+
): CommandHintItem[] {
|
|
68
|
+
const taken = new Set(local.map(command => command.command))
|
|
69
|
+
const entries: CommandHintItem[] = local.map(({ command, description, hint }) => ({
|
|
70
|
+
command,
|
|
71
|
+
description,
|
|
72
|
+
...(hint === undefined ? {} : { hint }),
|
|
73
|
+
}))
|
|
74
|
+
for (const entry of remote) {
|
|
75
|
+
const command = `/${entry.name}`
|
|
76
|
+
if (taken.has(command)) continue
|
|
77
|
+
taken.add(command)
|
|
78
|
+
entries.push({
|
|
79
|
+
command,
|
|
80
|
+
description: entry.description,
|
|
81
|
+
...(entry.hint === undefined ? {} : { hint: entry.hint }),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
return entries
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isSubsequence(query: string, text: string): boolean {
|
|
88
|
+
let at = 0
|
|
89
|
+
for (let index = 0; index < text.length && at < query.length; index++) {
|
|
90
|
+
if (text[index] === query[at]) at += 1
|
|
91
|
+
}
|
|
92
|
+
return at === query.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function filterHintEntries(entries: readonly CommandHintItem[], value: string): CommandHintItem[] {
|
|
96
|
+
if (!value.startsWith('/') || /\s/.test(value)) return []
|
|
97
|
+
return matchHintEntries(entries, value)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Static literal arguments for registry commands whose hints mix literals with free-form prose. */
|
|
101
|
+
export const KNOWN_COMMAND_ARGS: Record<string, readonly string[]> = {
|
|
102
|
+
plan: ['off'],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function literalHintArgs(hint: string | undefined): string[] {
|
|
106
|
+
if (hint === undefined || hint === '') return []
|
|
107
|
+
const body = hint.replace(/^\[/, '').replace(/\]$/, '')
|
|
108
|
+
return body
|
|
109
|
+
.split('|')
|
|
110
|
+
.map(part => part.trim())
|
|
111
|
+
.filter(part => part !== '' && !part.includes('<'))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CommandHintState {
|
|
115
|
+
commands: CommandHintItem[]
|
|
116
|
+
selectedIndex: number
|
|
117
|
+
startIndex?: number
|
|
118
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComposerInsertSpec } from '../../contract/index.ts'
|
|
2
|
+
|
|
3
|
+
type ComposerInserter = (spec: ComposerInsertSpec) => boolean
|
|
4
|
+
|
|
5
|
+
let inserter: ComposerInserter | undefined
|
|
6
|
+
|
|
7
|
+
export function setComposerInserter(next: ComposerInserter | undefined): void {
|
|
8
|
+
inserter = next
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function insertIntoComposer(spec: ComposerInsertSpec): boolean {
|
|
12
|
+
if (inserter === undefined) return false
|
|
13
|
+
return inserter(spec)
|
|
14
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocateField,
|
|
3
|
+
charactersChipLabel,
|
|
4
|
+
fieldExpandOf,
|
|
5
|
+
imageChipLabel,
|
|
6
|
+
isFieldChar,
|
|
7
|
+
linesChipLabel,
|
|
8
|
+
releaseUnreferenced,
|
|
9
|
+
} from '../../core/fields.ts'
|
|
10
|
+
import { classifyPaste, summarizePaste } from '../../core/paste.ts'
|
|
11
|
+
import type { PendingImage } from '../../core/paste.ts'
|
|
12
|
+
import type { ClipboardImage } from '../../terminal/clipboard.ts'
|
|
13
|
+
|
|
14
|
+
export interface ComposerField {
|
|
15
|
+
kind: string
|
|
16
|
+
label: string
|
|
17
|
+
images?: PendingImage[]
|
|
18
|
+
text?: string
|
|
19
|
+
data?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ComposerFieldMap = Map<string, ComposerField>
|
|
23
|
+
|
|
24
|
+
export function buildPasteFields(normalized: string, fields: ComposerFieldMap): string {
|
|
25
|
+
const segments = classifyPaste(normalized)
|
|
26
|
+
let value = ''
|
|
27
|
+
for (const segment of segments) {
|
|
28
|
+
if (segment.type === 'images') {
|
|
29
|
+
value += insertImageField(segment.paths.map(path => ({ kind: 'path' as const, path })), fields)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const summary = summarizePaste(segment.text)
|
|
33
|
+
if (summary === null) {
|
|
34
|
+
value += segment.text
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
const label = summary.lines > 1 ? linesChipLabel(summary.lines) : charactersChipLabel(summary.characters)
|
|
38
|
+
const char = allocateField('paste', label, 'composer')
|
|
39
|
+
if (char === null) {
|
|
40
|
+
value += segment.text
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
fields.set(char, { kind: 'paste', label, text: segment.text })
|
|
44
|
+
value += char
|
|
45
|
+
}
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function insertImageField(images: PendingImage[], fields: ComposerFieldMap): string {
|
|
50
|
+
if (images.length === 0) return ''
|
|
51
|
+
const label = imageChipLabel(images.length)
|
|
52
|
+
const char = allocateField('image', label, 'composer')
|
|
53
|
+
if (char === null) return images.length === 1 && images[0]!.kind === 'path' ? images[0]!.path : label
|
|
54
|
+
fields.set(char, { kind: 'image', label, images })
|
|
55
|
+
return char
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function insertClipboardImage(image: ClipboardImage, fields: ComposerFieldMap): string {
|
|
59
|
+
return insertImageField([{
|
|
60
|
+
kind: 'data',
|
|
61
|
+
data: image.data,
|
|
62
|
+
mediaType: image.mediaType,
|
|
63
|
+
name: 'clipboard',
|
|
64
|
+
}], fields)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function insertFieldSpec(
|
|
68
|
+
spec: { kind: string; label: string; data?: unknown },
|
|
69
|
+
fields: ComposerFieldMap,
|
|
70
|
+
): string {
|
|
71
|
+
const char = allocateField(spec.kind, spec.label, 'composer')
|
|
72
|
+
if (char === null) return spec.label
|
|
73
|
+
let field: ComposerField
|
|
74
|
+
if (spec.kind === 'paste') {
|
|
75
|
+
field = { kind: 'paste', label: spec.label, text: typeof spec.data === 'string' ? spec.data : spec.label }
|
|
76
|
+
} else if (spec.kind === 'image' && Array.isArray(spec.data)) {
|
|
77
|
+
field = { kind: 'image', label: spec.label, images: spec.data as PendingImage[] }
|
|
78
|
+
} else {
|
|
79
|
+
field = { kind: spec.kind, label: spec.label, data: spec.data }
|
|
80
|
+
}
|
|
81
|
+
fields.set(char, field)
|
|
82
|
+
return char
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function reconcileComposerFields(value: string, fields: ComposerFieldMap): void {
|
|
86
|
+
for (const char of fields.keys()) {
|
|
87
|
+
if (!value.includes(char)) fields.delete(char)
|
|
88
|
+
}
|
|
89
|
+
releaseUnreferenced('composer', value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ComposerSubmission {
|
|
93
|
+
text: string
|
|
94
|
+
images: PendingImage[][]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function expandComposerValue(value: string, fields: ComposerFieldMap): ComposerSubmission {
|
|
98
|
+
let text = ''
|
|
99
|
+
const images: PendingImage[][] = []
|
|
100
|
+
for (const char of value) {
|
|
101
|
+
const field = isFieldChar(char) ? fields.get(char) : undefined
|
|
102
|
+
if (field === undefined) {
|
|
103
|
+
text += char
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (field.kind === 'paste') text += field.text ?? ''
|
|
107
|
+
else if (field.kind === 'image' && (field.images ?? []).length > 0) images.push(field.images!)
|
|
108
|
+
else text += fieldExpandOf(field.kind)?.(field.data) ?? field.label
|
|
109
|
+
}
|
|
110
|
+
return { text: text.trim(), images }
|
|
111
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
2
|
+
import type { TuiKey } from '../../contract/index.ts'
|
|
3
|
+
|
|
4
|
+
export interface ComposerKeyBinding {
|
|
5
|
+
id: string
|
|
6
|
+
order?: number
|
|
7
|
+
handle(input: string, key: TuiKey): boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const bindings = keyedRegistry<ComposerKeyBinding>()
|
|
11
|
+
|
|
12
|
+
export function registerComposerKeyBinding(binding: ComposerKeyBinding): () => void {
|
|
13
|
+
return bindings.register(binding.id, binding, { order: binding.order })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function handleComposerKeyBindings(input: string, key: TuiKey): boolean {
|
|
17
|
+
for (const binding of bindings.values()) {
|
|
18
|
+
if (binding.handle(input, key) === true) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
}
|