full IDs, rename, info messages, collapsible tool calls

- Show full session IDs everywhere
- Double-click to rename sessions (sidebar + chat header)
- Parse :: info messages, render italic/faint
- Collapsible tool calls with disclosure triangle
- Pretty-print all tool JSON, show tool name/language detail
- Separate tool output from args
- Fix tool parsing for multi-line content with trailing output
This commit is contained in:
Levi Neely 2026-04-24 21:33:24 +02:00
parent 0dc8337365
commit ab08ad01c4
6 changed files with 159 additions and 24 deletions

View File

@ -12,6 +12,7 @@ import {
createSession, createSession,
killSession, killSession,
browse9p, browse9p,
renameSession,
} from './lib/api' } from './lib/api'
import type { IdxEntry } from './lib/api' import type { IdxEntry } from './lib/api'
import { parseChat } from './lib/chat' import { parseChat } from './lib/chat'
@ -122,6 +123,13 @@ export function App() {
setSessions(s) setSessions(s)
}, [selectedId]) }, [selectedId])
const handleRename = useCallback(async (id: string, newName: string) => {
await renameSession(id, newName)
if (selectedId === id) setSelectedId(newName)
const s = await listSessions()
setSessions(s)
}, [selectedId])
return ( return (
<div class="layout"> <div class="layout">
<Sidebar <Sidebar
@ -130,6 +138,7 @@ export function App() {
onSelect={handleSelect} onSelect={handleSelect}
onNew={() => setShowNew(true)} onNew={() => setShowNew(true)}
onKill={handleKill} onKill={handleKill}
onRename={handleRename}
themes={getThemes()} themes={getThemes()}
currentTheme={themeName} currentTheme={themeName}
onThemeChange={handleSetTheme} onThemeChange={handleSetTheme}
@ -144,6 +153,7 @@ export function App() {
browseData={browseData} browseData={browseData}
onBrowse={handleBrowse} onBrowse={handleBrowse}
onSelectSession={handleSelect} onSelectSession={handleSelect}
onRename={handleRename}
/> />
{showNew && ( {showNew && (
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} /> <NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />

View File

@ -17,6 +17,7 @@ interface Props {
browseData: any browseData: any
onBrowse: (path: string) => void onBrowse: (path: string) => void
onSelectSession: (id: string) => void onSelectSession: (id: string) => void
onRename: (id: string, newName: string) => void
} }
function stateClass(state: string) { function stateClass(state: string) {
@ -25,7 +26,7 @@ function stateClass(state: string) {
return 'running' return 'running'
} }
export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, browseData, onBrowse, onSelectSession }: Props) { export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, browseData, onBrowse, onSelectSession, onRename }: Props) {
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const [atBottom, setAtBottom] = useState(true) const [atBottom, setAtBottom] = useState(true)
@ -55,15 +56,35 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
) )
} }
const sid = shortId(session.id) const sid = session.id
const state = cfg.state ?? 'stopped' const state = cfg.state ?? 'stopped'
const [renaming, setRenaming] = useState(false)
const [renameVal, setRenameVal] = useState('')
const startRename = () => { setRenaming(true); setRenameVal(sid) }
const commitRename = () => {
setRenaming(false)
const name = renameVal.trim()
if (name && name !== sid) onRename(sid, name)
}
return ( return (
<main class="main"> <main class="main">
<header class="chat-header"> <header class="chat-header">
<div class="chat-path"> <div class="chat-path">
<span class="fs-path-link" onClick={() => onBrowse('/mnt/s/')}>↑</span>{' '} <span class="fs-path-link" onClick={() => onBrowse('/mnt/s/')}>↑</span>{' '}
/s/<span class="path-hi">{sid}</span>/chat /s/{renaming ? (
<input
class="session-rename-input"
value={renameVal}
onInput={e => setRenameVal((e.target as HTMLInputElement).value)}
onBlur={() => commitRename()}
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenaming(false) }}
autoFocus
/>
) : (
<span class="path-hi" onDblClick={startRename}>{sid}</span>
)}/chat
</div> </div>
<div class="header-meta"> <div class="header-meta">
<div class={`state-dot ${stateClass(state)}`} style="margin-right: 2px;" /> <div class={`state-dot ${stateClass(state)}`} style="margin-right: 2px;" />
@ -87,6 +108,8 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
<div class="user-text"><span class="msg-prefix">u: </span>{msg.content}</div> <div class="user-text"><span class="msg-prefix">u: </span>{msg.content}</div>
) : msg.role === 'tool' ? ( ) : msg.role === 'tool' ? (
<ToolCall content={msg.content} /> <ToolCall content={msg.content} />
) : msg.role === 'info' ? (
<div class="info-text">{msg.content}</div>
) : ( ) : (
<div <div
class="md" class="md"
@ -111,31 +134,37 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
) )
} }
function formatToolContent(raw: string): { name: string; body: string } { function formatToolContent(raw: string): { name: string; detail: string; body: string; output: string } {
const m = raw.match(/^(\w+)\(([\s\S]*)\)\s*$/) const m = raw.match(/^(\w+)\((\{[\s\S]*\})\)(.*)$/s)
if (!m) return { name: '', body: raw } if (!m) return { name: '', detail: '', body: raw, output: '' }
const name = m[1]
const output = m[3].trim()
try { try {
const obj = JSON.parse(m[2]) const obj = JSON.parse(m[2])
// For execute_code, extract just the code/tool from steps // Extract a useful detail: tool name or language
if (m[1] === 'execute_code' && obj.steps) { let detail = ''
const lines = obj.steps.map((s: any) => s.code ?? s.tool ?? JSON.stringify(s)).join('\n') if (obj.steps?.[0]?.tool) detail = obj.steps[0].tool
return { name: m[1], body: lines } else if (obj.steps?.[0]?.code) detail = obj.language ?? 'bash'
} return { name, detail, body: JSON.stringify(obj, null, 2), output }
return { name: m[1], body: JSON.stringify(obj, null, 2) }
} catch { } catch {
return { name: m[1], body: m[2] } return { name, detail: '', body: m[2], output }
} }
} }
function ToolCall({ content }: { content: string }) { function ToolCall({ content }: { content: string }) {
const { name, body } = formatToolContent(content) const { name, detail, body, output } = formatToolContent(content)
const [open, setOpen] = useState(false)
return ( return (
<div class="tool-call"> <div class="tool-call">
<div class="tool-header"> <details open={open} onToggle={(e: Event) => setOpen((e.target as HTMLDetailsElement).open)}>
<span class="tool-arrow">🖥️</span> <summary class="tool-header">
{name && <span class="tool-name">{name}</span>} <span class="tool-arrow">🖥️</span>
</div> {name && <span class="tool-name">{name}</span>}
<pre class="tool-body">{body}</pre> {detail && <span class="tool-detail">{detail}</span>}
</summary>
<pre class="tool-body">{body}</pre>
</details>
{output && <pre class="tool-output">{output}</pre>}
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
import { useState } from 'preact/hooks'
import type { IdxEntry } from '../lib/api' import type { IdxEntry } from '../lib/api'
import type { Theme } from '../lib/theme' import type { Theme } from '../lib/theme'
import { shortId, truncate } from '../lib/chat' import { truncate } from '../lib/chat'
interface Props { interface Props {
sessions: IdxEntry[] sessions: IdxEntry[]
@ -8,6 +9,7 @@ interface Props {
onSelect: (id: string | null) => void onSelect: (id: string | null) => void
onNew: () => void onNew: () => void
onKill: (id: string) => void onKill: (id: string) => void
onRename: (id: string, newName: string) => void
themes: Theme[] themes: Theme[]
currentTheme: string currentTheme: string
onThemeChange: (name: string) => void onThemeChange: (name: string) => void
@ -19,7 +21,21 @@ function stateClass(state: string) {
return 'running' // thinking, calling: <tool>, etc. return 'running' // thinking, calling: <tool>, etc.
} }
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes, currentTheme, onThemeChange }: Props) { export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, onRename, themes, currentTheme, onThemeChange }: Props) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const startRename = (e: MouseEvent, id: string) => {
e.stopPropagation()
setEditingId(id)
setEditValue(id)
}
const commitRename = (oldId: string) => {
const name = editValue.trim()
setEditingId(null)
if (name && name !== oldId) onRename(oldId, name)
}
return ( return (
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
@ -49,7 +65,19 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes,
> >
<div class="session-card-row"> <div class="session-card-row">
<div class={`state-dot ${stateClass(s.state)}`} /> <div class={`state-dot ${stateClass(s.state)}`} />
<div class="session-id-label">{shortId(s.id)}</div> {editingId === s.id ? (
<input
class="session-rename-input"
value={editValue}
onInput={e => setEditValue((e.target as HTMLInputElement).value)}
onBlur={() => commitRename(s.id)}
onKeyDown={e => { if (e.key === 'Enter') commitRename(s.id); if (e.key === 'Escape') setEditingId(null) }}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<div class="session-id-label" onDblClick={(e: MouseEvent) => startRename(e, s.id)}>{s.id}</div>
)}
<div class="session-state-label">{s.state}</div> <div class="session-state-label">{s.state}</div>
<button <button
class="btn-kill" class="btn-kill"

View File

@ -78,3 +78,11 @@ export async function browse9p(path: string): Promise<any> {
if (ct.includes('text/plain')) return await r.text() if (ct.includes('text/plain')) return await r.text()
return await r.json() return await r.json()
} }
export async function renameSession(id: string, newName: string): Promise<void> {
await fetch(`/s/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
})
}

View File

@ -1,5 +1,5 @@
export interface ChatMessage { export interface ChatMessage {
role: 'user' | 'assistant' | 'tool' role: 'user' | 'assistant' | 'tool' | 'info'
content: string content: string
} }
@ -20,6 +20,9 @@ export function parseChat(lines: string[]): ChatMessage[] {
} else if (line.startsWith('-> ')) { } else if (line.startsWith('-> ')) {
if (cur) messages.push(cur) if (cur) messages.push(cur)
cur = { role: 'tool', content: line.slice(3) } cur = { role: 'tool', content: line.slice(3) }
} else if (line.startsWith(':: ')) {
if (cur) messages.push(cur)
cur = { role: 'info', content: line.slice(3) }
} else if (cur) { } else if (cur) {
cur.content += '\n' + line cur.content += '\n' + line
} }

View File

@ -580,7 +580,20 @@ input, select, textarea { font-family: inherit; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 4px 10px;
cursor: pointer;
list-style: none;
}
.tool-header::-webkit-details-marker { display: none; }
.tool-header::marker { display: none; content: ''; }
.tool-header::before {
content: '▶';
font-size: 8px;
color: var(--text-faint);
transition: transform 0.15s;
}
details[open] > .tool-header::before {
transform: rotate(90deg);
} }
.tool-arrow { .tool-arrow {
font-size: 1.3em; font-size: 1.3em;
@ -590,6 +603,11 @@ input, select, textarea { font-family: inherit; }
color: var(--tool-text); color: var(--tool-text);
font-weight: 600; font-weight: 600;
} }
.tool-detail {
color: var(--text-faint);
font-size: 11px;
font-style: italic;
}
.tool-body { .tool-body {
margin: 0; margin: 0;
padding: 8px 14px; padding: 8px 14px;
@ -601,6 +619,17 @@ input, select, textarea { font-family: inherit; }
color: var(--text); color: var(--text);
overflow-x: auto; overflow-x: auto;
} }
.tool-output {
margin: 0;
padding: 6px 14px;
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-dim);
font-style: italic;
}
/* ---- Typing indicator ---- */ /* ---- Typing indicator ---- */
.typing { .typing {
@ -850,3 +879,31 @@ input, select, textarea { font-family: inherit; }
} }
.tree-row-link { cursor: pointer; } .tree-row-link { cursor: pointer; }
.tree-row-link:hover .tree-path { text-decoration: underline; } .tree-row-link:hover .tree-path { text-decoration: underline; }
/* ---- Inline rename ---- */
.session-rename-input {
font-family: var(--mono);
font-size: 12px;
background: var(--bg-soft);
border: 1px solid var(--accent-focus);
border-radius: var(--r-sm);
padding: 2px 6px;
color: var(--text);
outline: none;
flex: 1;
min-width: 0;
}
/* ---- Info messages ---- */
.message.info { justify-content: flex-start; }
.message.info .msg-bubble {
background: transparent;
border: none;
padding: 2px 8px;
}
.info-text {
font-family: var(--mono);
font-size: 12px;
font-style: italic;
color: var(--text-faint);
}