diff --git a/src/features/branch_switches/details.py b/src/features/branch_switches/details.py
deleted file mode 100644
index b06df7b6d..000000000
--- a/src/features/branch_switches/details.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from features.base import FeatureDetails
-from features.groups import Group
-
-
-class BranchSwitchesDetails(FeatureDetails):
-    explains = 'The chat marks each time the agent changes branches. You can see which working copy and branches were involved.'
-    name = "branch_switches"
-    group = Group.CHAT
-    label = "Show branch switches"
-    has_skill = False
-
-    title = "Branch switches in the chat"
-
-    abstract = "Whenever the agent switches branches, the chat shows a mark naming the branch"
-
-    help = """
-        After every tool call the journal reads which branch the agent's own checkout is on, the main checkout
-        or its worktree, straight from git's HEAD, so a switch by git switch, git checkout, an alias or a script is
-        seen alike. When it changed, the chat shows a mark such as "worktree ticket-16 switched from main to
-        custom-rule-checks"; a session that starts in a worktree gets one for the branch it starts on.
-    """
diff --git a/src/features/git_actions/actions.py b/src/features/git_actions/actions.py
new file mode 100644
index 000000000..e17859c14
--- /dev/null
+++ b/src/features/git_actions/actions.py
@@ -0,0 +1,183 @@
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+from engine.git import Checkout, checkout_of
+from engine.journal_calls import pieces
+from engine.proc import git
+
+VALUED = frozenset({"-m", "--message", "-b", "-B", "-F", "--onto", "-X", "--mainline"})
+FINISHING = frozenset({"--abort", "--continue", "--skip", "--quit"})
+FAILED = re.compile(r"^(?:fatal|error):|^CONFLICT|^! \[rejected\]|^Aborting|^Automatic merge failed", re.M)
+UP_TO_DATE = re.compile(r"Already up[ -]to[ -]date")
+SHA = re.compile(r"[0-9a-f]{7,40}")
+STASH_VERBS = frozenset({"pop", "apply", "drop"})
+STASH_PAST = {"pop": "Popped", "apply": "Applied", "drop": "Dropped"}
+MARKS: dict[str, Callable[["GitCall"], str]] = {}
+
+
+def marks(verb: str):
+    def register(describe: Callable[["GitCall"], str]):
+        MARKS[verb] = describe
+        return describe
+    return register
+
+
+@dataclass(frozen=True)
+class GitCall:
+    verb: str
+    args: tuple[str, ...]
+    flags: frozenset[str]
+    values: dict[str, str]
+    output: str
+    checkout: Checkout
+
+    @property
+    def branch(self) -> str:
+        return self.checkout.branch
+
+    @property
+    def remote(self) -> str:
+        return self.args[0] if self.args else "origin"
+
+    def has(self, *flags: str) -> bool:
+        return bool(self.flags & set(flags))
+
+    def short(self, ref: str) -> str:
+        return ref[:7] if SHA.fullmatch(ref) else ref
+
+    def mark(self) -> str:
+        describe = MARKS.get(self.verb)
+        if not describe or FAILED.search(self.output) or self.has(*FINISHING):
+            return ""
+        return describe(self)
+
+
+def calls(shell: str, output: str, folder: Path) -> list[GitCall]:
+    return [call for words in pieces(shell) if (call := parse(words, output, folder))]
+
+
+def parse(words: tuple[str, ...], output: str, folder: Path) -> GitCall | None:
+    if words[0] != "git":
+        return None
+    rest, where = list(words[1:]), folder
+    while rest and rest[0].startswith("-"):
+        option = rest.pop(0)
+        if option == "-C" and rest:
+            where = folder / rest.pop(0)
+        elif option == "-c" and rest:
+            rest.pop(0)
+    checkout = checkout_of(where)
+    if not rest or not checkout:
+        return None
+    verb, args, flags, values = rest.pop(0), [], set(), {}
+    while rest:
+        word = rest.pop(0)
+        if word in VALUED and rest:
+            values[word] = rest.pop(0)
+        elif word.startswith("-"):
+            flags.add(word)
+        else:
+            args.append(word)
+    return GitCall(verb, tuple(args), frozenset(flags), values, output, checkout)
+
+
+@marks("stash")
+def stash(call: GitCall) -> str:
+    verb = call.args[0] if call.args else "push"
+    if verb in STASH_VERBS:
+        which = f"`{call.args[1]}`" if len(call.args) > 1 else "the latest stash"
+        return f"{STASH_PAST[verb]} {which}"
+    if verb not in ("push", "save") or "No local changes" in call.output:
+        return ""
+    files = len(git(["stash", "show", "--name-only", "stash@{0}"], call.checkout.top).split())
+    return f"Stashed {files} changed file{'' if files == 1 else 's'}"
+
+
+@marks("merge")
+def merge(call: GitCall) -> str:
+    return f"Merged `{call.args[0]}` into `{call.branch}`" if call.args and not UP_TO_DATE.search(call.output) else ""
+
+
+@marks("rebase")
+def rebase(call: GitCall) -> str:
+    onto = call.values.get("--onto") or (call.args[0] if call.args else "")
+    return f"Rebased `{call.branch}` onto `{onto}`" if onto else ""
+
+
+@marks("cherry-pick")
+def cherry_pick(call: GitCall) -> str:
+    return f"Cherry-picked {', '.join(f'`{call.short(ref)}`' for ref in call.args)} onto `{call.branch}`" if call.args else ""
+
+
+@marks("revert")
+def revert(call: GitCall) -> str:
+    return f"Reverted {', '.join(f'`{call.short(ref)}`' for ref in call.args)} on `{call.branch}`" if call.args else ""
+
+
+@marks("reset")
+def reset(call: GitCall) -> str:
+    mode = next((flag[2:] + " " for flag in ("--hard", "--soft", "--mixed", "--keep") if flag in call.flags), "")
+    target = call.args[0] if call.args else "HEAD"
+    if not mode and call.args and not git(["rev-parse", "--verify", "-q", f"{target}^{{commit}}"], call.checkout.top):
+        return ""
+    return f"Reset `{call.branch}` {mode}to `{target}`"
+
+
+@marks("tag")
+def tag(call: GitCall) -> str:
+    if not call.args or call.has("-l", "--list", "-v", "--verify"):
+        return ""
+    if call.has("-d", "--delete"):
+        return f"Deleted tag `{call.args[0]}`"
+    return f"Tagged `{call.args[0]}`"
+
+
+@marks("push")
+def push(call: GitCall) -> str:
+    if call.has("--dry-run", "-n"):
+        return ""
+    if call.has("--tags"):
+        return f"Pushed tags to `{call.remote}`"
+    refs = call.args[1:] or [call.branch]
+    names = ", ".join(f"`{ref}`" for ref in refs)
+    if call.has("--delete", "-d"):
+        return f"Deleted {names} on `{call.remote}`"
+    return f"Pushed {names} to `{call.remote}`"
+
+
+@marks("pull")
+def pull(call: GitCall) -> str:
+    if UP_TO_DATE.search(call.output):
+        return ""
+    return f"Pulled `{call.args[1] if len(call.args) > 1 else call.branch}` from `{call.remote}`"
+
+
+@marks("fetch")
+def fetch(call: GitCall) -> str:
+    return "Fetched from every remote" if call.has("--all") else f"Fetched from `{call.remote}`"
+
+
+@marks("worktree")
+def worktree(call: GitCall) -> str:
+    if len(call.args) < 2:
+        return ""
+    verb, path = call.args[:2]
+    if verb == "remove":
+        return f"Removed worktree `{path}`"
+    if verb != "add":
+        return ""
+    branch = call.values.get("-b") or call.values.get("-B") or (call.args[2] if len(call.args) > 2 else "")
+    return f"Added worktree `{path}`" + (f" on branch `{branch}`" if branch else "")
+
+
+@marks("branch")
+def branch(call: GitCall) -> str:
+    if not call.args:
+        return ""
+    if call.has("-d", "-D", "--delete"):
+        return f"Deleted branch {', '.join(f'`{name}`' for name in call.args)}"
+    if call.has("-m", "-M", "--move"):
+        return f"Renamed branch `{call.args[0]}` to `{call.args[-1]}`"
+    return "" if call.flags - {"-f", "--force"} else f"Created branch `{call.args[0]}`"
diff --git a/src/features/git_actions/details.py b/src/features/git_actions/details.py
new file mode 100644
index 000000000..3ef188a10
--- /dev/null
+++ b/src/features/git_actions/details.py
@@ -0,0 +1,27 @@
+from features.base import FeatureDetails
+from features.groups import Group
+
+
+class GitActionsDetails(FeatureDetails):
+    explains = 'The chat marks each git action the agent takes: switching branches, stashing, merging, rebasing, pushing and the rest, in plain words.'
+    name = "git_actions"
+    aliases = ("branch_switches",)
+    group = Group.CHAT
+    label = "Show git actions"
+    has_skill = False
+
+    title = "Git actions in the chat"
+
+    abstract = "Whenever the agent runs a git action, the chat shows a mark saying what happened"
+
+    help = """
+        After every tool call the journal reads which branch the agent's own checkout is on, the main checkout
+        or its worktree, straight from git's HEAD, so a switch by git switch, git checkout, an alias or a script is
+        seen alike. When it changed, the chat shows a mark such as "worktree ticket-16 switched from main to
+        custom-rule-checks"; a session that starts in a worktree gets one for the branch it starts on.
+
+        Every git command in a shell call also gets a mark once it has run and not failed: "Stashed 3 changed files",
+        "Rebased helper-x onto overnight-refactor", "Pushed main to origin". Stash, merge, rebase, cherry-pick, revert,
+        reset, tag, push, pull, fetch, worktree add and remove, and branch create, rename and delete are marked;
+        commits are marked on their own, and a command that only reads is not marked.
+    """
diff --git a/src/features/history_searches/feature.py b/src/features/history_searches/feature.py
index b528ade38..4c53db5de 100644
--- a/src/features/history_searches/feature.py
+++ b/src/features/history_searches/feature.py
@@ -1,6 +1,6 @@
 from features.base import Feature
 from features.history_searches.details import HistorySearchesDetails
-from features.history_searches.handlers import MarkHistorySearches
+from features.history_searches.handlers import EndSearchReads, KeepSearchResults, MarkHistorySearches
 from features.journal import Journal
 
 
@@ -9,3 +9,6 @@ class HistorySearches(Feature):
 
     def register(self, journal: Journal) -> None:
         journal.agent.interceptor(MarkHistorySearches())
+
+        journal.events.handler(KeepSearchResults())
+        journal.events.handler(EndSearchReads())
diff --git a/src/features/history_searches/handlers.py b/src/features/history_searches/handlers.py
index bc54d9248..7c6c4ad6c 100644
--- a/src/features/history_searches/handlers.py
+++ b/src/features/history_searches/handlers.py
@@ -1,11 +1,17 @@
 import re
+import time
 
 from controllers.types import Agents
+from engine.events.engine import AgentMessageSent, CommandRan
 from engine.journal_calls import PUNCTUATION, JournalCall, pieces
+from engine.ran import SHELL
 from engine.reach import Reach
-from features.parts import AgentContext, ToolInterceptor
+from features.parts import AgentContext, Handler, ToolInterceptor
 
 TAKES_VALUE = {"--page", "--back"}
+OPEN = "open"
+OPENING = {"show", "read"}
+FOUND_LIMIT, READ_LIMIT, KEPT_READS = 4000, 2000, 20
 REDIRECT = re.compile(r"\s\d*[<>]+&?\s*[^\s|;&]*")
 
 
@@ -61,5 +67,41 @@ class MarkHistorySearches(ToolInterceptor):
     def intercept(self, context: AgentContext, call) -> str:
         for command in call.commands:
             for found in searches(command):
-                context.journal.get(Agents).card(context.agent.row.n, label=found, icon="search", tone="note")
+                key = f"search-{time.time_ns()}"
+                context.journal.get(Agents).card(context.agent.row.n, key=key, label=found, icon="search", tone="note")
+                context.state.set(OPEN, key)
         return ""
+
+
+def opened(event: CommandRan) -> str:
+    if event.tool == "Read":
+        return f"Read {event.command.rsplit(' ', 1)[-1]}"
+    if event.tool != SHELL:
+        return ""
+    for piece in pieces(REDIRECT.sub(" ", event.command)):
+        call = journal_call(piece)
+        words = asked(call) if call else []
+        if len(words) > 2 and words[1] in OPENING:
+            return f"Opened {words[0]} {words[2]}"
+    return ""
+
+
+class KeepSearchResults(Handler):
+    def handle(self, context: AgentContext, event: CommandRan) -> None:
+        key = context.state.get(OPEN)
+        agents = context.journal.get(Agents)
+        if not key:
+            return
+        if event.tool == SHELL and searches(event.command):
+            agents.card(context.agent.row.n, key=key, found=event.output[:FOUND_LIMIT])
+            return
+        label = opened(event)
+        card = next((kept for kept in agents.load(context.agent.row.n).data.get("cards") or [] if kept.get("key") == key), None)
+        if label and card:
+            reads = [*card.get("reads", []), {"label": label, "text": event.output[:READ_LIMIT]}]
+            agents.card(context.agent.row.n, key=key, reads=reads[:KEPT_READS])
+
+
+class EndSearchReads(Handler):
+    def handle(self, context: AgentContext, event: AgentMessageSent) -> None:
+        context.state.remove(OPEN)
diff --git a/src/features/history_searches/test.py b/src/features/history_searches/test.py
index 1f200eb84..e527ce772 100644
--- a/src/features/history_searches/test.py
+++ b/src/features/history_searches/test.py
@@ -21,6 +21,32 @@ def test_a_search_of_the_history_is_marked_in_the_chat_and_other_commands_are_no
     assert marks == ["Searched the history for 'phone link'", "Read back the conversation the last summary replaced", "Read back your own words"], marks
 
 
+def test_a_search_mark_keeps_what_the_search_found_and_what_was_read_from_it_until_the_next_search_or_answer():
+    from engine import bus, ran
+    from engine.chat import SENT
+    from features import FEATURES
+    from features.history_searches.handlers import MarkHistorySearches
+    from resources.base import AGENT
+    features.load()
+    record = fresh()
+    agents = Agents(record, actor=SYSTEM)
+    agent = agents.by_session("claude-1")
+    search = lambda command, output: (
+        MarkHistorySearches().intercept(AgentContext.of(FEATURES["history_searches"], record, agent), BashCall("Bash", {"command": command}, {}, command=command)),
+        ran.announce(record, agent.n, "Bash", command, output))
+    search('journal search "phone link"', "todo 12 phone link\nfact 3 tunnel")
+    ran.announce(record, agent.n, "Bash", "journal todo show 12", "Title: phone link")
+    ran.announce(record, agent.n, "Read", "reading a.py /x/a.py", "print(1)")
+    ran.announce(record, agent.n, "Bash", "ls", "a.py")
+    search('journal search "tunnel"', "fact 3 tunnel")
+    ran.announce(record, agent.n, "Bash", "journal fact show 3", "The tunnel needs TLS")
+    bus.announce(record, "agent", agent.n, SENT, AGENT, {"text": "done", "turn": "1"})
+    ran.announce(record, agent.n, "Bash", "journal todo show 13", "after the answer")
+    first, second = agents.load(agent.n).data["cards"]
+    assert (first["found"], first["reads"]) == ("todo 12 phone link\nfact 3 tunnel", [{"label": "Opened todo 12", "text": "Title: phone link"}, {"label": "Read /x/a.py", "text": "print(1)"}])
+    assert (second["found"], second["reads"]) == ("fact 3 tunnel", [{"label": "Opened fact 3", "text": "The tunnel needs TLS"}]), "a read after the answer belongs to no search"
+
+
 def test_a_transcript_rewritten_in_place_is_read_again_not_served_from_the_cache(tmp_path):
     import json
     from providers import PROVIDERS
diff --git a/src/web/src/chat/SearchResults.vue b/src/web/src/chat/SearchResults.vue
new file mode 100644
index 000000000..9ea64aa3e
--- /dev/null
+++ b/src/web/src/chat/SearchResults.vue
@@ -0,0 +1,50 @@
+<script setup>
+import Console from "../kit/Console.vue";
+import Dialog from "../kit/Dialog.vue";
+import EmptyState from "../kit/EmptyState.vue";
+import SectionHeading from "../kit/SectionHeading.vue";
+
+defineProps({search: {type: Object, required: true}});
+const emit = defineEmits(["close"]);
+</script>
+
+<template>
+    <Dialog :title="search.label" tall @close="emit('close')">
+        <SectionHeading>Results</SectionHeading>
+        <template v-if="search.found">
+            <Console><pre class="search-text">{{ search.found }}</pre></Console>
+        </template>
+        <template v-else>
+            <EmptyState>The search returned nothing.</EmptyState>
+        </template>
+        <SectionHeading class="search-reads">Opened after the search</SectionHeading>
+        <template v-if="search.reads?.length">
+            <template v-for="(read, i) in search.reads" :key="i">
+                <p class="search-read">{{ read.label }}</p>
+                <Console><pre class="search-text">{{ read.text }}</pre></Console>
+            </template>
+        </template>
+        <template v-else>
+            <EmptyState>Nothing was opened after this search.</EmptyState>
+        </template>
+    </Dialog>
+</template>
+
+<style scoped>
+.search-text {
+    margin: 0;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+.search-reads {
+    margin-top: 16px;
+}
+
+.search-read {
+    margin: 10px 0 4px;
+    color: var(--text);
+    font-size: 13px;
+    font-weight: 600;
+}
+</style>
diff --git a/src/web/src/chat/Turn.vue b/src/web/src/chat/Turn.vue
index e69ceced6..5b74c3c9b 100644
--- a/src/web/src/chat/Turn.vue
+++ b/src/web/src/chat/Turn.vue
@@ -1,10 +1,11 @@
 <script setup>
-import {computed} from "vue";
+import {computed, ref} from "vue";
 import SwitchCase from "../kit/SwitchCase.vue";
 import ChatMark from "../kit/ChatMark.vue";
 import SubagentMark from "./SubagentMark.vue";
 import WhisperMark from "./WhisperMark.vue";
 import MadeCard from "./MadeCard.vue";
+import SearchResults from "./SearchResults.vue";
 import TurnGroup from "./TurnGroup.vue";
 import TurnMessage from "./TurnMessage.vue";
 import TurnReceipt from "./TurnReceipt.vue";
@@ -27,7 +28,10 @@ const skillMark = computed(() => ({
 const compactedMark = computed(() => ({icon: "activity", tone: "warn", label: "Conversation summarized", at: props.turn.created}));
 const cardMark = computed(() => ({...data.value, at: props.turn.created}));
 
+const searching = ref(false);
+
 function markClick(data) {
+    if (data.search) return {click: () => (searching.value = true)};
     if (data.page) return {click: () => (store.pluginPage = {plugin: data.name, open: data.page})};
     return data.row ? {click: () => openRef(data.row)} : {};
 }
@@ -51,6 +55,9 @@ function markClick(data) {
         <template #card>
             <div :class="['thread-turn', 'card', {mine: data.side === 'user'}]" :data-ref="turn.ref">
                 <ChatMark :mark="cardMark" v-on="markClick(data)" />
+                <template v-if="searching">
+                    <SearchResults :search="data.search" @close="searching = false" />
+                </template>
             </div>
         </template>
         <template #group>
diff --git a/src/web/src/domain/thread.js b/src/web/src/domain/thread.js
index 12bb89122..f019a549c 100644
--- a/src/web/src/domain/thread.js
+++ b/src/web/src/domain/thread.js
@@ -92,6 +92,7 @@ const cards = (agents) =>
                 started: c.started,
                 depth: c.depth || 0,
                 ended: c.ended,
+                search: c.found === undefined ? null : {label: c.label, found: c.found, reads: c.reads || []},
             })
         )
     );
