Step through the last turn's edits, one diff at a time.

English | 中文
Ahel Desktop is a desktop chat app for ahel.ai. Sign in with your ahel.ai account and the Ahel MCP connects on its own, so your apps, their cards and their approvals work right in the chat.
Models come from your ahel.ai account, your own API key, or a coding CLI already installed on your computer.
Get the latest macOS (Apple silicon) or Windows build from the latest release.
Builds are unsigned beta builds for now. macOS: open the app once; when macOS refuses it, go to System Settings > Privacy & Security, press Open Anyway and enter your Mac password (right-click > Open no longer works on macOS 15). Windows: SmartScreen > More info > Run anyway.
Install Node.js and pnpm, then run:
pnpm install
pnpm run build
pnpm run package:desktop:mac:arm64:dev
The unsigned app lands under apps/desktop/.desktop-build/. See the development guide and AGENTS.md for the rest.
The desktop client in this repository is open source. The ahel connector (MCP), the chat gateway, the catalog, the knowledge data and billing are the hosted ahel.ai service and are not in this repository.
See CONTRIBUTING.md. Report security issues as described in SECURITY.md.
Code written by Ahel Technologies OÜ is licensed under the Apache License 2.0. Code inherited from DeepSeek Harness by DeepSeek stays under the MIT License; NOTICE keeps its copyright notice and explains how to tell the two apart. Third-party dependencies and their licenses are listed in THIRD_PARTY_NOTICES.md. Bundled fonts are licensed under the SIL Open Font License.
The ahel name, the tile and the wordmark are trademarks of Ahel Technologies OÜ and are not covered by either license; forks must rename and replace them, as TRADEMARK.md describes. DeepSeek Harness is a trademark of DeepSeek; this project is not affiliated with or endorsed by DeepSeek.
hooks/replay-theater.mjs 134 lines1// Replay Theater, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The post publishes the five hooks that record edits and register /replay
4// unchanged; the rest of the module (stepsFor, the diff, openReplay, the pane
5// and the hint) is completed to the post's description, marked where it starts.
6
7// Replay Theater: step through the last turn's edits, one diff at a time.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const EDIT_TOOLS = new Set(["Edit", "Write"]);
12
13// Edits of the running turn, the sealed replay of the last one, and where the pane is.
14const state = { pending: [], replay: [], index: 0, open: false, placed: false };
15
16export function register(on) {
17 on("tool.call", async ($, e, next) => {
18 if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e))); // old/new text → diff
19 return next(e); // the edit runs untouched
20 });
21
22 on("turn.start", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });
23
24 on("turn.complete", async ($, e, next) => {
25 const r = await next(e);
26 if (!e.agentId && state.pending.length) state.replay = state.pending; // one replay per turn
27 return r;
28 });
29
30 on("session.start", async ($, e, next) => {
31 const r = await next(e);
32 await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
33 return r;
34 });
35 on("command.run", { command: "replay" }, async ($, e) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
36
37 // —— completed to the post's description (not in the published excerpt) ——
38
39 on("ui.render", { component: "Pane" }, ($, e, next) => {
40 if (e.requestId !== "replay" || !state.open) return next(e);
41 return theater($, e);
42 });
43
44 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
45 if (state.open && !state.placed) return theater($, e); // no pane could be placed: the same tree, inline
46 if (state.open || state.replay.length === 0) return next(e);
47 const { Box, Text, Button } = $.ui.resolve(e);
48 const n = state.replay.length;
49 return Box({
50 flexDirection: "row",
51 paddingX: 1,
52 gap: 1,
53 children: [
54 Text({ color: "magenta", bold: true, children: "Replay Theater" }),
55 Text({ dimColor: true, children: `${n} edit${n === 1 ? "" : "s"} this turn — press r or type /replay` }),
56 Button({ label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
57 ],
58 });
59 });
60}
61
62// One replay step per edit: the file, and the text before and after.
63async function stepsFor($, e) {
64 const file = String(e.file_path ?? "");
65 if (e.tool === "Edit") {
66 return [{ file, before: String(e.old_string ?? ""), after: String(e.new_string ?? "") }];
67 }
68 // For a Write, the old contents are read just before the write lands, so the diff is real.
69 const before = await $.fs.read(file).catch(() => "");
70 return [{ file, before, after: String(e.content ?? "") }];
71}
72
73async function openReplay($) {
74 if (state.replay.length === 0) return false;
75 state.index = 0;
76 state.open = true;
77 const opened = await $.ui.open({ id: "replay", title: "Replay Theater", focus: true });
78 state.placed = opened.isPlaced;
79 $.ui.invalidate("ui.render");
80 return true;
81}
82
83function step(delta, $) {
84 state.index = Math.min(Math.max(state.index + delta, 0), state.replay.length - 1);
85 $.ui.invalidate("ui.render");
86}
87
88async function close($) {
89 state.open = false;
90 await $.ui.close({ id: "replay" });
91 $.ui.invalidate("ui.render");
92}
93
94// Lines the edit removed and added, as a unified diff shows them.
95function diffLines(before, after) {
96 const old = before.split("\n");
97 const neu = after.split("\n");
98 const common = new Set(neu);
99 const commonOld = new Set(old);
100 return [
101 ...old.filter((line) => !common.has(line)).map((line) => ({ sign: "-", line })),
102 ...neu.filter((line) => !commonOld.has(line)).map((line) => ({ sign: "+", line })),
103 ];
104}
105
106function theater($, e) {
107 const { Box, Text, Button } = $.ui.resolve(e);
108 const current = state.replay[state.index];
109 const strip = state.replay.map((_, i) => Text({ color: i === state.index ? "magenta" : undefined, bold: i === state.index, children: ` ${i + 1} ` }));
110 const diff = diffLines(current.before, current.after).map(({ sign, line }) =>
111 Text({ color: sign === "+" ? "green" : "red", children: `${sign} ${line}` }),
112 );
113 return Box({
114 flexDirection: "column",
115 border: true,
116 borderColor: "magenta",
117 paddingX: 1,
118 children: [
119 Box({ flexDirection: "row", children: [Text({ bold: true, children: "Replay Theater " }), ...strip] }),
120 Text({ dimColor: true, children: `${current.file} (step ${state.index + 1} of ${state.replay.length})` }),
121 ...diff,
122 Box({
123 flexDirection: "row",
124 gap: 2,
125 children: [
126 Button({ label: "Prev", hotkey: "p", disabled: state.index === 0, onPress: () => step(-1, $) }),
127 Button({ label: "Next", hotkey: "n", disabled: state.index >= state.replay.length - 1, onPress: () => step(1, $) }),
128 Button({ label: "Close", hotkey: "q", onPress: () => close($) }),
129 ],
130 }),
131 ],
132 });
133}
134