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:
7
.changeset/fn-7346-tui-settings-arrows.md
Normal file
7
.changeset/fn-7346-tui-settings-arrows.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user