/* 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} 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} [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} [ctx] */ function bareEditUseColor(ctx) { const env = ctx && typeof ctx === 'object' && ctx.env && typeof ctx.env === 'object' ? /** @type {Record} */ (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} 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 | 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, 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} 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} 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} */ (JSON.parse(t)) } catch { return null } } /** * @param {Record | 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} 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} */ (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} */ ( 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} */ (h) ) ) } } stickToBottom = true } } catch { /* ignore */ } } const reader = bareEditCreateStdinReader(stdin) let suspended = false let useAltScreen = false let useBracketPaste = false /** @type {ReturnType | 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() } } } /** * 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') }