@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
package/src/ui/app.tsx
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Box, Text, useInput } from 'ink'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
4
|
+
import type { RefObject } from 'react'
|
|
5
|
+
import { permissionModeInfo } from '../theme.ts'
|
|
6
|
+
import { writeClipboardText } from '../terminal/clipboard.ts'
|
|
7
|
+
import { copySelection } from './selection/service.ts'
|
|
8
|
+
import type { ChatBridge } from '../chat/bridge.ts'
|
|
9
|
+
import type { AgentActivity } from '../chat/store.ts'
|
|
10
|
+
import { rowIndexFor, selectionText } from './message/layout.ts'
|
|
11
|
+
import { glyphs } from '../terminal/glyphs.ts'
|
|
12
|
+
import { chromeSelectionText } from './selection-registry.ts'
|
|
13
|
+
import type { ScreenCapture } from '../terminal/screen.ts'
|
|
14
|
+
import type { LineSelection } from '../model/selection.ts'
|
|
15
|
+
import { SelectionContext } from './selection.tsx'
|
|
16
|
+
import { useTerminalSize } from './hooks/use-terminal-size.ts'
|
|
17
|
+
import { useChatEvents } from './hooks/use-chat-events.ts'
|
|
18
|
+
import { useScroll } from './hooks/use-scroll.ts'
|
|
19
|
+
import { useMouseSelection, hintRegion } from './hooks/use-mouse-selection.ts'
|
|
20
|
+
import { isKeyConsumed } from './key-arbiter.ts'
|
|
21
|
+
import { KeymapGate } from './chrome/key-gate.tsx'
|
|
22
|
+
import { useOverlayContribution } from './contributions.ts'
|
|
23
|
+
import { InputBar, inputLayout, INPUT_WIDTH_OFFSET, HINT_MAX_ROWS } from './input/input-bar.tsx'
|
|
24
|
+
import type { InputBarHandle } from './input/input-bar.tsx'
|
|
25
|
+
import { COMMANDS, KNOWN_COMMAND_ARGS, commandArgHints, filterHintEntries, matchCommand, mergeCommandEntries, matchAvailableCommand, useCommandVersion } from './input/commands.ts'
|
|
26
|
+
import { hintArgsFor } from './chrome/hint-service.ts'
|
|
27
|
+
import type { CommandAvailability, CommandDef } from './input/commands.ts'
|
|
28
|
+
import { MESSAGE_INPUT_GAP_ROWS } from '../core/metrics.ts'
|
|
29
|
+
import { useComposer } from './input/use-composer.ts'
|
|
30
|
+
import type { ComposerSubmission } from './input/composer-fields.ts'
|
|
31
|
+
import { Region } from './region.tsx'
|
|
32
|
+
import { MessageList } from './message/message-list.tsx'
|
|
33
|
+
import { useHomeLogoBubble } from './home-logo.ts'
|
|
34
|
+
import { SpinnerTickProvider } from './spinner-tick.tsx'
|
|
35
|
+
import { CloseGuardContext } from './dialog/dialog.tsx'
|
|
36
|
+
import type { DialogHandle } from './dialog/dialog.tsx'
|
|
37
|
+
import { registerBuiltinWindows } from './windows-builtin.tsx'
|
|
38
|
+
import { registerWindowServices, registerTodosService } from './window-services-bridge.ts'
|
|
39
|
+
import { useAvailableWindows } from './use-available-windows.ts'
|
|
40
|
+
import type { ActivePanel, InteractionStore } from '../chat/interactions.ts'
|
|
41
|
+
import type { PanelPointerHandle } from './panels/surface.tsx'
|
|
42
|
+
import { registerBuiltinPanels } from './panels-builtin.tsx'
|
|
43
|
+
import { isTodoActive, todoProgress } from '../chat/todo-view.ts'
|
|
44
|
+
import type { TodoItemLike } from '../chat/todo-view.ts'
|
|
45
|
+
import { useOverlayStack } from './overlay.ts'
|
|
46
|
+
import { handleKeyContributions } from './keymap.ts'
|
|
47
|
+
import { StatusBar } from './chrome/status-bar.tsx'
|
|
48
|
+
|
|
49
|
+
const FORCE_EXIT_DELAY_MS = 6000
|
|
50
|
+
const INTERRUPT_ARM_MS = 3000
|
|
51
|
+
|
|
52
|
+
const noopSubscribe = () => () => {}
|
|
53
|
+
const nullSnapshot = () => null
|
|
54
|
+
|
|
55
|
+
function useActivePanel(interactions: InteractionStore | undefined): ActivePanel | null {
|
|
56
|
+
return useSyncExternalStore(
|
|
57
|
+
interactions?.subscribe ?? noopSubscribe,
|
|
58
|
+
interactions?.getSnapshot ?? nullSnapshot,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AppProps {
|
|
63
|
+
bridge?: ChatBridge
|
|
64
|
+
screen?: ScreenCapture
|
|
65
|
+
themeTick?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function App({ bridge, screen, themeTick = 0 }: AppProps) {
|
|
69
|
+
const { columns, rows } = useTerminalSize()
|
|
70
|
+
const overlays = useOverlayStack<string>()
|
|
71
|
+
const dialog: string | null = overlays.top ?? null
|
|
72
|
+
const [, setSessionTick] = useState(0)
|
|
73
|
+
const { messages, modelName, setModelName, updateMessages, resetChat, activity, todos, retryStatus, streamedChars, registryCommands } = useChatEvents(bridge, dialog !== null)
|
|
74
|
+
const windows = useAvailableWindows()
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (bridge === undefined) return
|
|
77
|
+
const offServices = registerWindowServices(bridge)
|
|
78
|
+
const offPanels = registerBuiltinPanels({ bridge })
|
|
79
|
+
const offWindows = registerBuiltinWindows({
|
|
80
|
+
onModelSelected: (_provider, model) => setModelName(model),
|
|
81
|
+
onAddProvider: () => {
|
|
82
|
+
overlays.pop()
|
|
83
|
+
overlays.push('providers')
|
|
84
|
+
},
|
|
85
|
+
onBeforeSessionSelected: () => {
|
|
86
|
+
resetChat()
|
|
87
|
+
clearSelection()
|
|
88
|
+
},
|
|
89
|
+
onSessionSelected: () => {
|
|
90
|
+
setSessionTick(tick => tick + 1)
|
|
91
|
+
applyScroll(Infinity)
|
|
92
|
+
},
|
|
93
|
+
onNewSession: startNewSession,
|
|
94
|
+
})
|
|
95
|
+
void bridge.listSessions().catch(() => {})
|
|
96
|
+
return () => {
|
|
97
|
+
offWindows()
|
|
98
|
+
offPanels()
|
|
99
|
+
offServices()
|
|
100
|
+
}
|
|
101
|
+
}, [bridge])
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
return registerTodosService(todos)
|
|
104
|
+
}, [todos])
|
|
105
|
+
const todoActive = isTodoActive(todos)
|
|
106
|
+
const todoBadge = todoProgress(todos)
|
|
107
|
+
const running = activity.running
|
|
108
|
+
const dialogRef = useRef<DialogHandle | null>(null)
|
|
109
|
+
const inputRef = useRef<InputBarHandle | null>(null)
|
|
110
|
+
const panelHandleRef = useRef<PanelPointerHandle | null>(null)
|
|
111
|
+
const exiting = useRef(false)
|
|
112
|
+
const sendRef = useRef<(submission: ComposerSubmission) => void>(() => {})
|
|
113
|
+
const isCommandAvailable = useCallback<CommandAvailability>(
|
|
114
|
+
command => command.id !== 'todo' || todoActive,
|
|
115
|
+
[todoActive],
|
|
116
|
+
)
|
|
117
|
+
const busy = running || activity.compacting
|
|
118
|
+
const [escArmed, setEscArmed] = useState(false)
|
|
119
|
+
const escAtRef = useRef(0)
|
|
120
|
+
const escTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
121
|
+
const disarmInterrupt = () => {
|
|
122
|
+
escAtRef.current = 0
|
|
123
|
+
setEscArmed(false)
|
|
124
|
+
if (escTimerRef.current !== null) {
|
|
125
|
+
clearTimeout(escTimerRef.current)
|
|
126
|
+
escTimerRef.current = null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!running) disarmInterrupt()
|
|
131
|
+
return disarmInterrupt
|
|
132
|
+
}, [running])
|
|
133
|
+
const contentWidth = columns - INPUT_WIDTH_OFFSET
|
|
134
|
+
const panel = useActivePanel(bridge?.interactions)
|
|
135
|
+
const overlayContribution = useOverlayContribution(dialog)
|
|
136
|
+
const [panelHeight, setPanelHeight] = useState(7)
|
|
137
|
+
const composerInteractive = dialog === null && panel === null
|
|
138
|
+
const commandVersion = useCommandVersion()
|
|
139
|
+
const windowCommands = useMemo(
|
|
140
|
+
() => {
|
|
141
|
+
const staticNames = new Set(COMMANDS.map(command => command.command))
|
|
142
|
+
return windows
|
|
143
|
+
.filter(contribution => contribution.command !== undefined && !staticNames.has(`/${contribution.command.name}`))
|
|
144
|
+
.map(contribution => ({ id: contribution.id, command: `/${contribution.command!.name}`, description: contribution.command!.description }))
|
|
145
|
+
},
|
|
146
|
+
[windows],
|
|
147
|
+
)
|
|
148
|
+
const commandEntries = useMemo(
|
|
149
|
+
() => mergeCommandEntries([...COMMANDS.filter(isCommandAvailable), ...windowCommands], registryCommands),
|
|
150
|
+
[isCommandAvailable, windowCommands, registryCommands, commandVersion],
|
|
151
|
+
)
|
|
152
|
+
const registryNames = useMemo(
|
|
153
|
+
() => new Set(registryCommands.map(entry => `/${entry.name}`)),
|
|
154
|
+
[registryCommands],
|
|
155
|
+
)
|
|
156
|
+
const permissionPresetsRef = useRef<string[] | undefined>(undefined)
|
|
157
|
+
const listCommandArgs = useCallback(async (name: string): Promise<string[]> => {
|
|
158
|
+
// Plugin providers run first; builtins (registry hints, known args,
|
|
159
|
+
// permission presets) act as the fallback chain.
|
|
160
|
+
const contributed = await hintArgsFor(name, () => [])
|
|
161
|
+
if (contributed.length > 0) return contributed
|
|
162
|
+
const registered = commandArgHints(name)
|
|
163
|
+
if (registered !== undefined) return [...registered]
|
|
164
|
+
const known = KNOWN_COMMAND_ARGS[name]
|
|
165
|
+
if (known !== undefined) return [...known]
|
|
166
|
+
if (name !== 'permission') return []
|
|
167
|
+
const cached = permissionPresetsRef.current
|
|
168
|
+
if (cached !== undefined) return [...cached]
|
|
169
|
+
const list = await bridge?.listPermissionPresets?.() ?? []
|
|
170
|
+
permissionPresetsRef.current = list
|
|
171
|
+
return list
|
|
172
|
+
}, [bridge])
|
|
173
|
+
const { value, cursor, hintOpen, commandIndex, api } = useComposer(
|
|
174
|
+
submission => sendRef.current(submission),
|
|
175
|
+
composerInteractive,
|
|
176
|
+
contentWidth,
|
|
177
|
+
() => bridge?.cyclePermission(),
|
|
178
|
+
commandEntries,
|
|
179
|
+
listCommandArgs,
|
|
180
|
+
)
|
|
181
|
+
const commands = useMemo(() => filterHintEntries(commandEntries, value), [commandEntries, value])
|
|
182
|
+
const layout = inputLayout(value, cursor, columns)
|
|
183
|
+
const permissionMode = bridge?.permissionMode() ?? 'workspace-write'
|
|
184
|
+
const permissionChrome = permissionModeInfo(permissionMode)
|
|
185
|
+
const bottomHeight = panel === null ? layout.barHeight : panelHeight + 1
|
|
186
|
+
const inputHeight = bottomHeight
|
|
187
|
+
const messageHeight = Math.max(1, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS)
|
|
188
|
+
// The home page is a chat-styled surface for special messages; the logo
|
|
189
|
+
// bubble is its content. Any real message switches to the chat list and
|
|
190
|
+
// the logo disappears.
|
|
191
|
+
const logoBubble = useHomeLogoBubble(columns, messageHeight)
|
|
192
|
+
const displayMessages = messages.length === 0 ? [logoBubble] : messages
|
|
193
|
+
const total = rowIndexFor(displayMessages, columns).total
|
|
194
|
+
const { scrollTop, applyScroll, getScroll } = useScroll(total, messageHeight)
|
|
195
|
+
const startNewSession = () => {
|
|
196
|
+
if (!bridge) return
|
|
197
|
+
resetChat()
|
|
198
|
+
clearSelection()
|
|
199
|
+
applyScroll(Infinity)
|
|
200
|
+
void Promise.resolve(bridge.newSession())
|
|
201
|
+
.then(() => setSessionTick(tick => tick + 1))
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
}
|
|
204
|
+
const runCommand = (def: CommandDef) => {
|
|
205
|
+
if (def.run !== undefined) {
|
|
206
|
+
def.run()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
switch (def.id) {
|
|
210
|
+
case 'new':
|
|
211
|
+
startNewSession()
|
|
212
|
+
return
|
|
213
|
+
case 'todo':
|
|
214
|
+
if (bridge && todoActive) overlays.push('todo')
|
|
215
|
+
return
|
|
216
|
+
default:
|
|
217
|
+
if (bridge) overlays.push(def.id)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const handleSend = (submission: ComposerSubmission) => {
|
|
222
|
+
const text = submission.text
|
|
223
|
+
const matched = matchAvailableCommand(text, isCommandAvailable, windowCommands)
|
|
224
|
+
if (matched !== undefined) {
|
|
225
|
+
runCommand(matched)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (matchCommand(text) !== undefined) return
|
|
229
|
+
if (registryNames.has(text.split(/\s+/, 1)[0] ?? '')) {
|
|
230
|
+
void bridge?.executeCommandLine(text)
|
|
231
|
+
applyScroll(Infinity)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
bridge?.send(submission.text, submission.images)
|
|
235
|
+
applyScroll(Infinity)
|
|
236
|
+
}
|
|
237
|
+
sendRef.current = handleSend
|
|
238
|
+
const showHint = dialog === null && hintOpen && commands.length > 0
|
|
239
|
+
const maxVisible = Math.max(1, Math.min(HINT_MAX_ROWS, rows - inputHeight - 1))
|
|
240
|
+
const hintPrevIndexRef = useRef(0)
|
|
241
|
+
const hintDir: -1 | 1 = showHint && commandIndex !== hintPrevIndexRef.current
|
|
242
|
+
? commandIndex > hintPrevIndexRef.current ? 1 : -1
|
|
243
|
+
: 1
|
|
244
|
+
const hintAnchor = hintDir === 1 ? Math.floor(maxVisible / 2) : Math.floor((maxVisible - 1) / 2)
|
|
245
|
+
const hintVisibleStart = Math.max(0, Math.min(commandIndex - hintAnchor, Math.max(0, commands.length - maxVisible)))
|
|
246
|
+
hintPrevIndexRef.current = commandIndex
|
|
247
|
+
const hintState = showHint
|
|
248
|
+
? {
|
|
249
|
+
commands: commands.slice(hintVisibleStart, hintVisibleStart + maxVisible),
|
|
250
|
+
selectedIndex: commandIndex - hintVisibleStart,
|
|
251
|
+
startIndex: hintVisibleStart,
|
|
252
|
+
}
|
|
253
|
+
: null
|
|
254
|
+
const handleToggle = (id: string) => {
|
|
255
|
+
updateMessages(current => current.map(message =>
|
|
256
|
+
message.kind === 'collapsible' && message.id === id
|
|
257
|
+
? { ...message, collapsed: !message.collapsed }
|
|
258
|
+
: message,
|
|
259
|
+
))
|
|
260
|
+
}
|
|
261
|
+
const hintIndexAt = (y: number): number | undefined => {
|
|
262
|
+
const region = hintRegion(rows, hintState, dialog !== null, inputHeight)
|
|
263
|
+
if (region === null || hintState === null) return undefined
|
|
264
|
+
if (y < region.top || y > region.bottom) return undefined
|
|
265
|
+
const index = (hintState.startIndex ?? 0) + (y - region.top)
|
|
266
|
+
if (index < 0 || index >= commandEntries.length) return undefined
|
|
267
|
+
return index
|
|
268
|
+
}
|
|
269
|
+
const hintPendingPick = useRef<number | null>(null)
|
|
270
|
+
const handleHintPress = (_x: number, y: number): void => {
|
|
271
|
+
hintPendingPick.current = hintIndexAt(y) ?? null
|
|
272
|
+
}
|
|
273
|
+
const handleHintDragStart = (x: number, y: number): void => {
|
|
274
|
+
setSelection({ anchorRow: y, anchorCol: x, focusRow: y, focusCol: x, inMessage: false })
|
|
275
|
+
}
|
|
276
|
+
const handleHintDragMove = (x: number, y: number): void => {
|
|
277
|
+
setSelection(current => current === null || current.inMessage ? current : { ...current, focusRow: y, focusCol: x })
|
|
278
|
+
}
|
|
279
|
+
const handleHintRelease = (_x: number, _y: number, dragged: boolean): void => {
|
|
280
|
+
if (!dragged && hintPendingPick.current !== null) {
|
|
281
|
+
inputRef.current?.hintPick(hintPendingPick.current)
|
|
282
|
+
}
|
|
283
|
+
hintPendingPick.current = null
|
|
284
|
+
}
|
|
285
|
+
const selectionRef = useRef<LineSelection | null>(null)
|
|
286
|
+
const { selection, messageAreaSelection, chromeSelection, setSelection, clearSelection } = useMouseSelection({
|
|
287
|
+
messages: displayMessages,
|
|
288
|
+
columns,
|
|
289
|
+
rows,
|
|
290
|
+
scrollTop,
|
|
291
|
+
messageHeight,
|
|
292
|
+
inputHeight,
|
|
293
|
+
dialogOpen: dialog !== null,
|
|
294
|
+
hint: hintState,
|
|
295
|
+
screen,
|
|
296
|
+
inputHandle: inputRef,
|
|
297
|
+
panelActive: panel !== null,
|
|
298
|
+
panelHandle: panelHandleRef,
|
|
299
|
+
getScroll,
|
|
300
|
+
onHintPress: handleHintPress,
|
|
301
|
+
onHintDragStart: handleHintDragStart,
|
|
302
|
+
onHintDragMove: handleHintDragMove,
|
|
303
|
+
onHintRelease: handleHintRelease,
|
|
304
|
+
onDialogWheel: (y, dir) => dialogRef.current?.wheelAt(y, dir) === true,
|
|
305
|
+
onScroll: applyScroll,
|
|
306
|
+
onToggleMessage: handleToggle,
|
|
307
|
+
onDialogClick: (y, x) => dialogRef.current?.clickAt(y, x),
|
|
308
|
+
})
|
|
309
|
+
selectionRef.current = selection
|
|
310
|
+
useInput((input, key) => {
|
|
311
|
+
if (isKeyConsumed() && !(key.ctrl && input === 'c' && selection !== null)) return
|
|
312
|
+
if (dialog !== null && !selection) return
|
|
313
|
+
if (key.escape) {
|
|
314
|
+
if (hintOpen) return
|
|
315
|
+
if (panel === null && busy && bridge !== undefined) {
|
|
316
|
+
const now = Date.now()
|
|
317
|
+
if (escArmed && now - escAtRef.current <= INTERRUPT_ARM_MS) {
|
|
318
|
+
disarmInterrupt()
|
|
319
|
+
bridge.interrupt()
|
|
320
|
+
} else {
|
|
321
|
+
escAtRef.current = now
|
|
322
|
+
setEscArmed(true)
|
|
323
|
+
if (escTimerRef.current !== null) clearTimeout(escTimerRef.current)
|
|
324
|
+
escTimerRef.current = setTimeout(disarmInterrupt, INTERRUPT_ARM_MS)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (key.ctrl && input === 'c') {
|
|
330
|
+
if (selection) {
|
|
331
|
+
const text = copySelection(selection, {
|
|
332
|
+
messageText: sel => selectionText(displayMessages, columns, sel),
|
|
333
|
+
chromeText: sel => chromeSelectionText(sel),
|
|
334
|
+
})
|
|
335
|
+
if (text) writeClipboardText(text)
|
|
336
|
+
clearSelection()
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
if (!exiting.current) {
|
|
340
|
+
exiting.current = true
|
|
341
|
+
process.kill(process.pid, 'SIGINT')
|
|
342
|
+
setTimeout(() => process.exit(0), FORCE_EXIT_DELAY_MS).unref()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
return (
|
|
347
|
+
<SpinnerTickProvider busy={busy}>
|
|
348
|
+
<SelectionContext.Provider value={messageAreaSelection}>
|
|
349
|
+
<Box flexDirection="column" width={columns} height={rows}>
|
|
350
|
+
<KeymapGate />
|
|
351
|
+
<MessageList
|
|
352
|
+
messages={displayMessages}
|
|
353
|
+
height={messageHeight}
|
|
354
|
+
width={columns}
|
|
355
|
+
scrollTop={scrollTop}
|
|
356
|
+
onScroll={applyScroll}
|
|
357
|
+
interactive={dialog === null}
|
|
358
|
+
themeTick={themeTick}
|
|
359
|
+
/>
|
|
360
|
+
<SelectionContext.Provider value={chromeSelection}>
|
|
361
|
+
{panel === null && (
|
|
362
|
+
<InputBar
|
|
363
|
+
ref={inputRef}
|
|
364
|
+
width={columns}
|
|
365
|
+
columns={columns}
|
|
366
|
+
rows={rows}
|
|
367
|
+
value={value}
|
|
368
|
+
cursor={cursor}
|
|
369
|
+
api={api}
|
|
370
|
+
statusReady={bridge !== undefined}
|
|
371
|
+
modelName={bridge?.modelName() ?? modelName}
|
|
372
|
+
permissionMode={permissionMode}
|
|
373
|
+
effortName={bridge?.effortName()}
|
|
374
|
+
presetName={bridge?.presetName()}
|
|
375
|
+
interactive={composerInteractive}
|
|
376
|
+
hint={hintState}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
{panel !== null && bridge !== undefined && (() => {
|
|
380
|
+
const contribution = bridge.interactions.panels.of(panel.kind)
|
|
381
|
+
if (contribution === undefined) return null
|
|
382
|
+
const Panel = contribution.component
|
|
383
|
+
const request = panel.kind === 'approval'
|
|
384
|
+
? { approval: panel.approval }
|
|
385
|
+
: panel.kind === 'question'
|
|
386
|
+
? { question: panel.question }
|
|
387
|
+
: panel.request
|
|
388
|
+
const key = panel.kind === 'approval'
|
|
389
|
+
? `approval-${panel.approval?.id ?? ''}`
|
|
390
|
+
: panel.kind === 'question'
|
|
391
|
+
? `question-${panel.question?.id ?? ''}`
|
|
392
|
+
: panel.kind
|
|
393
|
+
return (
|
|
394
|
+
<Panel
|
|
395
|
+
key={key}
|
|
396
|
+
request={request}
|
|
397
|
+
resolve={value => {
|
|
398
|
+
if (panel.kind === 'approval') panel.settle?.(value)
|
|
399
|
+
else panel.resolve?.(value)
|
|
400
|
+
}}
|
|
401
|
+
reject={cause => {
|
|
402
|
+
if (panel.kind === 'approval') panel.settle?.('cancelled')
|
|
403
|
+
else panel.reject?.(cause)
|
|
404
|
+
}}
|
|
405
|
+
active={dialog === null}
|
|
406
|
+
columns={columns}
|
|
407
|
+
rows={rows}
|
|
408
|
+
innerWidth={contentWidth}
|
|
409
|
+
blockWidth={contentWidth + 4}
|
|
410
|
+
background={permissionChrome.color}
|
|
411
|
+
handleRef={panelHandleRef}
|
|
412
|
+
onResize={setPanelHeight}
|
|
413
|
+
/>
|
|
414
|
+
)
|
|
415
|
+
})()}
|
|
416
|
+
{bridge !== undefined && (
|
|
417
|
+
<StatusBar
|
|
418
|
+
bridge={bridge}
|
|
419
|
+
columns={columns}
|
|
420
|
+
top={rows - 1}
|
|
421
|
+
busy={busy}
|
|
422
|
+
running={running}
|
|
423
|
+
todoBadge={todoBadge}
|
|
424
|
+
activity={activity}
|
|
425
|
+
panel={panel}
|
|
426
|
+
retryStatus={retryStatus}
|
|
427
|
+
escArmed={escArmed}
|
|
428
|
+
streamedChars={streamedChars}
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
431
|
+
</SelectionContext.Provider>
|
|
432
|
+
{dialog !== null && (() => {
|
|
433
|
+
const entry = windows.find(candidate => candidate.id === dialog)
|
|
434
|
+
if (entry !== undefined && bridge !== undefined) {
|
|
435
|
+
const Window = entry.component
|
|
436
|
+
return (
|
|
437
|
+
<CloseGuardContext.Provider value={selection !== null}>
|
|
438
|
+
<SelectionContext.Provider value={chromeSelection}>
|
|
439
|
+
<Window open handleRef={dialogRef} onClose={() => overlays.pop()} />
|
|
440
|
+
</SelectionContext.Provider>
|
|
441
|
+
</CloseGuardContext.Provider>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
const overlay = overlayContribution
|
|
445
|
+
if (overlay !== undefined) {
|
|
446
|
+
return <Region>{overlay.render({ onClose: () => overlays.pop() })}</Region>
|
|
447
|
+
}
|
|
448
|
+
return null
|
|
449
|
+
})()}
|
|
450
|
+
</Box>
|
|
451
|
+
</SelectionContext.Provider>
|
|
452
|
+
</SpinnerTickProvider>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Text } from 'ink'
|
|
2
|
+
import { glyphs } from '../../terminal/glyphs.ts'
|
|
3
|
+
|
|
4
|
+
export function CapText({ background, top, width }: { background: string; top: boolean; width: number }) {
|
|
5
|
+
if (glyphs.halfBlockCaps) {
|
|
6
|
+
return <Text color={background}>{(top ? glyphs.blockCapTop : glyphs.blockCapBottom).repeat(width)}</Text>
|
|
7
|
+
}
|
|
8
|
+
return <Text backgroundColor={background}>{' '.repeat(width)}</Text>
|
|
9
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
2
|
+
|
|
3
|
+
export interface HintMatchContext {
|
|
4
|
+
/** The raw composer value; always starts with '/' without whitespace. */
|
|
5
|
+
value: string
|
|
6
|
+
/** Value without the leading slash, lowercased. */
|
|
7
|
+
query: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HintMatcherContribution {
|
|
11
|
+
id: string
|
|
12
|
+
order?: number
|
|
13
|
+
/**
|
|
14
|
+
* Filter and rank command hint entries. Return the ordered list to use;
|
|
15
|
+
* return null to fall through to the next matcher (builtin tiers last).
|
|
16
|
+
*/
|
|
17
|
+
match(entries: readonly HintEntry[], context: HintMatchContext): HintEntry[] | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HintEntry {
|
|
21
|
+
command: string
|
|
22
|
+
description: string
|
|
23
|
+
hint?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HintArgsContribution {
|
|
27
|
+
id: string
|
|
28
|
+
order?: number
|
|
29
|
+
/**
|
|
30
|
+
* Provide literal argument completions for a command name (without slash);
|
|
31
|
+
* return null to fall through to the next provider.
|
|
32
|
+
*/
|
|
33
|
+
args(name: string): string[] | null | Promise<string[] | null>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const matchers = keyedRegistry<HintMatcherContribution>()
|
|
37
|
+
const argProviders = keyedRegistry<HintArgsContribution>()
|
|
38
|
+
|
|
39
|
+
const BUILTIN_ORDER = 500
|
|
40
|
+
|
|
41
|
+
export function registerHintMatcher(contribution: HintMatcherContribution): () => void {
|
|
42
|
+
return matchers.register(contribution.id, contribution, { order: contribution.order ?? 100 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerHintArgsProvider(contribution: HintArgsContribution): () => void {
|
|
46
|
+
return argProviders.register(contribution.id, contribution, { order: contribution.order ?? 100 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** First matcher (by order) that returns a list wins; null falls through. */
|
|
50
|
+
export function matchHintEntries(entries: readonly HintEntry[], value: string): HintEntry[] {
|
|
51
|
+
const context: HintMatchContext = { value, query: value.slice(1).toLowerCase() }
|
|
52
|
+
for (const matcher of matchers.values()) {
|
|
53
|
+
const result = matcher.match(entries, context)
|
|
54
|
+
if (result !== null) return result
|
|
55
|
+
}
|
|
56
|
+
return tieredMatch(entries, context.query)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Builtin three-tier matching: prefix, subsequence, description substring. */
|
|
60
|
+
export function tieredMatch(entries: readonly HintEntry[], query: string): HintEntry[] {
|
|
61
|
+
const tiers: HintEntry[][] = [[], [], []]
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const name = entry.command.slice(1).toLowerCase()
|
|
64
|
+
if (name.startsWith(query)) tiers[0]!.push(entry)
|
|
65
|
+
else if (isSubsequence(query, name)) tiers[1]!.push(entry)
|
|
66
|
+
else if (entry.description.toLowerCase().includes(query)) tiers[2]!.push(entry)
|
|
67
|
+
}
|
|
68
|
+
return [...tiers[0]!, ...tiers[1]!, ...tiers[2]!]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isSubsequence(query: string, name: string): boolean {
|
|
72
|
+
let at = 0
|
|
73
|
+
for (const ch of name) {
|
|
74
|
+
if (ch === query[at]) at += 1
|
|
75
|
+
if (at === query.length) return true
|
|
76
|
+
}
|
|
77
|
+
return query.length === 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** First provider (by order) that returns a non-null list wins. */
|
|
81
|
+
export async function hintArgsFor(name: string, builtin: (name: string) => string[]): Promise<string[]> {
|
|
82
|
+
for (const provider of argProviders.values()) {
|
|
83
|
+
const result = await provider.args(name)
|
|
84
|
+
if (result !== null) return result
|
|
85
|
+
}
|
|
86
|
+
return builtin(name)
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { keyedRegistry } from '../../kernel/registry.ts'
|
|
2
|
+
import type { InputStatusContribution, InputStatusContext } from '../../contract/index.ts'
|
|
3
|
+
|
|
4
|
+
const inputStatus = keyedRegistry<InputStatusContribution>()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Contributions render segments into the composer status line, between the
|
|
8
|
+
* builtin mode/model/effort parts and the caret nonce. Order controls the
|
|
9
|
+
* sequence; default 100.
|
|
10
|
+
*/
|
|
11
|
+
export function registerInputStatus(contribution: InputStatusContribution): () => void {
|
|
12
|
+
return inputStatus.register(contribution.id, contribution, { order: contribution.order })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inputStatusParts(context: InputStatusContext): Array<{ text: string; color?: string; bold?: boolean }> {
|
|
16
|
+
const parts: Array<{ text: string; color?: string; bold?: boolean }> = []
|
|
17
|
+
for (const contribution of inputStatus.values()) {
|
|
18
|
+
const rendered = contribution.render(context)
|
|
19
|
+
if (rendered === null || rendered === undefined) continue
|
|
20
|
+
const list = Array.isArray(rendered) ? rendered : [rendered]
|
|
21
|
+
for (const part of list) {
|
|
22
|
+
if (part.text === '') continue
|
|
23
|
+
parts.push(part.color === undefined && part.bold === undefined ? { text: part.text } : part)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return parts
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useInput, useStdin } from 'ink'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import { beginKeyEvent, markKeymapConsumed } from '../key-arbiter.ts'
|
|
4
|
+
import { handleKeyContributions } from '../keymap.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Earliest key subscriber. Chrome key contributions are evaluated here with
|
|
8
|
+
* true preemption over every later participant (message list, composer,
|
|
9
|
+
* panels, dialog, shell), which all observe the arbiter's consumed flag.
|
|
10
|
+
* A new arbitration generation starts per stdin data chunk so a consumed
|
|
11
|
+
* flag never leaks into the next physical key event, however events are
|
|
12
|
+
* batched by the terminal or the test harness.
|
|
13
|
+
*/
|
|
14
|
+
export function KeymapGate(): null {
|
|
15
|
+
const { stdin } = useStdin()
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const onData = (): void => {
|
|
18
|
+
beginKeyEvent()
|
|
19
|
+
}
|
|
20
|
+
stdin?.on('data', onData)
|
|
21
|
+
return () => {
|
|
22
|
+
stdin?.off('data', onData)
|
|
23
|
+
}
|
|
24
|
+
}, [stdin])
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (handleKeyContributions(input, key)) markKeymapConsumed()
|
|
27
|
+
})
|
|
28
|
+
return null
|
|
29
|
+
}
|