import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { Fragment, memo, useEffect, useMemo, useRef } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $petBox } from '../app/petFlashStore.js'
import { $uiState } from '../app/uiStore.js'
import { usePet } from '../app/usePet.js'
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { prevRenderedMsg } from '../domain/blockLayout.js'
import {
COMPOSER_PROMPT_GAP_WIDTH,
composerPromptWidth,
inputVisualHeight,
stableComposerColumns
} from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js'
import { composerPromptText } from '../lib/prompt.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { Journey } from './journey.js'
import { MessageLine } from './messageLine.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput, type TextInputMouseApi } from './textInput.js'
// Box geometry, kept here so the transcript's reservation math matches the
// rendered overlay exactly.
const PET_BOTTOM = 3 // rows the pet floats above the screen bottom (over the composer)
const PET_PAD_LEFT = 2
const PET_RIGHT = 1
const PET_GUTTER_GAP = 1
const KITTY_PLACEHOLDER = '\u{10eeee}'
// Below this many columns of remaining text width, the right gutter is too
// cramped, so the transcript collapses to reserving bottom rows instead.
const MIN_GUTTER_BODY_COLS = 72
// Petdex mascot — a small floating overlay riding the bottom-right corner just
// above the status bar, with a little top/left breathing room. It reserves no
// layout rows (the transcript scrolls underneath); instead it publishes its
// footprint so the transcript can keep its text clear of it (right gutter on
// wide terminals, reserved bottom rows on narrow ones). Renders nothing unless
// a pet is installed + enabled.
export const PetPane = memo(function PetPane() {
const { enabled, grid, kitty } = usePet()
// Footprint in cells. For kitty we count real placeholder cells (zero-width
// diacritics make string length lie); for half-blocks it's the grid shape.
const { width, height } = useMemo(() => {
if (kitty) {
return {
height: kitty.placeholder.length,
width: Math.max(0, ...kitty.placeholder.map(row => [...row].filter(ch => ch === KITTY_PLACEHOLDER).length))
}
}
if (grid) {
return { height: grid.length, width: Math.max(0, ...grid.map(row => row.length)) }
}
return { height: 0, width: 0 }
}, [grid, kitty])
const active = enabled && width > 0 && height > 0
useEffect(() => {
$petBox.set(
active
? {
// Bottom PET_BOTTOM rows sit over the composer, so the transcript
// only needs to clear the rest in the row-reservation (band) mode.
height: Math.max(0, height - PET_BOTTOM),
width: width + PET_PAD_LEFT + PET_RIGHT + PET_GUTTER_GAP
}
: null
)
return () => $petBox.set(null)
}, [active, height, width])
if (!active) {
return null
}
return (
{kitty ? : null}
{!kitty && grid ? : null}
)
})
const PromptPrefix = memo(function PromptPrefix({
bold = false,
color,
promptText,
width
}: {
bold?: boolean
color: string
promptText: string
width: number
}) {
const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH)
return (
{promptText}
)
})
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
progress,
transcript
}: Pick) {
const ui = useStore($uiState)
const petBox = useStore($petBox)
// Keep transcript text clear of the floating pet, responsively:
// - wide terminals: reserve a right gutter so lines wrap to the pet's left
// (as long as enough width is left for comfortable reading);
// - narrow terminals: keep full width and reserve bottom rows instead, so
// the newest lines sit above the pet rather than getting cramped.
const useGutter = !!petBox && composer.cols - petBox.width >= MIN_GUTTER_BODY_COLS
const bodyCols = useGutter && petBox ? composer.cols - petBox.width : composer.cols
const petBandRows = petBox && !useGutter ? petBox.height : 0
// LiveTodoPanel rides as a child of the latest user-message row so it
// visually belongs to the prompt and follows it during scroll. -1 when
// empty → row.index === -1 is always false → no render.
const lastUserIdx = useMemo(() => {
const items = transcript.historyItems
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].role === 'user') {
return i
}
}
return -1
}, [transcript.historyItems])
// Index of the first user-role message; every later user message gets a
// small dash above it so multi-turn transcripts visually segment by
// turn. -1 when no user message has been sent yet → no separator ever
// renders.
const firstUserIdx = useMemo(
() => transcript.historyItems.findIndex(m => m.role === 'user'),
[transcript.historyItems]
)
return (
<>
{
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
ref={transcript.scrollRef}
stickyScroll
>
{transcript.virtualHistory.topSpacer > 0 ? : null}
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
{row.msg.role === 'user' && firstUserIdx >= 0 && row.index > firstUserIdx && (
───
)}
{row.msg.kind === 'intro' ? (
{row.msg.info && (
)}
) : row.msg.kind === 'panel' && row.msg.panelData ? (
) : (
transcript.virtualRows[i]?.msg, row.index, {
commandOverride: ui.detailsModeCommandOverride,
detailsMode: ui.detailsMode,
sections: ui.sections
})}
sections={ui.sections}
t={ui.theme}
/>
)}
{row.index === lastUserIdx && }
))}
{transcript.virtualHistory.bottomSpacer > 0 ? : null}
{/* Narrow terminals: reserve rows so the newest lines sit above the pet. */}
{petBandRows > 0 ? : null}
>
)
})
const ComposerPane = memo(function ComposerPane({
actions,
composer,
status
}: Pick) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const promptText = composerPromptText(
ui.theme.brand.prompt,
ui.info?.profile_name,
sh,
TERMUX_TUI_MODE,
composer.cols
)
const promptWidth = composerPromptWidth(promptText)
const promptBlank = ' '.repeat(promptWidth)
const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef(null)
const captureInputDrag = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.startAtBeginning()
}
// Drag origin matches the input box's top-left, so localRow / localCol
// map directly into TextInput coords (after backing out the prompt cell).
const dragFromPromptRow = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth)
}
// Spacer rows live on a different vertical origin; only the column is
// parent-aligned with the input. Force row=0 so vertical drags can't
// jump the cursor to the wrong wrapped line.
const dragFromSpacer = (e: GutterMouseEvent) => {
if (e.button !== 0) {
return
}
e.stopImmediatePropagation?.()
inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth)
}
const endInputDrag = () => inputMouseRef.current?.end()
return (
{
if (e.cellIsBlank) {
actions.clearSelection()
}
}}
paddingX={1}
>
{ui.bgTasks.size > 0 && (
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
)}
{status.showStickyPrompt ? (
↳
{status.stickyPrompt}
) : (
)}
{composer.input === '?' && !composer.inputBuf.length && }
{!isBlocked && (
<>
{composer.inputBuf.map((line, i) => (
{i === 0 ? (
) : (
{promptBlank}
)}
{line || ' '}
))}
{sh ? (
) : composer.inputBuf.length ? (
{promptBlank}
) : (
)}
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
>
)}
{!composer.empty && !ui.sid && ⚕ {ui.status}}
)
})
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
const overlay = useStore($overlayState)
return (
patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
t={ui.theme}
/>
)
})
const JourneyPane = memo(function JourneyPane() {
const { gw } = useGateway()
const ui = useStore($uiState)
return patchOverlayState({ journey: false })} t={ui.theme} />
})
const StatusRulePane = memo(function StatusRulePane({
at,
composer,
status
}: Pick & { at: 'bottom' | 'top' }) {
const ui = useStore($uiState)
if (ui.statusBar !== at) {
return null
}
return (
patchOverlayState({ sessions: true })}
sessionStartedAt={status.sessionStartedAt}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}
turnStartedAt={status.turnStartedAt}
usage={ui.usage}
voiceLabel={status.voiceLabel}
/>
)
})
export const AppLayout = memo(function AppLayout({
actions,
composer,
mouseTracking,
progress,
status,
transcript
}: AppLayoutProps) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
// stay anchored via normal flex-column flow.
const Shell = INLINE_MODE ? Fragment : AlternateScreen
const shellProps = INLINE_MODE ? {} : { mouseTracking }
return (
{overlay.agents ? (
) : overlay.journey ? (
) : (
)}
{!overlay.agents && !overlay.journey && (
<>
{SHOW_FPS && (
)}
>
)}
{!overlay.agents && }
)
})
type GutterMouseEvent = {
button: number
localCol?: number
localRow?: number
stopImmediatePropagation?: () => void
}