更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)

* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-04 23:24:27 +08:00
committed by GitHub
parent 02694918b5
commit 5b1a52b8e0
559 changed files with 103807 additions and 101817 deletions

View File

@@ -1,70 +1,83 @@
import { c as _c } from "react/compiler-runtime";
import figures from 'figures';
import React, { createContext, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { fileURLToPath } from 'url';
import { ModalContext } from '../context/modalContext.js';
import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js';
import instances from '../ink/instances.js';
import { Box, Text } from '../ink.js';
import type { Message } from '../types/message.js';
import { openBrowser, openPath } from '../utils/browser.js';
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js';
import { plural } from '../utils/stringUtils.js';
import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js';
import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js';
import type { StickyPrompt } from './VirtualMessageList.js';
import figures from 'figures'
import React, {
createContext,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { fileURLToPath } from 'url'
import { ModalContext } from '../context/modalContext.js'
import {
PromptOverlayProvider,
usePromptOverlay,
usePromptOverlayDialog,
} from '../context/promptOverlayContext.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import ScrollBox, { type ScrollBoxHandle } from '../ink/components/ScrollBox.js'
import instances from '../ink/instances.js'
import { Box, Text } from '../ink.js'
import type { Message } from '../types/message.js'
import { openBrowser, openPath } from '../utils/browser.js'
import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'
import { plural } from '../utils/stringUtils.js'
import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'
import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js'
import type { StickyPrompt } from './VirtualMessageList.js'
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
const MODAL_TRANSCRIPT_PEEK = 2;
const MODAL_TRANSCRIPT_PEEK = 2
/** Context for scroll-derived chrome (sticky header, pill). StickyTracker
* in VirtualMessageList writes via this instead of threading a callback
* up through Messages → REPL → FullscreenLayout. The setter is stable so
* consuming this context never causes re-renders. */
export const ScrollChromeContext = createContext<{
setStickyPrompt: (p: StickyPrompt | null) => void;
}>({
setStickyPrompt: () => {}
});
setStickyPrompt: (p: StickyPrompt | null) => void
}>({ setStickyPrompt: () => {} })
type Props = {
/** Content that scrolls (messages, tool output) */
scrollable: ReactNode;
scrollable: ReactNode
/** Content pinned to the bottom (spinner, prompt, permissions) */
bottom: ReactNode;
bottom: ReactNode
/** Content rendered inside the ScrollBox after messages — user can scroll
* up to see context while it's showing (used by PermissionRequest). */
overlay?: ReactNode;
overlay?: ReactNode
/** Absolute-positioned content anchored at the bottom-right of the
* ScrollBox area, floating over scrollback. Rendered inside the flexGrow
* region (not the bottom slot) so the overflowY:hidden cap doesn't clip
* it. Fullscreen only — used for the companion speech bubble. */
bottomFloat?: ReactNode;
bottomFloat?: ReactNode
/** Slash-command dialog content. Rendered in an absolute-positioned
* bottom-anchored pane (▔ divider, paddingX=2) that paints over the
* ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside
* skip their own frame. Fullscreen only; inline after overlay otherwise. */
modal?: ReactNode;
modal?: ReactNode
/** Ref passed via ModalContext so Tabs (or any scroll-owning descendant)
* can attach it to their own ScrollBox for tall content. */
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
scrollRef?: RefObject<ScrollBoxHandle | null>;
scrollRef?: RefObject<ScrollBoxHandle | null>
/** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill
* shows while viewport bottom hasn't reached this. Ref so REPL doesn't
* re-render on the one-shot snapshot write. */
dividerYRef?: RefObject<number | null>;
dividerYRef?: RefObject<number | null>
/** Force-hide the pill (e.g. viewing a sub-agent task). */
hidePill?: boolean;
hidePill?: boolean
/** Force-hide the sticky prompt header (e.g. viewing a teammate task). */
hideSticky?: boolean;
hideSticky?: boolean
/** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */
newMessageCount?: number;
newMessageCount?: number
/** Called when the user clicks the "N new" pill. */
onPillClick?: () => void;
};
onPillClick?: () => void
}
/**
* Tracks the in-transcript "N new messages" divider position while the
@@ -87,41 +100,43 @@ export function useUnseenDivider(messageCount: number): {
/** Index into messages[] where the divider line renders. Cleared on
* sticky-resume (scroll back to bottom) so the "N new" line doesn't
* linger once everything is visible. */
dividerIndex: number | null;
dividerIndex: number | null
/** scrollHeight snapshot at first scroll-away — the divider's y-position.
* FullscreenLayout subscribes to ScrollBox and compares viewport bottom
* against this for pillVisible. Ref so writes don't re-render REPL. */
dividerYRef: RefObject<number | null>;
onScrollAway: (handle: ScrollBoxHandle) => void;
onRepin: () => void;
dividerYRef: RefObject<number | null>
onScrollAway: (handle: ScrollBoxHandle) => void
onRepin: () => void
/** Scroll the handle so the divider line is at the top of the viewport. */
jumpToNew: (handle: ScrollBoxHandle | null) => void;
jumpToNew: (handle: ScrollBoxHandle | null) => void
/** Shift dividerIndex and dividerYRef when messages are prepended
* (infinite scroll-back). indexDelta = number of messages prepended;
* heightDelta = content height growth in rows. */
shiftDivider: (indexDelta: number, heightDelta: number) => void;
shiftDivider: (indexDelta: number, heightDelta: number) => void
} {
const [dividerIndex, setDividerIndex] = useState<number | null>(null);
const [dividerIndex, setDividerIndex] = useState<number | null>(null)
// Ref holds the current count for onScrollAway to snapshot. Written in
// the render body (not useEffect) so wheel events arriving between a
// message-append render and its effect flush don't capture a stale
// count (off-by-one in the baseline). React Compiler bails out here —
// acceptable for a hook instantiated once in REPL.
const countRef = useRef(messageCount);
countRef.current = messageCount;
const countRef = useRef(messageCount)
countRef.current = messageCount
// scrollHeight snapshot — the divider's y in content coords. Ref-only:
// read synchronously in onScrollAway (setState is batched, can't
// read-then-write in the same callback) AND by FullscreenLayout's
// pillVisible subscription. null = pinned to bottom.
const dividerYRef = useRef<number | null>(null);
const dividerYRef = useRef<number | null>(null)
const onRepin = useCallback(() => {
// Don't clear dividerYRef here — a trackpad momentum wheel event
// racing in the same stdin batch would see null and re-snapshot,
// overriding the setDividerIndex(null) below. The useEffect below
// clears the ref after React commits the null dividerIndex, so the
// ref stays non-null until the state settles.
setDividerIndex(null);
}, []);
setDividerIndex(null)
}, [])
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
// Nothing below the viewport → nothing to jump to. Covers both:
// • empty/short session: scrollUp calls scrollTo(0) which breaks sticky
@@ -132,20 +147,24 @@ export function useUnseenDivider(messageCount: number): {
// at max (Sarah Deaton, #claude-code-feedback 2026-03-15)
// pendingDelta: scrollBy accumulates without updating scrollTop. Without
// it, wheeling up from max would see scrollTop==max and suppress the pill.
const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight());
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return;
const max = Math.max(
0,
handle.getScrollHeight() - handle.getViewportHeight(),
)
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return
// Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY
// scroll action (not just the initial break from sticky) — this guard
// preserves the original baseline so the count doesn't reset on the
// second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render).
if (dividerYRef.current === null) {
dividerYRef.current = handle.getScrollHeight();
dividerYRef.current = handle.getScrollHeight()
// New scroll-away session → move the divider here (replaces old one)
setDividerIndex(countRef.current);
setDividerIndex(countRef.current)
}
}, []);
const jumpToNew = useCallback((handle_0: ScrollBoxHandle | null) => {
if (!handle_0) return;
}, [])
const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => {
if (!handle) return
// scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so
// useVirtualScroll mounts the tail and render-node-to-output pins
// scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp
@@ -153,8 +172,8 @@ export function useUnseenDivider(messageCount: number): {
// back, stopping short. The divider stays rendered (dividerIndex
// unchanged) so users see where new messages started; the clear on
// next submit/explicit scroll-to-bottom handles cleanup.
handle_0.scrollToBottom();
}, []);
handle.scrollToBottom()
}, [])
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
// scroll-to-bottom), it sets dividerIndex=null but leaves the ref
@@ -167,26 +186,31 @@ export function useUnseenDivider(messageCount: number): {
// below the divider index, the divider would point at nothing.
useEffect(() => {
if (dividerIndex === null) {
dividerYRef.current = null;
dividerYRef.current = null
} else if (messageCount < dividerIndex) {
dividerYRef.current = null;
setDividerIndex(null);
dividerYRef.current = null
setDividerIndex(null)
}
}, [messageCount, dividerIndex]);
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
setDividerIndex(idx => idx === null ? null : idx + indexDelta);
if (dividerYRef.current !== null) {
dividerYRef.current += heightDelta;
}
}, []);
}, [messageCount, dividerIndex])
const shiftDivider = useCallback(
(indexDelta: number, heightDelta: number) => {
setDividerIndex(idx => (idx === null ? null : idx + indexDelta))
if (dividerYRef.current !== null) {
dividerYRef.current += heightDelta
}
},
[],
)
return {
dividerIndex,
dividerYRef,
onScrollAway,
onRepin,
jumpToNew,
shiftDivider
};
shiftDivider,
}
}
/**
@@ -197,35 +221,37 @@ export function useUnseenDivider(messageCount: number): {
* carry text — tool-use-only entries are skipped (like progress messages)
* so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill.
*/
export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number {
let count = 0;
let prevWasAssistant = false;
export function countUnseenAssistantTurns(
messages: readonly Message[],
dividerIndex: number,
): number {
let count = 0
let prevWasAssistant = false
for (let i = dividerIndex; i < messages.length; i++) {
const m = messages[i]!;
if (m.type === 'progress') continue;
const m = messages[i]!
if (m.type === 'progress') continue
// Tool-use-only assistant entries aren't "new messages" to the user —
// skip them the same way we skip progress. prevWasAssistant is NOT
// updated, so a text block immediately following still counts as the
// same turn (tool_use + text from one API response = 1).
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue;
const isAssistant = m.type === 'assistant';
if (isAssistant && !prevWasAssistant) count++;
prevWasAssistant = isAssistant;
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue
const isAssistant = m.type === 'assistant'
if (isAssistant && !prevWasAssistant) count++
prevWasAssistant = isAssistant
}
return count;
return count
}
function assistantHasVisibleText(m: Message): boolean {
if (m.type !== 'assistant') return false;
if (!Array.isArray(m.message.content)) return false;
if (m.type !== 'assistant') return false
if (!Array.isArray(m.message.content)) return false
for (const b of m.message.content) {
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true;
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true
}
return false;
return false
}
export type UnseenDivider = {
firstUnseenUuid: Message['uuid'];
count: number;
};
export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number }
/**
* Builds the unseenDivider object REPL passes to Messages + the pill.
@@ -237,23 +263,27 @@ export type UnseenDivider = {
* the pill stays "Jump to bottom" through an entire tool-call sequence
* until Claude's text response lands.
*/
export function computeUnseenDivider(messages: readonly Message[], dividerIndex: number | null): UnseenDivider | undefined {
if (dividerIndex === null) return undefined;
export function computeUnseenDivider(
messages: readonly Message[],
dividerIndex: number | null,
): UnseenDivider | undefined {
if (dividerIndex === null) return undefined
// Skip progress and null-rendering attachments when picking the divider
// anchor — Messages.tsx filters these out of renderableMessages before the
// dividerBeforeIndex search, so their UUID wouldn't be found (CC-724).
// Hook attachments use randomUUID() so nothing shares their 24-char prefix.
let anchorIdx = dividerIndex;
while (anchorIdx < messages.length && (messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))) {
anchorIdx++;
let anchorIdx = dividerIndex
while (
anchorIdx < messages.length &&
(messages[anchorIdx]?.type === 'progress' ||
isNullRenderingAttachment(messages[anchorIdx]!))
) {
anchorIdx++
}
const uuid = messages[anchorIdx]?.uuid;
if (!uuid) return undefined;
const count = countUnseenAssistantTurns(messages, dividerIndex);
return {
firstUnseenUuid: uuid,
count: Math.max(1, count)
};
const uuid = messages[anchorIdx]?.uuid
if (!uuid) return undefined
const count = countUnseenAssistantTurns(messages, dividerIndex)
return { firstUnseenUuid: uuid, count: Math.max(1, count) }
}
/**
@@ -268,195 +298,198 @@ export function computeUnseenDivider(messages: readonly Message[], dividerIndex:
* (alt buffer + mouse tracking + height constraint) lives at REPL's root
* so nothing can accidentally render outside it.
*/
export function FullscreenLayout(t0) {
const $ = _c(47);
const {
scrollable,
bottom,
overlay,
bottomFloat,
modal,
modalScrollRef,
scrollRef,
dividerYRef,
hidePill: t1,
hideSticky: t2,
newMessageCount: t3,
onPillClick
} = t0;
const hidePill = t1 === undefined ? false : t1;
const hideSticky = t2 === undefined ? false : t2;
const newMessageCount = t3 === undefined ? 0 : t3;
const {
rows: terminalRows,
columns
} = useTerminalSize();
const [stickyPrompt, setStickyPrompt] = useState(null);
let t4;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t4 = {
setStickyPrompt
};
$[0] = t4;
} else {
t4 = $[0];
}
const chromeCtx = t4;
let t5;
if ($[1] !== scrollRef) {
t5 = listener => scrollRef?.current?.subscribe(listener) ?? _temp;
$[1] = scrollRef;
$[2] = t5;
} else {
t5 = $[2];
}
const subscribe = t5;
let t6;
if ($[3] !== dividerYRef || $[4] !== scrollRef) {
t6 = () => {
const s = scrollRef?.current;
const dividerY = dividerYRef?.current;
if (!s || dividerY == null) {
return false;
export function FullscreenLayout({
scrollable,
bottom,
overlay,
bottomFloat,
modal,
modalScrollRef,
scrollRef,
dividerYRef,
hidePill = false,
hideSticky = false,
newMessageCount = 0,
onPillClick,
}: Props): React.ReactNode {
const { rows: terminalRows, columns } = useTerminalSize()
// Scroll-derived chrome state lives HERE, not in REPL. StickyTracker
// writes via ScrollChromeContext; pillVisible subscribes directly to
// ScrollBox. Both change rarely (pill flips once per threshold crossing,
// sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on
// those is fine; re-rendering the 6966-line REPL + its 22+ useAppState
// selectors per-scroll-frame was not.
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null)
const chromeCtx = useMemo(() => ({ setStickyPrompt }), [])
// Boolean-quantized scroll subscription. Snapshot is "is viewport bottom
// above the divider y?" — Object.is on a boolean → FullscreenLayout only
// re-renders when the pill should actually flip, not per-frame.
const subscribe = useCallback(
(listener: () => void) =>
scrollRef?.current?.subscribe(listener) ?? (() => {}),
[scrollRef],
)
const pillVisible = useSyncExternalStore(subscribe, () => {
const s = scrollRef?.current
const dividerY = dividerYRef?.current
if (!s || dividerY == null) return false
return (
s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
)
})
// Wire up hyperlink click handling — in fullscreen mode, mouse tracking
// intercepts clicks before the terminal can open OSC 8 links natively.
useLayoutEffect(() => {
if (!isFullscreenEnvEnabled()) return
const ink = instances.get(process.stdout)
if (!ink) return
ink.onHyperlinkClick = url => {
// Most OSC 8 links emitted by Claude Code are file:// URLs from
// FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser
// rejects non-http(s) protocols — route file: to openPath instead.
if (url.startsWith('file:')) {
try {
void openPath(fileURLToPath(url))
} catch {
// Malformed file: URLs (e.g. file://host/path from plain-text
// detection) cause fileURLToPath to throw — ignore silently.
}
} else {
void openBrowser(url)
}
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY;
};
$[3] = dividerYRef;
$[4] = scrollRef;
$[5] = t6;
} else {
t6 = $[5];
}
const pillVisible = useSyncExternalStore(subscribe, t6);
let t7;
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
t7 = [];
$[6] = t7;
} else {
t7 = $[6];
}
useLayoutEffect(_temp3, t7);
}
return () => {
ink.onHyperlinkClick = undefined
}
}, [])
if (isFullscreenEnvEnabled()) {
const sticky = hideSticky ? null : stickyPrompt;
const headerPrompt = sticky != null && sticky !== "clicked" && overlay == null ? sticky : null;
const padCollapsed = sticky != null && overlay == null;
let t8;
if ($[7] !== headerPrompt) {
t8 = headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />;
$[7] = headerPrompt;
$[8] = t8;
} else {
t8 = $[8];
}
const t9 = padCollapsed ? 0 : 1;
let t10;
if ($[9] !== scrollable) {
t10 = <ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>;
$[9] = scrollable;
$[10] = t10;
} else {
t10 = $[10];
}
let t11;
if ($[11] !== overlay || $[12] !== scrollRef || $[13] !== t10 || $[14] !== t9) {
t11 = <ScrollBox ref={scrollRef} flexGrow={1} flexDirection="column" paddingTop={t9} stickyScroll={true}>{t10}{overlay}</ScrollBox>;
$[11] = overlay;
$[12] = scrollRef;
$[13] = t10;
$[14] = t9;
$[15] = t11;
} else {
t11 = $[15];
}
let t12;
if ($[16] !== hidePill || $[17] !== newMessageCount || $[18] !== onPillClick || $[19] !== overlay || $[20] !== pillVisible) {
t12 = !hidePill && pillVisible && overlay == null && <NewMessagesPill count={newMessageCount} onClick={onPillClick} />;
$[16] = hidePill;
$[17] = newMessageCount;
$[18] = onPillClick;
$[19] = overlay;
$[20] = pillVisible;
$[21] = t12;
} else {
t12 = $[21];
}
let t13;
if ($[22] !== bottomFloat) {
t13 = bottomFloat != null && <Box position="absolute" bottom={0} right={0} opaque={true}>{bottomFloat}</Box>;
$[22] = bottomFloat;
$[23] = t13;
} else {
t13 = $[23];
}
let t14;
if ($[24] !== t11 || $[25] !== t12 || $[26] !== t13 || $[27] !== t8) {
t14 = <Box flexGrow={1} flexDirection="column" overflow="hidden">{t8}{t11}{t12}{t13}</Box>;
$[24] = t11;
$[25] = t12;
$[26] = t13;
$[27] = t8;
$[28] = t14;
} else {
t14 = $[28];
}
let t15;
let t16;
if ($[29] === Symbol.for("react.memo_cache_sentinel")) {
t15 = <SuggestionsOverlay />;
t16 = <DialogOverlay />;
$[29] = t15;
$[30] = t16;
} else {
t15 = $[29];
t16 = $[30];
}
let t17;
if ($[31] !== bottom) {
t17 = <Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">{t15}{t16}<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">{bottom}</Box></Box>;
$[31] = bottom;
$[32] = t17;
} else {
t17 = $[32];
}
let t18;
if ($[33] !== columns || $[34] !== modal || $[35] !== modalScrollRef || $[36] !== terminalRows) {
t18 = modal != null && <ModalContext value={{
rows: terminalRows - MODAL_TRANSCRIPT_PEEK - 1,
columns: columns - 4,
scrollRef: modalScrollRef ?? null
}}><Box position="absolute" bottom={0} left={0} right={0} maxHeight={terminalRows - MODAL_TRANSCRIPT_PEEK} flexDirection="column" overflow="hidden" opaque={true}><Box flexShrink={0}><Text color="permission">{"\u2594".repeat(columns)}</Text></Box><Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">{modal}</Box></Box></ModalContext>;
$[33] = columns;
$[34] = modal;
$[35] = modalScrollRef;
$[36] = terminalRows;
$[37] = t18;
} else {
t18 = $[37];
}
let t19;
if ($[38] !== t14 || $[39] !== t17 || $[40] !== t18) {
t19 = <PromptOverlayProvider>{t14}{t17}{t18}</PromptOverlayProvider>;
$[38] = t14;
$[39] = t17;
$[40] = t18;
$[41] = t19;
} else {
t19 = $[41];
}
return t19;
// Overlay renders BELOW messages inside the same ScrollBox — user can
// scroll up to see prior context while a permission dialog is showing.
// The ScrollBox never unmounts across overlay transitions, so scroll
// position is preserved without save/restore. stickyScroll auto-scrolls
// to the appended overlay when it mounts (if user was already at
// bottom); REPL re-pins on the overlay appear/dismiss transition for
// the case where sticky was broken. Tall dialogs (FileEdit diffs) still
// get PgUp/PgDn/wheel — same scrollRef drives the same ScrollBox.
// Three sticky states: null (at bottom), {text,scrollTo} (scrolled up,
// header shows), 'clicked' (just clicked header — hide it so the
// content takes row 0). padCollapsed covers the latter two: once
// scrolled away from bottom, padding drops to 0 and stays there until
// repin. headerVisible is only the middle state. After click:
// scrollBox_y=0 (header gone) + padding=0 → viewportTop=0 → at
// row 0. On next scroll the onChange fires with a fresh {text} and
// header comes back (viewportTop 0→1, a single 1-row shift —
// acceptable since user explicitly scrolled).
const sticky = hideSticky ? null : stickyPrompt
const headerPrompt =
sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null
const padCollapsed = sticky != null && overlay == null
return (
<PromptOverlayProvider>
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{headerPrompt && (
<StickyPromptHeader
text={headerPrompt.text}
onClick={headerPrompt.scrollTo}
/>
)}
<ScrollBox
ref={scrollRef}
flexGrow={1}
flexDirection="column"
paddingTop={padCollapsed ? 0 : 1}
stickyScroll
>
<ScrollChromeContext value={chromeCtx}>
{scrollable}
</ScrollChromeContext>
{overlay}
</ScrollBox>
{!hidePill && pillVisible && overlay == null && (
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
)}
{bottomFloat != null && (
<Box position="absolute" bottom={0} right={0} opaque>
{bottomFloat}
</Box>
)}
</Box>
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
<SuggestionsOverlay />
<DialogOverlay />
<Box
flexDirection="column"
width="100%"
flexGrow={1}
overflowY="hidden"
>
{bottom}
</Box>
</Box>
{modal != null && (
<ModalContext
value={{
rows: terminalRows - MODAL_TRANSCRIPT_PEEK - 1,
columns: columns - 4,
scrollRef: modalScrollRef ?? null,
}}
>
{/* Bottom-anchored, grows upward to fit content. maxHeight keeps a
few rows of transcript peek above the ▔ divider. Short modals
(/model) sit small at the bottom with lots of transcript above;
tall modals (/buddy Card) grow as needed, clipped by overflow.
Previously fixed-height (top+bottom anchored) — any fixed cap
either clipped tall content or left short content floating in
a mostly-empty pane.
flexShrink=0 on the inner Box is load-bearing: with Shrink=1,
yoga squeezes deep children to h=0 when content > maxHeight,
and sibling Texts land on the same row → ghost overlap
("5 serversP servers"). Clipping at the outer Box's maxHeight
keeps children at natural size.
Divider wrapped in flexShrink=0: when the inner box overflows
(tall /config option list), yoga shrinks the divider Text to
h=0 to absorb the deficit — it's the only shrinkable sibling.
The wrapper keeps it at 1 row; overflow past maxHeight is
clipped at the bottom by overflow=hidden instead. */}
<Box
position="absolute"
bottom={0}
left={0}
right={0}
maxHeight={terminalRows - MODAL_TRANSCRIPT_PEEK}
flexDirection="column"
overflow="hidden"
opaque
>
<Box flexShrink={0}>
<Text color="permission">{'▔'.repeat(columns)}</Text>
</Box>
<Box
flexDirection="column"
paddingX={2}
flexShrink={0}
overflow="hidden"
>
{modal}
</Box>
</Box>
</ModalContext>
)}
</PromptOverlayProvider>
)
}
let t8;
if ($[42] !== bottom || $[43] !== modal || $[44] !== overlay || $[45] !== scrollable) {
t8 = <>{scrollable}{bottom}{overlay}{modal}</>;
$[42] = bottom;
$[43] = modal;
$[44] = overlay;
$[45] = scrollable;
$[46] = t8;
} else {
t8 = $[46];
}
return t8;
return (
<>
{scrollable}
{bottom}
{overlay}
{modal}
</>
)
}
// Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats
@@ -466,75 +499,42 @@ export function FullscreenLayout(t0) {
// (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows
// "Jump to bottom" when count is 0 (scrolled away but no new messages yet —
// the dead zone where users previously thought chat stalled).
function _temp3() {
if (!isFullscreenEnvEnabled()) {
return;
}
const ink = instances.get(process.stdout);
if (!ink) {
return;
}
ink.onHyperlinkClick = _temp2;
return () => {
ink.onHyperlinkClick = undefined;
};
}
function _temp2(url) {
if (url.startsWith("file:")) {
try {
openPath(fileURLToPath(url));
} catch {}
} else {
openBrowser(url);
}
}
function _temp() {}
function NewMessagesPill(t0) {
const $ = _c(10);
const {
count,
onClick
} = t0;
const [hover, setHover] = useState(false);
let t1;
let t2;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => setHover(true);
t2 = () => setHover(false);
$[0] = t1;
$[1] = t2;
} else {
t1 = $[0];
t2 = $[1];
}
const t3 = hover ? "userMessageBackgroundHover" : "userMessageBackground";
let t4;
if ($[2] !== count) {
t4 = count > 0 ? `${count} new ${plural(count, "message")}` : "Jump to bottom";
$[2] = count;
$[3] = t4;
} else {
t4 = $[3];
}
let t5;
if ($[4] !== t3 || $[5] !== t4) {
t5 = <Text backgroundColor={t3} dimColor={true}>{" "}{t4}{" "}{figures.arrowDown}{" "}</Text>;
$[4] = t3;
$[5] = t4;
$[6] = t5;
} else {
t5 = $[6];
}
let t6;
if ($[7] !== onClick || $[8] !== t5) {
t6 = <Box position="absolute" bottom={0} left={0} right={0} justifyContent="center"><Box onClick={onClick} onMouseEnter={t1} onMouseLeave={t2}>{t5}</Box></Box>;
$[7] = onClick;
$[8] = t5;
$[9] = t6;
} else {
t6 = $[9];
}
return t6;
function NewMessagesPill({
count,
onClick,
}: {
count: number
onClick?: () => void
}): React.ReactNode {
const [hover, setHover] = useState(false)
return (
<Box
position="absolute"
bottom={0}
left={0}
right={0}
justifyContent="center"
>
<Box
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Text
backgroundColor={
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
}
dimColor
>
{' '}
{count > 0
? `${count} new ${plural(count, 'message')}`
: 'Jump to bottom'}{' '}
{figures.arrowDown}{' '}
</Text>
</Box>
</Box>
)
}
// Context breadcrumb: when scrolled up into history, pin the current
@@ -549,44 +549,32 @@ function NewMessagesPill(t0) {
// even with scrollTop unchanged (the DECSTBM region top shifts with the
// ScrollBox, and the diff engine sees "everything moved"). Fixed height
// keeps the ScrollBox anchored; only the header TEXT changes, not its box.
function StickyPromptHeader(t0) {
const $ = _c(8);
const {
text,
onClick
} = t0;
const [hover, setHover] = useState(false);
const t1 = hover ? "userMessageBackgroundHover" : "userMessageBackground";
let t2;
let t3;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => setHover(true);
t3 = () => setHover(false);
$[0] = t2;
$[1] = t3;
} else {
t2 = $[0];
t3 = $[1];
}
let t4;
if ($[2] !== text) {
t4 = <Text color="subtle" wrap="truncate-end">{figures.pointer} {text}</Text>;
$[2] = text;
$[3] = t4;
} else {
t4 = $[3];
}
let t5;
if ($[4] !== onClick || $[5] !== t1 || $[6] !== t4) {
t5 = <Box flexShrink={0} width="100%" height={1} paddingRight={1} backgroundColor={t1} onClick={onClick} onMouseEnter={t2} onMouseLeave={t3}>{t4}</Box>;
$[4] = onClick;
$[5] = t1;
$[6] = t4;
$[7] = t5;
} else {
t5 = $[7];
}
return t5;
function StickyPromptHeader({
text,
onClick,
}: {
text: string
onClick: () => void
}): React.ReactNode {
const [hover, setHover] = useState(false)
return (
<Box
flexShrink={0}
width="100%"
height={1}
paddingRight={1}
backgroundColor={
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Text color="subtle" wrap="truncate-end">
{figures.pointer} {text}
</Text>
</Box>
)
}
// Slash-command suggestion overlay — see promptOverlayContext.tsx for why
@@ -597,41 +585,39 @@ function StickyPromptHeader(t0) {
// even when the overlay extends above the viewport. We omit minHeight and
// flex-end here: they would create empty padding rows that shift visible
// items down into the prompt area when the list has fewer items than max.
function SuggestionsOverlay() {
const $ = _c(4);
const data = usePromptOverlay();
if (!data || data.suggestions.length === 0) {
return null;
}
let t0;
if ($[0] !== data.maxColumnWidth || $[1] !== data.selectedSuggestion || $[2] !== data.suggestions) {
t0 = <Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque={true}><PromptInputFooterSuggestions suggestions={data.suggestions} selectedSuggestion={data.selectedSuggestion} maxColumnWidth={data.maxColumnWidth} overlay={true} /></Box>;
$[0] = data.maxColumnWidth;
$[1] = data.selectedSuggestion;
$[2] = data.suggestions;
$[3] = t0;
} else {
t0 = $[3];
}
return t0;
function SuggestionsOverlay(): React.ReactNode {
const data = usePromptOverlay()
if (!data || data.suggestions.length === 0) return null
return (
<Box
position="absolute"
bottom="100%"
left={0}
right={0}
paddingX={2}
paddingTop={1}
flexDirection="column"
opaque
>
<PromptInputFooterSuggestions
suggestions={data.suggestions}
selectedSuggestion={data.selectedSuggestion}
maxColumnWidth={data.maxColumnWidth}
overlay
/>
</Box>
)
}
// Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape
// pattern as SuggestionsOverlay. Renders later in tree order so it paints
// over suggestions if both are ever up (they shouldn't be).
function DialogOverlay() {
const $ = _c(2);
const node = usePromptOverlayDialog();
if (!node) {
return null;
}
let t0;
if ($[0] !== node) {
t0 = <Box position="absolute" bottom="100%" left={0} right={0} opaque={true}>{node}</Box>;
$[0] = node;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
function DialogOverlay(): React.ReactNode {
const node = usePromptOverlayDialog()
if (!node) return null
return (
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
{node}
</Box>
)
}