@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.
Files changed (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. 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
+ }