diff --git a/hermes_constants.py b/hermes_constants.py index 40b4da569..4c2b95b42 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f5b3ad73a..e74313178 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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), }, ) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index d43f6d56f..904e44ec2 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -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') diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index fdb7f24e5..80a4ea4a4 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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([]) - const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(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() - - 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 }) { )} - {pasteReview && ( - - - Review large paste before send - - pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')} - Enter to send · Esc/Ctrl+C to cancel - - )} - {clarify && ( Paste shelf ({pastes.length}) {pastes.slice(-4).map(paste => ( - #{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) ? · in draft : ''} {' · '} {compactPreview(paste.text, 44) || '(empty)'} diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index fb26d9b5e..40bd77763 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -22,6 +22,8 @@ export class GatewayClient extends EventEmitter { private reqId = 0 private logs: string[] = [] private pending = new Map() + 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') } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index b38b8fbd2..1841bdd77 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 8f24ba7ac..784b69015 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -88,6 +88,7 @@ export interface PendingPaste { lineCount: number mode: PasteMode text: string + tokenCount: number } export interface SlashCatalog {