mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): stabilize multiline input, persist tool traces, and port CLI-style context status bar
This commit is contained in:
parent
c49bbbe8c2
commit
b66550ed08
9 changed files with 262 additions and 129 deletions
|
|
@ -565,11 +565,18 @@ def _launch_tui():
|
|||
sys.exit(1)
|
||||
print("Installing TUI dependencies…")
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent"],
|
||||
cwd=str(tui_dir), capture_output=True, text=True,
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(tui_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"npm install failed:\n{result.stderr}")
|
||||
err = (result.stderr or "").strip()
|
||||
preview = "\n".join(err.splitlines()[-30:])
|
||||
print("npm install failed.")
|
||||
if preview:
|
||||
print(preview)
|
||||
sys.exit(1)
|
||||
|
||||
tsx = tui_dir / "node_modules" / ".bin" / "tsx"
|
||||
|
|
|
|||
|
|
@ -230,12 +230,21 @@ def _resolve_model() -> str:
|
|||
|
||||
def _get_usage(agent) -> dict:
|
||||
g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
|
||||
return {
|
||||
usage = {
|
||||
"input": g("session_input_tokens", "session_prompt_tokens"),
|
||||
"output": g("session_output_tokens", "session_completion_tokens"),
|
||||
"total": g("session_total_tokens"),
|
||||
"calls": g("session_api_calls"),
|
||||
}
|
||||
comp = getattr(agent, "context_compressor", None)
|
||||
if comp:
|
||||
ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0
|
||||
ctx_max = getattr(comp, "context_length", 0) or 0
|
||||
if ctx_max:
|
||||
usage["context_used"] = ctx_used
|
||||
usage["context_max"] = ctx_max
|
||||
usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100)))
|
||||
return usage
|
||||
|
||||
|
||||
def _session_info(agent) -> dict:
|
||||
|
|
@ -248,6 +257,7 @@ def _session_info(agent) -> dict:
|
|||
"release_date": "",
|
||||
"update_behind": None,
|
||||
"update_command": "",
|
||||
"usage": _get_usage(agent),
|
||||
}
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
|
|
|
|||
122
ui-tui/README.md
122
ui-tui/README.md
|
|
@ -84,31 +84,31 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an
|
|||
|
||||
### Main chat input
|
||||
|
||||
| Key | Behavior |
|
||||
|---|---|
|
||||
| `Enter` | Submit the current draft |
|
||||
| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message |
|
||||
| `\` + `Enter` | Append the line to the multiline buffer instead of sending |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Ctrl+G` | Open `$EDITOR` with the current draft |
|
||||
| `Ctrl+L` | New session (same as `/clear`) |
|
||||
| `Ctrl+V` | Paste clipboard image (same as `/paste`) |
|
||||
| `Esc` | Clear the current draft |
|
||||
| `Tab` | Apply the active completion |
|
||||
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
|
||||
| `Left/Right` | Move the cursor |
|
||||
| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key |
|
||||
| `Home` / `Ctrl+A` | Start of line |
|
||||
| `End` / `Ctrl+E` | End of line |
|
||||
| `Backspace` / `Delete` | Delete the character to the left of the cursor |
|
||||
| modified `Backspace` / `Delete` | Delete the previous word |
|
||||
| `Ctrl+W` | Delete the previous word |
|
||||
| `Ctrl+U` | Delete from the cursor back to the start of the line |
|
||||
| `Ctrl+K` | Delete from the cursor to the end of the line |
|
||||
| `Meta+B` / `Meta+F` | Move by word |
|
||||
| `!cmd` | Run a shell command through the gateway |
|
||||
| `{!cmd}` | Inline shell interpolation before send or queue |
|
||||
| Key | Behavior |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Enter` | Submit the current draft |
|
||||
| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message |
|
||||
| `Shift+Enter` / `Alt+Enter` | Insert a newline in the current draft |
|
||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Ctrl+G` | Open `$EDITOR` with the current draft |
|
||||
| `Ctrl+L` | New session (same as `/clear`) |
|
||||
| `Ctrl+V` | Paste clipboard image (same as `/paste`) |
|
||||
| `Tab` | Apply the active completion |
|
||||
| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history |
|
||||
| `Left/Right` | Move the cursor |
|
||||
| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key |
|
||||
| `Home` / `Ctrl+A` | Start of line |
|
||||
| `End` / `Ctrl+E` | End of line |
|
||||
| `Backspace` / `Delete` | Delete the character to the left of the cursor |
|
||||
| modified `Backspace` / `Delete` | Delete the previous word |
|
||||
| `Ctrl+W` | Delete the previous word |
|
||||
| `Ctrl+U` | Delete from the cursor back to the start of the line |
|
||||
| `Ctrl+K` | Delete from the cursor to the end of the line |
|
||||
| `Meta+B` / `Meta+F` | Move by word |
|
||||
| `!cmd` | Run a shell command through the gateway |
|
||||
| `{!cmd}` | Inline shell interpolation before send or queue |
|
||||
|
||||
Notes:
|
||||
|
||||
|
|
@ -118,20 +118,20 @@ Notes:
|
|||
|
||||
### Prompt and picker modes
|
||||
|
||||
| Context | Keys | Behavior |
|
||||
|---|---|---|
|
||||
| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice |
|
||||
| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` |
|
||||
| approval prompt | `Esc`, `Ctrl+C` | Deny |
|
||||
| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice |
|
||||
| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice |
|
||||
| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry |
|
||||
| clarify free-text mode | `Enter` | Submit typed answer |
|
||||
| sudo / secret prompt | `Enter` | Submit typed value |
|
||||
| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response |
|
||||
| resume picker | `Up/Down`, `Enter` | Move and resume the selected session |
|
||||
| resume picker | `1-9` | Quick-pick one of the first nine visible sessions |
|
||||
| resume picker | `Esc`, `Ctrl+C` | Close the picker |
|
||||
| Context | Keys | Behavior |
|
||||
| --------------------------- | ------------------- | ------------------------------------------------- |
|
||||
| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice |
|
||||
| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` |
|
||||
| approval prompt | `Esc`, `Ctrl+C` | Deny |
|
||||
| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice |
|
||||
| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice |
|
||||
| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry |
|
||||
| clarify free-text mode | `Enter` | Submit typed answer |
|
||||
| sudo / secret prompt | `Enter` | Submit typed value |
|
||||
| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response |
|
||||
| resume picker | `Up/Down`, `Enter` | Move and resume the selected session |
|
||||
| resume picker | `1-9` | Quick-pick one of the first nine visible sessions |
|
||||
| resume picker | `Esc`, `Ctrl+C` | Close the picker |
|
||||
|
||||
Notes:
|
||||
|
||||
|
|
@ -213,28 +213,28 @@ That lets Python own aliases, plugins, skills, and registry-backed commands with
|
|||
|
||||
Primary event types the client handles today:
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `gateway.ready` | `{ skin? }` |
|
||||
| `session.info` | session metadata for banner + tool/skill panels |
|
||||
| `message.start` | start assistant streaming |
|
||||
| `message.delta` | `{ text, rendered? }` |
|
||||
| `message.complete` | `{ text, rendered?, usage, status }` |
|
||||
| `thinking.delta` | `{ text }` |
|
||||
| `reasoning.delta` | `{ text }` |
|
||||
| `status.update` | `{ kind, text }` |
|
||||
| `tool.start` | `{ tool_id, name, context? }` |
|
||||
| `tool.progress` | `{ name, preview }` |
|
||||
| `tool.complete` | `{ tool_id, name }` |
|
||||
| `clarify.request` | `{ question, choices?, request_id }` |
|
||||
| `approval.request` | `{ command, description }` |
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `btw.complete` | `{ text }` |
|
||||
| `error` | `{ message }` |
|
||||
| `gateway.stderr` | synthesized from child stderr |
|
||||
| `gateway.protocol_error` | synthesized from malformed stdout |
|
||||
| Event | Payload |
|
||||
| ------------------------ | ----------------------------------------------- |
|
||||
| `gateway.ready` | `{ skin? }` |
|
||||
| `session.info` | session metadata for banner + tool/skill panels |
|
||||
| `message.start` | start assistant streaming |
|
||||
| `message.delta` | `{ text, rendered? }` |
|
||||
| `message.complete` | `{ text, rendered?, usage, status }` |
|
||||
| `thinking.delta` | `{ text }` |
|
||||
| `reasoning.delta` | `{ text }` |
|
||||
| `status.update` | `{ kind, text }` |
|
||||
| `tool.start` | `{ tool_id, name, context? }` |
|
||||
| `tool.progress` | `{ name, preview }` |
|
||||
| `tool.complete` | `{ tool_id, name }` |
|
||||
| `clarify.request` | `{ question, choices?, request_id }` |
|
||||
| `approval.request` | `{ command, description }` |
|
||||
| `sudo.request` | `{ request_id }` |
|
||||
| `secret.request` | `{ prompt, env_var, request_id }` |
|
||||
| `background.complete` | `{ task_id, text }` |
|
||||
| `btw.complete` | `{ text }` |
|
||||
| `error` | `{ message }` |
|
||||
| `gateway.stderr` | synthesized from child stderr |
|
||||
| `gateway.protocol_error` | synthesized from malformed stdout |
|
||||
|
||||
## Theme model
|
||||
|
||||
|
|
|
|||
|
|
@ -104,30 +104,83 @@ const stripTokens = (text: string, re: RegExp) =>
|
|||
|
||||
// ── StatusRule ────────────────────────────────────────────────────────
|
||||
|
||||
function ctxBarColor(pct: number | undefined, t: Theme) {
|
||||
if (pct == null) {
|
||||
return t.color.dim
|
||||
}
|
||||
|
||||
if (pct >= 95) {
|
||||
return t.color.statusCritical
|
||||
}
|
||||
|
||||
if (pct > 80) {
|
||||
return t.color.statusBad
|
||||
}
|
||||
|
||||
if (pct >= 50) {
|
||||
return t.color.statusWarn
|
||||
}
|
||||
|
||||
return t.color.statusGood
|
||||
}
|
||||
|
||||
function ctxBar(pct: number | undefined, w = 10) {
|
||||
const p = Math.max(0, Math.min(100, pct ?? 0))
|
||||
const filled = Math.round((p / 100) * w)
|
||||
|
||||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
function StatusRule({
|
||||
cols,
|
||||
color,
|
||||
dimColor,
|
||||
status,
|
||||
statusColor,
|
||||
parts
|
||||
model,
|
||||
usage,
|
||||
bgCount,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
color: string
|
||||
dimColor: string
|
||||
status: string
|
||||
statusColor: string
|
||||
parts: (string | false | undefined | null)[]
|
||||
model: string
|
||||
usage: Usage
|
||||
bgCount: number
|
||||
t: Theme
|
||||
}) {
|
||||
const label = parts.filter(Boolean).join(' · ')
|
||||
const lead = String(parts[0] ?? '')
|
||||
const pct = usage.context_percent
|
||||
const barColor = ctxBarColor(pct, t)
|
||||
|
||||
const ctxLabel = usage.context_max
|
||||
? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
|
||||
: usage.total > 0
|
||||
? `${fmtK(usage.total)} tok`
|
||||
: ''
|
||||
|
||||
const pctLabel = pct != null ? `${pct}%` : ''
|
||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||
|
||||
const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
|
||||
const inner = segs.join(' │ ')
|
||||
const pad = Math.max(0, cols - inner.length - 5)
|
||||
|
||||
return (
|
||||
<Text color={color}>
|
||||
<Text color={t.color.bronze}>
|
||||
{'─ '}
|
||||
<Text color={dimColor}>
|
||||
<Text color={statusColor}>{parts[0]}</Text>
|
||||
{label.slice(lead.length)}
|
||||
</Text>
|
||||
{' ' + '─'.repeat(Math.max(0, cols - label.length - 5))}
|
||||
<Text color={statusColor}>{status}</Text>
|
||||
<Text color={t.color.dim}> │ {model}</Text>
|
||||
{ctxLabel ? <Text color={t.color.dim}> │ {ctxLabel}</Text> : null}
|
||||
{bar ? (
|
||||
<Text color={t.color.dim}>
|
||||
{' │ '}
|
||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{' ' + '─'.repeat(pad)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
|
@ -186,7 +239,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const [secret, setSecret] = useState<SecretReq | null>(null)
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [reasoning, setReasoning] = useState('')
|
||||
const [thinkingText, setThinkingText] = useState('')
|
||||
const [statusBar, setStatusBar] = useState(true)
|
||||
const [lastUserMsg, setLastUserMsg] = useState('')
|
||||
const [pastes, setPastes] = useState<PendingPaste[]>([])
|
||||
|
|
@ -201,13 +253,16 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const buf = useRef('')
|
||||
const inflightPasteIdsRef = useRef<number[]>([])
|
||||
const interruptedRef = useRef(false)
|
||||
const reasoningRef = useRef('')
|
||||
const slashRef = useRef<(cmd: string) => boolean>(() => false)
|
||||
const lastEmptyAt = useRef(0)
|
||||
const lastStatusNoteRef = useRef('')
|
||||
const protocolWarnedRef = useRef(false)
|
||||
const pasteCounterRef = useRef(0)
|
||||
const colsRef = useRef(cols)
|
||||
const turnToolsRef = useRef<string[]>([])
|
||||
colsRef.current = cols
|
||||
reasoningRef.current = reasoning
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -275,15 +330,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const idle = () => {
|
||||
setThinking(false)
|
||||
setTools([])
|
||||
setActivity([])
|
||||
setBusy(false)
|
||||
setClarify(null)
|
||||
setApproval(null)
|
||||
setPasteReview(null)
|
||||
setSudo(null)
|
||||
setSecret(null)
|
||||
setReasoning('')
|
||||
setThinkingText('')
|
||||
setStreaming('')
|
||||
buf.current = ''
|
||||
}
|
||||
|
|
@ -330,6 +382,11 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
if (r.info) {
|
||||
setInfo(r.info)
|
||||
|
||||
if (r.info.usage) {
|
||||
setUsage(prev => ({ ...prev, ...r.info.usage }))
|
||||
}
|
||||
|
||||
appendHistory(introMsg(r.info))
|
||||
} else {
|
||||
setInfo(null)
|
||||
|
|
@ -766,6 +823,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
idle()
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
setStatus('interrupted')
|
||||
setTimeout(() => setStatus('ready'), 1500)
|
||||
} else if (input || inputBuf.length) {
|
||||
|
|
@ -797,10 +857,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
if (key.ctrl && ch === 'g') {
|
||||
return openEditor()
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
clearIn()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Gateway events ───────────────────────────────────────────────
|
||||
|
|
@ -839,13 +895,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
case 'session.info':
|
||||
setInfo(p as SessionInfo)
|
||||
|
||||
if (p?.usage) {
|
||||
setUsage(prev => ({ ...prev, ...p.usage }))
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'thinking.delta':
|
||||
if (p?.text) {
|
||||
setThinkingText(prev => prev + p.text)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'message.start':
|
||||
|
|
@ -853,7 +909,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setTurnKey(k => k + 1)
|
||||
setBusy(true)
|
||||
setReasoning('')
|
||||
setThinkingText('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
|
||||
break
|
||||
|
||||
|
|
@ -913,7 +970,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const done = prev.find(t => t.id === p.tool_id)
|
||||
const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name
|
||||
const ctx = (p.error as string) || done?.context || ''
|
||||
pushActivity(`${label}${ctx ? ': ' + ctx : ''} ${mark}`, p.error ? 'error' : 'info')
|
||||
const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}`
|
||||
pushActivity(line, p.error ? 'error' : 'info')
|
||||
turnToolsRef.current = [...turnToolsRef.current, line].slice(-8)
|
||||
|
||||
return prev.filter(t => t.id !== p.tool_id)
|
||||
})
|
||||
|
|
@ -976,7 +1035,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
break
|
||||
case 'message.complete': {
|
||||
const wasInterrupted = interruptedRef.current
|
||||
const savedReasoning = reasoningRef.current.trim()
|
||||
const savedTools = [...turnToolsRef.current]
|
||||
idle()
|
||||
setReasoning('')
|
||||
setStreaming('')
|
||||
|
||||
if (inflightPasteIdsRef.current.length) {
|
||||
|
|
@ -985,9 +1047,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
if (!wasInterrupted) {
|
||||
appendMessage({ role: 'assistant', text: (p?.rendered ?? p?.text ?? buf.current).trimStart() })
|
||||
appendMessage({
|
||||
role: 'assistant',
|
||||
text: (p?.rendered ?? p?.text ?? buf.current).trimStart(),
|
||||
thinking: savedReasoning || undefined,
|
||||
tools: savedTools.length ? savedTools : undefined
|
||||
})
|
||||
}
|
||||
|
||||
turnToolsRef.current = []
|
||||
setActivity([])
|
||||
|
||||
buf.current = ''
|
||||
setStatus('ready')
|
||||
|
||||
|
|
@ -1012,6 +1082,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
inflightPasteIdsRef.current = []
|
||||
sys(`error: ${p?.message}`)
|
||||
idle()
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
setStatus('ready')
|
||||
|
||||
break
|
||||
|
|
@ -1498,6 +1571,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
|
||||
idle()
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
setStatus('interrupted')
|
||||
setTimeout(() => setStatus('ready'), 1500)
|
||||
|
||||
|
|
@ -1577,7 +1653,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
<Thinking key={turnKey} reasoning={reasoning} t={theme} tools={tools} />
|
||||
)}
|
||||
|
||||
<ActivityLane items={activity} t={theme} />
|
||||
{busy && <ActivityLane items={activity} t={theme} />}
|
||||
|
||||
{pasteReview && (
|
||||
<PromptBox color={theme.color.warn}>
|
||||
|
|
@ -1663,6 +1739,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setSid(r.session_id)
|
||||
setInfo(r.info ?? null)
|
||||
|
||||
if (r.info?.usage) {
|
||||
setUsage(prev => ({ ...prev, ...r.info.usage }))
|
||||
}
|
||||
|
||||
if (r.info) {
|
||||
appendHistory(introMsg(r.info))
|
||||
}
|
||||
|
|
@ -1692,43 +1772,43 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
{statusBar && (
|
||||
<StatusRule
|
||||
color={theme.color.bronze}
|
||||
bgCount={bgTasks.size}
|
||||
cols={cols}
|
||||
dimColor={theme.color.dim}
|
||||
parts={[
|
||||
status,
|
||||
sid,
|
||||
info?.model?.split('/').pop(),
|
||||
bgTasks.size > 0 && `${bgTasks.size} bg`,
|
||||
usage.total > 0 && `${fmtK(usage.total)} tok`
|
||||
]}
|
||||
model={info?.model?.split('/').pop() ?? ''}
|
||||
status={status}
|
||||
statusColor={statusColor}
|
||||
t={theme}
|
||||
usage={usage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isBlocked && (
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={theme.color.gold}>
|
||||
{inputBuf.length ? '… ' : `${theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
{inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={3}>
|
||||
<Text color={theme.color.dim}>{i === 0 ? `${theme.brand.prompt} ` : ' '}</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
onChange={setInput}
|
||||
onPaste={handleTextPaste}
|
||||
onSubmit={submit}
|
||||
placeholder={
|
||||
empty
|
||||
? PLACEHOLDER
|
||||
: busy
|
||||
? 'Ctrl+C to interrupt…'
|
||||
: inputBuf.length
|
||||
? 'continue (or Enter to send)'
|
||||
: ''
|
||||
}
|
||||
value={input}
|
||||
/>
|
||||
<Text color={theme.color.cornsilk}>{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text bold color={theme.color.gold}>
|
||||
{inputBuf.length ? ' ' : `${theme.brand.prompt} `}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<TextInput
|
||||
onChange={setInput}
|
||||
onPaste={handleTextPaste}
|
||||
onSubmit={submit}
|
||||
placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={input}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ export const MessageLine = memo(function MessageLine({
|
|||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={msg.role === 'user' ? 1 : 0}>
|
||||
{msg.thinking && (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Box flexShrink={0} width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
|
|
@ -62,6 +68,20 @@ export const MessageLine = memo(function MessageLine({
|
|||
|
||||
<Box width={Math.max(20, cols - 5)}>{content}</Box>
|
||||
</Box>
|
||||
|
||||
{!!msg.tools?.length && (
|
||||
<Box flexDirection="column">
|
||||
{msg.tools.map((tool, i) => (
|
||||
<Text
|
||||
color={tool.endsWith(' ✗') ? t.color.error : t.color.dim}
|
||||
dimColor={!tool.endsWith(' ✗')}
|
||||
key={`${tool}-${i}`}
|
||||
>
|
||||
{t.brand.tool} {tool}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -117,7 +117,11 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
|
|||
}
|
||||
|
||||
if (k.return) {
|
||||
onSubmit?.(value)
|
||||
if (k.shift || k.meta) {
|
||||
commit(value.slice(0, cur) + '\n' + value.slice(cur), cur + 1)
|
||||
} else {
|
||||
onSubmit?.(value)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -163,6 +167,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
|
|||
return
|
||||
}
|
||||
|
||||
if (raw === '\n') {
|
||||
commit(v.slice(0, c) + '\n' + v.slice(c), c + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (raw.length > 1 || raw.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = c
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const Thinking = memo(function Thinking({
|
|||
const verb = VERBS[tick % VERBS.length] ?? 'thinking'
|
||||
const face = FACES[tick % FACES.length] ?? '(•_•)'
|
||||
const tail = reasoning.slice(-160).replace(/\n/g, ' ')
|
||||
const hasReasoning = !!tail
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -54,7 +55,7 @@ export const Thinking = memo(function Thinking({
|
|||
</Text>
|
||||
))}
|
||||
|
||||
{!tools.length && (
|
||||
{!tools.length && !hasReasoning && (
|
||||
<Text color={t.color.dim}>
|
||||
<Spinner color={t.color.dim} /> {face} {verb}…
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ export const HOTKEYS: [string, string][] = [
|
|||
['Ctrl+V', 'paste clipboard image'],
|
||||
['Tab', 'apply completion'],
|
||||
['↑/↓', 'completions / queue edit / history'],
|
||||
['Esc', 'clear input'],
|
||||
['Ctrl+A/E', 'home / end of line'],
|
||||
['Ctrl+W', 'delete word'],
|
||||
['Ctrl+U/K', 'delete to start / end'],
|
||||
['Ctrl+←/→', 'jump word'],
|
||||
['Home/End', 'start / end of line'],
|
||||
['\\+Enter', 'multi-line continuation'],
|
||||
['Shift+Enter / Alt+Enter', 'insert newline'],
|
||||
['\\+Enter', 'multi-line continuation (fallback)'],
|
||||
['!cmd', 'run shell command'],
|
||||
['{!cmd}', 'interpolate shell output inline']
|
||||
]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface Msg {
|
|||
text: string
|
||||
kind?: 'intro'
|
||||
info?: SessionInfo
|
||||
thinking?: string
|
||||
tools?: string[]
|
||||
}
|
||||
|
||||
export type Role = 'assistant' | 'system' | 'tool' | 'user'
|
||||
|
|
@ -43,6 +45,9 @@ export interface SessionInfo {
|
|||
|
||||
export interface Usage {
|
||||
calls: number
|
||||
context_max?: number
|
||||
context_percent?: number
|
||||
context_used?: number
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue