@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,149 @@
1
+ import { homedir } from 'node:os'
2
+ import { statSync } from 'node:fs'
3
+ import { fieldSlotOf, isFieldChar } from './fields.ts'
4
+
5
+ export const PASTE_SUMMARY_MIN_LINES = 4
6
+ export const PASTE_SUMMARY_MIN_CHARS = 601
7
+
8
+ /**
9
+ * Paste is the one composer ingress without a keystroke-level guard, so every
10
+ * byte that cannot render predictably is dropped here: C0 controls and DEL
11
+ * (except the newline and tab the wrap layer understands), the C1 range, BOM
12
+ * leftovers from clipboard round-trips, and private-use code points. A PUA
13
+ * char only survives when it names a live field slot - a chip inserted by a
14
+ * plugin paste handler; anything else in that range is a stray sentinel that
15
+ * would render as a raw block glyph.
16
+ */
17
+ export function sanitizePastedText(text: string): string {
18
+ let out = ''
19
+ let changed = false
20
+ for (const char of text) {
21
+ const code = char.codePointAt(0)!
22
+ if (code === 0x0a || code === 0x09) {
23
+ out += char
24
+ continue
25
+ }
26
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
27
+ changed = true
28
+ continue
29
+ }
30
+ if (code >= 0xe000 && code <= 0xf8ff) {
31
+ if (isFieldChar(char) && fieldSlotOf(char) !== undefined) {
32
+ out += char
33
+ continue
34
+ }
35
+ changed = true
36
+ continue
37
+ }
38
+ out += char
39
+ }
40
+ return changed ? out : text
41
+ }
42
+
43
+ export type PendingImage =
44
+ | { kind: 'path'; path: string }
45
+ | { kind: 'data'; data: Uint8Array; mediaType: string; name?: string }
46
+
47
+ export type PasteSegment =
48
+ | { type: 'images'; paths: string[] }
49
+ | { type: 'text'; text: string }
50
+
51
+ const IMAGE_MEDIA_TYPES: Record<string, string> = {
52
+ '.png': 'image/png',
53
+ '.jpg': 'image/jpeg',
54
+ '.jpeg': 'image/jpeg',
55
+ '.webp': 'image/webp',
56
+ '.gif': 'image/gif',
57
+ }
58
+
59
+ export function imageMediaTypeOf(path: string): string | undefined {
60
+ const dot = path.lastIndexOf('.')
61
+ if (dot < 0) return undefined
62
+ return IMAGE_MEDIA_TYPES[path.slice(dot).toLowerCase()]
63
+ }
64
+
65
+ function normalizePathToken(token: string): string {
66
+ let path = token.trim()
67
+ if (path.length >= 2 && ((path.startsWith('"') && path.endsWith('"')) || (path.startsWith("'") && path.endsWith("'")))) {
68
+ path = path.slice(1, -1)
69
+ }
70
+ if (path.startsWith('file://')) path = decodeURIComponent(path.slice('file://'.length))
71
+ if (path === '~') return homedir()
72
+ if (path.startsWith('~/')) return `${homedir()}${path.slice(1)}`
73
+ return path
74
+ }
75
+
76
+ /** File managers prefix dropped files with a `copy` action line. */
77
+ function dropClipboardActionPrefix(lines: string[]): string[] {
78
+ if (lines.length < 2) return lines
79
+ const first = lines[0]!.trim()
80
+ if (first !== 'copy' && first !== 'cut' && first !== 'link') return lines
81
+ // Only strip when the remainder actually looks like dropped file paths;
82
+ // plain prose that merely starts with such a word must survive.
83
+ const rest = lines.slice(1).map(line => line.trim()).filter(line => line !== '')
84
+ if (rest.length === 0) return lines
85
+ const pathLike = rest.every(line => line.startsWith('file://') || line.startsWith('/') || line.startsWith('~/') || /^[a-zA-Z]:[\\/]/.test(line))
86
+ return pathLike ? lines.slice(1) : lines
87
+ }
88
+
89
+ function isImagePath(line: string): boolean {
90
+ const path = normalizePathToken(line)
91
+ if (path === '' || imageMediaTypeOf(path) === undefined) return false
92
+ try {
93
+ return statSync(path).isFile()
94
+ } catch {
95
+ // A path that does not exist (or is unreadable) is not a pasteable image.
96
+ return false
97
+ }
98
+ }
99
+
100
+ export function codePointCount(text: string): number {
101
+ let count = 0
102
+ for (let i = 0; i < text.length; i++) {
103
+ count += 1
104
+ if (text.charCodeAt(i) >= 0xd800 && text.charCodeAt(i) <= 0xdbff) i += 1
105
+ }
106
+ return count
107
+ }
108
+
109
+ export function summarizePaste(text: string): { lines: number; characters: number } | null {
110
+ const lines = text.split('\n').length
111
+ const characters = codePointCount(text)
112
+ if (lines >= PASTE_SUMMARY_MIN_LINES) return { lines, characters }
113
+ if (lines === 1 && characters >= PASTE_SUMMARY_MIN_CHARS) return { lines, characters }
114
+ return null
115
+ }
116
+
117
+ export function classifyPaste(raw: string): PasteSegment[] {
118
+ const lines = dropClipboardActionPrefix(raw.split('\n'))
119
+ const segments: PasteSegment[] = []
120
+ let textRun: string[] = []
121
+ const flushText = (): void => {
122
+ if (textRun.length === 0) return
123
+ const text = textRun.join('\n')
124
+ if (text !== '') segments.push({ type: 'text', text })
125
+ textRun = []
126
+ }
127
+ const pushImages = (paths: string[]): void => {
128
+ const last = segments[segments.length - 1]
129
+ if (last?.type === 'images') last.paths.push(...paths)
130
+ else segments.push({ type: 'images', paths })
131
+ }
132
+ for (const line of lines) {
133
+ const trimmed = line.trim()
134
+ if (trimmed !== '' && isImagePath(trimmed)) {
135
+ flushText()
136
+ pushImages([normalizePathToken(trimmed)])
137
+ continue
138
+ }
139
+ const tokens = trimmed.split(/\s+/).filter(token => token !== '')
140
+ if (tokens.length > 1 && tokens.every(isImagePath)) {
141
+ flushText()
142
+ pushImages(tokens.map(normalizePathToken))
143
+ continue
144
+ }
145
+ textRun.push(line)
146
+ }
147
+ flushText()
148
+ return segments
149
+ }
@@ -0,0 +1,112 @@
1
+ import { computeWrapStarts, normalizeWrapText, segmentGraphemes } from './text.ts'
2
+
3
+ export interface MarkStyle {
4
+ color?: string
5
+ background?: string
6
+ bold?: boolean
7
+ italic?: boolean
8
+ strike?: boolean
9
+ underline?: boolean
10
+ }
11
+
12
+ export interface Segment {
13
+ text: string
14
+ style: MarkStyle
15
+ }
16
+
17
+ export function wrapSegments(segments: Segment[], width: number): Segment[][] {
18
+ if (width <= 0) return [[]]
19
+ const rows: Segment[][] = []
20
+ let clusters: string[] = []
21
+ let styles: MarkStyle[] = []
22
+ const flushLine = (): void => {
23
+ const starts = computeWrapStarts(clusters, width)
24
+ for (let r = 0; r < starts.length; r++) {
25
+ const from = starts[r]!
26
+ const to = r + 1 < starts.length ? starts[r + 1]! : clusters.length
27
+ if (from >= to) {
28
+ rows.push([])
29
+ continue
30
+ }
31
+ const row: Segment[] = []
32
+ let runText = ''
33
+ let runStyle = styles[from]!
34
+ for (let k = from; k < to; k++) {
35
+ const style = styles[k]!
36
+ if (k > from && !sameStyle(runStyle, style)) {
37
+ row.push({ text: runText, style: runStyle })
38
+ runText = ''
39
+ runStyle = style
40
+ }
41
+ runText += clusters[k]
42
+ }
43
+ row.push({ text: runText, style: runStyle })
44
+ rows.push(row)
45
+ }
46
+ clusters = []
47
+ styles = []
48
+ }
49
+ for (const seg of segments) {
50
+ const parts = normalizeWrapText(seg.text).split('\n')
51
+ for (let p = 0; p < parts.length; p++) {
52
+ if (p > 0) flushLine()
53
+ appendClusters(parts[p]!, seg.style, clusters, styles)
54
+ }
55
+ }
56
+ flushLine()
57
+ return rows
58
+ }
59
+
60
+ function sameStyle(a: MarkStyle, b: MarkStyle): boolean {
61
+ return a.color === b.color
62
+ && a.background === b.background
63
+ && a.bold === b.bold
64
+ && a.italic === b.italic
65
+ && a.strike === b.strike
66
+ && a.underline === b.underline
67
+ }
68
+
69
+ function appendClusters(text: string, style: MarkStyle, clusters: string[], styles: MarkStyle[]): void {
70
+ let ascii = true
71
+ for (let i = 0; i < text.length; i++) {
72
+ const code = text.charCodeAt(i)
73
+ if (code < 0x20 || code > 0x7e) {
74
+ ascii = false
75
+ break
76
+ }
77
+ }
78
+ if (ascii) {
79
+ for (let i = 0; i < text.length; i++) {
80
+ clusters.push(text[i]!)
81
+ styles.push(style)
82
+ }
83
+ return
84
+ }
85
+ for (const { segment } of segmentGraphemes(text)) {
86
+ clusters.push(segment)
87
+ styles.push(style)
88
+ }
89
+ }
90
+
91
+ export function mergeRuns(segments: Segment[]): Segment[] {
92
+ if (segments.length === 0) return segments
93
+ const out: Segment[] = []
94
+ for (const seg of segments) {
95
+ const last = out[out.length - 1]
96
+ if (last !== undefined && sameStyle(last.style, seg.style)) {
97
+ last.text += seg.text
98
+ } else {
99
+ out.push({ text: seg.text, style: seg.style })
100
+ }
101
+ }
102
+ return out
103
+ }
104
+
105
+ export function segmentsKey(segments: Segment[]): string {
106
+ let key = ''
107
+ for (const seg of segments) {
108
+ const flags = `${seg.style.bold ? 'b' : ''}${seg.style.italic ? 'i' : ''}${seg.style.strike ? 's' : ''}${seg.style.underline ? 'u' : ''}${seg.style.background ? 'g' : ''}`
109
+ key += `${seg.text.length}:${seg.text}\x1f${seg.style.color ?? ''}\x1e${seg.style.background ?? ''}\x1e${flags}\x1d`
110
+ }
111
+ return key
112
+ }
@@ -0,0 +1,290 @@
1
+ import stringWidth from 'string-width'
2
+ import { isFieldCode, fieldCodeWidth } from './fields.ts'
3
+
4
+ const WIDTH_CACHE_LIMIT = 8192
5
+
6
+ function cachedWidth(cache: Map<string, number>, text: string, compute: () => number): number {
7
+ const hit = cache.get(text)
8
+ if (hit !== undefined) return hit
9
+ const width = compute()
10
+ if (cache.size >= WIDTH_CACHE_LIMIT) cache.clear()
11
+ cache.set(text, width)
12
+ return width
13
+ }
14
+
15
+ const widthCache = new Map<string, number>()
16
+
17
+ export function textWidth(text: string): number {
18
+ if (isAsciiPrintable(text)) return text.length
19
+ const hit = widthCache.get(text)
20
+ if (hit !== undefined) return hit
21
+ let total = 0
22
+ let run = ''
23
+ for (let i = 0; i < text.length; i++) {
24
+ const code = text.charCodeAt(i)
25
+ if (isFieldCode(code)) {
26
+ if (run !== '') {
27
+ total += stringWidth(run)
28
+ run = ''
29
+ }
30
+ total += fieldCodeWidth(code)
31
+ continue
32
+ }
33
+ run += text[i]
34
+ }
35
+ if (run !== '') total += stringWidth(run)
36
+ if (widthCache.size >= WIDTH_CACHE_LIMIT) widthCache.clear()
37
+ widthCache.set(text, total)
38
+ return total
39
+ }
40
+
41
+ function isAsciiPrintable(text: string): boolean {
42
+ for (let i = 0; i < text.length; i++) {
43
+ const code = text.charCodeAt(i)
44
+ if (code < 0x20 || code > 0x7e) return false
45
+ }
46
+ return true
47
+ }
48
+
49
+ const charWidthCache = new Map<string, number>()
50
+
51
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
52
+
53
+ export function segmentGraphemes(text: string): Iterable<Intl.SegmentData> {
54
+ return graphemeSegmenter.segment(text)
55
+ }
56
+
57
+ export function charWidth(cluster: string): number {
58
+ if (cluster.length === 1) {
59
+ const code = cluster.charCodeAt(0)
60
+ if (code >= 0x20 && code <= 0x7e) return 1
61
+ if (isFieldCode(code)) return fieldCodeWidth(code)
62
+ }
63
+ return cachedWidth(charWidthCache, cluster, () => stringWidth(cluster))
64
+ }
65
+
66
+ const NO_START_CHARS = new Set<string>(
67
+ "!%,.:;?)]}'\"" + "。,、:;!?)]}》〉」』】〕〗〙〛…—~·%°′″‰",
68
+ )
69
+
70
+ const NO_END_CHARS = new Set<string>(
71
+ "([{'\"$#@`" + "([{《〈「『【〔〖$¥",
72
+ )
73
+
74
+ const BREAK_DELIM_CHARS = new Set<string>("-/\\,.;:!?)]}")
75
+
76
+ function matchesAny(cluster: string, set: Set<string>): boolean {
77
+ for (const ch of cluster) {
78
+ if (set.has(ch)) return true
79
+ }
80
+ return false
81
+ }
82
+
83
+ const ASCII_NO_START = new Set<number>()
84
+ for (const ch of "!%,.:;?)]}'\"") ASCII_NO_START.add(ch.charCodeAt(0))
85
+ const ASCII_NO_END = new Set<number>()
86
+ for (const ch of "([{'\"$#@`") ASCII_NO_END.add(ch.charCodeAt(0))
87
+
88
+ function canBreakBefore(prev: string, cur: string): boolean {
89
+ if (prev.length === 1 && cur.length === 1) {
90
+ const pc = prev.charCodeAt(0)
91
+ const cc = cur.charCodeAt(0)
92
+ if (pc >= 0x20 && pc < 0x7f && cc >= 0x20 && cc < 0x7f) {
93
+ if (ASCII_NO_START.has(cc) || ASCII_NO_END.has(pc)) return false
94
+ if (pc === 0x20 || BREAK_DELIM_CHARS.has(prev)) return true
95
+ return false
96
+ }
97
+ }
98
+ if (matchesAny(cur, NO_START_CHARS) || matchesAny(prev, NO_END_CHARS)) return false
99
+ if (charWidth(prev) >= 2 || charWidth(cur) >= 2) return true
100
+ if (prev === ' ' || prev === '\t') return true
101
+ if (BREAK_DELIM_CHARS.has(prev)) return true
102
+ return false
103
+ }
104
+
105
+ export function computeWrapStarts(clusters: string[], width: number): number[] {
106
+ const total = clusters.length
107
+ const starts: number[] = [0]
108
+ let start = 0
109
+ let lineW = 0
110
+ let lastBreak = -1
111
+ for (let i = 0; i < total; i++) {
112
+ const w = charWidth(clusters[i]!)
113
+ if (lineW + w > width && i > start) {
114
+ let j: number
115
+ if (lastBreak > start) {
116
+ j = lastBreak
117
+ } else {
118
+ j = i
119
+ while (j > start + 1 && matchesAny(clusters[j]!, NO_START_CHARS)) j--
120
+ while (j > start + 1 && matchesAny(clusters[j - 1]!, NO_END_CHARS)) j--
121
+ }
122
+ starts.push(j)
123
+ lineW = 0
124
+ for (let k = j; k < i; k++) lineW += charWidth(clusters[k]!)
125
+ start = j
126
+ lastBreak = -1
127
+ }
128
+ const next = i + 1
129
+ if (next < total && canBreakBefore(clusters[i]!, clusters[next]!)) lastBreak = next
130
+ lineW += w
131
+ }
132
+ return starts
133
+ }
134
+
135
+ function pushWrapped(line: string, width: number, out: string[]): void {
136
+ if (line.length === 0) {
137
+ out.push('')
138
+ return
139
+ }
140
+ const clusters: string[] = []
141
+ for (const { segment } of segmentGraphemes(line)) clusters.push(segment)
142
+ const starts = computeWrapStarts(clusters, width)
143
+ for (let r = 0; r < starts.length; r++) {
144
+ const from = starts[r]!
145
+ const to = r + 1 < starts.length ? starts[r + 1]! : clusters.length
146
+ out.push(clusters.slice(from, to).join(''))
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Single normalization applied before every wrap pass. Terminal rendering
152
+ * expands tabs and a lone carriage return rewinds the cursor, so both would
153
+ * make the visually rendered line wider than the width computation assumed
154
+ * and every later row would land on the wrong column. Both are replaced
155
+ * with spaces / newlines here so wrap math and rendering always agree.
156
+ */
157
+ export function normalizeWrapText(text: string): string {
158
+ if (!text.includes('\r') && !text.includes('\t')) return text
159
+ return text.replace(/\r\n?/g, '\n').replace(/\t/g, ' ')
160
+ }
161
+
162
+ export function wrapLines(text: string, width: number): string[] {
163
+ if (width <= 0) return ['']
164
+ const cacheKey = `${width}\u0000${text}`
165
+ const hit = wrapCache.get(cacheKey)
166
+ if (hit !== undefined) return hit.lines
167
+ const lines: string[] = []
168
+ for (const rawLine of normalizeWrapText(text).split('\n')) {
169
+ pushWrapped(rawLine, width, lines)
170
+ }
171
+ evictWrapCacheIfNeeded()
172
+ wrapCache.set(cacheKey, { lines, bytes: text.length })
173
+ return lines
174
+ }
175
+
176
+ // The memo entry keeps the result lines; the key keeps the source text, so a
177
+ // cap of ~4MB of source bounds the cache without hurting the streaming case
178
+ // where every frame re-wraps a growing body.
179
+ const WRAP_CACHE_MAX_BYTES = 4 * 1024 * 1024
180
+
181
+ interface WrapCacheEntry {
182
+ lines: string[]
183
+ bytes: number
184
+ }
185
+
186
+ const wrapCache = new Map<string, WrapCacheEntry>()
187
+ let wrapCacheBytes = 0
188
+
189
+ function evictWrapCacheIfNeeded(): void {
190
+ while (wrapCache.size > 0 && wrapCacheBytes >= WRAP_CACHE_MAX_BYTES) {
191
+ const oldest = wrapCache.keys().next()
192
+ if (oldest.done) return
193
+ const entry = wrapCache.get(oldest.value)
194
+ if (entry !== undefined) wrapCacheBytes -= entry.bytes
195
+ wrapCache.delete(oldest.value)
196
+ }
197
+ }
198
+
199
+ export interface LineBreak {
200
+ start: number
201
+ end: number
202
+ }
203
+
204
+ function pushWrappedOffsets(line: string, width: number, base: number, out: LineBreak[]): void {
205
+ if (line.length === 0) {
206
+ out.push({ start: base, end: base })
207
+ return
208
+ }
209
+ const clusters: string[] = []
210
+ const offsets: number[] = []
211
+ for (const { segment, index } of segmentGraphemes(line)) {
212
+ clusters.push(segment)
213
+ offsets.push(index)
214
+ }
215
+ const starts = computeWrapStarts(clusters, width)
216
+ for (let r = 0; r < starts.length; r++) {
217
+ const from = starts[r]!
218
+ const to = r + 1 < starts.length ? offsets[starts[r + 1]!]! : line.length
219
+ out.push({ start: base + offsets[from]!, end: base + to })
220
+ }
221
+ }
222
+
223
+ export function lineBreaks(text: string, width: number): LineBreak[] {
224
+ const breaks: LineBreak[] = []
225
+ let start = 0
226
+ for (const rawLine of text.split('\n')) {
227
+ pushWrappedOffsets(rawLine, width, start, breaks)
228
+ start += rawLine.length + 1
229
+ }
230
+ return breaks
231
+ }
232
+
233
+ export interface Point {
234
+ row: number
235
+ col: number
236
+ }
237
+
238
+ export function locToPoint(text: string, width: number, index: number): Point {
239
+ const breaks = lineBreaks(text, width)
240
+ for (let row = 0; row < breaks.length; row++) {
241
+ const { start, end } = breaks[row]!
242
+ if (index < end || (index === end && row < breaks.length - 1 && breaks[row + 1]!.start > end)) {
243
+ return { row, col: textWidth(text.slice(start, index)) }
244
+ }
245
+ }
246
+ const last = breaks[breaks.length - 1]!
247
+ return { row: breaks.length - 1, col: textWidth(text.slice(last.start, last.end)) }
248
+ }
249
+
250
+ export function colToCharIndex(line: string, col: number): number {
251
+ let width = 0
252
+ for (const { segment, index } of segmentGraphemes(line)) {
253
+ if (width >= col) return index
254
+ width += charWidth(segment)
255
+ }
256
+ return line.length
257
+ }
258
+
259
+ export function caretScrollStart(text: string, caret: number, width: number): number {
260
+ const clamped = Math.min(Math.max(0, caret), text.length)
261
+ const caretCol = textWidth(text.slice(0, clamped))
262
+ if (caretCol <= width) return 0
263
+ const minPrefix = caretCol - width
264
+ let prefix = 0
265
+ for (const { segment, index } of segmentGraphemes(text)) {
266
+ if (index >= clamped) break
267
+ if (prefix >= minPrefix) return index
268
+ prefix += charWidth(segment)
269
+ }
270
+ return clamped
271
+ }
272
+
273
+ export function padToWidth(text: string, width: number): string {
274
+ const used = textWidth(text)
275
+ return text + ' '.repeat(Math.max(0, width - used))
276
+ }
277
+
278
+ export function truncate(text: string, width: number): string {
279
+ let used = 0
280
+ for (const { segment, index } of segmentGraphemes(text)) {
281
+ const w = charWidth(segment)
282
+ if (used + w > width) return text.slice(0, index)
283
+ used += w
284
+ }
285
+ return text
286
+ }
287
+
288
+ export function errorText(cause: unknown): string {
289
+ return cause instanceof Error ? cause.message : String(cause)
290
+ }
package/src/dialog.ts ADDED
@@ -0,0 +1,11 @@
1
+ import './runtime/prod-react.ts'
2
+
3
+ export { Dialog, CloseGuardContext } from './ui/dialog/dialog.tsx'
4
+ export type { DialogProps, DialogHandle } from './ui/dialog/dialog.tsx'
5
+ export type { DialogRow, DialogItem, DialogFocus, TextItem } from './ui/dialog/items.ts'
6
+ export { asTextItem } from './ui/dialog/items.ts'
7
+ export type { DialogFooterLine } from './ui/dialog/geometry.ts'
8
+ export { rowHeight, wrapStatusLines } from './ui/dialog/geometry.ts'
9
+ export { registerWidget } from './ui/widgets/registry.ts'
10
+ export { PanelSurface, panelLegend, panelAnchorRow } from './ui/panels/exports.ts'
11
+ export type { PanelRow, PanelHint, PanelLegendOptions, PanelPointerHandle } from './ui/panels/exports.ts'
package/src/env.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ Every dshtui environment variable, parsed once at import.
3
+ */
4
+ import { LOG_LEVELS, type LogLevel } from './log.ts'
5
+
6
+ function parseLogLevel(value: string | undefined): LogLevel | undefined {
7
+ return (LOG_LEVELS as readonly string[]).includes(value ?? '') ? (value as LogLevel) : undefined
8
+ }
9
+
10
+ function parseBytes(value: string | undefined): number | undefined {
11
+ if (value === undefined || value.trim() === '') return undefined
12
+ const parsed = Number(value)
13
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
14
+ }
15
+
16
+ export const env = {
17
+ /** Force color level: 0 none, 1 ansi16, 2 ansi256, 3 truecolor. */
18
+ color: process.env.DSH_TUI_COLOR,
19
+ /** Render every glyph as plain ASCII. */
20
+ ascii: process.env.DSH_TUI_ASCII === '1',
21
+ /** Force background mode: 'dark' or 'light'. */
22
+ background: process.env.DSH_TUI_BG,
23
+ /** Reload theme.ts on every save (path relative to cwd, default src/theme.ts). */
24
+ hotTheme: process.env.DSH_TUI_HOT_THEME === '1',
25
+ themePath: process.env.DSH_TUI_THEME_PATH,
26
+ /** Mirror warnings and errors to stderr; only the exact value '1' enables it. */
27
+ debug: process.env.DSH_TUI_DEBUG === '1',
28
+ /** Write diagnostics under the harness home logs directory (default: on). */
29
+ logFile: process.env.DSH_TUI_LOG_FILE !== '0',
30
+ /** Override the harness home base directory used for logs. */
31
+ logDir: process.env.DSH_TUI_LOG_DIR,
32
+ /** Minimum level written to the log file: debug | info | warn | error. */
33
+ logLevel: parseLogLevel(process.env.DSH_TUI_LOG_LEVEL),
34
+ /** Rotation cap for the log file in bytes. */
35
+ logMaxBytes: parseBytes(process.env.DSH_TUI_LOG_MAX_BYTES),
36
+ /** Exit the host process on an uncaught crash (default: record only). */
37
+ crashExit: process.env.DSH_TUI_CRASH_EXIT === '1',
38
+ }
@@ -0,0 +1,22 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ export const DSH_HOME_DIR_NAME = '.dsh'
5
+ export const DSH_HOME_ENV = 'DSH_HOME'
6
+
7
+ function expandHomePath(path: string): string {
8
+ if (path === '~') return homedir()
9
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
10
+ return path
11
+ }
12
+
13
+ /**
14
+ * Resolve the DeepSeek Harness home the same way the harness does:
15
+ * `$DSH_HOME` when set (blank counts as unset), otherwise `~/.dsh`.
16
+ * Mirrors @deepseek-ai/dsh-home-paths without depending on it.
17
+ */
18
+ export function resolveDshHome(env: Record<string, string | undefined> = process.env): string {
19
+ const fromEnv = env[DSH_HOME_ENV]
20
+ const selected = fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), DSH_HOME_DIR_NAME)
21
+ return resolve(expandHomePath(selected))
22
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { replacePalettes } from './theme.ts'
5
+ import type { Theme, ThemeMode } from './theme.ts'
6
+ import { env } from './env.ts'
7
+ import { bumpSurface } from './kernel/surface.ts'
8
+
9
+ const REFRESH_MS = 300
10
+
11
+ export interface HotThemeHandle {
12
+ stop(): void
13
+ }
14
+
15
+ const themeUrl = env.themePath === undefined
16
+ ? new URL('../src/theme.ts', import.meta.url)
17
+ : pathToFileURL(resolve(process.cwd(), env.themePath))
18
+
19
+ export function startHotTheme(onChange: () => void): HotThemeHandle | undefined {
20
+ if (!env.hotTheme) return undefined
21
+ let lastSource = ''
22
+ try {
23
+ lastSource = readFileSync(themeUrl, 'utf8')
24
+ } catch {
25
+ console.error('dshtui: DSH_TUI_HOT_THEME is set but the theme file is missing; hot theme disabled')
26
+ return undefined
27
+ }
28
+ console.error('dshtui: hot theme enabled; saving the theme file applies colors live')
29
+ const timer = setInterval(async () => {
30
+ let source: string
31
+ try {
32
+ source = readFileSync(themeUrl, 'utf8')
33
+ } catch {
34
+ // The theme file vanished mid-session; keep the last applied palette.
35
+ return
36
+ }
37
+ if (source === lastSource) return
38
+ lastSource = source
39
+ try {
40
+ const fresh = (await import(`${themeUrl.href}?t=${Date.now()}`)) as {
41
+ palettes: Record<ThemeMode, Theme>
42
+ }
43
+ replacePalettes(fresh.palettes)
44
+ bumpSurface()
45
+ onChange()
46
+ } catch {
47
+ // The theme file is mid-edit or temporarily broken; retry on the next tick.
48
+ }
49
+ }, REFRESH_MS)
50
+ timer.unref()
51
+ return {
52
+ stop() {
53
+ clearInterval(timer)
54
+ },
55
+ }
56
+ }