2022 lines
57 KiB
Plaintext
2022 lines
57 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
|
|
}
|
|
|
|
/** Session env map (`vfs.env`, then `ctx.env`). Never throws. */
|
|
function bareOsEnv(ctx) {
|
|
const v = ctx && ctx.vfs && ctx.vfs.env
|
|
if (v && typeof v === 'object') return v
|
|
const e = ctx && ctx.env
|
|
if (e && typeof e === 'object') return e
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Strict POSIX-ish decimal integer (no octal, no exponent, no empty).
|
|
* @param {unknown} s
|
|
* @returns {number}
|
|
*/
|
|
function bareOsParseDecInt(s) {
|
|
const t = String(s == null ? '' : s).trim()
|
|
if (!/^[+-]?(?:0|[1-9][0-9]*)$/.test(t)) return NaN
|
|
const n = Number.parseInt(t, 10)
|
|
return Number.isSafeInteger(n) ? n : NaN
|
|
}
|
|
|
|
/** @param {unknown} s */
|
|
function bareOsParseNonNegInt(s) {
|
|
const n = bareOsParseDecInt(s)
|
|
return n >= 0 ? n : NaN
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} name
|
|
* @param {number} fallback
|
|
* @param {number} [min]
|
|
* @param {number} [max]
|
|
*/
|
|
function bareOsEnvInt(ctx, name, fallback, min, max) {
|
|
const raw = bareOsEnv(ctx)[name]
|
|
if (raw == null || raw === '') return fallback
|
|
const n = Number.parseInt(String(raw), 10)
|
|
if (!Number.isFinite(n)) return fallback
|
|
let v = n
|
|
if (min != null && v < min) v = min
|
|
if (max != null && v > max) v = max
|
|
return v
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} msg
|
|
* @param {number} [code]
|
|
*/
|
|
function bareOsFail(ctx, msg, code) {
|
|
if (msg) ctx.console.error(msg)
|
|
ctx.exitCode = code == null ? 1 : code
|
|
}
|
|
|
|
/** @param {unknown} e */
|
|
function bareOsIsNotFoundErr(e) {
|
|
const code = e && typeof e === 'object' ? e.code : ''
|
|
if (code === 'ENOENT') return true
|
|
const msg = String((e && e.message) || e || '')
|
|
return /ENOENT|No such file|not found/i.test(msg)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {unknown} buf
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsToU8(ctx, buf) {
|
|
if (!buf) return new Uint8Array(0)
|
|
if (buf instanceof Uint8Array) return buf
|
|
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(buf)
|
|
return new Uint8Array(buf)
|
|
}
|
|
|
|
/** @param {string} dir @param {string} name */
|
|
function bareOsJoinPath(dir, name) {
|
|
const d = String(dir || '').replace(/\/+$/, '')
|
|
const n = String(name || '').replace(/^\/+/, '')
|
|
if (!d || d === '/') return '/' + n
|
|
return d + '/' + n
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsBaseName(p) {
|
|
const t = String(p || '').replace(/\/+$/, '')
|
|
if (!t || t === '/') return t === '/' ? '/' : ''
|
|
const i = t.lastIndexOf('/')
|
|
return i < 0 ? t : t.slice(i + 1) || t
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsParentDir(p) {
|
|
const t = String(p || '').replace(/\/+$/, '') || '/'
|
|
if (t === '/') return '/'
|
|
const i = t.lastIndexOf('/')
|
|
return i <= 0 ? '/' : t.slice(0, i) || '/'
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsNormPath(p) {
|
|
return String(p || '').replace(/\/+$/, '') || '/'
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} p
|
|
*/
|
|
function bareOsResolvePath(ctx, p) {
|
|
if (ctx && ctx.vfs && typeof ctx.vfs.resolveLogical === 'function') {
|
|
try {
|
|
return String(ctx.vfs.resolveLogical(p) || p)
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return String(p || '')
|
|
}
|
|
|
|
/**
|
|
* True when dest is src or lives under src (self-copy / self-move).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} src
|
|
* @param {string} dest
|
|
*/
|
|
function bareOsDestInsideSrc(ctx, src, dest) {
|
|
const s = bareOsNormPath(bareOsResolvePath(ctx, src))
|
|
const d = bareOsNormPath(bareOsResolvePath(ctx, dest))
|
|
if (s === d) return true
|
|
if (s === '/') return d !== '/'
|
|
return d === s || d.startsWith(s + '/')
|
|
}
|
|
|
|
const BARE_OS_B64_ALPH =
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsB64Encode(u8) {
|
|
let out = ''
|
|
let i = 0
|
|
for (; i + 2 < u8.length; i += 3) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8) | u8[i + 2]
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
BARE_OS_B64_ALPH[n & 63]
|
|
}
|
|
const rest = u8.length - i
|
|
if (rest === 1) {
|
|
const n = u8[i] << 16
|
|
out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + '=='
|
|
} else if (rest === 2) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8)
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
'='
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* RFC 4648 Base64 decode (also accepts URL-safe alphabet). Rejects junk.
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsB64Decode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (!t) return new Uint8Array(0)
|
|
if (t.length % 4 === 1) throw new Error('invalid base64 length')
|
|
let pad = 0
|
|
if (t.endsWith('==')) pad = 2
|
|
else if (t.endsWith('=')) pad = 1
|
|
const body = pad ? t.slice(0, t.length - pad) : t
|
|
const bytes = []
|
|
let buf = 0
|
|
let bits = 0
|
|
for (let i = 0; i < body.length; i++) {
|
|
const c = body[i]
|
|
let v = BARE_OS_B64_ALPH.indexOf(c)
|
|
if (v < 0) {
|
|
if (c === '-') v = 62
|
|
else if (c === '_') v = 63
|
|
else throw new Error('invalid base64 character')
|
|
}
|
|
buf = (buf << 6) | v
|
|
bits += 6
|
|
if (bits >= 8) {
|
|
bits -= 8
|
|
bytes.push((buf >> bits) & 255)
|
|
}
|
|
}
|
|
if (pad) {
|
|
const want = Math.floor((body.length * 6) / 8)
|
|
if (bytes.length > want) bytes.length = want
|
|
}
|
|
return new Uint8Array(bytes)
|
|
}
|
|
|
|
/**
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsHexDecode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (t.length % 2 !== 0) throw new Error('odd hex length')
|
|
const out = new Uint8Array(t.length / 2)
|
|
for (let i = 0; i < out.length; i++) {
|
|
const pair = t.slice(i * 2, i * 2 + 2)
|
|
if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw new Error('invalid hex')
|
|
out[i] = Number.parseInt(pair, 16)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsHexEncode(u8) {
|
|
let s = ''
|
|
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, '0')
|
|
return s
|
|
}
|
|
|
|
/** 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> | 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
}
|