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:
Brooklyn Nicholson 2026-06-30 15:07:24 -05:00
parent 08be8e5ef7
commit bb67dad07a
5 changed files with 370 additions and 6 deletions

View 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 ?? ''}?`}
/>
</>
)
}

View file

@ -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. */}

View file

@ -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(),

View file

@ -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}>

View file

@ -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 })
}
}