692 lines
19 KiB
JavaScript
692 lines
19 KiB
JavaScript
/**
|
||
* 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' }
|
||
}
|