From ccab1ff2a533e53a55fe256475592a4289e9655e Mon Sep 17 00:00:00 2001 From: Dudka Date: Fri, 7 Aug 2026 14:55:56 +0300 Subject: [PATCH 1/4] feat(tui): move the Sessions/Tasks rail to the left, advertise chat scrolling in the footer Two small UX changes decided by product: - the sidebar renders on the LEFT of the chat column, matching the desktop chat apps users already know. Only the JSX order and the divider side change: focus order, width, and the 100-column visibility threshold are untouched - the footer now advertises how to scroll the chat, which had no visible entry point: a platform-aware chip (fn+up on macOS, pgup elsewhere) in the idle chat footer and, more importantly, while a turn is streaming, which is exactly when scrolling back matters. The chip takes ctrl+b's slot to stay within the six-chip row; Observe remains reachable via /observe Co-Authored-By: Claude Fable 5 --- src/tui/components/hotkey-hint.test.tsx | 47 +++++++++++++++++++++++++ src/tui/components/hotkey-hint.tsx | 15 +++++++- src/tui/components/sidebar.tsx | 6 ++-- src/tui/tui-app.tsx | 28 ++++++++------- 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 src/tui/components/hotkey-hint.test.tsx diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx new file mode 100644 index 0000000..dc9aa8a --- /dev/null +++ b/src/tui/components/hotkey-hint.test.tsx @@ -0,0 +1,47 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; +import React from "react"; + +import { fakeSession } from "../test-fixtures.js"; +import { createInitialTuiState, type TuiState } from "../tui-state.js"; +import { HotkeyHint } from "./hotkey-hint.js"; + +const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + +function renderHint(state: TuiState): string { + const { lastFrame, unmount } = render(); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + return out; +} + +function chatState(overrides: Partial = {}): TuiState { + return { + ...createInitialTuiState(fakeSession()), + uiMode: "chat" as const, + ...overrides, + }; +} + +const SCROLL_KEY = process.platform === "darwin" ? "fn+↑" : "pgup"; + +describe("HotkeyHint scroll chip", () => { + it("advertises the chat-scroll key in the idle chat footer", () => { + const out = renderHint(chatState()); + expect(out).toContain(SCROLL_KEY); + expect(out).toContain("scroll"); + // The scroll chip took ctrl+b's slot to stay within the 6-chip row. + expect(out).not.toContain("ctrl+b"); + }); + + it("advertises the chat-scroll key while a turn is running", () => { + const out = renderHint(chatState({ status: "running" })); + expect(out).toContain(SCROLL_KEY); + expect(out).toContain("scroll"); + }); + + it("keeps modal footers free of the scroll chip", () => { + const out = renderHint(chatState({ slashPaletteOpen: true })); + expect(out).not.toContain("scroll"); + }); +}); diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 945870f..8b0f2df 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -14,6 +14,13 @@ interface HotkeyChip { readonly label: string; } +/** + * Platform-aware label for the chat-scroll key. The physical key is + * PageUp; Mac keyboards reach it via Fn+Up, and that is the spelling + * Mac users actually recognise. + */ +const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191" : "pgup"; + /** * Bottom hint strip: surfaces the keybindings that are meaningful in * the current state so the user never has to guess. We cap to ~6 chips @@ -59,7 +66,10 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { ]; } if (state.status === "running") { + // A long streaming answer is exactly when the operator wants to + // scroll back, so the hint rides along with abort. return [ + { key: SCROLL_KEY, label: "scroll" }, { key: "esc", label: "abort" }, { key: "ctrl+c", @@ -91,11 +101,14 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] { }, ]; } + // Six chips is the cap for one row on narrow terminals. The scroll + // hint replaces ctrl+b: Observe stays reachable via /observe, while + // scrolling had no visible entry point at all. return [ { key: "enter", label: "send" }, { key: "alt+enter", label: "newline" }, { key: "tab", label: "sidebar" }, - { key: "ctrl+b", label: "open Observe" }, + { key: SCROLL_KEY, label: "scroll" }, { key: "/", label: "commands" }, { key: "ctrl+c", diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index f7ce46b..6069c67 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -54,9 +54,11 @@ export function Sidebar(props: SidebarProps): ReactElement { flexDirection="column" borderStyle="single" borderTop={false} - borderRight={false} + // The rail renders on the LEFT of the chat column, so its single + // divider faces right, toward the content it frames. + borderRight borderBottom={false} - borderLeft + borderLeft={false} borderColor={theme.colors.border} paddingLeft={1} paddingRight={1} diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 69fa0ae..8be15ae 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -691,6 +691,22 @@ export function TuiApp({ + {/* Sessions/Tasks rail sits on the LEFT, matching the desktop + chat apps users already know. Focus order (Tab: editor -> + sessions -> tasks) is logical, not positional, so it is + unaffected by which side the rail renders on. */} + {sidebarVisible ? ( + + ) : null} {state.uiMode === "chat" ? ( @@ -782,18 +798,6 @@ export function TuiApp({ /> - {sidebarVisible ? ( - - ) : null} ); From df5e125fd3f0c9812eb16e36a0aa781ac2f22ce7 Mon Sep 17 00:00:00 2001 From: Dudka Date: Fri, 7 Aug 2026 15:01:14 +0300 Subject: [PATCH 2/4] feat(tui): show both scroll directions in the footer chip fn+up/down on macOS, pgup/pgdn elsewhere, per product feedback. Co-Authored-By: Claude Fable 5 --- src/tui/components/hotkey-hint.test.tsx | 2 +- src/tui/components/hotkey-hint.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index dc9aa8a..2454d82 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -23,7 +23,7 @@ function chatState(overrides: Partial = {}): TuiState { }; } -const SCROLL_KEY = process.platform === "darwin" ? "fn+↑" : "pgup"; +const SCROLL_KEY = process.platform === "darwin" ? "fn+↑↓" : "pgup/pgdn"; describe("HotkeyHint scroll chip", () => { it("advertises the chat-scroll key in the idle chat footer", () => { diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx index 8b0f2df..1737510 100644 --- a/src/tui/components/hotkey-hint.tsx +++ b/src/tui/components/hotkey-hint.tsx @@ -19,7 +19,7 @@ interface HotkeyChip { * PageUp; Mac keyboards reach it via Fn+Up, and that is the spelling * Mac users actually recognise. */ -const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191" : "pgup"; +const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgdn"; /** * Bottom hint strip: surfaces the keybindings that are meaningful in From 8c733b1a5475ce553bab242c2ae55b991c5190d5 Mon Sep 17 00:00:00 2001 From: sosidudku1 Date: Fri, 7 Aug 2026 19:07:18 +0300 Subject: [PATCH 3/4] test(tui): cover both scroll-key spellings and fix stale sidebar docs The scroll-key test computed its expected value with the same process.platform expression as the component, so it could never fail. Both platform branches are now exercised via vi.resetModules plus a stubbed process.platform and a fresh dynamic import, against literal expected strings. Also add a case asserting the pending-approval footer (y/n/esc) carries no scroll chip, and update three comments and a test name that still said "right-rail" after the sidebar moved to the left. Co-Authored-By: Claude Fable 5 --- src/tui/components/hotkey-hint.test.tsx | 66 +++++++++++++++++++++++-- src/tui/components/sidebar.tsx | 2 +- src/tui/tui-app.test.tsx | 2 +- src/tui/tui-app.tsx | 2 +- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx index 2454d82..0c5b131 100644 --- a/src/tui/components/hotkey-hint.test.tsx +++ b/src/tui/components/hotkey-hint.test.tsx @@ -1,13 +1,21 @@ import { render } from "ink-testing-library"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react"; +import type { ApprovalRequest } from "../../approval/approval-gate.js"; import { fakeSession } from "../test-fixtures.js"; import { createInitialTuiState, type TuiState } from "../tui-state.js"; import { HotkeyHint } from "./hotkey-hint.js"; const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; +// Literal spellings, deliberately NOT derived from process.platform the +// way the component does it — otherwise the assertion would be true by +// construction on every platform. +const MAC_SCROLL_KEY = "fn+↑↓"; +const OTHER_SCROLL_KEY = "pgup/pgdn"; +const SCROLL_KEY_PATTERN = /fn\+↑↓|pgup\/pgdn/; + function renderHint(state: TuiState): string { const { lastFrame, unmount } = render(); const out = (lastFrame() ?? "").replace(ANSI, ""); @@ -23,12 +31,20 @@ function chatState(overrides: Partial = {}): TuiState { }; } -const SCROLL_KEY = process.platform === "darwin" ? "fn+↑↓" : "pgup/pgdn"; +function fakeApproval(): ApprovalRequest { + return { + approvalId: "appr-1", + sessionId: "sess-1", + tool: "bash", + reason: "wants to run a command", + preview: "rm -rf ./dist", + }; +} describe("HotkeyHint scroll chip", () => { it("advertises the chat-scroll key in the idle chat footer", () => { const out = renderHint(chatState()); - expect(out).toContain(SCROLL_KEY); + expect(out).toMatch(SCROLL_KEY_PATTERN); expect(out).toContain("scroll"); // The scroll chip took ctrl+b's slot to stay within the 6-chip row. expect(out).not.toContain("ctrl+b"); @@ -36,7 +52,7 @@ describe("HotkeyHint scroll chip", () => { it("advertises the chat-scroll key while a turn is running", () => { const out = renderHint(chatState({ status: "running" })); - expect(out).toContain(SCROLL_KEY); + expect(out).toMatch(SCROLL_KEY_PATTERN); expect(out).toContain("scroll"); }); @@ -44,4 +60,46 @@ describe("HotkeyHint scroll chip", () => { const out = renderHint(chatState({ slashPaletteOpen: true })); expect(out).not.toContain("scroll"); }); + + it("keeps the approval footer (y/n/esc) free of the scroll chip", () => { + const out = renderHint(chatState({ pendingApproval: fakeApproval() })); + expect(out).toContain("approve"); + expect(out).toContain("deny"); + expect(out).toContain("abort run"); + expect(out).not.toContain("scroll"); + expect(out).not.toMatch(SCROLL_KEY_PATTERN); + }); +}); + +describe("HotkeyHint scroll key spelling per platform", () => { + const realPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, "platform", { value: realPlatform }); + vi.resetModules(); + }); + + // SCROLL_KEY is resolved at module load, so each case re-imports the + // component after stubbing process.platform. + async function renderIdleFooterOn(platform: NodeJS.Platform): Promise { + Object.defineProperty(process, "platform", { value: platform }); + vi.resetModules(); + const fresh = await import("./hotkey-hint.js"); + const { lastFrame, unmount } = render(); + const out = (lastFrame() ?? "").replace(ANSI, ""); + unmount(); + return out; + } + + it("darwin spells the scroll key as fn+arrows", async () => { + const out = await renderIdleFooterOn("darwin"); + expect(out).toContain(MAC_SCROLL_KEY); + expect(out).not.toContain(OTHER_SCROLL_KEY); + }); + + it("linux spells the scroll key as pgup/pgdn", async () => { + const out = await renderIdleFooterOn("linux"); + expect(out).toContain(OTHER_SCROLL_KEY); + expect(out).not.toContain(MAC_SCROLL_KEY); + }); }); diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index 6069c67..2f384ba 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -23,7 +23,7 @@ const MAX_SESSION_ROWS = 10; const MAX_TASK_ROWS = 5; /** - * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and + * Always-on left-rail sidebar. Two stacked panes — Sessions (top) and * Tasks (bottom) — both navigable when the sidebar has focus. Tab * cycles editor → sessions → tasks → editor (handled by * `app-key-bindings.ts`); the sidebar component itself is purely diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 79c98be..5262aeb 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -193,7 +193,7 @@ describe("TuiApp (smoke)", () => { unmount(); }); - it("renders the right-rail sidebar with Sessions and Tasks panes in chat mode", () => { + it("renders the left-rail sidebar with Sessions and Tasks panes in chat mode", () => { const bus = makeTuiEventBus(); const { lastFrame, unmount } = render( , diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 8be15ae..d5acde5 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -317,7 +317,7 @@ const DEFAULT_MAX_VISIBLE_ROWS = 14; const CTRL_C_WINDOW_MS = 1500; /** - * Minimum terminal width (in columns) at which the right-rail sidebar + * Minimum terminal width (in columns) at which the left-rail sidebar * is rendered. Narrower terminals collapse the layout back to the * single-column form so cramped sessions over SSH stay usable. Picked * to match opencode's threshold. From 5fcfc6d75c1f1e18862b13abe69e4fe6a3f57cc9 Mon Sep 17 00:00:00 2001 From: Dudka Date: Fri, 7 Aug 2026 19:31:04 +0300 Subject: [PATCH 4/4] revert(tui): keep the Sessions/Tasks rail on the right, keep the scroll hint Product decision after live testing: the rail move to the left did not hold up, so the layout goes back to exactly what it was before this branch. The sidebar renders after the chat column again and its single divider is back on its left edge. sidebar.tsx, tui-app.tsx, and tui-app.test.tsx are now byte-identical to their pre-branch state; the right-rail wording in docs, comments, and the smoke-test name is restored. The footer scroll chip (fn+up/down on macOS, pgup/pgdn elsewhere) and all hotkey-hint tests stay: that part of the change shipped as planned. Co-Authored-By: Claude Fable 5 --- src/tui/components/sidebar.tsx | 8 +++----- src/tui/tui-app.test.tsx | 2 +- src/tui/tui-app.tsx | 30 +++++++++++++----------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/tui/components/sidebar.tsx b/src/tui/components/sidebar.tsx index 2f384ba..f7ce46b 100644 --- a/src/tui/components/sidebar.tsx +++ b/src/tui/components/sidebar.tsx @@ -23,7 +23,7 @@ const MAX_SESSION_ROWS = 10; const MAX_TASK_ROWS = 5; /** - * Always-on left-rail sidebar. Two stacked panes — Sessions (top) and + * Always-on right-rail sidebar. Two stacked panes — Sessions (top) and * Tasks (bottom) — both navigable when the sidebar has focus. Tab * cycles editor → sessions → tasks → editor (handled by * `app-key-bindings.ts`); the sidebar component itself is purely @@ -54,11 +54,9 @@ export function Sidebar(props: SidebarProps): ReactElement { flexDirection="column" borderStyle="single" borderTop={false} - // The rail renders on the LEFT of the chat column, so its single - // divider faces right, toward the content it frames. - borderRight + borderRight={false} borderBottom={false} - borderLeft={false} + borderLeft borderColor={theme.colors.border} paddingLeft={1} paddingRight={1} diff --git a/src/tui/tui-app.test.tsx b/src/tui/tui-app.test.tsx index 5262aeb..79c98be 100644 --- a/src/tui/tui-app.test.tsx +++ b/src/tui/tui-app.test.tsx @@ -193,7 +193,7 @@ describe("TuiApp (smoke)", () => { unmount(); }); - it("renders the left-rail sidebar with Sessions and Tasks panes in chat mode", () => { + it("renders the right-rail sidebar with Sessions and Tasks panes in chat mode", () => { const bus = makeTuiEventBus(); const { lastFrame, unmount } = render( , diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index d5acde5..69fa0ae 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -317,7 +317,7 @@ const DEFAULT_MAX_VISIBLE_ROWS = 14; const CTRL_C_WINDOW_MS = 1500; /** - * Minimum terminal width (in columns) at which the left-rail sidebar + * Minimum terminal width (in columns) at which the right-rail sidebar * is rendered. Narrower terminals collapse the layout back to the * single-column form so cramped sessions over SSH stay usable. Picked * to match opencode's threshold. @@ -691,22 +691,6 @@ export function TuiApp({ - {/* Sessions/Tasks rail sits on the LEFT, matching the desktop - chat apps users already know. Focus order (Tab: editor -> - sessions -> tasks) is logical, not positional, so it is - unaffected by which side the rail renders on. */} - {sidebarVisible ? ( - - ) : null} {state.uiMode === "chat" ? ( @@ -798,6 +782,18 @@ export function TuiApp({ /> + {sidebarVisible ? ( + + ) : null} );