@mobius-os/mobius 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +31 -23
- package/src/components/Chat.tsx +52 -14
- package/src/components/Screen.tsx +35 -0
- package/src/components/primitives.tsx +6 -3
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import { LoginScreen } from './components/Login.js'
|
|
|
17
17
|
import { PrepScreen, type ReadyState } from './components/PrepScreen.js'
|
|
18
18
|
import { ChatScreen } from './components/Chat.js'
|
|
19
19
|
import { ResumePicker } from './components/ResumePicker.js'
|
|
20
|
+
import { Screen } from './components/Screen.js'
|
|
20
21
|
import { startAimuxConnection, stopAimuxConnection, type AimuxStatus } from './aimux.js'
|
|
21
22
|
import { AimuxStatusLine } from './components/AimuxStatus.js'
|
|
22
23
|
|
|
@@ -117,29 +118,36 @@ export function App() {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
// The chat screen already pins itself to the terminal height, so render it
|
|
122
|
+
// bare — a <Screen> wrapper would clip its transcript in short terminals and
|
|
123
|
+
// in the non-TTY test harness. Every other route (login, the project/issue/
|
|
124
|
+
// session pickers) renders inside <Screen> so each frame is pinned to the
|
|
125
|
+
// terminal height and transitions stay free of stale-frame residue. See
|
|
126
|
+
// components/Screen.tsx.
|
|
127
|
+
if (route === 'chat' && ready && client) {
|
|
128
|
+
return (
|
|
129
|
+
<ChatScreen
|
|
130
|
+
key={chatKey}
|
|
131
|
+
client={client}
|
|
132
|
+
ready={ready}
|
|
133
|
+
webUserId={ready.project.created_by || userId || ready.issue.created_by || ''}
|
|
134
|
+
resumeSessionId={resumeSessionId}
|
|
135
|
+
onClear={onClear}
|
|
136
|
+
onResume={onResume}
|
|
137
|
+
onQuit={onQuit}
|
|
138
|
+
aimuxStatus={aimuxStatus}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
125
141
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (route === '
|
|
130
|
-
|
|
142
|
+
let node: React.ReactNode
|
|
143
|
+
if (route === 'boot') {
|
|
144
|
+
node = <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
|
|
145
|
+
} else if (route === 'login' || !client) {
|
|
146
|
+
node = <LoginScreen onSuccess={onLoginSuccess} />
|
|
147
|
+
} else if (route === 'resume' && ready) {
|
|
148
|
+
node = <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} compact /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
|
|
149
|
+
} else {
|
|
150
|
+
node = <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} compact /><PrepScreen client={client} onReady={onPrepReady} onQuit={onQuit} /></Box>
|
|
131
151
|
}
|
|
132
|
-
return
|
|
133
|
-
<ChatScreen
|
|
134
|
-
key={chatKey}
|
|
135
|
-
client={client}
|
|
136
|
-
ready={ready}
|
|
137
|
-
webUserId={ready.project.created_by || userId || ready.issue.created_by || ''}
|
|
138
|
-
resumeSessionId={resumeSessionId}
|
|
139
|
-
onClear={onClear}
|
|
140
|
-
onResume={onResume}
|
|
141
|
-
onQuit={onQuit}
|
|
142
|
-
aimuxStatus={aimuxStatus}
|
|
143
|
-
/>
|
|
144
|
-
)
|
|
152
|
+
return <Screen>{node}</Screen>
|
|
145
153
|
}
|
package/src/components/Chat.tsx
CHANGED
|
@@ -35,7 +35,7 @@ interface TerminalSize {
|
|
|
35
35
|
isTty: boolean
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const VERSION = '0.2.
|
|
38
|
+
const VERSION = '0.2.6'
|
|
39
39
|
const WELCOME_ROWS = 12
|
|
40
40
|
const CHROME_ROWS = 11
|
|
41
41
|
|
|
@@ -49,6 +49,7 @@ const SLASH_COMMANDS = [
|
|
|
49
49
|
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) {
|
|
50
50
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
51
51
|
const [showHelp, setShowHelp] = useState(false)
|
|
52
|
+
const [scrollBack, setScrollBack] = useState(0)
|
|
52
53
|
const terminal = useTerminalSize()
|
|
53
54
|
|
|
54
55
|
const runSlash = useCallback((raw: string) => {
|
|
@@ -70,19 +71,42 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
70
71
|
return
|
|
71
72
|
}
|
|
72
73
|
setShowHelp(false)
|
|
74
|
+
setScrollBack(0)
|
|
73
75
|
void chat.send(t)
|
|
74
76
|
}, [chat, runSlash])
|
|
75
77
|
|
|
76
78
|
const transcriptRows = Math.max(5, terminal.rows - CHROME_ROWS)
|
|
77
79
|
const fitted = useMemo(
|
|
78
|
-
() => fitTranscript(chat.entries, transcriptRows, terminal.columns),
|
|
79
|
-
[chat.entries, transcriptRows, terminal.columns],
|
|
80
|
+
() => fitTranscript(chat.entries, transcriptRows, terminal.columns, scrollBack),
|
|
81
|
+
[chat.entries, transcriptRows, terminal.columns, scrollBack],
|
|
80
82
|
)
|
|
81
83
|
// Welcome card is for fresh / short sessions only. Once the conversation is
|
|
82
84
|
// long enough that fitTranscript hides older entries, switch to the compact
|
|
83
85
|
// header + full transcript — otherwise the 12-row welcome card crowds out the
|
|
84
86
|
// recent messages and the chat area reads as blank after "已隐藏较早的…".
|
|
85
|
-
const showWelcome = fitted.
|
|
87
|
+
const showWelcome = fitted.hiddenOlder === 0 && scrollBack === 0 && fitted.estimatedRows + WELCOME_ROWS <= transcriptRows
|
|
88
|
+
|
|
89
|
+
// In-app history pager. Ink redraws only the live frame, so the terminal's
|
|
90
|
+
// own scrollback holds no past turns — older entries are unreachable unless we
|
|
91
|
+
// page through them here. PageUp/PageDown move the viewport back/forward over
|
|
92
|
+
// the transcript. While reading history (scrollBack > 0) we keep the view
|
|
93
|
+
// pinned as new entries stream in; sending a message (onSubmit above) snaps
|
|
94
|
+
// back to the latest so the conversation auto-follows again.
|
|
95
|
+
const prevLenRef = useRef(chat.entries.length)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const prev = prevLenRef.current
|
|
98
|
+
const cur = chat.entries.length
|
|
99
|
+
prevLenRef.current = cur
|
|
100
|
+
if (cur > prev && scrollBack > 0) setScrollBack(s => s + (cur - prev))
|
|
101
|
+
}, [chat.entries.length, scrollBack])
|
|
102
|
+
|
|
103
|
+
useInput((_input, key) => {
|
|
104
|
+
// The composer ignores pageUp/pageDown, so binding them here can't clash
|
|
105
|
+
// with text entry, history navigation, or the slash-command popup.
|
|
106
|
+
const step = Math.max(1, fitted.entries.length)
|
|
107
|
+
if (key.pageUp) setScrollBack(s => Math.min(chat.entries.length, s + step))
|
|
108
|
+
else if (key.pageDown) setScrollBack(s => Math.max(0, s - step))
|
|
109
|
+
})
|
|
86
110
|
|
|
87
111
|
return (
|
|
88
112
|
<Box
|
|
@@ -97,14 +121,17 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
97
121
|
? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} />
|
|
98
122
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} />}
|
|
99
123
|
|
|
100
|
-
<Box flexDirection="column"
|
|
101
|
-
{fitted.
|
|
102
|
-
? <Text dimColor>
|
|
124
|
+
<Box flexGrow={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
125
|
+
{fitted.hiddenOlder > 0 || scrollBack > 0
|
|
126
|
+
? <Text dimColor> ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'}</Text>
|
|
103
127
|
: null}
|
|
104
128
|
{fitted.entries.map((entry, index) => (
|
|
105
129
|
<EntryBlock key={entry.__id ?? `entry-${index}`} entry={entry} />
|
|
106
130
|
))}
|
|
107
131
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
132
|
+
{fitted.hiddenRecent > 0
|
|
133
|
+
? <Text dimColor> ↓ PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text>
|
|
134
|
+
: null}
|
|
108
135
|
</Box>
|
|
109
136
|
|
|
110
137
|
{chat.entries.length === 0 && chat.pendingUser === null && !showHelp
|
|
@@ -506,18 +533,29 @@ function entryRows(entry: AnyEntry, columns: number): number {
|
|
|
506
533
|
}, 0)
|
|
507
534
|
}
|
|
508
535
|
|
|
509
|
-
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number): {
|
|
536
|
+
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
510
537
|
entries: AnyEntry[]
|
|
511
|
-
|
|
538
|
+
hiddenOlder: number
|
|
539
|
+
hiddenRecent: number
|
|
512
540
|
estimatedRows: number
|
|
513
541
|
} {
|
|
542
|
+
// `scrollBack` = how many of the most-recent entries are paged out of view
|
|
543
|
+
// below the viewport (the user pressed PageUp). The visible window is then
|
|
544
|
+
// fit from the tail of what remains, backward, until the row budget is full.
|
|
545
|
+
const tail = Math.max(0, entries.length - scrollBack)
|
|
546
|
+
const avail = tail === 0 ? [] : entries.slice(0, tail)
|
|
514
547
|
let rows = 0
|
|
515
|
-
let first =
|
|
516
|
-
for (let index =
|
|
517
|
-
const nextRows = entryRows(
|
|
518
|
-
if (first <
|
|
548
|
+
let first = avail.length
|
|
549
|
+
for (let index = avail.length - 1; index >= 0; index--) {
|
|
550
|
+
const nextRows = entryRows(avail[index], columns)
|
|
551
|
+
if (first < avail.length && rows + nextRows > rowBudget) break
|
|
519
552
|
rows += nextRows
|
|
520
553
|
first = index
|
|
521
554
|
}
|
|
522
|
-
return {
|
|
555
|
+
return {
|
|
556
|
+
entries: avail.slice(first),
|
|
557
|
+
hiddenOlder: first, // entries older than the viewport
|
|
558
|
+
hiddenRecent: entries.length - tail, // == scrollBack: entries newer than the viewport
|
|
559
|
+
estimatedRows: rows,
|
|
560
|
+
}
|
|
523
561
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen — the full-terminal root every route renders into.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: Ink renders inline in the terminal and, on each render,
|
|
5
|
+
* erases only as many lines as the PREVIOUS frame occupied. If a frame is ever
|
|
6
|
+
* taller than the terminal window it scrolls, Ink can no longer move the cursor
|
|
7
|
+
* back to the real top of that frame, and stale lines from the old screen stay
|
|
8
|
+
* on screen as "residue" (most visible when a tall picker — project / issue /
|
|
9
|
+
* session list — gives way to a shorter one). Pinning the root to exactly the
|
|
10
|
+
* terminal height with `overflow="hidden"` makes every frame the same height,
|
|
11
|
+
* so Ink's erase always realigns and transitions stay clean.
|
|
12
|
+
*
|
|
13
|
+
* Pickers below still budget their own height (Select `reserveRows`) so nothing
|
|
14
|
+
* meaningful gets clipped; this box is the hard guarantee that nothing scrolls.
|
|
15
|
+
*/
|
|
16
|
+
import React, { useCallback, useEffect, useState } from 'react'
|
|
17
|
+
import { Box, useStdout } from 'ink'
|
|
18
|
+
|
|
19
|
+
export function Screen({ children }: { children: React.ReactNode }) {
|
|
20
|
+
const { stdout } = useStdout()
|
|
21
|
+
const read = useCallback(() => Math.max(8, stdout.rows ?? 24), [stdout])
|
|
22
|
+
const [rows, setRows] = useState(read)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const onResize = () => setRows(read())
|
|
26
|
+
stdout.on('resize', onResize)
|
|
27
|
+
return () => { stdout.off('resize', onResize) }
|
|
28
|
+
}, [stdout, read])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Box height={rows} flexDirection="column" overflow="hidden">
|
|
32
|
+
{children}
|
|
33
|
+
</Box>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -180,11 +180,14 @@ export function Select(props: SelectProps) {
|
|
|
180
180
|
|
|
181
181
|
// viewport: keep the active item on screen. Without this a long list renders
|
|
182
182
|
// every row and pushes the lower items (and the rest of the UI) past the
|
|
183
|
-
// terminal bottom
|
|
184
|
-
// "↑/↓ 还有 N 项" hint for
|
|
183
|
+
// terminal bottom, which scrolls Ink's frame and leaves on-screen residue.
|
|
184
|
+
// We render a sliding window around `active` plus a "↑/↓ 还有 N 项" hint for
|
|
185
|
+
// the hidden tails. Reserve generously (13): the window items plus the two
|
|
186
|
+
// scroll hints, the active item's desc line, the AIMUX status line, and the
|
|
187
|
+
// picker's own header/footer/padding must all fit within `rows`.
|
|
185
188
|
const total = items.length
|
|
186
189
|
const rows = stdout?.rows ?? 24
|
|
187
|
-
const maxVisible = props.maxVisible ?? Math.max(3, rows -
|
|
190
|
+
const maxVisible = props.maxVisible ?? Math.max(3, rows - 13)
|
|
188
191
|
let start = 0
|
|
189
192
|
if (total > maxVisible) {
|
|
190
193
|
const half = Math.floor(maxVisible / 2)
|