Files
bare-operating-system/packages/bare-os-coreutils/lib/chat-tui.js
T
Raven Scott ddebf42f1c
Release rolling / release (push) Successful in 9m38s
TUI Updates p2
2026-08-12 22:55:38 -04:00

1151 lines
34 KiB
JavaScript

/** Full-screen swarm chat TUI — preamble: edit-ansi → edit-key-parse → edit-stream-read. */
const BARE_CHAT_MAX_TRANSCRIPT = 4000
const BARE_CHAT_INPUT_MAX = 8192
/**
* @param {string} s
* @param {number} maxCols
*/
function bareChatTruncateVis(s, maxCols) {
const t = String(s || '')
if (maxCols < 8) return ''
if (t.length <= maxCols) return t
return t.slice(0, Math.max(0, maxCols - 1)) + '\u2026'
}
/**
* @param {unknown} ms
*/
function bareChatFmtClock(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '--:--:--'
const d = new Date(ms)
const z = (n) => (n < 10 ? '0' : '') + n
return z(d.getHours()) + ':' + z(d.getMinutes()) + ':' + z(d.getSeconds())
}
/**
* @param {Record<string, unknown>} ev
*/
function bareChatFmtEventLine(ev) {
const nickRaw = String(ev.displayName ?? '').trim()
const pkHint =
typeof ev.fromPeerKey === 'string' ? ev.fromPeerKey.slice(0, 10) : ''
const nick = nickRaw || pkHint || 'peer'
const body = String(ev.body ?? '')
.replace(/\r\n/g, '\n')
.split('\n')
.join('\u2423 ')
const tag = ev.local ? '*' : ' '
const ms =
typeof ev.receivedAtMs === 'number'
? ev.receivedAtMs
: typeof ev.tsMs === 'number'
? ev.tsMs
: Date.now()
return '[' + bareChatFmtClock(ms) + ']' + tag + nick + ': ' + body
}
/**
* @param {Record<string, unknown>} ctx
*/
async function bareChatReadProcSnapshot(ctx) {
try {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readFile !== 'function') return null
const b = await vfs.readFile('/proc/bare_os/chat.json')
if (!b) return null
const t = ctx.b4a.toString(b).trim()
if (!t) return null
return /** @type {Record<string, unknown>} */ (JSON.parse(t))
} catch {
return null
}
}
/**
* @param {Record<string, unknown> | null} snap
* @param {{
* swarmPeers: { current: number | null },
* muxRx: { current: number | null },
* muxTx: { current: number | null },
* wireRxTotal: { current: number | null },
* dropRate: { current: number | null },
* dropVerify: { current: number | null }
* }} out
*/
function bareChatApplyProcSnap(snap, out) {
if (!snap || typeof snap !== 'object') return
const sp = snap.swarmPeers
out.swarmPeers.current =
typeof sp === 'number' && Number.isFinite(sp) ? sp : null
const met = snap.metrics
if (met && typeof met === 'object') {
const m =
/** @type {{ rxEvent?: number, txEvent?: number, droppedRate?: number, droppedVerify?: number }} */ (
met
)
out.muxRx.current =
typeof m.rxEvent === 'number' && Number.isFinite(m.rxEvent)
? m.rxEvent
: null
out.muxTx.current =
typeof m.txEvent === 'number' && Number.isFinite(m.txEvent)
? m.txEvent
: null
out.dropRate.current =
typeof m.droppedRate === 'number' && Number.isFinite(m.droppedRate)
? m.droppedRate
: null
out.dropVerify.current =
typeof m.droppedVerify === 'number' && Number.isFinite(m.droppedVerify)
? m.droppedVerify
: null
} else {
out.muxRx.current = null
out.muxTx.current = null
out.dropRate.current = null
out.dropVerify.current = null
}
const wire = snap.protomuxChatRxTotal
out.wireRxTotal.current =
typeof wire === 'number' && Number.isFinite(wire) ? wire : null
}
/**
* @param {Record<string, unknown>} ctx
*/
function bareChatTuiRunOpts(ctx) {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
/** @type {{ altScreen?: boolean, bracketedPaste?: boolean }} */
const opts = {}
if (
(env.BARE_EDIT_NO_ALTSCREEN != null &&
String(env.BARE_EDIT_NO_ALTSCREEN) !== '') ||
(env.BARE_OS_TUI_NO_ALTSCREEN != null &&
String(env.BARE_OS_TUI_NO_ALTSCREEN) !== '')
) {
opts.altScreen = false
}
if (
env.BARE_EDIT_NO_BRACKETED_PASTE != null &&
String(env.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
) {
opts.bracketedPaste = false
}
return opts
}
/**
* @param {Record<string, unknown>} ctx
*/
function bareChatTickMs(ctx) {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
let tickMs = 1000
const tickRaw = env.BARE_CHAT_STATUS_MS
if (tickRaw != null && String(tickRaw) !== '') {
const n = Number.parseInt(String(tickRaw), 10)
if (Number.isFinite(n) && n >= 0)
tickMs = Math.min(Math.max(0, n), 3_600_000)
}
return tickMs
}
/**
* Windowed input line (inverse cursor). Matches the pre-SDK painter.
* @param {{ value: string, cursor: number, prompt?: string }} input
* @param {number} cols
*/
function bareChatViewInput(input, cols) {
const prompt = input.prompt || '> '
const budget = Math.max(8, cols - prompt.length - 2)
const ib = String(input.value || '')
const ic = Math.min(Math.max(0, input.cursor | 0), ib.length)
let winStart = 0
if (ib.length > budget) {
winStart = ic - Math.floor(budget / 2)
if (winStart < 0) winStart = 0
if (winStart > ib.length - budget) {
winStart = Math.max(0, ib.length - budget)
}
}
const slice = ib.slice(winStart, winStart + budget)
const rel = ic - winStart
return {
prompt,
before: slice.slice(0, rel),
curCh: rel < slice.length ? slice.charAt(rel) : ' ',
after: slice.slice(rel + 1)
}
}
/**
* TEA model for the swarm chat TUI.
* @param {Record<string, unknown>} ctx
* @param {string} argv0
*/
function bareChatCreateTuiApp(ctx, argv0) {
const tui = ctx.tui
const size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
const tickMs = bareChatTickMs(ctx)
return {
argv0: argv0 || 'chat',
mode: /** @type {'main'|'help'} */ ('main'),
transcript: /** @type {string[]} */ ([]),
scrollTop: 0,
stickToBottom: true,
input: tui.textinput.create({
prompt: '> ',
charLimit: BARE_CHAT_INPUT_MAX,
focused: true
}),
tickMs,
width: size0.width || 80,
height: size0.height || 24,
metrics: {
swarmPeers: /** @type {{ current: number | null }} */ ({ current: null }),
muxRx: /** @type {{ current: number | null }} */ ({ current: null }),
muxTx: /** @type {{ current: number | null }} */ ({ current: null }),
wireRxTotal: /** @type {{ current: number | null }} */ ({
current: null
}),
dropRate: /** @type {{ current: number | null }} */ ({ current: null }),
dropVerify: /** @type {{ current: number | null }} */ ({ current: null })
},
_unsub: /** @type {(() => void) | null} */ (null),
init: function () {
const self = this
if (typeof ctx.bareOsChatSubscribe === 'function') {
this._unsub = ctx.bareOsChatSubscribe(function (ev) {
if (tui && typeof tui.send === 'function') {
tui.send({
type: 'chat.event',
ev: ev && typeof ev === 'object' ? ev : {}
})
}
})
}
if (typeof ctx.bareOsChatHistory === 'function') {
try {
const hist = ctx.bareOsChatHistory(BARE_CHAT_MAX_TRANSCRIPT)
if (Array.isArray(hist)) {
for (const h of hist) {
if (h && typeof h === 'object') {
self._push(
bareChatFmtEventLine(
/** @type {Record<string, unknown>} */ (h)
)
)
}
}
self.stickToBottom = true
}
} catch {
/* ignore */
}
}
return tui.batch(this._refreshProc(), this._tickCmd())
},
dispose: function () {
if (typeof this._unsub === 'function') {
try {
this._unsub()
} catch {
/* ignore */
}
this._unsub = null
}
},
_push: function (text) {
this.transcript.push(text)
while (this.transcript.length > BARE_CHAT_MAX_TRANSCRIPT) {
this.transcript.shift()
if (this.scrollTop > 0) this.scrollTop--
}
},
_msgH: function () {
return Math.max(1, (this.height || 24) - 6)
},
_clamp: function () {
const maxTop = Math.max(0, this.transcript.length - this._msgH())
if (this.stickToBottom || this.scrollTop > maxTop) this.scrollTop = maxTop
if (this.scrollTop < 0) this.scrollTop = 0
},
_refreshProc: function () {
return function () {
return Promise.resolve()
.then(function () {
let snap = null
if (typeof ctx.bareOsChatProcSnapshot === 'function') {
try {
snap = ctx.bareOsChatProcSnapshot()
} catch {
snap = null
}
}
if (!snap || typeof snap !== 'object') {
return bareChatReadProcSnapshot(ctx)
}
return snap
})
.then(function (snap) {
return { type: 'chat.proc', snap: snap }
})
}
},
_tickCmd: function () {
if (this.tickMs <= 0) return null
const ms = this.tickMs
return tui.tick(ms, function () {
return { type: 'chat.tick' }
})
},
_insertPaste: function (text) {
const first = String(text || '')
.split(/\r?\n/)[0]
.slice(0, BARE_CHAT_INPUT_MAX)
if (!first) return
const field = this.input
const room = Math.max(0, BARE_CHAT_INPUT_MAX - field.value.length)
const chunk = first.slice(0, room)
const c = field.cursor
field.setValue(field.value.slice(0, c) + chunk + field.value.slice(c))
field.cursor = c + chunk.length
},
update: function (msg) {
if (this.mode === 'help') {
if (msg && msg.type === 'key') this.mode = 'main'
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+c', 'ctrl+q', 'ctrl+x')) {
return [this, tui.quit]
}
if (msg && msg.type === 'resize') {
this.width = msg.width || this.width
this.height = msg.height || this.height
this._clamp()
return [this, null]
}
if (msg && msg.type === 'chat.proc') {
bareChatApplyProcSnap(
/** @type {Record<string, unknown> | null} */ (msg.snap),
this.metrics
)
return [this, null]
}
if (msg && msg.type === 'chat.event') {
this._push(
bareChatFmtEventLine(
/** @type {Record<string, unknown>} */ (msg.ev || {})
)
)
this._clamp()
return [this, this._refreshProc()]
}
if (msg && msg.type === 'chat.tick') {
return [this, tui.batch(this._refreshProc(), this._tickCmd())]
}
if (msg && msg.type === 'paste') {
this._insertPaste(msg.text)
return [this, null]
}
if (tui.key.matches(msg, '?')) {
this.mode = 'help'
return [this, null]
}
if (tui.key.matches(msg, 'enter')) {
const line = String(this.input.value || '').trim()
this.input.reset()
if (line && typeof ctx.bareOsChatSend === 'function') {
ctx.bareOsChatSend(line)
}
this.stickToBottom = true
this._clamp()
return [this, this._refreshProc()]
}
if (tui.key.matches(msg, 'ctrl+r')) {
return [this, this._refreshProc()]
}
if (tui.key.matches(msg, 'ctrl+l')) {
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+a')) {
this.input.cursor = 0
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+e')) {
this.input.cursor = this.input.value.length
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+u')) {
this.input.setValue(this.input.value.slice(this.input.cursor))
this.input.cursor = 0
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+k')) {
this.input.setValue(this.input.value.slice(0, this.input.cursor))
return [this, null]
}
if (tui.key.matches(msg, 'up')) {
this.stickToBottom = false
if (this.scrollTop > 0) this.scrollTop--
return [this, null]
}
if (tui.key.matches(msg, 'down')) {
const maxTop = Math.max(0, this.transcript.length - this._msgH())
if (this.scrollTop < maxTop) this.scrollTop++
if (this.scrollTop >= maxTop) this.stickToBottom = true
return [this, null]
}
if (tui.key.matches(msg, 'pageup')) {
this.stickToBottom = false
this.scrollTop = Math.max(0, this.scrollTop - this._msgH())
return [this, null]
}
if (tui.key.matches(msg, 'pagedown')) {
const maxTop = Math.max(0, this.transcript.length - this._msgH())
this.scrollTop = Math.min(maxTop, this.scrollTop + this._msgH())
if (this.scrollTop >= maxTop) this.stickToBottom = true
return [this, null]
}
const pair = this.input.update(msg)
this.input = pair[0]
return [this, pair[1]]
},
view: function () {
const cols = Math.max(40, this.width || 80)
const st = tui.style
if (this.mode === 'help') {
return (
st().bold().foreground('cyan').render('Bare OS — swarm chat') +
'\n\n' +
'Send with Enter. Scroll transcript with \u2191/\u2193 or PgUp/PgDn.\n' +
'Input: Backspace/Delete, \u2190/\u2192, Home/End, ^A/^E line start/end, ^U kill to start, ^K kill to end.\n' +
'^R refresh status ^L redraw ^Q / ^X / Ctrl+C exit\n' +
'`chat send TEXT` / `chat who` / `chat history` remain for scripts.\n' +
'Env: BARE_CHAT_STATUS_MS — status refresh interval in ms (default 1000; 0 disables timer).\n\n' +
'Press any key.\n'
)
}
this._clamp()
const rooms =
typeof ctx.bareOsChatRooms === 'function' ? ctx.bareOsChatRooms() : []
const roomStr =
Array.isArray(rooms) && rooms.length ? rooms.join(', ') : 'general'
const m = this.metrics
const peerStr =
m.swarmPeers.current != null ? String(m.swarmPeers.current) : '?'
const rxStr = m.muxRx.current != null ? String(m.muxRx.current) : '?'
const txStr = m.muxTx.current != null ? String(m.muxTx.current) : '?'
const wireStr =
m.wireRxTotal.current != null ? String(m.wireRxTotal.current) : '?'
const titleParts = [
this.argv0 || 'chat',
roomStr,
'peers ' + peerStr,
'rx ' + rxStr,
'tx ' + txStr,
'wire ' + wireStr
]
const dr = m.dropRate.current ?? 0
const dv = m.dropVerify.current ?? 0
if (dr > 0 || dv > 0) {
titleParts.push('drops r' + dr + '/v' + dv)
}
const title = st()
.foreground('brightwhite')
.background('blue')
.width(cols)
.render(' \u250c ' + titleParts.join(' \u00b7 ') + ' ')
const nowClock = bareChatFmtClock(Date.now())
const hint = st()
.dim()
.width(cols)
.render(
nowClock +
' bare-os-chat-v1 ? help ^Q quit ^R refresh' +
(this.tickMs > 0 ? ' tick ' + this.tickMs + 'ms' : ' tick off')
)
const rule = st()
.dim()
.width(cols)
.render('\u2500'.repeat(Math.min(cols, 120)))
const msgH = this._msgH()
const body = []
for (let i = 0; i < msgH; i++) {
const idx = this.scrollTop + i
const raw =
idx >= 0 && idx < this.transcript.length ? this.transcript[idx] : ''
const line = st.truncate(raw, cols)
if (idx === this.transcript.length - 1 && this.stickToBottom && raw) {
body.push(st().foreground('green').width(cols).render(line))
} else {
body.push(line)
}
}
const linesBelow = Math.max(
0,
this.transcript.length - this.scrollTop - msgH
)
let scrollHint = ''
if (!this.stickToBottom && (this.scrollTop > 0 || linesBelow > 0)) {
const parts = []
if (this.scrollTop > 0)
parts.push('\u2191 ' + this.scrollTop + ' older')
if (linesBelow > 0) parts.push('\u2193 ' + linesBelow + ' newer')
scrollHint = parts.join(' ')
}
const hint2 = st()
.dim()
.width(cols)
.render(
(scrollHint ? scrollHint + ' ' : '') +
'Enter send \u2191\u2193 transcript Backspace / ^A ^E ^U ^K'
)
const win = bareChatViewInput(this.input, cols)
const inputLine =
st().dim().render(win.prompt) +
win.before +
st()
.reverse()
.render(win.curCh || ' ') +
win.after
return (
title +
'\n' +
hint +
'\n' +
rule +
'\n' +
body.join('\n') +
'\n' +
rule +
'\n' +
hint2 +
'\n' +
inputLine
)
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} argv0
*/
async function bareOsRunChatTui(ctx, argv0) {
if (ctx.tui && typeof ctx.tui.run === 'function') {
const app = bareChatCreateTuiApp(ctx, argv0)
try {
await ctx.tui.run(app, bareChatTuiRunOpts(ctx))
} finally {
if (app && typeof app.dispose === 'function') app.dispose()
}
return
}
await bareOsRunChatTuiLegacy(ctx, argv0)
}
/**
* Pre-SDK key loop (BARE_OS_TUI=0).
* @param {Record<string, unknown>} ctx
* @param {string} argv0
*/
async function bareOsRunChatTuiLegacy(ctx, argv0) {
const stdin = /** @type {import('stream').Readable | undefined} */ (
ctx.replStdin
)
const stdout = bareEditResolveStdout(ctx)
if (!stdin || !stdout) {
ctx.console.error('chat: missing stdin/stdout')
ctx.exitCode = 1
return
}
const useColor = bareEditUseColor(ctx)
const envEarly =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
let tickMs = 1000
const tickRaw = envEarly.BARE_CHAT_STATUS_MS
if (tickRaw != null && String(tickRaw) !== '') {
const n = Number.parseInt(String(tickRaw), 10)
if (Number.isFinite(n) && n >= 0)
tickMs = Math.min(Math.max(0, n), 3_600_000)
}
/** @type {'main'|'help'} */
let mode = 'main'
/** @type {string[]} */
const transcript = []
/** First visible transcript index */
let scrollTop = 0
/** When true, new messages snap scroll to bottom */
let stickToBottom = true
let inputBuf = ''
let inputCursor = 0
const metricRef = {
swarmPeers: /** @type {{ current: number | null }} */ ({ current: null }),
muxRx: /** @type {{ current: number | null }} */ ({ current: null }),
muxTx: /** @type {{ current: number | null }} */ ({ current: null }),
wireRxTotal: /** @type {{ current: number | null }} */ ({ current: null }),
dropRate: /** @type {{ current: number | null }} */ ({ current: null }),
dropVerify: /** @type {{ current: number | null }} */ ({ current: null })
}
async function refreshProcStrip() {
let snap = null
if (typeof ctx.bareOsChatProcSnapshot === 'function') {
try {
snap = ctx.bareOsChatProcSnapshot()
} catch {
snap = null
}
}
if (!snap || typeof snap !== 'object') {
snap = await bareChatReadProcSnapshot(ctx)
}
bareChatApplyProcSnap(snap, metricRef)
}
function clampScroll(viewH) {
const maxTop = Math.max(0, transcript.length - viewH)
if (stickToBottom || scrollTop > maxTop) scrollTop = maxTop
if (scrollTop < 0) scrollTop = 0
}
function appendEvent(rec) {
bareChatPushTranscript(transcript, bareChatFmtEventLine(rec))
}
function bareChatPushTranscript(lines, text) {
lines.push(text)
while (lines.length > BARE_CHAT_MAX_TRANSCRIPT) {
lines.shift()
if (scrollTop > 0) scrollTop--
}
}
function termDims() {
const cols =
/** @type {{ columns?: number }} */ (stdout).columns ||
parseInt(envEarly.COLUMNS || '80', 10) ||
80
const rows =
/** @type {{ rows?: number }} */ (stdout).rows ||
parseInt(envEarly.LINES || '24', 10) ||
24
return { cols: Math.max(40, cols), rows: Math.max(10, rows) }
}
let paintBusy = false
let paintAgain = false
function draw() {
const { cols, rows } = termDims()
const headerRows = 3
const footerRows = 3
const msgH = Math.max(1, rows - headerRows - footerRows)
clampScroll(msgH)
let out = '\x1b[?25l\x1b[2J\x1b[H'
if (mode === 'help') {
out +=
bareEditSgr('keyword', useColor) +
'Bare OS — swarm chat' +
EDIT_ANSI_RESET +
'\r\n\r\n' +
'Send with Enter. Scroll transcript with \u2191/\u2193 or PgUp/PgDn.\r\n' +
'Input: Backspace/Delete, \u2190/\u2192, Home/End, ^A/^E line start/end, ^U kill to start, ^K kill to end.\r\n' +
'^R refresh status ^L redraw ^Q / ^X / Ctrl+C exit\r\n' +
'`chat send TEXT` / `chat who` / `chat history` remain for scripts.\r\n' +
'Env: BARE_CHAT_STATUS_MS — status refresh interval in ms (default 1000; 0 disables timer).\r\n\r\n' +
'Press any key.\r\n'
bareEditWrite(ctx, stdout, out + '\x1b[?25h')
return
}
const rooms =
typeof ctx.bareOsChatRooms === 'function' ? ctx.bareOsChatRooms() : []
const roomStr =
Array.isArray(rooms) && rooms.length ? rooms.join(', ') : 'general'
const peerStr =
metricRef.swarmPeers.current != null
? String(metricRef.swarmPeers.current)
: '?'
const rxStr =
metricRef.muxRx.current != null ? String(metricRef.muxRx.current) : '?'
const txStr =
metricRef.muxTx.current != null ? String(metricRef.muxTx.current) : '?'
const wireStr =
metricRef.wireRxTotal.current != null
? String(metricRef.wireRxTotal.current)
: '?'
/** @type {string[]} */
const titleParts = [
argv0 || 'chat',
roomStr,
'peers ' + peerStr,
'rx ' + rxStr,
'tx ' + txStr,
'wire ' + wireStr
]
const dr = metricRef.dropRate.current ?? 0
const dv = metricRef.dropVerify.current ?? 0
if (dr > 0 || dv > 0) {
titleParts.push('drops r' + dr + '/v' + dv)
}
const title =
bareEditSgr('status', useColor) +
bareChatTruncateVis(
' \u250c ' + titleParts.join(' \u00b7 ') + ' ',
cols
) +
EDIT_ANSI_RESET
const nowClock = bareChatFmtClock(Date.now())
const hint =
bareEditSgr('dim', useColor) +
bareChatTruncateVis(
nowClock +
' bare-os-chat-v1 ? help ^Q quit ^R refresh' +
(tickMs > 0 ? ' tick ' + tickMs + 'ms' : ' tick off'),
cols
) +
EDIT_ANSI_RESET
out += bareEditCup(1, 1) + '\x1b[K' + title
out += bareEditCup(2, 1) + '\x1b[K' + hint
out +=
bareEditCup(3, 1) +
'\x1b[K' +
bareEditSgr('dim', useColor) +
'\u2500'.repeat(Math.min(cols, 120)) +
EDIT_ANSI_RESET
for (let i = 0; i < msgH; i++) {
const idx = scrollTop + i
const raw = idx >= 0 && idx < transcript.length ? transcript[idx] : ''
const line = bareChatTruncateVis(raw, cols)
const row = headerRows + 1 + i
const dim =
idx === transcript.length - 1 && stickToBottom
? bareEditSgr('string', useColor)
: ''
out += bareEditCup(row, 1) + '\x1b[K' + dim + line + EDIT_ANSI_RESET
}
const sepRow = rows - footerRows + 1
out +=
bareEditCup(sepRow, 1) +
'\x1b[K' +
bareEditSgr('dim', useColor) +
'\u2500'.repeat(Math.min(cols, 120)) +
EDIT_ANSI_RESET
const hintRow = sepRow + 1
const linesBelow = Math.max(0, transcript.length - scrollTop - msgH)
let scrollHint = ''
if (!stickToBottom && (scrollTop > 0 || linesBelow > 0)) {
const parts = []
if (scrollTop > 0) parts.push('\u2191 ' + scrollTop + ' older')
if (linesBelow > 0) parts.push('\u2193 ' + linesBelow + ' newer')
scrollHint = parts.join(' ')
}
const hint2 =
bareEditSgr('dim', useColor) +
bareChatTruncateVis(
(scrollHint ? scrollHint + ' ' : '') +
'Enter send \u2191\u2193 transcript Backspace / ^A ^E ^U ^K',
cols
) +
EDIT_ANSI_RESET
out += bareEditCup(hintRow, 1) + '\x1b[K' + hint2
const prompt = '> '
const promptLen = prompt.length
const budget = Math.max(8, cols - promptLen - 2)
const ib = inputBuf
const ic = Math.min(Math.max(0, inputCursor), ib.length)
let winStart = 0
if (ib.length > budget) {
winStart = ic - Math.floor(budget / 2)
if (winStart < 0) winStart = 0
if (winStart > ib.length - budget) {
winStart = Math.max(0, ib.length - budget)
}
}
const slice = ib.slice(winStart, winStart + budget)
const rel = ic - winStart
const before = slice.slice(0, rel)
const curCh = rel < slice.length ? slice.charAt(rel) : ' '
const after = slice.slice(rel + 1)
const inputRow = hintRow + 1
const inputLine =
bareEditSgr('dim', useColor) +
prompt +
EDIT_ANSI_RESET +
before +
bareEditSgr('inverse', useColor) +
(curCh || ' ') +
EDIT_ANSI_RESET +
after
out += bareEditCup(inputRow, 1) + '\x1b[K' + inputLine
bareEditWrite(ctx, stdout, out + '\x1b[?25h')
}
function paint() {
if (paintBusy) {
paintAgain = true
return
}
paintBusy = true
try {
do {
paintAgain = false
draw()
} while (paintAgain)
} finally {
paintBusy = false
}
}
/** @type {(() => void) | null} */
let unsub = null
if (typeof ctx.bareOsChatSubscribe === 'function') {
unsub = ctx.bareOsChatSubscribe((ev) => {
appendEvent(
/** @type {Record<string, unknown>} */ (
ev && typeof ev === 'object' ? ev : {}
)
)
void refreshProcStrip().then(() => paint())
})
}
if (typeof ctx.bareOsChatHistory === 'function') {
try {
const hist = ctx.bareOsChatHistory(BARE_CHAT_MAX_TRANSCRIPT)
if (Array.isArray(hist)) {
for (const h of hist) {
if (h && typeof h === 'object') {
bareChatPushTranscript(
transcript,
bareChatFmtEventLine(/** @type {Record<string, unknown>} */ (h))
)
}
}
stickToBottom = true
}
} catch {
/* ignore */
}
}
const reader = bareEditCreateStdinReader(stdin)
let suspended = false
let useAltScreen = false
let useBracketPaste = false
/** @type {ReturnType<typeof setInterval> | null} */
let tickTimer = null
function onResize() {
void refreshProcStrip().then(() => paint())
}
await refreshProcStrip()
try {
if (typeof ctx.suspendReplForSubprocess === 'function') {
ctx.suspendReplForSubprocess()
suspended = true
}
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
if (typeof stdin.resume === 'function') stdin.resume()
const bareChatNoAltScreen =
envEarly.BARE_EDIT_NO_ALTSCREEN != null &&
String(envEarly.BARE_EDIT_NO_ALTSCREEN) !== ''
if (!bareChatNoAltScreen) {
bareEditWrite(ctx, stdout, '\x1b[?1049h')
useAltScreen = true
}
const bareChatNoBracketPaste =
envEarly.BARE_EDIT_NO_BRACKETED_PASTE != null &&
String(envEarly.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
if (!bareChatNoBracketPaste) {
bareEditWrite(ctx, stdout, '\x1b[?2004h')
useBracketPaste = true
}
if (typeof stdout.on === 'function') {
try {
stdout.on('resize', onResize)
} catch {
/* ignore */
}
}
try {
if (typeof process !== 'undefined' && typeof process.on === 'function') {
process.on('SIGWINCH', onResize)
}
} catch {
/* ignore */
}
if (tickMs > 0 && typeof setInterval === 'function') {
tickTimer = setInterval(() => {
void refreshProcStrip().then(() => paint())
}, tickMs)
}
paint()
for (;;) {
const ev = await bareEditReadKey(reader)
if (ev.type === 'eof') break
if (mode === 'help') {
mode = 'main'
paint()
continue
}
if (ev.type === 'ctrl') {
if (ev.code === 'interrupt') break
/** Backspace — key-parse uses `code: 'backspace'` for bytes 8 and 127 (not numeric). */
if (ev.code === 'backspace') {
if (inputCursor > 0) {
inputBuf =
inputBuf.slice(0, inputCursor - 1) + inputBuf.slice(inputCursor)
inputCursor--
}
paint()
continue
}
if (ev.code === 'delete') {
if (inputCursor < inputBuf.length) {
inputBuf =
inputBuf.slice(0, inputCursor) + inputBuf.slice(inputCursor + 1)
}
paint()
continue
}
const code = typeof ev.code === 'number' ? ev.code : 0
if (code === 3) break
if (code === 12) {
paint()
continue
}
if (code === 17 || code === 24) break
if (code === 18) {
await refreshProcStrip()
paint()
continue
}
/** Readline-style shortcuts (ASCII control chars). */
if (code === 1) {
inputCursor = 0
paint()
continue
}
if (code === 5) {
inputCursor = inputBuf.length
paint()
continue
}
if (code === 21) {
inputBuf = inputBuf.slice(inputCursor)
inputCursor = 0
paint()
continue
}
if (code === 11) {
inputBuf = inputBuf.slice(0, inputCursor)
paint()
continue
}
if (code === 8 || code === 127) {
if (inputCursor > 0) {
inputBuf =
inputBuf.slice(0, inputCursor - 1) + inputBuf.slice(inputCursor)
inputCursor--
}
paint()
continue
}
continue
}
if (ev.type === 'nav') {
const k = /** @type {{ key?: string }} */ (ev).key
if (k === 'up') {
stickToBottom = false
if (scrollTop > 0) scrollTop--
paint()
continue
}
if (k === 'down') {
const { rows: rr } = termDims()
const msgH = Math.max(1, rr - 6)
const maxTop = Math.max(0, transcript.length - msgH)
if (scrollTop < maxTop) scrollTop++
if (scrollTop >= maxTop) stickToBottom = true
paint()
continue
}
if (k === 'left') {
if (inputCursor > 0) inputCursor--
paint()
continue
}
if (k === 'right') {
if (inputCursor < inputBuf.length) inputCursor++
paint()
continue
}
if (k === 'home') {
inputCursor = 0
paint()
continue
}
if (k === 'end') {
inputCursor = inputBuf.length
paint()
continue
}
if (k === 'pageup') {
stickToBottom = false
const { rows: rr } = termDims()
const msgH = Math.max(1, rr - 6)
scrollTop = Math.max(0, scrollTop - msgH)
paint()
continue
}
if (k === 'pagedown') {
const { rows: rr } = termDims()
const msgH = Math.max(1, rr - 6)
const maxTop = Math.max(0, transcript.length - msgH)
scrollTop = Math.min(maxTop, scrollTop + msgH)
if (scrollTop >= maxTop) stickToBottom = true
paint()
continue
}
continue
}
if (ev.type === 'key' && ev.ch) {
if (ev.ch === '?') {
mode = 'help'
paint()
continue
}
const ch = ev.ch
if (ch === '\n') {
const line = inputBuf.trim()
inputBuf = ''
inputCursor = 0
if (line && typeof ctx.bareOsChatSend === 'function') {
ctx.bareOsChatSend(line)
}
stickToBottom = true
await refreshProcStrip()
paint()
continue
}
if (inputBuf.length < BARE_CHAT_INPUT_MAX) {
inputBuf =
inputBuf.slice(0, inputCursor) + ch + inputBuf.slice(inputCursor)
inputCursor += ch.length
}
paint()
continue
}
if (ev.type === 'paste') {
const first =
String(ev.text || '')
.split(/\r?\n/)[0]
?.slice(0, BARE_CHAT_INPUT_MAX) ?? ''
if (first) {
const room = Math.max(
0,
BARE_CHAT_INPUT_MAX -
inputBuf.length +
(inputBuf.length - inputCursor)
)
const chunk = first.slice(0, room)
inputBuf =
inputBuf.slice(0, inputCursor) + chunk + inputBuf.slice(inputCursor)
inputCursor += chunk.length
}
paint()
continue
}
}
} finally {
if (tickTimer != null && typeof clearInterval === 'function') {
try {
clearInterval(tickTimer)
} catch {
/* ignore */
}
tickTimer = null
}
if (typeof stdout.removeListener === 'function') {
try {
stdout.removeListener('resize', onResize)
} catch {
/* ignore */
}
}
try {
if (typeof process !== 'undefined' && typeof process.off === 'function') {
process.off('SIGWINCH', onResize)
}
} catch {
/* ignore */
}
try {
if (useBracketPaste) bareEditWrite(ctx, stdout, '\x1b[?2004l')
if (useAltScreen) bareEditWrite(ctx, stdout, '\x1b[?1049l')
else bareEditWrite(ctx, stdout, '\x1b[2J\x1b[H')
bareEditWrite(ctx, stdout, '\x1b[?25h\x1b[0m')
} catch {
/* ignore */
}
try {
if (typeof unsub === 'function') unsub()
} catch {
/* ignore */
}
reader.dispose()
try {
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false)
} catch {
/* ignore */
}
if (suspended && typeof ctx.resumeReplAfterSubprocess === 'function') {
ctx.resumeReplAfterSubprocess()
}
}
}