mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
feat(journey): edit/delete in TUI overlay and desktop star map
TUI /journey gets d/e with confirm + $EDITOR; desktop gets a right-click context menu with inline edit modal. Both refresh the graph after mutation. Extract openInEditor into the shared TUI editor helper.
This commit is contained in:
parent
08be8e5ef7
commit
bb67dad07a
5 changed files with 370 additions and 6 deletions
159
apps/desktop/src/app/starmap/node-context-menu.tsx
Normal file
159
apps/desktop/src/app/starmap/node-context-menu.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
|
||||
|
||||
export interface NodeMenuTarget {
|
||||
id: string
|
||||
kind: 'memory' | 'skill'
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
onChanged: () => void
|
||||
onClose: () => void
|
||||
target: NodeMenuTarget | null
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
content: string
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
|
||||
export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuProps) {
|
||||
const [editing, setEditing] = useState<EditState | null>(null)
|
||||
const [deleting, setDeleting] = useState<{ id: string; label: string } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const noun = target?.kind === 'memory' ? 'memory' : 'skill'
|
||||
|
||||
const openEdit = async () => {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const detail = await getLearningNode(target.id)
|
||||
setEditing({ content: detail.content, id: target.id, label: target.label })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await editLearningNode(editing.id, editing.content)
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
setEditing(null)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const menuOpen = target && !editing && !deleting
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuOpen ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
|
||||
<div
|
||||
className="fixed z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover py-1 text-sm shadow-md"
|
||||
style={{ left: target.x, top: target.y }}
|
||||
>
|
||||
<div className="truncate px-3 py-1 text-xs text-muted-foreground">{target.label}</div>
|
||||
<button
|
||||
className="block w-full px-3 py-1 text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => void openEdit()}
|
||||
type="button"
|
||||
>
|
||||
Edit {noun}…
|
||||
</button>
|
||||
<button
|
||||
className="block w-full px-3 py-1 text-left text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
setDeleting({ id: target.id, label: target.label })
|
||||
onClose()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Delete {noun}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {editing?.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="h-80 font-mono text-xs"
|
||||
onChange={e => setEditing(prev => (prev ? { ...prev, content: e.target.value } : prev))}
|
||||
value={editing?.content ?? ''}
|
||||
/>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void save()}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
noun === 'skill'
|
||||
? 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
: 'This memory is removed permanently.'
|
||||
}
|
||||
destructive
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await deleteLearningNode(deleting.id)
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
onChanged()
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={`Delete ${deleting?.label ?? ''}?`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
|
||||
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
|
||||
import { loadStarmapGraph } from '@/store/starmap'
|
||||
import type { StarmapGraph } from '@/types/hermes'
|
||||
|
||||
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
|
||||
|
|
@ -15,6 +16,7 @@ import { ShareControls } from './share-controls'
|
|||
import { buildSimulation } from './simulation'
|
||||
import { formatDate } from './text'
|
||||
import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis'
|
||||
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
|
||||
import { Timeline } from './timeline'
|
||||
import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types'
|
||||
|
||||
|
|
@ -153,6 +155,7 @@ export function StarMap({
|
|||
}>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } })
|
||||
|
||||
const [selectedId, setSelectedId] = useState<null | string>(null)
|
||||
const [menuTarget, setMenuTarget] = useState<NodeMenuTarget | null>(null)
|
||||
const [size, setSize] = useState({ h: 0, w: 0 })
|
||||
// Increments on every theme repaint (shared hook) so the legend swatch and the
|
||||
// canvas palette re-resolve against the freshly-painted CSS custom properties.
|
||||
|
|
@ -867,6 +870,25 @@ export function StarMap({
|
|||
endDrag()
|
||||
}
|
||||
|
||||
const onContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault()
|
||||
const { x, y } = localXY(e)
|
||||
const node = pickNode(x, y)
|
||||
|
||||
if (!node) {
|
||||
return setMenuTarget(null)
|
||||
}
|
||||
|
||||
setSelectedId(node.id)
|
||||
setMenuTarget({
|
||||
id: node.id,
|
||||
kind: node.kind === 'memory' ? 'memory' : 'skill',
|
||||
label: node.label,
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
})
|
||||
}
|
||||
|
||||
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
|
||||
|
|
@ -899,12 +921,23 @@ export function StarMap({
|
|||
onDoubleClick={resetView}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onWheel={onWheel}
|
||||
ref={canvasRef}
|
||||
/>
|
||||
|
||||
<NodeContextMenu
|
||||
onChanged={() => {
|
||||
setMenuTarget(null)
|
||||
setSelectedId(null)
|
||||
void loadStarmapGraph(true)
|
||||
}}
|
||||
onClose={() => setMenuTarget(null)}
|
||||
target={menuTarget}
|
||||
/>
|
||||
|
||||
{/* Timeline scrubber — centered along the top, clear of the close button.
|
||||
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
|
||||
scrubber receives pointer events instead of dragging the window. */}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,38 @@ export function getStarmapGraph(): Promise<StarmapGraph> {
|
|||
})
|
||||
}
|
||||
|
||||
export interface LearningNodeDetail {
|
||||
content: string
|
||||
kind: 'memory' | 'skill'
|
||||
label: string
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export function getLearningNode(id: string): Promise<LearningNodeDetail> {
|
||||
return window.hermesDesktop.api<LearningNodeDetail>({
|
||||
...profileScoped(),
|
||||
path: `/api/learning/node?id=${encodeURIComponent(id)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteLearningNode(id: string): Promise<{ message: string; ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/learning/node',
|
||||
method: 'DELETE',
|
||||
body: { id }
|
||||
})
|
||||
}
|
||||
|
||||
export function editLearningNode(id: string, content: string): Promise<{ message: string; ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/learning/node',
|
||||
method: 'PUT',
|
||||
body: { content, id }
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...profileScoped(),
|
||||
|
|
|
|||
|
|
@ -2,12 +2,23 @@ import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdo
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { openInEditor } from '../lib/editor.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { deriveStarmapPalette, fadeHex, fadeInk, type StarmapPalette } from '../lib/starmapPalette.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayScrollbar } from './overlayScrollbar.js'
|
||||
|
||||
interface MutationResult {
|
||||
message: string
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
interface NodeDetail extends MutationResult {
|
||||
content?: string
|
||||
kind?: string
|
||||
}
|
||||
|
||||
// A run is [text, styleKey, alpha?, hexOverride?] from learning_graph_render.py.
|
||||
type Run = [string, string, number?, (string | null)?]
|
||||
|
||||
|
|
@ -22,6 +33,7 @@ interface BucketNode {
|
|||
body?: string
|
||||
fullLabel?: string
|
||||
glyph: string
|
||||
id: string
|
||||
label: string
|
||||
meta: string
|
||||
style: string
|
||||
|
|
@ -132,9 +144,14 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
const [cursor, setCursor] = useState(0)
|
||||
const [mode, setMode] = useState<'item' | 'timeline'>('timeline')
|
||||
const [tick, setTick] = useState(0)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
const itemScroll = useRef<null | ScrollBoxHandle>(null)
|
||||
|
||||
// The renderer is size-aware, so refetch when the terminal resizes.
|
||||
// The renderer is size-aware, so refetch when the terminal resizes (or after a
|
||||
// mutation bumps reloadKey).
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setData(null)
|
||||
|
|
@ -155,13 +172,67 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [gw, cols, chartRows])
|
||||
}, [gw, cols, chartRows, reloadKey])
|
||||
|
||||
const tree = buildTree(data?.buckets ?? [])
|
||||
const activeRow = tree[Math.min(cursor, Math.max(0, tree.length - 1))]
|
||||
const activeNode = activeRow?.kind === 'node' ? activeRow.node : undefined
|
||||
const activeBucket = activeRow && activeRow.kind !== 'gap' ? activeRow.bucket : undefined
|
||||
|
||||
const doDelete = () => {
|
||||
const node = activeNode
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
gw.request<MutationResult>('learning.delete', { id: node.id })
|
||||
.then(res => {
|
||||
setNotice(res.message)
|
||||
|
||||
if (res.ok) {
|
||||
setMode('timeline')
|
||||
setReloadKey(k => k + 1)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => setNotice(rpcErrorMessage(e)))
|
||||
.finally(() => {
|
||||
setBusy(false)
|
||||
setConfirmDelete(false)
|
||||
})
|
||||
}
|
||||
|
||||
const doEdit = async () => {
|
||||
const node = activeNode
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
const detail = await gw.request<NodeDetail>('learning.detail', { id: node.id })
|
||||
if (!detail.ok || detail.content == null) {
|
||||
return setNotice(detail.message || 'cannot edit')
|
||||
}
|
||||
|
||||
const edited = await openInEditor(detail.content, detail.kind === 'skill' ? '.md' : '.txt')
|
||||
if (edited == null || edited.trim() === detail.content.trim()) {
|
||||
return setNotice('no changes')
|
||||
}
|
||||
|
||||
const res = await gw.request<MutationResult>('learning.edit', { content: edited, id: node.id })
|
||||
setNotice(res.message)
|
||||
|
||||
if (res.ok) {
|
||||
setReloadKey(k => k + 1)
|
||||
}
|
||||
} catch (e) {
|
||||
setNotice(rpcErrorMessage(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'item') {
|
||||
itemScroll.current?.scrollTo(0)
|
||||
|
|
@ -196,12 +267,38 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pending delete confirmation swallows the next key (y confirms, else cancel).
|
||||
if (confirmDelete) {
|
||||
if (ch === 'y' || ch === 'Y') {
|
||||
return doDelete()
|
||||
}
|
||||
|
||||
return setConfirmDelete(false)
|
||||
}
|
||||
|
||||
const back = key.escape || key.leftArrow || ch === 'h'
|
||||
|
||||
if (ch === 'q') {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
// Edit / delete work in both modes whenever a node is selected.
|
||||
if (activeNode && ch === 'd' && !key.ctrl) {
|
||||
setNotice('')
|
||||
|
||||
return setConfirmDelete(true)
|
||||
}
|
||||
|
||||
if (activeNode && ch === 'e') {
|
||||
setNotice('')
|
||||
|
||||
return void doEdit()
|
||||
}
|
||||
|
||||
if (mode === 'item') {
|
||||
if (back) {
|
||||
return setMode('timeline')
|
||||
|
|
@ -332,7 +429,8 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
</Box>
|
||||
|
||||
<Footer>
|
||||
<Hint t={t}>↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back · q close</Hint>
|
||||
<StatusLines confirm={confirmDelete} label={activeNode.fullLabel || activeNode.label} notice={notice} t={t} />
|
||||
<Hint t={t}>↑↓/jk scroll · PgUp/PgDn page · e edit · d delete · Esc/← back · q close</Hint>
|
||||
</Footer>
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -394,9 +492,16 @@ export function Journey({ gw, onClose, t }: JourneyProps) {
|
|||
</Box>
|
||||
|
||||
<Footer>
|
||||
{data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
|
||||
<StatusLines
|
||||
confirm={confirmDelete}
|
||||
label={activeNode ? activeNode.fullLabel || activeNode.label : ''}
|
||||
notice={notice}
|
||||
t={t}
|
||||
/>
|
||||
{!confirmDelete && !notice && data.summary.length ? <Hint t={t}>{data.summary.join(' · ')}</Hint> : null}
|
||||
<Hint t={t}>
|
||||
↑↓/jk move{activeNode?.body ? ' · Enter/→ open' : ''} · g/G top/bottom · q close
|
||||
↑↓/jk move{activeNode?.body ? ' · Enter/→ open' : ''}
|
||||
{activeNode ? ' · e edit · d delete' : ''} · g/G top/bottom · q close
|
||||
</Hint>
|
||||
</Footer>
|
||||
</Box>
|
||||
|
|
@ -453,6 +558,18 @@ function Shell({ children, t }: { children: React.ReactNode; t: Theme }) {
|
|||
)
|
||||
}
|
||||
|
||||
function StatusLines({ confirm, label, notice, t }: { confirm: boolean; label: string; notice: string; t: Theme }) {
|
||||
if (confirm) {
|
||||
return <Text color={t.color.error}>delete {label}? y/N</Text>
|
||||
}
|
||||
|
||||
if (notice) {
|
||||
return <Text color={t.color.accent}>{notice}</Text>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Footer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { accessSync, constants } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { accessSync, constants, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
import { withInkSuspended } from '@hermes/ink'
|
||||
|
||||
/**
|
||||
* Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors
|
||||
* prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and
|
||||
|
|
@ -45,3 +49,22 @@ export const resolveEditor = (
|
|||
|
||||
return [found ?? 'vi']
|
||||
}
|
||||
|
||||
/** Suspend Ink, open ``initial`` in $EDITOR, return the edited text (null if aborted). */
|
||||
export async function openInEditor(initial: string, suffix = '.txt'): Promise<null | string> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-edit-'))
|
||||
const file = join(dir, `edit${suffix}`)
|
||||
writeFileSync(file, initial)
|
||||
const [cmd, ...args] = resolveEditor()
|
||||
let status: null | number = null
|
||||
|
||||
await withInkSuspended(async () => {
|
||||
status = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
|
||||
})
|
||||
|
||||
try {
|
||||
return status === 0 ? readFileSync(file, 'utf8') : null
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue