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:
parent
0dc8337365
commit
ab08ad01c4
10
src/app.tsx
10
src/app.tsx
|
|
@ -12,6 +12,7 @@ import {
|
|||
createSession,
|
||||
killSession,
|
||||
browse9p,
|
||||
renameSession,
|
||||
} from './lib/api'
|
||||
import type { IdxEntry } from './lib/api'
|
||||
import { parseChat } from './lib/chat'
|
||||
|
|
@ -122,6 +123,13 @@ export function App() {
|
|||
setSessions(s)
|
||||
}, [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 (
|
||||
<div class="layout">
|
||||
<Sidebar
|
||||
|
|
@ -130,6 +138,7 @@ export function App() {
|
|||
onSelect={handleSelect}
|
||||
onNew={() => setShowNew(true)}
|
||||
onKill={handleKill}
|
||||
onRename={handleRename}
|
||||
themes={getThemes()}
|
||||
currentTheme={themeName}
|
||||
onThemeChange={handleSetTheme}
|
||||
|
|
@ -144,6 +153,7 @@ export function App() {
|
|||
browseData={browseData}
|
||||
onBrowse={handleBrowse}
|
||||
onSelectSession={handleSelect}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
{showNew && (
|
||||
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface Props {
|
|||
browseData: any
|
||||
onBrowse: (path: string) => void
|
||||
onSelectSession: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
}
|
||||
|
||||
function stateClass(state: string) {
|
||||
|
|
@ -25,7 +26,7 @@ function stateClass(state: string) {
|
|||
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 [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 [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 (
|
||||
<main class="main">
|
||||
<header class="chat-header">
|
||||
<div class="chat-path">
|
||||
<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 class="header-meta">
|
||||
<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>
|
||||
) : msg.role === 'tool' ? (
|
||||
<ToolCall content={msg.content} />
|
||||
) : msg.role === 'info' ? (
|
||||
<div class="info-text">{msg.content}</div>
|
||||
) : (
|
||||
<div
|
||||
class="md"
|
||||
|
|
@ -111,31 +134,37 @@ export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, b
|
|||
)
|
||||
}
|
||||
|
||||
function formatToolContent(raw: string): { name: string; body: string } {
|
||||
const m = raw.match(/^(\w+)\(([\s\S]*)\)\s*$/)
|
||||
if (!m) return { name: '', body: raw }
|
||||
function formatToolContent(raw: string): { name: string; detail: string; body: string; output: string } {
|
||||
const m = raw.match(/^(\w+)\((\{[\s\S]*\})\)(.*)$/s)
|
||||
if (!m) return { name: '', detail: '', body: raw, output: '' }
|
||||
const name = m[1]
|
||||
const output = m[3].trim()
|
||||
try {
|
||||
const obj = JSON.parse(m[2])
|
||||
// For execute_code, extract just the code/tool from steps
|
||||
if (m[1] === 'execute_code' && obj.steps) {
|
||||
const lines = obj.steps.map((s: any) => s.code ?? s.tool ?? JSON.stringify(s)).join('\n')
|
||||
return { name: m[1], body: lines }
|
||||
}
|
||||
return { name: m[1], body: JSON.stringify(obj, null, 2) }
|
||||
// Extract a useful detail: tool name or language
|
||||
let detail = ''
|
||||
if (obj.steps?.[0]?.tool) detail = obj.steps[0].tool
|
||||
else if (obj.steps?.[0]?.code) detail = obj.language ?? 'bash'
|
||||
return { name, detail, body: JSON.stringify(obj, null, 2), output }
|
||||
} catch {
|
||||
return { name: m[1], body: m[2] }
|
||||
return { name, detail: '', body: m[2], output }
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCall({ content }: { content: string }) {
|
||||
const { name, body } = formatToolContent(content)
|
||||
const { name, detail, body, output } = formatToolContent(content)
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div class="tool-call">
|
||||
<div class="tool-header">
|
||||
<span class="tool-arrow">🖥️</span>
|
||||
{name && <span class="tool-name">{name}</span>}
|
||||
</div>
|
||||
<pre class="tool-body">{body}</pre>
|
||||
<details open={open} onToggle={(e: Event) => setOpen((e.target as HTMLDetailsElement).open)}>
|
||||
<summary class="tool-header">
|
||||
<span class="tool-arrow">🖥️</span>
|
||||
{name && <span class="tool-name">{name}</span>}
|
||||
{detail && <span class="tool-detail">{detail}</span>}
|
||||
</summary>
|
||||
<pre class="tool-body">{body}</pre>
|
||||
</details>
|
||||
{output && <pre class="tool-output">{output}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'preact/hooks'
|
||||
import type { IdxEntry } from '../lib/api'
|
||||
import type { Theme } from '../lib/theme'
|
||||
import { shortId, truncate } from '../lib/chat'
|
||||
import { truncate } from '../lib/chat'
|
||||
|
||||
interface Props {
|
||||
sessions: IdxEntry[]
|
||||
|
|
@ -8,6 +9,7 @@ interface Props {
|
|||
onSelect: (id: string | null) => void
|
||||
onNew: () => void
|
||||
onKill: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
themes: Theme[]
|
||||
currentTheme: string
|
||||
onThemeChange: (name: string) => void
|
||||
|
|
@ -19,7 +21,21 @@ function stateClass(state: string) {
|
|||
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 (
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
|
|
@ -49,7 +65,19 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes,
|
|||
>
|
||||
<div class="session-card-row">
|
||||
<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>
|
||||
<button
|
||||
class="btn-kill"
|
||||
|
|
|
|||
|
|
@ -78,3 +78,11 @@ export async function browse9p(path: string): Promise<any> {
|
|||
if (ct.includes('text/plain')) return await r.text()
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'tool'
|
||||
role: 'user' | 'assistant' | 'tool' | 'info'
|
||||
content: string
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +20,9 @@ export function parseChat(lines: string[]): ChatMessage[] {
|
|||
} else if (line.startsWith('-> ')) {
|
||||
if (cur) messages.push(cur)
|
||||
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) {
|
||||
cur.content += '\n' + line
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,7 +580,20 @@ input, select, textarea { font-family: inherit; }
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 1.3em;
|
||||
|
|
@ -590,6 +603,11 @@ input, select, textarea { font-family: inherit; }
|
|||
color: var(--tool-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-detail {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
.tool-body {
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
|
|
@ -601,6 +619,17 @@ input, select, textarea { font-family: inherit; }
|
|||
color: var(--text);
|
||||
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 {
|
||||
|
|
@ -850,3 +879,31 @@ input, select, textarea { font-family: inherit; }
|
|||
}
|
||||
.tree-row-link { cursor: pointer; }
|
||||
.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);
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue