Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 11 additions & 6 deletions src/command_system/memory_command.py
Original file line number Diff line number Diff line change
@@ -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**
Expand All @@ -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).
Expand Down
34 changes: 34 additions & 0 deletions src/server/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
102 changes: 102 additions & 0 deletions tests/server/test_memory_controls.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export interface OverlayState {
clarify: ClarifyReq | null
confirm: ConfirmReq | null
logoPicker: boolean
memoryPicker: boolean
modelPicker: boolean
pager: null | PagerState
petPicker: boolean
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -476,6 +478,7 @@ export interface AppOverlaysProps {
onActiveSessionSelect: (sessionId: string) => void
onActiveSessionClose: (sessionId: string) => Promise<null | SessionCloseResponse>
onLogoSelect: (value: string) => void
onMemorySelect: (path: string) => void
onModelSelect: (value: string) => void
onNewLiveSession: () => void
onNewPromptSession: (prompt: string, modelArg?: string) => void
Expand Down
4 changes: 4 additions & 0 deletions ui-tui/src/app/overlayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const buildOverlayState = (): OverlayState => ({
clarify: null,
confirm: null,
logoPicker: false,
memoryPicker: false,
modelPicker: false,
pager: null,
petPicker: false,
Expand All @@ -33,6 +34,7 @@ export const $isBlocked = computed(
clarify,
confirm,
logoPicker,
memoryPicker,
modelPicker,
pager,
petPicker,
Expand All @@ -51,6 +53,7 @@ export const $isBlocked = computed(
clarify ||
confirm ||
logoPicker ||
memoryPicker ||
modelPicker ||
pager ||
petPicker ||
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions ui-tui/src/app/slash/commands/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>]',
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> for direct toggle)',
Expand Down
4 changes: 4 additions & 0 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
29 changes: 28 additions & 1 deletion ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'
Expand Down Expand Up @@ -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…' })
Expand Down Expand Up @@ -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.
Expand All @@ -1230,6 +1256,7 @@ export function useMainApp(gw: GatewayClient) {
closeLiveSession,
newPromptSession,
onLogoSelect,
onMemorySelect,
onModelSelect,
session
]
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/components/appLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading
Loading