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,
|
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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Reference in New Issue