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.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +30 -0
  2. package/.github/workflows/release.yml +67 -0
  3. package/bun.lock +33 -0
  4. package/dist/index.js +321 -0
  5. package/dist/tinyclaw.exe +0 -0
  6. package/install.ps1 +119 -0
  7. package/package.json +25 -0
  8. package/skills/business.md +77 -0
  9. package/skills/default.md +77 -0
  10. package/src/ansi.ts +164 -0
  11. package/src/approval.ts +74 -0
  12. package/src/auth.ts +125 -0
  13. package/src/briefing.ts +52 -0
  14. package/src/claude.ts +267 -0
  15. package/src/cli.ts +137 -0
  16. package/src/clipboard.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/context-window.ts +190 -0
  19. package/src/context.ts +125 -0
  20. package/src/decisions.ts +122 -0
  21. package/src/email.ts +123 -0
  22. package/src/errors.ts +78 -0
  23. package/src/export.ts +82 -0
  24. package/src/finance.ts +148 -0
  25. package/src/git.ts +62 -0
  26. package/src/history.ts +100 -0
  27. package/src/images.ts +68 -0
  28. package/src/index.ts +1431 -0
  29. package/src/investigate.ts +415 -0
  30. package/src/markdown.ts +125 -0
  31. package/src/memos.ts +191 -0
  32. package/src/models.ts +94 -0
  33. package/src/monitor.ts +169 -0
  34. package/src/morning.ts +108 -0
  35. package/src/news.ts +329 -0
  36. package/src/openai-provider.ts +127 -0
  37. package/src/people.ts +472 -0
  38. package/src/personas.ts +99 -0
  39. package/src/platform.ts +84 -0
  40. package/src/plugins.ts +125 -0
  41. package/src/pomodoro.ts +169 -0
  42. package/src/providers.ts +70 -0
  43. package/src/retry.ts +108 -0
  44. package/src/session.ts +128 -0
  45. package/src/skills.ts +102 -0
  46. package/src/tasks.ts +418 -0
  47. package/src/tokens.ts +102 -0
  48. package/src/tool-safety.ts +100 -0
  49. package/src/tools.ts +1479 -0
  50. package/src/tui.ts +693 -0
  51. package/src/types.ts +55 -0
  52. package/src/undo.ts +83 -0
  53. package/src/windows.ts +299 -0
  54. package/src/workflows.ts +197 -0
  55. package/tests/ansi.test.ts +58 -0
  56. package/tests/approval.test.ts +43 -0
  57. package/tests/briefing.test.ts +10 -0
  58. package/tests/cli.test.ts +53 -0
  59. package/tests/context-window.test.ts +83 -0
  60. package/tests/images.test.ts +28 -0
  61. package/tests/memos.test.ts +116 -0
  62. package/tests/models.test.ts +34 -0
  63. package/tests/news.test.ts +13 -0
  64. package/tests/path-guard.test.ts +37 -0
  65. package/tests/people.test.ts +204 -0
  66. package/tests/skills.test.ts +35 -0
  67. package/tests/ssrf.test.ts +80 -0
  68. package/tests/tasks.test.ts +152 -0
  69. package/tests/tokens.test.ts +44 -0
  70. package/tests/tool-safety.test.ts +55 -0
  71. package/tests/windows-security.test.ts +59 -0
  72. package/tests/windows.test.ts +20 -0
  73. 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
+ }