/** * Live thinking viewport for /bin/agent — fixed-height auto-follow box with * scrollbar + keyboard review, separated from the assistant reply. * Also splits Qwen-style tags out of content deltas. */ /** * Resolve usable terminal width (full width; no artificial 80/100/120 cap). * @param {Record} [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} */ (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} ctx * @param {import('stream').Writable | undefined} stdout * @param {{ * useColor?: boolean, * bodyLines?: number, * maxChars?: number, * write?: (ctx: Record, 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} */ (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} ctx * @param {ReturnType | 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 `` (and ``) 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' ? ['', '', ''] : ['', '', ''] 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(' 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' } }