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
2 changes: 2 additions & 0 deletions .changeset/shared-hunk-session-router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 4 additions & 3 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
* `bun run deps:check` fails on any violation not in the baseline.
*/

// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: the composition shell, the two
// named session adapter hooks, and the session-navigation resolution helper those hooks
// share. Everything else in packages/hunk/src/ui stays presentation-only.
// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: App/AppHost,
// HunkSessionHost, runInteractiveApp, the named session adapter hooks, and their shared navigation
// helper. Everything else in packages/hunk/src/ui stays presentation-only.
const UI_SESSION_ADAPTERS = [
"^packages/hunk/src/ui/App\\.tsx$",
"^packages/hunk/src/ui/AppHost\\.tsx$",
"^packages/hunk/src/ui/runInteractiveApp\\.tsx$",
"^packages/hunk/src/ui/session/HunkSessionHost\\.tsx$",
"^packages/hunk/src/ui/hooks/useHunkSessionBridge\\.ts$",
"^packages/hunk/src/ui/hooks/useTerminalReview\\.ts$",
"^packages/hunk/src/ui/lib/reviewState\\.ts$",
Expand Down
6 changes: 4 additions & 2 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,10 @@ second root/config pass when a global, config-path, or CLI adapter recognizes a
repository unavailable to the bundled catalog.

`hunk log` follows the same boundary. Core/app and `src/ui/history/` own the built-in command,
validated graph planning, presentation, themes, paging, terminal lifecycle, and child-process
orchestration. The selected adapter's public `history` capability owns traversal, filtering,
validated graph planning, presentation, themes, paging, and child-process orchestration. The shared
`src/ui/session/` runner owns the process-level renderer/root lifetime, while its closed host routes
retained history and fresh review surfaces without giving either surface terminal ownership. The
selected adapter's public `history` capability owns traversal, filtering,
immutable revision and parent identities, structured decorations, and the declarative review action
for a selected item. The host treats those ids as opaque and never constructs provider revision
syntax or decides root/merge comparison semantics. History pages remain child-before-parent across
Expand Down
2 changes: 1 addition & 1 deletion docs/module-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ packages/hunk/src/session daemon/broker transport + protocol; consume
packages/hunk/src/app startup composition: CLI parsing plus the wiring of core,
extensions, and the session broker; no rendering
packages/hunk/src/ui terminal surface; only the composition shell (App, AppHost,
runInteractiveApp), the named session adapter hooks
runInteractiveApp, session/HunkSessionHost), the named session adapter hooks
(useTerminalReview, useHunkSessionBridge), and their shared
navigation helper (ui/lib/reviewState) may import app/session
packages/hunk/src/opentui published facade re-exporting ui/core pieces for `hunkdiff/opentui`
Expand Down
11 changes: 8 additions & 3 deletions docs/source-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Use it when adding a new module or deciding where an existing responsibility bel

```text
packages/hunk/src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap
packages/hunk/src/app/session/ mounted-review registration, bridge, and reload authorization
packages/hunk/src/app/session/ mounted-review runtime, registration, bridge, and reload authorization
packages/hunk/src/core/ review model, patch handling, VCS contracts, configuration, and
runtime primitives
packages/hunk/src/core/changeset/ the changeset model and the pipeline that acquires one: loaders,
Expand All @@ -27,6 +27,7 @@ packages/hunk/src/session/client/ shared session-daemon HTTP and compatibility
packages/hunk/src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting
packages/hunk/src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections
packages/hunk/src/ui/ interactive review application, rendering, interaction, and chrome
packages/hunk/src/ui/session/ one OpenTUI renderer/root lifetime and the closed history/review surface router
packages/hunk/src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary
packages/hunk/src/opentui/ public `hunkdiff/opentui` component boundary
packages/hunk/src/lib/ small product-wide utilities with no feature ownership
Expand Down Expand Up @@ -58,8 +59,12 @@ and bundled-provider -> core boundaries, including the public extension-barrel r
Initial launch and live-session reload use `app/sessionBootstrap.ts`. That service is the one
place that applies extension registrations, resolves extension-aware VCS selection, loads the
normalized changeset, applies changeset transforms, and attaches session theme/config state.
Callers retain their distinct lifecycle work (terminal setup, extension rediscovery, notices,
and mounted-app state), but must not recreate this ordering.
Callers retain their distinct lifecycle work (extension rediscovery, notices, and mounted-app
state), but must not recreate this ordering. Interactive entry adapters supply their terminal,
signal, mouse, and exit-status policies to `ui/session/runHunkSession`; that runner owns one
renderer/root lifetime and waits for `HunkSessionHost` to finish graceful surface cleanup before
restoring the terminal. `HunkSessionHost` routes only the current history and review surfaces;
review reload and extension-event commit ordering remain with `AppHost`.

## Migration policy

Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import type { HistoryRuntime } from "../history/types";
import { prepareEmbeddedHistoryReview } from "../runInteractiveApp";
import type { ExtensionLoadResult } from "../extensions/types";
import { prepareEmbeddedHistoryReview } from "./historyReview";

/** Provide only the provider-neutral fields embedded review startup consumes. */
function createTestRuntime() {
const extensionSession = { registry: {} };
function createTestRequest() {
const extensionSession = { registry: {} } as unknown as ExtensionLoadResult;
return {
repoRoot: resolve("repository"),
action: { kind: "revision-show", revisionId: "--opaque:id" } as const,
startupCwd: resolve("invocation"),
providerId: "opaque-vcs",
input: { extensionPaths: ["extensions/provider.ts"], extensionsEnabled: true },
extensionPaths: ["extensions/provider.ts"],
extensionsEnabled: true,
extensionSession,
} as unknown as HistoryRuntime;
};
}

describe("embedded history review bootstrap", () => {
test("preserves opaque actions, invocation-relative extensions, cwd, theme, and signal", async () => {
const abort = new AbortController();
let captured: { argv: string[]; deps: Record<string, unknown> } | undefined;
const runtime = createTestRuntime();
const request = createTestRequest();
const result = await prepareEmbeddedHistoryReview(
runtime,
{ kind: "revision-show", revisionId: "--opaque:id" },
{ ...request, themeId: "github-dark", themeMode: "dark" },
{
themeId: "github-dark",
themeMode: "dark",
signal: abort.signal,
env: {},
prepareStartupPlanImpl: (async (argv: string[], deps: Record<string, unknown>) => {
captured = { argv, deps };
return {
kind: "app",
bootstrap: { extensions: runtime.extensionSession },
bootstrap: { extensions: request.extensionSession },
cliInput: {},
controllingTerminal: null,
};
Expand All @@ -47,7 +45,7 @@ describe("embedded history review bootstrap", () => {
terminalThemeMode: "dark",
signal: abort.signal,
});
expect(captured?.deps.borrowedExtensionLoad).toBe(runtime.extensionSession);
expect(captured?.deps.borrowedExtensionLoad).toBe(request.extensionSession);
expect(result.borrowsExtensions).toBe(true);
expect(captured?.argv.join(" ")).not.toContain("--opaque:id");
});
Expand All @@ -58,8 +56,7 @@ describe("embedded history review bootstrap", () => {
let called = false;
await expect(
prepareEmbeddedHistoryReview(
createTestRuntime(),
{ kind: "revision-show", revisionId: "opaque" },
{ ...createTestRequest(), action: { kind: "revision-show", revisionId: "opaque" } },
{
signal: abort.signal,
prepareStartupPlanImpl: (async () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/hunk/src/app/historyReview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { resolve } from "node:path";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New Filenames Violate Convention

This new file uses a camel-cased filename, but the repository requires TypeScript filenames to use dash-case. The same violation appears in src/app/session/reviewRuntime.ts and src/ui/session/HunkSessionHost.tsx, with another occurrence elsewhere in the new session module. These files must be renamed and their imports updated before merging.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/historyReview.ts
Line: 1

Comment:
**New Filenames Violate Convention**

This new file uses a camel-cased filename, but the repository requires TypeScript filenames to use dash-case. The same violation appears in `src/app/session/reviewRuntime.ts` and `src/ui/session/HunkSessionHost.tsx`, with another occurrence elsewhere in the new session module. These files must be renamed and their imports updated before merging.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repository does not require dash-case TypeScript filenames. Its established naming convention includes AppHost.tsx, LogApp.tsx, useAppKeyboardShortcuts.ts, and many other camel-cased component/hook/module names. The cited rule belongs to a different repository (modem-dev/modem), while Hunk’s own AGENTS.md contains no dash-case requirement. These new filenames follow the local convention, so no rename is needed.

This comment was generated by Pi using gpt-5.6-sol

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I incorrectly applied a dash-case convention from another repository; Hunk's AGENTS.md does not require it, and the existing AppHost.tsx, LogApp.tsx, and useAppKeyboardShortcuts.ts names support the local convention. No rename is needed, and I withdraw this comment.

import type { AppBootstrap } from "../core/bootstrap";
import type { TerminalThemeMode } from "../core/theme/detection";
import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types";
import { retireExtensionLoadResult } from "../extensions/events";
import type { ExtensionLoadResult } from "../extensions/types";
import { prepareStartupPlan } from "./startup";

export interface EmbeddedHistoryReviewRequest {
action: ExtensionVcsHistoryReviewAction;
providerId: string;
startupCwd: string;
extensionsEnabled: boolean;
extensionPaths: readonly string[];
extensionSession?: ExtensionLoadResult;
themeId?: string;
themeMode?: TerminalThemeMode;
}

export interface EmbeddedHistoryReview {
bootstrap: AppBootstrap<ExtensionLoadResult>;
/** The history session owns this bootstrap's extension registry. */
borrowsExtensions: boolean;
}

/** Convert a provider-owned review declaration into one option-safe internal invocation. */
export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) {
const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url");
return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload];
}

/** Bootstrap one provider-planned history review without creating or claiming a renderer. */
export async function prepareEmbeddedHistoryReview(
request: EmbeddedHistoryReviewRequest,
{
signal,
env = process.env,
prepareStartupPlanImpl = prepareStartupPlan,
}: {
signal?: AbortSignal;
env?: NodeJS.ProcessEnv;
prepareStartupPlanImpl?: typeof prepareStartupPlan;
} = {},
): Promise<EmbeddedHistoryReview> {
signal?.throwIfAborted();
const extensionArgs = request.extensionPaths.flatMap((path) => [
"--extension",
resolve(request.startupCwd, path),
]);
const args = [
...historyReviewArgs(request.action),
"--vcs",
request.providerId,
...(request.themeId ? ["--theme", request.themeId] : []),
...(request.extensionsEnabled ? extensionArgs : ["--no-extensions"]),
];
const plan = await prepareStartupPlanImpl(["hunk", "hunk", ...args], {
cwd: request.startupCwd,
env,
signal,
borrowedExtensionLoad: request.extensionSession,
stdinIsTTY: true,
stdoutIsTTY: true,
terminalThemeMode: request.themeMode,
});
if (signal?.aborted && plan.kind === "app") {
plan.controllingTerminal?.close();
if (plan.bootstrap.extensions !== request.extensionSession) {
await retireExtensionLoadResult(plan.bootstrap.extensions);
}
signal.throwIfAborted();
}
if (plan.kind !== "app") {
throw new Error("The selected commit did not produce an interactive review.");
}
plan.controllingTerminal?.close();
return {
bootstrap: plan.bootstrap as AppBootstrap<ExtensionLoadResult>,
borrowsExtensions: plan.bootstrap.extensions === request.extensionSession,
};
}
41 changes: 41 additions & 0 deletions packages/hunk/src/app/session/reviewRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createNativeSessionBrokerLifecycleClock } from "@hunk/session-broker";
import type { AppBootstrap } from "../../core/bootstrap";
import { SessionBrokerClient } from "../../session/broker/brokerClient";
import { reportHunkSessionBrokerLifecycleDefect } from "../../session/broker/lifecycleDefect";
import { ReviewProducer } from "../review/producer";
import { createInitialSessionSnapshot, createSessionRegistration } from "./registration";

export interface ReviewSessionRuntime {
hostClient: SessionBrokerClient;
reviewProducer: ReviewProducer;
stop(): void;
}

/** Create broker and producer resources for one independently mountable review surface. */
export function createReviewSessionRuntime(
bootstrap: AppBootstrap,
cwd = process.cwd(),
): ReviewSessionRuntime {
const reviewProducer = new ReviewProducer({
files: bootstrap.changeset.files,
sourceLabel: bootstrap.changeset.sourceLabel,
});
const publication = reviewProducer.getPublication();
const lifecycleClock = createNativeSessionBrokerLifecycleClock();
const hostClient = new SessionBrokerClient(
createSessionRegistration(bootstrap, publication, cwd),
createInitialSessionSnapshot(bootstrap, publication),
{ lifecycleClock, onDefect: reportHunkSessionBrokerLifecycleDefect },
);
hostClient.start();
let stopped = false;
return {
hostClient,
reviewProducer,
stop() {
if (stopped) return;
hostClient.stop();
stopped = true;
},
};
}
24 changes: 0 additions & 24 deletions packages/hunk/src/core/process/shutdown.test.ts

This file was deleted.

27 changes: 0 additions & 27 deletions packages/hunk/src/core/process/shutdown.ts

This file was deleted.

15 changes: 3 additions & 12 deletions packages/hunk/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,10 @@ async function main() {
}

// OpenTUI stays behind the interactive plan so headless commands never materialize its embedded
// native library. The highlighting client starts the compiled worker only when an opted-in,
// eligible diff needs it, so normal sessions do not pay its startup cost. The interactive app
// owns that worker's disposal: this call returns once the app is mounted, not once it exits.
// native library. The shared interactive runner owns the highlighting worker and terminal until
// the mounted surface acknowledges graceful shutdown.
const { runInteractiveApp } = await import("./ui/runInteractiveApp");
try {
await runInteractiveApp(startupPlan);
} catch (error) {
startupPlan.controllingTerminal?.close();
await (
await import("./extensions/events")
).retireExtensionLoadResult(startupPlan.bootstrap.extensions);
throw error;
}
await runInteractiveApp(startupPlan);
}

await main().catch((error) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/ui/history/runInteractiveHistory.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { historyReviewArgs } from "./runInteractiveHistory";
import { historyReviewArgs } from "../../app/historyReview";

describe("history review child arguments", () => {
test("encodes provider-owned opaque actions without exposing ids to CLI option parsing", () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/hunk/src/ui/history/runInteractiveHistory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
/** Preserve the original import seam while interactive history moves into the log feature folder. */
export { historyReviewArgs } from "../log/reviewLaunch";
/** Preserve the original runner import seam while interactive history lives in the log feature. */
export { runInteractiveLog as runInteractiveHistory } from "../log/runInteractiveLog";
Loading
Loading