Skip to content

Commit 7cba854

Browse files
committed
feat(tui): add header/footer/shortcuts toggle for small screen optimization
Add System commands to toggle header, footer, and shortcuts visibility via command palette (ctrl+p). All preferences persist across restarts. - All toggle commands under System category in app.tsx - Available globally (home + session pages) - When footer hidden: compact status shown in prompt info line - When footer visible: detailed status in bottom section Fixes #5277
1 parent 9090133 commit 7cba854

File tree

4 files changed

+205
-97
lines changed

4 files changed

+205
-97
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ function App() {
161161
const local = useLocal()
162162
const kv = useKV()
163163
const command = useCommandDialog()
164+
const [shortcutsHidden, setShortcutsHidden] = createSignal(kv.get("shortcuts_hidden", false))
165+
const [headerHidden, setHeaderHidden] = createSignal(kv.get("header_hidden", false))
166+
const [footerHidden, setFooterHidden] = createSignal(kv.get("footer_hidden", false))
164167
const { event } = useSDK()
165168
const toast = useToast()
166169
const { theme, mode, setMode } = useTheme()
@@ -408,6 +411,45 @@ function App() {
408411
dialog.clear()
409412
},
410413
},
414+
{
415+
title: shortcutsHidden() ? "Show shortcuts" : "Hide shortcuts",
416+
value: "app.shortcuts.toggle",
417+
category: "System",
418+
onSelect: (dialog) => {
419+
setShortcutsHidden((prev) => {
420+
const next = !prev
421+
kv.set("shortcuts_hidden", next)
422+
return next
423+
})
424+
dialog.clear()
425+
},
426+
},
427+
{
428+
title: headerHidden() ? "Show header" : "Hide header",
429+
value: "app.header.toggle",
430+
category: "System",
431+
onSelect: (dialog) => {
432+
setHeaderHidden((prev) => {
433+
const next = !prev
434+
kv.set("header_hidden", next)
435+
return next
436+
})
437+
dialog.clear()
438+
},
439+
},
440+
{
441+
title: footerHidden() ? "Show footer" : "Hide footer",
442+
value: "app.footer.toggle",
443+
category: "System",
444+
onSelect: (dialog) => {
445+
setFooterHidden((prev) => {
446+
const next = !prev
447+
kv.set("footer_hidden", next)
448+
return next
449+
})
450+
dialog.clear()
451+
},
452+
},
411453
{
412454
title: "Suspend terminal",
413455
value: "terminal.suspend",

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 149 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { useDialog } from "@tui/ui/dialog"
2727
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
2828
import { DialogAlert } from "../../ui/dialog-alert"
2929
import { useToast } from "../../ui/toast"
30+
import { useKV } from "../../context/kv"
3031

3132
export type PromptProps = {
3233
sessionID?: string
@@ -35,6 +36,7 @@ export type PromptProps = {
3536
ref?: (ref: PromptRef) => void
3637
hint?: JSX.Element
3738
showPlaceholder?: boolean
39+
footerVisible?: boolean
3840
}
3941

4042
export type PromptRef = {
@@ -115,7 +117,13 @@ export function Prompt(props: PromptProps) {
115117
const sync = useSync()
116118
const dialog = useDialog()
117119
const toast = useToast()
120+
const kv = useKV()
118121
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
122+
const permissions = createMemo(() => {
123+
if (!props.sessionID) return []
124+
return sync.data.permission[props.sessionID] ?? []
125+
})
126+
const shortcutsVisible = createMemo(() => !kv.get("shortcuts_hidden", false))
119127
const history = usePromptHistory()
120128
const command = useCommandDialog()
121129
const renderer = useRenderer()
@@ -844,17 +852,50 @@ export function Prompt(props: PromptProps) {
844852
cursorColor={theme.text}
845853
syntaxStyle={syntax()}
846854
/>
847-
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
848-
<text fg={highlight()}>
849-
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
850-
</text>
851-
<Show when={store.mode === "normal"}>
852-
<box flexDirection="row" gap={1}>
853-
<text flexShrink={0} fg={theme.text}>
854-
{local.model.parsed().model}
855-
</text>
856-
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
857-
</box>
855+
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
856+
<box flexDirection="row" gap={1}>
857+
<text fg={highlight()}>
858+
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
859+
</text>
860+
<Show when={store.mode === "normal"}>
861+
<box flexDirection="row" gap={1}>
862+
<text flexShrink={0} fg={theme.text}>
863+
{local.model.parsed().model}
864+
</text>
865+
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
866+
</box>
867+
</Show>
868+
</box>
869+
<Show when={store.mode === "normal" && props.sessionID && !props.footerVisible}>
870+
<Switch>
871+
<Match when={permissions().length > 0}>
872+
<text fg={theme.warning}>
873+
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
874+
{permissions().length > 1 ? "s" : ""} pending
875+
</text>
876+
</Match>
877+
<Match when={status().type !== "idle"}>
878+
<box flexDirection="row" gap={1} flexShrink={0}>
879+
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
880+
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
881+
esc{" "}
882+
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
883+
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
884+
</span>
885+
</text>
886+
</box>
887+
</Match>
888+
<Match when={shortcutsVisible()}>
889+
<box gap={2} flexDirection="row" flexShrink={0}>
890+
<text fg={theme.text}>
891+
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
892+
</text>
893+
<text fg={theme.text}>
894+
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
895+
</text>
896+
</box>
897+
</Match>
898+
</Switch>
858899
</Show>
859900
</box>
860901
</box>
@@ -886,103 +927,117 @@ export function Prompt(props: PromptProps) {
886927
}
887928
/>
888929
</box>
889-
<box flexDirection="row" justifyContent="space-between">
890-
<Show when={status().type !== "idle"} fallback={<text />}>
891-
<box
892-
flexDirection="row"
893-
gap={1}
894-
flexGrow={1}
895-
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
896-
>
897-
<box flexShrink={0} flexDirection="row" gap={1}>
898-
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
899-
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
900-
<box flexDirection="row" gap={1} flexShrink={0}>
901-
{(() => {
902-
const retry = createMemo(() => {
903-
const s = status()
904-
if (s.type !== "retry") return
905-
return s
906-
})
907-
const message = createMemo(() => {
908-
const r = retry()
909-
if (!r) return
910-
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
911-
return "gemini is way too hot right now"
912-
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
913-
return r.message
914-
})
915-
const isTruncated = createMemo(() => {
916-
const r = retry()
917-
if (!r) return false
918-
return r.message.length > 120
919-
})
920-
const [seconds, setSeconds] = createSignal(0)
921-
onMount(() => {
922-
const timer = setInterval(() => {
923-
const next = retry()?.next
924-
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
925-
}, 1000)
926-
927-
onCleanup(() => {
928-
clearInterval(timer)
930+
<Show when={store.mode === "shell"}>
931+
<box gap={2} flexDirection="row">
932+
<text fg={theme.text}>
933+
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
934+
</text>
935+
</box>
936+
</Show>
937+
<Show when={store.mode !== "shell"}>
938+
<box flexDirection="row" justifyContent="space-between">
939+
<Show when={props.footerVisible && status().type !== "idle"} fallback={<text />}>
940+
<box
941+
flexDirection="row"
942+
gap={1}
943+
flexGrow={1}
944+
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
945+
>
946+
<box flexShrink={0} flexDirection="row" gap={1}>
947+
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
948+
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
949+
<box flexDirection="row" gap={1} flexShrink={0}>
950+
{(() => {
951+
const retry = createMemo(() => {
952+
const s = status()
953+
if (s.type !== "retry") return
954+
return s
955+
})
956+
const message = createMemo(() => {
957+
const r = retry()
958+
if (!r) return
959+
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
960+
return "gemini is way too hot right now"
961+
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
962+
return r.message
929963
})
930-
})
931-
const handleMessageClick = () => {
932-
const r = retry()
933-
if (!r) return
934-
if (isTruncated()) {
935-
DialogAlert.show(dialog, "Retry Error", r.message)
964+
const isTruncated = createMemo(() => {
965+
const r = retry()
966+
if (!r) return false
967+
return r.message.length > 120
968+
})
969+
const [seconds, setSeconds] = createSignal(0)
970+
onMount(() => {
971+
const timer = setInterval(() => {
972+
const next = retry()?.next
973+
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
974+
}, 1000)
975+
976+
onCleanup(() => {
977+
clearInterval(timer)
978+
})
979+
})
980+
const handleMessageClick = () => {
981+
const r = retry()
982+
if (!r) return
983+
if (isTruncated()) {
984+
DialogAlert.show(dialog, "Retry Error", r.message)
985+
}
936986
}
937-
}
938987

939-
const retryText = () => {
940-
const r = retry()
941-
if (!r) return ""
942-
const baseMessage = message()
943-
const truncatedHint = isTruncated() ? " (click to expand)" : ""
944-
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
945-
return baseMessage + truncatedHint + retryInfo
946-
}
988+
const retryText = () => {
989+
const r = retry()
990+
if (!r) return ""
991+
const baseMessage = message()
992+
const truncatedHint = isTruncated() ? " (click to expand)" : ""
993+
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
994+
return baseMessage + truncatedHint + retryInfo
995+
}
947996

948-
return (
949-
<Show when={retry()}>
950-
<box onMouseUp={handleMessageClick}>
951-
<text fg={theme.error}>{retryText()}</text>
952-
</box>
953-
</Show>
954-
)
955-
})()}
997+
return (
998+
<Show when={retry()}>
999+
<box onMouseUp={handleMessageClick}>
1000+
<text fg={theme.error}>{retryText()}</text>
1001+
</box>
1002+
</Show>
1003+
)
1004+
})()}
1005+
</box>
9561006
</box>
1007+
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
1008+
esc{" "}
1009+
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
1010+
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
1011+
</span>
1012+
</text>
9571013
</box>
958-
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
959-
esc{" "}
960-
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
961-
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
962-
</span>
963-
</text>
964-
</box>
965-
</Show>
966-
<Show when={status().type !== "retry"}>
967-
<box gap={2} flexDirection="row">
968-
<Switch>
969-
<Match when={store.mode === "normal"}>
1014+
</Show>
1015+
<Show when={!props.sessionID && status().type === "idle" && shortcutsVisible()}>
1016+
<box flexDirection="row" justifyContent="flex-end" flexGrow={1}>
1017+
<box gap={2} flexDirection="row">
9701018
<text fg={theme.text}>
9711019
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
9721020
</text>
9731021
<text fg={theme.text}>
9741022
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
9751023
</text>
976-
</Match>
977-
<Match when={store.mode === "shell"}>
1024+
</box>
1025+
</box>
1026+
</Show>
1027+
<Show when={props.sessionID && props.footerVisible && status().type === "idle" && shortcutsVisible()}>
1028+
<box flexDirection="row" justifyContent="flex-end" flexGrow={1}>
1029+
<box gap={2} flexDirection="row">
9781030
<text fg={theme.text}>
979-
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
1031+
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
9801032
</text>
981-
</Match>
982-
</Switch>
983-
</box>
984-
</Show>
985-
</box>
1033+
<text fg={theme.text}>
1034+
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
1035+
</text>
1036+
</box>
1037+
</box>
1038+
</Show>
1039+
</box>
1040+
</Show>
9861041
</box>
9871042
</>
9881043
)

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function Home() {
7272
promptRef.set(r)
7373
}}
7474
hint={Hint}
75+
footerVisible={false}
7576
/>
7677
</box>
7778
<Toast />

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export function Session() {
131131
return false
132132
})
133133
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
134+
const headerVisible = createMemo(() => !sidebarVisible() && !kv.get("header_hidden", false))
135+
const footerVisible = createMemo(() => !sidebarVisible() && !kv.get("footer_hidden", false))
134136

135137
const scrollAcceleration = createMemo(() => {
136138
const tui = sync.data.config.tui
@@ -829,9 +831,16 @@ export function Session() {
829831
}}
830832
>
831833
<box flexDirection="row">
832-
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
834+
<box
835+
flexGrow={1}
836+
paddingBottom={footerVisible() ? 1 : 0}
837+
paddingTop={1}
838+
paddingLeft={2}
839+
paddingRight={2}
840+
gap={1}
841+
>
833842
<Show when={session()}>
834-
<Show when={!sidebarVisible()}>
843+
<Show when={headerVisible()}>
835844
<Header />
836845
</Show>
837846
<scrollbox
@@ -956,9 +965,10 @@ export function Session() {
956965
toBottom()
957966
}}
958967
sessionID={route.sessionID}
968+
footerVisible={footerVisible()}
959969
/>
960970
</box>
961-
<Show when={!sidebarVisible()}>
971+
<Show when={footerVisible()}>
962972
<Footer />
963973
</Show>
964974
</Show>

0 commit comments

Comments
 (0)