@mobius-os/mobius 0.2.3 → 0.2.5
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 +1 -1
- package/src/components/Screen.tsx +35 -0
- package/src/components/primitives.tsx +6 -3
- package/src/hooks/useChat.ts +32 -4
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
|
@@ -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)
|
package/src/hooks/useChat.ts
CHANGED
|
@@ -118,13 +118,30 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
118
118
|
const appendEntries = useCallback((newOnes: AnyEntry[]) => {
|
|
119
119
|
if (!newOnes.length) return
|
|
120
120
|
setEntries(prev => {
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// De-duplicate by uuid so a live jsonl_entry that also appears in a
|
|
122
|
+
// reconnect's history replay is never shown twice.
|
|
123
|
+
const seen = new Set<string>()
|
|
124
|
+
for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
|
|
125
|
+
const stamped: AnyEntry[] = []
|
|
126
|
+
for (const e of newOnes) {
|
|
127
|
+
const k = entryKey(e)
|
|
128
|
+
if (k && seen.has(k)) continue
|
|
129
|
+
if (k) seen.add(k)
|
|
130
|
+
stamped.push({ ...e, __id: e.__id ?? nextId() })
|
|
131
|
+
}
|
|
132
|
+
return stamped.length ? [...prev, ...stamped] : prev
|
|
123
133
|
})
|
|
124
134
|
}, [])
|
|
125
135
|
|
|
126
136
|
const setHistory = useCallback((list: AnyEntry[]) => {
|
|
127
|
-
|
|
137
|
+
const seen = new Set<string>()
|
|
138
|
+
const out: AnyEntry[] = []
|
|
139
|
+
for (const e of list) {
|
|
140
|
+
const k = entryKey(e)
|
|
141
|
+
if (k) { if (seen.has(k)) continue; seen.add(k) }
|
|
142
|
+
out.push({ ...e, __id: e.__id ?? nextId() })
|
|
143
|
+
}
|
|
144
|
+
setEntries(out)
|
|
128
145
|
}, [])
|
|
129
146
|
|
|
130
147
|
// ── SSE connection ────────────────────────────────────────────────────────
|
|
@@ -136,6 +153,14 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
136
153
|
const conn = new SseConnection(url, {
|
|
137
154
|
onHistoryEntries: (es, _done) => {
|
|
138
155
|
if (es.length) setHistory(es)
|
|
156
|
+
// A reconnect replays the session tail. If our optimistic placeholder is
|
|
157
|
+
// now backed by its real entry, retire it so the user's input isn't shown
|
|
158
|
+
// twice (once as the entry, once as the placeholder). The live jsonl_entry
|
|
159
|
+
// path already clears pendingUser, but a dropped SSE stream (reverse-proxy
|
|
160
|
+
// idle timeout) can deliver the message only via this history replay — and
|
|
161
|
+
// if the whole turn finished while disconnected, no live entry ever comes
|
|
162
|
+
// to clear it, leaving the duplication on screen until the next send.
|
|
163
|
+
setPendingUser(prev => (prev !== null && es.some(e => entryMatchesPendingUser(e, prev)) ? null : prev))
|
|
139
164
|
},
|
|
140
165
|
onEntry: (entry) => {
|
|
141
166
|
if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntry]', entry?.type, (entry?.message?.content?.[0]?.text ?? '').slice(0, 40))
|
|
@@ -317,7 +342,10 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
317
342
|
|
|
318
343
|
const send = useCallback(async (text: string) => {
|
|
319
344
|
const body = text.trim()
|
|
320
|
-
|
|
345
|
+
// Guard on the ref (synchronous truth) as well as the state so a stale
|
|
346
|
+
// closure can't dispatch the same message twice (two distinct reqIds → two
|
|
347
|
+
// user entries on the server).
|
|
348
|
+
if (!body || sending || sendingRef.current) return
|
|
321
349
|
setError(null)
|
|
322
350
|
setPendingUser(body)
|
|
323
351
|
statusEpochRef.current += 1
|