@mobius-os/mobius 0.2.5 → 0.2.7

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.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -35,7 +35,7 @@ interface TerminalSize {
35
35
  isTty: boolean
36
36
  }
37
37
 
38
- const VERSION = '0.2.5'
38
+ const VERSION = '0.2.7'
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.hiddenCount === 0 && fitted.estimatedRows + WELCOME_ROWS <= transcriptRows
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" marginTop={showWelcome ? 1 : 0}>
101
- {fitted.hiddenCount > 0
102
- ? <Text dimColor> 已隐藏较早的 {fitted.hiddenCount} 条记录;使用 /resume 可重新载入会话</Text>
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
- hiddenCount: number
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 = entries.length
516
- for (let index = entries.length - 1; index >= 0; index--) {
517
- const nextRows = entryRows(entries[index], columns)
518
- if (first < entries.length && rows + nextRows > rowBudget) break
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 { entries: entries.slice(first), hiddenCount: first, estimatedRows: rows }
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
  }
@@ -131,11 +131,13 @@ export function PrepScreen({ client, onReady, onQuit }: {
131
131
  setStep(next)
132
132
  if (!next) finish(iss, p)
133
133
  }
134
- async function createIssue(name: string, useWt: boolean) {
134
+ async function createIssue(name: string) {
135
135
  if (!project) return
136
136
  setStatusMsg('创建任务…')
137
137
  try {
138
- const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: useWt })
138
+ // git worktree is disabled by default and not offered as a choice TUI
139
+ // tasks always run in the bound working tree.
140
+ const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: false })
139
141
  setIssues(await client.listIssues(project.id, 'active'))
140
142
  await pickIssue(iss)
141
143
  } catch (e: any) { setStatusMsg(`创建任务失败: ${e?.message ?? e}`) }
@@ -278,29 +280,21 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
278
280
  function IssuePicker({ issues, onPick, onCreate }: {
279
281
  issues: Issue[]
280
282
  onPick: (i: Issue) => void
281
- onCreate: (name: string, useWt: boolean) => void
283
+ onCreate: (name: string) => void
282
284
  }) {
283
- const [mode, setMode] = useState<'list' | 'create-name' | 'create-wt'>('list')
285
+ const [mode, setMode] = useState<'list' | 'create-name'>('list')
284
286
  const [name, setName] = useState('')
285
287
 
286
288
  if (mode === 'create-name') {
289
+ // git worktree is disabled by default and the option is intentionally not
290
+ // offered — TUI-created tasks always run in the bound working tree.
287
291
  return (
288
292
  <Box flexDirection="column" paddingX={2}>
289
- <Text bold color="cyan">创建新任务 · 第 1 步:名称</Text>
293
+ <Text bold color="cyan">创建新任务</Text>
294
+ <Text color="gray">输入任务名称(不使用 git worktree)</Text>
290
295
  <TextInput value={name} onChange={setName} focused placeholder="命令行任务"
291
- onSubmit={() => setMode('create-wt')} />
292
- <Text color="gray">回车继续 · Esc 返回</Text>
293
- </Box>
294
- )
295
- }
296
- if (mode === 'create-wt') {
297
- return (
298
- <Box flexDirection="column" paddingX={2}>
299
- <Text bold color="cyan">创建新任务 · 第 2 步:是否使用 git worktree?</Text>
300
- <Select
301
- items={[{ label: '否(默认)', value: 'no' }, { label: '是', value: 'yes' }]}
302
- onSelect={v => onCreate(name, v === 'yes')} />
303
- <Text color="gray">回车确认 · Esc 返回</Text>
296
+ onSubmit={() => onCreate(name)} />
297
+ <Text color="gray">回车创建 · Esc 返回</Text>
304
298
  </Box>
305
299
  )
306
300
  }