@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,495 @@
|
|
|
1
|
+
import { useInput, usePaste } from 'ink'
|
|
2
|
+
import { isKeyConsumed } from '../key-arbiter.ts'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { isMouseResidue } from '../../terminal/mouse.ts'
|
|
5
|
+
import { colToCharIndex } from '../../core/text.ts'
|
|
6
|
+
import { moveCaretLine } from '../../core/composer-layout.ts'
|
|
7
|
+
import { editBackspace, editCursorLeft, editCursorRight, editCursorWordLeft, editCursorWordRight, editDelete, editDeleteWordLeft, editDeleteWordRight, editInsert } from '../../core/edit.ts'
|
|
8
|
+
import type { EditState } from '../../core/edit.ts'
|
|
9
|
+
import { buildPasteFields, expandComposerValue, insertClipboardImage, insertFieldSpec, reconcileComposerFields } from './composer-fields.ts'
|
|
10
|
+
import type { ComposerFieldMap, ComposerSubmission } from './composer-fields.ts'
|
|
11
|
+
import { sanitizePastedText } from '../../core/paste.ts'
|
|
12
|
+
import { readClipboardImage, readClipboardImageUris, readClipboardText } from '../../terminal/clipboard.ts'
|
|
13
|
+
import { claimPaste } from './composer-paste.ts'
|
|
14
|
+
import { handleComposerKeyBindings } from './composer-keys.ts'
|
|
15
|
+
import { setComposerInserter } from './composer-bus.ts'
|
|
16
|
+
import { COMMANDS, filterHintEntries, literalHintArgs } from './commands.ts'
|
|
17
|
+
import type { CommandHintItem } from './commands.ts'
|
|
18
|
+
|
|
19
|
+
export interface ComposerState {
|
|
20
|
+
value: string
|
|
21
|
+
cursor: number
|
|
22
|
+
hintOpen: boolean
|
|
23
|
+
commandIndex: number
|
|
24
|
+
api: ComposerApi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ComposerApi {
|
|
28
|
+
moveLineBy(delta: -1 | 1): void
|
|
29
|
+
placeCursor(charIndex: number): void
|
|
30
|
+
selectHint(absoluteIndex: number): void
|
|
31
|
+
confirmHint(): void
|
|
32
|
+
hintMove(delta: -1 | 1): void
|
|
33
|
+
hintClickAt(absoluteIndex: number): void
|
|
34
|
+
hintPick(absoluteIndex: number): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function opensHint(text: string): boolean {
|
|
38
|
+
return text.startsWith('/') && !/\s/.test(text)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UNDO_LIMIT = 200
|
|
42
|
+
|
|
43
|
+
export function useComposer(
|
|
44
|
+
onSend: (submission: ComposerSubmission) => void,
|
|
45
|
+
interactive: boolean,
|
|
46
|
+
contentWidth: number,
|
|
47
|
+
onCycleMode?: () => void,
|
|
48
|
+
entries?: readonly CommandHintItem[],
|
|
49
|
+
listCommandArgs?: (name: string) => Promise<string[]>,
|
|
50
|
+
): ComposerState {
|
|
51
|
+
const [value, setValue] = useState('')
|
|
52
|
+
const [cursor, setCursor] = useState(0)
|
|
53
|
+
const [hintOpen, setHintOpen] = useState(false)
|
|
54
|
+
const [commandIndex, setCommandIndex] = useState(0)
|
|
55
|
+
const valueRef = useRef('')
|
|
56
|
+
const cursorRef = useRef(0)
|
|
57
|
+
const hintOpenRef = useRef(false)
|
|
58
|
+
const commandIndexRef = useRef(0)
|
|
59
|
+
const widthRef = useRef(contentWidth)
|
|
60
|
+
const entriesRef = useRef<readonly CommandHintItem[] | undefined>(entries)
|
|
61
|
+
const listCommandArgsRef = useRef(listCommandArgs)
|
|
62
|
+
const interactiveRef = useRef(interactive)
|
|
63
|
+
const fieldsRef = useRef<ComposerFieldMap>(new Map())
|
|
64
|
+
widthRef.current = contentWidth
|
|
65
|
+
entriesRef.current = entries
|
|
66
|
+
listCommandArgsRef.current = listCommandArgs
|
|
67
|
+
interactiveRef.current = interactive
|
|
68
|
+
valueRef.current = value
|
|
69
|
+
cursorRef.current = cursor
|
|
70
|
+
hintOpenRef.current = hintOpen
|
|
71
|
+
commandIndexRef.current = commandIndex
|
|
72
|
+
const visibleFor = (text: string) => filterHintEntries(entriesRef.current ?? COMMANDS, text)
|
|
73
|
+
const undoStackRef = useRef<EditState[]>([])
|
|
74
|
+
const redoStackRef = useRef<EditState[]>([])
|
|
75
|
+
const pushUndoSnapshot = (): void => {
|
|
76
|
+
resetHistoryBrowse()
|
|
77
|
+
undoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
|
|
78
|
+
if (undoStackRef.current.length > UNDO_LIMIT) undoStackRef.current.shift()
|
|
79
|
+
redoStackRef.current.length = 0
|
|
80
|
+
}
|
|
81
|
+
const applySnapshot = (next: EditState): void => {
|
|
82
|
+
valueRef.current = next.value
|
|
83
|
+
cursorRef.current = next.cursor
|
|
84
|
+
setValue(next.value)
|
|
85
|
+
setCursor(next.cursor)
|
|
86
|
+
reconcileComposerFields(next.value, fieldsRef.current)
|
|
87
|
+
}
|
|
88
|
+
const applyEdit = (next: EditState | null): void => {
|
|
89
|
+
if (next === null) return
|
|
90
|
+
pushUndoSnapshot()
|
|
91
|
+
applySnapshot(next)
|
|
92
|
+
}
|
|
93
|
+
const undoEdit = (): void => {
|
|
94
|
+
const snapshot = undoStackRef.current.pop()
|
|
95
|
+
if (snapshot === undefined) return
|
|
96
|
+
redoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
|
|
97
|
+
if (redoStackRef.current.length > UNDO_LIMIT) redoStackRef.current.shift()
|
|
98
|
+
applySnapshot(snapshot)
|
|
99
|
+
refreshHint()
|
|
100
|
+
}
|
|
101
|
+
const redoEdit = (): void => {
|
|
102
|
+
const snapshot = redoStackRef.current.pop()
|
|
103
|
+
if (snapshot === undefined) return
|
|
104
|
+
undoStackRef.current.push({ value: valueRef.current, cursor: cursorRef.current })
|
|
105
|
+
if (undoStackRef.current.length > UNDO_LIMIT) undoStackRef.current.shift()
|
|
106
|
+
applySnapshot(snapshot)
|
|
107
|
+
refreshHint()
|
|
108
|
+
}
|
|
109
|
+
const HISTORY_LIMIT = 50
|
|
110
|
+
const historyRef = useRef<string[]>([])
|
|
111
|
+
const historyIndexRef = useRef(-1)
|
|
112
|
+
const historyDraftRef = useRef<EditState | null>(null)
|
|
113
|
+
const resetHistoryBrowse = (): void => {
|
|
114
|
+
historyIndexRef.current = -1
|
|
115
|
+
historyDraftRef.current = null
|
|
116
|
+
}
|
|
117
|
+
const historyOlder = (): void => {
|
|
118
|
+
const history = historyRef.current
|
|
119
|
+
if (history.length === 0) return
|
|
120
|
+
if (historyIndexRef.current === -1) {
|
|
121
|
+
historyDraftRef.current = { value: valueRef.current, cursor: cursorRef.current }
|
|
122
|
+
historyIndexRef.current = 0
|
|
123
|
+
} else if (historyIndexRef.current >= history.length - 1) {
|
|
124
|
+
return
|
|
125
|
+
} else {
|
|
126
|
+
historyIndexRef.current += 1
|
|
127
|
+
}
|
|
128
|
+
const entry = history[history.length - 1 - historyIndexRef.current]!
|
|
129
|
+
applySnapshot({ value: entry, cursor: entry.length })
|
|
130
|
+
closeHint()
|
|
131
|
+
}
|
|
132
|
+
const historyNewer = (): void => {
|
|
133
|
+
if (historyIndexRef.current === -1) return
|
|
134
|
+
if (historyIndexRef.current === 0) {
|
|
135
|
+
const draft = historyDraftRef.current
|
|
136
|
+
resetHistoryBrowse()
|
|
137
|
+
applySnapshot(draft ?? { value: '', cursor: 0 })
|
|
138
|
+
closeHint()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
historyIndexRef.current -= 1
|
|
142
|
+
const entry = historyRef.current[historyRef.current.length - 1 - historyIndexRef.current]!
|
|
143
|
+
applySnapshot({ value: entry, cursor: entry.length })
|
|
144
|
+
closeHint()
|
|
145
|
+
}
|
|
146
|
+
const recordHistoryEntry = (text: string): void => {
|
|
147
|
+
resetHistoryBrowse()
|
|
148
|
+
if (text === '') return
|
|
149
|
+
const history = historyRef.current
|
|
150
|
+
if (history[history.length - 1] === text) return
|
|
151
|
+
history.push(text)
|
|
152
|
+
if (history.length > HISTORY_LIMIT) history.shift()
|
|
153
|
+
}
|
|
154
|
+
const closeHint = (): void => {
|
|
155
|
+
setHintOpen(false)
|
|
156
|
+
hintOpenRef.current = false
|
|
157
|
+
setCommandIndex(0)
|
|
158
|
+
commandIndexRef.current = 0
|
|
159
|
+
}
|
|
160
|
+
const openHint = (): void => {
|
|
161
|
+
setHintOpen(true)
|
|
162
|
+
hintOpenRef.current = true
|
|
163
|
+
setCommandIndex(0)
|
|
164
|
+
commandIndexRef.current = 0
|
|
165
|
+
}
|
|
166
|
+
const refreshHint = (): void => {
|
|
167
|
+
if (opensHint(valueRef.current)) openHint()
|
|
168
|
+
else closeHint()
|
|
169
|
+
}
|
|
170
|
+
const applyEditAndRefreshHint = (next: { value: string; cursor: number } | null): boolean => {
|
|
171
|
+
if (next === null) return false
|
|
172
|
+
applyEdit(next)
|
|
173
|
+
refreshHint()
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
const apiRef = useRef<ComposerApi>({
|
|
177
|
+
moveLineBy(delta) {
|
|
178
|
+
const next = moveCaretLine(valueRef.current, cursorRef.current, widthRef.current, delta)
|
|
179
|
+
cursorRef.current = next
|
|
180
|
+
setCursor(next)
|
|
181
|
+
},
|
|
182
|
+
placeCursor(charIndex) {
|
|
183
|
+
const clamped = Math.max(0, Math.min(charIndex, valueRef.current.length))
|
|
184
|
+
cursorRef.current = clamped
|
|
185
|
+
setCursor(clamped)
|
|
186
|
+
},
|
|
187
|
+
selectHint(absoluteIndex) {
|
|
188
|
+
const commands = visibleFor(valueRef.current)
|
|
189
|
+
const clamped = Math.max(0, Math.min(absoluteIndex, commands.length - 1))
|
|
190
|
+
commandIndexRef.current = clamped
|
|
191
|
+
setCommandIndex(clamped)
|
|
192
|
+
},
|
|
193
|
+
confirmHint() {
|
|
194
|
+
const commands = visibleFor(valueRef.current)
|
|
195
|
+
if (commands.length === 0) return
|
|
196
|
+
const command = commands[Math.min(commandIndexRef.current, commands.length - 1)]?.command
|
|
197
|
+
if (command === undefined) return
|
|
198
|
+
const completed = `${command} `
|
|
199
|
+
pushUndoSnapshot()
|
|
200
|
+
valueRef.current = completed
|
|
201
|
+
cursorRef.current = completed.length
|
|
202
|
+
setValue(completed)
|
|
203
|
+
setCursor(completed.length)
|
|
204
|
+
closeHint()
|
|
205
|
+
},
|
|
206
|
+
hintMove(delta) {
|
|
207
|
+
const commands = visibleFor(valueRef.current)
|
|
208
|
+
if (commands.length === 0) return
|
|
209
|
+
const next = (commandIndexRef.current + delta + commands.length) % commands.length
|
|
210
|
+
commandIndexRef.current = next
|
|
211
|
+
setCommandIndex(next)
|
|
212
|
+
},
|
|
213
|
+
hintClickAt(absoluteIndex) {
|
|
214
|
+
if (!hintOpenRef.current) return
|
|
215
|
+
if (absoluteIndex === commandIndexRef.current) {
|
|
216
|
+
apiRef.current.confirmHint()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
apiRef.current.selectHint(absoluteIndex)
|
|
220
|
+
},
|
|
221
|
+
hintPick(absoluteIndex) {
|
|
222
|
+
if (!hintOpenRef.current) return
|
|
223
|
+
apiRef.current.selectHint(absoluteIndex)
|
|
224
|
+
apiRef.current.confirmHint()
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
const insertPaste = (normalized: string): void => {
|
|
228
|
+
const sanitized = sanitizePastedText(normalized)
|
|
229
|
+
if (sanitized === '') return
|
|
230
|
+
const { value: current, cursor: at } = { value: valueRef.current, cursor: cursorRef.current }
|
|
231
|
+
const inserted = buildPasteFields(sanitized, fieldsRef.current)
|
|
232
|
+
applyEdit(editInsert({ value: current, cursor: at }, inserted))
|
|
233
|
+
refreshHint()
|
|
234
|
+
}
|
|
235
|
+
const upgradeFirstImageGroupFromClipboard = async (): Promise<void> => {
|
|
236
|
+
const entries = [...fieldsRef.current.entries()]
|
|
237
|
+
const imageFields = entries.filter(([, field]) => field.kind === 'image')
|
|
238
|
+
if (imageFields.length !== 1) return
|
|
239
|
+
const [, field] = imageFields[0]!
|
|
240
|
+
const pathCount = (field.images ?? []).filter(image => image.kind === 'path').length
|
|
241
|
+
if (pathCount !== 1) return
|
|
242
|
+
try {
|
|
243
|
+
const uris = await readClipboardImageUris()
|
|
244
|
+
if (uris.length > 1) field.images = uris.map(path => ({ kind: 'path' as const, path }))
|
|
245
|
+
} catch {
|
|
246
|
+
// Clipboard URI lists are best-effort; a single pasted path stays as-is.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const pasteFromClipboard = async (allowText: boolean): Promise<void> => {
|
|
250
|
+
try {
|
|
251
|
+
const image = await readClipboardImage()
|
|
252
|
+
if (image !== undefined) {
|
|
253
|
+
insertPaste(insertClipboardImage(image, fieldsRef.current))
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (!allowText) return
|
|
257
|
+
const text = await readClipboardText()
|
|
258
|
+
if (text !== undefined && text !== '') insertPaste(text.replace(/\r\n?/g, '\n'))
|
|
259
|
+
} catch {
|
|
260
|
+
// A clipboard backend failure is ignored: the paste simply does not insert.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
usePaste(text => {
|
|
264
|
+
if (!interactive) return
|
|
265
|
+
const normalized = text.replace(/\r\n?/g, '\n')
|
|
266
|
+
if (normalized === '') {
|
|
267
|
+
void pasteFromClipboard(true)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
void (async () => {
|
|
271
|
+
const claimed = claimPaste(normalized, cursorRef.current)
|
|
272
|
+
insertPaste(claimed === undefined ? normalized : claimed.insert)
|
|
273
|
+
if (claimed === undefined) void upgradeFirstImageGroupFromClipboard()
|
|
274
|
+
})()
|
|
275
|
+
}, { isActive: interactive })
|
|
276
|
+
const insertText = (text: string): void => {
|
|
277
|
+
applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, text))
|
|
278
|
+
}
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
setComposerInserter(spec => {
|
|
281
|
+
if (!interactiveRef.current) return false
|
|
282
|
+
if (spec.field !== undefined) {
|
|
283
|
+
const char = insertFieldSpec(spec.field, fieldsRef.current)
|
|
284
|
+
applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, char))
|
|
285
|
+
refreshHint()
|
|
286
|
+
return true
|
|
287
|
+
}
|
|
288
|
+
if (spec.text !== undefined && spec.text !== '') {
|
|
289
|
+
applyEdit(editInsert({ value: valueRef.current, cursor: cursorRef.current }, sanitizePastedText(spec.text)))
|
|
290
|
+
refreshHint()
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
return false
|
|
294
|
+
})
|
|
295
|
+
return () => setComposerInserter(undefined)
|
|
296
|
+
}, [])
|
|
297
|
+
const completeCommandArg = (): void => {
|
|
298
|
+
const v = valueRef.current
|
|
299
|
+
const match = /^(\/\S+)(?:\s+(\S*))?(?:\s+(.*\S))?\s*$/.exec(v)
|
|
300
|
+
if (match === null) return
|
|
301
|
+
const commandToken = match[1]!
|
|
302
|
+
const entry = entriesRef.current?.find(candidate => candidate.command === commandToken)
|
|
303
|
+
if (entry === undefined) return
|
|
304
|
+
const currentArg = match[2] ?? ''
|
|
305
|
+
const rest = match[3]
|
|
306
|
+
void (async () => {
|
|
307
|
+
let candidates: string[] = []
|
|
308
|
+
try {
|
|
309
|
+
candidates = await listCommandArgsRef.current?.(entry.command.slice(1)) ?? []
|
|
310
|
+
} catch {
|
|
311
|
+
// An argument provider failure abandons the completion quietly.
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (candidates.length === 0) candidates = literalHintArgs(entry.hint)
|
|
315
|
+
if (candidates.length === 0) return
|
|
316
|
+
const exactIndex = candidates.indexOf(currentArg)
|
|
317
|
+
let next: string
|
|
318
|
+
if (exactIndex >= 0) {
|
|
319
|
+
next = candidates[(exactIndex + 1) % candidates.length]!
|
|
320
|
+
} else {
|
|
321
|
+
next = candidates.find(candidate => candidate.startsWith(currentArg)) ?? candidates[0]!
|
|
322
|
+
}
|
|
323
|
+
const completed = `${commandToken} ${next}${rest === undefined ? '' : ` ${rest}`}`
|
|
324
|
+
pushUndoSnapshot()
|
|
325
|
+
valueRef.current = completed
|
|
326
|
+
cursorRef.current = completed.length
|
|
327
|
+
setValue(completed)
|
|
328
|
+
setCursor(completed.length)
|
|
329
|
+
closeHint()
|
|
330
|
+
})()
|
|
331
|
+
}
|
|
332
|
+
useInput((input, key) => {
|
|
333
|
+
if (!interactive || isKeyConsumed()) return
|
|
334
|
+
if (handleComposerKeyBindings(input, key)) {
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (input === '\n') {
|
|
338
|
+
insertText('\n')
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
const v = valueRef.current
|
|
342
|
+
const c = cursorRef.current
|
|
343
|
+
const commands = visibleFor(v)
|
|
344
|
+
const showHint = hintOpenRef.current && commands.length > 0
|
|
345
|
+
if (key.return && !key.shift && showHint) {
|
|
346
|
+
const selected = commands[Math.min(commandIndexRef.current, commands.length - 1)]
|
|
347
|
+
const exactArgless = selected !== undefined && selected.hint === undefined && v === selected.command
|
|
348
|
+
if (!exactArgless) {
|
|
349
|
+
apiRef.current.confirmHint()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (key.escape && showHint) {
|
|
354
|
+
closeHint()
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (key.tab) {
|
|
358
|
+
if (!v.startsWith('/')) {
|
|
359
|
+
onCycleMode?.()
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
const lastChar = v.length > 0 ? v[v.length - 1] : undefined
|
|
363
|
+
const completable = c === v.length && lastChar !== undefined && !/\s/.test(lastChar)
|
|
364
|
+
const trailingCommand = c === v.length && /^(\/\S+)\s+$/.test(v)
|
|
365
|
+
const exactCommand = entriesRef.current?.some(entry => entry.command === v) ?? false
|
|
366
|
+
if (completable && exactCommand) {
|
|
367
|
+
completeCommandArg()
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
if (showHint) {
|
|
371
|
+
apiRef.current.confirmHint()
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (completable || trailingCommand) {
|
|
375
|
+
completeCommandArg()
|
|
376
|
+
}
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
if (key.upArrow && showHint) {
|
|
380
|
+
const next = (commandIndexRef.current - 1 + commands.length) % commands.length
|
|
381
|
+
commandIndexRef.current = next
|
|
382
|
+
setCommandIndex(next)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
if (key.downArrow && showHint) {
|
|
386
|
+
const next = (commandIndexRef.current + 1) % commands.length
|
|
387
|
+
commandIndexRef.current = next
|
|
388
|
+
setCommandIndex(next)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (key.return && !key.shift) {
|
|
392
|
+
const submission = expandComposerValue(v, fieldsRef.current)
|
|
393
|
+
if (submission.text !== '' || submission.images.length > 0) onSend(submission)
|
|
394
|
+
recordHistoryEntry(submission.text)
|
|
395
|
+
fieldsRef.current = new Map()
|
|
396
|
+
valueRef.current = ''
|
|
397
|
+
cursorRef.current = 0
|
|
398
|
+
setValue('')
|
|
399
|
+
setCursor(0)
|
|
400
|
+
undoStackRef.current.length = 0
|
|
401
|
+
redoStackRef.current.length = 0
|
|
402
|
+
closeHint()
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
if (key.return) {
|
|
406
|
+
insertText('\n')
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (key.ctrl && input === 'v') {
|
|
410
|
+
void pasteFromClipboard(true)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
if (key.ctrl && input === 'z') {
|
|
414
|
+
if (key.shift) redoEdit()
|
|
415
|
+
else undoEdit()
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (key.ctrl && input === 'y') {
|
|
419
|
+
redoEdit()
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
if (key.meta && key.backspace && applyEditAndRefreshHint(editDeleteWordLeft({ value: v, cursor: c }))) {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (key.meta && key.delete && applyEditAndRefreshHint(editDeleteWordRight({ value: v, cursor: c }))) {
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (key.meta && key.leftArrow && c > 0) {
|
|
429
|
+
const moved = editCursorWordLeft({ value: v, cursor: c })
|
|
430
|
+
cursorRef.current = moved.cursor
|
|
431
|
+
setCursor(moved.cursor)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
if (key.meta && key.rightArrow && c < v.length) {
|
|
435
|
+
const moved = editCursorWordRight({ value: v, cursor: c })
|
|
436
|
+
cursorRef.current = moved.cursor
|
|
437
|
+
setCursor(moved.cursor)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (key.backspace && applyEditAndRefreshHint(editBackspace({ value: v, cursor: c }))) {
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
if (key.delete && applyEditAndRefreshHint(editDelete({ value: v, cursor: c }))) {
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
if (key.leftArrow && c > 0) {
|
|
447
|
+
const moved = editCursorLeft({ value: v, cursor: c })
|
|
448
|
+
cursorRef.current = moved.cursor
|
|
449
|
+
setCursor(moved.cursor)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
if (key.rightArrow && c < v.length) {
|
|
453
|
+
const moved = editCursorRight({ value: v, cursor: c })
|
|
454
|
+
cursorRef.current = moved.cursor
|
|
455
|
+
setCursor(moved.cursor)
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
if (key.upArrow) {
|
|
459
|
+
const moved = moveCaretLine(v, c, contentWidth, -1)
|
|
460
|
+
if (moved !== c) {
|
|
461
|
+
cursorRef.current = moved
|
|
462
|
+
setCursor(moved)
|
|
463
|
+
} else {
|
|
464
|
+
historyOlder()
|
|
465
|
+
}
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
if (key.downArrow) {
|
|
469
|
+
const moved = moveCaretLine(v, c, contentWidth, 1)
|
|
470
|
+
if (moved !== c) {
|
|
471
|
+
cursorRef.current = moved
|
|
472
|
+
setCursor(moved)
|
|
473
|
+
} else {
|
|
474
|
+
historyNewer()
|
|
475
|
+
}
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (key.home) {
|
|
479
|
+
cursorRef.current = 0
|
|
480
|
+
setCursor(0)
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (key.end) {
|
|
484
|
+
cursorRef.current = v.length
|
|
485
|
+
setCursor(v.length)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
if (input && !key.ctrl && !key.meta && !isMouseResidue(input)) {
|
|
489
|
+
if (/[\u0000-\u001f\u007f]/.test(input)) return
|
|
490
|
+
applyEditAndRefreshHint(editInsert({ value: v, cursor: c }, input))
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
return { value, cursor, hintOpen, commandIndex, api: apiRef.current }
|
|
495
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-event suppression for ink's broadcast-based useInput.
|
|
3
|
+
*
|
|
4
|
+
* ink delivers every stdin chunk to every useInput subscriber, and delivery
|
|
5
|
+
* order follows subscription order: KeymapGate (chrome key contributions,
|
|
6
|
+
* mounted as the first child) -> message list -> composer -> panels ->
|
|
7
|
+
* dialog -> the App shell. A participant that must win over the later ones
|
|
8
|
+
* marks the current event consumed; later participants skip it.
|
|
9
|
+
*
|
|
10
|
+
* The consumed flag is scoped to one stdin chunk. KeymapGate bumps the
|
|
11
|
+
* generation from its own stdin data listener, so even several synchronous
|
|
12
|
+
* writes (as tests do) each get a fresh generation and no flag leaks across
|
|
13
|
+
* events.
|
|
14
|
+
*/
|
|
15
|
+
let generation = 0
|
|
16
|
+
let consumedAtGeneration = -1
|
|
17
|
+
|
|
18
|
+
/** Start a new key-event generation; called once per stdin chunk. */
|
|
19
|
+
export function beginKeyEvent(): void {
|
|
20
|
+
generation += 1
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Chrome key contributions consumed the event; later participants must skip it. */
|
|
24
|
+
export function markKeymapConsumed(): void {
|
|
25
|
+
consumedAtGeneration = generation
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True when the chrome key chain consumed the current key event. */
|
|
29
|
+
export function isKeyConsumed(): boolean {
|
|
30
|
+
return consumedAtGeneration === generation
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test helper. */
|
|
34
|
+
export function resetKeyEvents(): void {
|
|
35
|
+
generation = 0
|
|
36
|
+
consumedAtGeneration = -1
|
|
37
|
+
}
|
package/src/ui/keymap.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { keyedRegistry } from '../kernel/registry.ts'
|
|
2
|
+
import type { KeyBindingContribution, TuiKey } from '../contract/index.ts'
|
|
3
|
+
|
|
4
|
+
const bindings = keyedRegistry<KeyBindingContribution>()
|
|
5
|
+
|
|
6
|
+
export function registerKeyBinding(contribution: KeyBindingContribution): () => void {
|
|
7
|
+
return bindings.register(contribution.id, contribution, { order: contribution.order })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function handleKeyContributions(input: string, key: TuiKey): boolean {
|
|
11
|
+
for (const contribution of bindings.values()) {
|
|
12
|
+
if (contribution.handle(input, key) === true) return true
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CHROME_FRAME_ROWS, MESSAGE_INPUT_GAP_ROWS, SCROLLBAR_COL_FROM_EDGE, SCROLLBAR_GAP_COLS, CHROME_MARGIN_X, hintBlockTop } from '../core/metrics.ts'
|
|
2
|
+
import { HINT_INPUT_GAP_ROWS } from '../core/metrics.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single source of truth for the shell's derived geometry. Every consumer
|
|
6
|
+
* (App layout, pointer handlers, panels, message scrollbar) derives its
|
|
7
|
+
* region from these helpers instead of repeating the formulas, so changing
|
|
8
|
+
* the chrome skeleton only touches this file.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ShellGeometry {
|
|
12
|
+
columns: number
|
|
13
|
+
rows: number
|
|
14
|
+
/** Full height of the input block (frame rows + content rows). */
|
|
15
|
+
inputHeight: number
|
|
16
|
+
/** Height of the message area. */
|
|
17
|
+
messageHeight: number
|
|
18
|
+
/** Height of the active interaction panel body, when a panel owns the bottom. */
|
|
19
|
+
panelHeight: number | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function inputContentTop(rows: number, inputHeight: number): number {
|
|
23
|
+
return rows - inputHeight
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Inclusive bottom row of the input's editable content area. */
|
|
27
|
+
export function inputContentBottom(rows: number, inputHeight: number): number {
|
|
28
|
+
return inputContentTop(rows, inputHeight) + inputHeight - CHROME_FRAME_ROWS
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function inputContentContains(y: number, rows: number, inputHeight: number): boolean {
|
|
32
|
+
return y >= inputContentTop(rows, inputHeight) && y <= inputContentBottom(rows, inputHeight)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function messageHeightOf(rows: number, inputHeight: number): number {
|
|
36
|
+
return Math.max(1, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Inclusive row span of the active interaction panel (message bottom .. above the status line). */
|
|
40
|
+
export function panelSpan(geometry: ShellGeometry): { top: number; bottom: number } {
|
|
41
|
+
return { top: geometry.messageHeight, bottom: geometry.rows - 2 }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function panelContains(y: number, geometry: ShellGeometry): boolean {
|
|
45
|
+
const span = panelSpan(geometry)
|
|
46
|
+
return y >= span.top && y <= span.bottom
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Anchor row where a panel of `bodyRows` lines must start to sit above the status line. */
|
|
50
|
+
export function panelAnchorRow(rows: number, bodyRows: number): number {
|
|
51
|
+
return rows - 2 - bodyRows
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface HintSpan {
|
|
55
|
+
top: number
|
|
56
|
+
bottom: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hintSpan(rows: number, inputHeight: number, visibleCount: number, dialogOpen: boolean): HintSpan | null {
|
|
60
|
+
if (dialogOpen || visibleCount <= 0) return null
|
|
61
|
+
const top = hintBlockTop(rows, inputHeight, visibleCount)
|
|
62
|
+
const bottom = rows - inputHeight - MESSAGE_INPUT_GAP_ROWS - HINT_INPUT_GAP_ROWS
|
|
63
|
+
return { top, bottom }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hintContains(y: number, rows: number, inputHeight: number, visibleCount: number, dialogOpen: boolean): boolean {
|
|
67
|
+
const span = hintSpan(rows, inputHeight, visibleCount, dialogOpen)
|
|
68
|
+
return span !== null && y >= span.top && y <= span.bottom
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function scrollbarColumn(columns: number): number {
|
|
72
|
+
return columns - SCROLLBAR_COL_FROM_EDGE
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function messageBackgroundWidth(width: number): number {
|
|
76
|
+
return width - CHROME_MARGIN_X - SCROLLBAR_COL_FROM_EDGE - SCROLLBAR_GAP_COLS
|
|
77
|
+
}
|