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
19 changes: 19 additions & 0 deletions .tegami/feat-hunk-diff-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
packages:
"group:tooee":
type: minor
---

## Render diffs with Hunk

Patches are now a first-class Tooee format. `tooee view changes.patch` (or piping `git diff` into
`tooee view`) opens a diff viewer built on Hunk's OpenTUI primitives, with stacked and split
layouts, word-level highlights and multi-file review.

Navigation is per hunk: `j`/`k` step between hunks, `]`/`[` jump between files, `f` opens a file
picker, `s` toggles split, `w` toggles wrapping, and `h`/`l` pan wide hunks. Search, copy and
selection all work in real patch text.

Markdown ` ```diff ` and ` ```patch ` fences render as diff blocks too, with `split`, `nolines` and
`wrap` options in the fence info string. Fences that are not real unified diffs keep falling back
to the syntax-highlighted code block.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ User-facing changes should include a Tegami release note. See [docs/releasing.md
- `@tooee/shell` is the composition layer — `TooeeProvider` wraps all providers, `launchCli()` creates renderers
- Hotkey format: `ctrl+x`, sequences `g g`, leader keys `<leader>n`
- **Raw `useKeyboard` policy**: app-level `useKeyboard` handlers MUST guard with `useHasOverlay()` (from `@tooee/overlays`) or be ported to `useCommand`. Raw handlers subscribe before the command dispatcher (child effects run first), so modal command surfaces cannot suspend them and `key.preventDefault()` does not protect them — an unguarded handler double-handles keys while an overlay is open. See the `@tooee/commands` README.
- **Diff rendering**: `@tooee/diff` is the only package allowed to import `hunkdiff` (pinned exactly, pre-1.0). It owns the `diff` content format, the `DiffView` row document, and the ` ```diff `/` ```patch ` Markdown fence renderer — see its README for the row model and known limits.
- **Store conventions**: stateful interaction systems use `@xstate/store` event stores with thin React adapters — see [docs/store-conventions.md](docs/store-conventions.md) for when to use a store vs `useState` vs an effect, file layout, testing, and selector discipline.

## Documentation
Expand Down
7 changes: 4 additions & 3 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { launch as launchChoose, createStdinChooseProvider } from "@tooee/choose

const [command, ...args] = process.argv.slice(2);

const RENDERERS: ContentFormat[] = ["markdown", "code", "text", "table"];
const RENDERERS: ContentFormat[] = ["markdown", "code", "text", "table", "diff"];

interface ViewArgs {
filePath?: string;
Expand Down Expand Up @@ -64,20 +64,21 @@ const printUsage = function printUsage(): void {
console.log("Usage: tooee <command> [options]");
console.log("");
console.log("Commands:");
console.log(" view [file] Display markdown, code, text, or tables");
console.log(" view [file] Display markdown, code, text, diffs, or tables");
console.log(" ask [prompt] Gather multiline user input");
console.log(" choose Select items from a filterable list (stdin)");
console.log(" table [file] Display tabular data (deprecated; use view --renderer table)");

console.log("");
console.log("View options:");
console.log(" --renderer, -r <renderer> Force renderer: markdown, code, text, table");
console.log(" --renderer, -r <renderer> Force renderer: markdown, code, text, table, diff");

console.log("");
console.log("Examples:");
console.log(" tooee view README.md");
console.log(" tooee view --renderer text README.md");
console.log(" tooee view --renderer table data.csv");
console.log(" git diff | tooee view --renderer diff");
console.log(" cat file.md | tooee view");
console.log(" cat data.csv | tooee view --renderer table");
console.log(' tooee ask "Search for:"');
Expand Down
188 changes: 187 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions examples/diff-showcase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Patch Review: Pocket Tasks

This review follows a small task app as it gains priorities, keyboard shortcuts, and a clearer empty state. Each patch is a real unified diff rendered by Hunk inside Tooee's Markdown view.

> Move the cursor with `j` and `k`. Use `h` and `l` to pan a wide patch. Press `t` or `T` to change the theme.

## 1. Give every task a priority

The first patch updates the shared model and keeps the default explicit. Word-level highlighting makes the type and function changes easy to spot.

```diff
diff --git a/src/tasks.ts b/src/tasks.ts
index 83d42c1..a51d9a7 100644
--- a/src/tasks.ts
+++ b/src/tasks.ts
@@ -1,5 +1,8 @@
+export type Priority = "low" | "normal" | "high";
+
export interface Task {
id: string;
title: string;
done: boolean;
+ priority: Priority;
}
@@ -7,5 +10,5 @@ export interface Task {
-export function createTask(id: string, title: string): Task {
- return { id, title, done: false };
+export function createTask(id: string, title: string, priority: Priority = "normal"): Task {
+ return { id, title, done: false, priority };
}
```

## 2. Add a compact task card

This patch requests the `split` layout. Tooee uses a stacked layout automatically when the terminal is too narrow for two readable columns.

```diff split
diff --git a/src/task-card.tsx b/src/task-card.tsx
new file mode 100644
index 0000000..c78b512
--- /dev/null
+++ b/src/task-card.tsx
@@ -0,0 +1,23 @@
+import type { Task } from "./tasks";
+
+const priorityLabel = {
+ high: "Urgent",
+ low: "Whenever",
+ normal: "Next",
+} as const;
+
+export function TaskCard({ task, onToggle }: { task: Task; onToggle: () => void }) {
+ return (
+ <button
+ className={`task-card task-card--${task.priority}`}
+ onClick={onToggle}
+ type="button"
+ >
+ <span aria-hidden="true">{task.done ? "✓" : "○"}</span>
+ <span className="task-card__title">{task.title}</span>
+ <small>{priorityLabel[task.priority]}</small>
+ </button>
+ );
+}
```

## 3. Improve the empty state and shortcuts

The `nolines` option removes line-number columns. The `wrap` option keeps long copy visible instead of clipping it.

```patch nolines wrap
diff --git a/src/app.tsx b/src/app.tsx
index 6d46ee2..31ca37b 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -8,1 +8,13 @@ export function App() {
const [tasks, setTasks] = useState<Task[]>([]);
+ useEffect(() => {
+ const addTask = (event: KeyboardEvent) => {
+ if (event.key === "n" && !event.metaKey && !event.ctrlKey) {
+ setComposerOpen(true);
+ }
+ };
+ window.addEventListener("keydown", addTask);
+ return () => window.removeEventListener("keydown", addTask);
+ }, []);
+
@@ -10,3 +22,3 @@ export function App() {
if (tasks.length === 0) {
- return <p>No tasks.</p>;
+ return <EmptyState title="A clear list" hint="Press N to capture the first thing on your mind." />;
}
```

## Review summary

| Area | Result |
| ----------- | ----------------------------------------------- |
| Data model | Priority is typed and defaults to `normal` |
| Interface | Task cards expose state without extra chrome |
| Keyboard | `n` opens the composer when no modifier is held |
| Empty state | The first action is visible and specific |

### Ordinary diff-style notes still work

A fence that is not a unified patch falls back to Tooee's syntax-highlighted code renderer:

```diff
- vague empty-state copy
+ a direct prompt for the next action
```

---

_Press `q` when the review is complete._
30 changes: 30 additions & 0 deletions examples/view-markdown-diffs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bun
/**
* view-markdown-diffs.ts - Demonstrates Hunk-backed patches inside Markdown
*
* This example shows:
* - Loading a Markdown document from a separate file
* - Rendering real `diff` and `patch` fences through @tooee/diff
* - Selecting split, hidden-line-number, and wrapped layouts per fence
* - Falling back to a code block when a diff fence is not a unified patch
*
* Run: bun examples/view-markdown-diffs.ts
* Controls: j/k move, h/l pan, q quit, t/T cycle themes
*/

import { launch } from "@tooee/view";
import type { ContentProvider } from "@tooee/view";

const showcasePath = new URL("diff-showcase.md", import.meta.url);

const contentProvider: ContentProvider = {
async load() {
return {
format: "markdown",
markdown: await Bun.file(showcasePath).text(),
title: "Pocket Tasks · Patch Review",
};
},
};

await launch({ contentProvider });
2 changes: 2 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ export interface TooeeConfig {
wrap?: boolean;
gutter?: boolean;
copyOnSelect?: boolean | "primary" | "clipboard";
/** Initial layout for diff content. Defaults to "stack" (unified). */
diffLayout?: "split" | "stack";
};
}
73 changes: 73 additions & 0 deletions packages/diff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# @tooee/diff

Unified and split diff rendering for Tooee, drawn by [Hunk](https://github.com/modem-dev/hunk)'s
public OpenTUI primitives (`hunkdiff/opentui`).

Part of the [Tooee](https://github.com/gingerhendrix/tooee) monorepo. See the main repo for
documentation.

## What it provides

- `buildDiffModel(patch)` — parses unified patch text into navigation rows: one row per file
header and one per `@@` hunk, each carrying its own patch text and its span in the original
patch.
- `DiffView` — a `row-document` whose rows are those diff rows. The row document stays the only
scroll owner, so the cursor, search decorations, marks, scroll-follow and mouse routing all keep
working.
- `diffCodeBlockRenderer` / `DIFF_CODE_BLOCK_RENDERERS` — a Markdown code-block renderer that
draws ` ```diff ` and ` ```patch ` fences as Hunk blocks. `@tooee/view` registers it by default.
- `resolveHunkDiffTheme` — maps a Tooee theme onto the closest bundled Hunk theme.
- `isDiffPatch` — content sniffing for patch text.

`@tooee/diff` is the only package that imports `hunkdiff`. `@tooee/renderers` stays free of it.

## Row model

Hunk renders a whole file at a time, but a diff is only pleasant to navigate hunk by hunk. A hunk
row therefore carries a copy of its file whose `metadata.hunks` is narrowed to a single hunk while
the whole-file line arrays stay intact — so line numbers and the `··· N unchanged lines ···`
counts still resolve against the complete file.

Files Hunk renders without hunks (binary, too large, untracked) contribute one `body` row instead,
so the notice Hunk draws for them is still shown.

## Fence options

Words after the fence type are read as options; unknown words are ignored.

```diff split nolines wrap

| Word | Effect |
| --------- | ------------------------------------------------------------ |
| `split` | Side-by-side layout (falls back to stacked below 80 columns) |
| `nolines` | Hides Hunk's line-number columns |
| `wrap` | Wraps long lines instead of clipping them |

A fence whose body is not a real unified diff — prose-style `+`/`-` bullets, for instance —
returns `null` and falls back to the default syntax-highlighted code block.

## Known limits

- **Line-number column width is per hunk.** Hunk sizes its line-number columns from the hunks it
is given, and each hunk row is given one hunk, so two hunks of the same file can differ by a
column when their line numbers differ in digit count. Everything else — collapsed-gap counts,
content, word-level highlights — matches a whole-file render.
- **Themes are approximated.** Hunk resolves one of its own bundled palettes by name and accepts
no custom colour table, so each Tooee theme is mapped to the closest bundled Hunk theme rather
than reproduced exactly. Unmapped (user) themes fall back to GitHub's palette on the matching
light/dark side.
- **Marks are not painted inside hunks.** Row-level decorations (cursor, search, selection, marks)
paint under the row, but Hunk draws its own backgrounds over most of it, so a mark on a diff row
reads mainly from the gutter sign.
- **Diff content is replace-only when streaming.** `ContentChunk`'s `append` does not accept
`diff`; send the full patch through a `replace` chunk instead.
- **Peer range.** `hunkdiff@0.18.0` declares `@opentui/core`/`@opentui/react` `^0.4.3`. It runs and
type-checks against Tooee's `0.5.1`, and Bun resolves it without a warning inside this workspace,
but a standalone install of `@tooee/diff` may print a peer-dependency warning until Hunk widens
the range. The version is pinned exactly because Hunk is pre-1.0.
- **Install weight.** `hunkdiff` declares the `bun` npm package as a runtime dependency and ships a
prebuilt CLI binary as an optional one, neither of which `hunkdiff/opentui` imports. Adding this
package grew the local Bun store by roughly 580 MB, of which about 400 MB is the Bun binary and
its platform variants and 134 MB is `hunkdiff-linux-x64`. Only the ~18 MB `hunkdiff/opentui`
bundle is actually used. Fixing this needs an upstream packaging change; `bun patch` cannot drop
a declared dependency from the resolution graph.
58 changes: 58 additions & 0 deletions packages/diff/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@tooee/diff",
"version": "0.6.3",
"description": "Hunk-backed unified/split diff rendering for Tooee",
"keywords": [
"cli",
"diff",
"opentui",
"patch",
"terminal",
"tui"
],
"homepage": "https://github.com/gingerhendrix/tooee",
"bugs": "https://github.com/gingerhendrix/tooee/issues",
"license": "MIT",
"author": "Gareth Andrew",
"repository": {
"type": "git",
"url": "https://github.com/gingerhendrix/tooee.git",
"directory": "packages/diff"
},
"files": [
"dist",
"src"
],
"type": "module",
"exports": {
".": {
"import": {
"@tooee/source": "./src/index.ts",
"default": "./dist/index.js"
}
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tooee/renderers": "workspace:*",
"@tooee/themes": "workspace:*",
"hunkdiff": "0.18.0"
},
"devDependencies": {
"@opentui/core": "^0.5.1",
"@opentui/react": "^0.5.1",
"@types/bun": "^1.3.10",
"@types/react": "^19.2.14",
"typescript": "^5.9.3"
},
"peerDependencies": {
"@opentui/core": "^0.5.1",
"@opentui/react": "^0.5.1",
"react": "^18.0.0 || ^19.0.0"
}
}
18 changes: 18 additions & 0 deletions packages/diff/src/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Lines that only appear at the start of a unified diff. */
const GIT_HEADER = /^diff --git /mu;
const UNIFIED_HEADERS = /^--- .*\n\+\+\+ /mu;
const HUNK_HEADER = /^@@ -\d/mu;

/**
* Whether text looks like a unified patch.
*
* Used to route extension-less input (stdin, files without a `.patch`/`.diff`
* suffix) to the diff viewer. A hunk header is required as well as a file
* header so prose containing a stray `--- ` rule is not mistaken for a diff.
*/
export const isDiffPatch = function isDiffPatch(text: string): boolean {
if (!HUNK_HEADER.test(text)) {
return false;
}
return GIT_HEADER.test(text) || UNIFIED_HEADERS.test(text);
};
Loading
Loading