free-coding-models 0.5.10 β†’ 0.5.12

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.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * @file playground.js
3
+ * @description TUI Playground β€” a chat overlay that talks to the local FCM
4
+ * router through its `/v1/chat/completions` endpoint. Streams responses via
5
+ * SSE so the user sees the answer appear token-by-token.
6
+ *
7
+ * πŸ“– The TUI Playground is intentionally simpler than the Web Playground:
8
+ * πŸ“– one chat at a time, no copy-paste, no theme overrides. It's a quick
9
+ * πŸ“– way to test the router without leaving the TUI.
10
+ *
11
+ * @functions
12
+ * β†’ openPlaygroundOverlay β€” reset state and mark the overlay open
13
+ * β†’ closePlaygroundOverlay β€” cancel any in-flight request, mark closed
14
+ * β†’ renderPlayground β€” render the full-screen chat overlay
15
+ * β†’ handlePlaygroundKeypress β€” handle Enter / Esc / arrow keys
16
+ * β†’ playgroundSubmit β€” POST the current draft to the router
17
+ * β†’ parsePlaygroundSseFrame β€” parse one SSE frame (defensive, mirrors router-dashboard)
18
+ *
19
+ * @see ./router-daemon.js β€” chat-completions endpoint we POST to
20
+ * @see ./config.js β€” pre-prompt lives under `router.prePrompt`
21
+ * @see ../tui/overlays.js β€” overlay factory that mounts this renderer
22
+ */
23
+
24
+ import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
25
+ import { ROUTER_PORT_PATH } from './router-daemon.js'
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { themeColors } from '../tui/theme.js'
28
+
29
+ // πŸ“– Width budget for the wrapped input + transcript columns inside the
30
+ // πŸ“– overlay. Slightly tighter than the full terminal so borders have room
31
+ // πŸ“– and long assistant messages wrap nicely.
32
+ const STREAM_TIMEOUT_MS = 90000
33
+
34
+ /**
35
+ * πŸ“– Extract a human-readable message from an OpenAI-style error payload.
36
+ * πŸ“– Mirrors the helper in `web/src/components/playground/PlaygroundView.jsx`
37
+ * πŸ“– so the TUI and the Web show the same error string. Without this, a
38
+ * πŸ“– router error like `{ error: { message, type, code, ... } }` would be
39
+ * πŸ“– stored as a React child and crash the playground on the next render.
40
+ *
41
+ * @param {unknown} errBody
42
+ * @returns {string|null}
43
+ */
44
+ export function extractErrorMessage(errBody) {
45
+ if (!errBody || typeof errBody !== 'object') {
46
+ return typeof errBody === 'string' ? errBody : null
47
+ }
48
+ if (typeof errBody.error === 'string') return errBody.error
49
+ if (errBody.error && typeof errBody.error === 'object' && typeof errBody.error.message === 'string') {
50
+ return errBody.error.message
51
+ }
52
+ if (typeof errBody.message === 'string') return errBody.message
53
+ return null
54
+ }
55
+
56
+ export const PLAYGROUND_OVERLAY_STATE = {
57
+ open: false,
58
+ messages: [], // { role, content, meta? }
59
+ draft: '',
60
+ busy: false,
61
+ model: 'fcm',
62
+ streamOn: true,
63
+ prePrompt: null, // { enabled, text } hydrated on open
64
+ statusMessage: null,
65
+ abortController: null,
66
+ scrollOffset: 0,
67
+ cursor: 0, // line cursor inside the textarea draft
68
+ lastError: null,
69
+ }
70
+
71
+ const MAX_TRANSCRIPT_LINES = 200
72
+
73
+ /**
74
+ * πŸ“– Reset the playground to a clean state and mark the overlay open.
75
+ * πŸ“– Fetches the pre-prompt from the router (if reachable) so the chat
76
+ * πŸ“– shows the real persona.
77
+ *
78
+ * @param {object} state - global TUI state
79
+ * @param {object} deps - { fetchFn, loadConfig }
80
+ */
81
+ export async function openPlaygroundOverlay(state, deps = {}) {
82
+ PLAYGROUND_OVERLAY_STATE.open = true
83
+ PLAYGROUND_OVERLAY_STATE.messages = []
84
+ PLAYGROUND_OVERLAY_STATE.draft = ''
85
+ PLAYGROUND_OVERLAY_STATE.busy = false
86
+ PLAYGROUND_OVERLAY_STATE.streamOn = true
87
+ PLAYGROUND_OVERLAY_STATE.model = 'fcm'
88
+ PLAYGROUND_OVERLAY_STATE.statusMessage = null
89
+ PLAYGROUND_OVERLAY_STATE.scrollOffset = 0
90
+ PLAYGROUND_OVERLAY_STATE.cursor = 0
91
+ PLAYGROUND_OVERLAY_STATE.lastError = null
92
+ PLAYGROUND_OVERLAY_STATE.abortController = null
93
+
94
+ // πŸ“– Best-effort fetch of the pre-prompt so the persona pill in the
95
+ // πŸ“– header is accurate. Never fail the overlay over a missing read.
96
+ try {
97
+ const port = await readDaemonPort()
98
+ if (port) {
99
+ const fetchFn = deps.fetchFn || globalThis.fetch
100
+ const resp = await fetchFn(`http://127.0.0.1:${port}/api/router/preprompt`, { signal: AbortSignal.timeout(2000) })
101
+ if (resp.ok) {
102
+ const data = await resp.json().catch(() => null)
103
+ if (data && typeof data === 'object') {
104
+ PLAYGROUND_OVERLAY_STATE.prePrompt = {
105
+ enabled: data.enabled === true,
106
+ text: typeof data.text === 'string' ? data.text : '',
107
+ }
108
+ }
109
+ }
110
+ }
111
+ } catch {
112
+ PLAYGROUND_OVERLAY_STATE.prePrompt = null
113
+ }
114
+ }
115
+
116
+ /**
117
+ * πŸ“– Mark the overlay closed. Cancels any in-flight streaming request so we
118
+ * πŸ“– don't leave dangling fetch handles behind.
119
+ */
120
+ export function closePlaygroundOverlay() {
121
+ if (PLAYGROUND_OVERLAY_STATE.abortController) {
122
+ try { PLAYGROUND_OVERLAY_STATE.abortController.abort() } catch {}
123
+ PLAYGROUND_OVERLAY_STATE.abortController = null
124
+ }
125
+ PLAYGROUND_OVERLAY_STATE.open = false
126
+ PLAYGROUND_OVERLAY_STATE.busy = false
127
+ }
128
+
129
+ /**
130
+ * πŸ“– Append a delta token to the last assistant message in the transcript.
131
+ * πŸ“– Mutates in place so streaming updates don't trigger expensive array
132
+ * πŸ“– recreations.
133
+ */
134
+ function appendAssistantDelta(delta) {
135
+ const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
136
+ if (last && last.role === 'assistant') {
137
+ last.content = (last.content || '') + delta
138
+ }
139
+ }
140
+
141
+ /**
142
+ * πŸ“– Finalize the last assistant message with provider/latency/tokens
143
+ * πŸ“– metadata so the next render can show the routed-via chip.
144
+ */
145
+ function finalizeAssistantMeta(meta) {
146
+ const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
147
+ if (last && last.role === 'assistant') {
148
+ last.meta = { ...(last.meta || {}), ...meta }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * πŸ“– POST the current draft to the router and stream the response back.
154
+ * πŸ“– Cancellation: aborting the in-flight controller keeps the partial
155
+ * πŸ“– answer visible but flags it with `aborted: true` so the renderer
156
+ * πŸ“– can show "stopped" instead of pretending the response completed.
157
+ */
158
+ export async function playgroundSubmit(state, deps = {}) {
159
+ if (PLAYGROUND_OVERLAY_STATE.busy) return
160
+ const text = PLAYGROUND_OVERLAY_STATE.draft.trim()
161
+ if (!text) return
162
+
163
+ const port = await readDaemonPort()
164
+ if (!port) {
165
+ PLAYGROUND_OVERLAY_STATE.lastError = 'Router is not running. Press R or run `free-coding-models --daemon-bg`.'
166
+ return
167
+ }
168
+
169
+ const userMessage = { role: 'user', content: text, ts: Date.now() }
170
+ PLAYGROUND_OVERLAY_STATE.messages.push(userMessage)
171
+ PLAYGROUND_OVERLAY_STATE.messages.push({ role: 'assistant', content: '', ts: Date.now() })
172
+ PLAYGROUND_OVERLAY_STATE.draft = ''
173
+ PLAYGROUND_OVERLAY_STATE.cursor = 0
174
+ PLAYGROUND_OVERLAY_STATE.busy = true
175
+ PLAYGROUND_OVERLAY_STATE.lastError = null
176
+ PLAYGROUND_OVERLAY_STATE.statusMessage = 'Sending…'
177
+
178
+ const controller = new AbortController()
179
+ PLAYGROUND_OVERLAY_STATE.abortController = controller
180
+
181
+ const transcript = PLAYGROUND_MESSAGES_SAFE()
182
+
183
+ const body = {
184
+ model: PLAYGROUND_OVERLAY_STATE.model || 'fcm',
185
+ messages: transcript.map(({ role, content }) => ({ role, content })),
186
+ stream: PLAYGROUND_OVERLAY_STATE.streamOn,
187
+ temperature: 0.7,
188
+ }
189
+
190
+ const url = `http://127.0.0.1:${port}/v1/chat/completions`
191
+ const fetchFn = deps.fetchFn || globalThis.fetch
192
+
193
+ try {
194
+ const resp = await fetchFn(url, {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body: JSON.stringify(body),
198
+ signal: controller.signal,
199
+ })
200
+ if (!resp.ok) {
201
+ const errBody = await resp.json().catch(() => null)
202
+ const msg = extractErrorMessage(errBody)
203
+ PLAYGROUND_OVERLAY_STATE.lastError = `HTTP ${resp.status}: ${msg || 'request failed'}`
204
+ finalizeAssistantMeta({ error: PLAYGROUND_OVERLAY_STATE.lastError, aborted: true })
205
+ return
206
+ }
207
+ if (PLAYGROUND_OVERLAY_STATE.streamOn) {
208
+ await readSseStream(resp, fetchFn, controller)
209
+ } else {
210
+ const json = await resp.json().catch(() => null)
211
+ const content = json?.choices?.[0]?.message?.content || ''
212
+ const usage = json?.usage || {}
213
+ // πŸ“– Some upstreams do not echo the `X-Routed-Via` header in the body;
214
+ // πŸ“– fall back to the `x-routed-via` snake-case field if present.
215
+ finalizeAssistantMeta({
216
+ provider: json?.x_routed_via || null,
217
+ model: json?.x_routed_model || null,
218
+ latencyMs: json?.x_latency_ms || null,
219
+ tokens: usage?.total_tokens || 0,
220
+ fallbackAttempts: json?.x_fallback_attempts || 0,
221
+ })
222
+ // πŸ“– Direct content replacement for non-streaming responses.
223
+ const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
224
+ if (last && last.role === 'assistant') last.content = content
225
+ }
226
+ } catch (err) {
227
+ if (err?.name === 'AbortError') {
228
+ finalizeAssistantMeta({ aborted: true })
229
+ } else {
230
+ PLAYGROUND_OVERLAY_STATE.lastError = err?.message || String(err)
231
+ finalizeAssistantMeta({ error: PLAYGROUND_OVERLAY_STATE.lastError, aborted: true })
232
+ }
233
+ } finally {
234
+ PLAYGROUND_OVERLAY_STATE.busy = false
235
+ PLAYGROUND_OVERLAY_STATE.abortController = null
236
+ PLAYGROUND_OVERLAY_STATE.statusMessage = null
237
+ }
238
+ }
239
+
240
+ function PLAYGROUND_MESSAGES_SAFE() {
241
+ // πŸ“– Trim the transcript to the last 20 turns so long sessions do not blow
242
+ // πŸ“– the request body budget. We always keep the pre-prompt implicit on
243
+ // πŸ“– the server side, so this is the user-facing history only.
244
+ return PLAYGROUND_OVERLAY_STATE.messages.slice(-20)
245
+ }
246
+
247
+ async function readSseStream(resp, fetchFn, controller) {
248
+ const reader = resp.body?.getReader()
249
+ if (!reader) {
250
+ const json = await resp.json().catch(() => null)
251
+ const last = PLAYGROUND_OVERLAY_STATE.messages[PLAYGROUND_OVERLAY_STATE.messages.length - 1]
252
+ if (last && last.role === 'assistant') last.content = json?.choices?.[0]?.message?.content || ''
253
+ return
254
+ }
255
+ const decoder = new TextDecoder()
256
+ let buffer = ''
257
+ let streamTimer = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
258
+ try {
259
+ while (true) {
260
+ const { value, done } = await reader.read()
261
+ if (done) break
262
+ clearTimeout(streamTimer)
263
+ streamTimer = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
264
+ buffer += decoder.decode(value, { stream: true })
265
+ const events = buffer.split(/\n\n/)
266
+ buffer = events.pop() || ''
267
+ for (const event of events) {
268
+ for (const line of event.split(/\n/)) {
269
+ if (!line.startsWith('data:')) continue
270
+ const payload = line.slice(5).trim()
271
+ if (payload === '[DONE]') continue
272
+ try {
273
+ const json = JSON.parse(payload)
274
+ const delta = json?.choices?.[0]?.delta?.content
275
+ if (delta) appendAssistantDelta(delta)
276
+ if (json?.x_routed_via) finalizeAssistantMeta({ provider: json.x_routed_via })
277
+ if (json?.x_routed_model) finalizeAssistantMeta({ model: json.x_routed_model })
278
+ if (json?.x_latency_ms) finalizeAssistantMeta({ latencyMs: json.x_latency_ms })
279
+ if (json?.x_fallback_attempts) finalizeAssistantMeta({ fallbackAttempts: json.x_fallback_attempts })
280
+ if (json?.usage?.total_tokens) finalizeAssistantMeta({ tokens: json.usage.total_tokens })
281
+ } catch {
282
+ // πŸ“– Non-JSON keep-alive frames; ignore.
283
+ }
284
+ }
285
+ }
286
+ }
287
+ } finally {
288
+ clearTimeout(streamTimer)
289
+ }
290
+ }
291
+
292
+ /**
293
+ * πŸ“– Read the daemon port from disk. Returns null when the daemon is not
294
+ * πŸ“– running. Mirrors the helper in `router-dashboard.js` so the playground
295
+ * πŸ“– can stand alone in the TUI process.
296
+ */
297
+ async function readDaemonPort() {
298
+ // πŸ“– Try the recorded port file first.
299
+ try {
300
+ if (existsSync(ROUTER_PORT_PATH)) {
301
+ const raw = readFileSync(ROUTER_PORT_PATH, 'utf8').trim()
302
+ if (/^\d+$/.test(raw)) return Number(raw)
303
+ }
304
+ } catch {}
305
+ return null
306
+ }
307
+
308
+ /**
309
+ * πŸ“– Handle a keypress inside the playground overlay. Returns true if the
310
+ * πŸ“– key was consumed so the main key handler can skip it.
311
+ */
312
+ export function handlePlaygroundKeypress(key, deps = {}) {
313
+ if (!PLAYGROUND_OVERLAY_STATE.open) return false
314
+ // πŸ“– Esc always closes.
315
+ if (key === 'Escape' || key === '\u001b') {
316
+ closePlaygroundOverlay()
317
+ return true
318
+ }
319
+ if (PLAYGROUND_OVERLAY_STATE.busy) {
320
+ // πŸ“– While streaming, only Esc and Ctrl+C are accepted.
321
+ if (key === 'C-c' || key === '\u0003') {
322
+ if (PLAYGROUND_OVERLAY_STATE.abortController) PLAYGROUND_OVERLAY_STATE.abortController.abort()
323
+ return true
324
+ }
325
+ return key === 'Escape'
326
+ }
327
+
328
+ if (key === 'C-c' || key === '\u0003') {
329
+ if (PLAYGROUND_OVERLAY_STATE.abortController) PLAYGROUND_OVERLAY_STATE.abortController.abort()
330
+ PLAYGROUND_OVERLAY_STATE.busy = false
331
+ return true
332
+ }
333
+
334
+ if (key === 'Enter' || key === '\r' || key === '\n') {
335
+ void playgroundSubmit(null, deps)
336
+ return true
337
+ }
338
+ if (key === 'Backspace' || key === '\b' || key === '\u007f') {
339
+ PLAYGROUND_OVERLAY_STATE.draft = PLAYGROUND_OVERLAY_STATE.draft.slice(0, -1)
340
+ return true
341
+ }
342
+ if (key === 'Tab' || key === '\t') {
343
+ // πŸ“– Cycle the model between fcm and a random catalog entry for quick testing.
344
+ PLAYGROUND_OVERLAY_STATE.model = PLAYGROUND_OVERLAY_STATE.model === 'fcm' ? 'groq/llama-3.3-70b-versatile' : 'fcm'
345
+ return true
346
+ }
347
+ if (key === 'C-l' || key === '\u000c') {
348
+ // πŸ“– Clear transcript
349
+ PLAYGROUND_OVERLAY_STATE.messages = []
350
+ PLAYGROUND_OVERLAY_STATE.scrollOffset = 0
351
+ return true
352
+ }
353
+ if (key === 'C-s' || key === '\u0013') {
354
+ PLAYGROUND_OVERLAY_STATE.streamOn = !PLAYGROUND_OVERLAY_STATE.streamOn
355
+ return true
356
+ }
357
+
358
+ // πŸ“– Arrow keys for transcript scrolling (page up/down style).
359
+ if (key === 'PageUp' || key === '\u001b[5~') {
360
+ PLAYGROUND_OVERLAY_STATE.scrollOffset = Math.max(0, PLAYGROUND_OVERLAY_STATE.scrollOffset - 4)
361
+ return true
362
+ }
363
+ if (key === 'PageDown' || key === '\u001b[6~') {
364
+ PLAYGROUND_OVERLAY_STATE.scrollOffset = PLAYGROUND_OVERLAY_STATE.scrollOffset + 4
365
+ return true
366
+ }
367
+
368
+ // πŸ“– Regular printable characters: append to draft.
369
+ if (key.length === 1 && key >= ' ' && key <= '~') {
370
+ PLAYGROUND_OVERLAY_STATE.draft += key
371
+ return true
372
+ }
373
+ // πŸ“– Multi-byte (UTF-8) printable characters: still treat as one grapheme.
374
+ if (key && key.length > 1 && !key.startsWith('\u001b')) {
375
+ PLAYGROUND_OVERLAY_STATE.draft += key
376
+ return true
377
+ }
378
+ return false
379
+ }
380
+
381
+ /**
382
+ * πŸ“– Render the playground overlay. Returns the painted buffer string ready
383
+ * πŸ“– to write to the alt-screen. Wraps long lines and tints the overlay
384
+ * πŸ“– background for visual separation.
385
+ *
386
+ * @param {object} state - global TUI state
387
+ * @param {number} terminalRows
388
+ * @param {number} terminalCols
389
+ * @returns {string}
390
+ */
391
+ export function renderPlayground(state, terminalRows, terminalCols) {
392
+ if (!PLAYGROUND_OVERLAY_STATE.open) return ''
393
+ const lines = []
394
+ const innerWidth = Math.max(40, terminalCols - 8)
395
+
396
+ // πŸ“– Header
397
+ lines.push(themeColors.accentBold(' πŸ’¬ Playground β€” chat with the FCM router'))
398
+ lines.push(themeColors.dim(' Press Enter to send Β· Shift+Tab cycles model Β· Ctrl+S toggles streaming Β· Esc closes Β· Ctrl+L clears'))
399
+ lines.push('')
400
+
401
+ // πŸ“– Persona pill (one liner preview of the pre-prompt)
402
+ const pre = PLAYGROUND_OVERLAY_STATE.prePrompt
403
+ if (pre && pre.enabled && pre.text) {
404
+ const preview = pre.text.length > innerWidth - 16 ? `${pre.text.slice(0, innerWidth - 19)}…` : pre.text
405
+ lines.push(themeColors.dim(` Persona: ${preview.replace(/\n+/g, ' ')}`))
406
+ } else {
407
+ lines.push(themeColors.dim(' Persona: (none)'))
408
+ }
409
+
410
+ // πŸ“– Model + mode row
411
+ const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? 'streaming' : 'one-shot'
412
+ lines.push(themeColors.dim(` Model: ${PLAYGROUND_OVERLAY_STATE.model} Β· ${mode}`))
413
+ if (PLAYGROUND_OVERLAY_STATE.lastError) {
414
+ lines.push(themeColors.errorBold(` ⚠ ${PLAYGROUND_OVERLAY_STATE.lastError}`))
415
+ }
416
+ lines.push('')
417
+
418
+ // πŸ“– Transcript
419
+ const transcriptLines = []
420
+ for (const msg of PLAYGROUND_OVERLAY_STATE.messages) {
421
+ const role = msg.role === 'user'
422
+ ? themeColors.accentBold(' ❯ you')
423
+ : msg.role === 'assistant'
424
+ ? themeColors.dim(' ✦ fcm')
425
+ : themeColors.dim(` Β· ${msg.role}`)
426
+ transcriptLines.push(role)
427
+ const wrapped = wrapMessage(msg.content || '', innerWidth)
428
+ for (const line of wrapped) {
429
+ transcriptLines.push(` ${line}`)
430
+ }
431
+ if (msg.role === 'assistant' && msg.meta) {
432
+ const metaChips = []
433
+ if (msg.meta.provider) metaChips.push(`routed ${msg.meta.provider}/${msg.meta.model || '?'}`)
434
+ if (msg.meta.latencyMs != null) metaChips.push(`${msg.meta.latencyMs}ms`)
435
+ if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} tok`)
436
+ if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} fallback`)
437
+ if (metaChips.length) {
438
+ transcriptLines.push(themeColors.dim(` [ ${metaChips.join(' Β· ')} ]`))
439
+ }
440
+ if (msg.meta.aborted) {
441
+ transcriptLines.push(themeColors.dim(' [ stopped ]'))
442
+ }
443
+ if (msg.meta.error) {
444
+ transcriptLines.push(themeColors.error(` [ error: ${msg.meta.error} ]`))
445
+ }
446
+ }
447
+ transcriptLines.push('')
448
+ }
449
+ lines.push(...transcriptLines)
450
+
451
+ // πŸ“– Pad the transcript up to a stable minimum height so the input box
452
+ // πŸ“– doesn't jump around between renders.
453
+ const minTranscriptLines = Math.max(8, terminalRows - 12)
454
+ while (lines.length < minTranscriptLines) lines.push('')
455
+
456
+ // πŸ“– Input box
457
+ lines.push(themeColors.dim(' ─'.repeat(Math.max(8, Math.floor(innerWidth / 4)))))
458
+ if (PLAYGROUND_OVERLAY_STATE.busy) {
459
+ lines.push(themeColors.accent(' ⏳ waiting for response β€” Esc to stop'))
460
+ } else {
461
+ const draft = PLAYGROUND_OVERLAY_STATE.draft || 'Type your message and press Enter…'
462
+ const draftDisplay = PLAYGROUND_OVERLAY_STATE.draft ? draft : themeColors.dim(draft)
463
+ const wrappedDraft = wrapMessage(draftDisplay, innerWidth - 4)
464
+ for (const line of wrappedDraft) {
465
+ lines.push(` ❯ ${line}`)
466
+ }
467
+ if (!PLAYGROUND_OVERLAY_STATE.draft) {
468
+ lines.push(themeColors.dim(' (Enter to send Β· Shift+Tab for a pinned model)'))
469
+ }
470
+ }
471
+
472
+ // πŸ“– Slice for the terminal height so we never overflow.
473
+ const { visible, offset } = sliceOverlayLines(lines, PLAYGROUND_OVERLAY_STATE.scrollOffset, terminalRows)
474
+ PLAYGROUND_OVERLAY_STATE.scrollOffset = offset
475
+ const tinted = tintOverlayLines(visible, themeColors.overlayBgPlayground, terminalCols)
476
+ return tinted.map((l) => l + '\x1b[0m').join('\n')
477
+ }
478
+
479
+ /**
480
+ * πŸ“– Wrap a string into lines of at most `width` display columns. Respects
481
+ * πŸ“– existing newlines so the user can paste multi-line content.
482
+ */
483
+ function wrapMessage(text, width) {
484
+ if (!text) return ['']
485
+ const out = []
486
+ for (const paragraph of text.split(/\n/)) {
487
+ if (!paragraph) { out.push(''); continue }
488
+ const words = paragraph.split(/(\s+)/)
489
+ let current = ''
490
+ for (const word of words) {
491
+ const candidate = current + word
492
+ if (displayWidth(candidate) > width && current) {
493
+ out.push(current)
494
+ current = word.trimStart()
495
+ } else {
496
+ current = candidate
497
+ }
498
+ }
499
+ if (current) out.push(current)
500
+ }
501
+ return out
502
+ }