TUI Updates p2
Release rolling / release (push) Successful in 9m38s

This commit is contained in:
Raven Scott
2026-08-12 22:55:38 -04:00
parent 2a42171eba
commit ddebf42f1c
92 changed files with 23242 additions and 1938 deletions
+517 -7
View File
@@ -1,10 +1,501 @@
/** Full-screen TUI for /bin/edit (stdin/key helpers in edit-stream-read.js; bareEditWrite in edit-ansi.js). */
/** Full-screen TUI for /bin/edit (TEA via ctx.tui; pre-SDK loop in Legacy). */
/**
* @param {Record<string, unknown>} ctx
*/
function bareEditTuiRunOpts(ctx) {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
/** @type {{ altScreen?: boolean, bracketedPaste?: boolean, buffer: 'cell' }} */
const opts = { buffer: 'cell' }
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 bareEditMaxPromptLen(ctx) {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
let maxPromptLen = 4096
const maxPromptRaw = env.BARE_EDIT_MAX_PROMPT
if (maxPromptRaw != null && String(maxPromptRaw) !== '') {
const n = parseInt(String(maxPromptRaw), 10)
if (Number.isFinite(n) && n > 0) maxPromptLen = Math.min(n, 65536)
}
return maxPromptLen
}
/**
* @param {string} text
* @param {'saveas'|'search'|'goto'} kind
* @param {number} maxPromptLen
*/
function bareEditPromptLineFromPaste(text, kind, maxPromptLen) {
let line = String(text).split(/\r?\n/)[0] ?? ''
line = line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
if (line.length > maxPromptLen) line = line.slice(0, maxPromptLen)
if (kind === 'goto') {
const m = /^(\s*\d+)/.exec(line)
return m ? m[1].trim() : ''
}
return line
}
/**
* Ctrl+_ (ASCII US, 0x1f) — Go to line. Decoder names it as ctrl+\x7f.
* @param {unknown} msg
*/
function bareEditIsGotoChord(msg) {
if (!msg || /** @type {{ type?: string }} */ (msg).type !== 'key')
return false
const m =
/** @type {{ ctrl?: boolean, sequence?: string, name?: string }} */ (msg)
if (!m.ctrl) return false
return m.sequence === '\x1f' || m.name === '\x7f'
}
/**
* TEA model for /bin/edit. Cell buffer so help / prompts overlay without reflow.
* @param {Record<string, unknown>} ctx
* @param {{ path: string, initialText: string, argv0?: string }} opts
*/
function bareEditCreateTuiApp(ctx, opts) {
const tui = ctx.tui
const vfs = ctx.vfs
const b4a = ctx.b4a
const size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
const useColor =
typeof bareEditUseColor === 'function' ? bareEditUseColor(ctx) : true
return {
path: opts.path || 'Untitled',
display: opts.argv0 || 'edit',
lang: bareEditDetectLang(opts.path || ''),
buf: bareEditCreateBuffer(opts.initialText),
scrollRow: 0,
scrollCol: 0,
savePath: opts.path || 'Untitled',
mode: /** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */ (
'edit'
),
promptBuf: '',
promptTitle: '',
lastSearch: '',
maxPromptLen: bareEditMaxPromptLen(ctx),
pendingQuit: false,
statusMsg: '',
width: size0.width || 80,
height: size0.height || 24,
init: function () {
return null
},
_contentH: function () {
return Math.max(1, (this.height || 24) - 2)
},
_ensureScroll: function () {
const cols = Math.max(40, this.width || 80)
const contentH = this._contentH()
const buf = this.buf
if (buf.row < this.scrollRow) this.scrollRow = buf.row
if (buf.row >= this.scrollRow + contentH)
this.scrollRow = buf.row - contentH + 1
if (this.scrollRow < 0) this.scrollRow = 0
if (this.scrollCol > buf.col) this.scrollCol = buf.col
if (buf.col - this.scrollCol >= cols) this.scrollCol = buf.col - cols + 1
if (this.scrollCol < 0) this.scrollCol = 0
},
_save: function (targetPath) {
const self = this
return function () {
return Promise.resolve()
.then(function () {
const text = bareEditJoinAll(self.buf)
return vfs.writeFile(targetPath, b4a.from(text))
})
.then(function () {
return { type: 'edit.saved', ok: true, path: targetPath }
})
.catch(function (error) {
return { type: 'edit.saved', ok: false, error: error }
})
}
},
_applySearch: function () {
this.lastSearch = this.promptBuf
if (!this.lastSearch) return
const hit = bareEditFindNext(
this.buf,
this.lastSearch,
this.buf.row,
this.buf.col + 1
)
if (!hit) {
const hit2 = bareEditFindNext(this.buf, this.lastSearch, 0, 0)
if (hit2) {
this.buf.row = hit2.row
this.buf.col = hit2.col
}
} else {
this.buf.row = hit.row
this.buf.col = hit.col
}
},
_promptKind: function () {
if (this.mode === 'prompt_search') return 'search'
if (this.mode === 'prompt_goto') return 'goto'
if (this.mode === 'prompt_saveas') return 'saveas'
return ''
},
update: function (msg) {
if (msg && msg.type === 'resize') {
this.width = msg.width || this.width
this.height = msg.height || this.height
this._ensureScroll()
return [this, null]
}
if (msg && msg.type === 'edit.saved') {
if (msg.ok) {
this.buf.dirty = false
this.savePath = String(msg.path || this.savePath)
this.statusMsg = ''
if (this.pendingQuit) return [this, tui.quit]
} else {
const err = msg.error
this.statusMsg =
'edit: ' +
(err && err.message ? err.message : String(err || 'save failed'))
this.pendingQuit = false
}
return [this, null]
}
if (this.mode === 'help') {
if (msg && msg.type === 'key') this.mode = 'edit'
return [this, null]
}
if (this.mode === 'quit_confirm') {
if (tui.key.matches(msg, 'ctrl+c')) {
this.mode = 'edit'
return [this, null]
}
if (tui.key.matches(msg, 'y', 'Y')) {
this.pendingQuit = true
this.mode = 'edit'
return [this, this._save(this.savePath)]
}
if (tui.key.matches(msg, 'n', 'N')) return [this, tui.quit]
return [this, null]
}
if (this._promptKind()) {
if (tui.key.matches(msg, 'ctrl+c')) {
this.mode = 'edit'
return [this, null]
}
if (msg && msg.type === 'paste') {
this.promptBuf = bareEditPromptLineFromPaste(
String(msg.text || ''),
/** @type {'saveas'|'search'|'goto'} */ (this._promptKind()),
this.maxPromptLen
)
return [this, null]
}
if (tui.key.matches(msg, 'enter')) {
if (this.mode === 'prompt_search') this._applySearch()
else if (this.mode === 'prompt_goto') {
const n = parseInt(this.promptBuf.trim(), 10)
if (Number.isFinite(n)) bareEditGotoLine(this.buf, n)
} else if (this.mode === 'prompt_saveas') {
const p = this.promptBuf.trim()
this.mode = 'edit'
if (p) {
this.savePath = p
return [this, this._save(this.savePath)]
}
}
this.mode = 'edit'
return [this, null]
}
if (tui.key.matches(msg, 'backspace')) {
this.promptBuf = this.promptBuf.slice(0, -1)
return [this, null]
}
if (
msg &&
msg.type === 'key' &&
!msg.ctrl &&
!msg.meta &&
typeof msg.sequence === 'string' &&
msg.sequence.length === 1 &&
msg.sequence >= ' ' &&
msg.sequence !== '\x7f'
) {
if (this.promptBuf.length < this.maxPromptLen)
this.promptBuf += msg.sequence
}
return [this, null]
}
if (msg && msg.type === 'paste') {
bareEditInsertPasteText(this.buf, msg.text || '')
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+x')) {
if (this.buf.dirty) {
this.mode = 'quit_confirm'
return [this, null]
}
return [this, tui.quit]
}
if (tui.key.matches(msg, 'ctrl+s')) {
return [this, this._save(this.savePath)]
}
if (tui.key.matches(msg, 'ctrl+o')) {
this.mode = 'prompt_saveas'
this.promptTitle = 'File: '
this.promptBuf = this.savePath
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+w')) {
this.mode = 'prompt_search'
this.promptTitle = 'Search: '
this.promptBuf = this.lastSearch
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+g')) {
this.mode = 'help'
return [this, null]
}
if (bareEditIsGotoChord(msg)) {
this.mode = 'prompt_goto'
this.promptTitle = 'Go to line: '
this.promptBuf = ''
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+z')) {
bareEditUndo(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+y')) {
bareEditRedo(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'pageup')) {
const step = Math.max(1, (this.height || 24) - 4)
this.buf.row = Math.max(0, this.buf.row - step)
bareEditClampCursor(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'pagedown')) {
const step = Math.max(1, (this.height || 24) - 4)
this.buf.row = Math.min(this.buf.lines.length - 1, this.buf.row + step)
bareEditClampCursor(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'up', 'down', 'left', 'right', 'home', 'end')) {
const name = msg && msg.name
if (
name === 'up' ||
name === 'down' ||
name === 'left' ||
name === 'right' ||
name === 'home' ||
name === 'end'
) {
bareEditMoveKey(this.buf, name)
}
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'backspace')) {
bareEditBackspace(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'delete')) {
bareEditDelete(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'enter')) {
bareEditNewline(this.buf)
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'tab')) {
bareEditInsertChar(this.buf, ' ')
this._ensureScroll()
return [this, null]
}
if (tui.key.matches(msg, 'ctrl+c')) {
return [this, null]
}
if (
msg &&
msg.type === 'key' &&
!msg.ctrl &&
!msg.meta &&
typeof msg.sequence === 'string' &&
msg.sequence.length === 1 &&
msg.sequence >= ' '
) {
bareEditInsertChar(this.buf, msg.sequence)
this._ensureScroll()
}
return [this, null]
},
view: function () {
const cols = Math.max(40, this.width || 80)
const contentH = this._contentH()
this._ensureScroll()
const lines = []
for (let r = 0; r < contentH; r++) {
const lineIndex = this.scrollRow + r
const line = this.buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, this.lang)
lines.push(
bareEditPaintLineWindow(line, spans, this.scrollCol, cols, useColor)
)
}
const dirtyMark = this.buf.dirty ? ' [Modified]' : ''
const statusRaw =
this.statusMsg ||
this.display +
' ' +
this.savePath +
dirtyMark +
' ' +
(this.buf.row + 1) +
',' +
(this.buf.col + 1)
const helpRaw =
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo'
const st = tui.style
const status = st
? st()
.foreground('brightwhite')
.background('blue')
.width(cols)
.render(statusRaw)
: statusRaw
const help = st ? st().dim().width(cols).render(helpRaw) : helpRaw
return lines.join('\n') + '\n' + status + '\n' + help
},
overlays: function (size) {
const cols = (size && size.width) || this.width || 80
const rows = (size && size.height) || this.height || 24
const st = tui.style
/** @type {{ row: number, col: number, text: string }[]} */
const list = []
if (this.mode === 'help') {
const body =
'Bare OS edit — help\n\n' +
'^O ^S Save ^X Exit\n' +
'^W Search ^_ Go to line\n' +
'^Z Undo ^Y Redo\n' +
'Arrows move cursor; Home/End; Backspace/Del\n\n' +
'Press any key to return.'
const boxed = st
? st()
.border(st.borders.rounded)
.padding(1, 2)
.background('black')
.render(body)
: body
const h = st ? st.height(boxed) : boxed.split('\n').length
const w = st ? st.width(boxed) : 40
list.push({
row: Math.max(0, Math.floor((rows - h) / 2)),
col: Math.max(0, Math.floor((cols - w) / 2)),
text: boxed
})
return list
}
if (this.mode === 'quit_confirm') {
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
list.push({
row: Math.max(0, rows - 2),
col: 0,
text: st
? st()
.foreground('brightwhite')
.background('blue')
.width(cols)
.render(msg)
: msg
})
return list
}
if (this._promptKind()) {
const bar = this.promptTitle + this.promptBuf
list.push({
row: Math.max(0, rows - 2),
col: 0,
text: st
? st()
.foreground('brightwhite')
.background('blue')
.width(cols)
.render(bar)
: bar
})
return list
}
this._ensureScroll()
const cr = this.buf.row - this.scrollRow
const cc = this.buf.col - this.scrollCol
if (cr >= 0 && cr < rows - 2 && cc >= 0 && cc < cols) {
const line = this.buf.lines[this.buf.row] || ''
const ch = line.charAt(this.buf.col) || ' '
list.push({
row: cr,
col: cc,
text: st ? st().reverse().render(ch) : ch
})
}
return list
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {{ path: string, initialText: string, argv0?: string }} opts
*/
async function bareOsRunEditTui(ctx, opts) {
if (ctx.tui && typeof ctx.tui.run === 'function') {
await ctx.tui.run(bareEditCreateTuiApp(ctx, opts), bareEditTuiRunOpts(ctx))
return
}
await bareOsRunEditTuiLegacy(ctx, opts)
}
/**
* Pre-SDK key loop (BARE_OS_TUI=0).
* @param {Record<string, unknown>} ctx
* @param {{ path: string, initialText: string, argv0?: string }} opts
*/
async function bareOsRunEditTuiLegacy(ctx, opts) {
const vfs = ctx.vfs
const b4a = ctx.b4a
const path = opts.path || 'Untitled'
@@ -63,10 +554,12 @@ async function bareOsRunEditTui(ctx, opts) {
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
const cols = /** @type {{ columns?: number }} */ (stdout).columns ||
const cols =
/** @type {{ columns?: number }} */ (stdout).columns ||
parseInt(env.COLUMNS || '80', 10) ||
80
const rows = /** @type {{ rows?: number }} */ (stdout).rows ||
const rows =
/** @type {{ rows?: number }} */ (stdout).rows ||
parseInt(env.LINES || '24', 10) ||
24
return { cols: Math.max(40, cols), rows: Math.max(8, rows) }
@@ -116,13 +609,23 @@ async function bareOsRunEditTui(ctx, opts) {
return
}
if (mode === 'prompt_search' || mode === 'prompt_goto' || mode === 'prompt_saveas') {
if (
mode === 'prompt_search' ||
mode === 'prompt_goto' ||
mode === 'prompt_saveas'
) {
const editorLines = Math.max(0, contentH - 1)
for (let r = 0; r < editorLines; r++) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
const vis = bareEditPaintLineWindow(
line,
spans,
scrollCol,
cols,
useColor
)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
const barBody =
@@ -142,7 +645,13 @@ async function bareOsRunEditTui(ctx, opts) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
const vis = bareEditPaintLineWindow(
line,
spans,
scrollCol,
cols,
useColor
)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
@@ -306,7 +815,8 @@ async function bareOsRunEditTui(ctx, opts) {
continue
}
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
if (ev.ch >= ' ' && promptBuf.length < maxPromptLen) promptBuf += ev.ch
if (ev.ch >= ' ' && promptBuf.length < maxPromptLen)
promptBuf += ev.ch
draw()
continue
}