chore: update how txt pasting ux feels

This commit is contained in:
Brooklyn Nicholson 2026-04-13 14:49:10 -05:00
parent 9db94e8521
commit 77b97b810a
8 changed files with 239 additions and 83 deletions

View file

@ -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:

View file

@ -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),
},
)

View file

@ -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')

View file

@ -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

View file

@ -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>

View file

@ -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')
}

View file

@ -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)

View file

@ -88,6 +88,7 @@ export interface PendingPaste {
lineCount: number
mode: PasteMode
text: string
tokenCount: number
}
export interface SlashCatalog {