@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,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
|
+
}
|
package/src/core/text.ts
ADDED
|
@@ -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
|
+
}
|
package/src/hot-theme.ts
ADDED
|
@@ -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
|
+
}
|