+ Append the per-hop identifier width to the hop-count badge on received messages —
+ e.g. (2 · 2B) for a 2-hop path with 2-byte
+ hops. Direct (0-hop) messages show no width. Off by default.
+
+
+
+
void;
+}
+
+const noop = () => {};
+
+const PathHopWidthContext = createContext({
+ showPathHopWidth: false,
+ setShowPathHopWidth: noop,
+});
+
+export function PathHopWidthProvider({
+ showPathHopWidth,
+ setShowPathHopWidth,
+ children,
+}: PathHopWidthContextValue & { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePathHopWidth() {
+ return useContext(PathHopWidthContext);
+}
diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts
index 2db16db..e601dd3 100644
--- a/frontend/src/hooks/useAppShell.ts
+++ b/frontend/src/hooks/useAppShell.ts
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
import { getSavedRenderRichPayloads } from '../utils/richPayloadPreference';
+import { getSavedShowPathHopWidth } from '../utils/pathHopWidthPreference';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash';
@@ -16,12 +17,14 @@ interface UseAppShellResult {
localLabel: LocalLabel;
distanceUnit: DistanceUnit;
renderRichPayloads: boolean;
+ showPathHopWidth: boolean;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
setRenderRichPayloads: (enabled: boolean) => void;
+ setShowPathHopWidth: (enabled: boolean) => void;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
@@ -42,6 +45,7 @@ export function useAppShell(): UseAppShellResult {
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
const [renderRichPayloads, setRenderRichPayloads] = useState(getSavedRenderRichPayloads);
+ const [showPathHopWidth, setShowPathHopWidth] = useState(getSavedShowPathHopWidth);
const previousHashRef = useRef('');
const isOpeningSettingsRef = useRef(false);
const pushedSettingsEntryRef = useRef(false);
@@ -132,12 +136,14 @@ export function useAppShell(): UseAppShellResult {
localLabel,
distanceUnit,
renderRichPayloads,
+ showPathHopWidth,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
setRenderRichPayloads,
+ setShowPathHopWidth,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx
index faed098..3097027 100644
--- a/frontend/src/test/messageList.test.tsx
+++ b/frontend/src/test/messageList.test.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
+import { PathHopWidthProvider } from '../contexts/PathHopWidthContext';
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
const scrollIntoViewMock = vi.fn();
@@ -87,6 +88,66 @@ describe('MessageList channel sender rendering', () => {
expect(screen.queryByText('nl-gr')).not.toBeInTheDocument();
});
+ it('shows per-hop byte width in the path badge when the toggle is on', () => {
+ render(
+ {}}>
+
+
+ );
+
+ expect(screen.getByText('(2 · 2B)')).toBeInTheDocument();
+ expect(screen.getByTitle('View message path (2B per hop)')).toBeInTheDocument();
+ });
+
+ it('hides the width by default (toggle off) and shows only the hop count', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('(2)')).toBeInTheDocument();
+ expect(screen.queryByText('(2 · 2B)')).not.toBeInTheDocument();
+ expect(screen.getByTitle('View message path')).toBeInTheDocument();
+ });
+
+ it('omits the width for direct (0-hop) paths even when the toggle is on', () => {
+ render(
+ {}}>
+
+
+ );
+
+ expect(screen.getByText('(d)')).toBeInTheDocument();
+ expect(screen.getByTitle('View message path')).toBeInTheDocument();
+ });
+
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
render(
{
expect(result.hasMultiple).toBe(true);
});
});
+
+describe('formatPathHopWidths', () => {
+ it('returns null for null or empty paths', () => {
+ expect(formatPathHopWidths(null)).toBeNull();
+ expect(formatPathHopWidths([])).toBeNull();
+ });
+
+ it('returns null for direct (0-hop) paths', () => {
+ expect(formatPathHopWidths([{ path: '', received_at: 1700000000 }])).toBeNull();
+ });
+
+ it('returns null for legacy paths without hop metadata', () => {
+ // No path_len -> width is not derivable, so we show nothing rather than guess.
+ expect(formatPathHopWidths([{ path: 'AABBCCDD', received_at: 1700000000 }])).toBeNull();
+ });
+
+ it('derives 1-byte width', () => {
+ expect(formatPathHopWidths([{ path: '1A2B', path_len: 2, received_at: 1700000000 }])).toBe(
+ '1B'
+ );
+ });
+
+ it('derives 2-byte width', () => {
+ expect(formatPathHopWidths([{ path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }])).toBe(
+ '2B'
+ );
+ });
+
+ it('derives 3-byte width', () => {
+ expect(
+ formatPathHopWidths([{ path: 'AABBCCDDEEFF', path_len: 2, received_at: 1700000000 }])
+ ).toBe('3B');
+ });
+
+ it('dedupes identical widths across repeat paths', () => {
+ expect(
+ formatPathHopWidths([
+ { path: 'AABBCCDD', path_len: 2, received_at: 1700000000 },
+ { path: '11223344', path_len: 2, received_at: 1700000001 },
+ ])
+ ).toBe('2B');
+ });
+
+ it('ignores direct paths when other paths have a width', () => {
+ expect(
+ formatPathHopWidths([
+ { path: '', received_at: 1700000000 },
+ { path: 'AABBCCDD', path_len: 2, received_at: 1700000001 },
+ ])
+ ).toBe('2B');
+ });
+
+ it('joins mixed widths sorted ascending', () => {
+ expect(
+ formatPathHopWidths([
+ { path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }, // 2-byte
+ { path: '1A2B', path_len: 2, received_at: 1700000001 }, // 1-byte
+ ])
+ ).toBe('1B/2B');
+ });
+});
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index 3b0cffd..9867099 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -22,6 +22,7 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
+import { SHOW_PATH_HOP_WIDTH_KEY } from '../utils/pathHopWidthPreference';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
@@ -656,6 +657,19 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
});
+ it('defaults the path-hop-width toggle to off and persists enabling it', () => {
+ renderModal();
+ openLocalSection();
+
+ const checkbox = screen.getByLabelText('Show Path Hop Width');
+ expect(checkbox).not.toBeChecked();
+ expect(localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY)).toBeNull();
+
+ fireEvent.click(checkbox);
+
+ expect(localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY)).toBe('true');
+ });
+
it('defaults distance units to metric and stores local changes', () => {
renderModal();
openLocalSection();
diff --git a/frontend/src/utils/pathHopWidthPreference.ts b/frontend/src/utils/pathHopWidthPreference.ts
new file mode 100644
index 0000000..65e9cbc
--- /dev/null
+++ b/frontend/src/utils/pathHopWidthPreference.ts
@@ -0,0 +1,25 @@
+// Browser-local preference for showing the per-hop byte width (e.g. "2B")
+// next to the hop-count badge on received messages. Pure display tweak, stored
+// per-browser in localStorage. Off by default.
+
+export const SHOW_PATH_HOP_WIDTH_KEY = 'remoteterm-show-path-hop-width';
+
+export function getSavedShowPathHopWidth(): boolean {
+ try {
+ return localStorage.getItem(SHOW_PATH_HOP_WIDTH_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+export function setSavedShowPathHopWidth(enabled: boolean): void {
+ try {
+ if (enabled) {
+ localStorage.setItem(SHOW_PATH_HOP_WIDTH_KEY, 'true');
+ } else {
+ localStorage.removeItem(SHOW_PATH_HOP_WIDTH_KEY);
+ }
+ } catch {
+ // localStorage may be unavailable
+ }
+}
diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts
index ed4dcfc..ca288e0 100644
--- a/frontend/src/utils/pathUtils.ts
+++ b/frontend/src/utils/pathUtils.ts
@@ -473,6 +473,35 @@ export function formatHopCounts(paths: MessagePath[] | null | undefined): {
return { display, allDirect, hasMultiple };
}
+/**
+ * Compact per-hop width label for a message's paths, e.g. "2B" (2 bytes per
+ * hop) or "1B/2B" when different receive paths used different hash widths.
+ *
+ * The width is derived from each path's hex length divided by its hop count,
+ * so it is only shown for paths that actually carry hop bytes with usable
+ * `path_len` metadata. Returns null when no path has a derivable width — direct
+ * (0-hop) paths and legacy rows without hop metadata contribute nothing.
+ */
+export function formatPathHopWidths(paths: MessagePath[] | null | undefined): string | null {
+ if (!paths || paths.length === 0) {
+ return null;
+ }
+ const bytesPerHop = new Set();
+ for (const p of paths) {
+ const mode = inferPathHashMode(p.path, p.path_len);
+ if (mode != null) {
+ bytesPerHop.add(mode + 1);
+ }
+ }
+ if (bytesPerHop.size === 0) {
+ return null;
+ }
+ return [...bytesPerHop]
+ .sort((a, b) => a - b)
+ .map((w) => `${w}B`)
+ .join('/');
+}
+
/**
* Build complete path resolution with sender, hops, and receiver
*/