Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/chat
T
2026-04-21 21:20:18 -04:00

1193 lines
32 KiB
Plaintext

/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
function bareStdin(ctx) {
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
}
/** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */
function bareFormatModeString(mode, type) {
const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-'
const perm = mode & 0o777
const r = (bit) => (perm & bit ? 'r' : '-')
const w = (bit) => (perm & bit ? 'w' : '-')
const x = (bit) => (perm & bit ? 'x' : '-')
return (
typeChar +
r(0o400) +
w(0o200) +
x(0o100) +
r(0o040) +
w(0o020) +
x(0o010) +
r(0o004) +
w(0o002) +
x(0o001)
)
}
/** @param {number} mtimeMs @param {number} [nowMs] */
function bareFormatLsMtime(mtimeMs, nowMs) {
const now = nowMs != null ? nowMs : Date.now()
const d = new Date(mtimeMs)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
const mon = months[d.getMonth()]
const day = String(d.getDate()).padStart(2, ' ')
const sixMo = 180 * 24 * 3600 * 1000
if (Math.abs(now - mtimeMs) > sixMo) {
const yr = String(d.getFullYear()).padStart(4, ' ')
return mon + ' ' + day + ' ' + yr
}
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return mon + ' ' + day + ' ' + hh + ':' + mm
}
/** @param {number} size */
function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
/**
* Raw stdout for NUL/binary when **`process.stdout.write`** is missing.
* If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it.
* @param {Record<string, unknown>} ctx
* @param {string | Uint8Array} chunk
* @returns {boolean}
*/
function bareOsEmitRaw(ctx, chunk) {
if (typeof ctx.bareOsBinWrite === 'function') {
const b4 = ctx.b4a
const u8 =
typeof chunk === 'string'
? b4 && typeof b4.from === 'function'
? b4.from(chunk)
: new TextEncoder().encode(chunk)
: chunk
ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8))
return true
}
const w = globalThis.process?.stdout?.write
if (typeof w === 'function') {
w.call(globalThis.process.stdout, chunk)
return true
}
return false
}
/** ANSI helpers for /bin/edit (preamble; no import in src). */
const EDIT_ANSI_RESET = '\x1b[0m'
/**
* @param {Record<string, unknown>} [ctx]
* @returns {import('stream').Writable | undefined}
*/
function bareEditResolveStdout(ctx) {
if (!ctx || typeof ctx !== 'object') return globalThis.process?.stdout
const c = /** @type {{ replStdout?: unknown, stdout?: unknown }} */ (ctx)
const out = c.replStdout || c.stdout || globalThis.process?.stdout
return /** @type {import('stream').Writable | undefined} */ (out)
}
/**
* @param {Record<string, unknown>} [ctx]
*/
function bareEditUseColor(ctx) {
const env =
ctx && typeof ctx === 'object' && ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
if (env.NO_COLOR != null && String(env.NO_COLOR) !== '') return false
const out = bareEditResolveStdout(ctx)
return Boolean(out && /** @type {{ isTTY?: boolean }} */ (out).isTTY)
}
/**
* @param {'keyword'|'string'|'comment'|'number'|'status'|'inverse'|'dim'} cls
* @param {boolean} on
*/
function bareEditSgr(cls, on) {
if (!on) return ''
switch (cls) {
case 'keyword':
return '\x1b[36m'
case 'string':
return '\x1b[32m'
case 'comment':
return '\x1b[90m'
case 'number':
return '\x1b[33m'
case 'status':
return '\x1b[44m\x1b[97m'
case 'inverse':
return '\x1b[7m'
case 'dim':
return '\x1b[2m'
default:
return ''
}
}
/**
* @param {string} fullLine
* @param {Array<{ start: number, end: number, cls: string }>} spans offsets into fullLine
* @param {number} visStart first column (0-based)
* @param {number} maxLen max code units to show
* @param {boolean} useColor
*/
function bareEditPaintLineWindow(fullLine, spans, visStart, maxLen, useColor) {
const slice = fullLine.slice(visStart, visStart + maxLen)
if (!useColor) return slice
const n = slice.length
const relSpans = spans
.map((s) => ({
start: Math.max(0, s.start - visStart),
end: Math.min(n, s.end - visStart),
cls: s.cls
}))
.filter((s) => s.end > 0 && s.start < n)
.sort((a, b) => a.start - b.start)
let out = ''
let pos = 0
for (const sp of relSpans) {
if (sp.start > pos) out += slice.slice(pos, sp.start)
out +=
bareEditSgr(/** @type {'keyword'} */ (sp.cls), true) +
slice.slice(sp.start, sp.end) +
EDIT_ANSI_RESET
pos = sp.end
}
if (pos < n) out += slice.slice(pos)
return out
}
/**
* Move cursor (1-based row/col, DEC origin). Clamp to sane bounds for escape parsing.
* @param {number} row1
* @param {number} col1
*/
function bareEditCup(row1, col1) {
const r = Math.max(1, Math.min(Math.floor(row1), 9999))
const c = Math.max(1, Math.min(Math.floor(col1), 9999))
return '\x1b[' + r + ';' + c + 'H'
}
/**
* @param {Record<string, unknown>} ctx
* @param {import('stream').Writable} stdout
* @param {string} s
*/
function bareEditWrite(ctx, stdout, s) {
if (!stdout || typeof stdout.write !== 'function') return
try {
stdout.write(s)
} catch {
try {
ctx.console?.error?.('edit: stdout write failed')
} catch {
/* ignore */
}
}
}
/** TTY key parsing for /bin/edit: consume one logical key from a mutable byte queue. */
/**
* @param {number} b1
*/
function bareEditUtf8TrailCount(b1) {
if (b1 >= 0xc0 && b1 < 0xe0) return 1
if (b1 >= 0xe0 && b1 < 0xf0) return 2
if (b1 >= 0xf0) return 3
return 0
}
/**
* @param {number[]} bytes
*/
function bareEditUtf8DecodeKey(bytes) {
try {
const u = new Uint8Array(bytes)
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(u)
}
} catch {
/* fall through */
}
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return s
}
/**
* @param {string} seq CSI payload after ESC [, including final byte (e.g. "A", "1;5A", "3~")
*/
function bareEditCsiToEvent(seq) {
if (seq === '3~') return { type: 'ctrl', code: 'delete' }
if (seq === '5~') return { type: 'nav', key: 'pageup' }
if (seq === '6~') return { type: 'nav', key: 'pagedown' }
const last = seq.charAt(seq.length - 1)
if (last === '~') {
if (seq === '1~' || seq === '7~') return { type: 'nav', key: 'home' }
if (seq === '4~' || seq === '8~') return { type: 'nav', key: 'end' }
const tildeNum = /^(\d+)~$/.exec(seq)
if (tildeNum) {
const n = parseInt(tildeNum[1], 10)
const fnMap = {
11: 1,
12: 2,
13: 3,
14: 4,
15: 5,
17: 6,
18: 7,
19: 8,
20: 9,
21: 10,
23: 11,
24: 12
}
if (fnMap[n] != null) return { type: 'fn', n: fnMap[n] }
}
return { type: 'unknown' }
}
if (last === 'Z') return { type: 'nav', key: 'stab' }
if (last === 'A' || last === 'B' || last === 'C' || last === 'D') {
const map = { A: 'up', B: 'down', C: 'right', D: 'left' }
return { type: 'nav', key: map[last] }
}
if (last === 'H') return { type: 'nav', key: 'home' }
if (last === 'F') return { type: 'nav', key: 'end' }
return { type: 'unknown' }
}
/**
* @param {number} b3 byte after ESC O
*/
function bareEditSs3ToEvent(b3) {
if (b3 === 72) return { type: 'nav', key: 'home' }
if (b3 === 70) return { type: 'nav', key: 'end' }
if (b3 === 65) return { type: 'nav', key: 'up' }
if (b3 === 66) return { type: 'nav', key: 'down' }
if (b3 === 67) return { type: 'nav', key: 'right' }
if (b3 === 68) return { type: 'nav', key: 'left' }
if (b3 === 80) return { type: 'fn', n: 1 }
if (b3 === 81) return { type: 'fn', n: 2 }
if (b3 === 82) return { type: 'fn', n: 3 }
if (b3 === 83) return { type: 'fn', n: 4 }
return { type: 'unknown' }
}
/** ESC [ 200 ~ */
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
/** ESC [ 201 ~ */
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
/**
* @param {number[]} q
* @param {number[]} prefix
*/
function bareEditStartsWithBytes(q, prefix) {
if (q.length < prefix.length) return false
for (let i = 0; i < prefix.length; i++) {
if (q[i] !== prefix[i]) return false
}
return true
}
/**
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
* @param {number[]} q
*/
function bareEditCouldBeBracketPastePrefix(q) {
const pre = BARE_EDIT_BRACKET_PASTE_START
const n = Math.min(q.length, pre.length)
for (let i = 0; i < n; i++) {
if (q[i] !== pre[i]) return false
}
return true
}
/**
* @param {number[]} bytes
*/
function bareEditDecodePasteInner(bytes) {
try {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(
new Uint8Array(bytes)
)
}
} catch {
/* fall through */
}
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return s
}
/**
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
* @param {number[]} q
* @returns {string|undefined}
*/
function bareEditFinalizeBracketedPasteOnEof(q) {
const SL = BARE_EDIT_BRACKET_PASTE_START.length
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
const inner = q.slice(SL)
q.length = 0
return bareEditDecodePasteInner(inner)
}
return undefined
}
/**
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
* @param {number[]} q mutable queue
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
*/
function bareEditTryConsumeBracketedPaste(q) {
const SL = BARE_EDIT_BRACKET_PASTE_START.length
const EL = BARE_EDIT_BRACKET_PASTE_END.length
if (!q.length) return undefined
if (q.length < SL) {
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
}
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
return undefined
}
for (let i = SL; i <= q.length - EL; i++) {
let ok = true
for (let j = 0; j < EL; j++) {
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
ok = false
break
}
}
if (ok) {
const inner = q.slice(SL, i)
q.splice(0, i + EL)
return bareEditDecodePasteInner(inner)
}
}
return null
}
/**
* @param {number[]} q mutable queue (front = index 0)
* @returns {Record<string, unknown> | null} null if more bytes needed
*/
function bareEditTryConsumeKey(q) {
if (!q.length) return null
const b1 = q[0]
if (b1 === 3) {
q.shift()
return { type: 'ctrl', code: 'interrupt' }
}
if (b1 === 8 || b1 === 127) {
q.shift()
return { type: 'ctrl', code: 'backspace' }
}
if (b1 === 13 || b1 === 10) {
q.shift()
return { type: 'key', ch: '\n' }
}
if (b1 === 9) {
q.shift()
return { type: 'key', ch: '\t' }
}
if (b1 === 27) {
if (q.length < 2) return null
const b2 = q[1]
if (b2 === 91) {
let i = 2
while (i < q.length) {
const b = q[i]
if (b >= 0x40 && b <= 0x7e) {
const seq = String.fromCharCode.apply(null, q.slice(2, i + 1))
q.splice(0, i + 1)
return bareEditCsiToEvent(seq)
}
i++
}
return null
}
if (b2 === 79) {
if (q.length < 3) return null
const b3 = q[2]
q.splice(0, 3)
return bareEditSs3ToEvent(b3)
}
q.splice(0, 2)
return { type: 'key', ch: String.fromCharCode(b2) }
}
if (b1 < 0x20) {
q.shift()
return { type: 'ctrl', code: b1 }
}
const need = bareEditUtf8TrailCount(b1)
if (q.length < 1 + need) return null
const chunk = q.splice(0, 1 + need)
return { type: 'key', ch: bareEditUtf8DecodeKey(chunk) }
}
/** Stdin chunk queue + async key reader for edit/chat TUIs (requires edit-key-parse.js first). */
/**
* @param {unknown} chunk
* @returns {number[]}
*/
function bareEditChunkBytes(chunk) {
if (chunk == null) return []
if (typeof chunk === 'string') {
const out = []
for (let i = 0; i < chunk.length; i++) out.push(chunk.charCodeAt(i) & 0xff)
return out
}
const len = /** @type {{ length: number, [k: number]: number }} */ (chunk).length
const out = []
for (let i = 0; i < len; i++) out.push(Number(chunk[i]) & 0xff)
return out
}
/**
* @param {{ nextByte: () => Promise<number|undefined>, dispose?: () => void, _keyq?: number[] }} reader
*/
async function bareEditReadKey(reader) {
reader._keyq = reader._keyq || []
const q = reader._keyq
for (;;) {
const pasteText = bareEditTryConsumeBracketedPaste(q)
if (pasteText !== undefined && pasteText !== null) {
return { type: 'paste', text: pasteText }
}
if (pasteText === null) {
const b = await reader.nextByte()
if (b === undefined) {
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
if (eofPaste !== undefined) {
return { type: 'paste', text: eofPaste }
}
if (!q.length) return { type: 'eof' }
if (q.length === 1 && q[0] === 27) {
q.length = 0
return { type: 'key', ch: '\x1b' }
}
const lone = q.shift()
if (lone !== undefined && lone < 0x20) {
return { type: 'ctrl', code: lone }
}
if (lone !== undefined) {
return { type: 'key', ch: String.fromCharCode(lone) }
}
return { type: 'eof' }
}
q.push(b)
continue
}
const ev = bareEditTryConsumeKey(q)
if (ev) return ev
const b = await reader.nextByte()
if (b === undefined) {
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
if (eofPaste !== undefined) {
return { type: 'paste', text: eofPaste }
}
if (!q.length) return { type: 'eof' }
if (q.length === 1 && q[0] === 27) {
q.length = 0
return { type: 'key', ch: '\x1b' }
}
const lone = q.shift()
if (lone !== undefined && lone < 0x20) {
return { type: 'ctrl', code: lone }
}
if (lone !== undefined) {
return { type: 'key', ch: String.fromCharCode(lone) }
}
return { type: 'eof' }
}
q.push(b)
}
}
/**
* @param {import('stream').Readable} stdin
*/
function bareEditCreateStdinReader(stdin) {
/** @type {number[]} */
const bytes = []
/** @type {(() => void)[]} */
const waiters = []
function drain() {
while (waiters.length && bytes.length) {
const w = waiters.shift()
if (w) w()
}
}
/** @param {unknown} chunk */
function onData(chunk) {
bytes.push(...bareEditChunkBytes(chunk))
drain()
}
stdin.on('data', onData)
return {
nextByte() {
if (bytes.length) return Promise.resolve(bytes.shift())
return new Promise((resolve) => {
waiters.push(() => resolve(bytes.shift()))
})
},
dispose() {
stdin.removeListener('data', onData)
}
}
}
/** 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>} ctx
* @param {string} argv0
*/
async function bareOsRunChatTui(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)
: {}
/** @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
/** @type {number|null} */
let swarmPeers = null
/** @type {number|null} */
let muxRx = null
async function refreshProcStrip() {
const snap = await bareChatReadProcSnapshot(ctx)
if (!snap || typeof snap !== 'object') return
const sp = snap.swarmPeers
if (typeof sp === 'number' && Number.isFinite(sp)) swarmPeers = sp
const met = snap.metrics
if (met && typeof met === 'object') {
const rx = /** @type {{ rxEvent?: number }} */ (met).rxEvent
if (typeof rx === 'number') muxRx = rx
}
}
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--
}
}
/** @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 : {}
)
)
draw()
})
}
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 */
}
}
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) }
}
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 strip ^L redraw ^Q / ^X / Ctrl+C exit\r\n' +
'`chat send TEXT` / `chat who` / `chat history` remain for scripts.\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 =
swarmPeers != null ? String(swarmPeers) : '?'
const rxStr = muxRx != null ? String(muxRx) : '?'
const title =
bareEditSgr('status', useColor) +
bareChatTruncateVis(
' ' +
(argv0 || 'chat') +
' · ' +
roomStr +
' · peers ' +
peerStr +
' · rx ' +
rxStr +
' ',
cols
) +
EDIT_ANSI_RESET
out += bareEditCup(1, 1) + '\x1b[K' + title
const hint =
bareEditSgr('dim', useColor) +
bareChatTruncateVis(
'bare-os-chat-v1 · ? help · ^Q quit · ^R refresh strip',
cols
) +
EDIT_ANSI_RESET
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 hint2 =
bareEditSgr('dim', useColor) +
bareChatTruncateVis(
'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')
}
const reader = bareEditCreateStdinReader(stdin)
let suspended = false
let useAltScreen = false
let useBracketPaste = false
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
}
draw()
for (;;) {
const ev = await bareEditReadKey(reader)
if (ev.type === 'eof') break
if (mode === 'help') {
mode = 'main'
draw()
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--
}
draw()
continue
}
if (ev.code === 'delete') {
if (inputCursor < inputBuf.length) {
inputBuf =
inputBuf.slice(0, inputCursor) + inputBuf.slice(inputCursor + 1)
}
draw()
continue
}
const code = typeof ev.code === 'number' ? ev.code : 0
if (code === 3) break
if (code === 12) {
draw()
continue
}
if (code === 17 || code === 24) break
if (code === 18) {
await refreshProcStrip()
draw()
continue
}
/** Readline-style shortcuts (ASCII control chars). */
if (code === 1) {
inputCursor = 0
draw()
continue
}
if (code === 5) {
inputCursor = inputBuf.length
draw()
continue
}
if (code === 21) {
inputBuf = inputBuf.slice(inputCursor)
inputCursor = 0
draw()
continue
}
if (code === 11) {
inputBuf = inputBuf.slice(0, inputCursor)
draw()
continue
}
if (code === 8 || code === 127) {
if (inputCursor > 0) {
inputBuf =
inputBuf.slice(0, inputCursor - 1) + inputBuf.slice(inputCursor)
inputCursor--
}
draw()
continue
}
continue
}
if (ev.type === 'nav') {
const k = /** @type {{ key?: string }} */ (ev).key
if (k === 'up') {
stickToBottom = false
if (scrollTop > 0) scrollTop--
draw()
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
draw()
continue
}
if (k === 'left') {
if (inputCursor > 0) inputCursor--
draw()
continue
}
if (k === 'right') {
if (inputCursor < inputBuf.length) inputCursor++
draw()
continue
}
if (k === 'home') {
inputCursor = 0
draw()
continue
}
if (k === 'end') {
inputCursor = inputBuf.length
draw()
continue
}
if (k === 'pageup') {
stickToBottom = false
const { rows: rr } = termDims()
const msgH = Math.max(1, rr - 6)
scrollTop = Math.max(0, scrollTop - msgH)
draw()
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
draw()
continue
}
continue
}
if (ev.type === 'key' && ev.ch) {
if (ev.ch === '?') {
mode = 'help'
draw()
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()
draw()
continue
}
if (inputBuf.length < BARE_CHAT_INPUT_MAX) {
inputBuf =
inputBuf.slice(0, inputCursor) + ch + inputBuf.slice(inputCursor)
inputCursor += ch.length
}
draw()
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
}
draw()
continue
}
}
} finally {
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()
}
}
}
/**
* Swarm chat — full-screen TUI on a TTY (like edit/nano); scriptable subcommands otherwise.
*/
async function run(ctx, argv) {
const args = argv.slice(1)
if (args.includes('-h') || args.includes('--help')) {
ctx.console.log(
'usage: ' +
(argv[0] || 'chat') +
' [send TEXT | history [N] | who | join]\n' +
'With no arguments on a terminal: full-screen swarm chat (bare-os-chat-v1).\n' +
'Requires host booter with swarm chat (see BARE_OS_PROTOMUX_CHAT_CHANNEL).\n' +
'See man chat.'
)
return
}
const sub = args[0] || ''
const cs = ctx.bareOsChatSend
if (sub === 'send') {
const rest = args.slice(1).join(' ').trim()
if (!rest) {
ctx.console.error('chat: send requires text')
ctx.exitCode = 1
return
}
if (typeof cs !== 'function') {
ctx.console.error(
'chat: bareOsChatSend unavailable — stock hosts enable swarm chat unless BARE_OS_PROTOMUX_CHAT_CHANNEL=0'
)
ctx.exitCode = 1
return
}
const r = cs.call(ctx, rest)
if (r && r.ok === false) ctx.exitCode = 1
return
}
if (sub === 'history') {
const n = args[1] ? Number.parseInt(args[1], 10) : 20
const hist =
typeof ctx.vfs?.readFile === 'function'
? await ctx.vfs.readFile('/proc/bare_os/chat.json')
: null
if (hist) ctx.console.log(ctx.b4a.toString(hist).trimEnd())
else if (typeof ctx.bareOsReadProcMetricsLive === 'function') {
ctx.console.log(JSON.stringify(ctx.bareOsReadProcMetricsLive(), null, 2))
}
void n
return
}
if (sub === 'who') {
ctx.console.log(JSON.stringify(ctx.bareOsChatPresence?.() ?? {}, null, 2))
return
}
if (sub === 'join') {
ctx.console.log(
'chat join: only the general room is implemented; you are already in general when chat is enabled.'
)
return
}
const stdin = ctx.replStdin
const stdout = ctx.replStdout || ctx.stdout
const isTTY = Boolean(stdin && /** @type {{ isTTY?: boolean }} */ (stdin).isTTY)
if (!isTTY || !stdout) {
ctx.console.error(
'chat: a terminal (TTY) is required for full-screen mode; use: chat send TEXT, chat history, chat who'
)
ctx.exitCode = 1
return
}
if (typeof cs !== 'function') {
ctx.console.error(
'chat: swarm chat unavailable on this host — stock default is on unless BARE_OS_PROTOMUX_CHAT_CHANNEL=0'
)
ctx.exitCode = 1
return
}
await bareOsRunChatTui(ctx, argv[0] || 'chat')
}