Skip to content

Commit 9e57105

Browse files
authored
ui: keep every full-frame screen on the alternate buffer (#119)
* ui: trim whitespace on wrapped fact lines, align wrapped prompt lines, rename placeholder to cloud agent * ui: keep every full-frame screen on the alternate buffer Only a live chat runs on the primary buffer now. The new-session composer and the loading placeholder painted full-height frames onto the primary buffer, pushing stale copies of themselves into scrollback on every repaint. useAltScreen also reports when the buffer hop has settled, and the chat mounts only after the terminal is back on the primary buffer, so its Static transcript rows are never printed onto (and destroyed with) the alt screen.
1 parent 4b199df commit 9e57105

2 files changed

Lines changed: 55 additions & 18 deletions

File tree

src/ui/SessionsApp.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,6 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
262262
// terminal rather than a fixed handful of rows.
263263
const [focus, setFocus] = useState<'nav' | 'chat'>('chat')
264264
const navOpen = focus === 'nav' && !hideNav
265-
useAltScreen(navOpen)
266265
const [mainPane, setMainPane] = useState<MainPane>(
267266
props.initialSessionId ? { type: 'chat', sessionId: props.initialSessionId } : { type: 'new' },
268267
)
@@ -273,6 +272,19 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
273272
// ------------------------------ chat entries ------------------------------
274273

275274
const [entries, setEntries] = useState<ReadonlyMap<string, ChatEntry>>(new Map())
275+
// Only a live chat runs on the primary buffer, printing into the terminal's
276+
// real scrollback. Every other screen — the picker, the new-session
277+
// composer, the loading placeholder — paints a full-height frame, and a
278+
// frame repainted on the primary buffer pushes a stale copy of itself into
279+
// scrollback on every repaint. So all of them live on the alternate buffer,
280+
// and the primary buffer only ever holds transcript rows.
281+
const chatOwnsScreen = mainPane.type === 'chat' && entries.has(mainPane.sessionId)
282+
// `altScreenOn` trails the hop: it stays true until the terminal is actually
283+
// back on the primary buffer. The chat may only mount once it is false —
284+
// its transcript prints each row exactly ONCE (ink <Static>), and a row
285+
// printed while the alt screen still has the terminal is destroyed with it.
286+
const altScreenOn = useAltScreen(navOpen || !chatOwnsScreen)
287+
const chatVisible = chatOwnsScreen && !altScreenOn
276288
// Sessions whose chat has already printed into this terminal's scrollback.
277289
// The NEXT one to open prints a rule naming itself first, so two conversations
278290
// in one scrollback don't run together (see ConnectApp's scrollbackBreak).
@@ -459,9 +471,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
459471
// ctrl+c quits from the nav and from the panes that aren't a live chat (the
460472
// new-session form, a chat still loading) — the chat pane owns its own, where
461473
// the first press also interrupts the running turn.
462-
const chatOwnsCtrlC = mainPane.type === 'chat' && entries.has(mainPane.sessionId)
463474
const navArmed = useCtrlCQuit(focus === 'nav' && isRawModeSupported)
464-
const paneArmed = useCtrlCQuit(focus === 'chat' && !chatOwnsCtrlC && isRawModeSupported)
475+
const paneArmed = useCtrlCQuit(focus === 'chat' && !chatOwnsScreen && isRawModeSupported)
465476

466477
// With the session bar hidden there is nothing to hand focus to: esc and ↓
467478
// at the chat's bottom edge land where they started.
@@ -735,9 +746,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement {
735746
// A LIVE CHAT owns the terminal outright: no box around it, no header, no
736747
// inset. All three would be frame furniture around a region that prints into
737748
// the terminal's scrollback, and the rows scrolling past would run straight
738-
// through them.
739-
const chatOwnsScreen = mainPane.type === 'chat' && entries.has(mainPane.sessionId)
740-
if (chatOwnsScreen) return main
749+
// through them. It mounts only once the alt-screen hop has finished (see
750+
// chatVisible above); the one frame in between renders nothing.
751+
if (chatVisible) return main
752+
if (chatOwnsScreen) return <Box />
741753

742754
// The remaining screens (the new-session composer, a chat still loading) do
743755
// own their frame, so they keep the inset and the header.

src/ui/altScreen.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { useEffect, useRef } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
22
import { useApp, useStdout } from 'ink'
33

44
// The alternate screen buffer (DECSET 1049), hand-rolled because ink 7 takes
55
// `alternateScreen` only as a render()-time option and this app has to hop
66
// buffers at runtime under ONE live ink instance: the default view lives in the
77
// primary buffer with its settled transcript flushed to real scrollback, and
8-
// the transcript browser takes over the alt screen and hands the primary buffer
9-
// back untouched on exit.
8+
// the full-frame screens (picker, composer, browser) take over the alt screen
9+
// and hand the primary buffer back untouched on exit.
1010
//
1111
// The switch goes through ink's suspendTerminal, which is the only way to keep
1212
// ink's picture of the screen honest across it: suspend ERASES the current
@@ -18,26 +18,51 @@ import { useApp, useStdout } from 'ink'
1818
const ENTER = '[?1049h'
1919
const LEAVE = '[?1049l'
2020

21-
export function useAltScreen(active: boolean): void {
21+
// Returns whether the app is ON the alt screen right now, trailing `active` by
22+
// the hop: it flips only after the buffer switch has been written. Callers that
23+
// print into the terminal's real scrollback (ink's <Static>) must wait for
24+
// `false` before mounting — <Static> prints each row exactly once, so a row
25+
// flushed while the alt screen still has the terminal is lost when it closes.
26+
export function useAltScreen(active: boolean): boolean {
2227
const { suspendTerminal } = useApp()
2328
const { stdout } = useStdout()
2429
// Transitions are serialized: suspendTerminal throws if the terminal is
2530
// already suspended, and two fast toggles (ctrl+r then esc) would otherwise
2631
// overlap. Errors are swallowed — failing to hop buffers must not take the
2732
// session down.
28-
const queue = useRef<Promise<unknown>>(Promise.resolve())
29-
const hop = (write: string): void => {
30-
if (!stdout?.isTTY) return
33+
const queue = useRef<Promise<void>>(Promise.resolve())
34+
const hop = (write: string): Promise<void> => {
35+
if (!stdout?.isTTY) return Promise.resolve()
3136
queue.current = queue.current
3237
.then(() => suspendTerminal(() => void stdout.write(write)))
3338
.catch(() => {})
39+
return queue.current
3440
}
41+
// What the terminal is actually showing. `on` tracks the writes issued (so a
42+
// re-render never re-enters a buffer it is already in); `settled` follows the
43+
// completed hop and is what callers key rendering off.
44+
const on = useRef(false)
45+
const [settled, setSettled] = useState(false)
3546
useEffect(() => {
36-
if (!active) return
37-
hop(ENTER)
38-
// Also the unmount path: quitting from inside the browser must give the
39-
// primary buffer back rather than leave the shell on the alt screen.
40-
return () => hop(LEAVE)
47+
if (active === on.current) return
48+
on.current = active
49+
let alive = true
50+
void hop(active ? ENTER : LEAVE).then(() => {
51+
if (alive) setSettled(active)
52+
})
53+
return () => {
54+
alive = false
55+
}
4156
// eslint-disable-next-line react-hooks/exhaustive-deps
4257
}, [active, stdout, suspendTerminal])
58+
// The unmount path: quitting from inside an alt screen must give the primary
59+
// buffer back rather than leave the shell on the alt screen.
60+
useEffect(
61+
() => () => {
62+
if (on.current) void hop(LEAVE)
63+
},
64+
// eslint-disable-next-line react-hooks/exhaustive-deps
65+
[],
66+
)
67+
return settled
4368
}

0 commit comments

Comments
 (0)