dsh-oc-tui 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/LICENSE +504 -0
- package/README.md +247 -0
- package/bin/dsh-oc-tui.js +219 -0
- package/cordis.patch.yml +24 -0
- package/docs/max-thinking.gif +0 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +324 -0
- package/lib/index.js +1771 -0
- package/lib/interrupt.js +31 -0
- package/lib/markdown.js +297 -0
- package/lib/metrics.js +70 -0
- package/lib/startup.js +42 -0
- package/lib/term.js +506 -0
- package/lib/ui.js +1646 -0
- package/lib/util.js +281 -0
- package/lib/web-settings.js +450 -0
- package/package.json +61 -0
package/lib/util.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Text and display helpers for the TUI. No dsh dependencies.
|
|
2
|
+
|
|
3
|
+
// Width of a rune in terminal cells: CJK/full-width runes are double width,
|
|
4
|
+
// combining marks are zero width, everything else is one.
|
|
5
|
+
const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{2FFFD}]/u
|
|
6
|
+
const ZERO = /[\u0300-\u036F\u200B-\u200F\uFE00-\uFE0F]/u
|
|
7
|
+
|
|
8
|
+
export function runeWidth(ch) {
|
|
9
|
+
if (ZERO.test(ch)) return 0
|
|
10
|
+
if (WIDE.test(ch)) return 2
|
|
11
|
+
return 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function displayWidth(str) {
|
|
15
|
+
let w = 0
|
|
16
|
+
for (const ch of str) w += runeWidth(ch)
|
|
17
|
+
return w
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ANSI escape sequence: CSI ... final byte in 0x40-0x7E.
|
|
21
|
+
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g
|
|
22
|
+
export function stripAnsi(str) {
|
|
23
|
+
return str.replace(ANSI_RE, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Truncate by display width, appending '…' when something was cut.
|
|
27
|
+
export function truncateWidth(str, width) {
|
|
28
|
+
if (width <= 0) return ''
|
|
29
|
+
let out = ''
|
|
30
|
+
let w = 0
|
|
31
|
+
for (const ch of str) {
|
|
32
|
+
const cw = runeWidth(ch)
|
|
33
|
+
if (w + cw > width) {
|
|
34
|
+
if (out.length > 0 && w < width) out += '…'
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
out += ch
|
|
38
|
+
w += cw
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pad/truncate a string to exactly width cells.
|
|
44
|
+
export function fitWidth(str, width, fill = ' ') {
|
|
45
|
+
const s = truncateWidth(str, width)
|
|
46
|
+
const w = displayWidth(s)
|
|
47
|
+
return s + fill.repeat(Math.max(0, width - w))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Wrap text to lines of at most width cells. Splits on whitespace, hard-wraps
|
|
51
|
+
// overlong words. Existing newlines are preserved.
|
|
52
|
+
export function wrapText(str, width) {
|
|
53
|
+
if (width <= 0) return []
|
|
54
|
+
const lines = []
|
|
55
|
+
for (const raw of str.split(/\r?\n/)) {
|
|
56
|
+
if (raw === '') {
|
|
57
|
+
lines.push('')
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
let line = ''
|
|
61
|
+
let lineW = 0
|
|
62
|
+
for (const token of raw.split(/(\s+)/)) {
|
|
63
|
+
if (token === '') continue
|
|
64
|
+
if (/^\s+$/.test(token)) {
|
|
65
|
+
// Whitespace: keep if it fits on the current line.
|
|
66
|
+
const tw = displayWidth(token)
|
|
67
|
+
if (lineW + tw <= width) {
|
|
68
|
+
line += token
|
|
69
|
+
lineW += tw
|
|
70
|
+
} else if (line !== '') {
|
|
71
|
+
lines.push(line)
|
|
72
|
+
line = ''
|
|
73
|
+
lineW = 0
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const tw = displayWidth(token)
|
|
78
|
+
if (lineW + tw <= width) {
|
|
79
|
+
line += token
|
|
80
|
+
lineW += tw
|
|
81
|
+
} else {
|
|
82
|
+
if (line !== '') {
|
|
83
|
+
lines.push(line)
|
|
84
|
+
line = ''
|
|
85
|
+
lineW = 0
|
|
86
|
+
}
|
|
87
|
+
if (tw > width) {
|
|
88
|
+
// Hard-wrap an overlong token.
|
|
89
|
+
let rest = token
|
|
90
|
+
while (displayWidth(rest) > width) {
|
|
91
|
+
let cut = 0
|
|
92
|
+
let cw = 0
|
|
93
|
+
for (const ch of rest) {
|
|
94
|
+
const w = runeWidth(ch)
|
|
95
|
+
if (cw + w > width) break
|
|
96
|
+
cw += w
|
|
97
|
+
cut += ch.length
|
|
98
|
+
}
|
|
99
|
+
lines.push(rest.slice(0, cut))
|
|
100
|
+
rest = rest.slice(cut)
|
|
101
|
+
}
|
|
102
|
+
line = rest
|
|
103
|
+
lineW = displayWidth(rest)
|
|
104
|
+
} else {
|
|
105
|
+
line = token
|
|
106
|
+
lineW = tw
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push(line)
|
|
111
|
+
}
|
|
112
|
+
return lines
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Detect a pasted image from its leading magic bytes. Returns the attachment
|
|
116
|
+
// media type when the bytes are a supported raster image, otherwise null.
|
|
117
|
+
export function detectImageMediaType(bytes) {
|
|
118
|
+
if (!bytes || bytes.length < 4) return null
|
|
119
|
+
const b = bytes
|
|
120
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47
|
|
121
|
+
&& b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'image/png'
|
|
122
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
|
123
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
|
|
124
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46
|
|
125
|
+
&& b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp'
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Map a file name / URL path extension to an image media type.
|
|
130
|
+
export function imageMediaTypeFromName(name) {
|
|
131
|
+
if (!name) return null
|
|
132
|
+
const clean = String(name).split(/[?#]/)[0].trim().toLowerCase()
|
|
133
|
+
const m = /\.(png|jpe?g|gif|webp)$/.exec(clean)
|
|
134
|
+
if (!m) return null
|
|
135
|
+
return m[1] === 'jpg' ? 'image/jpeg' : 'image/' + m[1]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Decode a `data:image/<type>;base64,...` paste into bytes, or return null.
|
|
139
|
+
export function decodeDataUrl(text) {
|
|
140
|
+
if (typeof text !== 'string') return null
|
|
141
|
+
const m = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\s]+)$/i.exec(text.trim())
|
|
142
|
+
if (!m) return null
|
|
143
|
+
try {
|
|
144
|
+
const data = Buffer.from(m[2].replace(/\s+/g, ''), 'base64')
|
|
145
|
+
return { mediaType: m[1].toLowerCase(), data }
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Normalize a pasted local-image path: strip surrounding quotes, trailing
|
|
152
|
+
// whitespace, and a leading file:// URI. Returns the path only when its name
|
|
153
|
+
// looks like a supported image, otherwise null.
|
|
154
|
+
export function localImagePath(text) {
|
|
155
|
+
if (typeof text !== 'string') return null
|
|
156
|
+
let s = text.trim()
|
|
157
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) s = s.slice(1, -1).trim()
|
|
158
|
+
if (/^file:\/\//i.test(s)) {
|
|
159
|
+
s = s.replace(/^file:\/\//i, '')
|
|
160
|
+
if (/^\/[A-Za-z]:/.test(s)) s = s.slice(1)
|
|
161
|
+
}
|
|
162
|
+
if (!imageMediaTypeFromName(s)) return null
|
|
163
|
+
return s
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Compact local timestamp with date, e.g. 2026-08-04 14:03:22.
|
|
167
|
+
export function timeString(ms = Date.now()) {
|
|
168
|
+
const d = new Date(ms)
|
|
169
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
170
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
|
|
171
|
+
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Rough token estimate: CJK-heavy text ~1 token per rune, latin ~1 per 4 chars.
|
|
175
|
+
export function roughTokens(text) {
|
|
176
|
+
let cjk = 0
|
|
177
|
+
let other = 0
|
|
178
|
+
for (const ch of text) {
|
|
179
|
+
if (/[\u2E80-\u9FFF\uF900-\uFAFF\uAC00-\uD7A3]/.test(ch)) cjk += 1
|
|
180
|
+
else other += 1
|
|
181
|
+
}
|
|
182
|
+
return Math.max(1, cjk + Math.round(other / 4))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Compact token count (mirrors the web StatsLine format): 517 / 12.2K / 517K / 1.2M.
|
|
186
|
+
export function formatTokens(n) {
|
|
187
|
+
const scaled = (v) => (v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10))
|
|
188
|
+
if (n < 1_000) return String(n)
|
|
189
|
+
if (n < 1_000_000) return scaled(n / 1_000) + 'K'
|
|
190
|
+
return scaled(n / 1_000_000) + 'M'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Compact preview of a raw JSON tool-arguments string.
|
|
194
|
+
export function jsonPreview(raw) {
|
|
195
|
+
if (raw === undefined || raw === null) return ''
|
|
196
|
+
let text = String(raw)
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(text)
|
|
199
|
+
text = JSON.stringify(parsed)
|
|
200
|
+
} catch {
|
|
201
|
+
// Keep the raw string; it is already a preview.
|
|
202
|
+
}
|
|
203
|
+
if (text.length > 160) text = text.slice(0, 159) + '…'
|
|
204
|
+
return text
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Human-readable tool-invocation summary. Known tools surface their primary
|
|
208
|
+
// value (path, command, pattern, query) without leaking the JSON field names;
|
|
209
|
+
// unknown tools fall back to the value-led fields, then a compact preview.
|
|
210
|
+
export function toolSummary(name, args) {
|
|
211
|
+
const label = name ?? 'tool'
|
|
212
|
+
const obj = argObject(args)
|
|
213
|
+
const pick = (...keys) => {
|
|
214
|
+
if (obj === null || typeof obj !== 'object') return ''
|
|
215
|
+
for (const key of keys) {
|
|
216
|
+
const value = obj[key]
|
|
217
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
218
|
+
}
|
|
219
|
+
return ''
|
|
220
|
+
}
|
|
221
|
+
const withValue = (verb, ...keys) => {
|
|
222
|
+
const value = pick(...keys)
|
|
223
|
+
return value ? `${verb} ${value}` : verb
|
|
224
|
+
}
|
|
225
|
+
switch (label) {
|
|
226
|
+
case 'read': return withValue('read', 'file_path', 'path')
|
|
227
|
+
case 'write': return withValue('write', 'file_path', 'path')
|
|
228
|
+
case 'edit': return withValue('edit', 'file_path', 'path')
|
|
229
|
+
case 'str_replace_editor': {
|
|
230
|
+
const command = pick('command')
|
|
231
|
+
const path = pick('path', 'file_path')
|
|
232
|
+
return ['str_replace_editor', command, path].filter(Boolean).join(' ')
|
|
233
|
+
}
|
|
234
|
+
case 'glob': return withValue('glob', 'pattern')
|
|
235
|
+
case 'grep': return withValue('grep', 'pattern')
|
|
236
|
+
case 'pwsh':
|
|
237
|
+
case 'bash':
|
|
238
|
+
case 'shell': return withValue('run', 'command')
|
|
239
|
+
case 'web_search': return withValue('search', 'query')
|
|
240
|
+
case 'todo_write': return 'todo'
|
|
241
|
+
default: {
|
|
242
|
+
const value = pick('file_path', 'path', 'command', 'pattern', 'query', 'url')
|
|
243
|
+
if (value) return `${label} ${value}`
|
|
244
|
+
if (obj) {
|
|
245
|
+
const preview = typeof obj === 'string' ? obj : JSON.stringify(obj)
|
|
246
|
+
return `${label} ${jsonPreview(preview)}`
|
|
247
|
+
}
|
|
248
|
+
return label
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function argObject(args) {
|
|
254
|
+
if (args === undefined || args === null) return null
|
|
255
|
+
if (typeof args === 'string') {
|
|
256
|
+
try { return JSON.parse(args) } catch { return null }
|
|
257
|
+
}
|
|
258
|
+
return args
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Extract plain text from an array of content blocks (llm ContentBlock[]).
|
|
262
|
+
// Pass { skipReasoning: true } to exclude reasoning blocks — assistant output
|
|
263
|
+
// keeps reasoning in its own box, never mixed into the visible text.
|
|
264
|
+
export function contentText(blocks, { skipReasoning = false } = {}) {
|
|
265
|
+
if (!Array.isArray(blocks)) return ''
|
|
266
|
+
return blocks
|
|
267
|
+
.map((block) => {
|
|
268
|
+
switch (block?.type) {
|
|
269
|
+
case 'text': return block.text ?? ''
|
|
270
|
+
case 'reasoning': return skipReasoning ? '' : block.text ?? ''
|
|
271
|
+
case 'tool-call': return ''
|
|
272
|
+
default: return ''
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
.join('')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function formatError(error) {
|
|
279
|
+
if (error instanceof Error) return error.message
|
|
280
|
+
return String(error)
|
|
281
|
+
}
|