@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
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
- if (route === 'boot') {
121
- return <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
122
- }
123
- if (route === 'login' || !client) {
124
- return <LoginScreen onSuccess={onLoginSuccess} />
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
- if (route === 'prep' || !ready) {
127
- return <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} /><PrepScreen client={client} onReady={onPrepReady} onQuit={onQuit} /></Box>
128
- }
129
- if (route === 'resume') {
130
- return <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
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
  }
@@ -35,7 +35,7 @@ interface TerminalSize {
35
35
  isTty: boolean
36
36
  }
37
37
 
38
- const VERSION = '0.2.1'
38
+ const VERSION = '0.2.5'
39
39
  const WELCOME_ROWS = 12
40
40
  const CHROME_ROWS = 11
41
41
 
@@ -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. We render a sliding window around `active` plus a
184
- // "↑/↓ 还有 N 项" hint for the hidden tails.
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 - 8)
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)
@@ -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
- const stamped = newOnes.map(e => ({ ...e, __id: e.__id ?? nextId() }))
122
- return [...prev, ...stamped]
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
- setEntries(list.map(e => ({ ...e, __id: e.__id ?? nextId() })))
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
- if (!body || sending) return
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