/** * A small, dependency-light interactive terminal channel for DeepSeek * Harness. It renders the durable session transcript, streams assistant * output, shows tool-call cards, answers approval requests and * `ask_user_question` prompts from the keyboard, and drives one configured * agent with followup/steer. * * The renderer uses plain ANSI and coalesces each frame into one stdout * write of dirty rows only — jump-host / proxied SSH should see one packet * per paint, not one per line. Cadence is DSH_TUI_PAINT_MS, else local 80 ms * or an SSH tier from a CSI 6n round-trip (default 160 ms). * * Pure helpers live next to this file (`term-text`, `paint`, `footer`, * `quota`, `plan`, `tool-present`) and are re-exported here so existing * `lib/tui.js` imports keep working. The launch picker imports `paint` / * `term-text` directly and does not load this module. */ import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'; import type { Context } from '@deepseek-ai/cordis'; import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'; import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'; import { auditQuestionPrimaries, type RepresentationAudit } from './representation.js'; import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent'; import { type TerminalCapabilities } from './terminal-caps.js'; import { type SessionRoute } from './session-route.js'; import { type SubagentSelectionRef } from './subagent-model.js'; import { type AskUserQuestionAnswer, type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'; import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'; import type { CollapsibleBlock, DisconnectPolicyName, Row } from './transcript-types.js'; import { paintedLinkHits } from './term-text.js'; export type { CollapsibleBlock, DisplayKind, DisconnectPolicyName, PlanTodoItem, Row, SubagentLogEntry, ToolDiffHunk, } from './transcript-types.js'; export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js'; export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, findCursorPositionReply, isHangupErrno, linkQualityOf, linkRedrawBudgetMs, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, type LinkQuality, type PaintLinkKind, } from './paint.js'; export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, footerSubagentForeign, paintFooterSubagentChip, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatQuotaUnknown, formatStatusReport, formatStatusThroughput, formatStatusStats, providerHasQuotaSurface, quotaWindowTag, runtimeStrip, shortModelName, shortQuotaPlanName, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, subagentRouteLabel, type ContextPressureSample, type ContextPressureView, type FooterActivityKind, type FooterStatsInput, type FooterStatusInput, type StatusReportInput, } from './footer.js'; export { commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, type AccountBalanceLine, type AccountBalanceSnapshot, type CommandCodeSource, type OpenCodeFlavor, type OpenCodeSource, type QuotaPeriod, type QuotaSnapshot, type QuotaWindow, } from './quota.js'; export { commandAcceptsAttachments, forEachSessionEvent, isTokenDeltaChunk, listPersistenceHeaders, inspectPersistenceSession, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, } from './dsh-compat.js'; export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, buildSubagentHeader, clipSubagentActivity, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, foldSubagentUserLog, todoProgressLabel, todoSummary, type CardCategory, } from './plan.js'; export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js'; /** Presentation configuration for the terminal channel. */ export interface TuiConfig { /** Exact shared agent/session identity driven by this terminal. */ sessionId: string; /** Render model reasoning blocks. */ showReasoning?: boolean; /** Maximum tool-result body lines retained on each card. */ maxToolOutputLines?: number; /** Apply ANSI colors. */ color?: boolean; /** Banner subtitle line shown while no session title exists. */ welcome?: string; /** Override for the launcher-provided goodbye/resume hint. */ goodbye?: string; /** Whether this launch resumes an existing persisted session. */ resume?: boolean; /** One-line notice after entering a resumed session's working directory. */ cwdNotice?: string; /** * The route this resumed session ran on, when it has a record. Applied before * the TUI mounts; this is only for saying so on screen. */ restoredRoute?: SessionRoute; /** * What the launcher decided about the resumed route: a supplier that is gone, * or one that cannot be routed to at all. Pushed on screen at boot. */ launchNotices?: readonly { kind: 'system' | 'error'; text: string; }[]; /** * Report a settled route to the launcher, which owns the per-session record. * Absent in tests and in line mode: nothing here touches the disk. */ onRouteSettled?: (route: Omit) => void; /** Provider route selected at launch (defaults to deepseek-official). */ provider?: string; /** Model selected at launch (defaults to the saved/fallback model). */ model?: string; /** Live model-selection ref installed on the agent; mutated by /model. */ selectionRef?: ModelSelectionRef; /** Settings-backed model/effort selection applied to subagent requests. */ subagentSelection?: SubagentSelectionRef; /** * The terminal capabilities to act on. Defaults to reading the environment; * tests pass their own so a suite does not change meaning with the terminal * it happens to run in (`TERM=linux node --test` used to fail three cases). */ terminalCaps?: TerminalCapabilities; /** Active agent-preset id (standard/code/minimal/cordis/...). */ presetId?: string; /** Display name of the active preset. */ presetName?: string; /** * Trust of the root the active preset came from (`system`/`user`). A `user` * preset keeps its own published name; a shipped one resolves through the * locale dictionary. */ presetTrust?: string; /** Notify the launcher of an explicit in-process selection change. */ onSelectionChanged?: (selection: ModelSelection) => void; /** * Minimum milliseconds between paints while a turn is streaming. * Jump-host / proxied SSH can raise this so token ticks do not flood the * link. Defaults from `DSH_TUI_PAINT_MS` (160). */ paintIntervalMs?: number; /** Called after hangup when the Host is kept (busy) so the launcher can update the lock. */ onHangup?: () => void | Promise; /** Called when a Display relay attaches after hangup. */ onReattach?: () => void | Promise; /** * The display just went away, before the Host has decided whether to stay. * * The lock stops saying `attached` from here: that field is what a resume (and * the launch picker's list) reads to decide whether another window is on the * session, and leaving it set for the whole cancel-and-flush below turned a * reconnect in that window into "already attached to another window". */ onDetach?: () => void | Promise; /** Host process: no local TTY; paint only through the display socket. */ headlessDisplay?: boolean; /** Append events as plain lines instead of painting (see `line-mode`). */ lineMode?: boolean; /** Key overrides, action to key name (see `keymap`). */ keys?: Readonly>; /** Hangup policy while busy: pause cancels the turn; continue lets it finish detached. Idle hangup always exits. */ disconnectPolicy?: DisconnectPolicyName; } export interface TuiController { dispose(): Promise; handleHangup(): Promise; disconnectPolicy(): DisconnectPolicyName; /** * Whether the reader ever typed into this session. * * The launch path uses it on the way out: a fresh session nothing was typed * into is deleted rather than left for other profiles' menus to list. */ sessionHadUserInput(): boolean; } /** * `DSH_TUI_FEEDBACK_MS` — how long that is, in milliseconds. * * `0` is the pre-0.8.2 behaviour and a legitimate preference: keep an acknowledgement * or a warning until the reader submits something. Anything unreadable or negative * falls back to the default rather than to a surprise. */ export declare function resolveFeedbackTtlMs(env?: NodeJS.ProcessEnv): number; export type WorkspaceView = 'detailed' | 'compact'; /** * One row the card cursor can land on. * * Every collapsible card, plus the model replies. A reply has nothing to expand, * which is why it used to be left out of the ring — and that is exactly what * made `/copy` unreachable for it: `/copy` takes the focused row, so as soon as * the reader selected any card the reply stopped being a copy target, and the * newest reply survived only as the no-focus fallback. */ export type FocusTarget = Row | CollapsibleBlock; export declare function parseDisconnectPolicy(raw: string): DisconnectPolicyName | undefined; /** * The `reasoningEfforts` map `/setup` writes for a hand-declared * OpenAI-compatible route. * * Such a route is absent from pi-ai's catalog, so `defaultReasoningEffort` * resolves nothing before the profile is saved and the model would land as a * bare `{ id }` entry. The Harness then reports that model as non-reasoning * (`levels [off]`) and refuses every effort the TUI itself offers for an * undeclared model. Declaring the offered vocabulary up front keeps the model * dispatchable; no parameter is sent until an effort is selected. * @returns the level-to-wire map, with `off` meaning "send no parameter". */ export declare function handDeclaredReasoningEfforts(): Record; /** * The `reasoningEfforts` map `/setup` persists for one model entry. * * A hand-declared OpenAI-compatible completions route always receives the * offered vocabulary; a catalog-backed route keeps whatever live model info * resolved, preserving the existing behavior of redeclaring only the chosen * default. * @param providerType - the wizard template that produced the route. * @param defaultEffort - the level resolved from live model info, if any. * @returns the level-to-wire map, or `undefined` to declare nothing. */ export declare function onboardingReasoningEfforts(providerType: string, defaultEffort: string | undefined): Record | undefined; /** Parse `/effort high` / `/subeffort default`. Empty or unknown → undefined. */ export declare function parseEffortArg(raw: string): { kind: 'default'; } | { kind: 'id'; id: string; } | undefined; export declare function parseWorkspaceView(raw: string): WorkspaceView | undefined; /** Owns one interactive terminal channel and its agent event wiring. */ export declare class SshTui { private readonly ctx; private readonly agent; private readonly rows; private streaming; private autoApprovalMode; private autoAllowedCount; private autoDeniedCount; private aiReviewCount; private cacheHitCount; /** Verdicts this TUI already reviewed, reusable for a bounded time. */ private readonly approvalCache; /** Host knobs folded from the session log: auto mode needs approval=ask to see requests. */ private hostSandboxMode; private hostApprovalPolicy; private approvalMismatchWarned; /** Web-aligned provider presets from the host's pi-ai catalog (undefined until loaded / when unreachable). */ private catalogPresets; private catalogLoad; /** Memoized id→window index over {@link catalogPresets}. */ private catalogWindows; /** Wizard drafts whose models step is already asking the gateway, so a * second Enter cannot start a duplicate listing. */ private readonly finishingModels; /** `\0` → the family row last seen serving that model. */ /** * `\0` → the family row last seen serving that model. * * Bounded: a gateway can publish hundreds of models and their rows are * re-learned on every listing, so the oldest entries are dropped once the map * passes FAMILY_OWNER_CACHE_MAX rather than growing for the life of the * session. Losing an entry only costs one future "which row?" lookup. */ private readonly familyModelOwner; private input; private cursor; private inputFolded; private history; private historyIndex; /** Live input parked while browsing history with ↑. Restored by ↓ past the newest item. */ private historyDraft; private status; private dialog; /** * What the open dialog *means*, which its shape cannot say: `/model` and * `ask_user_question` are both `questions` dialogs, and only one of them means * the agent is waiting for a person. Declared by whoever opens the dialog. */ private dialogRole; private readonly dialogQueue; private onboardingCompletion; private dirty; private disposed; private exiting; private hangingUp; /** How long the last frame took to compose, for the resize pacing below. */ private lastPaintCostMs; private resizeLastEventAt; private resizeLastPaintAt; private resizePaintTimer; /** * Whether a resize frame is currently rendering only the transcript's tail * (see {@link narrowToResizeTail}), so the drag's last geometry can be painted * whole. */ private resizeTailNarrowed; /** * A resize arrived: paint the size the window is *at*, not every size it * passed through. * * Dragging an edge emits an event every few milliseconds, and each one used to * get a synchronous full repaint — on a long transcript that is ~100 ms of * rendering per event, so a drag queued seconds of work and the screen trailed * the pointer. Nothing about an intermediate geometry is worth a frame: the * reader cannot use it and the next event invalidates it. Painting the *first* * event of a burst is no better, because that geometry is stale by the time the * frame is composed and it costs the frame that is actually wanted. * * So the frame has a deadline, recomputed on every event: * * - {@link RESIZE_SETTLE_MS} after the newest event (the size has stopped * moving, so this is the geometry the user is looking at), and * - at the latest one {@link resizeBudgetMs} after the last frame, so a drag * that never pauses still shows the chrome following the pointer. * * Whichever comes first wins, and the frame is composed from the terminal's * size at the moment it runs — so a newer geometry can never be painted after * an older one; the older one is simply never painted. */ private requestResizePaint; /** * Whether the previous *drag* frame is still on its way to the terminal. * * A drag frame is not a token batch: the reader is moving the edge and watching * for the screen to agree with it, so a frame composed now would be superseded * before it landed. What matters is the *queue*, not the pipe's absolute * capacity — measured on a byte-rate-limited consumer, a drag toward a wider * window is the case that hurts, because a frame's size is proportional to the * width: every frame is bigger than the last while the terminal drains at a * fixed rate, the backlog compounds, and the screen keeps moving for tens of * milliseconds after the pointer has stopped (30–44 ms measured, against 0–10 ms * for the shrinking direction). * * So the threshold is one frame, not a fixed 32 KB. That constant is right for * the cadence path, where the question is "is this link slow"; here the question * is "would this frame be a stale geometry queued behind a live one", and the * answer is yes as soon as the last one has not been accepted — however small * both frames are. The named constant stays as the floor, so a long session's * frames can queue a little rather than starving the drag of feedback. */ private resizeWireBehind; /** Bytes the last frame carried, whoever owns the wire (see above). */ private lastFrameBytes; private pendingBytes; /** * Cap what a resize frame re-renders, and remember that the frame is a * compromise so the drag's last geometry can be painted whole. * * Both matters, and they are different matters: the cap is what keeps the * event loop free while the pointer moves (see * {@link RESIZE_TAIL_MIN_ROWS}), and the remembered flag is what stops the * compromise from *becoming* the screen — the moment the drag pauses, the * transcript is painted in full again at the settled width. * * A scroll position is left alone: a reader who has scrolled back is looking * at rows the tail does not contain, and moving the view under them would be a * bigger lie than a slower frame. `paintTailBudget` already refuses to fold * anything while `scrollOffset > 0` for the same reason. */ private narrowToResizeTail; /** * Undo {@link narrowToResizeTail}: the drag has settled, so the frame that is * about to be painted must be the whole transcript at the final width. */ private widenPastResizeTail; /** * The longest a resize frame may wait when the wire cannot take another one yet. * * This is a *ceiling*, not a rate: the normal case paints at once (see * {@link requestResizePaint}), and this only decides how often a link that is * genuinely behind is asked again — the previous frame has not drained, so the * next one would be a stale geometry queued behind a live one. Two costs feed it: * * - **composing** a frame — measured (`lastPaintCostMs`): a short transcript is a * few milliseconds and a long one is a hundred, and repainting faster than that * starves the event loop that has to receive the next resize; * - **delivering** one on a link whose speed is unknown (see * {@link linkRedrawBudgetMs}: only an unprobed link falls back to a slow * budget, because there the interval in force is a number nobody measured). * * A probed link does not need the second term: the backlog check above already * says whether it is keeping up, and pacing it from the round trip as well would * put the artificial delay back that this function exists to avoid. */ private resizeBudgetMs; /** * Whether a resize is holding the frame. * * While a deadline is armed the burst is collapsing, and the cadence timer must * not paint on its own: it fires on the paint interval, which is *shorter* than * the budget a heavy session needs, so leaving both flushers live put the frame * count back near one per event — the cost this exists to remove. Content that * changes mid-drag waits at most one budget. */ private resizeBurstOpen; /** (Re)arm the frame's deadline. Every event pushes it out. */ private armResizePaint; private readonly onDirectResize; private readonly headlessDisplay; private disconnectPolicy; private detachedIdleTimer; /** Armed once an SSH drop left this Host alive with work still running. */ private hostKeptAlive; private idleExitTimer; private displayDetached; private displayHost; private relayColumns; private relayRows; private readonly onHangup; private readonly onReattach; private readonly onDetach; private renderTimer; private readonly color; private readonly maxToolOutputLines; private readonly showReasoning; private workspaceView; private readonly goodbye; private readonly resume; private readonly providerName; private readonly selectionRef; private readonly subagentSelection; private readonly onSelectionChanged; private readonly onRouteSettled; private readonly disposers; private userQuestionDisposer; private presetId; private presetName; /** Last route reported to the launcher; a repaint must not report it again. */ private lastSessionRoute; private readonly useAlternateScreen; /** * What the terminal in front of us can do. Read from the environment at * construction (see `terminal-caps.ts`): the mouse, bracketed paste and the * alternate screen are only claimed where the terminal has them. */ private readonly terminalCaps; /** The OSC 52 caveat is worth saying once, not after every copy. */ private osc52HintShown; private agentGone; private onboarding; private commandSuggestions; private suggestionIndex; private focusedRow; /** * The active palette. Roles resolve through `theme.ts`, so switching themes is * a repaint, not a re-render — no row holds a colour of its own. */ private theme; /** * Rendered display lines per row, keyed by the row object. * * Every frame used to re-render the whole transcript — markdown parsing, card * layout, wrapping and clipping for thousands of rows — to show the twenty on * screen. Measured on a 5000-row session that was ~550 ms per frame, which is * what "rendering feels slower the longer the session runs" was: a keystroke * costs the same as a full repaint because both redo all of it. * * The cache is `WeakMap`-keyed on the row object, so a replaced row starts a new * entry and an unreferenced one is collected. What each entry stores is a * *fingerprint* of every input the row's rendering depends on plus the lines it * produced; a row whose fingerprint is unchanged replays instead of re-rendering, * which is the common case — during a turn only the streaming row changes. */ private displayRowCache; /** Fingerprint of the state that affects *every* row's rendering. */ private displayBaseKey; /** * Fingerprint of one tool burst. * * A burst is a *group of tool rows drawn inside the reply above them*, so the * reply's cached lines contain cards belonging to rows the reply's own * fingerprint never sees. Without this, the reply kept replaying its cached * burst: a finished tool went on saying "processing" and the stale card stayed * on screen beside the live one — the residue a reader reported twice. */ private static burstKey; /** * Whether a row's rendering depends on the clock. * * A running card draws a spinner and an elapsed time, and a streaming row is * being appended to: their *fields* may not change between two frames while * their lines must. Such a row gets a tick in its cache key, so it re-renders * every frame and becomes cacheable again once it settles. A frozen spinner was * the alternative — and, once the same card was also drawn live elsewhere, the * duplicate a reader actually reported. */ private static isLiveRow; /** * Fingerprint of everything one row's rendering reads. * * Written by hand rather than hashing the object: the rendering reads a known * set of fields, and a generic walk would cost more per frame than the render * it is meant to avoid. Scalar fields are compared by value; nested arrays the * rendering walks (todos, sources, intent) get a shallow signature of their own * scalars, because those are updated in place. * * The list is a contract: a field the rendering starts reading must be added * here, or a frame replays stale lines. A test mutates one field in place and * asserts the next frame notices, which is the pattern to copy for a new field. */ private static rowKey; private static rowRendersEqual; /** * The text of the last prompt the user sent, kept so an opted-in retry can * send the same thing again after a provider-side auth failure. Cleared when * the retry fires, which is what bounds it to one attempt per user message. */ /** * Whether this session ever saw the reader's input. * * A fresh launch creates a session before anything is typed; quitting straight * away leaves an artifact the TUI hides but other profiles' menus list. On the * way out, a session that never saw input is deleted (see `session-blank.ts`). */ private sawUserInput; private lastUserText; /** Set on every turn/start; a retry consumes it. */ private authRetryArmed; private pendingMessages; private lastActivity; private lastIdleCtrlCAt; private stalledWarningShown; private lastPaintAt; private commandAbort; private readonly seenCommandDoneIds; private activeSubagents; private subagentSessions; /** Parent `subagent` tool descriptions waiting for the matching child card. */ private pendingSubagentTasks; private openToolCalls; /** Survives result settlement so a card-less result can still be labelled. */ private toolCallNames; /** Session totals; the tracker owns the arithmetic (see `stats.ts`). */ private readonly statsTracker; /** Snapshot of the session totals, for the footer and `/status`. */ private get stats(); /** Attempt whose live `start` frame opened the current token stream. */ private liveStreamOwner; /** Live events parked while the (yielding) history replay holds the floor. */ private replayQueue; /** * True while the durable log is still being read into the transcript. * * Frames are not composed during it (see `paint`): a window anchored to a session * that is still growing is the rolling resume this flag exists to prevent. */ private loadingHistory; /** * A relay attached while the log was still being read, so the screen entry it * asks for has not been written yet (see `attachRelayDisplay` and `paint`). */ private screenEntryPending; /** A relay claimed the display while a hangup was still cancelling/flushing. */ private reattachedDuringHangup; private scrollOffset; /** * How many rows `/clear` hid, as a count from the front of `rows`. * * A *view* boundary, not a deletion (AD-4): the rows stay, the session log is * untouched, and a resume shows everything again because this number is Host-local * and never written anywhere. A count is the stable expression here — every new row * is appended at the end, and an update to an old row mutates it in place, so a row * cannot cross the boundary by being updated. When the transcript trims its oldest * rows the count is decremented with them, which keeps the boundary on the same * semantic row. */ private clearedRows; private readonly clickableRows; /** * The transcript as painted, for free-form selection. Only the lines that came * from a model reply are copyable: the mouse belongs to the TUI, so dragging * cannot reach the terminal's own selection, and this is what replaces it. */ private selectableLines; /** Screen row (1-based) of the first transcript line in the last frame. */ private transcriptTopScreenY; /** * The footer echo: the last thing this Host confirmed to the reader. * * Host-local and deliberately not history (AD-13): "theme switched" is an * acknowledgement, not a fact about the session. It lives in the footer's own row * as the lowest-priority chip, is replaced by the next echo, and leaves the row * again when the reader submits — or after `feedbackTtlMs`, whichever comes first. * A detach keeps it (the Host never died); a resume does not restore it (it was * never in the log). */ private footerEcho; /** * The ephemeral notice: feedback that needs reading now, and is not history. * * A failed command, an operational warning — messages with too much in them for a * footer chip. It takes the telemetry row for its lifetime: no geometry change, no * content hidden, always visible. It does not steal input, and the next notice * replaces it while the next submit (or its own expiry) clears it. No NotificationManager: one field, * one row. */ private notice; /** * The Screen that is up, if any. At most one, and never a dialog. * * A Screen replaces the workspace rather than borrowing rows from it, so it is * deliberately *not* part of `dialog`/`dialogQueue`: a queued question cannot * open behind a report, and a report cannot swallow a question. Nothing else in * this class needs to know which Screen it is — `paintScreen` renders whatever * state is here, and every key gate reads this one field (B2.1). */ private screen; /** * A Screen's own action Surface (the doctor confirmation). * * It exists so the channel stays separated in both directions: while a Screen is * up, a confirmation it needs must not enter the workspace queue, and a * workspace Surface that arrives meanwhile must not be consumed by the Screen * (AD-3). At most one, resolved by the Screen that opened it. */ private screenSurface; /** Resolver for `screenSurface`, so closing it always settles its promise. */ private screenSurfaceResolve; /** * Whether the next Screen frame must establish the whole picture. * * True when a Screen opens and after a reattach: the terminal is empty or holds * an unrelated frame, and one full paint is both cheaper and less surprising than * a partial one against unknown contents. Every later frame is incremental. */ private screenNeedsFullPaint; /** The strip's last painted text, so an unchanged strip is not re-sent. */ private lastScreenStrip; /** * The rows the transient layer covers, 1-based and inclusive of `top`..`top+rows-1`. * * The layer is drawn over the transcript, so anything the frame registers as * clickable or selectable below this line is a target the reader cannot see. * The mouse handlers already refuse to start while a surface owns the keyboard; * this makes the maps themselves honest, so a later change cannot introduce * click-through by forgetting that gate. B1.2 widened what the layer carries — * a question and a control-plane picker both land here — but not what it means: * covered rows are hidden, never re-defined, and the composer's boundary is * always below the last of them. */ private interactionRegion; /** * The rows the live tail covers, 1-based, same convention as the layer above. * * The tail is a *projection* of runtime state (streaming text, the wait card), * not history: it is drawn over the bottom of the window and owns no source row. * Rows under it are hidden, so — exactly like the interaction layer — nothing * behind them may stay a click, drop or link target. */ private liveTailRegion; /** * The interaction row that must stay visible when the layer has to window * itself (the highlighted option): on a short terminal the list is scrolled to * the selection instead of being clipped at the bottom. */ private dialogFocusLine; /** Where a drag started; the run it belongs to decides what can be selected. */ private mouseAnchor; /** * What the drag grabbed, so a transcript that scrolls mid-drag cannot slide * the selection onto a different row: the source row and the line's text. */ private mouseAnchorRef; private mouseAnchorText; /** The drag being painted in reverse video right now. */ private mouseSelection; /** A press that has not moved yet: a release without motion is still a click. */ private pendingMouseClick; private readonly paintedLinkHitsByRow; private copyYank; /** Last OSC-52 payload (tests; empty when nothing has been copied). */ get lastCopiedText(): string; /** Screen-row → OSC 8 hits from the last paint (tests). */ get linkHitsByRow(): Map>; private streamingReasoning; /** * The reader's expansion choice for the live thinking card, for this turn. * * A turn thinks in phases: think, call a tool, think again. Each phase used to * start a fresh collapsed card, so a reader who had just expanded the card being * written watched it fold itself shut at the next phase — "the thinking card * cannot be expanded" as the reader experiences it, even though each individual * card could be. The choice is the reader's, it belongs to the turn, and * `turn/start` is the only thing that resets it. */ private reasoningExpandedChoice; /** * How long an acknowledgement or a warning stays on the chrome. * * B2.3b kept them until the next submit, with no timer, so a reader who came back * after a while still saw what happened. Measured against the actual use — a * "copied 412 characters" chip and a "applies at the next step" notice — that is * the wrong trade: the reader has read them within seconds, and until they type * something the row is occupied by a sentence about the past instead of the * telemetry it replaced. They now expire on their own (and a submit still clears * them at once). */ private feedbackTtlMs; private feedbackTimer; /** * The input Reader (AD-21): decoding, reply filtering, escape assembly, paste * framing and unit classification live there; the frames come back through * `handleInputText` / `consumeFrame`, which own the routing and the semantics. */ private readonly reader; /** * The Router (AD-22, W-2): the one place a unit's owner is decided. * * `ownershipView` is the read-only snapshot it answers from — one object, rewritten * per decision, so a keystroke allocates nothing (W-2 is a hot-path migration). */ private readonly router; private readonly ownershipView; private thinkingStartedAt; private waitStartedAt; /** * Whether the running turn produced anything a person can read: reply text or * a tool card. A turn that ends with thinking only is the upstream returning * an empty stop, and painting 完成 for it reads as a finished answer — see * the hint at `turn/end`. */ private turnSawOutput; /** Whether the turn produced thinking, which the empty-stop hint names. */ private turnSawReasoning; private completionSignaled; private replaying; /** Display-line budget for the first paint after resume; 0 = full transcript. */ private paintTailBudget; private completedAt; private lastTitleUpdateAt; private lastPaintRows; private lastPaintCursorColumn; private lastPaintCursorRow; private lastChromeKey; private lastPaintWidth; private lastPaintHeight; private lastChromeStart; private lastTranscriptStart; /** 1-based screen row of the footer `目录:` chip, when painted. */ private cwdChipRow; /** Set when a card expand/collapse moves chrome; next paint full-redraws. */ private forceFullPaint; /** * How many times the Screen has been dismissed. * * A read is not a key: its units are delivered one at a time, and a Screen owns the * read it was sent — once it is gone the rest is dropped (0.8.3's rule, W-3a keeps * it). Counting dismissals rather than comparing the Screen object keeps that check * out of the owner-read ratchet, which exists to stop new *ownership* reads. */ private screenDismissals; /** Highest row the last budgeted frame could not paint, resumed next tick. */ private paintResume; /** How many times a display has come back after a drop in this Host's life. */ private displayDrops; /** When the display went away, so the reconnect can say how long it was gone. */ private detachedAt; /** * Whether the agent-preset roster service is mounted. Read once at boot and * after a repair — never per frame: answering it from the composition would * mean touching the filesystem on every repaint. */ private rosterMissing; /** * Screen row of the install warning, for click-to-doctor. * * It is a chip on the status row like any other — that row is the last line of * the frame, so its index is the frame's last row — and clicking anywhere on it * opens the report that explains it. */ private healthChipRow; /** How much colour this terminal can take (see `color-depth`). */ private readonly colorDepth; /** Append events as plain lines instead of painting a framed screen. */ private readonly lineMode; /** Rebindable actions (see `keymap`); defaults are only replaced on request. */ private readonly keymap; /** * Lines produced before a display attached. * * The Host boots, pushes its banner, and only then does the launcher's relay * arrive — and a Host's stdout is a discarded pipe, so anything written in * that window is gone. Without this buffer line mode loses the beginning of * the session, which is the one thing a log must not do. */ private lineModePending; /** The counters as they stood when the display went away, for the summary. */ private awaySnapshot; /** Approvals refused because nobody could confirm them (a subset of denials). */ private detachedDeniedCount; /** Questions waiting for a display right now — their cards do not exist yet. */ /** * The questions each `ask_user_question` call recorded, and the cards drawn for * them. * * Keyed by call *and* question id: one call carries a batch, and two calls may * reuse a question id. The card is the presentation only — the Session's * projection is what says whether a question is still answerable and what it was * answered with, so a replayed session draws the same card a lived one did. */ private readonly askedByCall; private readonly questionCards; /** * Every plan-relevant durable event of this session, in order (B2.5). * * The plan artifact is a *projection* of these, so nothing here is plan state: the * fold decides, and the transcript row, the dock and the review Surface all read * the result. `live` marks the events that arrived from the running Host, because * only they can carry an unanswered review (a resumed process never finishes one). */ private planEvents; /** The folded artifacts. Recomputed whenever `planEvents` grows. */ private planArtifacts; /** The state last *said* in the transcript per artifact, so a transition is one row. */ private readonly planAnnounced; /** * The approvals the Session log has recorded, by the Harness's own `id`. * * `approval/asked` states the request (and the tool call it guards), * `approval/decided` states the outcome, and the two are joined here so a * decision that arrives before its row exists — or a row that arrives after both * — still lands on the same card. Nothing is written back: the log is the * Harness's, and this plugin keeps no approval history of its own (B2.4). */ private readonly approvalAsked; /** Approvals resolved before their tool card existed, waiting for it. */ private readonly pendingApprovalByCall; /** Cards that exist only because a live request opened them (no durable call id). */ private liveQuestionSeq; private queuedQuestions; /** When the current detached wait began, so the reconnect can say how long. */ private questionWaitSince; private paintIntervalMs; private paintLink; /** * Whether this process runs inside an SSH session, from the sshd environment. * * Distinct from `paintLink`, which `applyProbedRtt` pins to `'ssh'` for any * relayed frame — a local window included. Only this flag says the capability * table described a remote tty rather than the terminal the bytes reach. */ private sshSession; private paintProbed; /** * Where {@link paintIntervalMs} came from: a measured round trip, an explicit * `DSH_TUI_PAINT_MS`, or a local TTY's constant. Only the *unprobed SSH* case is * an unknown link, and only that case falls back to * {@link RESIZE_UNKNOWN_LINK_BUDGET_MS}. */ private paintCadence; private paintRttMs; /** The last few measured round-trips; the chip and the budget use their median. */ private paintRttHistory; private sessionTitle; private llmRetry; private quotaSnapshot; private balanceSnapshot; private quotaAlerted; private quotaStepsSinceRefresh; private quotaRefreshInFlight; /** * While the first reading is missing, ask again on this cadence instead of * waiting for the next step: a quota API that was down at boot is usually up a * few seconds later, and until then the footer shows `?%`. */ private quotaRetryMs; private quotaRetryTimer; private contextPressure; private contextAlertLevel; private idleCompactInFlight; private lastIdleCompactAt; private searchHits; private searchIndex; private searchQuery; /** The plain text the user searched for, which is what gets highlighted. */ private searchNeedle; private planNudgePending; private pendingReveal; constructor(ctx: Context, agent: Agent, config: TuiConfig); /** Enter raw mode, switch to the alternate screen, and start listening. */ start(): void; /** * Take over the screen: alternate screen where it is safe, then the input * modes the terminal actually supports, then hide the cursor. * * Joining the parts here (rather than at each call site) is what keeps * `start`, a reattach and a teardown from drifting apart — the reattach path * used to duplicate this string verbatim. */ private enterScreenSequence; private bindAgentEvents; private bootBackgroundTasks; private notifyPluginUpdate; private readSkippedUpdate; private persistSkippedUpdate; private mergeUiSettings; private readWorkspaceView; private readDisconnectPolicy; private readAutoApprovalMode; private detachedIdleMs; private clearDetachedIdleTimer; private armDetachedIdleTimer; /** * How long a leftover, finished Host may sit with no display before it exits * and hands the session back. `0` disables the exit (legacy behavior). */ private idleExitMs; private clearIdleExitTimer; /** * Arm the exit for a Host a busy SSH drop left behind. Called when the agent * goes idle — the reason the Host was kept is gone, and every second it stays * is a second its `session.lock` keeps the browser surface from opening the * session (`resume failed for session "…"`). A reattach cancels it. */ private armIdleExitTimer; private isCompactView; /** Test helper: switch the workspace view without going through /view. */ setWorkspaceView(view: WorkspaceView): void; private paintCompactBurst; private paintCompactSummary; private startRenderTimer; private calibratePaintInterval; /** * Replay the durable session log so a resumed session renders its history. * Chunked on purpose: a synchronous walk of a long log froze the TUI on the * pre-replay frame, so the relay's RTT frame could not be applied and the * footer sat on `SSH ○○○○` for the whole load. */ replayHistory(): Promise; /** * Close the chips replay could not settle. * * A spawn `tool/call` with no `tool/result` in the log means the wrapper was * still in flight when the log was last written. Replay only runs in a Host * that has just started, and the child lived in the Host running back then, * so that child is gone and no `subagent/end` will ever arrive for it. * Leaving the chip "running" made every resume of an interrupted turn show a * child counting up forever while `/status` reported none. * * `stopReason: 'unknown'` is also what lets a late live end adopt this row * instead of adding a twin beside it. */ private settleUnfinishedReplayChips; /** Show the first-launch provider/API-key onboarding when nothing is configured. */ private maybeRunOnboarding; /** Run the provider/API-key onboarding wizard. Resolves true when saved. */ private runOnboarding; private cancelOnboarding; /** * Drop the TTY without disposing the agent. Safe to call when the fd is * already dead: DECSET restore is best-effort and never throws. */ detachDisplay(): void; /** Restore the terminal and drop event wiring. Does not flush or exit. */ dispose(): Promise; /** * `/diag`: collect the local facts about this session's channel, lock, and * Host, then print the decision chain. Everything is local; nothing is sent * anywhere (see the privacy note in the README). */ private runDiagCommand; /** * Whether a host service is registered. * * The typed `ctx.get` knows only the services this plugin depends on; * `/doctor` also probes `codeRuntime`, which the PTC preset mounts and this * plugin deliberately does not depend on, so the lookup is untyped here. */ private hasHostService; /** Routing facts for the doctor's self-consistency check. */ private doctorRouting; /** One collected `/doctor` snapshot, from this process's own services. */ private collectDoctorFacts; /** * `/doctor`: the deployment checkup. `/doctor --fix` then repairs whatever * the report marked fixable, after one confirmation. */ private runDoctorCommand; /** `/fix `: repair one named roster row of the profile patch. */ private runFixCommand; /** * Write the planned repairs into the profile patch after one confirmation. * * Only two things are ever changed: rows that are missing get mounted, and an * insert that repeats an earlier one is removed. An override or disable the * user wrote is never touched, and the previous file is kept as a backup. */ private repairProfilePatch; /** * Where an action's outcome is reported: the Screen that asked, or a row. * * A Screen's own action is answered *on the Screen* — the reader is looking at it, * and the row it would otherwise push lands behind their own view. With no Screen * up (the same code path serves `/fix`), the answer is an ordinary row. */ private resultChannel; /** Report a Screen action's outcome, or fall back to a row. */ private reportActionResult; /** Human-facing exit with goodbye and flush; called from key handling. */ requestExit(code: number): Promise; /** * True while the session is doing work the user would lose by killing the * Host: a running turn (thinking / reply / tools), live subagents, in-flight * compaction, or an LLM retry. Idle (including a waiting approval dialog * after the turn has already settled) is not busy — hangup then exits * instead of leaving a leftover process. */ private isBusyForHangupKeepalive; /** * Display socket closed. Replacing a leftover Display with a new HELLO, * or closing a socket after we already detached, is not an SSH hangup. */ handleDisplayDetach(info?: { replaced?: boolean; }): void; /** * SSH / TTY hangup: drop the local display, flush, and either keep the Host * (busy: thinking / reply / tools / subagents) or exit (idle). * When keeping the Host, `pause` cancels the turn; `continue` lets it finish. * Ctrl+C is not a hangup. */ handleHangup(): Promise; private readonly handleHangupSignal; private readonly handleHangupStream; private readonly handleIoError; private writeGoodbye; private flushSession; private exitProcess; /** Capture one painted frame. Used by README screenshot fixtures. */ captureFrame(columns?: number, rows?: number): string[]; /** Last CSI cursor column written by {@link paint} (1-based). Tests only. */ lastPaintedCursorColumn(): number; /** Last CSI cursor row written by {@link paint} (1-based). Tests only. */ lastPaintedCursorRow(): number; private screenColumns; private screenRows; private write; private ensureDisplayHost; /** * One row for what happened while nobody was watching. * * Only the parts that actually happened are listed, so a plain link blip stays * one line. The counts are deltas against the snapshot taken at the drop: the * totals belong to the session, this belongs to the gap. */ private pushAwaySummary; /** Say the user was away, and what happened while they were. */ private pushReconnectNotice; /** Re-open DECSET and start painting to an attached Display relay. */ attachRelayDisplay(): void; currentDisconnectPolicy(): DisconnectPolicyName; /** The paint cadence in force right now: the link tier the footer shows. */ currentPaintIntervalMs(): number; applyProbedRtt(rttMs: number | undefined): void; private markDirty; private toolCardSummary; private mergeIntoToolCard; private findToolRowByCallId; /** * The approval state of one tool call (B2.4). * * Approval has no row of its own: it is a field of the card for the call it * guarded. A decision can arrive before the card does (a `tool/result`-only * fragment, a resumed log read out of order), so an unresolved call id is kept * until its row appears rather than dropped. * @param callId - the tool call the approval belongs to. * @param next - the fact being applied, or undefined to read it back. * @returns the field after the merge. */ private setToolApproval; /** * The weakest approval reading there is, and the only one allowed to be weak. * * A log from before the Harness recorded its audit pair still says *something* * about a refused tool: the tool never ran, and the reason is the Harness's own * sentence for an approval that came back rejected or cancelled. That is a * reading, not a decision, so it is filed as `inferred` — and it can only ever * produce `rejected`/`unknown`, never `approved`: a tool that ran proves nothing, * because a tool nobody had to approve runs too. * * Anything already known from a stronger source wins, which is also what keeps a * live decision from being rewritten by the sentence it produced. * @param row - the card that just settled. * @param message - the tool result's message. * @param error - the result's error, when it has one. */ private inferApprovalFromResult; /** Hand a card the approval that was resolved before the card existed. */ private adoptPendingApproval; private findMergeableToolRow; /** Append one transcript row, bounding memory on long sessions. */ /** * The only way a line enters the transcript. * * It takes a `Representation` — a row *plus* the policy that produced it — and not * a `Row`, so a creation site cannot skip the classification: a bare row is a type * error, and a source id with no policy is one too (`RepresentationSource` is the * policy table's keys). The policy is copied onto the row here, which is what makes * a real transcript auditable afterwards: `auditRepresentations(this.rows)` can * then answer how many lines are history and how many are the Host's own voice. * * A JavaScript caller (a test fixture, an embedder) can still hand over something * that is not a representation. That case is *recorded*, not defaulted: the row is * pushed with no metadata and `unclassifiedRepresentations` counts it, so the audit * fails loudly instead of quietly calling an unknown line durable. */ private pushRow; /** Append one classified row to the transcript (the `transcript` destination). */ private appendRow; /** Append one event, holding it until a display can carry it. */ private writeLineMode; /** Hand the held lines to the display that just attached. */ private flushLineMode; /** The transcript rows that support per-row expand/collapse. */ private collapsibleRows; /** * Everything the card cursor walks, in transcript order: the collapsible * cards plus the model replies. * * Built from the two lists rather than from the rows alone, because one of the * collapsible entries is synthetic: the live thinking block exists only while a * turn streams and is never a member of `this.rows`. Filtering the rows (the * first version of this) silently dropped it out of the cursor walk — ↑ on a * turn that had only streamed thinking did nothing, and Enter could no longer * expand the card being written. It belongs at the end, which is where it is * painted, so anything not in the rows is appended. */ private focusRing; /** * The live thinking block, created on first use. * * Expanded or not is the reader's choice for the turn, not this frame's: see * `reasoningExpandedChoice`. */ private liveThinkingBlock; private spinnerFrame; /** * Codex wait card: shown while the turn is running. The live thinking * stream feeds the shimmer header; a live tool becomes the detail rows. * While the reply itself is streaming, the transcript paints those tokens * and the card yields (Codex hides the status row once output commits). */ private waitCardVisible; private beginWait; private endWait; private waitCardSource; private findSubagentRow; private subagentInspectId; /** Courtesy name for a child session; never the raw session hash. */ private subagentNameFor; private findLivePlanRow; /** * Keep the plan log, and re-fold when it grew. * * Only the events the fold reads are kept (B2.5). They are rare — a plan mode * switch, a todo snapshot, a review — so a whole re-fold per plan event stays * trivial, and it keeps the projection a *pure function of the log* rather than a * running tally that could drift from one. */ private recordPlanEvent; /** * The projection, onto the rows three readers share. * * One artifact, one row: the row is the artifact's *reference* in the transcript * (its lifecycle summary and, when expanded, its steps), the dock paints the live * one, and the review Surface is the dialog. None of the three keeps plan state of * its own (AD-15): they all read what this fold decided. */ private syncPlanArtifacts; /** The row that stands for one artifact, if it has been drawn yet. */ private planRowFor; /** The artifact a docked row belongs to, when the workspace should show one. */ private dockedPlanRow; private createPlanRow; /** * Say one lifecycle transition, once. * * The transcript keeps the artifact's *lifecycle*: entering plan mode, a review's * answer, leaving the mode. It does not keep a second copy of the plan body — that * is the artifact's (the dock while it is live, the review Surface while it is under * review, this row's overlay afterwards) — and a transition is never announced * twice, which is what makes the fold idempotent under a re-`sync`. */ private announcePlanTransition; /** Older / finished plans stay in the scrolling transcript. */ private archiveStalePlans; /** * There used to be a second plan-row writer here (`upsertPlanRow`). * * B2.5 moved every plan row onto the artifact fold — one artifact, one row, and * `syncPlanArtifacts` is the only place a plan row is created — which left this * one with no caller. The B2 final audit removed it rather than leave a second * truth in the file for someone to find and call. */ /** Whether the live plan strip should occupy the workspace footer. */ private shouldDockPlan; /** Send the leftover-todo nudge only from true idle, so /compact is not blocked. */ private flushPlanCloseNudge; /** Compact web-style plan strip pinned above the input, not in the transcript. */ /** * The live region's runtime lines: the thinking card, the streamed text, the * wait card — in that order. * * Built before the window is computed, because the region *reserves* rows now: * `paint` needs its height before it can size the transcript. `waitStart` is * where the wait card begins, which is where the compact view splices the * running burst (that is where the transcript paints it for a settled reply). * `thinkingRows` is how many lines the live thinking card owns, so a click on * them reaches the card. */ private runtimeTailLines; private paintPlanDock; /** * One diff line with its changed characters in reverse video. * * The line is split at the spans and each piece is styled on its own. Reverse * is an attribute rather than a colour, so the emphasis survives every palette, * including a monochrome terminal. */ private styleEmphasisedLine; /** * A body row whose parts carry different roles: the two columns of a * side-by-side diff. * * No single kind fits such a row — the left column is a removal, the right an * addition — so each part is painted with its own style, the gutter keeps the * row's own (neutral) one, and a word emphasis rides inside whichever column * it belongs to. The first column takes the leading indent and the last one * runs to the end of the row, so both bars are flush. */ private styleColumnedLine; private paintToolBodyLine; /** * The representations this transcript is made of (B2.3a's audit artifact). * * Reads the policy off the rows themselves, so it reports what is really in the * transcript rather than what the table says should be — including anything that * arrived unclassified. */ representationAudit(): RepresentationAudit; /** * The ask-user half of the audit: one semantic question, one primary row. * * Read off the real transcript rather than declared, because the invariant is * about what a reader can see: a call with both a question card and a generic * tool card is two primaries for one event, and the count is what catches a * regression (B2.4). */ questionPrimaryAudit(): ReturnType; /** * Drop an acknowledgement or a warning whose time is up. * * Called before a frame is built and by the expiry timer, so every reader — the * footer, the Screen strip, `/diag` and the tests — sees the same thing at the same * moment: the row is the session's again once the sentence has been readable. */ private expireFeedback; /** * Repaint when the newest feedback expires, so the row goes back by itself. * * Unref'd: this timer exists to refresh a UI that is already running, and a Host * with nothing else pending (no window attached, no turn) may exit instead — the * expiry is evaluated against the timestamp on the next frame anyway, so nothing * depends on the callback being reached. */ private armFeedbackExpiry; /** The footer echo the reader can see right now, if any (a test/`/diag` seam). */ currentFooterEcho(): string | undefined; /** The ephemeral notice the reader can see right now, if any. */ currentNotice(): string | undefined; /** The audit as text, for the dev script and for `/diag`-style reporting. */ formatRepresentationAudit(): string; /** The rows the reader can see: everything after the `/clear` cutoff. */ private visibleRows; private workspaceRowsFor; /** * Open one report as a Screen. * * A report is not narrative: it cannot be rebuilt from the session log (it is not * a session event), so leaving it in the transcript meant the reader scrolled * through something that would vanish on the next resume, pushed the real history * out of the window (`/help` moved the anchor 53 rows, measured) and cost a full * clear per invocation. It is ephemeral by design: a Screen, gone when dismissed, * never written to the log (AD-7). * * Only the *successful* path comes here. A command that fails still writes an * `error` row: a failure is part of the session's story and has to outlive the * visit. */ private openReport; /** * Open a Screen: it replaces the workspace for as long as it is up. * * The workspace's own state — the transcript window, `scrollOffset`, the focused * card, `/find` hits, the composer draft, and whatever Surface is queued behind * it — is neither touched here nor rebuilt on the way out. A Screen is a visit, * not a second workspace. */ private openScreen; /** Leave the Screen and hand the pixels back to the workspace, as they were. */ closeScreen(): void; /** * Ask a confirmation in whichever channel owns the screen. * * A Screen's own action (the doctor repair) needs a human decision, and while a * Screen is up that question belongs to it: it is painted on the Screen's hint * row and settled by the Screen's key handler, so it never enters the workspace * `dialogQueue` and a workspace question waiting there is left untouched (AD-3). * Returns `undefined` when no Screen is up, so the caller uses the ordinary path. */ private askScreenConfirm; /** Settle the Screen's own confirmation, if one is up. */ private settleScreenConfirm; /** * Paint the Screen that is up: it replaces the workspace entirely. * * A Screen is state (`ScreenState`) plus this renderer, and nothing else: the * same state redraws the same picture, which is what makes a reattach after an * SSH drop ordinary rather than special. Only the *first* frame of a Screen — * opening, a resize, a reattach — clears the screen; scrolling a report repaints * the body rows that actually changed and leaves the title, hint and strip alone. * The previous implementation passed `sizeChanged: true` on every frame, so every * arrow key cost a full clear and a full frame (measured: 2.7 KB per keypress at * 100×20). */ private paintScreen; /** * The Screen's bottom hint row: how to leave, where the reader is, how to move. * * Composed in that order so truncation takes the least load-bearing part first: * a reader on a 48-column terminal still sees `全文 12–16/80 · Esc 返回`, while a * wide one also gets the navigation keys. A confirmation owned by the Screen, and * a notice from an action it performed, take the row outright — both are answers * to something the reader just did, and both belong to *this* Screen rather than * to the workspace's dialog queue (AD-3). */ private screenHintLine; /** * The setup Screen's rows, built from the wizard's state every frame. * * The order is the contract (B2.6 §8): what this step *is*, then its prose, then the * control the reader acts on, then validation, then the keys. The layout keeps the * last two whatever the height — prose is what gets cut, never the field — and the * secret step renders the key masked, because a Screen is a place other people can * see (B2.6 §4/§8). */ private renderSetupBody; /** * Fit prose and controls into whatever rows are left. * * Under height pressure the *prose* is what goes: the field, the notice and the key * hints are how the reader continues, and a setup screen whose only control was cut * away is a dead end (B2.6 §8). */ private windowSetupBody; /** Rows the setup body may use: the Screen's own layout, minus the step indicator. */ private setupBodyBudget; /** A Screen's body, wrapped for the width it will be shown at. */ private renderScreenBody; /** * Reverse-video (or a `»` in monochrome) on the overlay row that holds the * current `/find` needle, matching what the transcript does for the same hit. */ private markSearchRow; private openToolInspect; private openSubagentInspect; /** * Read one reply full-screen. * * What Enter does on a selected reply: a reply has no body to fold away, and * the transcript wraps it to the window — which for a long answer with tables * or code blocks is not how it reads. The overlay is the same surface the tool * cards use, and the copy key works inside it, so "read it, then copy it" * needs no trip back through the timeline. */ private openReplyInspect; /** * Line mode has no framed scroller, so an inspect body goes into the log. * * Opening the modal there printed nothing and then swallowed the next Enter: * an invisible dialog. The log is the only detail surface line mode has, and * writing it keeps the input free. */ private echoInspectToLog; closeInspect(): void; private paintCollapsibleHeader; /** * Which file line of an expanded changes card the screen cursor is on. * * The card paints its header, then one row per file, and `selectableLines` * holds exactly the viewport. The cursor sits on the input row unless the * reader scrolled, so the file is how many painted rows above the cursor the * card's header is. A cursor on the header, the remainder line, or the prompt * falls back to the first file, which is the only defensible guess. */ private changesFileIndex; /** Move the card cursor among the cards and the replies. */ private moveFocus; /** Toggle the focused block; without focus, toggle the most recent one. */ toggleCollapsible(): void; /** * Whether an inline tool body would be cut short. * * Two things stop a body short: it cannot fit the workspace, or the measured * link is slow enough that the paint budget trims it. Both leave the reader * looking at part of a diff under a header that counts all of it, and both * are what the overlay is for — so both answers are the overlay, and the * card's own `Enter 全览` hint stays true. */ private toolBodyTruncated; toggleCard(target: CollapsibleBlock): void; /** Expand all collapsible blocks, or collapse them again when all are open. */ toggleAllCollapsible(): void; /** * Mark the search hit across the lines that are actually on screen. * * Only the lines that show the match are marked — highlighting the whole card * made a hit look like a selected card, and on a narrow screen the reader * could not tell which word had matched. When the wrap split the needle so no * single line contains it, the row's first line is marked instead: `/find` * reported a hit, so the reader has to see where it is. */ private highlightSearchLines; private revealRow; private focusCard; /** Jump to the newest card in a category (thinking / plan / subagent / reply). */ private jumpToCategory; private applySearchHits; private runFindCommand; private stepSearch; private paint; /** * The dock line for a terminal with no room for the card. * * `compact` keeps the current step (the one thing a reader acts on) and `minimal` * keeps only the mode and the progress. Both stay one row: the dock is under height * pressure precisely because the workspace is small, and a second row would come out * of the transcript. */ private planDockCompactLine; /** * The runtime facts the footer and a Screen's compact strip both report. * * One derivation, two readers. The Screen strip could have read `this.*` for * itself, but then a new activity kind would have to be taught to two assemblers * and the two surfaces could disagree about whether the agent is running — the * second truth source the decision record forbids (AD-8). The two parameters are * the composer's *shape*, the one input that is a property of the frame rather * than of the session; the strip does not read them. */ private footerFacts; private paintFrame; private workspaceCwd; private announceWorkspaceCwd; private buildSuggestions; private suggestionsVisible; private currentProviderId; private currentSelectionLabel; /** * The session's own telemetry, in the shapes the old status line used. * * Kept because the footer is not the only reader: the numbers a user files a * bug report with (turns, steps, model time, tool time, cache hit, and the * exact per-step decode rate) are still part of the session's public surface, * and this is the accessor `/status` and the tests reach for. The default row * itself no longer shows any of it. */ statsText(): string; /** * When the activity the status row is reporting began. * * Each state has its own natural clock, and the row shows whichever one * belongs to the state it is painting: a tool shows how long that tool has * run, a subagent the age of the oldest child, a compaction its card, and a * streaming step the time since its first delta. A state with no honest clock * — plan mode, a goal, a question nobody has answered yet — gets none, so the * chip shows the verb alone rather than borrowing a number that measures * something else. */ private footerActivityStartedAt; /** * What the open tool calls are, as one word for the activity chip. * * One call names itself through the transcript's own tool vocabulary * (`toolTitle`, the same table the cards use, so `bash` reads `terminal` in * both places). Several at once get the count instead, because "which one" has * no single answer and a comma-separated list is not a status chip. */ private activityToolLabel; private agentStatusRunning; /** Refresh the terminal window title (throttled while running). */ private updateTerminalTitle; /** Terminal bell on completion (opt out with DSH_TUI_NO_BELL=1). */ private playCompletionSignal; private render; /** Whether the previous frames have not drained yet (slow link, big burst). */ /** * Whether the last frame is still on its way to the terminal. * * Two wires, one question. A direct TTY is `stdout`; a relayed session writes * into the display socket, and that socket is the one that knows how much has * not left yet. The relay path used to answer "no, never backlogged", which left * the Host with no way to tell a link that is keeping up from one that is not — * so it paced itself from the measured round trip alone, and a drag on a fast * link waited for a cadence nobody needed. */ private outputBacklogged; /** The name the paint path has always used for this question. */ private stdoutBacklogged; /** * `/preset`: list, inspect, copy, rename, describe, and delete agent presets. * * The four operations the web client drives over the remote surface (list, * read, copy, delete) plus display-metadata edits, which upstream has no * method for and this command writes itself. Every write is either an * upstream service call — which validates the id, the trust level, and the * writable root — or a `preset.yml` written beside a backup. Nothing here * throws: refusals and host errors become rows. */ private runPresetCommand; /** The preset service, or undefined on a profile without the roster. */ private presetService; /** * The interactive path: pick a preset, then pick what to do with it. * * Built only on the dialogs the rest of the TUI already uses — a list with a * preselected current entry, and a question with no options for typed input — * and it hands every action to the same handler the line command calls, so a * wizard write can never take a shortcut past a plan, a refusal, or the * delete confirmation. */ private presetWizard; /** One free-form answer; `undefined` when the user cancels with Esc. */ private askPresetInput; /** A wizard step the user backed out of: nothing was written. */ private presetCancelled; /** One row per preset: id, display name, trust, and what it is missing. */ private presetList; /** One preset's metadata and the rows its composition mounts. */ private presetShow; /** Copy an existing preset (upstream's only creation path). */ private presetCopy; /** Edit the display name or description: a `preset.yml` written beside a backup. */ private presetWriteMetadata; /** Delete a user preset after one confirmation. */ private presetDelete; /** * Run one authoring call and report it. * * The service validates more than this command can (the writable root, an * occupied id, the trust recorded on disk), so its message is what the user * sees when it refuses. */ private presetWrite; /** One refusal, in the user's language. */ private pushPresetRefusal; /** * Apply the footer's muted style around a strip that carries its own accents * (the link pips, the ⚠). {@link styleLine} cannot do this: it sanitises its * input, which strips an accent's `ESC` and leaves the `[32m` body on the grid * as four literal characters — the garbled footer B-1 shipped. Every inner * reset re-opens the muted style, so the text between two accents stays muted. */ private muteFooterLine; /** The muted strip separator, so the groups between the accents stay dim. */ private mutedSeparator; private mutedSgr; private styleLine; /** * One emphasised piece of a body line — the words a diff changed. * * Inside a filled diff row the mark is a lighter shade of that same fill. * Inverse video was there first, and it inverted the *fill*: a line replaced * wholesale is emphasised from end to end, so it came out a bright block * beside rows that kept the muted tone — visibly from another palette. On a * terminal with no colour left to shift, the attribute is what survives, so * the inverse video stays for that case. */ private styleEmphasisedPiece; /** * Paint a row the reader has selected. * * The obvious `7`…`27` wrap only reaches the part before the line's first * reset, and every painted segment ends with one: a focused card kept its * marker and status dot highlighted while the title, the state word and the * summary stayed plain, so ↑/↓ gave no visible clue which card was picked — * on exactly the rows that carry a segmented header. Re-opening the attribute * after every reset covers the whole row, whatever colours sit on top of it. */ private selectLine; /** * Fold one live stream chunk into the in-progress assistant row. Hosts from * 0.1.5 on emit the chunk on `agent/assistant-stream` and never write it to * the log. */ private applyStreamChunk; /** * 0.1.5 and 0.1.7 live tokens arrive as process-local * `agent/assistant-stream` frames (start / chunk / end). Chunk frames carry * the same `StreamChunk` the settled `assistant/message` embeds in `stream`. */ readonly handleAssistantStream: (...args: unknown[]) => void; /** * Apply the TUI's subagent model selection to every child-agent request. * The parent request is left untouched (its own `/model` waterfall already * owns the route); direct children created by tool-subagent inherit the * parent provider unless `/submodel` stored an explicit subagent provider. * * Effort is not inherited from the parent. `next()` copies the live parent * config, so a grok-4.6 `xhigh` parent would otherwise send `xhigh` with * the light grok-4.5 child and fail with UNSUPPORTED_REASONING_EFFORT * before the child writes a closing message. An explicit `/subeffort` * still wins; otherwise the child's own adapter default is used. */ readonly handleAgentRequest: ({ agent }: { agent: Agent; }, next: () => Promise) => Promise; readonly handleSessionEvent: (session: { id: SessionId; }, event: SessionEvent) => void; private applySessionEvent; private readonly handleStatus; private readonly handleError; private readonly handleInboxClaimed; private readonly handleInboxDiscarded; /** * Say that a message is queued, and hold the notice until it is really submitted. * * The one notice that must outlive its clock: it is the only sign the reader has that * something is waiting, and it stays true until the step claims it — so it lives as * long as the queue does (`noteQueueDrained` clears it), not as long as a timer says. */ private showQueuedNotice; /** * The last queued message was claimed (or dropped): the acknowledgement goes with it. * * This is what "until it is really submitted" means — the notice is not about the * keystroke that queued the message, it is about the message still waiting, so it * ends where the waiting does. */ private noteQueueDrained; private readonly handleDisposed; /** * One `workspace/changes` event: the files a turn changed. * * The event carries only the turn number; the summary stays on the Host and * is served by `workspaceChanges` for this event's own sequence, and only * while the Session lives. A missing service (every 0.1.5 Host) or a summary * that can no longer be opened (a log replayed after a restart) shows * nothing — upstream's rule, so a card that cannot be read is never drawn. * A later event for the same turn replaces the earlier summary in place. */ private noteWorkspaceChanges; /** Copy one summary onto its card. Shared by the first event and its replacements. */ private fillChangesCard; /** * Full view of one file on a changes card: its comparison, as the service * computed it. Binary and oversized files have no lines, so the overlay * carries the one-line explanation instead of an empty body. */ private openChangesInspect; /** Open (or replace) the changes overlay, or echo it to the log in line mode. */ private openChangesInspectLines; /** Plan-mode / command / team events that plugins merge into SessionEventMap. */ private handleExtensionEvent; private findCompactionRow; /** * Compaction cards that can still be in flight: not finished, and within the * window a real compaction could take. A stale card is treated as not running * so it cannot hold the footer, the spinner, or `/compact` hostage. */ private runningCompactions; /** Whether a compaction is genuinely in flight right now. */ private compactionRunning; /** * Close compaction cards whose Host is gone. * * `compaction/start` is written before the work and `compaction/end` after it, * so a Host that exits in between leaves an open start in the durable log. * Replaying that log faithfully used to recreate a card stuck on "running" * forever: the footer said 压缩中, the spinner kept animating, and every later * `/compact` was refused as "already compacting". After a full replay nothing * more can arrive for those starts, so they are settled here. * * `staleOnly` keeps the same sweep usable while the session is live, where a * compaction that is merely slow must not be closed under it. */ private settleUnfinishedCompactions; private handleCompactionEvent; private readContextPressure; private refreshContextPressure; private canRunCompactCommand; private maybeIdleAutoCompact; private dispatchCompactCommand; private handleCommandRun; private formatCommandText; private handleCommandDone; private handleGoalChange; private pushPromptInjection; private handleSubagentExtensionEvent; /** Fold a live subagent's own session events into that child's card. */ readonly handleSubagentSessionEvent: (sessionId: SessionId, event: SessionEvent) => void; readonly handleSubagentStart: (info: SubagentRunInfo) => void; readonly handleSubagentEnd: (info: SubagentRunEndInfo) => void; /** Keep an open inspect overlay in sync with the live child log. */ private refreshOpenSubagentInspect; private hasLiveDisplay; /** * Tell the absent user that a question is waiting. * * The marker file is the part that needs no configuration: it sits next to the * session's stderr log, so logging back into the jump host shows it before the * TUI is open. The command is whatever the user pointed `ssh-tui.notify` at, * run once; it never blocks the wait and never reports its own failure. */ private announceWaitingQuestion; /** The SMTP account `/notify smtp` saved, when the target is authenticated. */ private readNotifySmtp; /** `DSH_TUI_NOTIFY`, else the saved `ssh-tui.notify`. Empty means off. */ private readNotifyCommand; private waitForLiveDisplay; /** * Auto mode rides the approval waterfall: when the host approval policy is * `never` no request is ever produced, so the classifier silently does * nothing. Say so instead of letting the user believe a guard is active. */ private warnApprovalMismatch; /** * AI review for rule-table `ask` outcomes: one shot at the subagent * model route with a compact, injection-fenced context. Returns * 'allow' | 'deny', or undefined when the reviewer is unavailable or its * output was unusable (caller falls back to prompt/reject). */ private latestUserAuthorizationText; /** * The route the AI approval reviewer runs on: the subagent selection when it * matches the parent provider, else that provider's light model. Shared with * the verdict cache, whose key has to name the reviewer that produced a * verdict — a different model may decide differently. */ private reviewerRoute; /** * The cache key for one `ask`-shaped request, or `undefined` when the shape * must not be remembered (no command, or an opaque interpreter payload). */ private approvalCacheKey; private reviewUnknownWithModel; private recordAutoApproval; /** * Host ApprovalOutcome cannot carry a reason, so the model only sees * `the user rejected tool "bash"`. Steer a plugin notice with the real * classifier/reviewer reason. Summary matches the workspace decision row * so the handler does not paint the body twice. * * This notice is committed to the session, so it is exactly the injection a * V4 log refuses when it wears the released `plugin` wrapper — see * {@link TUI_SOURCE_KIND}. */ private tellModelApprovalDenied; /** * The card an approval request guards, marked as *being asked*. * * Waiting is a live-only state: it is true while a Host is holding the request * open, and a replay can never claim it (see `approval/asked`). Set here rather * than in the auto path so a manual prompt and an automatic decision both show * the same thing on the card while the decision is being made. * @param request - the pending approval. * @param provenance - who is deciding: a person (`live`) or this plugin (`policy`). */ private markApprovalWaiting; /** * Record the decision on the card it guarded. * * `provenance` is the strongest source that produced it: `policy` for a rule, a * cached verdict or a reviewer, `live` for the person at the keyboard. The * Harness's own `approval/decided` event follows this on the same turn and says * strictly less, so `mergeApproval` lets this win (B2.4). */ private settleToolApproval; readonly handleApproval: (request: ApprovalRequest, _next: () => Promise) => Promise; /** * Answer a question whose window closed but whose call the Session still holds. * * `ctx.userQuestions.answer(agent, callId, batch)` is the Harness's own channel * for this: it steers a `user-question-reply` message into the agent, and that * message is what closes the question in the projection. Two preconditions come * from the service, not from us — the agent must be the session's live runtime * root, and the question must still be `continued` — so a refusal here is * reported rather than retried. * @param row - the focused question card. * @returns true when the card consumed the key. */ private answerContinuedQuestion; /** * The text field a surface borrowed, when the surface needs typed input. * * The composer's draft used to *be* every dialog's text field: a free-text * answer, a setup field and a picker's search all wrote `this.input`. That made * "what does this string mean" depend on which mode happened to be active, and * a paste or a cancelled answer could overwrite the message the reader was * writing. A surface that needs text now gets its own field; the composer keeps * its draft until the composer is the thing being typed into. */ private borrowedText; /** Whether this dialog types into a field of its own rather than the composer. */ private dialogNeedsText; /** Give a text-needing dialog its own field. The composer draft stays put. */ private borrowTextFor; private releaseBorrowedText; /** The text the keyboard is editing: a borrowed field, or the composer draft. */ private fieldText; private fieldCursor; /** Write the field the keyboard is editing, wherever it lives. */ private setField; /** Insert at the caret of whichever field owns the keyboard. */ private insertIntoField; /** * Move the caret of whichever field owns the keyboard to one end (Home/End). * * One implementation for the two owners that carry these keys (W-3b.1): the composer * edits the draft, a Surface with a borrowed field edits the answer, and a Surface * without one has nothing to move — `textIsEditable` is what says which of those the * keyboard is on. Ctrl+A/Ctrl+E reach the same two positions through `composerUnit`, * which asks the same question. */ private moveFieldCursor; /** One card's key: a call's batch can repeat a question id across calls. */ private questionCardKey; /** * The Session's durable question view, when the projection is registered. * * `ctx.sessionProjections` is driven by `@deepseek-ai/dsh-user-questions`, which * folds `tool/call` + `tool/result` (and late replies) into * `{ active, settled }`. Absent in a bare Context — a test, an embedder without * the projection package — which is why every caller here has a live fallback. */ private questionProjection; /** * Draw the cards the Session log can account for. * * Called for the events that can change question state, live and replayed alike, * so the two paths produce the same transcript. It never removes a card: a card * is history once it is drawn, and a call the projection cannot see (the legacy * blocking schema, or no projection at all) is the live path's to own. */ private syncQuestionRows; /** One durable record onto its card, creating the card the first time. */ private applyQuestionRecord; /** * The card for one call's question, created from its structured form if new. * * A **plan review** gets none (B2.5 §7): it is the plan artifact's interaction, not * an ordinary ask, and the artifact already has a row — the same review drawn as * both a question summary and a plan state is the duplicate this round removes. The * Surface (the dialog) is unchanged, and `dsh-plan-mode` identifies the review by the * `exit_plan_mode` call it belongs to, which the projection reads. * @returns the card, or undefined when the question belongs to another artifact. */ private ensureQuestionCard; readonly handleUserQuestions: (request: AskUserQuestionRequest) => Promise; /** * Queue one dialog behind an already-open one instead of overwriting it. * * The active surface is never replaced: whoever owns the keyboard keeps it until * it is answered or cancelled, so a picker can never cover a question (and a * question can never silently discard a half-made choice). What priority decides * is the *line*: a task interaction takes the keyboard ahead of a picker that was * already waiting, because someone is blocked on it. Ranks are compared, not * stacked — equal ranks keep arrival order. */ private openDialog; private showNextDialog; /** * Line mode has no framed dialog. Write the prompt (and its keys) into the * log once when it becomes active, so a `tee` or a screen reader can answer * it. Options stay on the framed path; the log only needs the question. */ private echoLineModeDialog; private removeQueuedDialog; private settleQuestion; private openConfirm; private closeConfirm; /** Resolve one queued or active confirm from its abort signal. */ private abortConfirm; private openQuestion; /** * Open one control-plane question and await its answer (cancellation rejects). * * Every `/model`-style menu goes through here, which is why the role is * `picker`: the agent is neither waiting nor interrupted by a menu that only * changes the environment. */ private askQuestion; /** One question dialog with an explicit role, awaited by the caller. */ private openQuestionAs; /** The stored llm-pi-ai profile for one provider route, when settings provide one. */ private piAiProviderProfile; /** * Every settings-backed row one gateway family occupies, base first. * * `llm-pi-ai` puts the wire protocol on the provider entry, so a gateway * whose catalogue spans protocols (Command Code, OpenCode Go) is stored as * sibling rows. To the user they are one supplier: the TUI lists, picks and * remembers them under the family's base id, and files each picked model on * the row that actually serves it. * * The `-responses` / `-completions` / `-messages` suffix is only a naming * convention, so a row is adopted into the family only when it corroborates: * it must speak the protocol its suffix names, point at the same endpoint, * and (when both name one) carry the same display name. A provider someone * legitimately called `foo-messages` on another host therefore stays its own * supplier instead of vanishing from `/provider` behind `foo`. A family of * one — the ordinary case, and a sibling whose base row is missing — stays * exactly as configured. */ private providerFamilyRows; /** * Whether `sibling` is genuinely the same gateway as `base`, on another * protocol: its `api` matches its suffix, its endpoint matches the base row's, * and the two labels agree when both are set. */ private isFamilyRow; /** The id the TUI shows for a route: its family's base when it is spread. */ private displayProviderId; /** The models one row configures, as ids with the labels settings carry. */ private configuredModelsOf; /** * The protocols the gateway publishes for one model, best-effort. * * Used when a model is being filed but no picker listed it yet (`/model ` * on a new id, a `--model` override, a `/submodel` pin): the gateway's own * answer is the only thing that can put it on a route that can send it. * Any failure is "no opinion", never a blocked selection. */ private advertisedFor; /** * The row of a gateway family that serves `model`: the one learned from the * last listing, else the one that already configures it, else the base — * where a brand-new model lands and is re-filed from. */ /** * Record which row a family's model belongs to, keeping the map bounded. * * Re-inserting keeps a key fresh, and the oldest half is dropped once the cap * is crossed, so a gateway whose catalogue is re-listed on every `/model` * does not accumulate a second copy of itself each time. */ private rememberFamilyOwner; private familyOwnerRow; /** Default listing endpoint for a built-in OpenCode route with no stored base URL. */ private openCodeListingBaseURL; /** * Fetch the live model list for an OpenCode or third-party provider from its * OpenAI-compatible listing endpoint. The provider route is deliberately not * passed to discovery: pi-ai would answer a catalog route from its installed * registry, while the TUI wants the endpoint's current list. * * A row speaks exactly one protocol (`api` on the provider entry), so with a * gateway that publishes `supported_endpoints` the list is narrowed to the * models that row can actually serve — otherwise `/model` on * `command-code-messages` would offer, and then persist, a model that only * answers `/responses`. */ private discoverEndpointModels; /** * One gateway's own model listing: every model the endpoint returns plus the * route table it publishes. The caller narrows it to a protocol; a gateway * spread over rows asks once and files the answer per row, so opening a * picker costs one listing instead of one per sibling. */ private discoverGatewayListing; /** * The row of the same gateway that the gateway itself says serves `model`, * when that is not the row the caller is on. `undefined` when the gateway has * no opinion, when the current row already serves it, or when the model would * need a row that does not exist yet. */ private rowServingModel; /** Add one endpoint-listed model to the stored provider profile when needed. */ private ensureProviderModelConfigured; /** * One pick across a possibly long model list. The question dialog keeps a * 12-row sliding window so dozens of models stay selectable with ↑/↓. */ private pickModelOption; /** Live adapter routes the TUI can switch to, plus the current selection. */ private listSelectableProviders; /** Built-in SuperGrok catalog used when the live adapter list is still warming up. */ private static readonly XAI_FALLBACK_MODELS; private loadModelOptions; /** * The model list of a gateway spread over protocol rows. * * Every row is asked what it serves and the answers are merged under the * family's base id, so the user picks from one list. Each model remembers the * row that offered it, which is what routes the pick back to the protocol * that can actually send it — the split stays invisible end to end. */ private loadFamilyModelOptions; /** /model: models and effort for the current provider only. */ private runModelCommand; /** /provider: pick a provider, then its model (remembered route pre-filled). */ private runProviderCommand; /** Persist a provider/model/effort choice and keep the subagent on the same family. */ private rememberedRoute; /** The route this session is running right now, record-shaped. */ private sessionRouteRecord; /** * Tell the launcher which route this session has settled on, so resuming it * comes back on the same supplier — the subagent route included, which is what * makes a resumed cross-provider conversation reproducible. * * Reported when a route settles (`/model`, `/submodel`, `/setup`) and at the * start of every turn, which catches a preset or a settings edit moving the * route behind the TUI's back. The launcher owns the file; the TUI only says * what changed, and says nothing when nothing did. */ private noteSessionRoute; private rememberRoute; private applyModelSelection; /** * Persist the default provider/model selection. `agentDefaultModel` may be * unavailable or its settings namespace may not be registered in this * process, so a failed `saveSelection` falls back to writing the * `agent-default-model` settings section directly and surfaces a warning * when neither path sticks. */ private persistDefaultSelection; /** Provider route the next subagent request should use. */ private effectiveSubagentProvider; /** * When the parent provider changes (OAuth or API key), keep the subagent * on a same-family model. An explicit leftover DeepSeek flash id after * switching to xAI is treated as stale. * * A `/submodel` pin is the user's explicit route, so a parent switch asks * before moving the children: dropping the pin silently spends on a route * they pinned away from, and keeping it silently leaves the children on the * old provider. */ private syncSubagentToProvider; /** * Ask whether a `/submodel` pin survives a parent provider change. * * Returns true when the pin should be kept. Cancelling the dialog keeps it * too: the pin is the user's explicit choice, so only an explicit answer * moves the children onto the new parent route. */ private confirmKeepSubagentPin; private clearQuotaForProvider; /** Persist one subagent selection and publish it to the live request waterfall. */ private saveSubagentSelection; /** Resolve the picker model list for one provider (endpoint first, then catalog). */ private subagentModelOptions; /** /submodel: pick (or set) the model subagent children use. */ private runSubmodelCommand; /** Persist one subagent provider/model pair and say whether it follows or is pinned. */ private commitSubagentRoute; /** /effort: pick or set the reasoning effort for the current model. */ private runEffortCommand; /** * Persist one explicitly chosen level as a model's `reasoningEfforts`. * * `/effort` offers `low`…`max` for a model whose route declares nothing, but * the selection alone cannot make the request legal: the Harness still * resolves the model as non-reasoning and refuses it. Writing the chosen * level — merged with whatever the entry already declares — keeps the * selection and the declaration consistent. Only a route that explicitly * speaks OpenAI Completions is amended: its dialect omits the parameter for * `off` and was verified against a gateway, while another protocol may * materialize an unset level as a value its endpoint refuses. * @returns whether the entry now declares the level. */ private declareReasoningEffort; private setReasoningEffort; /** /subeffort: pick the reasoning effort subagent children use. */ private runSubeffortCommand; /** /language or /lang: persist zh/en and repaint chrome immediately. */ private runLanguageCommand; /** /view: detailed (see the work) vs compact (Codex-like summary). */ private runViewCommand; /** * /notify: where a question waiting on an absent user is announced. * * No argument shows what is configured. `off` clears it. `mail`, `smtp` and * `local` store the command that target needs; the SMTP password is kept * beside it and handed to the command through the environment, so it is never * part of the command string. */ private runNotifyCommand; /** /disconnect: pause (default) or continue the turn after SSH drop. */ /** * Say where an auth failure came from, and retry it if the user asked for that. * * `code: "AUTH"` collapses two opposite situations (see `auth-failure.ts`), and * the host does not retry either of them: a provider-side 401/403 that lasted * 45 seconds killed two turns in a row on 2026-09-29 while the same key, model * and route answered 200 straight afterwards. The hint costs nothing and the * retry is opt-in, because resending a long session re-sends its whole context. */ private reportAuthFailure; private explainAuthFailure; /** Whether the opt-in retry is on (`/retryauth`, the settings form, or the env). */ private retryProviderAuthEnabled; /** Whether anything the reader typed reached this session (used on exit). */ sessionHadUserInput(): boolean; /** * `/cleanup [--dry-run]` — delete sessions that never saw user input. * * Every fresh start creates a session, so quitting without typing leaves an * artifact behind. The picker hides those, but the web session list reads the * same files without that filter, which is how an unused session still shows * up in another profile's menu. This walks the whole history once and deletes * the blank ones (the listing prunes as it resolves, which is the same rule the * picker applies). */ private runCleanupCommand; /** The theme saved in the `ssh-tui` settings section, if any. */ private readThemeName; /** * `/theme [name]` — list the palettes, or switch to one and remember it. * * Switching repaints from the row cache, so it costs one frame rather than a * re-render of the transcript; `mono` exists for terminals where colour is the * problem rather than the answer, and it keeps every difference as an * attribute instead of throwing the difference away. */ private runThemeCommand; /** `/retryauth [on|off]` — the setting that lets one provider 401/403 retry itself. */ private runRetryAuthCommand; private runDisconnectCommand; /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */ private runModeCommand; /** * The two tools a 0.1.7 terminal profile mounts itself, read from the running * composition. The profile patch is only the instruction; this is the answer * to whether the instruction actually produced a tool. */ private agentPlaneTools; /** Whether this process's agent plane has both tools. */ private agentPlaneReady; /** * Repair an already-configured profile at boot and say a restart is needed. * * A first run deliberately does not call this: its wizard save is the point * where settings and credentials exist, and `onboardDone` repairs immediately * after that write. This method serves the old/partial profile upgrade path, * where there is no wizard to wait for. */ private repairAgentPlaneAtBoot; /** * Add only the profile rows whose tools are missing from this composition. * * A row that is already present in the patch is never written again, even if * its service failed to activate: the planner sees overrides and disables by * id or module name, and `ensureRosterRows` creates a backup first. This is * the first-run/upgrade repair, not an install-hook claim: it changes what * the next boot composes, not what this process has already composed. */ private ensureProfileAgentPlane; /** Re-read the roster fact this process was composed with (cheap, no IO). */ private refreshRosterHealth; /** Which settings protocol this host speaks; decides what "missing" means. */ private get settingsGeneration(); /** * The two lines `/mode` and `/preset` print when the preset service is absent. * * The reason differs by host line: 0.1.5 lost a roster the profile can mount, * while 0.1.7 composes the agent process-wide and has no terminal presets at * all — the repair there is the agent-plane rows, not a roster. */ private missingPresetService; /** * `/mode fix`: write the missing profile rows into the user patch layer. * * The write only takes effect on the next launch — the loader composes the * patch tree once at boot — so the report says so instead of pretending the * running session gained a roster. The helper consults the actual tools, so * a row a bundle already supplies is not mounted a second time. */ private repairRoster; /** Current provider route selected for the running agent. */ private currentProvider; /** Resolve one credential reference without exposing its value. */ private resolveCredential; /** Query the OpenCode Go quota endpoint. */ private fetchOpenCodeGoUsage; /** * Command Code's quota: the rolling 5h/weekly windows and credit pool off * `/alpha/billing/credits`, enriched by the subscription period and spend. * Only the credits read is required; the two enrichment reads are * best-effort so one failing endpoint cannot hide the windows. * @param source - the recognized Command Code route. */ private fetchCommandCodeQuota; /** Best-effort JSON read for quota enrichment; a failure leaves the field out. */ private tryFetchJson; /** Explain Zen metered billing instead of pretending it has a quota. */ private zenUsageText; /** /usage and /balance: remaining quota or prepaid balance for the current provider. */ private runUsageCommand; private applyQuotaSnapshot; /** Stop the first-reading retry; the normal cadence owns the refresh now. */ private clearQuotaRetry; /** Ask again soon, but only while the footer still has nothing to show. */ private armQuotaRetry; /** * Whether this provider has a quota surface at all. `false` means the footer * shows no quota widget — a balance line covers DeepSeek, and a provider with * neither gets nothing. */ private hasQuotaSurface; private refreshQuota; private fetchAccountBalance; private fetchQuotaSnapshot; private resolveSuperGrokToken; private fetchJson; private readonly handleData; /** * One key, handled by the Screen that is up. * * The Screen owns the keyboard completely, so this is also the place that keeps * the workspace's state out of reach: no arrow moves a card behind the Screen, no * page key scrolls the transcript, no print reaches the composer draft. Closing * keys come from `inspectClosesOn`, which is what the overlay always accepted — * plus `q`, which a full-screen report is expected to answer to. */ private handleScreenInput; /** * Whether anything human is waiting behind the Screen that is up. * * A Surface cannot open while a Screen owns the screen (it stays in the workspace * queue), so without this the reader would have no way to know that an approval * or a question is waiting for them behind the report — the one thing a Screen * must never hide (AD-8). A question the Session still holds counts too: the agent * is not blocked, but the reader has something to answer. */ private screenWaitingWork; /** Rows one PageUp moves a Screen: the body, less one row of context. */ private screenPage; /** Scroll a Screen by `up` rows toward the start of its body. */ private scrollScreen; private scrollScreenTo; /** The copy key inside a Screen: what the Screen shows is what the reader means. */ private copyFromScreen; /** * One read, as the Reader framed it: paste traffic is dispatched per frame, * everything else is routed (W-2 lifts the routing itself into a Router). */ private handleInputText; /** One framed unit, at the owner that takes it. */ private consumeFrame; /** * One read from the Reader, as the ladder has always taken it. * * Two kinds of table live here and they are not the same thing: * * - **binding** — which byte means which action (the keymap's rebindable actions, * the Alt chords, the SS3 tail). Those stay; AD-20 F-5 wants one table per key. * - **ownership** — who takes the unit. That question is the Router's now, and it * is asked exactly once per unit, before any binding table is consulted: a * Screen owns the read outright, so the tables below are for the unlayered case, * which is the order the ladder always had. * * The parsing branches that are neither (a read that could still grow — the Reader * holds it; an escape sequence nobody claimed — dropped) stay here as well. */ private route; /** * The Router's read-only view, rewritten per decision (no allocation). * * The card question is the only expensive one — `collapsibleRows()` walks the * visible transcript — so it is answered only for the arrow that asks it, and only * when nothing is layered over the workspace. */ private observe; /** * Whether a read is one of the sequence shapes the ladder claims as a whole. * * These are matched *before* the Reader's hold window — the order the ladder always * had (`ESC [ 5 ~` is a page key, not a prefix that might still grow). */ private isSequenceShape; /** The unit class of one read, by shape (the Reader's classification, allocation-free). */ private readKind; /** The unit class of one character — what `handleChar` routes. */ private charKind; /** One unit, one owner: the Router decides, the owner adapter runs it. */ private dispatch; /** Run a unit at the owner the Router already named. */ private dispatchWith; /** * A paste payload: the Router decides, the paste path executes. * * A paste is text, so the field that owns the keyboard takes it — and a Screen that * owns the interaction terminates it (its own field or nothing), never a field * behind it (AD-20 §4.3 priority 1). `handlePasteText` still re-asks that per owner; * that is the compat path W-3 folds into the Router (marked there). */ private dispatchPaste; /** * The Screen adapter: a Screen owns the keyboard outright (AD-20 §4.3 priority 3). * * One unit goes in — the Reader split the read, so a Screen sees the same keys the * composer would (W-3a) and `handleScreenInput` is where the paste-burst rule stays. * Its own `screen === undefined` guard stays too: a release raced by a teardown * reaches it, and refusing there is the adapter's own business rather than a second * ownership decision. */ private ownerScreen; /** * The Surface adapter: a dialog owns the keyboard while it is up. * * Its arrows move a question's cursor (or nothing — an arrow a Surface does not use * is not the transcript's), the page keys are not its business, and its mouse reports * take the same path they always did so the refusal happens where it always did, down * to the remembered press. Its own editing keys are `Home`/`End`/`Delete` (W-3b.1): * they act on the field the Surface borrowed — the one row the reader can see — and a * Surface that borrowed none has no field for them, so it consumes them. The kitty * copy key falls out with the other sequences below: a Surface has no copy contract, * and this round did not invent one. Everything else goes to the shared * composer/Surface body, whose `dialog` branches are the Surface's own behaviour. */ private ownerSurface; /** * The Composer adapter. * * Text — one character or many — is typed: the burst rule first, then one character * at a time through the Router, which is what lets ownership change inside a single * read (a Surface answering `y` with the composer taking the `es` behind it). The * sequence shapes the composer owns are the arrows (the suggestion list, else * history) and the caret keys — which it now only ever gets while it owns the * keyboard: with a Surface up, the Router hands those keys to the Surface (W-3b.1). */ private ownerComposer; /** * The Workspace adapter: the transcript owns the mouse, the page keys and the focus * ring — and the ring only when the Router said so (empty field, a card to land on). */ private ownerWorkspace; /** An arrow the composer keeps: the suggestion list while it is up, else history. */ private arrowHistory; /** Press, motion and release: selection, links and card toggles — the transcript's. */ private mouseGesture; /** * The composer/Surface body — the switch and the tail the ladder always had. * * It is shared by two adapters on purpose: its `dialog` guards *are* the Surface's * behaviour for those keys (a borrowed field takes them, a field-less dialog refuses * them), and splitting them is W-3's job. What the Router guarantees is that a Screen * never reaches it, so every key below can only spend itself on a field the reader * has in front of them — the composer's or a borrowed one. The unreachable second * Screen gate the old body carried is gone with it (G-6). */ private composerUnit; /** * Ctrl+L's repaint, in one place for the owners that share it (G-6). * * The Screen has its own implementation (`screenNeedsFullPaint`, because it repaints * a Screen's frame); this one drops the cached rows so the next full frame is rebuilt * from scratch. W-2 does not merge them — only a test that proves both owners repaint * identically may (AD-20 §6) — but it does give the composer's copy a name. */ private repaintNow; /** * Insert pasted text into the field that owns the keyboard; CR/LF are literal * newlines. * * A paste is text input, so it goes through the same ownership question ordinary * typing does: into the composer's draft when the composer owns the keyboard, and * into a borrowed field when a surface that has one does. A surface without a * text field (a confirmation, a menu, a report) takes no paste at all — the * alternative was writing the composer's hidden draft behind it, which is how a * paste into an approval dialog could silently rewrite the message being written. * * A **Screen** is that same question with a different answer, and it is asked * first because `textIsEditable()` only ever answers for the composer's field: the * setup Screen keeps a field of its own, which the composer's helpers cannot see * and must never be handed (B2.6 §4). */ private handlePasteText; /** * Give a paste to the field the Screen owns, or to nobody. * * The setup Screen is the only Screen with a field, and it is the one place a * paste matters most: an API key, a base URL and a model list are all copied from * somewhere else, and a first run that could only be typed by hand is the * difference between a working session and an empty one. A step without a field — * the model pickers, the confirmation — is not a text field, so the paste is * dropped exactly as a menu drops it: a paste is text, never an answer. */ private pasteIntoScreen; /** * Whether the keys that edit text belong to a field right now. * * The composer always has one; an open surface only has one when it borrowed it * (a free-text answer). Everything else — a confirmation, a menu, the inspect * overlay, every Screen — owns the keyboard without owning the composer's text, * and those keys must do nothing rather than reach the draft behind the surface. * A Screen is included by name: the setup Screen has a field of its own, and it * is reached through the Screen's own keys, never through the composer's. */ private textIsEditable; private handlePlainText; /** * One character, from a read or from the typing loop: the Router decides, again. * * It is asked per character rather than per read because ownership can change inside * one read — a Surface that answers the first character of `yes` is gone for the rest * (pinned by the `yes-typed-at-dialog` trace). A Screen never reaches the body below: * the Router answered `screen` and the Screen adapter took the character. */ private handleChar; /** * Run one rebindable action. * @param action - the action the pressed key was moved to. * @returns true when the key was consumed. */ private runKeyAction; private moveQuestionCursor; private handleDialogChar; /** * The wizard's first-step list: the pinned templates plus the web-catalog * presets (deduped), filtered by the search box text. */ private mergedProviderEntries; private moveProviderCursor; /** The installed catalog's id→window index, built once per catalog load. */ private catalogWindowIndexSource; /** * Size the wizard's picked models from the installed catalog, filling in the * capacities the endpoint's listing did not disclose and recording them on * the wizard state so the saved entries carry them. * @param state - the wizard state whose `models` are being sized. * @returns the windows discovered for this pick, by model id. */ private sizeOnboardingModels; /** * Leave the models step, after one best-effort trip to the gateway for what * the picks are still missing: the context window / output cap of every model * the listing has not described yet, and — for a gateway that spreads over * several protocols — the endpoints each picked model answers. * * `Ctrl+F` stays as it was, but it is no longer the only way to learn these: * a model typed by hand would otherwise be saved with no capacity at all, and * with no endpoint data the split had nothing to file it by. */ private finishModelsStep; /** Whether leaving the models step has anything left to ask the gateway for. */ private onboardingWantsEndpoint; /** Fill the picks' capacities and endpoints from the gateway; never fatal. */ private enrichOnboardingModels; /** The wizard's field text, from the wizard's own state. */ private setupField; /** Replace the wizard's field and caret; the composer is not touched. */ private setupSetField; /** Insert into the wizard's field at its caret. */ private setupInsert; /** Delete the code point before the wizard's caret. */ private setupBackspace; /** * Say something inside the setup Screen. * * Framed mode keeps it on the Screen's own message row — the wizard is a screen, * not history, and a `Step 1 done` transcript would be a record of a flow that * only ever ran once (B2.6 §12/§13). Line mode has no screen to say it on and has * always printed the wizard's messages as text, so it still appends one. */ private setupMessage; /** * The setup Screen's key handling: its own field, its own navigation. * * Everything the wizard used to get from the workspace's composer and its * dialog key path is handled here — the caret keys edit the wizard's field, the * arrows move the wizard's pickers, Esc steps back through the picker steps or * abandons the flow, and the confirm step answers itself. A key this does not * consume is *not* forwarded anywhere: a setup screen has no other owner (B2.6 §4). */ private handleSetupKey; private handleOnboardingChar; private advanceOnboarding; /** * Open the models picker: every model the listing offered — or the template's * pinned ones when nothing was listed — with the template's models checked. * * Enter used to accept the whole listing, which quietly wrote every model of * the gateway into settings and made the session's model whichever id came * first. The picker makes both the set and the session's model explicit; a * typed list still skips it. */ private openOnboardingModelPicker; /** The checked models of the wizard's picker, in listing order. */ private checkedOnboardingModels; /** Move the highlight in either picker step. */ private moveOnboardingModelCursor; /** * Esc in a picker step goes back one step instead of throwing the wizard * away — the listing is the expensive part and the user is still choosing. */ private stepBackOnboarding; /** Fetch the endpoint's model list into the onboarding wizard's models step. */ private fetchOnboardingModels; private saveOnboarding; /** * Say the wizard is done, where the reader will still be able to read it. * * A saved wizard closes its Screen in the same frame, so its own notice row would * be painted for no frames at all — and the credential line that goes with it is a * footer echo (`command-feedback`), which expires. This is the durable half of the * answer: `command-status` routes to the transcript, exactly like the boot line * that asked for the setup in the first place. */ private onboardDone; /** Store one credential, falling back to a launch-environment override on shadow/absence. */ private saveCredential; /** Write launch-environment overrides so they beat system-injected variables. */ private writeLaunchEnv; /** Persist one variable into the Windows user environment (best-effort). */ private setWindowsEnv; /** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */ private ensurePosixEnvHook; private handleEscape; /** Toggle the collapsible row under a click, or copy an OSC-8 link. */ /** * The anchor's line index in the *current* frame. * * A live session repaints while the button is down, and the transcript is * pinned to the bottom: a row arriving below shifts everything up by one. The * stored index would then point at whatever moved into that screen row, so * the anchor is re-found by the row it grabbed (and by its text when the row * carries no identity). When the row has scrolled out of view entirely, the * old index is the best that can be done. */ private resolveAnchorLine; /** The painted lines as plain text, for the selection rules. */ private plainSelectableLines; /** Screen row from a mouse report to an index into `selectableLines`. */ private selectableLineAt; /** The last transcript row the interaction does not cover, or the whole list. */ private lastSelectableLine; /** * Left button pressed. The click itself waits for the release: this may turn * into a drag, and a press that never moves must still open a link or toggle * a card exactly as before. */ private beginMouseSelection; /** The button is held and the pointer moved: extend the drag and repaint it. */ private extendMouseSelection; /** Release: copy the dragged text, or perform the click that never became one. */ private endMouseSelection; /** Paint the drag in reverse video; the affected lines drop their colors. */ private highlightSelection; handleMouseClick(y: number, x?: number): void; private copyPlainText; /** * `/copy [reply|highlight|error]` — copy plain text to the local clipboard. * * The default is the model's **last reply**, which is what a reader wants on * the clipboard most of the time: the answer they just read, whole, ready to * paste into a report. It used to be "the highlighted card, else the newest * reply", so any card left highlighted (one click on a tool card does it) * silently redirected the copy and the reply became unreachable without * deselecting first. `highlight` is that old rule, now said out loud. */ copyFocusedCard(arg?: string): boolean; /** * The copy key: what the reader is *pointing at*. * * The key and the command are two grammars over the same act, and they differ * in exactly one place: with nothing highlighted the key falls back to the * newest reply (a key that copies nothing reads as a broken key), while * `/copy highlight` says there is nothing highlighted. Inside a Surface the key * is the only way to copy at all: a Screen carries what it shows, and every * other dialog keeps the key inert, exactly as it always was. */ private copyKey; /** The newest error or diagnostic row, whole. */ private copyErrorRow; /** The highlighted card — or, for the key, the newest reply when none is. */ private copyHighlighted; /** The model's last reply: the `/copy` default, and the key's last resort. */ private copyLatestReply; /** * Scroll whichever surface the reader is looking at, by `up` lines toward the * earlier content. * * One sign for every caller, because the two surfaces count in opposite * directions: the transcript's `scrollOffset` is the distance *back* from the * newest line, and the overlay's `offset` is the index of its first visible line. * Adding one delta to both — which is what this did — made PgUp and the wheel * scroll the overlay the wrong way, while the arrow keys, which passed their own * inverted sign, were the only ones that felt right. A surface that is not the * inspect overlay owns the keyboard and takes no scroll at all: the transcript * behind a dialog must not move. */ private scrollWorkspace; private handleCtrlC; private submit; private runCommand; /** * Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole. * * The cluster is measured in the field that owns the keyboard, like `backspace` and * `deleteAtCursor` above, which is what makes their ranges mean the same thing as the * cursor they were handed (W-3b.1b): segmenting the *draft* while editing a borrowed * answer gave offsets from another string, so a borrowed `a🚀` lost one surrogate and * kept the caret inside the pair. */ private graphemeBefore; /** Grapheme cluster containing or following `cursor`, in the field that owns the keyboard. */ private graphemeAfter; /** Delete one grapheme back, in whichever field owns the keyboard. */ private backspace; /** Delete one grapheme forward, in whichever field owns the keyboard. */ private deleteAtCursor; /** Move the caret one grapheme, in whichever field owns the keyboard. */ private moveCursor; private historyBack; private historyForward; /** * Typing while browsing history detaches from the saved item. * * The browse is the **composer's**, so only the composer's own field may end it * (W-3b.3a). Every editing key a Surface borrowed the field for arrives here — * `backspace`, `deleteAtCursor`, `handlePasteText`, and `composerUnit`'s Ctrl+U / * Ctrl+K — and detaching on that path wrote the hidden composer's state from the * visible answer: the recalled entry replaced `historyDraft`, so the unsent draft * the browse was holding could never be reached again with `↓`. */ private leaveHistoryBrowse; } /** * Mount the terminal channel once the configured agent exists. * * @param ctx - context supplying the agent registry, sessions, and event stream. * @param config - target agent and presentation config. * @returns lifecycle controller used by the Cordis effect disposer. */ export declare function mountTui(ctx: Context, config: TuiConfig): TuiController;