SLOPSHOPPER

account-history-sync

Keep local Desktop Code history in sync with an inactive account.

newpanebandspinnercommandprocess
v0.5.1no licenseupdated 2026-10-15roozbehid/ClaudeSync/mods/account-history-sync
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · account-history-sync
│ ┃ History sync ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ History sync ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Copy diagnostics ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ › Accounts ] [ Sync ] [ Backups ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Accounts │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ From · Confirm the account open in Claude │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Sync paused after an error. │ ┃ › /history-sync │ ┃ Account open now: Choose the open account… │ ┃ │ ┃ To · inactive account: Choose destination… │ ┃ │ ┃ Copy saved Code history from this account │ ┃ into the destination. Keep the destination │ ┃ account inactive. │ ┃ │ ┃ [ Copy profile list ] │ ┃ │ ┃ [ Refresh accounts ] [ Continue to Sync ] │ ┃ ⟨Claude Code's own drawing⟩ [ History sync · Prompt · needs attention ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ [ History sync · Hint · needs attention ] ⟨Claude Code's own drawing⟩ [ History sync · Mode · needs attention ]

Draws

Band
⟨Claude Code's own drawing⟩ [ History sync · Prompt · needs attention ]
Pane · History sync
History sync [ Copy diagnostics ] [ › Accounts ] [ Sync ] [ Backups ] Accounts From · Confirm the account open in Claude Sync paused after an error. Account open now: Choose the open account… ▾ To · inactive account: Choose destination… ▾ Copy saved Code history from this account into the destination. Keep the destination account inactive. [ Copy profile list ] [ Refresh accounts ] [ Continue to Sync ] Prompt controls Show near the prompt: Menu button only ▾ Launcher placement: Compare all three locations ▾ Compare shows Prompt, Mode, and Hint labels. A location appears only if this Desktop build asks the mod to draw it. /history-sync always opens the pane. This launcher appears in the main conversation only. Claude decides where the settings pane opens. Account history sync runs on native Windows sessions.
Prompt hint
⟨Claude Code's own drawing⟩ [ History sync · Hint · needs attention ]
README

Account history sync mod

Windows plugin for Claude Code Mods. Once loaded, a History sync menu button appears in the enabled Code-session locations. Click it, choose an inactive destination in Accounts, then open Sync to preview, copy once, or start automatic syncing. Copying is always from this session's account into the destination. The source's Desktop metadata is never changed. Archive indexes and cards in the destination get verified backups.

The mod matches the current session ID to local Desktop history cards. A unique match supplies the source; check the displayed account before syncing. If the session appears in several profiles (including previously copied sessions), or has no Desktop card yet, click Sync settings and confirm which account is open. That confirmation lasts only for this session. No global CLI login or saved source choice is treated as Desktop's current account. A new session card is discovered after a turn or on the next 30-second check.

Auto-sync checks saved files every 30 seconds and 1.5 seconds after a main-agent turn completes. It uses PowerShell, without model calls. It starts paused in each session and after hot reload. Pause before switching accounts. Choosing another destination waits for any running copy and pauses automation. It does not run at shutdown. The destination and Details preference are remembered, along with the last successful result and eight recent backup receipts. Source confirmation and automatic-sync permission stay session-local. Selecting another destination clears its preview but keeps previous receipts with their original account direction.

Controls

Version 0.4.0 defaults to Compare all three locations: an above-prompt button, a SessionMode-area button, and a PromptHint-area button, labeled Prompt, Mode, and Hint. A site appears only when the host asks the mod to render it; native fixture tests validate all three, but do not prove this Desktop build paints each one. Accounts → Launcher placement keeps one location or restores comparison. The existing contents of each site are preserved.

By default, only a menu button appears at each enabled site. Account details, results, and errors stay inside the pane; Pause also appears beside the button while automation is running. The above-prompt launcher is omitted in subagent transcript panels. Under Accounts → Prompt controls, choose Menu button only, Status and quick controls, or Hide. Hiding it leaves /history-sync available. This preference is remembered without starting or stopping a sync.

Three additional DOM launchers provide a sidebar entry, header menu, and floating button. They open this same pane by activating its existing native button; they do not add another sync engine. Their temporary loader supports a Store-build comparison through a loopback DevTools session instead of patching installed files. Live injection needs a Desktop restart; see the instructions and current verification status there.

The optional status band shows source → destination and the last sync time for that direction, with Sync now, Sync settings, and collapsible Details. The official API does not offer a global Desktop toolbar slot; the DOM experiment adds its own controls to the page. Claude controls where the band and pane are placed.

The menu has three sections:

  • Accounts: confirm the source if needed and choose the inactive destination.
  • Sync: preview changes, sync once, and start or pause automation.
  • Backups: review recent backup paths, original directions, and interrupted copies.

Before and after preview

Version 0.5.1 includes local conversations that spawned cloud tasks, provided their local transcript exists. New destination cards omit the source's cloud task links; existing cards keep their own links. Account-bound environment scopes remain excluded.

Version 0.5.0 replaces the filename-heavy preview with a table showing the real session title, destination state before, destination state after, and change. Renames show the old title as well. Unchanged sessions are included; skipped and conflicting sessions show the destination remaining unchanged. A session without a saved title is labeled Untitled session. File IDs are available under the row's Details button and in copied diagnostic data.

  • All / Active / Archived: filters use the planned destination state. All also includes source cards that would remain absent, such as unfinished sessions.
  • Changes: show all sessions, additions/updates, or conflicts/skipped entries.
  • Search titles: matches both source and before/after destination titles.
  • Previous / Next: 15 sessions per page, sorted by title.
  • Copy filtered table: copies every matching row across pages as tab-separated text. Copy preview retains the complete held comparison regardless of filters.

Filtering, searching, paging, and copying do not run another plan, copy history, or enable automatic sync. Comparisons use the original plan snapshot; constructing a proposed update no longer mutates the destination snapshot. Up to 1,000 comparison rows are returned, with an explicit notice if a larger inventory is truncated. Use Preview again to refresh the snapshot.

Version 0.4.1 adds Copy diagnostics, Copy profile list, Copy preview, and Copy backup path. These copy through the clipboard API for the surface where the button was pressed. Diagnostics retain the complete error; Copy preview includes the held report beyond the panel's text display limit. A clipboard failure shows feedback without changing sync permission or starting a helper. Native Text elements do not expose a selection property, so these buttons provide copying even when Desktop does not allow selecting the pane's text with the mouse.

No slash command is required. The interactive preview uses fixture accounts and simulated operations only. Claude controls the actual styling and pane placement; this preview does not demonstrate host compatibility.

Load and install

For first-time persistent installation in Desktop, open PowerShell and run:

Set-Location C:\path\to\ClaudeSync
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Build-Mod.ps1
claude plugin marketplace add C:\path\to\ClaudeSync
claude plugin install account-history-sync@account-history-tools --scope user

For an existing installation, update it instead:

claude plugin marketplace update account-history-tools
claude plugin update account-history-sync@account-history-tools --scope user

Installation can be completed even while the rollout blocks execution. Then:

  1. Open Claude Desktop and sign into the account you want to copy from.
  2. Open the Code tab and a native Windows local session.
  3. Run /reload-plugins, or start a fresh Code session. In /plugin, verify account-history-sync is installed and enabled.
  4. Look for the History sync · Prompt, Mode, or Hint buttons at the sites Desktop renders. No slash command is needed. /history-sync is the fallback.
  5. Click History sync → Accounts. Check From, then choose the other, inactive account in To. Confirm the open account if discovery is ambiguous.
  6. Click Continue to Sync, then Preview, Sync now, or Start auto-sync. Open Backups to see receipts. Enable the optional status band for a Sync now shortcut.
  7. Before changing accounts, click Pause auto-sync. Open a fresh session in the other account; check its source and choose the destination before restarting.

If the controls are missing and /history-sync is unknown, check the rollout from PowerShell:

claude plugin test C:\path\to\ClaudeSync\mods\account-history-sync

For a temporary CLI session instead of persistent installation:

claude --plugin-dir C:\path\to\ClaudeSync\mods\account-history-sync

The menu button appears above Claude's prompt. /history-sync also opens its settings pane. Desktop WSL sessions do not support Mods according to the current docs.

If the plugin is enabled but both its UI and /history-sync are missing, check the session's environment first. Desktop can list the Windows-installed plugin while the selected Code session runs in WSL, where plugins are unavailable. Opening a native Windows Code session in the same signed-in account provides the controller. Keep the other account inactive and confirm the displayed source before copying. The controller can copy eligible WSL history cards from the Windows side; WSL execution itself does not host this Mod.

For a read-only profile query from PowerShell:

Set-Location C:\path\to\ClaudeSync
.\Sync-ClaudeHistory.ps1 -Mode Profiles

It lists full profile IDs, known email labels, their sources, and session counts. Use -ConfigPath "$env:LOCALAPPDATA\ClaudeHistorySync\profiles.json" to query the Mod's configured roots instead. Labels identify folders; this query does not authenticate which Desktop account is currently open.

Anthropic documents engine 2.1.287 or later as the Mods baseline. However, Desktop 2.19675.0, bundling 2.1.286, was observed rendering this mod and passed all three native fixture tests, including Desktop-surface menu interactions. Observed loading is stronger evidence than assuming a build cannot load solely from its version. The terminal CLI's rollout result is separate: CLI 2.1.288 still reported a cached disabled switch while Desktop's engine ran these tests. No real account history sync or full account-switch test was performed.

For a missing UI, check that Desktop's session loaded the plugin, and run tests with its actual worker executable. A terminal claude --version or test result does not establish Desktop's engine or rollout state. Updating the plugin does not update Desktop's Code engine. See the Desktop UI and loading review for evidence and ideas from the reference UI project.

Commands

  • /history-sync opens the settings pane (optional unless the launcher is hidden).
  • /history-sync accounts opens account selection.
  • /history-sync backups opens recent backup receipts.
  • /history-sync profiles lists emails, IDs, and session counts.
  • /history-sync to <destination-email-or-id> selects the inactive destination.
  • /history-sync preview checks without writing Desktop history.
  • /history-sync sync copies once, declaring the source active and destination inactive.
  • /history-sync start enables automatic copying for this session with the same declaration.
  • /history-sync pause waits for the current operation and releases the controller.
  • /history-sync status prints status.

Ambiguous emails require full account-GUID\organization-GUID profile IDs. CLI commands accept selectors without spaces. A compatibility pair <source> <destination> command can confirm an unresolved source, but cannot reverse a source already identified in this session. The destination is remembered; permission to auto-sync is not remembered. Multiple sessions share an exclusive controller lease. Pause the controller in its original session to switch control; after a crashed or ended session, its lease expires within three minutes.

Configuration and recovery

The helper initializes %LOCALAPPDATA%\ClaudeHistorySync\profiles.json with automatic Desktop-root discovery. If discovery cannot choose a root, set SessionsRoot to the absolute history directory in that config, after checking which directory the running Desktop uses. Save a config backup before changing it. For this Store installation, that root was verified under %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude-code-sessions. Discovery creates that private config but does not edit history. Put profile-labels.json alongside it for manual email labels. Settings, state, and backups live outside the plugin install/cache folder.

Desktop can save a correctly named card before its CLI session starts. A missing, null, or empty CLI session ID now marks that card Pending in inventory and Skipped in the plan. The card and its archive state stay untouched, including when it is in the destination. Incorrect nonempty CLI IDs, filename/session-ID mismatches, missing working directories, malformed JSON, and invalid archive flags still stop the plan. Preview returns up to 1,000 report entries and explicitly reports truncation beyond that limit.

For custom roots or existing labels, set ACCOUNT_HISTORY_SYNC_CONFIG to the full path of a profiles.json before starting Claude, or through Claude's environment settings. The script uses that config's directory for state and the mod controller. It must support private Windows ACLs. Configuration and labels use the same format as the standalone script. No credentials or tokens are copied into the plugin.

The Backups section shows up to eight recent receipts, and automatic failures pause syncing. Use the standalone engine's Undo with that config and printed backup, after closing Claude. Undo is deliberately not automated by the mod:

.\Sync-ClaudeHistory.ps1 -Mode Undo -ConfigPath "$env:LOCALAPPDATA\ClaudeHistorySync\profiles.json" -Backup 'C:\printed\backup\path' -WhatIf

After reviewing, remove -WhatIf to restore. A timed-out helper can leave a prepared receipt under state\pairs\PAIR_HASH\backups; inspect that before retrying. Backups include conversation summaries and should stay private.

Mods' published API does not provide a current account ID. Matching a session's local card is account-folder discovery, not authentication; check the source shown in the UI. If ambiguous, the mod relies on your session-local confirmation. Before preview, arming, or sync, the helper rejects a source outside the session's matching profiles, when matches exist. It cannot verify that the destination is inactive. File hashes and atomic replacement reduce write races but cannot prevent Desktop from later writing cached metadata. It syncs local history cards and environment references, not cloud session ownership, credentials, or transcripts. See the repository README for eligibility and policies.

Tests

node --test .\mods\account-history-sync\tests\controller.test.mjs
.\Test-ModBridge.ps1
claude plugin test .\mods\account-history-sync

Node is needed only for the independent controller tests, not to run the mod. PowerShell bridge tests use isolated local fixtures and never modify real history. Claude's native test runner requires Mods to load in the executable running the tests. Rebuild the fixture preview after changing the UI with node .\tools\Build-UiPreview.mjs. The DOM fixture suite runs separately with npm ci --ignore-scripts and npm test under experiments/dom-launchers; those packages are not shipped in the native plugin.

Source 3 files
hooks/register.mjs 118 lines
1import { createController } from './controller.mjs';
2import { renderBand, renderPane, renderLauncher, launcherVisible } from './ui.mjs';
3
4const PANE = 'history-sync';
5const HELP = 'Open History sync above the prompt or use /history-sync. Optional commands: accounts, backups, profiles, to <inactive-destination-email-or-id>, preview, sync, start, pause, status. Pause before switching accounts.';
6
7export function register(on) {
8  const controller = createController();
9  on('session.start', async ($, e, next) => {
10    const adapter = {
11      root: $.plugin.root,
12      windows: (await $.env.get('OS')) === 'Windows_NT',
13      processRun: (argv, options) => $.process.run(argv, options),
14      redraw: () => $.ui.invalidate('ui.render'),
15      log: text => $.ui.log(text),
16      now: () => $.clock.now(),
17      sessionId: () => $.session.id(),
18      envGet: () => $.env.get('ACCOUNT_HISTORY_SYNC_CONFIG'),
19      storeGet: key => $.store.get(key),
20      storeSet: (key, value) => $.store.set(key, value),
21      every: (ms, fn) => $.clock.every(ms, fn),
22      after: (ms, fn) => $.clock.after(ms, fn),
23    };
24    await controller.initialize(adapter);
25    await $.command.register({ name: 'history-sync', description: 'Manage local account history sync', argumentHint: '[accounts|backups|to|preview|sync|start|pause|status]', immediate: true });
26    return next(e);
27  });
28  on('session.end', async ($, e, next) => {
29    controller.stop();
30    // No last-minute writes on logout, resume, clear, or shutdown.
31    return next(e);
32  });
33  on('turn.complete', async ($, e, next) => {
34    const result = await next(e);
35    if (!e.agentId) { await controller.reopen(); controller.afterTurn(); }
36    return result;
37  });
38  on('command.run', { command: 'history-sync' }, async ($, e) => {
39    const args = (e.args || '').trim().split(/\s+/).filter(Boolean);
40    try {
41      await controller.reopen();
42      switch (args[0] || '') {
43        case '': case 'accounts': case 'backups':
44          if (args[0]) controller.showSection(args[0]);
45          if (!controller.view.source || !controller.view.target) controller.showSection(args[0] === 'backups' ? 'backups' : 'accounts');
46          await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
47          await controller.refresh();
48          return {};
49        case 'profiles': {
50          await controller.refresh();
51          return { text: controller.view.profiles.map(p => `${p.Email || p.Label} | ${p.Id} | ${p.Sessions} sessions`).join('\n') || controller.statusText() };
52        }
53        case 'to':
54          if (args.length !== 2) return { text: HELP };
55          await controller.refresh();
56          await controller.choose(controller.view.source, controller.resolveSelector(args[1]));
57          return { text: controller.statusText() };
58        case 'pair': // Compatibility: cannot replace an already identified current source.
59          if (args.length !== 3) return { text: HELP };
60          await controller.refresh();
61          await controller.choose(controller.resolveSelector(args[1]), controller.resolveSelector(args[2]));
62          return { text: controller.statusText() };
63        case 'preview': await controller.preview(); return { text: controller.statusText() };
64        case 'sync': await controller.sync(); return { text: controller.statusText() };
65        case 'start': await controller.start(); return { text: controller.statusText() };
66        case 'pause': await controller.pause(); return { text: controller.statusText() };
67        case 'status': return { text: controller.statusText() };
68        default: return { text: HELP };
69      }
70    } catch (error) { return { text: error.message }; }
71  });
72  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
73    if (e.requestId !== PANE) return next(e);
74    const { Box, Text, Button, Select, Input } = $.ui.resolve(e);
75    return renderPane({ Box, Text, Button, Select, Input }, controller,
76      (text, surface) => $.ui.copy({ text, surface: surface || e.surface }));
77  });
78  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79    const existing = await next(e);
80    if (e.props?.hasSurvey || e.props?.view?.agentId || !launcherVisible(controller.view, 'band')) return existing;
81    const { Box, Text, Button } = $.ui.resolve(e);
82    return Box({ flexDirection: 'column', children: [existing,
83      renderBand({ Box, Text, Button }, controller, async () => {
84        await controller.reopen();
85        if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
86        await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
87        await controller.refresh();
88      }),
89    ] });
90  });
91  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
92    const existing = await next(e);
93    if (!launcherVisible(controller.view, 'mode')) return existing;
94    const { Box, Button } = $.ui.resolve(e);
95    return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [existing,
96      renderLauncher({ Box, Button }, controller, async () => {
97        await controller.reopen();
98        if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
99        await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
100        await controller.refresh();
101      }, 'mode'),
102    ] });
103  });
104  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
105    const existing = await next(e);
106    if (!launcherVisible(controller.view, 'hint')) return existing;
107    const { Box, Button } = $.ui.resolve(e);
108    return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [existing,
109      renderLauncher({ Box, Button }, controller, async () => {
110        await controller.reopen();
111        if (!controller.view.source || !controller.view.target) controller.showSection('accounts');
112        await $.ui.open({ id: PANE, title: 'History sync', focus: true, closeOnEscape: true });
113        await controller.refresh();
114      }, 'hint'),
115    ] });
116  });
117}
118
hooks/controller.mjs 274 lines
1const PROFILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3export function describeResult(result) {
4  const mode = result.Action === 'Plan' ? 'Preview' : 'Synced';
5  return `${mode}: ${result.Additions ?? 0} added, ${result.Updates ?? 0} updated, ${result.Conflicts ?? 0} conflicts, ${result.Skipped ?? 0} skipped.`;
6}
7
8export function createController() {
9  const view = {
10    profiles: [], source: '', sourceCandidates: [], target: '', enabled: false, busy: false,
11    status: 'Finding the account for this session…', error: '',
12    backup: '', report: [], previewRows: [], previewFilter: 'all', previewChanges: 'all', previewSearch: '', previewPage: 0,
13    selectedPreview: '', configPath: '', lastSync: '', copyStatus: '',
14    section: 'accounts', expanded: false, promptDisplay: 'launcher', launcherPlacement: 'all', preview: null, lastResult: null, receipts: [], notice: '',
15  };
16  let api;
17  let owner = '';
18  let configOverride = '';
19  let running = null;
20  let ended = false;
21  let interval = null;
22  let debounce = null;
23  let revision = 0;
24
25  function redraw() { api.redraw('ui.render'); }
26  async function remember(key, value) {
27    try { await api.storeSet(key, value); }
28    catch { view.notice = 'Could not remember preferences or recent results. Any backup already created remains on disk.'; redraw(); }
29  }
30  function showSection(section) {
31    if (!['accounts', 'sync', 'backups'].includes(section)) return;
32    view.section = section; redraw();
33  }
34  function filterPreview(value) {
35    if (!['all', 'active', 'archived'].includes(value)) return;
36    view.previewFilter = value; view.previewPage = 0; redraw();
37  }
38  function filterPreviewChanges(value) {
39    if (!['all', 'changes', 'issues'].includes(value)) return;
40    view.previewChanges = value; view.previewPage = 0; redraw();
41  }
42  function searchPreview(value) { view.previewSearch = String(value).slice(0, 200); view.previewPage = 0; redraw(); }
43  function pagePreview(value) { if (Number.isInteger(value) && value >= 0) { view.previewPage = value; redraw(); } }
44  function selectPreview(file) { if (view.previewRows.some(r => r.File === file)) { view.selectedPreview = view.selectedPreview === file ? '' : file; redraw(); } }
45  function clearPreview() { view.report = []; view.previewRows = []; view.preview = null; view.previewPage = 0; view.selectedPreview = ''; }
46  async function copyText(text, writer, surface) {
47    try {
48      const result = await writer(text, surface);
49      view.copyStatus = result?.isCopied ? 'Copied to clipboard.' : `Could not copy to clipboard (${result?.reason || 'unavailable'}).`;
50    } catch (error) { view.copyStatus = `Copy failed: ${error.message}`; }
51    redraw();
52  }
53  async function toggleDetails() {
54    view.expanded = !view.expanded; redraw();
55    await remember('expanded', view.expanded);
56  }
57  async function choosePromptDisplay(value) {
58    if (!['launcher', 'compact', 'hidden'].includes(value)) return;
59    view.promptDisplay = value; redraw();
60    await remember('promptDisplay', value);
61  }
62  async function chooseLauncherPlacement(value) {
63    if (!['all', 'band', 'mode', 'hint'].includes(value)) return;
64    view.launcherPlacement = value; redraw();
65    await remember('launcherPlacement', value);
66  }
67  async function rememberBackup(result, pair) {
68    const receipt = { backup: result.Backup, source: pair?.source || '', target: pair?.target || '',
69      at: new Date(await api.now()).toISOString(), status: result.Ok ? 'complete' : 'prepared',
70      message: result.Ok ? describeResult(result) : result.Error || 'Sync interrupted; inspect this backup before retrying.' };
71    view.receipts = [receipt, ...view.receipts.filter(r => r.backup !== receipt.backup)].slice(0, 8);
72    await remember('receipts', view.receipts);
73  }
74  function selectedPair() {
75    if (!PROFILE_ID.test(view.source)) throw new Error('Confirm which account is open in Sync settings first.');
76    if (!PROFILE_ID.test(view.target)) throw new Error('Choose an inactive destination account first.');
77    if (view.source === view.target) throw new Error('Source and destination must be different.');
78    if (!view.profiles.some(p => p.Id === view.source) || !view.profiles.some(p => p.Id === view.target)) {
79      throw new Error('A selected profile is no longer available. Refresh profiles.');
80    }
81    return { source: view.source, target: view.target };
82  }
83  async function bridge(action, pair, keepLease = false) {
84    if (api.windows === false) throw new Error('Account history sync runs on native Windows sessions.');
85    const argv = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
86      '-File', `${api.root}/scripts/HistorySync-Bridge.ps1`, '-Action', action];
87    if (configOverride) argv.push('-ConfigPath', configOverride);
88    if (pair) argv.push('-SourceProfile', pair.source, '-TargetProfile', pair.target);
89    if (action !== 'Release') argv.push('-SessionId', owner);
90    if (['Arm', 'Sync', 'Release'].includes(action)) argv.push('-OwnerSession', owner);
91    if (keepLease) argv.push('-KeepLease');
92    const processResult = await api.processRun(argv, { timeoutMs: 90_000 });
93    let result;
94    try { result = JSON.parse(processResult.stdout.trim()); }
95    catch { throw new Error(`History helper returned invalid output (exit ${processResult.exitCode}). ${processResult.stderr?.slice(0, 500) || ''}`); }
96    if (result.Backup) { view.backup = result.Backup; await rememberBackup({ ...result, Ok: result.Ok && processResult.exitCode === 0 }, pair); }
97    if (result.ConfigPath) view.configPath = result.ConfigPath;
98    if (processResult.exitCode !== 0 || !result.Ok) throw new Error(result.Error || processResult.stderr || 'History helper failed.');
99    return result;
100  }
101  async function release() {
102    if (!owner) return;
103    try { await bridge('Release'); }
104    catch (error) { view.error = `Paused; controller lease expires within three minutes. ${error.message}`; }
105  }
106  async function run(label, work) {
107    if (ended || running) return null;
108    view.busy = true; view.error = ''; view.status = label; redraw();
109    // Promise.resolve defers work until running is assigned, including synchronous failures.
110    running = Promise.resolve().then(work).catch(async error => {
111      view.enabled = false;
112      view.error = error.message;
113      view.status = 'Sync paused after an error.';
114      await release();
115      return null;
116    }).finally(() => { running = null; view.busy = false; redraw(); });
117    return running;
118  }
119  async function sync(automatic = false) {
120    if (automatic && !view.enabled) return null;
121    return run('Checking saved history…', async () => {
122      const pair = selectedPair();
123      const result = await bridge('Sync', pair, view.enabled);
124      view.status = describeResult(result);
125      const at = new Date(await api.now()).toISOString();
126      view.lastSync = new Date(at).toLocaleTimeString();
127      view.lastResult = { source: pair.source, target: pair.target, at, message: view.status, backup: result.Backup || '' };
128      await remember('lastResult', view.lastResult);
129      if (result.Backup) api.log(`History backup: ${result.Backup}`);
130      return result;
131    });
132  }
133  async function pause() {
134    revision += 1;
135    view.enabled = false;
136    debounce?.cancel(); debounce = null;
137    redraw();
138    if (running) await running;
139    view.enabled = false;
140    await release();
141    view.status = 'Auto-sync paused.';
142    redraw();
143  }
144  async function refresh() {
145    return run('Finding local profiles…', async () => {
146      const result = await bridge('Profiles');
147      view.profiles = result.Profiles || [];
148      view.sourceCandidates = result.SessionProfiles || [];
149      const previousSource = view.source;
150      const previousTarget = view.target;
151      const wasEnabled = view.enabled;
152      if (view.source && !view.profiles.some(p => p.Id === view.source)) view.source = '';
153      if (result.CurrentProfile) view.source = result.CurrentProfile;
154      else if (view.sourceCandidates.length && !view.sourceCandidates.includes(view.source)) view.source = '';
155      if (previousSource !== view.source) {
156        revision += 1; view.enabled = false;
157        debounce?.cancel(); debounce = null;
158        if (previousSource) await release();
159      }
160      if (view.target && !view.profiles.some(p => p.Id === view.target)) { view.target = ''; view.enabled = false; }
161      if (view.target === view.source) view.target = '';
162      if (previousSource === view.source && previousTarget !== view.target && wasEnabled) await release();
163      if (previousSource !== view.source || previousTarget !== view.target) clearPreview();
164      view.status = view.source ? 'This account → inactive destination. Choose a destination and sync.' :
165        view.sourceCandidates.length > 1 ? 'This session exists in several accounts. Confirm the open account in Sync settings.' :
166        'No account folder identifies this session yet. Confirm the open account in Sync settings, or refresh after a turn.';
167      return result;
168    });
169  }
170  async function choose(source, target) {
171    if (view.source && source !== view.source) throw new Error('Source is this session’s account. Switch accounts and open a fresh session to reverse the direction.');
172    if (source && (!view.profiles.some(p => p.Id === source) || (view.sourceCandidates.length && !view.sourceCandidates.includes(source)))) {
173      throw new Error('The chosen source does not contain this session. Refresh profiles.');
174    }
175    // Confirming an ambiguous source is session-local, never a saved login assumption.
176    await pause();
177    if (view.source && source !== view.source) throw new Error('The current source changed while waiting. Choose the destination again.');
178    view.source = source; view.target = target;
179    clearPreview();
180    await remember('destination', target);
181    view.status = source ? 'This account → selected destination. Destination must be inactive.' : 'Destination selected. Confirm the open account in Sync settings.';
182    redraw();
183  }
184  async function preview() {
185    return run('Building a read-only preview…', async () => {
186      const result = await bridge('Plan', selectedPair());
187      view.report = result.Report || [];
188      view.previewRows = Array.isArray(result.Rows) ? result.Rows : [];
189      view.previewPage = 0; view.selectedPreview = '';
190      view.status = describeResult(result);
191      view.preview = { source: view.source, target: view.target, message: view.status,
192        writes: result.Writes ?? 0, reportTotal: result.ReportTotal ?? view.report.length,
193        reportTruncated: result.ReportTruncated === true, rowsTotal: result.RowsTotal ?? view.previewRows.length,
194        rowsTruncated: result.RowsTruncated === true, at: new Date(await api.now()).toISOString() };
195      return result;
196    });
197  }
198  async function start() {
199    const startingRevision = revision;
200    const result = await run('Starting auto-sync…', async () => {
201      const pair = selectedPair();
202      await bridge('Arm', pair);
203      if (ended || startingRevision !== revision) { await release(); return null; }
204      view.enabled = true;
205      view.status = 'Auto-sync active: source → destination every 30 seconds.';
206      return true;
207    });
208    if (result && view.enabled) await sync(true);
209  }
210  function stop() {
211    revision += 1;
212    ended = true; view.enabled = false;
213    interval?.cancel(); debounce?.cancel();
214  }
215  async function initialize(adapter) {
216    api = adapter;
217    if (api.windows === false) {
218      view.error = 'Account history sync runs on native Windows sessions.';
219      return;
220    }
221    owner = await api.sessionId();
222    configOverride = (await api.envGet('ACCOUNT_HISTORY_SYNC_CONFIG')) || '';
223    view.source = ''; view.sourceCandidates = [];
224    const saved = await api.storeGet('destination');
225    view.target = typeof saved === 'string' ? saved : '';
226    view.expanded = (await api.storeGet('expanded')) === true;
227    const promptDisplay = await api.storeGet('promptDisplay');
228    view.promptDisplay = ['launcher', 'compact', 'hidden'].includes(promptDisplay) ? promptDisplay : 'launcher';
229    const placement = await api.storeGet('launcherPlacement');
230    view.launcherPlacement = ['all', 'band', 'mode', 'hint'].includes(placement) ? placement : 'all';
231    const receipts = await api.storeGet('receipts');
232    view.receipts = Array.isArray(receipts) ? receipts.filter(r => r && typeof r.backup === 'string' && r.backup && typeof r.message === 'string' &&
233      PROFILE_ID.test(r.source) && PROFILE_ID.test(r.target) && !Number.isNaN(Date.parse(r.at)) &&
234      ['complete', 'prepared'].includes(r.status)).slice(0, 8) : [];
235    view.backup = view.receipts[0]?.backup || '';
236    const lastResult = await api.storeGet('lastResult');
237    view.lastResult = lastResult && PROFILE_ID.test(lastResult.source) && PROFILE_ID.test(lastResult.target) &&
238      typeof lastResult.message === 'string' && !Number.isNaN(Date.parse(lastResult.at)) ? lastResult : null;
239    view.lastSync = view.lastResult ? new Date(view.lastResult.at).toLocaleTimeString() : '';
240    // Never persist auto-enable across sessions, login changes or hot reloads.
241    await release();
242    await refresh();
243    interval = api.every(30_000, async () => { if (ended) return; if (view.enabled) await sync(true); else if (!view.source) await refresh(); });
244  }
245  function afterTurn() {
246    if (ended) return;
247    if (!view.enabled && view.source) return;
248    debounce?.cancel();
249    debounce = api.after(1500, async () => { if (ended) return; if (view.enabled) await sync(true); else if (!view.source) await refresh(); });
250  }
251  async function reopen() {
252    if (!ended) return;
253    if (running) await running;
254    await release();
255    ended = false;
256    view.enabled = false; view.error = '';
257    interval = null; debounce = null;
258    await initialize(api);
259    view.status = `Session changed; auto-sync paused. ${view.status}`;
260    redraw();
261  }
262  function resolveSelector(selector) {
263    const matches = view.profiles.filter(p => p.Id === selector || p.Email?.toLowerCase() === selector.toLowerCase());
264    if (matches.length !== 1) throw new Error('Profile selector is unknown or ambiguous. Use a full profile ID from /history-sync profiles.');
265    return matches[0].Id;
266  }
267  function statusText() {
268    return `${view.enabled ? 'Auto-sync active' : 'Auto-sync paused'}; ${view.source || '(confirm open account)'} → ${view.target || '(choose destination)'}. ${view.status}${view.error ? ` ${view.error}` : ''}`;
269  }
270  return { view, initialize, refresh, choose, preview, start, pause, sync, stop, afterTurn, reopen, resolveSelector, statusText,
271    showSection, toggleDetails, choosePromptDisplay, chooseLauncherPlacement, copyText,
272    filterPreview, filterPreviewChanges, searchPreview, pagePreview, selectPreview };
273}
274
hooks/ui.mjs 265 lines
1export function accountLabel(view, id, empty = 'Choose destination') {
2  if (!id) return empty;
3  const profile = view.profiles.find(p => p.Id === id);
4  const [account, org] = id.split('\\');
5  const name = profile?.Email || (profile?.Label && profile.Label !== account ? profile.Label : `Account ${account.slice(0, 8)}`);
6  // Organizations can share an email; keep them distinguishable in every picker.
7  return `${name} · ${org?.slice(0, 8) || ''}`;
8}
9
10function mode(view) {
11  if (view.error) return 'Error · auto-sync paused';
12  if (view.busy) return 'Working…';
13  return view.enabled ? 'Auto · every 30s' : 'Paused';
14}
15
16function samePair(view, result) {
17  return result && result.source === view.source && result.target === view.target;
18}
19
20function time(at, full = false) {
21  return full ? new Date(at).toLocaleString() : new Date(at).toLocaleTimeString();
22}
23
24function action(Button, controller, key, label, work, needsPair = false) {
25  const v = controller.view;
26  const ready = !needsPair || Boolean(v.source && v.target && v.source !== v.target);
27  return Button({ key, label, dimColor: v.busy || !ready, onPress: async () => {
28    await controller.reopen();
29    if (!v.busy && (!needsPair || (v.source && v.target && v.source !== v.target))) await work();
30  } });
31}
32
33function automatic(Button, controller) {
34  const v = controller.view;
35  return Button({ key: 'auto', label: v.enabled ? 'Pause auto-sync' : 'Start auto-sync',
36    dimColor: !v.enabled && (v.busy || !v.source || !v.target || v.source === v.target), onPress: async () => {
37      await controller.reopen();
38      if (v.enabled) await controller.pause();
39      else if (!v.busy && v.source && v.target && v.source !== v.target) await controller.start();
40    } });
41}
42
43export function launcherVisible(view, site) {
44  return view.promptDisplay !== 'hidden' && (view.launcherPlacement === 'all' || view.launcherPlacement === site);
45}
46
47export function renderLauncher({ Box, Button }, controller, openSettings, site) {
48  const v = controller.view;
49  const location = v.launcherPlacement === 'all' ? ` · ${ {band:'Prompt', mode:'Mode', hint:'Hint'}[site] }` : '';
50  const status = v.error ? ' · needs attention' : v.enabled ? ' · Auto' : '';
51  return Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [
52    Button({ key: site === 'band' ? 'settings' : `settings-${site}`, label: `History sync${location}${status}`,
53      ...(site === 'band' ? {hotkey: 's'} : {}), onPress: openSettings }),
54    ...(v.enabled ? [automatic(Button, controller)] : []),
55  ] });
56}
57
58export function renderBand({ Box, Text, Button }, controller, openSettings) {
59  const v = controller.view;
60  if (v.promptDisplay === 'hidden') return null;
61  if (v.promptDisplay !== 'compact') return renderLauncher({ Box, Button }, controller, openSettings, 'band');
62  const direction = `${accountLabel(v, v.source, 'Confirm this account')} → ${accountLabel(v, v.target)}`;
63  const last = samePair(v, v.lastResult) ? ` · Last ${time(v.lastResult.at)}` : '';
64  return Box({ flexDirection: 'column', children: [
65    Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children: [
66      Text({ bold: true, children: `History sync · ${mode(v)}` }),
67      action(Button, controller, 'sync', 'Sync now', () => controller.sync(), true),
68      ...(v.enabled ? [automatic(Button, controller)] : []),
69      Button({ key: 'settings', label: 'Sync settings', hotkey: 's', onPress: openSettings }),
70      Button({ key: 'details', label: v.expanded ? 'Less' : 'Details', onPress: () => controller.toggleDetails() }),
71    ] }),
72    Text({ dimColor: true, children: `${direction}${last}` }),
73    ...(v.error ? [Text({ color: 'red', children: v.error })] : []),
74    ...(v.busy ? [Text({ children: v.status })] : []),
75    ...(v.expanded ? [
76      Text({ children: samePair(v, v.lastResult) ? v.lastResult.message : v.source && v.target ? 'Ready to preview or sync this direction.' : 'Choose an inactive destination in Sync settings.' }),
77      ...(samePair(v, v.lastResult) && v.lastResult.backup ? [Text({ dimColor: true, children: 'Verified backup saved · details in Backups' })] : []),
78      Text({ dimColor: true, children: 'Source stays untouched. Pause before switching accounts.' }),
79    ] : []),
80  ] });
81}
82
83export function profilesText(view) {
84  return view.profiles.map(p => `${accountLabel(view, p.Id)} | ${p.Id} | ${p.Sessions} sessions`).join('\n');
85}
86
87export function filteredPreviewRows(view) {
88  const search = view.previewSearch.trim().toLocaleLowerCase();
89  return view.previewRows.filter(r =>
90    (view.previewFilter === 'all' || (r.After && (view.previewFilter === 'archived' ? r.After.Archived : !r.After.Archived))) &&
91    (view.previewChanges === 'all' || (view.previewChanges === 'changes' ? ['Add','Update'].includes(r.Action) : ['Conflict','Skipped'].includes(r.Action))) &&
92    (!search || [r.Title,r.Before?.Title,r.After?.Title].some(t => t?.toLocaleLowerCase().includes(search)))
93  ).sort((a,b) => a.Title.localeCompare(b.Title) || a.File.localeCompare(b.File));
94}
95
96function archiveState(state) { return state ? state.Archived ? 'Archived' : 'Active' : 'Not present'; }
97
98export function previewTableText(rows) {
99  const cell = text => String(text ?? '').replace(/[\t\r\n]+/g,' ');
100  return [['Session','Change','Before title','Before state','After title','After state','Reason','Session file'],
101    ...rows.map(r => [r.Title,r.Action,r.Before?.Title,archiveState(r.Before),r.After?.Title,archiveState(r.After),r.Detail,r.File]),
102  ].map(row => row.map(cell).join('\t')).join('\n');
103}
104
105export function previewText(view) {
106  const p = view.preview;
107  if (!p) return '';
108  return ['Preview only — no history writes performed.',
109    `From: ${accountLabel(view, p.source)} | ${p.source}`,
110    `To: ${accountLabel(view, p.target)} | ${p.target}`, time(p.at, true), p.message,
111    `Planned operations: ${p.writes}`,
112    ...(view.previewRows.length ? [previewTableText(view.previewRows)] : view.report.map(r => `${r.Action}: ${r.File}\n${r.Detail}`)),
113    ...(p.reportTruncated ? [`Report limited to ${view.report.length} of ${p.reportTotal} entries.`] : []),
114    ...(p.rowsTruncated ? [`Comparison limited to ${view.previewRows.length} of ${p.rowsTotal} sessions.`] : []),
115  ].join('\n');
116}
117
118export function diagnosticsText(view) {
119  return ['History sync diagnostics', `From: ${accountLabel(view, view.source, 'Unconfirmed')} | ${view.source}`,
120    `To: ${accountLabel(view, view.target)} | ${view.target}`, mode(view), view.status,
121    ...(view.error ? [`Error: ${view.error}`] : []), `Config: ${view.configPath || '(default)'}`,
122    profilesText(view), ...(view.preview ? [previewText(view)] : []),
123    ...(view.backup ? [`Latest backup: ${view.backup}`] : []),
124  ].join('\n');
125}
126
127function renderPreviewTable({Box,Text,Button,Select,Input}, controller, copy) {
128  const v=controller.view;
129  const filtered=filteredPreviewRows(v);
130  const pageCount=Math.max(1,Math.ceil(filtered.length/15));
131  const page=Math.min(v.previewPage,pageCount-1);
132  const pageRows=filtered.slice(page*15,(page+1)*15);
133  const selected=v.previewRows.find(r=>r.File===v.selectedPreview);
134  const widths=['36%','23%','23%','18%'];
135  const cell=(content,index)=>Box({width:widths[index],flexShrink:0,flexDirection:'column',paddingX:1,children:content});
136  const stateCell=(state,row)=>[Text({bold:true,children:archiveState(state)}),
137    ...(state && state.Title!==row.Title ? [Text({dimColor:true,children:state.Title.slice(0,1000)})] : [])];
138  const filters=Box({flexDirection:'row',flexWrap:'wrap',gap:1,children:['all','active','archived'].map(key=>Button({
139    key:`filter-${key}`,label:`${v.previewFilter===key?'› ':''}${{all:'All',active:'Active',archived:'Archived'}[key]}`,
140    dimColor:v.previewFilter!==key,onPress:()=>controller.filterPreview(key),
141  }))});
142  const search=Input ? [Input({key:'preview-search',label:'Search titles',placeholder:'Session title…',value:v.previewSearch,
143    onInput:value=>controller.searchPreview(value),onSubmit:value=>controller.searchPreview(value)})] : [];
144  return [
145    Text({bold:true,children:'Destination before → after'}),
146    Text({dimColor:true,children:'Active and Archived filter the planned destination state. All also includes sessions left absent. Titles come from the source when available.'}),
147    filters,
148    Select({key:'preview-changes',label:'Changes',value:v.previewChanges,
149      options:[{value:'all',label:'All sessions'},{value:'changes',label:'Additions and updates'},{value:'issues',label:'Conflicts and skipped'}],
150      onSelect:value=>controller.filterPreviewChanges(value)}),
151    ...search,
152    copy('copy-filtered','Copy filtered table',()=>previewTableText(filtered)),
153    Text({dimColor:true,children:`${filtered.length} of ${v.previewRows.length} sessions · page ${page+1} of ${pageCount}`}),
154    ...(v.preview.rowsTruncated ? [Text({dimColor:true,children:`Comparison contains the first ${v.previewRows.length} of ${v.preview.rowsTotal} sessions.`})] : []),
155    Box({flexDirection:'column',borderStyle:'single',children:[
156      Box({flexDirection:'row',children:['Session','Before','After','Change'].map((label,i)=>cell([Text({bold:true,children:label})],i))}),
157      ...pageRows.map(r=>Box({key:`preview-row-${r.File}`,flexDirection:'row',paddingY:1,children:[
158        cell([Text({children:r.Title.slice(0,1000)})],0),cell(stateCell(r.Before,r),1),cell(stateCell(r.After,r),2),
159        cell([Text({children:r.Action}),Button({key:`details-${r.File}`,label:'Details',onPress:()=>controller.selectPreview(r.File)})],3),
160      ]})),
161      ...(!pageRows.length?[Text({dimColor:true,children:'No sessions match these filters.'})]:[]),
162    ]}),
163    Box({flexDirection:'row',gap:1,children:[
164      Button({key:'preview-prev',label:'Previous',dimColor:page===0,onPress:()=>{if(page>0)controller.pagePreview(page-1);}}),
165      Button({key:'preview-next',label:'Next',dimColor:page>=pageCount-1,onPress:()=>{if(page<pageCount-1)controller.pagePreview(page+1);}}),
166    ]}),
167    ...(selected?[
168      Text({bold:true,children:selected.Title.slice(0,1000)}),
169      Text({children:`${selected.Action}: ${selected.Detail}`}),
170      Text({children:`Before: ${selected.Before?.Title||'Not present'} · ${archiveState(selected.Before)}\nAfter: ${selected.After?.Title||'Not present'} · ${archiveState(selected.After)}`}),
171      Text({dimColor:true,children:`Changed fields: ${selected.ChangedFields?.join(', ')||'None'}\nSession file: ${selected.File}`}),
172    ]:[]),
173  ];
174}
175
176export function renderPane({ Box, Text, Button, Select, Input }, controller, writer = async () => ({isCopied:false,reason:'no-clipboard'})) {
177  const v = controller.view;
178  const row = children => Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, children });
179  const heading = text => Text({ bold: true, children: text });
180  const note = text => Text({ dimColor: true, children: text });
181  const go = section => controller.showSection(section);
182  const copy = (key, label, getText) => Button({key, label, onPress: press => controller.copyText(getText(), writer, press?.surface)});
183  const section = v.section || 'accounts';
184  let content;
185  if (section === 'accounts') {
186    const sourceChoices = v.profiles.filter(p => !v.sourceCandidates.length || v.sourceCandidates.includes(p.Id));
187    content = [
188      heading('Accounts'),
189      Text({ children: `From · ${accountLabel(v, v.source, 'Confirm the account open in Claude')}` }),
190      ...(v.source ? [note('This session’s account is the source.')] : [
191        Text({ children: v.status }),
192        Select({ key: 'source-confirmation', label: 'Account open now', value: '',
193          options: [{ value: '', label: 'Choose the open account…' }, ...sourceChoices.map(p => ({ value: p.Id, label: accountLabel(v, p.Id) }))],
194          onSelect: async value => { await controller.reopen(); if (!v.busy && value) await controller.choose(value, v.target === value ? '' : v.target); } }),
195      ]),
196      Select({ key: 'target', label: 'To · inactive account', value: v.target,
197        options: [{ value: '', label: 'Choose destination…' }, ...v.profiles.filter(p => p.Id !== v.source).map(p => ({
198          value: p.Id, label: `${accountLabel(v, p.Id)} · ${p.Sessions} sessions`,
199        }))],
200        onSelect: async value => { await controller.reopen(); if (!v.busy) await controller.choose(v.source, value); } }),
201      note('Copy saved Code history from this account into the destination. Keep the destination account inactive.'),
202      copy('copy-profiles', 'Copy profile list', () => profilesText(v)),
203      row([action(Button, controller, 'refresh', 'Refresh accounts', () => controller.refresh()),
204        action(Button, controller, 'continue', 'Continue to Sync', () => go('sync'), true)]),
205      heading('Prompt controls'),
206      Select({ key: 'prompt-display', label: 'Show near the prompt', value: v.promptDisplay,
207        options: [{ value: 'launcher', label: 'Menu button only' }, { value: 'compact', label: 'Status and quick controls' },
208          { value: 'hidden', label: 'Hide — open with /history-sync' }],
209        onSelect: value => controller.choosePromptDisplay(value) }),
210      Select({ key: 'launcher-placement', label: 'Launcher placement', value: v.launcherPlacement,
211        options: [{ value: 'all', label: 'Compare all three locations' }, { value: 'band', label: 'Above the prompt' },
212          { value: 'mode', label: 'Session mode area' }, { value: 'hint', label: 'Prompt hint area' }],
213        onSelect: value => controller.chooseLauncherPlacement(value) }),
214      note('Compare shows Prompt, Mode, and Hint labels. A location appears only if this Desktop build asks the mod to draw it. /history-sync always opens the pane.'),
215      note('This launcher appears in the main conversation only. Claude decides where the settings pane opens.'),
216    ];
217  } else if (section === 'sync') {
218    content = [
219      heading('Sync'),
220      Text({ children: `${accountLabel(v, v.source, 'Confirm this account')} → ${accountLabel(v, v.target)}` }),
221      ...(!v.source || !v.target ? [note('Choose accounts before previewing or syncing.'),
222        Button({ key: 'choose-accounts', label: 'Choose accounts', onPress: () => go('accounts') })] : []),
223      row([action(Button, controller, 'preview', 'Preview', () => controller.preview(), true),
224        action(Button, controller, 'sync', 'Sync now', () => controller.sync(), true)]),
225      heading('Automatic sync'),
226      Text({ children: mode(v) }),
227      note('Checks saved history every 30 seconds and shortly after a turn finishes. Starts paused in each session.'),
228      row([automatic(Button, controller)]),
229      note('Pause before switching accounts. Changing the destination pauses automatic sync.'),
230      heading('Results'),
231      Text({ children: v.status }),
232      ...(samePair(v, v.lastResult) ? [Text({ children: `${v.lastResult.message}\nLast sync: ${time(v.lastResult.at, true)}` })] : [note('No completed sync for this direction yet.')]),
233      ...(v.preview ? [Text({ children: v.preview.message }),
234        copy('copy-preview', 'Copy preview', () => previewText(v)),
235        ...(v.preview.reportTruncated ? [note(`Showing ${v.report.length} of ${v.preview.reportTotal} report entries.`)] : []),
236        ...(v.previewRows.length ? renderPreviewTable({Box,Text,Button,Select,Input},controller,copy) :
237          v.report.length ? [Text({children:v.report.map(r=>`${r.Action}: ${r.File}\n${r.Detail}`).join('\n').slice(0,9500)})] : [note('No sessions in this preview.')])] : []),
238    ];
239  } else {
240    content = [
241      heading('Backups'),
242      note('Each modifying sync backs up the destination cards and archive index. Recent backups include their original direction.'),
243      ...(v.receipts.length ? v.receipts.flatMap((r, index) => [
244        heading(`${index === 0 ? 'Latest backup' : 'Backup'} · ${r.status === 'prepared' ? 'Interrupted — inspect before retrying' : 'Complete'}`),
245        Text({ children: `${accountLabel(v, r.source)} → ${accountLabel(v, r.target)}\n${time(r.at, true)}\n${r.message}` }),
246        Text({ children: r.backup }),
247        copy(`copy-backup-${index}`, 'Copy backup path', () => r.backup),
248      ]) : [note('No backup recorded yet. A preview or sync with no changes creates no backup.')]),
249      heading('Restore'),
250      note('Fully exit Claude before restoring a backup with the standalone Undo command. Review the restore with -WhatIf first.'),
251    ];
252  }
253  return Box({ flexDirection: 'column', gap: 1, padding: 1, children: [
254    heading('History sync'),
255    copy('copy-diagnostics', 'Copy diagnostics', () => diagnosticsText(v)),
256    row(['accounts', 'sync', 'backups'].map((key, i) => Button({ key: `nav-${key}`,
257      label: `${section === key ? '› ' : ''}${['Accounts', 'Sync', 'Backups'][i]}`, hotkey: ['a', 's', 'b'][i],
258      dimColor: section !== key, onPress: () => go(key) }))),
259    ...content,
260    ...(v.error ? [Text({ color: 'red', children: v.error })] : []),
261    ...(v.notice ? [note(v.notice)] : []),
262    ...(v.copyStatus ? [note(v.copyStatus)] : []),
263  ] });
264}
265