From 0ef40b74b58d7c57f2114a0d273f6c45205d18e1 Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 11 Jul 2026 20:42:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(memory):=20port=20openclaude's=20/memory?= =?UTF-8?q?=20=E2=80=94=20TUI=20picker=20overlay,=20$EDITOR=20spawn,=20bac?= =?UTF-8?q?kend=20memory=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing /memory in the Ink TUI now opens the memory-file picker (port of openclaude's commands/memory + MemoryFileSelector): synthetic User/Project rows plus every loaded CLAUDE.md / rules file / @-import, served by a new memory_targets control over the shared build_memory_options enumeration. Selecting a file ensure-creates it (exclusive-create), suspends the TUI to the alternate screen, spawns $VISUAL/$EDITOR (bare code/subl get wait flags — the TS EDITOR_OVERRIDES), prints the TS-verbatim 'Opened memory file at …' line + editor hint, and busts the backend memory-file cache (memory_edited) so the next turn re-reads the edited content. Previously /memory had no reachable surface: the Python InteractiveCommand port existed but the Ink TUI (sole interactive UI since #566) never dispatched it. Verified end-to-end over a real PTY (picker rows, editor spawn under suspend, transcript message, file created+edited, Esc cancel) plus 17 new unit tests (3 backend controls, 14 memoryEdit lib). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +++ src/command_system/memory_command.py | 17 ++- src/server/agent_server.py | 34 ++++++ tests/server/test_memory_controls.py | 102 ++++++++++++++++ ui-tui/src/app/interfaces.ts | 3 + ui-tui/src/app/overlayStore.ts | 4 + ui-tui/src/app/slash/commands/ops.ts | 13 ++ ui-tui/src/app/useInputHandlers.ts | 4 + ui-tui/src/app/useMainApp.ts | 29 ++++- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/appOverlays.tsx | 15 +++ ui-tui/src/components/memoryPicker.tsx | 144 ++++++++++++++++++++++ ui-tui/src/gatewayClient.ts | 11 ++ ui-tui/src/gatewayTypes.ts | 15 +++ ui-tui/src/lib/memoryEdit.test.ts | 163 +++++++++++++++++++++++++ ui-tui/src/lib/memoryEdit.ts | 121 ++++++++++++++++++ 16 files changed, 684 insertions(+), 7 deletions(-) create mode 100644 tests/server/test_memory_controls.py create mode 100644 ui-tui/src/components/memoryPicker.tsx create mode 100644 ui-tui/src/lib/memoryEdit.test.ts create mode 100644 ui-tui/src/lib/memoryEdit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6bf317f..47d9d228e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ⏰ 1 scheduled` line above the composer (cronStore + CronIndicator), fed by new `cron_status` events that also render fire/stop/restore lines in the transcript. +- **`/memory` now opens memory files in your `$EDITOR` from the TUI** — the + full port of openclaude's memory-file picker (`commands/memory/` + + `MemoryFileSelector`). Typing `/memory` opens a picker overlay listing the + memory hierarchy (synthetic **User memory** `~/.clawcodex/CLAUDE.md` and + **Project memory** rows first, then every loaded CLAUDE.md / rules file / + `@`-import, each with its "Saved in …" / "@-imported" description), served + by a new `memory_targets` control over the shared `build_memory_options` + enumeration. Selecting a file ensure-creates it (exclusive-create preserves + existing content), suspends the TUI to the alternate screen, and spawns + `$VISUAL`/`$EDITOR` (bare `code`/`subl` get their wait flags, the TS + `EDITOR_OVERRIDES`); on return the TS-verbatim "Opened memory file at …" + line lands in the transcript and a `memory_edited` control busts the + backend's memory-file cache so the very next turn re-reads the edited + content. Previously `/memory` wasn't wired into the TUI at all — the + Python `InteractiveCommand` port existed but had no reachable surface. ### Fixed diff --git a/src/command_system/memory_command.py b/src/command_system/memory_command.py index 642bd1feb..3400ebea9 100644 --- a/src/command_system/memory_command.py +++ b/src/command_system/memory_command.py @@ -1,4 +1,4 @@ -"""memory — ``/memory`` memory-file picker (port of TS local-jsx, degraded no-spawn). +"""memory — ``/memory`` memory-file picker (port of TS local-jsx). Port of ``typescript/src/commands/memory/`` + the core of ``MemoryFileSelector``. Presents the CLAUDE.md memory hierarchy — the synthetic **User memory** @@ -9,12 +9,17 @@ (exclusive-create; existing content preserved), and reports its path with an editor hint. +The FULL port — picker overlay + TS ``editFileInEditor``'s suspend-aware +``$EDITOR`` spawn + post-edit cache bust — lives in the Ink TUI +(``ui-tui/src/components/memoryPicker.tsx`` + ``ui-tui/src/lib/memoryEdit.ts``), +fed by the agent-server's ``memory_targets`` / ``memory_edited`` controls over +``build_memory_options`` below. This InteractiveCommand remains the UIHost-driven +fallback for non-TUI hosts (and yields the clean ``NullUIHost`` engine error on +headless surfaces); with no way to suspend a caller's screen from here, it +reports the path instead of spawning (the ``/copy`` clipboard standard — the +cancel/error strings stay TS-verbatim). + Deliberate divergences (documented for parity review): - * **No ``$EDITOR`` spawn.** TS ``editFileInEditor`` suspends the Ink app; Python has - no suspend-aware helper, and spawning an editor inside a full-screen TUI corrupts the - screen. The success message is adapted accordingly (an "Opened … in your editor" - claim would be false — the ``/copy`` clipboard standard); the cancel/error strings - stay TS-verbatim. * **Folder-open extras dropped** (auto-memory / team / agent folders — need the open-folder mechanism and those subsystems). * Rules files are listed flat (the ``select`` primitive has flat labels; TS indents). diff --git a/src/server/agent_server.py b/src/server/agent_server.py index e6019d2cc..b14211b3d 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -485,6 +485,21 @@ async def _handle_control_request(self, msg: dict) -> None: ok = False self._reply(request_id, {"ok": ok}) return + if subtype == "memory_targets": + await self._do_memory_targets(request_id) + return + if subtype == "memory_edited": + # /memory post-editor sync: the memory-file cache keys on cwd only + # (no mtimes), so an external $EDITOR write stays invisible until a + # bust — clear so the next turn's prompt assembly re-reads disk. + try: + from src.context_system.claude_md import clear_memory_file_caches + + clear_memory_file_caches() + self._reply(request_id, {"ok": True}) + except Exception as exc: # noqa: BLE001 + self._reply(request_id, {"ok": False, "error": str(exc)}) + return if subtype == "set_effort": self._do_set_effort(request_id, inner.get("effort")) return @@ -1256,6 +1271,25 @@ def _do_plan(self, request_id: object, action: object, text: object) -> None: logger.exception("[agent-server] plan failed") self._reply(request_id, {"ok": False, "error": str(exc)}) + async def _do_memory_targets(self, request_id: object) -> None: + """/memory picker rows (the TS MemoryFileSelector data): the shared + ``build_memory_options`` hierarchy — synthetic User/Project candidates + plus every loaded memory file — serialized for the TUI overlay, which + owns the ensure-create + ``$EDITOR`` spawn (memory.tsx).""" + try: + from src.command_system.memory_command import build_memory_options + + options = await build_memory_options(self.cwd) + self._reply(request_id, { + "ok": True, + "targets": [ + {"path": o.value, "label": o.label, "description": o.description or ""} + for o in options + ], + }) + except Exception as exc: # noqa: BLE001 + self._reply(request_id, {"ok": False, "error": str(exc), "targets": []}) + def _do_insights(self, request_id: object) -> None: """/insights: a model-based analysis of the session (the original's Insights). Runs the model call in a daemon thread (_emit is thread-safe) diff --git a/tests/server/test_memory_controls.py b/tests/server/test_memory_controls.py new file mode 100644 index 000000000..da9feb475 --- /dev/null +++ b/tests/server/test_memory_controls.py @@ -0,0 +1,102 @@ +"""Agent-server /memory wiring: the ``memory_targets`` control serializes the +shared ``build_memory_options`` hierarchy (synthetic User/Project rows plus +loaded files) for the TUI picker overlay, and ``memory_edited`` busts the +memory-file cache after the TUI's ``$EDITOR`` spawn so the next turn re-reads +disk. Covers: the two synthetic rows with TS-verbatim descriptions, the +exception guard, and the cache bust.""" + +from __future__ import annotations + +import asyncio +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from src.server.agent_server import AgentServerConfig, _AgentSession + + +def _make_session(cwd: str) -> tuple[_AgentSession, list[dict]]: + emitted: list[dict] = [] + sess = _AgentSession( + session_id="memory-sess", cwd=cwd, + config=AgentServerConfig(single_session=True), + loop=MagicMock(), out_queue=MagicMock(), + ) + sess._emit = lambda env: emitted.append(env) # type: ignore[method-assign] + return sess, emitted + + +def _control(sess: _AgentSession, subtype: str, **params) -> None: + asyncio.run(sess._handle_control_request({ + "type": "control_request", + "request_id": "req-1", + "request": {"subtype": subtype, **params}, + })) + + +def _last_reply(emitted: list[dict]) -> dict: + for env in reversed(emitted): + if env.get("type") == "control_response": + return env["response"]["response"] + raise AssertionError(f"no control_response in {emitted!r}") + + +async def _no_files(cwd=None, **kwargs): + return [] + + +class TestMemoryTargetsControl(unittest.TestCase): + def test_targets_lead_with_synthetic_user_and_project_rows(self) -> None: + with tempfile.TemporaryDirectory(prefix="mem_ctl_") as tmp: + home = Path(tmp) / "home" + home.mkdir() + cwd = Path(tmp) / "proj" + cwd.mkdir() + with ( + patch("pathlib.Path.home", classmethod(lambda cls: home)), + patch("src.context_system.claude_md.get_memory_files", _no_files), + patch("src.utils.git.get_repo_root", lambda *a, **k: None), + ): + sess, emitted = _make_session(str(cwd)) + _control(sess, "memory_targets") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"]) + targets = reply["targets"] + self.assertEqual(targets[0]["label"], "User memory") + self.assertEqual(targets[0]["path"], str(home / ".clawcodex" / "CLAUDE.md")) + self.assertEqual(targets[0]["description"], "Saved in ~/.clawcodex/CLAUDE.md") + self.assertEqual(targets[1]["label"], "Project memory") + self.assertEqual(targets[1]["path"], str(cwd / "CLAUDE.md")) + self.assertEqual(targets[1]["description"], "Saved in ./CLAUDE.md") + + def test_targets_error_guard_replies_clean_failure(self) -> None: + with tempfile.TemporaryDirectory(prefix="mem_ctl_") as tmp: + with patch( + "src.command_system.memory_command.build_memory_options", + side_effect=RuntimeError("boom"), + ): + sess, emitted = _make_session(tmp) + _control(sess, "memory_targets") + reply = _last_reply(emitted) + self.assertFalse(reply["ok"]) + self.assertIn("boom", reply["error"]) + self.assertEqual(reply["targets"], []) + + def test_memory_edited_busts_memory_file_cache(self) -> None: + import src.context_system.claude_md as claude_md + + with tempfile.TemporaryDirectory(prefix="mem_ctl_") as tmp: + saved = claude_md._memory_files_cache + try: + claude_md._memory_files_cache = ("stale-key", []) + sess, emitted = _make_session(tmp) + _control(sess, "memory_edited") + self.assertTrue(_last_reply(emitted)["ok"]) + self.assertIsNone(claude_md._memory_files_cache) + finally: + claude_md._memory_files_cache = saved + + +if __name__ == "__main__": + unittest.main() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 8edc1ddb4..718f1b596 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -141,6 +141,7 @@ export interface OverlayState { clarify: ClarifyReq | null confirm: ConfirmReq | null logoPicker: boolean + memoryPicker: boolean modelPicker: boolean pager: null | PagerState petPicker: boolean @@ -412,6 +413,7 @@ export interface AppLayoutActions { newLiveSession: () => void newPromptSession: (prompt: string, modelArg?: string) => void onLogoSelect: (value: string) => void + onMemorySelect: (path: string) => void onModelSelect: (value: string) => void resumeById: (id: string) => void setStickyPrompt: (value: string) => void @@ -476,6 +478,7 @@ export interface AppOverlaysProps { onActiveSessionSelect: (sessionId: string) => void onActiveSessionClose: (sessionId: string) => Promise onLogoSelect: (value: string) => void + onMemorySelect: (path: string) => void onModelSelect: (value: string) => void onNewLiveSession: () => void onNewPromptSession: (prompt: string, modelArg?: string) => void diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 34c4e4731..24c8db644 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -10,6 +10,7 @@ const buildOverlayState = (): OverlayState => ({ clarify: null, confirm: null, logoPicker: false, + memoryPicker: false, modelPicker: false, pager: null, petPicker: false, @@ -33,6 +34,7 @@ export const $isBlocked = computed( clarify, confirm, logoPicker, + memoryPicker, modelPicker, pager, petPicker, @@ -51,6 +53,7 @@ export const $isBlocked = computed( clarify || confirm || logoPicker || + memoryPicker || modelPicker || pager || petPicker || @@ -86,6 +89,7 @@ export const resetFlowOverlays = () => agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, logoPicker: $overlayState.get().logoPicker, + memoryPicker: $overlayState.get().memoryPicker, modelPicker: $overlayState.get().modelPicker, petPicker: $overlayState.get().petPicker, pluginsHub: $overlayState.get().pluginsHub, diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 186548bab..4fc0c8337 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -666,6 +666,19 @@ export const opsCommands: SlashCommand[] = [ } }, + { + // Port of openclaude's /memory (commands/memory/): picker-only — the TS + // command ignores arguments, so any arg just opens the same overlay. The + // selection flow (ensure-create + $EDITOR spawn) lives in onMemorySelect. + // Cancel closes silently like the sibling pickers — the TS "Cancelled + // memory editing" line existed only because local-jsx onDone must resolve. + help: 'edit memory files (CLAUDE.md) in your editor', + name: 'memory', + run: () => { + patchOverlayState({ memoryPicker: true }) + } + }, + { argumentHint: '[enable|disable ]', help: 'view & toggle plugins (no arg opens the hub; enable/disable for direct toggle)', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 3718f63f7..931dce5a4 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -205,6 +205,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ logoPicker: false }) } + if (overlay.memoryPicker) { + return patchOverlayState({ memoryPicker: false }) + } + if (overlay.billing) { return patchOverlayState({ billing: null }) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 4b14ef3ee..c63c50fce 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,12 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@clawcodex/ink' +import { + type ScrollBoxHandle, + useApp, + useHasSelection, + useSelection, + useStdout, + useTerminalTitle, + withInkSuspended +} from '@clawcodex/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -21,6 +29,7 @@ import type { import { useGitBranch } from '../hooks/useGitBranch.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { composerPromptWidth } from '../lib/inputMetrics.js' +import { openMemoryFileInEditor } from '../lib/memoryEdit.js' import { appendTranscriptMessage } from '../lib/messages.js' import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' @@ -1102,6 +1111,22 @@ export function useMainApp(gw: GatewayClient) { slashRef.current(`/logo ${value}`) }, []) + // /memory picker choice → the memory.tsx flow: ensure-create, $EDITOR under + // the alt-screen suspend, "Opened memory file at …" system line, and a + // memory_edited cache bust so the next turn re-reads the file. + const onMemorySelect = useCallback( + (path: string) => { + patchOverlayState({ memoryPicker: false }) + void openMemoryFileInEditor(path, { + cwd: getUiState().info?.cwd || process.env.CLAWCODEX_WORKSPACE || process.env.CLAWCODEX_CWD || process.cwd(), + notifyEdited: () => void gw.request('memory.edited', {}).catch(() => {}), + suspend: withInkSuspended, + sys + }) + }, + [gw, sys] + ) + const closeLiveSession = useCallback( async (id: string) => { patchUiState({ status: 'closing session…' }) @@ -1206,6 +1231,7 @@ export function useMainApp(gw: GatewayClient) { newLiveSession: () => session.newLiveSession(), newPromptSession, onLogoSelect, + onMemorySelect, onModelSelect, // Resuming a cold session from the overlay CLOSES the current one, so it // must respect the busy guard just like the `/resume` slash path. @@ -1230,6 +1256,7 @@ export function useMainApp(gw: GatewayClient) { closeLiveSession, newPromptSession, onLogoSelect, + onMemorySelect, onModelSelect, session ] diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d362a181f..769421cc6 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -304,6 +304,7 @@ const ComposerPane = memo(function ComposerPane({ onActiveSessionClose={actions.closeLiveSession} onActiveSessionSelect={actions.activateLiveSession} onLogoSelect={actions.onLogoSelect} + onMemorySelect={actions.onMemorySelect} onModelSelect={actions.onModelSelect} onNewLiveSession={actions.newLiveSession} onNewPromptSession={actions.newPromptSession} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index e2ef7d43c..f8cf509de 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -12,6 +12,7 @@ import { FloatBox } from './appChrome.js' import { BillingOverlay } from './billingOverlay.js' import { LogoPicker } from './logoPicker.js' import { MaskedPrompt } from './maskedPrompt.js' +import { MemoryPicker } from './memoryPicker.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' import { PetPicker } from './petPicker.js' @@ -141,6 +142,7 @@ export function FloatingOverlays({ onActiveSessionSelect, onActiveSessionClose, onLogoSelect, + onMemorySelect, onModelSelect, onNewLiveSession, onNewPromptSession, @@ -154,6 +156,7 @@ export function FloatingOverlays({ | 'onActiveSessionSelect' | 'onActiveSessionClose' | 'onLogoSelect' + | 'onMemorySelect' | 'onModelSelect' | 'onNewLiveSession' | 'onNewPromptSession' @@ -168,6 +171,7 @@ export function FloatingOverlays({ const hasAny = overlay.logoPicker || + overlay.memoryPicker || overlay.modelPicker || overlay.pager || overlay.petPicker || @@ -228,6 +232,17 @@ export function FloatingOverlays({ )} + {overlay.memoryPicker && ( + + patchOverlayState({ memoryPicker: false })} + onSelect={onMemorySelect} + t={theme} + /> + + )} + {overlay.petPicker && ( patchOverlayState({ petPicker: false })} t={theme} /> diff --git a/ui-tui/src/components/memoryPicker.tsx b/ui-tui/src/components/memoryPicker.tsx new file mode 100644 index 000000000..162b104dd --- /dev/null +++ b/ui-tui/src/components/memoryPicker.tsx @@ -0,0 +1,144 @@ +import { Box, Text, useInput, useStdout } from '@clawcodex/ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import type { MemoryTarget, MemoryTargetsResponse } from '../gatewayTypes.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, windowItems } from './overlayControls.js' + +const VISIBLE = 10 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 + +// The TS MemoryFileSelector remembers the previously chosen file across +// opens (module-level `lastSelectedPath`) — same idiom here. +let lastSelectedPath: string | undefined + +/** + * Interactive /memory picker overlay — port of openclaude's `/memory` dialog + * (`commands/memory/memory.tsx` + the core of `MemoryFileSelector`). Rows come + * from the backend's `memory_targets` control (the shared + * `build_memory_options` hierarchy): the synthetic **User memory** and + * **Project memory** candidates first, then every loaded memory file, each + * differentiated by its dim description ("Saved in …", "@-imported"). Enter + * hands the chosen path to `onSelect` (the app ensure-creates it and spawns + * `$EDITOR` under the alt-screen suspend); Esc/q cancels. + */ +export function MemoryPicker({ gw, onCancel, onSelect, t }: MemoryPickerProps) { + const [targets, setTargets] = useState([]) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + const [idx, setIdx] = useState(0) + + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useEffect(() => { + gw.request('memory.targets', {}) + .then(raw => { + const r = asRpcResult(raw) + + if (!r || r.ok === false || !Array.isArray(r.targets) || !r.targets.length) { + setErr(r?.error || 'no memory files reported by the backend') + setLoading(false) + + return + } + + setTargets(r.targets) + setIdx(Math.max(0, r.targets.findIndex(target => target.path === lastSelectedPath))) + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw]) + + useInput((ch, key) => { + if (key.escape || ch === 'q') { + return onCancel() + } + + if (key.upArrow) { + return setIdx(i => Math.max(0, i - 1)) + } + + if (key.downArrow) { + return setIdx(i => Math.min(Math.max(0, targets.length - 1), i + 1)) + } + + if (key.return) { + const target = targets[idx] + + if (target) { + lastSelectedPath = target.path + onSelect(target.path) + } + } + }) + + if (loading) { + return loading memory files… + } + + if (err) { + return ( + + error: {err} + Esc/q cancel + + ) + } + + const { items, offset } = windowItems(targets, idx, VISIBLE) + + return ( + + + Memory + + + + Select a memory file to edit + + + + {offset > 0 ? ` ↑ ${offset} more` : ' '} + + + {items.map((target, i) => { + const at = offset + i === idx + + return ( + + {at ? '▸ ' : ' '} + {target.label} + {target.description ? · {target.description} : null} + + ) + })} + + + {offset + VISIBLE < targets.length ? ` ↓ ${targets.length - offset - VISIBLE} more` : ' '} + + + ↑/↓ select · Enter edit in $EDITOR · Esc/q cancel + + ) +} + +interface MemoryPickerProps { + gw: GatewayClient + onCancel: () => void + onSelect: (path: string) => void + t: Theme +} diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 47c3ece63..71cc77f4f 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -293,6 +293,7 @@ const SLASHES: ReadonlyArray<{ desc: string; hint?: string; name: string }> = [ }, { desc: 'List running and recent dynamic workflows', name: '/workflows' }, { desc: 'Search / manage the knowledge base', hint: '[status|list|clear|enable|disable]', name: '/knowledge' }, + { desc: 'Edit Claude memory files', name: '/memory' }, { desc: 'Browse and inspect available skills', hint: '[list | inspect | search ]', name: '/skills' }, { desc: 'Enable plan mode or view the current session plan', hint: '[]', name: '/plan' }, { @@ -706,6 +707,16 @@ export class GatewayClient extends EventEmitter { skills => ({ output: `Re-scanned skills: ${this.skillsTotal || skills.length} available.` }) as T ) + // ── /memory picker (backend enumerates; the TUI owns the editor) ───── + case 'memory.targets': + return this.controlQuery('memory_targets', {}).then( + r => (r ?? { error: 'no response from backend', ok: false, targets: [] }) as T + ) + + case 'memory.edited': + // Post-$EDITOR cache bust so the next turn re-reads memory files. + return this.controlQuery('memory_edited', {}).then(r => (r ?? { ok: false }) as T) + // ── slash commands → clawcodex control_requests ────────────────────── case 'command.dispatch': return this.dispatchSlash(String(p.name ?? ''), p.arg == null ? undefined : String(p.arg)) as Promise diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 87d4cce3f..ae138dea7 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -72,6 +72,21 @@ export interface SlashExecResponse { warning?: string } +// ── /memory picker (memory_targets control) ───────────────────────── + +export interface MemoryTarget { + /** Secondary dim text — e.g. "Saved in ~/.clawcodex/CLAUDE.md", "@-imported". */ + description?: string + label: string + path: string +} + +export interface MemoryTargetsResponse { + error?: string + ok?: boolean + targets?: MemoryTarget[] +} + // ── Credits / top-up ───────────────────────────────────────────────── export interface CreditsViewResponse { diff --git a/ui-tui/src/lib/memoryEdit.test.ts b/ui-tui/src/lib/memoryEdit.test.ts new file mode 100644 index 000000000..7470423e9 --- /dev/null +++ b/ui-tui/src/lib/memoryEdit.test.ts @@ -0,0 +1,163 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + editorHint, + ensureMemoryFile, + memoryEditorArgv, + openMemoryFileInEditor, + relativeMemoryPath +} from './memoryEdit.js' + +describe('ensureMemoryFile', () => { + let home: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mem-home-')) + }) + + it('creates the config home and file for the user memory target', () => { + const path = join(home, '.clawcodex', 'CLAUDE.md') + + ensureMemoryFile(path, home) + + expect(readFileSync(path, 'utf8')).toBe('') + }) + + it('preserves existing content (exclusive create swallows EEXIST)', () => { + const path = join(home, 'CLAUDE.md') + + writeFileSync(path, 'precious') + ensureMemoryFile(path, home) + + expect(readFileSync(path, 'utf8')).toBe('precious') + }) + + it('propagates non-EEXIST errors (missing dir outside the config home)', () => { + expect(() => ensureMemoryFile(join(home, 'nope', 'CLAUDE.md'), home)).toThrow() + }) +}) + +describe('editorHint', () => { + it('names $VISUAL above $EDITOR', () => { + expect(editorHint({ EDITOR: 'vim', VISUAL: 'helix' })).toBe( + '> Using $VISUAL="helix". To change editor, set $EDITOR or $VISUAL environment variable.' + ) + }) + + it('names $EDITOR when $VISUAL is unset', () => { + expect(editorHint({ EDITOR: 'vim' })).toBe( + '> Using $EDITOR="vim". To change editor, set $EDITOR or $VISUAL environment variable.' + ) + }) + + it('falls back to the set-an-editor hint', () => { + expect(editorHint({})).toBe( + '> To use a different editor, set the $EDITOR or $VISUAL environment variable.' + ) + }) +}) + +describe('relativeMemoryPath', () => { + it('prefers the shorter of ~ and ./ spellings', () => { + expect(relativeMemoryPath('/home/u/w/CLAUDE.md', '/home/u/w', '/home/u')).toBe('./CLAUDE.md') + expect(relativeMemoryPath('/home/u/.clawcodex/CLAUDE.md', '/home/u/deep/nested/dir', '/home/u')).toBe( + '~/.clawcodex/CLAUDE.md' + ) + }) + + it('falls back to the absolute path outside home and cwd', () => { + expect(relativeMemoryPath('/etc/CLAUDE.md', '/home/u/w', '/home/u')).toBe('/etc/CLAUDE.md') + }) +}) + +describe('memoryEditorArgv', () => { + it('appends the wait flag to a bare GUI editor', () => { + expect(memoryEditorArgv('/f.md', { EDITOR: 'code', PATH: '' })).toEqual(['code', '-w', '/f.md']) + expect(memoryEditorArgv('/f.md', { EDITOR: '/usr/local/bin/subl', PATH: '' })).toEqual([ + '/usr/local/bin/subl', + '--wait', + '/f.md' + ]) + }) + + it('never second-guesses user-supplied arguments', () => { + expect(memoryEditorArgv('/f.md', { EDITOR: 'code --wait', PATH: '' })).toEqual(['code', '--wait', '/f.md']) + }) + + it('leaves terminal editors untouched', () => { + expect(memoryEditorArgv('/f.md', { EDITOR: 'vim', PATH: '' })).toEqual(['vim', '/f.md']) + }) +}) + +describe('openMemoryFileInEditor', () => { + let home: string + let cwd: string + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mem-home-')) + cwd = join(home, 'w') + mkdirSync(cwd) + }) + + const deps = (over: Record = {}) => { + const calls: string[] = [] + + return { + calls, + cwd, + env: { EDITOR: 'vim' } as NodeJS.ProcessEnv, + home, + notifyEdited: vi.fn(() => void calls.push('notify')), + spawn: vi.fn((cmd: string, args: string[]) => { + calls.push(`spawn:${cmd} ${args.join(' ')}`) + + return {} + }), + suspend: vi.fn(async (run: () => Promise) => { + calls.push('suspend:enter') + await run() + calls.push('suspend:exit') + }), + sys: vi.fn((text: string) => void calls.push(`sys:${text}`)), + ...over + } + } + + it('creates the file, spawns inside the suspend, notifies, and reports', async () => { + const d = deps() + const path = join(cwd, 'CLAUDE.md') + + await openMemoryFileInEditor(path, d) + + expect(readFileSync(path, 'utf8')).toBe('') + expect(d.calls).toEqual([ + 'suspend:enter', + `spawn:vim ${path}`, + 'suspend:exit', + 'notify', + `sys:Opened memory file at ./CLAUDE.md\n\n${editorHint({ EDITOR: 'vim' })}` + ]) + }) + + it('reports a launch failure and skips the edited notification', async () => { + const d = deps({ spawn: vi.fn(() => ({ error: new Error('ENOENT') })) }) + + await openMemoryFileInEditor(join(cwd, 'CLAUDE.md'), d) + + expect(d.notifyEdited).not.toHaveBeenCalled() + expect(d.sys).toHaveBeenCalledWith('Error opening memory file: Error: ENOENT') + }) + + it('reports ensure-create failures without spawning', async () => { + const d = deps() + + await openMemoryFileInEditor(join(cwd, 'missing-dir', 'CLAUDE.md'), d) + + expect(d.spawn).not.toHaveBeenCalled() + expect(d.sys.mock.calls[0]?.[0]).toMatch(/^Error opening memory file: /) + }) +}) diff --git a/ui-tui/src/lib/memoryEdit.ts b/ui-tui/src/lib/memoryEdit.ts new file mode 100644 index 000000000..f61754578 --- /dev/null +++ b/ui-tui/src/lib/memoryEdit.ts @@ -0,0 +1,121 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, join, relative } from 'node:path' + +import { resolveEditor } from './editor.js' + +/** + * /memory editor flow — port of openclaude's `commands/memory/memory.tsx` + * (ensure-create + result message) and the spawn half of + * `utils/promptEditor.ts#editFileInEditor`. The picker overlay hands a path + * to `openMemoryFileInEditor`, which creates the file if missing, suspends + * the TUI (alt-screen) around a blocking `$EDITOR` spawn, then reports the + * TS-verbatim "Opened memory file at …" line and busts the backend's + * memory-file cache so the next turn re-reads disk. + */ + +/** TS `editFileInEditor`'s EDITOR_OVERRIDES — GUI editors that return before + * the file is closed unless told to wait. Keyed on the command basename + * (small deliberate superset of the TS exact-string match, so an absolute + * `$EDITOR=/usr/local/bin/code` also waits). Applied only to a bare command; + * user-supplied arguments are never second-guessed. */ +const EDITOR_WAIT_FLAGS: Record = { + code: '-w', + subl: '--wait' +} + +/** Port of memory.tsx:23-41 — mkdir the config home when the target lives + * under it, then exclusive-create so existing content is preserved. */ +export const ensureMemoryFile = (path: string, home: string = homedir()): void => { + const configHome = join(home, '.clawcodex') + + if (path.startsWith(configHome)) { + mkdirSync(configHome, { recursive: true }) + } + + try { + writeFileSync(path, '', { encoding: 'utf8', flag: 'wx' }) + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') { + throw e + } + } +} + +/** Port of memory.tsx:44-55 — which env var picked the editor, verbatim. */ +export const editorHint = (env: NodeJS.ProcessEnv = process.env): string => { + const source = env.VISUAL ? '$VISUAL' : env.EDITOR ? '$EDITOR' : '' + const value = env.VISUAL || env.EDITOR || '' + + return source + ? `> Using ${source}="${value}". To change editor, set $EDITOR or $VISUAL environment variable.` + : '> To use a different editor, set the $EDITOR or $VISUAL environment variable.' +} + +/** Port of `getRelativeMemoryPath` (MemoryUpdateNotification.tsx) — the + * shorter of the `~`- and `./`-relative spellings, else absolute. */ +export const relativeMemoryPath = (path: string, cwd: string, home: string = homedir()): string => { + const toHome = path.startsWith(home) ? `~${path.slice(home.length)}` : null + const toCwd = path.startsWith(cwd) ? `./${relative(cwd, path)}` : null + + if (toHome && toCwd) { + return toHome.length <= toCwd.length ? toHome : toCwd + } + + return toHome ?? toCwd ?? path +} + +/** Editor invocation argv for a memory file: `resolveEditor` plus the + * wait-flag override, with the file path appended. */ +export const memoryEditorArgv = (path: string, env: NodeJS.ProcessEnv = process.env): string[] => { + const argv = [...resolveEditor(env)] + const wait = EDITOR_WAIT_FLAGS[basename(argv[0] ?? '')] + + if (wait && argv.length === 1) { + argv.push(wait) + } + + return [...argv, path] +} + +export interface OpenMemoryFileDeps { + cwd: string + env?: NodeJS.ProcessEnv + home?: string + /** Post-edit backend sync (`memory.edited` RPC) — fire-and-forget. */ + notifyEdited: () => void + spawn?: (cmd: string, args: string[]) => { error?: Error } + suspend: (run: () => Promise) => Promise + sys: (text: string) => void +} + +export async function openMemoryFileInEditor(path: string, deps: OpenMemoryFileDeps): Promise { + const env = deps.env ?? process.env + const home = deps.home ?? homedir() + + const run = + deps.spawn ?? ((cmd: string, args: string[]) => spawnSync(cmd, args, { stdio: 'inherit' })) + + try { + ensureMemoryFile(path, home) + + const [cmd, ...args] = memoryEditorArgv(path, env) + let result: { error?: Error } = {} + + await deps.suspend(async () => { + result = run(cmd!, args) + }) + + // A failed launch (ENOENT) means nothing was opened; a non-zero editor + // exit is deliberately ignored, like memory.tsx ignoring EditorResult. + if (result.error) { + throw result.error + } + + deps.notifyEdited() + deps.sys(`Opened memory file at ${relativeMemoryPath(path, deps.cwd, home)}\n\n${editorHint(env)}`) + } catch (error) { + deps.sys(`Error opening memory file: ${error}`) + } +}