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/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
+ }