mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
chore: update how txt pasting ux feels
This commit is contained in:
parent
9db94e8521
commit
77b97b810a
8 changed files with 239 additions and 83 deletions
|
|
@ -14,7 +14,8 @@ def get_hermes_home() -> Path:
|
|||
Reads HERMES_HOME env var, falls back to ~/.hermes.
|
||||
This is the single source of truth — all other copies should import this.
|
||||
"""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
|
||||
def get_default_hermes_root() -> Path:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,32 @@ def _status_update(sid: str, kind: str, text: str | None = None):
|
|||
_emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body})
|
||||
|
||||
|
||||
def _estimate_image_tokens(width: int, height: int) -> int:
|
||||
"""Very rough UI estimate for image prompt cost.
|
||||
|
||||
Uses 512px tiles at ~85 tokens/tile as a lightweight cross-provider hint.
|
||||
This is intentionally approximate and only used for attachment display.
|
||||
"""
|
||||
if width <= 0 or height <= 0:
|
||||
return 0
|
||||
return max(1, (width + 511) // 512) * max(1, (height + 511) // 512) * 85
|
||||
|
||||
|
||||
def _image_meta(path: Path) -> dict:
|
||||
meta = {"name": path.name}
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as img:
|
||||
width, height = img.size
|
||||
meta["width"] = int(width)
|
||||
meta["height"] = int(height)
|
||||
meta["token_estimate"] = _estimate_image_tokens(int(width), int(height))
|
||||
except Exception:
|
||||
pass
|
||||
return meta
|
||||
|
||||
|
||||
def _ok(rid, result: dict) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
||||
|
||||
|
|
@ -393,6 +419,18 @@ def _get_usage(agent) -> dict:
|
|||
return usage
|
||||
|
||||
|
||||
def _probe_credentials(agent) -> str:
|
||||
"""Light credential check at session creation — returns warning or ''."""
|
||||
try:
|
||||
key = getattr(agent, "api_key", "") or ""
|
||||
provider = getattr(agent, "provider", "") or ""
|
||||
if not key or key == "no-key-required":
|
||||
return f"No API key configured for provider '{provider}'. First message will fail."
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _session_info(agent) -> dict:
|
||||
info: dict = {
|
||||
"model": getattr(agent, "model", ""),
|
||||
|
|
@ -712,7 +750,11 @@ def _(rid, params: dict) -> dict:
|
|||
_init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
|
||||
except Exception as e:
|
||||
return _err(rid, 5000, f"agent init failed: {e}")
|
||||
return _ok(rid, {"session_id": sid, "info": _session_info(agent)})
|
||||
info = _session_info(agent)
|
||||
warn = _probe_credentials(agent)
|
||||
if warn:
|
||||
info["credential_warning"] = warn
|
||||
return _ok(rid, {"session_id": sid, "info": info})
|
||||
|
||||
|
||||
@method("session.list")
|
||||
|
|
@ -1049,7 +1091,15 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"attached": False, "message": msg})
|
||||
|
||||
session.setdefault("attached_images", []).append(str(img_path))
|
||||
return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])})
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"attached": True,
|
||||
"path": str(img_path),
|
||||
"count": len(session["attached_images"]),
|
||||
**_image_meta(img_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@method("image.attach")
|
||||
|
|
@ -1075,10 +1125,10 @@ def _(rid, params: dict) -> dict:
|
|||
{
|
||||
"attached": True,
|
||||
"path": str(image_path),
|
||||
"name": image_path.name,
|
||||
"count": len(session["attached_images"]),
|
||||
"remainder": remainder,
|
||||
"text": remainder or f"[User attached image: {image_path.name}]",
|
||||
**_image_meta(image_path),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -1109,9 +1159,9 @@ def _(rid, params: dict) -> dict:
|
|||
"matched": True,
|
||||
"is_image": True,
|
||||
"path": str(drop_path),
|
||||
"name": drop_path.name,
|
||||
"count": len(session["attached_images"]),
|
||||
"text": text,
|
||||
**_image_meta(drop_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { estimateRows, fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js'
|
||||
import {
|
||||
edgePreview,
|
||||
estimateRows,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
isToolTrailResultLine,
|
||||
lastCotTrailIndex,
|
||||
pasteTokenLabel,
|
||||
sameToolTrailGroup
|
||||
} from '../lib/text.js'
|
||||
|
||||
describe('isToolTrailResultLine', () => {
|
||||
it('detects completion markers', () => {
|
||||
|
|
@ -50,6 +59,46 @@ describe('fmtK', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('estimateTokensRough', () => {
|
||||
it('uses 4 chars per token rounding up', () => {
|
||||
expect(estimateTokensRough('')).toBe(0)
|
||||
expect(estimateTokensRough('a')).toBe(1)
|
||||
expect(estimateTokensRough('abcd')).toBe(1)
|
||||
expect(estimateTokensRough('abcde')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgePreview', () => {
|
||||
it('keeps both ends for long text', () => {
|
||||
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
||||
'Vampire.. stained with blood'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteTokenLabel', () => {
|
||||
it('builds readable long-paste labels with counts', () => {
|
||||
expect(
|
||||
pasteTokenLabel({
|
||||
charCount: 1000,
|
||||
id: 7,
|
||||
lineCount: 250,
|
||||
text: 'Vampire Bondage ropes slipped from her neck, still stained with blood',
|
||||
tokenCount: 250
|
||||
})
|
||||
).toContain('[[paste:7 ')
|
||||
expect(
|
||||
pasteTokenLabel({
|
||||
charCount: 1000,
|
||||
id: 7,
|
||||
lineCount: 250,
|
||||
text: 'Vampire Bondage ropes slipped from her neck, still stained with blood',
|
||||
tokenCount: 250
|
||||
})
|
||||
).toContain('[250 lines · 250 tok · 1K chars]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateRows', () => {
|
||||
it('handles tilde code fences', () => {
|
||||
const md = ['~~~markdown', '# heading', '~~~'].join('\n')
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
|
|||
import {
|
||||
buildToolTrailLine,
|
||||
compactPreview,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
hasInterpolation,
|
||||
isToolTrailResultLine,
|
||||
isTransientTrailLine,
|
||||
pasteTokenLabel,
|
||||
pick,
|
||||
sameToolTrailGroup,
|
||||
stripTrailingPasteNewlines,
|
||||
|
|
@ -54,7 +56,7 @@ import type {
|
|||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const PLACEHOLDER = pick(PLACEHOLDERS)
|
||||
const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g
|
||||
const PASTE_TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g
|
||||
const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||
|
||||
const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||
|
|
@ -109,14 +111,20 @@ const redactSecrets = (text: string) => {
|
|||
return { redactions, text: cleaned }
|
||||
}
|
||||
|
||||
const pasteToken = (id: number) => `[[paste:${id}]]`
|
||||
|
||||
const stripTokens = (text: string, re: RegExp) =>
|
||||
text
|
||||
.replace(re, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
|
||||
const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => {
|
||||
const dims = info?.width && info?.height ? `${info.width}x${info.height}` : ''
|
||||
const tok =
|
||||
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
|
||||
|
||||
return [dims, tok].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||
if (!Array.isArray(rows)) {
|
||||
return []
|
||||
|
|
@ -345,7 +353,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const [statusBar, setStatusBar] = useState(true)
|
||||
const [lastUserMsg, setLastUserMsg] = useState('')
|
||||
const [pastes, setPastes] = useState<PendingPaste[]>([])
|
||||
const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [turnTrail, setTurnTrail] = useState<string[]>([])
|
||||
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
|
||||
|
|
@ -425,7 +432,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
)
|
||||
|
||||
function blocked() {
|
||||
return !!(clarify || approval || pasteReview || picker || secret || sudo || pager)
|
||||
return !!(clarify || approval || picker || secret || sudo || pager)
|
||||
}
|
||||
|
||||
const empty = !messages.length
|
||||
|
|
@ -617,7 +624,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setBusy(false)
|
||||
setClarify(null)
|
||||
setApproval(null)
|
||||
setPasteReview(null)
|
||||
setSudo(null)
|
||||
setSecret(null)
|
||||
setStreaming('')
|
||||
|
|
@ -632,7 +638,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const clearIn = () => {
|
||||
setInput('')
|
||||
setInputBuf([])
|
||||
setPasteReview(null)
|
||||
setQueueEdit(null)
|
||||
setHistoryIdx(null)
|
||||
historyDraftRef.current = ''
|
||||
|
|
@ -661,6 +666,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
(msg?: string) =>
|
||||
rpc('session.create', { cols: colsRef.current }).then((r: any) => {
|
||||
if (!r) {
|
||||
setStatus('ready')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -681,6 +688,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setInfo(null)
|
||||
}
|
||||
|
||||
if (r.info?.credential_warning) {
|
||||
sys(`warning: ${r.info.credential_warning}`)
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
sys(msg)
|
||||
}
|
||||
|
|
@ -727,20 +738,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
// ── Paste pipeline ───────────────────────────────────────────────
|
||||
|
||||
const listPasteIds = useCallback((text: string) => {
|
||||
const ids = new Set<number>()
|
||||
|
||||
for (const m of text.matchAll(PASTE_TOKEN_RE)) {
|
||||
const id = parseInt(m[1] ?? '-1', 10)
|
||||
|
||||
if (id > 0) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}, [])
|
||||
|
||||
const resolvePasteTokens = useCallback(
|
||||
(text: string) => {
|
||||
const byId = new Map(pastes.map(p => [p.id, p]))
|
||||
|
|
@ -792,11 +789,20 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
const paste = useCallback(
|
||||
(quiet = false) =>
|
||||
rpc('clipboard.paste', { session_id: sid }).then((r: any) =>
|
||||
r?.attached
|
||||
? sys(`📎 Image #${r.count} attached from clipboard`)
|
||||
: quiet || sys(r?.message || 'No image found in clipboard')
|
||||
),
|
||||
rpc('clipboard.paste', { session_id: sid }).then((r: any) => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
||||
if (r.attached) {
|
||||
const meta = imageTokenMeta(r)
|
||||
sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
quiet || sys(r.message || 'No image found in clipboard')
|
||||
}),
|
||||
[rpc, sid, sys]
|
||||
)
|
||||
|
||||
|
|
@ -830,7 +836,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
pasteCounterRef.current++
|
||||
const id = pasteCounterRef.current
|
||||
const mode: PasteMode = 'attach'
|
||||
const token = pasteToken(id)
|
||||
const charCount = cleanedText.length
|
||||
const tokenCount = estimateTokensRough(cleanedText)
|
||||
const token = pasteTokenLabel({ charCount, id, lineCount, text: cleanedText, tokenCount })
|
||||
const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : ''
|
||||
const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : ''
|
||||
const insert = `${lead}${token}${tail}`
|
||||
|
|
@ -839,18 +847,19 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
[
|
||||
...prev,
|
||||
{
|
||||
charCount: cleanedText.length,
|
||||
charCount,
|
||||
createdAt: Date.now(),
|
||||
id,
|
||||
kind: classifyPaste(cleanedText),
|
||||
lineCount,
|
||||
mode,
|
||||
text: cleanedText
|
||||
text: cleanedText,
|
||||
tokenCount
|
||||
}
|
||||
].slice(-24)
|
||||
)
|
||||
|
||||
pushActivity(`captured ${lineCount}L paste as ${token} (${mode})`)
|
||||
pushActivity(`captured ${lineCount} lines · ${fmtK(tokenCount)} tok as #${id} (${mode})`)
|
||||
|
||||
return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) }
|
||||
},
|
||||
|
|
@ -898,7 +907,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
.then((r: any) => {
|
||||
if (r?.matched) {
|
||||
if (r.is_image) {
|
||||
pushActivity(`attached image: ${r.name}`)
|
||||
const meta = imageTokenMeta(r)
|
||||
pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
|
||||
} else {
|
||||
pushActivity(`detected file: ${r.name}`)
|
||||
}
|
||||
|
|
@ -1017,7 +1027,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
// ── Dispatch ─────────────────────────────────────────────────────
|
||||
|
||||
const dispatchSubmission = useCallback(
|
||||
(full: string, allowLarge = false) => {
|
||||
(full: string) => {
|
||||
if (!full.trim() || !sid) {
|
||||
return
|
||||
}
|
||||
|
|
@ -1053,19 +1063,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
return
|
||||
}
|
||||
|
||||
const largeIds = listPasteIds(full).filter(id => {
|
||||
const p = pastes.find(x => x.id === id)
|
||||
|
||||
return !!p && (p.charCount >= LARGE_PASTE.chars || p.lineCount >= LARGE_PASTE.lines)
|
||||
})
|
||||
|
||||
if (!allowLarge && largeIds.length) {
|
||||
setPasteReview({ largeIds, text: full })
|
||||
setStatus(`review large paste (${largeIds.length})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearInput()
|
||||
|
||||
const editIdx = queueEditRef.current
|
||||
|
|
@ -1108,7 +1105,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
send(full)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appendMessage, busy, enqueue, gw, listPasteIds, pastes, pushHistory, resolvePasteTokens, sid]
|
||||
[appendMessage, busy, enqueue, gw, pushHistory, resolvePasteTokens, sid]
|
||||
)
|
||||
|
||||
// ── Input handling ───────────────────────────────────────────────
|
||||
|
|
@ -1135,18 +1132,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
return
|
||||
}
|
||||
|
||||
if (pasteReview) {
|
||||
if (key.return) {
|
||||
setPasteReview(null)
|
||||
dispatchSubmission(pasteReview.text, true)
|
||||
} else if (key.escape || ctrl(key, ch, 'c')) {
|
||||
setPasteReview(null)
|
||||
setStatus('ready')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl(key, ch, 'c')) {
|
||||
if (clarify) {
|
||||
answerClarify('')
|
||||
|
|
@ -1450,6 +1435,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
break
|
||||
|
||||
case 'gateway.stderr':
|
||||
if (p?.line) {
|
||||
pushActivity(String(p.line).slice(0, 120), 'error')
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'gateway.protocol_error':
|
||||
setStatus('protocol warning')
|
||||
|
||||
|
|
@ -1648,12 +1640,18 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
case 'error':
|
||||
inflightPasteIdsRef.current = []
|
||||
sys(`error: ${p?.message}`)
|
||||
idle()
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
persistedToolLabelsRef.current.clear()
|
||||
|
||||
if (statusTimerRef.current) {
|
||||
clearTimeout(statusTimerRef.current)
|
||||
statusTimerRef.current = null
|
||||
}
|
||||
|
||||
pushActivity(String(p?.message || 'unknown error'), 'error')
|
||||
sys(`error: ${p?.message}`)
|
||||
setStatus('ready')
|
||||
|
||||
break
|
||||
|
|
@ -1687,6 +1685,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
gw.on('event', handler)
|
||||
gw.on('exit', exitHandler)
|
||||
gw.drain()
|
||||
|
||||
return () => {
|
||||
gw.off('event', handler)
|
||||
|
|
@ -2002,7 +2001,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
return
|
||||
}
|
||||
|
||||
sys(`attached image: ${r.name}`)
|
||||
const meta = imageTokenMeta(r)
|
||||
sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`)
|
||||
|
||||
if (r?.remainder) {
|
||||
setInput(r.remainder)
|
||||
|
|
@ -2650,7 +2650,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
if (next && sid) {
|
||||
setQueueEdit(null)
|
||||
dispatchSubmission(next, true)
|
||||
dispatchSubmission(next)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2733,16 +2733,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{pasteReview && (
|
||||
<PromptBox color={theme.color.warn}>
|
||||
<Text bold color={theme.color.warn}>
|
||||
Review large paste before send
|
||||
</Text>
|
||||
<Text color={theme.color.dim}>pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')}</Text>
|
||||
<Text color={theme.color.dim}>Enter to send · Esc/Ctrl+C to cancel</Text>
|
||||
</PromptBox>
|
||||
)}
|
||||
|
||||
{clarify && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import { compactPreview } from '../lib/text.js'
|
||||
import { compactPreview, fmtK } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { PendingPaste } from '../types.js'
|
||||
|
||||
const TOKEN_RE = /\[\[paste:(\d+)\]\]/g
|
||||
const TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g
|
||||
|
||||
const modeLabel = {
|
||||
attach: 'attach',
|
||||
|
|
@ -28,7 +28,8 @@ export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: Pendin
|
|||
<Text color={t.color.amber}>Paste shelf ({pastes.length})</Text>
|
||||
{pastes.slice(-4).map(paste => (
|
||||
<Text color={t.color.dim} key={paste.id}>
|
||||
#{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {paste.kind}
|
||||
#{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '}
|
||||
{fmtK(paste.charCount)} chars · {paste.kind}
|
||||
{inDraft.has(paste.id) ? <Text color={t.color.label}> · in draft</Text> : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.cornsilk}>{compactPreview(paste.text, 44) || '(empty)'}</Text>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ export class GatewayClient extends EventEmitter {
|
|||
private reqId = 0
|
||||
private logs: string[] = []
|
||||
private pending = new Map<string, Pending>()
|
||||
private bufferedEvents: GatewayEvent[] = []
|
||||
private subscribed = false
|
||||
|
||||
start() {
|
||||
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
|
||||
|
|
@ -76,7 +78,13 @@ export class GatewayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
if (msg.method === 'event') {
|
||||
this.emit('event', msg.params as GatewayEvent)
|
||||
const ev = msg.params as GatewayEvent
|
||||
|
||||
if (this.subscribed) {
|
||||
this.emit('event', ev)
|
||||
} else {
|
||||
this.bufferedEvents.push(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +103,15 @@ export class GatewayClient extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
drain() {
|
||||
this.subscribed = true
|
||||
const pending = this.bufferedEvents.splice(0)
|
||||
|
||||
for (const ev of pending) {
|
||||
this.emit('event', ev)
|
||||
}
|
||||
}
|
||||
|
||||
getLogTail(limit = 20): string {
|
||||
return this.logs.slice(-Math.max(1, limit)).join('\n')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,53 @@ export const compactPreview = (s: string, max: number) => {
|
|||
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
||||
}
|
||||
|
||||
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
|
||||
|
||||
export const edgePreview = (s: string, head = 16, tail = 28) => {
|
||||
const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]')
|
||||
|
||||
if (!one) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (one.length <= head + tail + 4) {
|
||||
return one
|
||||
}
|
||||
|
||||
return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
||||
}
|
||||
|
||||
export const pasteTokenLabel = ({
|
||||
charCount,
|
||||
id,
|
||||
lineCount,
|
||||
text,
|
||||
tokenCount
|
||||
}: {
|
||||
charCount: number
|
||||
id: number
|
||||
lineCount: number
|
||||
text: string
|
||||
tokenCount: number
|
||||
}) => {
|
||||
const preview = edgePreview(text)
|
||||
const counts = `[${fmtK(lineCount)} lines · ${fmtK(tokenCount)} tok · ${fmtK(charCount)} chars]`
|
||||
|
||||
if (!preview) {
|
||||
return `[[paste:${id} ${counts}]]`
|
||||
}
|
||||
|
||||
const one = text.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]')
|
||||
|
||||
if (one.length === preview.length) {
|
||||
return `[[paste:${id} ${preview} ${counts}]]`
|
||||
}
|
||||
|
||||
const [head = preview, tail = ''] = preview.split('.. ', 2)
|
||||
|
||||
return `[[paste:${id} ${head.trimEnd()}.. ${counts} .. ${tail.trimStart()}]]`
|
||||
}
|
||||
|
||||
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => {
|
||||
const text = reasoning.replace(/\n/g, ' ').trim()
|
||||
|
||||
|
|
@ -196,4 +243,4 @@ export const userDisplay = (text: string): string => {
|
|||
}
|
||||
|
||||
export const isPasteBackedText = (text: string): boolean =>
|
||||
/\[\[paste:\d+\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text)
|
||||
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export interface PendingPaste {
|
|||
lineCount: number
|
||||
mode: PasteMode
|
||||
text: string
|
||||
tokenCount: number
|
||||
}
|
||||
|
||||
export interface SlashCatalog {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue