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.
- package/README.md +40 -0
- package/bin/free-coding-models.js +6 -1
- package/changelog/v0.5.11.md +56 -0
- package/changelog/v0.5.12.md +152 -0
- package/package.json +1 -1
- package/src/core/config.js +59 -1
- package/src/core/playground.js +502 -0
- package/src/core/router-daemon.js +861 -29
- package/src/core/utils.js +7 -0
- package/src/tui/app.js +9 -1
- package/src/tui/cli-help.js +2 -0
- package/src/tui/command-palette.js +1 -0
- package/src/tui/key-handler.js +22 -0
- package/src/tui/overlays.js +11 -0
- package/src/tui/render-helpers.js +35 -0
- package/src/tui/render-table.js +22 -7
- package/src/tui/theme.js +3 -0
- package/web/dist/assets/index-Ce_pr2YF.js +39 -0
- package/web/dist/assets/index-H9JWDRIh.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +203 -1
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/ModelTable.jsx +9 -0
- package/web/src/components/dashboard/ModelTable.module.css +13 -0
- package/web/src/components/layout/Header.jsx +2 -1
- package/web/src/components/palette/CommandPalette.jsx +1 -0
- package/web/src/components/playground/PlaygroundView.jsx +528 -0
- package/web/src/components/playground/PlaygroundView.module.css +413 -0
- package/web/src/components/router/RouterView.jsx +519 -35
- package/web/src/components/router/RouterView.module.css +355 -0
- package/web/dist/assets/index-BrJgRhTA.css +0 -1
- package/web/dist/assets/index-BwLMt0bw.js +0 -39
|
@@ -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
|
+
}
|