FN-7346: fix TUI settings arrow editing

Fix Settings keyboard handling so arrow keys edit selected enum values in the terminal dashboard.

- Keep Tab as the Settings pane switcher so arrow keys remain scoped to the active pane.
- Route detail-pane left/right arrows and h/l keys through enum setting cycling and remote provider activation.
- Add terminal dashboard coverage for list navigation and enum cycling, plus CLI docs and release note coverage.

Files changed:
 .changeset/fn-7346-tui-settings-arrows.md          |   7 ++
 docs/cli-reference.md                              |   5 +
 .../commands/dashboard-tui/__tests__/app.test.tsx  | 121 ++++++++++++++++++++-
 packages/cli/src/commands/dashboard-tui/app.tsx    | 119 ++++++++++----------
 4 files changed, 186 insertions(+), 66 deletions(-)

Fusion-Task-Id: FN-7346

Fusion-Task-Lineage: c67861a7-7bb3-408d-926c-e85a44fe2dec

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 00:52:09 -07:00
parent 47fa2ae2b6
commit c93f2d4da0
4 changed files with 221 additions and 101 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix arrow-key editing for Settings in the terminal dashboard.
category: fix
dev: Settings detail-pane arrows now edit enum values instead of switching panes.

View File

@@ -270,6 +270,11 @@ Remote actions support:
> ⚠️ Remote URL/QR payloads include tokenized query data. Treat them like credentials and avoid sharing them in screenshots/chat/logs. Prefer short-lived links for ad-hoc phone login.
Settings pane navigation and editing:
- `Tab` switches focus between the settings list and the detail/edit pane.
- In the settings list, `↑`/`↓` or `k`/`j` moves the selected setting.
- In the detail/edit pane, `←`/`→` or `h`/`l` cycles enum values such as **Remote Provider**; `Space` toggles booleans; `+`/`-` adjusts numbers.
Remote action keys in Settings detail pane:
- `C` activate selected provider
- `V` start tunnel

View File

@@ -46,6 +46,7 @@ function makeInteractiveData(opts: {
models?: ModelItem[];
taskDetail?: TaskDetailData | null;
updateAgentState?: (id: string, state: string) => Promise<void>;
updateSettings?: (partial: Partial<SettingsValues>) => Promise<void>;
remote?: Partial<{
getSettings: () => Promise<{ activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>;
getStatus: () => Promise<{ provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "error"; url: string | null; lastError: string | null }>;
@@ -63,7 +64,7 @@ function makeInteractiveData(opts: {
const agents = opts.agents ?? [];
const detail = opts.detail ?? null;
const taskDetail = opts.taskDetail ?? null;
const settings: SettingsValues = opts.settings ?? {
let settings: SettingsValues = opts.settings ?? {
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
@@ -109,7 +110,9 @@ function makeInteractiveData(opts: {
updateAgentState: opts.updateAgentState ?? (async (_id: string, _state: string) => {}),
deleteAgent: async (_id: string) => {},
getSettings: async () => settings,
updateSettings: async (_partial: Partial<SettingsValues>) => {},
updateSettings: opts.updateSettings ?? (async (partial: Partial<SettingsValues>) => {
settings = { ...settings, ...partial };
}),
listModels: () => models,
remote,
git: {
@@ -188,11 +191,20 @@ async function waitForFrameUpdateAfterInput() {
}
async function focusSettingsDetailPane(stdin: { write: (chunk: string) => void }, lastFrame: () => string | undefined) {
stdin.write("\u001b[C");
stdin.write("\t");
await waitForFrameUpdateAfterInput();
await waitForFrameContains(lastFrame, "[C/V/X/P/L/U/K/R] remote actions");
}
async function selectSettingsRow(stdin: { write: (chunk: string) => void }, label: string, rowOffsetFromTop: number, lastFrame: () => string | undefined) {
await waitForFrameContains(lastFrame, label);
for (let i = 0; i < rowOffsetFromTop; i += 1) {
stdin.write("\u001b[B");
await waitForFrameUpdateAfterInput();
}
await waitForFrameContains(lastFrame, label);
}
function findTokenPosition(frame: string, token: string): { row: number; col: number } {
const lines = frame.split("\n");
const row = lines.findIndex((line) => line.includes(token));
@@ -556,6 +568,109 @@ describe("Settings view", () => {
unmount();
});
it("keeps list-pane up/down arrows scoped to settings selection", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData());
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "Max Concurrent");
stdin.write("\u001b[B");
await waitForFrameUpdateAfterInput();
expect(lastFrame() ?? "").toContain("▶ Max Worktrees");
stdin.write("\u001b[A");
await waitForFrameUpdateAfterInput();
expect(lastFrame() ?? "").toContain("▶ Max Concurrent");
unmount();
});
it("cycles Remote Provider with right arrow in the focused detail pane", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const settings: SettingsValues = {
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "direct",
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 600000,
remoteStatus: { provider: "tailscale", state: "running", url: "https://remote.example.com", lastError: null },
};
const updateSettings = vi.fn(async (partial: Partial<SettingsValues>) => {
Object.assign(settings, partial);
});
const activateProvider = vi.fn(async (_provider: "tailscale" | "cloudflare") => {});
controller.setInteractiveData(makeInteractiveData({
settings,
updateSettings,
remote: { activateProvider },
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await selectSettingsRow(stdin, "Remote Provid", 7, lastFrame);
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("\u001b[C");
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ remoteActiveProvider: "cloudflare" }));
await vi.waitFor(() => expect(activateProvider).toHaveBeenCalledWith("cloudflare"));
await waitForFrameContains(lastFrame, "Provider: cloudflare");
expect(lastFrame() ?? "").toContain("[←/→] cycle options:");
unmount();
});
it("cycles merge strategy with vim-style detail-pane enum keys", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const settings: SettingsValues = {
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "direct",
pollIntervalMs: 60000,
enginePaused: false,
globalPause: false,
remoteActiveProvider: null,
remoteShortLivedEnabled: false,
remoteShortLivedTtlMs: 900000,
};
const updateSettings = vi.fn(async (partial: Partial<SettingsValues>) => {
Object.assign(settings, partial);
});
controller.setInteractiveData(makeInteractiveData({
updateSettings,
settings,
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await selectSettingsRow(stdin, "Merge Strategy", 3, lastFrame);
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("l");
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ mergeStrategy: "squash" }));
await waitForFrameContains(lastFrame, "squash");
stdin.write("h");
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledWith({ mergeStrategy: "direct" }));
await waitForFrameContains(lastFrame, "direct");
unmount();
});
it("renders the Remote subsection and supports provider/lifecycle actions", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());

View File

@@ -2562,23 +2562,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
}
useInput((input, key) => {
// Tab cycles list ↔ detail. Left/right also switch — list = left,
// detail = right, matching the visual layout (consistent with AgentsView).
if (key.tab) {
setDetailFocused((f) => !f);
return;
}
if (key.leftArrow) {
setDetailFocused(false);
return;
}
if (key.rightArrow) {
setDetailFocused(true);
return;
}
const inputUpper = input.toUpperCase();
if (ttlInputMode) {
if (key.escape) {
setTtlInputMode(false);
@@ -2587,7 +2570,16 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
return;
}
if (inputUpper === "R") {
/*
FNXC:TuiSettingsKeyboard 2026-06-30-22:52:
Tab is the only Settings pane-switch key. Arrow keys must remain available to the focused pane so Windows terminals can use ←/→ to edit enum settings such as Remote Provider instead of getting trapped switching focus.
*/
if (key.tab) {
setDetailFocused((f) => !f);
return;
}
if (input === "R") {
void refreshRemoteStatus();
setStatusMsg(t("tui.settingsRemoteStatusRefreshed", "Remote status refreshed"));
return;
@@ -2595,62 +2587,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
if (!localSettings) return;
if (data?.remote && inputUpper === "C") {
const provider = localSettings.remoteActiveProvider;
if (!provider) {
setStatusMsg(t("tui.settingsSelectProviderFirst", "Select a remote provider first"));
} else {
void data.remote.activateProvider(provider)
.then(() => refreshRemoteStatus())
.then(() => setStatusMsg(t("tui.settingsActivatedProvider", "Activated provider: {{provider}}", { provider })))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
}
return;
}
if (data?.remote && inputUpper === "V") {
void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStarting", "Remote tunnel starting")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && inputUpper === "X") {
void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStopped", "Remote tunnel stopped")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && inputUpper === "P") {
void data.remote.regeneratePersistentToken()
.then((result) => {
setPersistentMaskedToken(result.maskedToken ?? null);
setStatusMsg(t("tui.settingsPersistentTokenRegenerated", "Persistent token regenerated"));
})
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && inputUpper === "L") {
setTtlInputValue(String(localSettings.remoteShortLivedTtlMs));
setTtlInputMode(true);
setStatusMsg(t("tui.settingsEnterTtl", "Enter TTL milliseconds and press Enter"));
return;
}
if (data?.remote && inputUpper === "U") {
void handleFetchRemoteUrl("persistent")
.then(() => setStatusMsg(t("tui.settingsRemoteUrlFetched", "Remote URL fetched")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && inputUpper === "K") {
void handleFetchRemoteQr("persistent")
.then(() => setStatusMsg(t("tui.settingsQrFetched", "QR payload fetched")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (!detailFocused) {
if (key.upArrow || input === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
@@ -2665,6 +2601,87 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
if (!selectedDef) return;
if (selectedDef.type === "enum" && selectedDef.options) {
const current = localSettings[selectedDef.key];
const idx = typeof current === "string" ? selectedDef.options.indexOf(current) : -1;
if (key.rightArrow || input === "l") {
const next = selectedDef.options[(idx + 1) % selectedDef.options.length];
const updated = { ...localSettings, [selectedDef.key]: next };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: next });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
if (key.leftArrow || input === "h") {
const prev = selectedDef.options[(idx - 1 + selectedDef.options.length) % selectedDef.options.length];
const updated = { ...localSettings, [selectedDef.key]: prev };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: prev });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
}
if (data?.remote && input === "C") {
const provider = localSettings.remoteActiveProvider;
if (!provider) {
setStatusMsg(t("tui.settingsSelectProviderFirst", "Select a remote provider first"));
} else {
void data.remote.activateProvider(provider)
.then(() => refreshRemoteStatus())
.then(() => setStatusMsg(t("tui.settingsActivatedProvider", "Activated provider: {{provider}}", { provider })))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
}
return;
}
if (data?.remote && input === "V") {
void data.remote.startTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStarting", "Remote tunnel starting")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "X") {
void data.remote.stopTunnel().then(() => refreshRemoteStatus()).then(() => setStatusMsg(t("tui.settingsTunnelStopped", "Remote tunnel stopped")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "P") {
void data.remote.regeneratePersistentToken()
.then((result) => {
setPersistentMaskedToken(result.maskedToken ?? null);
setStatusMsg(t("tui.settingsPersistentTokenRegenerated", "Persistent token regenerated"));
})
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "L") {
setTtlInputValue(String(localSettings.remoteShortLivedTtlMs));
setTtlInputMode(true);
setStatusMsg(t("tui.settingsEnterTtl", "Enter TTL milliseconds and press Enter"));
return;
}
if (data?.remote && input === "U") {
void handleFetchRemoteUrl("persistent")
.then(() => setStatusMsg(t("tui.settingsRemoteUrlFetched", "Remote URL fetched")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (data?.remote && input === "K") {
void handleFetchRemoteQr("persistent")
.then(() => setStatusMsg(t("tui.settingsQrFetched", "QR payload fetched")))
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
return;
}
if (selectedDef.type === "boolean" && input === " ") {
const current = localSettings[selectedDef.key] as boolean;
const updated = { ...localSettings, [selectedDef.key]: !current };
@@ -2692,30 +2709,6 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
}
}
if (selectedDef.type === "enum" && selectedDef.options) {
const current = localSettings[selectedDef.key] as string;
const idx = selectedDef.options.indexOf(current);
if (key.rightArrow || input === "l") {
const next = selectedDef.options[(idx + 1) % selectedDef.options.length];
const updated = { ...localSettings, [selectedDef.key]: next };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: next });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(next as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
if (key.leftArrow || input === "h") {
const prev = selectedDef.options[(idx - 1 + selectedDef.options.length) % selectedDef.options.length];
const updated = { ...localSettings, [selectedDef.key]: prev };
setLocalSettings(updated);
void saveField({ [selectedDef.key]: prev });
if (selectedDef.key === "remoteActiveProvider" && data?.remote) {
void data.remote.activateProvider(prev as "tailscale" | "cloudflare").catch(() => {});
}
return;
}
}
});
function renderValue(def: SettingDef, settings: SettingsValues): React.ReactNode {