@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,63 @@
|
|
|
1
|
+
export interface LineSelection {
|
|
2
|
+
anchorRow: number
|
|
3
|
+
anchorCol: number
|
|
4
|
+
focusRow: number
|
|
5
|
+
focusCol: number
|
|
6
|
+
inMessage: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function selectedRange(sel: LineSelection, row: number): { start: number; end: number } | null {
|
|
10
|
+
const top = Math.min(sel.anchorRow, sel.focusRow)
|
|
11
|
+
const bottom = Math.max(sel.anchorRow, sel.focusRow)
|
|
12
|
+
if (row < top || row > bottom) return null
|
|
13
|
+
if (sel.anchorRow === sel.focusRow) {
|
|
14
|
+
return { start: Math.min(sel.anchorCol, sel.focusCol), end: Math.max(sel.anchorCol, sel.focusCol) }
|
|
15
|
+
}
|
|
16
|
+
const upward = sel.focusRow < sel.anchorRow
|
|
17
|
+
if (row === sel.anchorRow) {
|
|
18
|
+
return upward ? { start: 0, end: sel.anchorCol } : { start: sel.anchorCol, end: Infinity }
|
|
19
|
+
}
|
|
20
|
+
if (row === sel.focusRow) {
|
|
21
|
+
return upward ? { start: sel.focusCol, end: Infinity } : { start: 0, end: sel.focusCol }
|
|
22
|
+
}
|
|
23
|
+
return { start: 0, end: Infinity }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toScreenSelection(
|
|
27
|
+
selection: LineSelection | null,
|
|
28
|
+
scrollTop: number,
|
|
29
|
+
messageHeight: number,
|
|
30
|
+
): LineSelection | null {
|
|
31
|
+
if (selection === null) return null
|
|
32
|
+
if (!selection.inMessage) return selection
|
|
33
|
+
const rawAnchor = selection.anchorRow - scrollTop
|
|
34
|
+
const rawFocus = selection.focusRow - scrollTop
|
|
35
|
+
const visible = (row: number) => row >= 0 && row < messageHeight
|
|
36
|
+
if (!visible(rawAnchor) && !visible(rawFocus)) return null
|
|
37
|
+
const clampRow = (row: number): number => (row < 0 ? 0 : row >= messageHeight ? messageHeight - 1 : row)
|
|
38
|
+
const snapCol = (raw: number, col: number): number => (raw < 0 ? 0 : raw >= messageHeight ? Infinity : col)
|
|
39
|
+
return {
|
|
40
|
+
anchorRow: clampRow(rawAnchor),
|
|
41
|
+
anchorCol: snapCol(rawAnchor, selection.anchorCol),
|
|
42
|
+
focusRow: clampRow(rawFocus),
|
|
43
|
+
focusCol: snapCol(rawFocus, selection.focusCol),
|
|
44
|
+
inMessage: true,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function clampFocusRow(
|
|
49
|
+
inMessage: boolean,
|
|
50
|
+
eventY: number,
|
|
51
|
+
scrollTop: number,
|
|
52
|
+
messageHeight: number,
|
|
53
|
+
rows: number,
|
|
54
|
+
dialogOpen: boolean,
|
|
55
|
+
): number {
|
|
56
|
+
if (inMessage) {
|
|
57
|
+
return Math.min(eventY + scrollTop, scrollTop + messageHeight - 1)
|
|
58
|
+
}
|
|
59
|
+
if (dialogOpen) {
|
|
60
|
+
return Math.max(0, Math.min(eventY, rows - 1))
|
|
61
|
+
}
|
|
62
|
+
return Math.max(messageHeight, eventY)
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks'
|
|
2
|
+
|
|
3
|
+
const TIMELINE_SWEEP_MS = 10_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The development react-reconciler build records one user-timing entry per
|
|
7
|
+
* component render into the global timeline, which Node never evicts by
|
|
8
|
+
* itself, so a long streaming session grows the heap until OOM. The entry
|
|
9
|
+
* sink is read at call time, so neutralising it before the first render
|
|
10
|
+
* keeps the timeline flat regardless of which build is loaded; the periodic
|
|
11
|
+
* sweep drains anything recorded outside this process boundary.
|
|
12
|
+
*/
|
|
13
|
+
export function startPerformanceGuard(): () => void {
|
|
14
|
+
const measure = performance.measure.bind(performance)
|
|
15
|
+
const mark = performance.mark.bind(performance)
|
|
16
|
+
performance.measure = (() => {}) as unknown as typeof performance.measure
|
|
17
|
+
performance.mark = (() => {}) as unknown as typeof performance.mark
|
|
18
|
+
const timer = setInterval(() => {
|
|
19
|
+
performance.clearMeasures()
|
|
20
|
+
performance.clearMarks()
|
|
21
|
+
}, TIMELINE_SWEEP_MS)
|
|
22
|
+
timer.unref()
|
|
23
|
+
return () => {
|
|
24
|
+
clearInterval(timer)
|
|
25
|
+
performance.measure = measure
|
|
26
|
+
performance.mark = mark
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// React picks its development or production build at import time from
|
|
2
|
+
// NODE_ENV; the dsh launcher leaves it unset, so the default resolution loads
|
|
3
|
+
// react.development.js and every reconciler frame pays roughly twice the
|
|
4
|
+
// CPU. Pin production before the UI graph's react imports evaluate — this
|
|
5
|
+
// module must stay the first import of every entry that pulls react. The
|
|
6
|
+
// host reads NODE_ENV only during module loading (verified across
|
|
7
|
+
// @deepseek-ai packages), so the pin is inert outside module resolution.
|
|
8
|
+
if (process.env.NODE_ENV === undefined || process.env.NODE_ENV === '') {
|
|
9
|
+
process.env.NODE_ENV = 'production'
|
|
10
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { colorLevel } from './capabilities.ts'
|
|
2
|
+
import { env } from '../env.ts'
|
|
3
|
+
import type { ThemeMode } from '../theme.ts'
|
|
4
|
+
|
|
5
|
+
const OSC11_QUERY = '\x1b]11;?\x1b\\'
|
|
6
|
+
const OSC11_RESPONSE = /\x1b]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)/
|
|
7
|
+
const OSC11_TIMEOUT_MS = 250
|
|
8
|
+
|
|
9
|
+
function parseColorFgbg(): ThemeMode | undefined {
|
|
10
|
+
const raw = process.env.COLORFGBG
|
|
11
|
+
if (raw === undefined) return undefined
|
|
12
|
+
const bg = Number(raw.split(';')[1])
|
|
13
|
+
if (Number.isNaN(bg)) return undefined
|
|
14
|
+
return bg === 7 || bg === 15 ? 'light' : 'dark'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function luminance(r: number, g: number, b: number): number {
|
|
18
|
+
return 0.299 * r + 0.587 * g + 0.114 * b
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function queryOsc11Background(): Promise<ThemeMode | undefined> {
|
|
22
|
+
return new Promise(resolve => {
|
|
23
|
+
const stdin = process.stdin
|
|
24
|
+
if (colorLevel === 0 || !stdin.isTTY || !process.stdout.isTTY || stdin.setRawMode === undefined) {
|
|
25
|
+
resolve(undefined)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
let buffer = ''
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
30
|
+
const finish = (result: ThemeMode | undefined): void => {
|
|
31
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
32
|
+
stdin.off('data', onData)
|
|
33
|
+
try {
|
|
34
|
+
stdin.setRawMode(false)
|
|
35
|
+
} catch {
|
|
36
|
+
// The stream may have closed while the probe was in flight; nothing to restore.
|
|
37
|
+
}
|
|
38
|
+
resolve(result)
|
|
39
|
+
}
|
|
40
|
+
const onData = (chunk: Buffer | string): void => {
|
|
41
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
42
|
+
const match = OSC11_RESPONSE.exec(buffer)
|
|
43
|
+
if (match === null) return
|
|
44
|
+
const channel = (hex: string): number => parseInt(hex, 16) / (16 ** hex.length - 1)
|
|
45
|
+
const bright = luminance(channel(match[1]), channel(match[2]), channel(match[3])) > 0.5
|
|
46
|
+
finish(bright ? 'light' : 'dark')
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
stdin.setRawMode(true)
|
|
50
|
+
} catch {
|
|
51
|
+
// Raw mode is unavailable (piped stdin or non-TTY); default to dark.
|
|
52
|
+
resolve(undefined)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
stdin.on('data', onData)
|
|
56
|
+
process.stdout.write(OSC11_QUERY)
|
|
57
|
+
timer = setTimeout(() => finish(undefined), OSC11_TIMEOUT_MS)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function detectBackgroundMode(): Promise<ThemeMode> {
|
|
62
|
+
if (env.background === 'dark' || env.background === 'light') return env.background
|
|
63
|
+
const colorFgbg = parseColorFgbg()
|
|
64
|
+
if (colorFgbg !== undefined) return colorFgbg
|
|
65
|
+
return (await queryOsc11Background()) ?? 'dark'
|
|
66
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { env } from '../env.ts'
|
|
3
|
+
|
|
4
|
+
export type ColorLevel = 0 | 1 | 2 | 3
|
|
5
|
+
|
|
6
|
+
function parseForcedColorLevel(value: string | undefined): ColorLevel | undefined {
|
|
7
|
+
if (value === '0' || value === '1' || value === '2' || value === '3') return Number(value) as ColorLevel
|
|
8
|
+
if (value === 'truecolor') return 3
|
|
9
|
+
if (value === 'none' || value === 'false') return 0
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function detectColorLevel(): ColorLevel {
|
|
14
|
+
const forced = parseForcedColorLevel(env.color)
|
|
15
|
+
if (forced !== undefined) return forced
|
|
16
|
+
if (process.env.NO_COLOR !== undefined) return 0
|
|
17
|
+
const forceColor = process.env.FORCE_COLOR
|
|
18
|
+
if (forceColor !== undefined) {
|
|
19
|
+
if (forceColor === 'false' || forceColor === '0') return 0
|
|
20
|
+
if (forceColor === 'true' || forceColor === '') return 1
|
|
21
|
+
const parsed = Number(forceColor)
|
|
22
|
+
if (parsed === 1 || parsed === 2 || parsed === 3) return parsed
|
|
23
|
+
}
|
|
24
|
+
const term = process.env.TERM ?? ''
|
|
25
|
+
if (term === 'dumb') return 0
|
|
26
|
+
const colorterm = process.env.COLORTERM ?? ''
|
|
27
|
+
if (colorterm === 'truecolor' || colorterm === '24bit') return 3
|
|
28
|
+
if (term.includes('truecolor')) return 3
|
|
29
|
+
if (process.env.WT_SESSION !== undefined) return 3
|
|
30
|
+
if (term.includes('256color')) return 2
|
|
31
|
+
if (term !== '' || process.env.TERM_PROGRAM !== undefined) return 1
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function detectUnicode(): boolean {
|
|
36
|
+
if (env.ascii) return false
|
|
37
|
+
if ((process.env.TERM ?? '') === 'dumb') return false
|
|
38
|
+
const locale = process.env.LC_ALL ?? process.env.LANG ?? ''
|
|
39
|
+
if (locale === '' || /utf-?8/i.test(locale)) return true
|
|
40
|
+
const base = locale.split('.')[0] ?? locale
|
|
41
|
+
if (base === 'C' || base === 'POSIX') return false
|
|
42
|
+
return !locale.includes('.')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export let colorLevel: ColorLevel = detectColorLevel()
|
|
46
|
+
|
|
47
|
+
export function setColorLevel(level: ColorLevel): void {
|
|
48
|
+
colorLevel = level
|
|
49
|
+
chalk.level = level
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
chalk.level = colorLevel
|
|
53
|
+
|
|
54
|
+
export const unicode: boolean = detectUnicode()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { keyedRegistry } from '../kernel/registry.ts'
|
|
2
|
+
|
|
3
|
+
export interface ClipboardBackendContribution {
|
|
4
|
+
id: string
|
|
5
|
+
order?: number
|
|
6
|
+
/** Read text from the clipboard; return undefined to fall through. */
|
|
7
|
+
readText?(): string | undefined | Promise<string | undefined>
|
|
8
|
+
/** Read an image; return undefined to fall through. */
|
|
9
|
+
readImage?(): { data: Uint8Array; mediaType: string } | undefined | Promise<{ data: Uint8Array; mediaType: string } | undefined>
|
|
10
|
+
/** Write text; return true to stop the write chain. */
|
|
11
|
+
writeText?(text: string): boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const backends = keyedRegistry<ClipboardBackendContribution>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Plugin clipboard backends run before the builtin platform chain (default
|
|
18
|
+
* order 100 vs builtin 500): a backend that returns undefined lets the next
|
|
19
|
+
* one try. Write chains stop at the first backend that returns true.
|
|
20
|
+
*/
|
|
21
|
+
export const CLIPBOARD_BUILTIN_ORDER = 500
|
|
22
|
+
|
|
23
|
+
export function registerClipboardBackend(contribution: ClipboardBackendContribution): () => void {
|
|
24
|
+
return backends.register(contribution.id, contribution, { order: contribution.order ?? 100 })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function subscribeClipboardBackends(listener: () => void): () => void {
|
|
28
|
+
return backends.subscribe(listener)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function clipboardReadText(builtin: () => Promise<string | undefined>): Promise<string | undefined> {
|
|
32
|
+
for (const backend of backends.values()) {
|
|
33
|
+
if (backend.readText === undefined) continue
|
|
34
|
+
try {
|
|
35
|
+
const result = await backend.readText()
|
|
36
|
+
if (result !== undefined) return result
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through to the next backend
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return builtin()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function clipboardReadImage(
|
|
45
|
+
builtin: () => Promise<{ data: Uint8Array; mediaType: string } | undefined>,
|
|
46
|
+
): Promise<{ data: Uint8Array; mediaType: string } | undefined> {
|
|
47
|
+
for (const backend of backends.values()) {
|
|
48
|
+
if (backend.readImage === undefined) continue
|
|
49
|
+
try {
|
|
50
|
+
const result = await backend.readImage()
|
|
51
|
+
if (result !== undefined) return result
|
|
52
|
+
} catch {
|
|
53
|
+
// fall through to the next backend
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return builtin()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clipboardWriteText(text: string, builtin: (text: string) => void): void {
|
|
60
|
+
for (const backend of backends.values()) {
|
|
61
|
+
if (backend.writeText === undefined) continue
|
|
62
|
+
try {
|
|
63
|
+
if (backend.writeText(text) === true) return
|
|
64
|
+
} catch {
|
|
65
|
+
// fall through to the next backend
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
builtin(text)
|
|
69
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { platform, release, tmpdir } from 'node:os'
|
|
3
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { clipboardReadImage, clipboardReadText, clipboardWriteText } from './clipboard-backends.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Clipboard writes go through OSC52 only: one escape sequence to the terminal,
|
|
9
|
+
* no child processes. The terminal owns the system clipboard, so the app never
|
|
10
|
+
* races platform tools (clip.exe mangles unicode through the console codepage
|
|
11
|
+
* and every spawned helper adds copy latency for no benefit).
|
|
12
|
+
*/
|
|
13
|
+
export function writeOsc52(text: string): void {
|
|
14
|
+
const encoded = Buffer.from(text, 'utf8').toString('base64')
|
|
15
|
+
process.stdout.write(`\x1b]52;c;${encoded}\x07`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isWsl(): boolean {
|
|
19
|
+
if (platform() !== 'linux') return false
|
|
20
|
+
if (process.env.WSL_DISTRO_NAME !== undefined) return true
|
|
21
|
+
return release().toLowerCase().includes('microsoft')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ClipboardImage {
|
|
25
|
+
data: Uint8Array
|
|
26
|
+
mediaType: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EXEC_TIMEOUT_MS = 2000
|
|
30
|
+
const MAX_CAPTURE_BYTES = 32 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
function capture(command: string, args: string[]): Promise<Buffer> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
execFile(command, args, { timeout: EXEC_TIMEOUT_MS, maxBuffer: MAX_CAPTURE_BYTES, encoding: 'buffer' }, (error, stdout) => {
|
|
35
|
+
if (error !== null) {
|
|
36
|
+
reject(error)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (stdout.length === 0) {
|
|
40
|
+
reject(new Error(`${command} returned nothing`))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
resolve(stdout)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function firstCapture(attempts: Array<{ command: string; args: string[]; mediaType: string }>): Promise<ClipboardImage | undefined> {
|
|
49
|
+
for (const attempt of attempts) {
|
|
50
|
+
try {
|
|
51
|
+
const data = new Uint8Array(await capture(attempt.command, attempt.args))
|
|
52
|
+
return { data, mediaType: attempt.mediaType }
|
|
53
|
+
} catch {
|
|
54
|
+
// The helper is missing or returned nothing; try the next backend.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function firstText(attempts: Array<{ command: string; args: string[] }>): Promise<string | undefined> {
|
|
61
|
+
for (const attempt of attempts) {
|
|
62
|
+
try {
|
|
63
|
+
const data = await capture(attempt.command, attempt.args)
|
|
64
|
+
return data.toString('utf8')
|
|
65
|
+
} catch {
|
|
66
|
+
// The helper is missing or returned nothing; try the next backend.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readClipboardImageBuiltin(): Promise<ClipboardImage | undefined> {
|
|
73
|
+
if (platform() === 'darwin') {
|
|
74
|
+
const file = join(tmpdir(), 'dshtui-clipboard.png')
|
|
75
|
+
const escaped = file.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
76
|
+
const script = [
|
|
77
|
+
'set imageData to the clipboard as "PNGf"',
|
|
78
|
+
`set fileRef to open for access POSIX file "${escaped}" with write permission`,
|
|
79
|
+
'set eof fileRef to 0',
|
|
80
|
+
'write imageData to fileRef',
|
|
81
|
+
'close access fileRef',
|
|
82
|
+
]
|
|
83
|
+
const args: string[] = []
|
|
84
|
+
for (const line of script) args.push('-e', line)
|
|
85
|
+
try {
|
|
86
|
+
await capture('osascript', args)
|
|
87
|
+
const data = new Uint8Array(await readFile(file))
|
|
88
|
+
return { data, mediaType: 'image/png' }
|
|
89
|
+
} catch {
|
|
90
|
+
// No image on the clipboard (or osascript missing); report undefined.
|
|
91
|
+
return undefined
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(file, { force: true }).catch(() => {})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const unix = await firstCapture([
|
|
97
|
+
{ command: 'wl-paste', args: ['-t', 'image/png'], mediaType: 'image/png' },
|
|
98
|
+
{ command: 'wl-paste', args: ['-t', 'image/jpeg'], mediaType: 'image/jpeg' },
|
|
99
|
+
{ command: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], mediaType: 'image/png' },
|
|
100
|
+
{ command: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/jpeg', '-o'], mediaType: 'image/jpeg' },
|
|
101
|
+
])
|
|
102
|
+
if (unix !== undefined) return unix
|
|
103
|
+
if (platform() === 'win32' || isWsl()) {
|
|
104
|
+
const script = 'Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([Convert]::ToBase64String($ms.ToArray())) }'
|
|
105
|
+
try {
|
|
106
|
+
const base64 = (await capture('powershell.exe', ['-NonInteractive', '-NoProfile', '-command', script])).toString('utf8').trim()
|
|
107
|
+
if (base64 === '') return undefined
|
|
108
|
+
return { data: new Uint8Array(Buffer.from(base64, 'base64')), mediaType: 'image/png' }
|
|
109
|
+
} catch {
|
|
110
|
+
// No image on the clipboard (or powershell missing); report undefined.
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readClipboardTextBuiltin(): Promise<string | undefined> {
|
|
118
|
+
if (platform() === 'darwin') return firstText([{ command: 'pbpaste', args: [] }])
|
|
119
|
+
const unix = await firstText([
|
|
120
|
+
{ command: 'wl-paste', args: [] },
|
|
121
|
+
{ command: 'xclip', args: ['-selection', 'clipboard', '-o'] },
|
|
122
|
+
{ command: 'xsel', args: ['--clipboard', '--output'] },
|
|
123
|
+
])
|
|
124
|
+
if (unix !== undefined) return unix
|
|
125
|
+
if (platform() === 'win32' || isWsl()) {
|
|
126
|
+
return firstText([{ command: 'powershell.exe', args: ['-NonInteractive', '-NoProfile', '-command', '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard'] }])
|
|
127
|
+
}
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function readClipboardImageUris(): Promise<string[]> {
|
|
132
|
+
if (platform() !== 'linux') return []
|
|
133
|
+
const uris = await firstText([
|
|
134
|
+
{ command: 'wl-paste', args: ['-t', 'text/uri-list'] },
|
|
135
|
+
{ command: 'xclip', args: ['-selection', 'clipboard', '-t', 'text/uri-list', '-o'] },
|
|
136
|
+
])
|
|
137
|
+
if (uris === undefined) return []
|
|
138
|
+
return uris
|
|
139
|
+
.split(/\r?\n/)
|
|
140
|
+
.map(line => line.trim())
|
|
141
|
+
.filter(line => line.startsWith('file://'))
|
|
142
|
+
.map(line => decodeURIComponent(line.slice('file://'.length)))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Backend-chain-aware clipboard read; plugin backends run first. */
|
|
146
|
+
export async function readClipboardText(): Promise<string | undefined> {
|
|
147
|
+
const text = await clipboardReadText(readClipboardTextBuiltin)
|
|
148
|
+
// A U+FEFF in clipboard text is BOM contamination (legacy clip.exe writes),
|
|
149
|
+
// never meaningful content: drop it for every consumer.
|
|
150
|
+
return text === undefined ? undefined : text.replace(/\ufeff/g, '')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Backend-chain-aware image read; plugin backends run first. */
|
|
154
|
+
export async function readClipboardImage(): Promise<ClipboardImage | undefined> {
|
|
155
|
+
return clipboardReadImage(readClipboardImageBuiltin)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Backend-chain-aware write; the first backend claiming the write stops the chain. */
|
|
159
|
+
export function writeClipboardText(text: string): void {
|
|
160
|
+
clipboardWriteText(text, writeOsc52)
|
|
161
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const SEQUENCES = {
|
|
2
|
+
beam: '\x1b[1 q',
|
|
3
|
+
block: '\x1b[2 q',
|
|
4
|
+
reset: '\x1b[0 q',
|
|
5
|
+
hide: '\x1b[?25l',
|
|
6
|
+
show: '\x1b[?25h',
|
|
7
|
+
} as const
|
|
8
|
+
|
|
9
|
+
export type CursorShape = keyof typeof SEQUENCES
|
|
10
|
+
|
|
11
|
+
export function writeCursorShape(shape: CursorShape): void {
|
|
12
|
+
process.stdout.write(SEQUENCES[shape])
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { unicode } from './capabilities.ts'
|
|
2
|
+
|
|
3
|
+
type BorderTriple = readonly [string, string, string]
|
|
4
|
+
|
|
5
|
+
const borderTop: BorderTriple = unicode ? ['┌', '┬', '┐'] : ['+', '-', '+']
|
|
6
|
+
const borderMiddle: BorderTriple = unicode ? ['├', '┼', '┤'] : ['+', '-', '+']
|
|
7
|
+
const borderBottom: BorderTriple = unicode ? ['└', '┴', '┘'] : ['+', '-', '+']
|
|
8
|
+
|
|
9
|
+
export const glyphs = {
|
|
10
|
+
spinnerFrames: unicode
|
|
11
|
+
? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
12
|
+
: ['|', '/', '-', '\\'],
|
|
13
|
+
bullets: unicode ? ['•', '◦', '▪'] : ['*', 'o', '-'],
|
|
14
|
+
horizontal: unicode ? '─' : '-',
|
|
15
|
+
tableVertical: unicode ? '│' : '|',
|
|
16
|
+
tableBorders: {
|
|
17
|
+
top: borderTop,
|
|
18
|
+
middle: borderMiddle,
|
|
19
|
+
bottom: borderBottom,
|
|
20
|
+
},
|
|
21
|
+
quoteBar: unicode ? '▌' : '|',
|
|
22
|
+
taskChecked: unicode ? '☑' : '[x]',
|
|
23
|
+
taskUnchecked: unicode ? '☐' : '[ ]',
|
|
24
|
+
headerExpanded: unicode ? '↓' : 'v',
|
|
25
|
+
headerCollapsed: '-',
|
|
26
|
+
treeBranch: unicode ? '└' : '+',
|
|
27
|
+
separator: unicode ? '·' : '-',
|
|
28
|
+
tokenArrow: unicode ? '→' : '->',
|
|
29
|
+
ellipsis: unicode ? '…' : '...',
|
|
30
|
+
focusMarker: unicode ? '❯' : '>',
|
|
31
|
+
tick: unicode ? '✓' : 'x',
|
|
32
|
+
checkboxOn: unicode ? '[✓]' : '[x]',
|
|
33
|
+
checkboxOff: '[ ]',
|
|
34
|
+
pageFlip: unicode ? '⇄' : '<->',
|
|
35
|
+
wrapArrows: unicode ? '⇅' : '^v',
|
|
36
|
+
carouselLeft: unicode ? '◀' : '<',
|
|
37
|
+
carouselRight: unicode ? '▶' : '>',
|
|
38
|
+
todoPending: '[ ]',
|
|
39
|
+
todoInProgress: unicode ? '[●]' : '[o]',
|
|
40
|
+
todoDone: unicode ? '[√]' : '[x]',
|
|
41
|
+
halfBlockCaps: unicode,
|
|
42
|
+
blockCapTop: '▄',
|
|
43
|
+
blockCapBottom: '▀',
|
|
44
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export type MouseEventType = 'down' | 'up' | 'drag' | 'move' | 'scroll'
|
|
2
|
+
|
|
3
|
+
export interface MouseEventData {
|
|
4
|
+
type: MouseEventType
|
|
5
|
+
button: number
|
|
6
|
+
x: number
|
|
7
|
+
y: number
|
|
8
|
+
modifiers: { shift: boolean; alt: boolean; ctrl: boolean }
|
|
9
|
+
scrollDirection?: 'up' | 'down'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MOUSE_ENABLE_SEQUENCES = '\x1b[?1003l\x1b[?1000h\x1b[?1002h\x1b[?1006h'
|
|
13
|
+
const MOUSE_DISABLE_SEQUENCES = '\x1b[?1003l\x1b[?1000l\x1b[?1002l\x1b[?1006l'
|
|
14
|
+
|
|
15
|
+
export function writeMouseEnable(): void {
|
|
16
|
+
process.stdout.write(MOUSE_ENABLE_SEQUENCES)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeMouseDisable(): void {
|
|
20
|
+
process.stdout.write(MOUSE_DISABLE_SEQUENCES)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ParsedSequence {
|
|
24
|
+
event: MouseEventData
|
|
25
|
+
consumed: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_BUFFER = 64
|
|
29
|
+
const SGR_PREFIX = '\x1b[<'
|
|
30
|
+
|
|
31
|
+
function partialPrefixLength(buffer: string): number {
|
|
32
|
+
for (let keep = Math.min(2, buffer.length); keep > 0; keep--) {
|
|
33
|
+
if (SGR_PREFIX.startsWith(buffer.slice(-keep))) return keep
|
|
34
|
+
}
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMouseParser(onEvent: (event: MouseEventData) => void) {
|
|
39
|
+
let buffer = ''
|
|
40
|
+
const pressedButtons = new Set<number>()
|
|
41
|
+
function parseSequenceAt(str: string, offset: number): ParsedSequence | null {
|
|
42
|
+
if (!str.startsWith('\x1b[<', offset)) return null
|
|
43
|
+
let index = offset + 3
|
|
44
|
+
const values: number[] = []
|
|
45
|
+
let current = 0
|
|
46
|
+
let hasDigit = false
|
|
47
|
+
let terminator = ''
|
|
48
|
+
for (; index < str.length; index++) {
|
|
49
|
+
const ch = str[index]
|
|
50
|
+
if (ch >= '0' && ch <= '9') {
|
|
51
|
+
current = current * 10 + (ch.charCodeAt(0) - 48)
|
|
52
|
+
hasDigit = true
|
|
53
|
+
} else if (ch === ';' && hasDigit) {
|
|
54
|
+
values.push(current)
|
|
55
|
+
current = 0
|
|
56
|
+
hasDigit = false
|
|
57
|
+
} else if (ch === 'M' || ch === 'm') {
|
|
58
|
+
if (!hasDigit) return null
|
|
59
|
+
values.push(current)
|
|
60
|
+
terminator = ch
|
|
61
|
+
break
|
|
62
|
+
} else {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (terminator === '') return null
|
|
67
|
+
if (values.length !== 3) return null
|
|
68
|
+
return { event: decodeSgrEvent(values[0], values[1], values[2], terminator as 'M' | 'm', pressedButtons), consumed: index - offset + 1 }
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
feed(chunk: string): void {
|
|
72
|
+
buffer += chunk
|
|
73
|
+
while (true) {
|
|
74
|
+
const start = buffer.indexOf('\x1b[<')
|
|
75
|
+
if (start === -1) {
|
|
76
|
+
const keep = partialPrefixLength(buffer)
|
|
77
|
+
buffer = keep === 0 ? '' : buffer.slice(-keep)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (start > 0) {
|
|
81
|
+
buffer = buffer.slice(start)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (buffer.length > MAX_BUFFER) {
|
|
85
|
+
buffer = ''
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const parsed = parseSequenceAt(buffer, 0)
|
|
89
|
+
if (parsed === null) return
|
|
90
|
+
const { event, consumed } = parsed
|
|
91
|
+
buffer = buffer.slice(consumed)
|
|
92
|
+
onEvent(event)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeSgrEvent(rawButtonCode: number, wireX: number, wireY: number, pressRelease: 'M' | 'm', pressed: Set<number>): MouseEventData {
|
|
99
|
+
const button = rawButtonCode & 3
|
|
100
|
+
const isScroll = (rawButtonCode & 64) !== 0
|
|
101
|
+
const isMotion = (rawButtonCode & 32) !== 0
|
|
102
|
+
const modifiers = {
|
|
103
|
+
shift: (rawButtonCode & 4) !== 0,
|
|
104
|
+
alt: (rawButtonCode & 8) !== 0,
|
|
105
|
+
ctrl: (rawButtonCode & 16) !== 0,
|
|
106
|
+
}
|
|
107
|
+
let type: MouseEventType
|
|
108
|
+
let scrollDirection: 'up' | 'down' | undefined
|
|
109
|
+
if (isScroll) {
|
|
110
|
+
type = 'scroll'
|
|
111
|
+
scrollDirection = button === 0 ? 'up' : 'down'
|
|
112
|
+
} else if (isMotion) {
|
|
113
|
+
type = pressed.size > 0 ? 'drag' : 'move'
|
|
114
|
+
} else if (pressRelease === 'M') {
|
|
115
|
+
type = 'down'
|
|
116
|
+
if (button !== 3) pressed.add(button)
|
|
117
|
+
} else {
|
|
118
|
+
type = 'up'
|
|
119
|
+
if (button === 3) pressed.clear()
|
|
120
|
+
else pressed.delete(button)
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
type,
|
|
124
|
+
button: button === 3 ? 0 : button,
|
|
125
|
+
x: wireX - 1,
|
|
126
|
+
y: wireY - 1,
|
|
127
|
+
modifiers,
|
|
128
|
+
...(scrollDirection === undefined ? {} : { scrollDirection }),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isMouseResidue(input: string): boolean {
|
|
133
|
+
return input.includes('\x1b') || /^\[<\d+(;\d+)*[Mm]$/.test(input)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface MouseController {
|
|
137
|
+
enable: () => void
|
|
138
|
+
disable: () => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createMouseController(onEvent: (event: MouseEventData) => void): MouseController {
|
|
142
|
+
const parser = createMouseParser(onEvent)
|
|
143
|
+
const onData = (chunk: Buffer) => parser.feed(chunk.toString('latin1'))
|
|
144
|
+
return {
|
|
145
|
+
enable() {
|
|
146
|
+
writeMouseEnable()
|
|
147
|
+
process.stdin.on('data', onData)
|
|
148
|
+
},
|
|
149
|
+
disable() {
|
|
150
|
+
writeMouseDisable()
|
|
151
|
+
process.stdin.off('data', onData)
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|