Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/borderless-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add a borderless chrome mode that replaces drawn borders and separators with filled background bands derived from the active theme. Each theme gains a derived surface elevation ladder so file headers, the unchanged-lines band, popups, and comment boxes stay visually distinct without lines. Toggle it from the View menu, the `Shift+B` keybinding, the `borderless` config key, or the `--borderless` flag.
4 changes: 4 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function buildCommonOptions(
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
borderless: resolveBooleanFlag(argv, "--borderless", "--no-borderless"),
transparentBackground: resolveBooleanFlag(argv, "--transparent-bg", "--no-transparent-bg"),
};
}
Expand All @@ -94,6 +95,8 @@ function applyCommonOptions(command: Command) {
.option("--no-hunk-headers", "hide hunk metadata rows")
.option("--agent-notes", "show agent notes by default")
.option("--no-agent-notes", "hide agent notes by default")
.option("--borderless", "use filled background bands instead of chrome borders")
.option("--no-borderless", "use drawn borders for chrome")
.option("--transparent-bg", "let terminal background show through Hunk surfaces")
.option("--no-transparent-bg", "paint Hunk surfaces with the active theme");
}
Expand Down Expand Up @@ -157,6 +160,7 @@ function renderCliHelp() {
" --wrap / --no-wrap wrap or truncate long diff lines",
" --hunk-headers / --no-hunk-headers show or hide hunk metadata rows",
" --agent-notes / --no-agent-notes show or hide agent notes by default",
" --borderless / --no-borderless fill chrome with background bands or draw borders",
" --transparent-bg / --no-transparent-bg let terminal background show through Hunk surfaces",
" --theme <theme> named theme override",
"",
Expand Down
5 changes: 5 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
showHunkHeaders: true,
showAgentNotes: false,
copyDecorations: false,
borderless: false,
};

interface ConfigResolutionOptions {
Expand Down Expand Up @@ -238,6 +239,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
hunkHeaders: normalizeBoolean(source.hunk_headers),
agentNotes: normalizeBoolean(source.agent_notes),
copyDecorations: normalizeBoolean(source.copy_decorations),
borderless: normalizeBoolean(source.borderless),
transparentBackground:
normalizeBoolean(source.transparentBackground) ??
normalizeBoolean(source.transparent_background),
Expand All @@ -261,6 +263,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
agentNotes: overrides.agentNotes ?? base.agentNotes,
copyDecorations: overrides.copyDecorations ?? base.copyDecorations,
borderless: overrides.borderless ?? base.borderless,
transparentBackground: overrides.transparentBackground ?? base.transparentBackground,
colorMoved: overrides.colorMoved ?? base.colorMoved,
};
Expand Down Expand Up @@ -327,6 +330,7 @@ export function resolveConfiguredCliInput(
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes,
copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations,
borderless: DEFAULT_VIEW_PREFERENCES.borderless,
transparentBackground: false,
};

Expand Down Expand Up @@ -357,6 +361,7 @@ export function resolveConfiguredCliInput(
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations,
borderless: resolvedOptions.borderless ?? DEFAULT_VIEW_PREFERENCES.borderless,
transparentBackground: resolvedOptions.transparentBackground ?? false,
colorMoved: resolvedOptions.colorMoved,
};
Expand Down
1 change: 1 addition & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,5 +460,6 @@ export async function loadAppBootstrap(
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowAgentNotes: input.options.agentNotes ?? false,
initialCopyDecorations: input.options.copyDecorations ?? false,
initialBorderless: input.options.borderless ?? false,
};
}
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface CommonOptions {
hunkHeaders?: boolean;
agentNotes?: boolean;
copyDecorations?: boolean;
borderless?: boolean;
transparentBackground?: boolean;
colorMoved?: boolean;
}
Expand Down Expand Up @@ -157,6 +158,7 @@ export interface PersistedViewPreferences {
showHunkHeaders: boolean;
showAgentNotes: boolean;
copyDecorations: boolean;
borderless: boolean;
}

export interface HelpCommandInput {
Expand Down Expand Up @@ -366,4 +368,5 @@ export interface AppBootstrap {
initialShowHunkHeaders?: boolean;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
initialBorderless?: boolean;
}
36 changes: 31 additions & 5 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { openSelectedFileInEditor } from "./lib/openInEditor";
import { resolveResponsiveLayout } from "./lib/responsive";
import { resizeSidebarWidth } from "./lib/sidebar";
import { availableThemes, resolveTheme, withTransparentBackground } from "./themes";
import type { ChromeMode } from "./themes";

type FocusArea = "files" | "filter" | "note";
type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string };
Expand Down Expand Up @@ -68,6 +69,7 @@ function withCurrentViewOptions(
showHunkHeaders: boolean;
showLineNumbers: boolean;
wrapLines: boolean;
borderless: boolean;
},
): CliInput {
return {
Expand All @@ -80,6 +82,7 @@ function withCurrentViewOptions(
hunkHeaders: view.showHunkHeaders,
lineNumbers: view.showLineNumbers,
wrapLines: view.wrapLines,
borderless: view.borderless,
},
};
}
Expand Down Expand Up @@ -134,6 +137,7 @@ export function App({
const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false);
const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0);
const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true);
const [borderless, setBorderless] = useState(bootstrap.initialBorderless ?? false);
const [themeSelectorState, setThemeSelectorState] = useState<ThemeSelectorState>({
open: false,
selectedIndex: 0,
Expand All @@ -156,10 +160,18 @@ export function App({
[bootstrap.customTheme],
);
const effectiveThemeId = themeSelectorState.previewThemeId ?? themeId;
const baseTheme = useMemo(
() => resolveTheme(effectiveThemeId, detectedThemeMode ?? null, bootstrap.customTheme),
[effectiveThemeId, detectedThemeMode, bootstrap.customTheme],
);
const baseTheme = useMemo(() => {
const resolved = resolveTheme(
effectiveThemeId,
detectedThemeMode ?? null,
bootstrap.customTheme,
);
const chrome: ChromeMode = borderless ? "borderless" : "bordered";
// Carry the chrome mode on the base theme so overlays (menus/dialogs), which
// use baseTheme for a solid background, render borderless too. Only clone when
// the mode differs so the lazy syntax-style getter stays untouched when bordered.
return resolved.chrome === chrome ? resolved : { ...resolved, chrome };
}, [effectiveThemeId, detectedThemeMode, bootstrap.customTheme, borderless]);
const activeTheme = useMemo(
() =>
bootstrap.input.options.transparentBackground
Expand Down Expand Up @@ -238,7 +250,9 @@ export function App({
showAgentNotes,
});

const bodyPadding = pagerMode ? 0 : BODY_PADDING;
// Borderless chrome fills the width edge-to-edge; the legacy body gutter would otherwise expose
// the root (code) background as a 1-column band down each side, reading as a stray vertical bar.
const bodyPadding = pagerMode || borderless ? 0 : BODY_PADDING;
const bodyWidth = Math.max(0, terminal.width - bodyPadding);
const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width);
const canForceShowSidebar = bodyWidth >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + DIFF_MIN_WIDTH;
Expand Down Expand Up @@ -504,6 +518,11 @@ export function App({
setShowHunkHeaders((current) => !current);
};

/** Switch chrome between drawn borders and filled background bands. */
const toggleBorderless = () => {
setBorderless((current) => !current);
};

const canRefreshCurrentInput = canReloadInput(bootstrap.input);
const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput);

Expand All @@ -520,6 +539,7 @@ export function App({
showHunkHeaders,
showLineNumbers,
wrapLines,
borderless,
});

await onReloadSession(nextInput, {
Expand All @@ -542,6 +562,7 @@ export function App({
showLineNumbers,
themeId,
wrapLines,
borderless,
]);

const triggerRefreshCurrentInput = useCallback(() => {
Expand Down Expand Up @@ -744,6 +765,8 @@ export function App({
showHunkHeaders,
showLineNumbers,
renderSidebar,
borderless,
toggleBorderless,
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
Expand Down Expand Up @@ -775,6 +798,8 @@ export function App({
showHunkHeaders,
showLineNumbers,
renderSidebar,
borderless,
toggleBorderless,
toggleAgentNotes,
toggleFocusArea,
toggleHelp,
Expand Down Expand Up @@ -834,6 +859,7 @@ export function App({
switchMenu,
themeSelectorOpen: themeSelectorState.open,
toggleAgentNotes,
toggleBorderless,
toggleFocusArea,
toggleGapForSelectedHunk: review.toggleSelectedHunkGap,
toggleHelp,
Expand Down
7 changes: 5 additions & 2 deletions src/ui/components/chrome/AgentSkillDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";
import { chromeSurfaceBg } from "./chromeSurface";
import { ModalFrame } from "./ModalFrame";

export const AGENT_SKILL_COMMAND = "hunk skill path";
Expand Down Expand Up @@ -61,8 +62,10 @@ export function AgentSkillDialog({
style={{
width: cardWidth,
height: promptRows.length + 2,
border: true,
borderColor: theme.border,
// Inner prompt box: a bordered inset, or a filled note band when borderless.
...(theme.chrome === "borderless"
? { backgroundColor: chromeSurfaceBg(theme, "note") }
: { border: true, borderColor: theme.border }),
flexDirection: "column",
paddingLeft: 1,
paddingRight: 1,
Expand Down
27 changes: 27 additions & 0 deletions src/ui/components/chrome/ChromeSeparator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { fitText } from "../../lib/text";
import type { AppTheme } from "../../themes";

/**
* Horizontal boundary between stacked sections (e.g. file blocks). Draws a rule
* glyph in bordered mode; renders nothing in borderless mode, where the adjacent
* section-header band carries the separation instead.
*/
export function ChromeSeparator({ theme, width }: { theme: AppTheme; width: number }) {
if (theme.chrome === "borderless") {
return null;
}

return (
<box
style={{
width: "100%",
height: 1,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: theme.panel,
}}
>
<text fg={theme.border}>{fitText("─".repeat(width), width)}</text>
</box>
);
}
1 change: 1 addition & 0 deletions src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function HelpDialog({
["a", "toggle AI notes"],
["z", "toggle unchanged context"],
["l / w / m", "lines / wrap / metadata"],
["B", "toggle borderless chrome"],
["e", "open file in $EDITOR"],
],
},
Expand Down
6 changes: 4 additions & 2 deletions src/ui/components/chrome/MenuBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AppTheme } from "../../themes";
import { fitText } from "../../lib/text";
import { topChromeBg } from "./chromeSurface";
import type { MenuId, MenuSpec } from "./menu";

/** Render the top menu bar and the current changeset title. */
Expand All @@ -20,11 +21,12 @@ export function MenuBar({
onHoverMenu: (menuId: MenuId) => void;
onToggleMenu: (menuId: MenuId) => void;
}) {
const chromeBg = topChromeBg(theme);
return (
<box
style={{
height: 1,
backgroundColor: theme.panelAlt,
backgroundColor: chromeBg,
flexDirection: "row",
alignItems: "center",
paddingLeft: 1,
Expand All @@ -39,7 +41,7 @@ export function MenuBar({
style={{
width: menu.width,
height: 1,
backgroundColor: active ? theme.accentMuted : theme.panelAlt,
backgroundColor: active ? theme.accentMuted : chromeBg,
}}
onMouseUp={() => onToggleMenu(menu.id)}
onMouseOver={() => onHoverMenu(menu.id)}
Expand Down
31 changes: 24 additions & 7 deletions src/ui/components/chrome/MenuDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AppTheme } from "../../themes";
import { blendHex } from "../../lib/color";
import { padText } from "../../lib/text";
import { chromeSurfaceBg, overlaySurfaceStyle } from "./chromeSurface";
import type { MenuEntry, MenuId, MenuSpec } from "./menu";

/** Render one actionable menu line with an optional keyboard hint. */
Expand Down Expand Up @@ -56,6 +58,14 @@ export function MenuDropdown({
}) {
const clampedWidth = Math.min(activeMenuWidth, Math.max(22, terminalWidth - 2));
const clampedLeft = Math.max(1, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth - 1));
const borderless = theme.chrome === "borderless";
// Bordered menus add 2 rows for the top/bottom rule; borderless ones have no border, so the
// box must hug its entries or it trails empty band rows.
const dropdownHeight = activeMenuEntries.length + (borderless ? 0 : 2);
// A faint rule keeps separators legible against the filled band without reintroducing chrome.
const separatorFg = borderless
? blendHex(theme.muted, chromeSurfaceBg(theme, "overlay"), 0.5)
: theme.border;

return (
<box
Expand All @@ -64,21 +74,25 @@ export function MenuDropdown({
top: 1,
left: clampedLeft,
width: clampedWidth,
height: activeMenuEntries.length + 2,
height: dropdownHeight,
zIndex: 40,
border: true,
borderColor: theme.border,
backgroundColor: theme.panel,
...overlaySurfaceStyle(theme, theme.border),
flexDirection: "column",
}}
>
{activeMenuEntries.map((entry, index) =>
entry.kind === "separator" ? (
<box
key={`${activeMenuId}:separator:${index}`}
style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}
style={{
height: 1,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: chromeSurfaceBg(theme, "overlay"),
}}
>
<text fg={theme.border}>{padText("-".repeat(clampedWidth - 4), clampedWidth - 2)}</text>
{/* Both modes rule off groups; borderless uses a fainter line so it stays subtle. */}
<text fg={separatorFg}>{padText("-".repeat(clampedWidth - 4), clampedWidth - 2)}</text>
</box>
) : (
<box
Expand All @@ -88,7 +102,10 @@ export function MenuDropdown({
paddingLeft: 1,
paddingRight: 1,
flexDirection: "row",
backgroundColor: activeMenuItemIndex === index ? theme.accentMuted : theme.panel,
backgroundColor:
activeMenuItemIndex === index
? chromeSurfaceBg(theme, "selection")
: chromeSurfaceBg(theme, "overlay"),
}}
onMouseOver={() => onHoverItem(index)}
onMouseUp={() => onSelectItem(entry)}
Expand Down
5 changes: 2 additions & 3 deletions src/ui/components/chrome/ModalFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import type { ReactNode } from "react";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";
import { overlaySurfaceStyle } from "./chromeSurface";

/** Render a centered framed modal container that other dialogs can reuse. */
export function ModalFrame({
Expand Down Expand Up @@ -53,9 +54,7 @@ export function ModalFrame({
width: clampedWidth,
height: clampedHeight,
zIndex: 60,
border: true,
borderColor: theme.accent,
backgroundColor: theme.panel,
...overlaySurfaceStyle(theme, theme.accent),
flexDirection: "column",
}}
onMouseScroll={(event: TuiMouseEvent) => {
Expand Down
Loading