Files
bare-operating-system/packages/bare-os-coreutils/lib/agent/agent-think-panel.js
T
2026-08-18 18:11:28 -04:00

692 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Live thinking viewport for /bin/agent — fixed-height auto-follow box with
* scrollbar + keyboard review, separated from the assistant reply.
* Also splits Qwen-style <think> tags out of content deltas.
*/
/**
* Resolve usable terminal width (full width; no artificial 80/100/120 cap).
* @param {Record<string, unknown>} [ctx]
* @param {import('stream').Writable | undefined} [stdout]
*/
function bareAgentResolveTermCols(ctx, stdout) {
/** @type {number[]} */
const cands = []
const push = (v) => {
const n = Number(v)
if (Number.isFinite(n) && n >= 20) cands.push(Math.floor(n))
}
if (stdout && typeof stdout === 'object') {
push(/** @type {{ columns?: number }} */ (stdout).columns)
}
if (ctx && typeof ctx === 'object') {
const rs = ctx.replStdout
if (rs && typeof rs === 'object') {
push(/** @type {{ columns?: number }} */ (rs).columns)
}
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: null
if (env) push(env.COLUMNS)
}
try {
if (globalThis.process && globalThis.process.stdout) {
push(globalThis.process.stdout.columns)
}
if (globalThis.process && globalThis.process.env) {
push(globalThis.process.env.COLUMNS)
}
} catch {
/* ignore */
}
if (!cands.length) return 80
// Prefer the largest reported size (stale COLUMNS=80 is common on wide TTYs).
return Math.min(500, Math.max(40, Math.max(...cands)))
}
/**
* @param {string} s
* @param {number} width
* @returns {string[]}
*/
function bareAgentThinkWrapLines(s, width) {
const w = Math.max(8, width | 0)
const raw = String(s || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
/** @type {string[]} */
const out = []
for (const para of raw.split('\n')) {
if (!para) {
out.push('')
continue
}
let rest = para
while (rest.length > w) {
let cut = rest.lastIndexOf(' ', w)
if (cut < Math.floor(w * 0.5)) cut = w
out.push(rest.slice(0, cut).trimEnd())
rest = rest.slice(cut).trimStart()
}
if (rest.length || !out.length) out.push(rest)
}
return out.length ? out : ['']
}
/**
* @param {string} s
* @param {number} width
*/
function bareAgentThinkPad(s, width) {
const t = String(s || '')
if (t.length >= width) return t.slice(0, width)
return t + ' '.repeat(width - t.length)
}
/**
* @param {string} title
* @param {number} inner
* @param {boolean} fancy
*/
function bareAgentThinkTitleBar(title, inner, fancy) {
const label = String(title || ' thinking ')
const fill = Math.max(0, inner - label.length)
const left = Math.floor(fill / 2)
const right = fill - left
if (fancy) {
return '─'.repeat(left) + label + '─'.repeat(right)
}
return '-'.repeat(left) + label + '-'.repeat(right)
}
/**
* Scrollbar column chars for a viewport.
* @param {number} bodyLines
* @param {number} totalLines
* @param {number} viewStart
* @param {boolean} fancy
* @returns {string[]} length === bodyLines
*/
function bareAgentThinkScrollbar(bodyLines, totalLines, viewStart, fancy) {
/** @type {string[]} */
const col = []
const track = fancy ? '│' : '|'
const thumb = fancy ? '█' : '#'
const gap = fancy ? '░' : ':'
if (totalLines <= bodyLines) {
for (let i = 0; i < bodyLines; i++) col.push(track)
return col
}
const maxStart = totalLines - bodyLines
const thumbSize = Math.max(
1,
Math.round((bodyLines / totalLines) * bodyLines)
)
const thumbStart =
maxStart <= 0
? 0
: Math.round((viewStart / maxStart) * (bodyLines - thumbSize))
for (let i = 0; i < bodyLines; i++) {
col.push(i >= thumbStart && i < thumbStart + thumbSize ? thumb : gap)
}
return col
}
/**
* Fixed-height thinking panel with auto-follow + manual scroll review.
* @param {Record<string, unknown>} ctx
* @param {import('stream').Writable | undefined} stdout
* @param {{
* useColor?: boolean,
* bodyLines?: number,
* maxChars?: number,
* write?: (ctx: Record<string, unknown>, out: unknown, s: string) => void
* }} [opts]
*/
function bareAgentCreateThinkPanel(ctx, stdout, opts) {
const useColor = opts && opts.useColor !== undefined ? Boolean(opts.useColor) : true
const bodyLines = Math.min(
18,
Math.max(4, (opts && opts.bodyLines) || 8)
)
const maxChars = Math.min(
80_000,
Math.max(400, (opts && opts.maxChars) || 24_000)
)
const write =
opts && typeof opts.write === 'function'
? opts.write
: typeof bareAgentWriteOut === 'function'
? bareAgentWriteOut
: (c, o, s) => {
try {
if (o && typeof o.write === 'function') o.write(s)
} catch {
/* ignore */
}
}
const fancy =
useColor &&
!(
ctx.env &&
typeof ctx.env === 'object' &&
String(/** @type {Record<string, string>} */ (ctx.env).BARE_OS_AGENT_ASCII || '') ===
'1'
)
let text = ''
let drawn = false
let sealed = false
let height = 0
let startedAt = 0
/** Lines below the panel (status / reply) that must be preserved on redraw. */
let belowLines = 0
/** 0 = pinned to bottom (auto-follow); >0 = scrolled up from bottom. */
let scrollFromBottom = 0
let followTail = true
/** Optional status line under the box (e.g. answering…). */
let statusLine = ''
function cols() {
return bareAgentResolveTermCols(ctx, stdout)
}
function dim(s) {
if (!useColor) return s
const paint =
typeof bareEditSgr === 'function' ? bareEditSgr('dim', true) : '\x1b[90m'
const reset = typeof EDIT_ANSI_RESET === 'string' ? EDIT_ANSI_RESET : '\x1b[0m'
return paint + s + reset
}
function accent(s) {
if (!useColor) return s
const paint =
typeof bareEditSgr === 'function' ? bareEditSgr('comment', true) : '\x1b[90m'
const reset = typeof EDIT_ANSI_RESET === 'string' ? EDIT_ANSI_RESET : '\x1b[0m'
return paint + s + reset
}
function keyword(s) {
if (!useColor) return s
const paint =
typeof bareEditSgr === 'function' ? bareEditSgr('keyword', true) : '\x1b[36m'
const reset = typeof EDIT_ANSI_RESET === 'string' ? EDIT_ANSI_RESET : '\x1b[0m'
return paint + s + reset
}
function contentWidth(inner) {
// border + space + text + space + scrollbar + border
return Math.max(12, inner - 4)
}
/**
* @returns {{ lines: string[], wrappedCount: number, viewStart: number }}
*/
function frameLines() {
const c = cols()
const inner = Math.max(28, c - 2)
const textW = contentWidth(inner)
const wrapped = bareAgentThinkWrapLines(text, textW)
const maxStart = Math.max(0, wrapped.length - bodyLines)
if (followTail) scrollFromBottom = 0
const viewStart = Math.max(
0,
Math.min(maxStart, maxStart - scrollFromBottom)
)
const view = wrapped.slice(viewStart, viewStart + bodyLines)
while (view.length < bodyLines) view.push('')
const sb = bareAgentThinkScrollbar(
bodyLines,
wrapped.length,
viewStart,
fancy
)
const elapsed =
startedAt > 0 ? ((Date.now() - startedAt) / 1000).toFixed(1) + 's' : ''
const pos =
wrapped.length > bodyLines
? ' · ' +
String(viewStart + 1) +
'' +
String(Math.min(wrapped.length, viewStart + bodyLines)) +
'/' +
String(wrapped.length)
: ''
const scrollHint =
wrapped.length > bodyLines ? ' · ↑↓/PgUp/PgDn' : ''
const title = sealed
? ' thinking · done' +
(elapsed ? ' · ' + elapsed : '') +
pos +
scrollHint +
' '
: ' thinking' +
(elapsed ? ' · ' + elapsed : '') +
(followTail ? ' · live' : ' · paused') +
pos +
scrollHint +
' '
const bar = bareAgentThinkTitleBar(title, inner, fancy)
/** @type {string[]} */
const lines = []
if (fancy) {
lines.push(accent('┌' + bar + '┐'))
for (let r = 0; r < bodyLines; r++) {
lines.push(
accent('│') +
' ' +
dim(bareAgentThinkPad(view[r], textW)) +
' ' +
keyword(sb[r]) +
accent('│')
)
}
const footLabel = followTail
? wrapped.length > bodyLines
? ' follow · ' + String(wrapped.length) + ' lines '
: ''
: ' scrolled · end to resume '
const foot = footLabel
? bareAgentThinkTitleBar(footLabel, inner, true)
: '─'.repeat(inner)
lines.push(accent('└' + foot + '┘'))
} else {
lines.push('+' + bar.replace(/─/g, '-') + '+')
for (let r = 0; r < bodyLines; r++) {
lines.push(
'| ' + bareAgentThinkPad(view[r], textW) + ' ' + sb[r] + '|'
)
}
lines.push('+' + '-'.repeat(inner) + '+')
}
return { lines, wrappedCount: wrapped.length, viewStart }
}
function totalDrawnHeight() {
return height + belowLines
}
function redraw() {
// Never paint an empty think frame.
if (!text.trim() && !statusLine) return
if (!text.trim()) return
const { lines } = frameLines()
/** @type {string[]} */
const block = lines.slice()
if (statusLine) block.push(statusLine)
let out = ''
if (drawn && totalDrawnHeight() > 0) {
out += '\x1b[' + String(totalDrawnHeight()) + 'A\r'
} else {
out += '\n'
}
for (let i = 0; i < block.length; i++) {
out += '\x1b[K' + block[i] + '\n'
}
const prev = totalDrawnHeight()
if (drawn && prev > block.length) {
for (let i = block.length; i < prev; i++) out += '\x1b[K\n'
out += '\x1b[' + String(prev - block.length) + 'A\r'
}
height = lines.length
belowLines = statusLine ? 1 : 0
drawn = true
write(ctx, stdout, out)
}
/**
* @param {number} delta positive = scroll up into history
*/
function scrollBy(delta) {
if (!drawn || !text.trim()) return false
const c = cols()
const inner = Math.max(28, c - 2)
const wrapped = bareAgentThinkWrapLines(text, contentWidth(inner))
const maxFromBottom = Math.max(0, wrapped.length - bodyLines)
if (maxFromBottom <= 0) return false
followTail = false
scrollFromBottom = Math.max(
0,
Math.min(maxFromBottom, scrollFromBottom + delta)
)
if (scrollFromBottom === 0) followTail = true
redraw()
return true
}
return {
/**
* @param {string} chunk
*/
append(chunk) {
const add = String(chunk || '')
if (!add || sealed) return
// Ignore whitespace-only until we have real thinking text (no empty box).
if (!text.trim() && !add.trim()) return
if (!startedAt) startedAt = Date.now()
text += add
if (text.length > maxChars) text = text.slice(text.length - maxChars)
if (!text.trim()) return
if (followTail) scrollFromBottom = 0
redraw()
},
seal() {
// Empty box: stay undrawn and unlocked so late reasoning can still appear.
if (!text.trim()) return
if (sealed) {
redraw()
return
}
sealed = true
redraw()
},
/**
* @param {string} s
*/
setStatus(s) {
if (!text.trim()) return
statusLine = String(s || '')
redraw()
},
clearStatus() {
if (!statusLine) return
statusLine = ''
if (drawn) redraw()
},
/**
* After reply is painted below, stop managing below-region.
*/
detachBelow() {
belowLines = 0
statusLine = ''
},
scrollUp(n) {
return scrollBy(Math.max(1, n || 1))
},
scrollDown(n) {
return scrollBy(-Math.max(1, n || 1))
},
pageUp() {
return scrollBy(Math.max(1, bodyLines - 1))
},
pageDown() {
return scrollBy(-Math.max(1, bodyLines - 1))
},
scrollHome() {
if (!drawn || !text.trim()) return false
const c = cols()
const inner = Math.max(28, c - 2)
const wrapped = bareAgentThinkWrapLines(text, contentWidth(inner))
const maxFromBottom = Math.max(0, wrapped.length - bodyLines)
followTail = false
scrollFromBottom = maxFromBottom
redraw()
return true
},
scrollEnd() {
if (!drawn || !text.trim()) return false
followTail = true
scrollFromBottom = 0
redraw()
return true
},
/**
* @param {{ type?: string, key?: string, ch?: string, code?: number | string }} ev
*/
handleKey(ev) {
if (!ev || !drawn) return false
if (ev.type === 'nav') {
if (ev.key === 'up') return this.scrollUp(1)
if (ev.key === 'down') return this.scrollDown(1)
if (ev.key === 'pageup') return this.pageUp()
if (ev.key === 'pagedown') return this.pageDown()
if (ev.key === 'home') return this.scrollHome()
if (ev.key === 'end') return this.scrollEnd()
}
if (ev.type === 'key') {
if (ev.ch === 'k') return this.scrollUp(1)
if (ev.ch === 'j') return this.scrollDown(1)
if (ev.ch === 'g') return this.scrollHome()
if (ev.ch === 'G') return this.scrollEnd()
}
return false
},
panelHeight() {
return height
},
isOpen() {
return drawn && !sealed
},
isDrawn() {
return drawn
},
hasContent() {
return text.trim().length > 0
},
getText() {
return text
},
isFollowing() {
return followTail
}
}
}
/**
* Attach arrow/page keys to a think panel for the duration of a turn.
* @param {Record<string, unknown>} ctx
* @param {ReturnType<typeof bareAgentCreateThinkPanel> | null} panel
* @param {{ onAbort?: () => void }} [opts]
* @returns {() => void} dispose
*/
function bareAgentAttachThinkScrollKeys(ctx, panel, opts) {
if (!panel) return () => {}
const stdin =
/** @type {{ isTTY?: boolean, setRawMode?: (v: boolean) => void, on?: Function, off?: Function, removeListener?: Function, resume?: Function }} */ (
ctx.replStdin || ctx.stdin
)
if (!stdin || !stdin.isTTY || typeof stdin.on !== 'function') return () => {}
let rawSet = false
let disposed = false
try {
if (typeof stdin.setRawMode === 'function') {
stdin.setRawMode(true)
rawSet = true
}
} catch {
rawSet = false
}
try {
if (typeof stdin.resume === 'function') stdin.resume()
} catch {
/* ignore */
}
/** @type {number[]} */
const q = []
const onAbort =
opts && typeof opts.onAbort === 'function' ? opts.onAbort : null
/**
* @param {string | Uint8Array | Buffer} chunk
*/
function onData(chunk) {
if (disposed) return
const bytes =
typeof bareEditChunkBytes === 'function'
? bareEditChunkBytes(chunk)
: typeof chunk === 'string'
? [...chunk].map((c) => c.charCodeAt(0) & 0xff)
: Array.from(/** @type {Uint8Array} */ (chunk))
for (const b of bytes) q.push(b)
for (;;) {
if (!q.length) break
// Ctrl+C — abort agent turn
if (q[0] === 3) {
q.shift()
try {
if (onAbort) onAbort()
else if (globalThis.process && typeof globalThis.process.emit === 'function') {
globalThis.process.emit('SIGINT')
}
} catch {
/* ignore */
}
continue
}
// Ctrl+D / Ctrl+X — treat as abort so shell exit is not wedged under raw mode
if (q[0] === 4 || q[0] === 24) {
q.shift()
try {
if (onAbort) onAbort()
} catch {
/* ignore */
}
continue
}
const ev =
typeof bareEditTryConsumeKey === 'function'
? bareEditTryConsumeKey(q)
: null
if (!ev) {
if (q.length && q[0] === 27 && q.length < 6) break
if (q.length && q[0] !== 27) {
const ch = String.fromCharCode(/** @type {number} */ (q.shift()))
panel.handleKey({ type: 'key', ch })
continue
}
break
}
panel.handleKey(ev)
}
}
stdin.on('data', onData)
return () => {
if (disposed) return
disposed = true
try {
if (typeof stdin.off === 'function') stdin.off('data', onData)
else if (typeof stdin.removeListener === 'function') {
stdin.removeListener('data', onData)
}
} catch {
/* ignore */
}
if (rawSet) {
try {
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false)
} catch {
/* ignore */
}
}
}
}
/**
* Split streamed assistant text into thinking vs visible content.
* Handles Qwen3 `<think>…</think>` (and `<thinking>`) across chunk boundaries.
* @param {{
* onThink: (s: string) => void,
* onContent: (s: string) => void
* }} handlers
*/
function bareAgentCreateThinkTagSplitter(handlers) {
const onThink = handlers.onThink
const onContent = handlers.onContent
/** @type {'content' | 'think'} */
let mode = 'content'
let buf = ''
const OPEN = '<'
/** @type {RegExp} */
const OPEN_TAG = /^<(?:think|thinking|redacted_thinking)\s*>/i
/** @type {RegExp} */
const CLOSE_TAG = /^<\/(?:think|thinking|redacted_thinking)\s*>/i
/**
* @param {string} s
* @param {'content' | 'think'} m
*/
function partialTagLen(s, m) {
const samples =
m === 'content'
? ['<think>', '<thinking>', '<redacted_thinking>']
: ['</think>', '</thinking>', '</redacted_thinking>']
let best = 0
for (const sample of samples) {
for (let n = 1; n < sample.length; n++) {
if (s.endsWith(sample.slice(0, n))) best = Math.max(best, n)
}
}
if (s.endsWith('<')) best = Math.max(best, 1)
if (s.endsWith('</')) best = Math.max(best, 2)
return best
}
/**
* @param {string} chunk
*/
function push(chunk) {
if (!chunk) return
buf += chunk
for (;;) {
if (mode === 'content') {
const lt = buf.indexOf(OPEN)
if (lt < 0) {
if (buf) onContent(buf)
buf = ''
return
}
if (lt > 0) {
onContent(buf.slice(0, lt))
buf = buf.slice(lt)
}
const om = OPEN_TAG.exec(buf)
if (om) {
buf = buf.slice(om[0].length)
mode = 'think'
continue
}
if (partialTagLen(buf, 'content') === buf.length) return
onContent(buf.slice(0, 1))
buf = buf.slice(1)
continue
}
const lt = buf.indexOf('<')
if (lt < 0) {
if (buf) onThink(buf)
buf = ''
return
}
if (lt > 0) {
onThink(buf.slice(0, lt))
buf = buf.slice(lt)
}
const cm = CLOSE_TAG.exec(buf)
if (cm) {
buf = buf.slice(cm[0].length)
mode = 'content'
continue
}
if (partialTagLen(buf, 'think') === buf.length) return
onThink(buf.slice(0, 1))
buf = buf.slice(1)
}
}
function flush() {
if (!buf) return
if (mode === 'think') onThink(buf)
else onContent(buf)
buf = ''
}
return { push, flush, inThink: () => mode === 'think' }
}