diff --git a/README.md b/README.md
index f58f3f8..3e57fc3 100644
--- a/README.md
+++ b/README.md
@@ -71,7 +71,8 @@ and the live Diff settings preview.
Content, rendered Preview, History, Compare, Blame, image, and directory
modes. Content uses Pierre's lightweight edit mode; unsaved drafts survive
navigation during the app session and reach disk only through Save or `Mod+S`;
- Discard changes resets the current buffer without writing it.
+ toolbar Undo/Redo controls share Pierre's structure-aware keyboard history,
+ and Discard changes resets the current buffer without writing it.
Multiple terminals run at the repository
root and keep output, scrollback, and selection across view, repository, and
workspace switches, pane splits, and resizes, and full-screen terminal apps receive the fitted PTY
diff --git a/ROADMAP.md b/ROADMAP.md
index f691403..f8c5466 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2487,6 +2487,12 @@ remain explicit through Save / Mod+S and retain the optimistic stale-write guard
the editor's Discard changes action resets the session buffer and reloads disk
content without issuing a write.
+**Editor undo/redo controls shipped (2026-08-06):** Work Content's toolbar now
+exposes Pierre's structure-aware undo history through state-aware Undo and Redo
+buttons. The controls track the same history as Mod+Z, Mod+Shift+Z, and Ctrl+Y,
+and reset when Strand intentionally rebuilds the editor after a clean external
+refresh or Discard.
+
---
## Cross-cutting tracks (run in parallel with all milestones)
diff --git a/TASKS.md b/TASKS.md
index 9d0af3c..763d7e9 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1221,7 +1221,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git-
and a refresh that finishes after typing starts cannot replace the draft
(`ContentTab` loaded-source/dirty guards). Mod+F uses Pierre's editor search
and replace while editing; read-only and palette-triggered searches retain
- Strand's wrap-around `FileSearchBar` + `searchFileText` path.
+ Strand's wrap-around `FileSearchBar` + `searchFileText` path. Toolbar Undo
+ and Redo controls bind directly to Pierre's structure-aware history, mirror
+ its `canUndo` / `canRedo` state, and stay aligned with the existing
+ Mod+Z / Mod+Shift+Z / Ctrl+Y editor shortcuts (`PierreFileEditor` history
+ bridge + `ContentTab` controls).
- ☑ Preview tab — rendered view for renderable text files, tab only offered
for them (`PreviewTab` in `FileView.tsx`): SVG through the image pipeline
(`ImagePreview`, data-URL ``), markdown through `lib/markdown.tsx`
diff --git a/docs/learnings.md b/docs/learnings.md
index dc4cee2..ab114e0 100644
--- a/docs/learnings.md
+++ b/docs/learnings.md
@@ -1941,6 +1941,11 @@ text separately for optimistic writes so the core can restore the original
line-ending convention on save. Discarding editor changes is a session-buffer
operation: clear the stored draft, rebuild Pierre from the last-read text, then
refresh from disk; never implement it through a working-tree write.
+Expose undo/redo through Pierre's public `Editor` instance and its `canUndo` /
+`canRedo` flags; never maintain a parallel history over the session draft. The
+toolbar and Pierre's keymap must walk the same stack, and an intentional editor
+rebuild (clean external refresh or Discard) must clear the exposed handle until
+the replacement document attaches.
**Windows discard keeps libgit2 fast and falls back only for its path ceiling
(2026-07-20).** `git2::Repository::checkout_index` may inspect an unrelated
diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx
index b69b8c0..a0c4361 100644
--- a/ui/src/components/Icon.tsx
+++ b/ui/src/components/Icon.tsx
@@ -8,7 +8,7 @@ export type IconName =
| 'history' | 'compare' | 'blame' | 'content' | 'terminal' | 'external' | 'eye' | 'sparkle'
| 'split' | 'unified' | 'rebase' | 'circle' | 'lock' | 'star' | 'gpg' | 'warning' | 'settings'
| 'trash' | 'workspace'
- | 'bell' | 'save'
+ | 'bell' | 'save' | 'undo' | 'redo'
| 'win-min' | 'win-max' | 'win-restore' | 'win-close';
interface Props extends Omit, 'name' | 'stroke'> {
@@ -51,6 +51,8 @@ export function Icon({ name, size = 14, stroke = 1.5, ...rest }: Props) {
case 'workspace': return ;
case 'bell': return ;
case 'save': return ;
+ case 'undo': return ;
+ case 'redo': return ;
case 'folder-open': return ;
case 'changes': return ;
case 'search': return ;
diff --git a/ui/src/components/PierreFileEditor.tsx b/ui/src/components/PierreFileEditor.tsx
index 3b410d2..877b4b7 100644
--- a/ui/src/components/PierreFileEditor.tsx
+++ b/ui/src/components/PierreFileEditor.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
import type { FileOptions } from '@pierre/diffs/react';
import { EditProvider, File } from '@pierre/diffs/react';
import { Editor, type EditorOptions } from '@pierre/diffs/edit';
@@ -10,6 +10,14 @@ interface PierreFileEditorProps {
selectedLine: number | null;
text: string;
onChange(text: string): void;
+ onHistoryChange(editor: PierreEditorHistory | null): void;
+}
+
+export interface PierreEditorHistory {
+ readonly canUndo: boolean;
+ readonly canRedo: boolean;
+ undo(): void;
+ redo(): void;
}
function createEditor(options: EditorOptions): Editor {
@@ -24,13 +32,26 @@ export default function PierreFileEditor({
selectedLine,
text,
onChange,
+ onHistoryChange,
}: PierreFileEditorProps) {
+ const editorRef = useRef | null>(null);
const file = useMemo(() => ({ name: path, contents: text, cacheKey }), [cacheKey, path, text]);
const editorOptions = useMemo>(
- () => ({ onChange: (changed) => onChange(changed.contents) }),
- [onChange],
+ () => ({
+ onAttach: (editor) => {
+ editorRef.current = editor;
+ onHistoryChange(editor);
+ },
+ onChange: (changed) => {
+ onChange(changed.contents);
+ if (editorRef.current) onHistoryChange(editorRef.current);
+ },
+ }),
+ [onChange, onHistoryChange],
);
+ useEffect(() => () => onHistoryChange(null), [onHistoryChange]);
+
return (
import('../components/PierreFileEditor'));
+interface EditorHistoryState {
+ editor: PierreEditorHistory | null;
+ canUndo: boolean;
+ canRedo: boolean;
+}
+
+const EMPTY_EDITOR_HISTORY: EditorHistoryState = {
+ editor: null,
+ canUndo: false,
+ canRedo: false,
+};
+
const TABS: { id: Tab; label: string; icon: IconName }[] = [
{ id: 'content', label: 'Content', icon: 'content' },
{ id: 'preview', label: 'Preview', icon: 'eye' },
@@ -429,6 +442,7 @@ function ContentTab({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const [editorGeneration, setEditorGeneration] = useState(0);
+ const [editorHistory, setEditorHistory] = useState(EMPTY_EDITOR_HISTORY);
const scrollRef = useRef(null);
const savingRef = useRef(false);
const loadedSourceRef = useRef(null);
@@ -463,6 +477,16 @@ function ContentTab({
);
}, [path, repoPath, setFileDraft]);
+ const updateEditorHistory = useCallback((editor: PierreEditorHistory | null) => {
+ const canUndo = editor?.canUndo ?? false;
+ const canRedo = editor?.canRedo ?? false;
+ setEditorHistory((current) => (
+ current.editor === editor && current.canUndo === canUndo && current.canRedo === canRedo
+ ? current
+ : { editor, canUndo, canRedo }
+ ));
+ }, []);
+
// Follow external changes while the document is clean, but never replace a
// user's unsaved buffer. A stale save is rejected by repo_file_write.
useEffect(() => {
@@ -525,7 +549,10 @@ function ContentTab({
if (cancelled || (refreshingLoadedSource && dirtyRef.current)) return;
loadedSourceRef.current = sourceKey;
setData(c);
- if (refreshingLoadedSource) setEditorGeneration((generation) => generation + 1);
+ if (refreshingLoadedSource) {
+ setEditorHistory(EMPTY_EDITOR_HISTORY);
+ setEditorGeneration((generation) => generation + 1);
+ }
const normalizedDisk = normalizeEditorText(c.text);
const stored = !revision
? useWork.getState().fileDrafts[workFileDraftKey(repoPath, path)]
@@ -592,6 +619,7 @@ function ContentTab({
// Pierre owns its document after mounting; a new cache key rebuilds it
// from the restored text without touching the working-tree file. Refresh
// afterward so an external disk change that caused a stale save also wins.
+ setEditorHistory(EMPTY_EDITOR_HISTORY);
setEditorGeneration((generation) => generation + 1);
setDiscardRefreshKey((key) => key + 1);
}, [dirty, path, repoPath, setFileDraft]);
@@ -650,6 +678,28 @@ function ContentTab({
? t('file.unsaved')
: t('file.saved')}
+
+
+
+
diff --git a/website/docs/work.md b/website/docs/work.md
index 9a5233f..0929d34 100644
--- a/website/docs/work.md
+++ b/website/docs/work.md
@@ -42,7 +42,10 @@ multiple cursors, smart indentation, bracket matching, and undo/redo. Unsaved
drafts remain available when you switch tabs, panes, views, workspaces, or
repositories during the current app session. Strand does not save on blur,
navigation, or idle time: use the save icon or `Mod+S` when you want to write
-the file. Use **Discard changes** beside Save to reset the current unsaved
+the file. Use the toolbar's **Undo** and **Redo** controls or `Mod+Z` and
+`Mod+Shift+Z`; Windows and Linux also support `Ctrl+Y` for redo. The buttons
+disable automatically at the ends of Pierre's structure-aware history. Use
+**Discard changes** beside Save to reset the current unsaved
buffer and reload the file from disk without writing it. Historical revisions,
binaries, oversized files, and non-UTF-8 text
stay read-only. If another tool changes the file while you have unsaved edits,