smolerclaw 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/.github/workflows/ci.yml +30 -0
- package/.github/workflows/release.yml +67 -0
- package/bun.lock +33 -0
- package/dist/index.js +321 -0
- package/dist/tinyclaw.exe +0 -0
- package/install.ps1 +119 -0
- package/package.json +25 -0
- package/skills/business.md +77 -0
- package/skills/default.md +77 -0
- package/src/ansi.ts +164 -0
- package/src/approval.ts +74 -0
- package/src/auth.ts +125 -0
- package/src/briefing.ts +52 -0
- package/src/claude.ts +267 -0
- package/src/cli.ts +137 -0
- package/src/clipboard.ts +27 -0
- package/src/config.ts +87 -0
- package/src/context-window.ts +190 -0
- package/src/context.ts +125 -0
- package/src/decisions.ts +122 -0
- package/src/email.ts +123 -0
- package/src/errors.ts +78 -0
- package/src/export.ts +82 -0
- package/src/finance.ts +148 -0
- package/src/git.ts +62 -0
- package/src/history.ts +100 -0
- package/src/images.ts +68 -0
- package/src/index.ts +1431 -0
- package/src/investigate.ts +415 -0
- package/src/markdown.ts +125 -0
- package/src/memos.ts +191 -0
- package/src/models.ts +94 -0
- package/src/monitor.ts +169 -0
- package/src/morning.ts +108 -0
- package/src/news.ts +329 -0
- package/src/openai-provider.ts +127 -0
- package/src/people.ts +472 -0
- package/src/personas.ts +99 -0
- package/src/platform.ts +84 -0
- package/src/plugins.ts +125 -0
- package/src/pomodoro.ts +169 -0
- package/src/providers.ts +70 -0
- package/src/retry.ts +108 -0
- package/src/session.ts +128 -0
- package/src/skills.ts +102 -0
- package/src/tasks.ts +418 -0
- package/src/tokens.ts +102 -0
- package/src/tool-safety.ts +100 -0
- package/src/tools.ts +1479 -0
- package/src/tui.ts +693 -0
- package/src/types.ts +55 -0
- package/src/undo.ts +83 -0
- package/src/windows.ts +299 -0
- package/src/workflows.ts +197 -0
- package/tests/ansi.test.ts +58 -0
- package/tests/approval.test.ts +43 -0
- package/tests/briefing.test.ts +10 -0
- package/tests/cli.test.ts +53 -0
- package/tests/context-window.test.ts +83 -0
- package/tests/images.test.ts +28 -0
- package/tests/memos.test.ts +116 -0
- package/tests/models.test.ts +34 -0
- package/tests/news.test.ts +13 -0
- package/tests/path-guard.test.ts +37 -0
- package/tests/people.test.ts +204 -0
- package/tests/skills.test.ts +35 -0
- package/tests/ssrf.test.ts +80 -0
- package/tests/tasks.test.ts +152 -0
- package/tests/tokens.test.ts +44 -0
- package/tests/tool-safety.test.ts +55 -0
- package/tests/windows-security.test.ts +59 -0
- package/tests/windows.test.ts +20 -0
- package/tsconfig.json +19 -0
package/src/tui.ts
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { A, C, CSI, w, stripAnsi, wrapText, visibleLength, displayWidth } from './ansi'
|
|
2
|
+
import { renderMarkdown } from './markdown'
|
|
3
|
+
import { InputHistory } from './history'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
// ─── TUI ─────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface Line {
|
|
9
|
+
text: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TUI {
|
|
13
|
+
private width = 80
|
|
14
|
+
private height = 24
|
|
15
|
+
private lines: Line[] = []
|
|
16
|
+
private streamBuf = ''
|
|
17
|
+
private streamLines: Line[] = []
|
|
18
|
+
private inputBuf = ''
|
|
19
|
+
private inputPos = 0
|
|
20
|
+
private isStreaming = false
|
|
21
|
+
private scrollOffset = 0
|
|
22
|
+
private history: InputHistory | null = null
|
|
23
|
+
private renderTimer: ReturnType<typeof setTimeout> | null = null
|
|
24
|
+
private spinnerFrame = 0
|
|
25
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null
|
|
26
|
+
private streamStartTime = 0
|
|
27
|
+
private sessionCost = ''
|
|
28
|
+
private commands = [
|
|
29
|
+
// English
|
|
30
|
+
'/help', '/clear', '/commit', '/persona', '/copy', '/fork',
|
|
31
|
+
'/new', '/load', '/sessions', '/delete', '/model', '/export',
|
|
32
|
+
'/cost', '/retry', '/undo', '/search', '/lang', '/config', '/exit',
|
|
33
|
+
'/briefing', '/news', '/open', '/openfile', '/openurl', '/apps',
|
|
34
|
+
'/sysinfo', '/calendar', '/ask', '/budget', '/plugins',
|
|
35
|
+
'/task', '/tasks', '/done', '/rmtask',
|
|
36
|
+
'/people', '/team', '/family', '/person', '/addperson',
|
|
37
|
+
'/delegate', '/delegations', '/followups', '/dashboard', '/contacts',
|
|
38
|
+
'/investigar', '/investigate', '/investigacoes',
|
|
39
|
+
'/monitor', '/vigiar',
|
|
40
|
+
'/workflow', '/fluxo',
|
|
41
|
+
'/pomodoro', '/foco',
|
|
42
|
+
'/entrada', '/saida', '/income', '/expense', '/finance', '/financas', '/balanco',
|
|
43
|
+
'/decisions', '/decisoes',
|
|
44
|
+
'/email', '/rascunho',
|
|
45
|
+
'/memo', '/memos', '/note', '/notas', '/tags', '/memotags', '/rmmemo', '/rmnota',
|
|
46
|
+
// Portugues
|
|
47
|
+
'/anotar', '/ajuda', '/limpar', '/commitar', '/modo', '/copiar',
|
|
48
|
+
'/novo', '/carregar', '/sessoes', '/deletar', '/modelo', '/exportar',
|
|
49
|
+
'/custo', '/repetir', '/desfazer', '/buscar', '/idioma', '/sair',
|
|
50
|
+
'/resumo', '/noticias', '/abrir', '/programas', '/sistema',
|
|
51
|
+
'/agenda', '/calendario', '/perguntar', '/orcamento',
|
|
52
|
+
'/tarefa', '/tarefas', '/feito', '/concluido', '/rmtarefa',
|
|
53
|
+
'/pessoas', '/equipe', '/familia', '/pessoa', '/novapessoa', '/addpessoa',
|
|
54
|
+
'/delegar', '/delegacoes', '/delegados', '/painel', '/contatos',
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
private onSubmit: ((s: string) => void) | null = null
|
|
58
|
+
private onCancel: (() => void) | null = null
|
|
59
|
+
private onExit: (() => void) | null = null
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private model: string,
|
|
63
|
+
private sessionName: string,
|
|
64
|
+
private authInfo: string = '',
|
|
65
|
+
private dataDir?: string,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
start(handlers: {
|
|
69
|
+
onSubmit: (s: string) => void
|
|
70
|
+
onCancel: () => void
|
|
71
|
+
onExit: () => void
|
|
72
|
+
}): void {
|
|
73
|
+
this.onSubmit = handlers.onSubmit
|
|
74
|
+
this.onCancel = handlers.onCancel
|
|
75
|
+
this.onExit = handlers.onExit
|
|
76
|
+
|
|
77
|
+
this.width = process.stdout.columns || 80
|
|
78
|
+
this.height = process.stdout.rows || 24
|
|
79
|
+
|
|
80
|
+
// Load input history
|
|
81
|
+
if (this.dataDir) {
|
|
82
|
+
this.history = new InputHistory(join(this.dataDir, 'history'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
w(A.altOn)
|
|
86
|
+
process.stdin.setRawMode?.(true)
|
|
87
|
+
process.stdin.resume()
|
|
88
|
+
process.stdin.on('data', (d: Buffer) => this.onKey(d))
|
|
89
|
+
process.stdout.on('resize', () => this.onResize())
|
|
90
|
+
|
|
91
|
+
this.render()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
stop(): void {
|
|
95
|
+
this.stopSpinner()
|
|
96
|
+
if (this.renderTimer) clearTimeout(this.renderTimer)
|
|
97
|
+
process.stdin.setRawMode?.(false)
|
|
98
|
+
process.stdin.pause()
|
|
99
|
+
w(A.show)
|
|
100
|
+
w(A.altOff)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Public API ──────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
addUserMessage(content: string): void {
|
|
106
|
+
this.addLabel('user')
|
|
107
|
+
this.addWrapped(content)
|
|
108
|
+
this.lines.push({ text: '' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
addAssistantMessage(content: string): void {
|
|
112
|
+
this.addLabel('assistant')
|
|
113
|
+
this.addMarkdown(content)
|
|
114
|
+
this.lines.push({ text: '' })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
startStream(): void {
|
|
118
|
+
this.isStreaming = true
|
|
119
|
+
this.streamBuf = ''
|
|
120
|
+
this.streamLines = []
|
|
121
|
+
this.streamStartTime = Date.now()
|
|
122
|
+
this.addLabel('assistant')
|
|
123
|
+
this.startSpinner()
|
|
124
|
+
this.renderAll()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
appendStream(text: string): void {
|
|
128
|
+
this.streamBuf += text
|
|
129
|
+
// Debounce markdown re-render to 50ms to avoid lag
|
|
130
|
+
if (!this.renderTimer) {
|
|
131
|
+
this.renderTimer = setTimeout(() => {
|
|
132
|
+
this.renderTimer = null
|
|
133
|
+
this.streamLines = renderMarkdown(this.streamBuf).map((t) => ({ text: t }))
|
|
134
|
+
this.renderMessages()
|
|
135
|
+
this.renderInput()
|
|
136
|
+
}, 50)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
flushStream(): void {
|
|
141
|
+
if (this.streamLines.length > 0) {
|
|
142
|
+
this.lines.push(...this.streamLines)
|
|
143
|
+
this.streamLines = []
|
|
144
|
+
this.streamBuf = ''
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
resetStreamBuffer(): void {
|
|
149
|
+
this.streamBuf = ''
|
|
150
|
+
this.streamLines = []
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
endStream(): void {
|
|
154
|
+
this.stopSpinner()
|
|
155
|
+
// Flush any pending debounced render
|
|
156
|
+
if (this.renderTimer) {
|
|
157
|
+
clearTimeout(this.renderTimer)
|
|
158
|
+
this.renderTimer = null
|
|
159
|
+
this.streamLines = renderMarkdown(this.streamBuf).map((t) => ({ text: t }))
|
|
160
|
+
}
|
|
161
|
+
this.lines.push(...this.streamLines)
|
|
162
|
+
this.lines.push({ text: '' })
|
|
163
|
+
this.streamBuf = ''
|
|
164
|
+
this.streamLines = []
|
|
165
|
+
this.isStreaming = false
|
|
166
|
+
this.scrollOffset = 0
|
|
167
|
+
this.renderAll()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
showToolCall(name: string, input: unknown): void {
|
|
171
|
+
const inp = input as Record<string, unknown>
|
|
172
|
+
let summary: string
|
|
173
|
+
switch (name) {
|
|
174
|
+
case 'read_file':
|
|
175
|
+
summary = String(inp.path || '')
|
|
176
|
+
if (inp.offset) summary += `:${inp.offset}`
|
|
177
|
+
break
|
|
178
|
+
case 'write_file':
|
|
179
|
+
case 'edit_file':
|
|
180
|
+
summary = String(inp.path || '')
|
|
181
|
+
break
|
|
182
|
+
case 'search_files':
|
|
183
|
+
summary = `/${inp.pattern || ''}/`
|
|
184
|
+
if (inp.include) summary += ` (${inp.include})`
|
|
185
|
+
break
|
|
186
|
+
case 'find_files':
|
|
187
|
+
summary = String(inp.pattern || '')
|
|
188
|
+
break
|
|
189
|
+
case 'list_directory':
|
|
190
|
+
summary = String(inp.path || '.')
|
|
191
|
+
break
|
|
192
|
+
case 'run_command':
|
|
193
|
+
summary = String(inp.command || '')
|
|
194
|
+
if (summary.length > 80) summary = summary.slice(0, 77) + '...'
|
|
195
|
+
break
|
|
196
|
+
default: {
|
|
197
|
+
const s = JSON.stringify(inp)
|
|
198
|
+
summary = s.length > 80 ? s.slice(0, 77) + '...' : s
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.lines.push({
|
|
202
|
+
text: ` ${C.tool}⚙ ${name}${A.reset} ${A.dim}${summary}${A.reset}`,
|
|
203
|
+
})
|
|
204
|
+
this.renderMessages()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
showToolResult(name: string, result: string): void {
|
|
208
|
+
const lines = result.split('\n')
|
|
209
|
+
const maxLines = 8
|
|
210
|
+
const shown = lines.slice(0, maxLines)
|
|
211
|
+
|
|
212
|
+
for (const line of shown) {
|
|
213
|
+
const trimmed = line.length > this.width - 6
|
|
214
|
+
? line.slice(0, this.width - 9) + '...'
|
|
215
|
+
: line
|
|
216
|
+
this.lines.push({
|
|
217
|
+
text: ` ${A.dim}${trimmed}${A.reset}`,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
if (lines.length > maxLines) {
|
|
221
|
+
this.lines.push({
|
|
222
|
+
text: ` ${A.dim}... (${lines.length - maxLines} more lines)${A.reset}`,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
this.renderMessages()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Prompt user for tool approval. Returns a promise resolved when user presses y/n/a.
|
|
230
|
+
* Shows the operation and waits for single keypress.
|
|
231
|
+
*/
|
|
232
|
+
promptApproval(description: string): Promise<boolean> {
|
|
233
|
+
this.lines.push({
|
|
234
|
+
text: ` ${C.prompt}? ${description}${A.reset} ${A.dim}[y]es / [n]o / [a]ll${A.reset}`,
|
|
235
|
+
})
|
|
236
|
+
this.renderAll()
|
|
237
|
+
|
|
238
|
+
return new Promise<boolean>((resolve) => {
|
|
239
|
+
const handler = (data: Buffer) => {
|
|
240
|
+
const key = data.toString().toLowerCase()
|
|
241
|
+
if (key === 'y' || key === '\r' || key === '\n') {
|
|
242
|
+
process.stdin.removeListener('data', handler)
|
|
243
|
+
this.lines.push({ text: ` ${C.sys}approved${A.reset}` })
|
|
244
|
+
this.renderAll()
|
|
245
|
+
resolve(true)
|
|
246
|
+
} else if (key === 'n' || key === '\x1b') {
|
|
247
|
+
process.stdin.removeListener('data', handler)
|
|
248
|
+
this.lines.push({ text: ` ${C.err}rejected${A.reset}` })
|
|
249
|
+
this.renderAll()
|
|
250
|
+
resolve(false)
|
|
251
|
+
} else if (key === 'a') {
|
|
252
|
+
process.stdin.removeListener('data', handler)
|
|
253
|
+
this.lines.push({ text: ` ${C.sys}approved all for this session${A.reset}` })
|
|
254
|
+
this.renderAll()
|
|
255
|
+
// Signal to caller that "approve all" was selected
|
|
256
|
+
this._approveAllRequested = true
|
|
257
|
+
resolve(true)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
process.stdin.on('data', handler)
|
|
261
|
+
|
|
262
|
+
// Timeout after 30s — auto-reject
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
process.stdin.removeListener('data', handler)
|
|
265
|
+
this.lines.push({ text: ` ${A.dim}timeout — auto-rejected${A.reset}` })
|
|
266
|
+
this.renderAll()
|
|
267
|
+
resolve(false)
|
|
268
|
+
}, 30_000)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Flag set when user presses 'a' for approve-all */
|
|
273
|
+
_approveAllRequested = false
|
|
274
|
+
|
|
275
|
+
showUsage(msg: string): void {
|
|
276
|
+
this.lines.push({ text: ` ${A.dim}tokens: ${msg}${A.reset}` })
|
|
277
|
+
this.renderAll()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
updateSessionCost(cost: string): void {
|
|
281
|
+
this.sessionCost = cost
|
|
282
|
+
this.renderHeader()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
showError(msg: string): void {
|
|
286
|
+
this.lines.push({ text: ` ${C.err}✗ ${msg}${A.reset}` })
|
|
287
|
+
this.lines.push({ text: '' })
|
|
288
|
+
this.renderAll()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
showSystem(msg: string): void {
|
|
292
|
+
for (const line of msg.split('\n')) {
|
|
293
|
+
this.lines.push({ text: ` ${C.sys}${line}${A.reset}` })
|
|
294
|
+
}
|
|
295
|
+
this.lines.push({ text: '' })
|
|
296
|
+
this.renderAll()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
clearMessages(): void {
|
|
300
|
+
this.lines = []
|
|
301
|
+
this.renderAll()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
updateModel(m: string): void {
|
|
305
|
+
this.model = m
|
|
306
|
+
this.renderHeader()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
updateSession(s: string): void {
|
|
310
|
+
this.sessionName = s
|
|
311
|
+
this.renderHeader()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
enableInput(): void {
|
|
315
|
+
this.inputBuf = ''
|
|
316
|
+
this.inputPos = 0
|
|
317
|
+
this.isStreaming = false
|
|
318
|
+
this.history?.reset()
|
|
319
|
+
this.renderInput()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
disableInput(): void {
|
|
323
|
+
w(A.hide)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Rendering ───────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
private render(): void {
|
|
329
|
+
w(A.hide)
|
|
330
|
+
w(A.clear)
|
|
331
|
+
this.renderHeader()
|
|
332
|
+
this.renderMessages()
|
|
333
|
+
this.renderInput()
|
|
334
|
+
w(A.show)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private renderAll(): void {
|
|
338
|
+
this.renderMessages()
|
|
339
|
+
this.renderInput()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private renderHeader(): void {
|
|
343
|
+
w(A.to(1, 1))
|
|
344
|
+
w(A.inv)
|
|
345
|
+
const left = ' smolerclaw'
|
|
346
|
+
const parts = [this.model, this.sessionName]
|
|
347
|
+
if (this.sessionCost) parts.push(this.sessionCost)
|
|
348
|
+
if (this.authInfo) parts.push(this.authInfo)
|
|
349
|
+
const right = parts.join(' | ') + ' '
|
|
350
|
+
const pad = Math.max(1, this.width - left.length - right.length)
|
|
351
|
+
w(left + ' '.repeat(pad) + right)
|
|
352
|
+
w(A.reset)
|
|
353
|
+
|
|
354
|
+
w(A.to(2, 1))
|
|
355
|
+
w(`${A.dim}${'─'.repeat(this.width)}${A.reset}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private renderMessages(): void {
|
|
359
|
+
const headerH = 2
|
|
360
|
+
const footerH = 2
|
|
361
|
+
const avail = this.height - headerH - footerH
|
|
362
|
+
|
|
363
|
+
const allLines = [...this.lines, ...this.streamLines]
|
|
364
|
+
const total = allLines.length
|
|
365
|
+
const start = Math.max(0, total - avail - this.scrollOffset)
|
|
366
|
+
const end = Math.min(total, start + avail)
|
|
367
|
+
const visible = allLines.slice(start, end)
|
|
368
|
+
|
|
369
|
+
w(A.hide)
|
|
370
|
+
for (let i = 0; i < avail; i++) {
|
|
371
|
+
w(A.to(headerH + i + 1, 1))
|
|
372
|
+
w(A.clearLine)
|
|
373
|
+
if (i < visible.length) {
|
|
374
|
+
const plain = stripAnsi(visible[i].text)
|
|
375
|
+
if (plain.length > this.width) {
|
|
376
|
+
// Truncate but try to preserve ANSI reset at end
|
|
377
|
+
w(visible[i].text.slice(0, this.width + (visible[i].text.length - plain.length)))
|
|
378
|
+
w(A.reset)
|
|
379
|
+
} else {
|
|
380
|
+
w(visible[i].text)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private renderInput(): void {
|
|
387
|
+
const sepRow = this.height - 1
|
|
388
|
+
const inputRow = this.height
|
|
389
|
+
|
|
390
|
+
w(A.to(sepRow, 1))
|
|
391
|
+
w(A.clearLine)
|
|
392
|
+
w(`${A.dim}${'─'.repeat(this.width)}${A.reset}`)
|
|
393
|
+
|
|
394
|
+
w(A.to(inputRow, 1))
|
|
395
|
+
w(A.clearLine)
|
|
396
|
+
|
|
397
|
+
if (this.isStreaming) {
|
|
398
|
+
const elapsed = ((Date.now() - this.streamStartTime) / 1000).toFixed(1)
|
|
399
|
+
w(` ${C.ai}${this.getSpinnerChar()}${A.reset} ${A.dim}streaming... ${elapsed}s${A.reset}`)
|
|
400
|
+
w(A.hide)
|
|
401
|
+
} else {
|
|
402
|
+
const display = visibleLength(this.inputBuf) > this.width - 3
|
|
403
|
+
? this.inputBuf.slice(this.inputBuf.length - this.width + 3)
|
|
404
|
+
: this.inputBuf
|
|
405
|
+
w(`${C.prompt}❯${A.reset} ${display}`)
|
|
406
|
+
// Unicode-aware cursor: compute display width of chars before cursor
|
|
407
|
+
const beforeCursor = this.inputBuf.slice(0, this.inputPos)
|
|
408
|
+
const cursorCol = visibleLength(beforeCursor) + 3
|
|
409
|
+
w(A.to(inputRow, Math.min(cursorCol, this.width)))
|
|
410
|
+
w(A.show)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Input Handling ──────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
private onKey(data: Buffer): void {
|
|
417
|
+
const key = data.toString('utf-8')
|
|
418
|
+
|
|
419
|
+
// Ctrl+C
|
|
420
|
+
if (key === '\x03') {
|
|
421
|
+
if (this.isStreaming) {
|
|
422
|
+
this.onCancel?.()
|
|
423
|
+
} else {
|
|
424
|
+
this.onExit?.()
|
|
425
|
+
}
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Ctrl+D
|
|
430
|
+
if (key === '\x04') {
|
|
431
|
+
this.onExit?.()
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Ctrl+L — redraw
|
|
436
|
+
if (key === '\x0c') {
|
|
437
|
+
this.render()
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Ignore input during streaming
|
|
442
|
+
if (this.isStreaming) return
|
|
443
|
+
|
|
444
|
+
// Tab — command completion
|
|
445
|
+
if (key === '\t') {
|
|
446
|
+
if (this.inputBuf.startsWith('/')) {
|
|
447
|
+
const matches = this.commands.filter((c) => c.startsWith(this.inputBuf))
|
|
448
|
+
if (matches.length === 1) {
|
|
449
|
+
this.inputBuf = matches[0] + ' '
|
|
450
|
+
this.inputPos = this.inputBuf.length
|
|
451
|
+
this.renderInput()
|
|
452
|
+
} else if (matches.length > 1) {
|
|
453
|
+
// Find common prefix
|
|
454
|
+
let prefix = matches[0]
|
|
455
|
+
for (const m of matches) {
|
|
456
|
+
while (!m.startsWith(prefix)) prefix = prefix.slice(0, -1)
|
|
457
|
+
}
|
|
458
|
+
if (prefix.length > this.inputBuf.length) {
|
|
459
|
+
this.inputBuf = prefix
|
|
460
|
+
this.inputPos = this.inputBuf.length
|
|
461
|
+
}
|
|
462
|
+
this.showSystem(matches.join(' '))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Paste detection: multi-char input that isn't an escape sequence
|
|
469
|
+
// Covers both newline-containing pastes and plain text pastes
|
|
470
|
+
if (key.length > 1 && !key.startsWith('\x1b') && !isSingleUnicodeChar(key)) {
|
|
471
|
+
const cleaned = key.replace(/\r?\n/g, ' ').trim()
|
|
472
|
+
if (cleaned.length > 0) {
|
|
473
|
+
this.inputBuf =
|
|
474
|
+
this.inputBuf.slice(0, this.inputPos) +
|
|
475
|
+
cleaned +
|
|
476
|
+
this.inputBuf.slice(this.inputPos)
|
|
477
|
+
this.inputPos += cleaned.length
|
|
478
|
+
this.renderInput()
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Enter
|
|
484
|
+
if (key === '\r' || key === '\n') {
|
|
485
|
+
// Backslash continuation for multi-line
|
|
486
|
+
if (this.inputBuf.endsWith('\\')) {
|
|
487
|
+
this.inputBuf = this.inputBuf.slice(0, -1) + '\n'
|
|
488
|
+
this.inputPos = this.inputBuf.length
|
|
489
|
+
this.renderInput()
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const input = this.inputBuf.trim()
|
|
494
|
+
if (input) {
|
|
495
|
+
this.history?.add(input)
|
|
496
|
+
this.inputBuf = ''
|
|
497
|
+
this.inputPos = 0
|
|
498
|
+
this.scrollOffset = 0
|
|
499
|
+
this.onSubmit?.(input)
|
|
500
|
+
}
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Backspace
|
|
505
|
+
if (key === '\x7f' || key === '\b') {
|
|
506
|
+
if (this.inputPos > 0) {
|
|
507
|
+
const charLen = prevCharLength(this.inputBuf, this.inputPos)
|
|
508
|
+
this.inputBuf =
|
|
509
|
+
this.inputBuf.slice(0, this.inputPos - charLen) +
|
|
510
|
+
this.inputBuf.slice(this.inputPos)
|
|
511
|
+
this.inputPos -= charLen
|
|
512
|
+
this.renderInput()
|
|
513
|
+
}
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Escape sequences
|
|
518
|
+
if (key.startsWith('\x1b[')) {
|
|
519
|
+
const code = key.slice(2)
|
|
520
|
+
switch (code) {
|
|
521
|
+
case 'D': // Left
|
|
522
|
+
if (this.inputPos > 0) {
|
|
523
|
+
this.inputPos -= prevCharLength(this.inputBuf, this.inputPos)
|
|
524
|
+
this.renderInput()
|
|
525
|
+
}
|
|
526
|
+
break
|
|
527
|
+
case 'C': // Right
|
|
528
|
+
if (this.inputPos < this.inputBuf.length) {
|
|
529
|
+
this.inputPos += nextCharLength(this.inputBuf, this.inputPos)
|
|
530
|
+
this.renderInput()
|
|
531
|
+
}
|
|
532
|
+
break
|
|
533
|
+
case 'A': { // Up — input history
|
|
534
|
+
const prev = this.history?.prev(this.inputBuf)
|
|
535
|
+
if (prev !== null && prev !== undefined) {
|
|
536
|
+
this.inputBuf = prev
|
|
537
|
+
this.inputPos = this.inputBuf.length
|
|
538
|
+
this.renderInput()
|
|
539
|
+
}
|
|
540
|
+
break
|
|
541
|
+
}
|
|
542
|
+
case 'B': { // Down — input history
|
|
543
|
+
const next = this.history?.next()
|
|
544
|
+
if (next !== undefined) {
|
|
545
|
+
this.inputBuf = next
|
|
546
|
+
this.inputPos = this.inputBuf.length
|
|
547
|
+
this.renderInput()
|
|
548
|
+
}
|
|
549
|
+
break
|
|
550
|
+
}
|
|
551
|
+
case '5~': // PageUp — scroll messages up
|
|
552
|
+
if (this.scrollOffset < this.lines.length) {
|
|
553
|
+
this.scrollOffset = Math.min(this.scrollOffset + 5, this.lines.length)
|
|
554
|
+
this.renderMessages()
|
|
555
|
+
}
|
|
556
|
+
break
|
|
557
|
+
case '6~': // PageDown — scroll messages down
|
|
558
|
+
if (this.scrollOffset > 0) {
|
|
559
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 5)
|
|
560
|
+
this.renderMessages()
|
|
561
|
+
}
|
|
562
|
+
break
|
|
563
|
+
case 'H': // Home
|
|
564
|
+
this.inputPos = 0
|
|
565
|
+
this.renderInput()
|
|
566
|
+
break
|
|
567
|
+
case 'F': // End
|
|
568
|
+
this.inputPos = this.inputBuf.length
|
|
569
|
+
this.renderInput()
|
|
570
|
+
break
|
|
571
|
+
case '3~': { // Delete
|
|
572
|
+
if (this.inputPos < this.inputBuf.length) {
|
|
573
|
+
const charLen = nextCharLength(this.inputBuf, this.inputPos)
|
|
574
|
+
this.inputBuf =
|
|
575
|
+
this.inputBuf.slice(0, this.inputPos) +
|
|
576
|
+
this.inputBuf.slice(this.inputPos + charLen)
|
|
577
|
+
this.renderInput()
|
|
578
|
+
}
|
|
579
|
+
break
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Regular printable characters (including multi-byte Unicode like ç, ã, é)
|
|
586
|
+
if (isPrintable(key)) {
|
|
587
|
+
this.inputBuf =
|
|
588
|
+
this.inputBuf.slice(0, this.inputPos) +
|
|
589
|
+
key +
|
|
590
|
+
this.inputBuf.slice(this.inputPos)
|
|
591
|
+
this.inputPos += key.length
|
|
592
|
+
this.renderInput()
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private onResize(): void {
|
|
597
|
+
this.width = process.stdout.columns || 80
|
|
598
|
+
this.height = process.stdout.rows || 24
|
|
599
|
+
this.render()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ── Helpers ─────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
private addLabel(role: 'user' | 'assistant'): void {
|
|
605
|
+
const ts = new Date().toLocaleTimeString('en', {
|
|
606
|
+
hour: '2-digit',
|
|
607
|
+
minute: '2-digit',
|
|
608
|
+
})
|
|
609
|
+
if (role === 'user') {
|
|
610
|
+
this.lines.push({
|
|
611
|
+
text: `${C.user}${A.bold} You${A.reset} ${A.dim}${ts}${A.reset}`,
|
|
612
|
+
})
|
|
613
|
+
} else {
|
|
614
|
+
this.lines.push({
|
|
615
|
+
text: `${C.ai}${A.bold} Claude${A.reset} ${A.dim}${ts}${A.reset}`,
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private startSpinner(): void {
|
|
621
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
622
|
+
this.spinnerFrame = 0
|
|
623
|
+
this.spinnerTimer = setInterval(() => {
|
|
624
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % frames.length
|
|
625
|
+
this.renderInput()
|
|
626
|
+
}, 80)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private stopSpinner(): void {
|
|
630
|
+
if (this.spinnerTimer) {
|
|
631
|
+
clearInterval(this.spinnerTimer)
|
|
632
|
+
this.spinnerTimer = null
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private getSpinnerChar(): string {
|
|
637
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
638
|
+
return frames[this.spinnerFrame % frames.length]
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private addWrapped(text: string): void {
|
|
642
|
+
const wrapped = wrapText(' ' + text, this.width - 2)
|
|
643
|
+
for (const line of wrapped) {
|
|
644
|
+
this.lines.push({ text: line })
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private addMarkdown(text: string): void {
|
|
649
|
+
const rendered = renderMarkdown(text)
|
|
650
|
+
for (const line of rendered) {
|
|
651
|
+
this.lines.push({ text: line })
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ─── Unicode helpers for input handling ───────────────────
|
|
657
|
+
|
|
658
|
+
/** Check if a string is a single Unicode codepoint (may be 1 or 2 JS chars for surrogates) */
|
|
659
|
+
function isSingleUnicodeChar(s: string): boolean {
|
|
660
|
+
const chars = [...s]
|
|
661
|
+
return chars.length === 1
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Check if a key input is a printable character (single codepoint, not control) */
|
|
665
|
+
function isPrintable(key: string): boolean {
|
|
666
|
+
if (!isSingleUnicodeChar(key)) return false
|
|
667
|
+
const code = key.codePointAt(0) || 0
|
|
668
|
+
return code >= 0x20 && code !== 0x7f
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Get the JS string length of the previous codepoint before position */
|
|
672
|
+
function prevCharLength(s: string, pos: number): number {
|
|
673
|
+
if (pos <= 0) return 0
|
|
674
|
+
// Check for surrogate pair (emoji, etc): low surrogate at pos-1 + high surrogate at pos-2
|
|
675
|
+
if (pos >= 2) {
|
|
676
|
+
const low = s.charCodeAt(pos - 1)
|
|
677
|
+
const high = s.charCodeAt(pos - 2)
|
|
678
|
+
if (low >= 0xdc00 && low <= 0xdfff && high >= 0xd800 && high <= 0xdbff) {
|
|
679
|
+
return 2
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return 1
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Get the JS string length of the codepoint at position */
|
|
686
|
+
function nextCharLength(s: string, pos: number): number {
|
|
687
|
+
if (pos >= s.length) return 0
|
|
688
|
+
const high = s.charCodeAt(pos)
|
|
689
|
+
if (high >= 0xd800 && high <= 0xdbff && pos + 1 < s.length) {
|
|
690
|
+
return 2
|
|
691
|
+
}
|
|
692
|
+
return 1
|
|
693
|
+
}
|