blob: 90528fd1844592112a054a889953e8df7c8f9540 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Markdown, visibleWidth } from '@earendil-works/pi-tui';
import { resolveReadInput } from '@maka/runtime/read-page';
import type {
ProviderRetryEvent,
ProviderRetryScheduledEvent,
FormRequestEvent,
SandboxBoundaryRequestEvent,
UserQuestionRequestEvent,
SessionEvent,
ShellRunSnapshotResult,
ToolOutputStream,
ToolResultContent,
} from '@maka/core/events';
import {
deriveTurnRecords,
isRuntimeSystemNoteKind,
STEP_LIMIT_NOTICE_TEXT,
type StoredMessage,
type SystemNoteMessage,
} from '@maka/core/session';
import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types';
import { providerRetryDisplaySeconds } from '@maka/core/provider-retry-countdown';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import {
defineUiMessageCatalog,
resolveUiMessageCatalog,
type UiLocale,
} from '@maka/core/ui-locale';
import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js';
import { isActiveShellRunStatus } from '@maka/core/shell-run';
import { mergeShellRunStateWithDiagnostics } from '@maka/core/shell-run-result';
import { projectToolActivityArgs } from '@maka/core/tool-activity-args';
import {
type ToolActivityStatus,
toolResultActivityStatus,
unfinishedToolActivityStatus,
} from '@maka/core/tool-result-status';
import { type ShellRunUpdate } from '@maka/core/events';
import { homedir } from 'node:os';
import { basename, isAbsolute, relative, sep } from 'node:path';
import type { MakaSessionDriver, MakaSideConversationParentStatus } from './session-driver.js';
import { BoundedChunkBuffer } from './bounded-chunk-buffer.js';
import { ansi } from './tui-ansi.js';
import {
collapseToSingleLine,
fitLine,
formatTokenCount,
formatUnknown,
limitText,
markdownTheme,
renderIndented,
} from './pi-transcript-format.js';
import { goalStatusLineText, isLiveGoalStatus } from './pi-goal.js';
import { renderToolBlock } from './pi-transcript-tools.js';
import { getTuiPrimaryGuidance } from './tui-primary-guidance.js';
import { renderTuiShortcutCopy } from './tui-shortcut-copy.js';
import type { GoalProjection } from '@maka/runtime-host/protocol';
export interface MakaPiUsageSummary {
/** Cumulative cost in USD across the session. */
costUsd: number;
/** Cumulative cache hit input tokens. */
cacheHitInput: number;
/** Cumulative cache miss input tokens. */
cacheMissInput: number;
/** Remaining context tokens from the latest token_usage event. */
contextRemaining?: number;
}
export interface MakaPiTranscriptState {
entries: MakaPiTranscriptEntry[];
pendingInteraction?: MakaPiPendingInteraction;
queuedInteractions: MakaPiPendingInteraction[];
/**
* Expansion defaults: entries stamp `expanded` from these at creation, and
* one Ctrl+O / Ctrl+T press retargets every tool / thinking entry inside the
* live viewport and flips the default for entries created later. Entries
* above the viewport keep their state — their rendered lines sit in terminal
* scrollback, which cannot be rewritten, so resizing one would force pi-tui
* into a scrollback-clearing full redraw (#1097). The deliberate exception:
* a second press within 2s of a collapse that stranded expanded entries
* above the viewport applies the default to them too and pays one such
* redraw knowingly (#4011, applyExpansionDefaultToAll). In-memory only;
* never persisted to storage. Resume resets both to collapsed.
*/
expandAllTools: boolean;
expandAllThinking: boolean;
/**
* Geometry of the transcript render pi-tui last diffed against:
* renderMakaPiTranscript records each entry's first line and
* MakaPiLayoutComponent records the live-viewport top. The expansion toggles
* read it to leave entries above the viewport untouched (#1097); see
* entryInLiveViewport.
*/
renderGeometry: MakaPiRenderGeometry;
/** Aggregated token usage for statusline display; reset on session switch. */
usage: MakaPiUsageSummary;
/**
* Read-only mirror of the runtime's authoritative pending queues, driven by
* `queue_update` events and enqueue results. Rendered as the pending bar above
* the editor; never the source of truth (the runtime owns that).
*/
steering: string[];
followup: string[];
/** Current non-durable provider retry progress for the activity strip. */
providerRetry?: ProviderRetryCountdown;
}
export type MakaPiPendingInteraction =
| SandboxBoundaryRequestEvent
| UserQuestionRequestEvent
| FormRequestEvent;
/**
* A provider retry event plus the CLIENT-local time it was applied. Counting
* down from `receivedAtMs` keeps the whole countdown in one clock domain —
* the event's own `ts` is stamped on the (possibly remote) Runtime Host
* clock, so subtracting it from a client clock would skew the display by the
* clock offset between the two machines.
*/
export interface ProviderRetryCountdown {
event: ProviderRetryEvent;
receivedAtMs: number;
}
export interface MakaPiRenderGeometry {
/**
* First rendered transcript-line index per entry, from the latest render.
* `undefined` means no entry position is known — the transcript was just
* replaced wholesale and has not rendered since — which the toggles must
* treat as "nothing safely reachable" while the viewport has scrolled.
*/
entryFirstLine: Map<MakaPiTranscriptEntry, number> | undefined;
/**
* pi-tui's live-viewport top in transcript-line coordinates (the transcript
* is the first layout child, so transcript line i is composed line i). Held
* as a monotonic max: pi-tui's viewport never scrolls back up short of a
* full redraw, and a full redraw has already cleared scrollback, so
* overestimating only makes the toggles more conservative.
*/
viewportTop: number;
}
/** A single live output chunk from a `tool_output_delta` event. */
export interface MakaPiToolOutputDelta {
seq: number;
stream: ToolOutputStream;
chunk: string;
redacted: boolean;
}
const LIVE_TOOL_BUFFER_MAX_CHARS = 64 * 1024;
const LIVE_TOOL_BUFFER_MAX_CHUNKS = 512;
export type MakaPiTranscriptEntry =
| { kind: 'user'; messageId: string; text: string; transient?: boolean }
| { kind: 'legacy_automation'; text: string }
| { kind: 'goal_continuation'; text: string }
| { kind: 'assistant'; messageId: string; text: string }
| { kind: 'thinking'; messageId: string; text: string; expanded: boolean }
| {
kind: 'tool';
/** Present for live events so durable hydration is turn-scoped. */
turnId?: string;
toolUseId: string;
toolName: string;
title?: string;
/** Runtime-authored, bounded description of the live call's purpose. */
intent?: string;
input: unknown;
/** Structured result returned by the tool. */
result?: ToolResultContent;
/** In-memory revision for render-cache invalidation when a result is replaced. */
resultVersion: number;
progress: BoundedChunkBuffer<string>;
outputDeltas: BoundedChunkBuffer<MakaPiToolOutputDelta>;
durationMs?: number;
/** Invocation lifecycle. Resource liveness remains authoritative in `result`. */
callStatus: ToolActivityStatus;
/** Ownership of an active ShellRun; absent means locally owned. */
shellRunSource?: 'source_owned' | 'unavailable';
/** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */
expanded: boolean;
/** An internal shell-run poll retained for correlation but not displayed. */
suppressed?: boolean;
/** Local-only Runtime Resource started by `!<command>`, never a model tool call. */
userOwned?: boolean;
}
| { kind: 'notice'; level: 'info' | 'error'; text: string };
export interface MakaPiTranscriptMetadata {
title: string;
cwd: string;
model: string;
connectionSlug: string;
permissionMode: string;
orchestrationMode?: 'default' | 'swarm' | 'graph';
thinkingLevel?: ThinkingLevel;
thinkingLevels?: readonly ThinkingLevel[];
sessionId?: string | null;
busy?: boolean;
usage?: MakaPiUsageSummary;
/** Maximum context tokens for the active model, for the `ctx used/window pct%` segment. */
modelContextWindow?: number;
/** Elapsed milliseconds of the running agent turn, for the activity strip. */
turnElapsedMs?: number;
providerRetry?: ProviderRetryCountdown;
/** Resolved locale for primary TUI guidance. Defaults to English for direct embeddings. */
uiLocale?: UiLocale;
/**
* Latest known goal projection for the session, or null when no goal is
* set. The status line shows live goals only (active/waiting/paused);
* terminal goals leave no segment, matching the desktop chip.
*/
goal?: GoalProjection | null;
sideConversation?: {
view: 'parent' | 'side';
parentStatus?: MakaSideConversationParentStatus;
};
}
export function createMakaPiTranscriptState(): MakaPiTranscriptState {
return {
entries: [],
queuedInteractions: [],
expandAllTools: false,
expandAllThinking: false,
renderGeometry: { entryFirstLine: undefined, viewportTop: 0 },
usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 },
steering: [],
followup: [],
};
}
function accumulateUsage(
usage: MakaPiUsageSummary,
msg: {
costUsd?: number;
input?: number;
cacheHitInput?: number;
cacheRead?: number;
cacheWriteInput?: number;
cacheCreation?: number;
cacheMissInput?: number;
contextRemaining?: number;
},
): void {
usage.costUsd += msg.costUsd ?? 0;
const hit = msg.cacheHitInput ?? msg.cacheRead ?? 0;
const write = msg.cacheWriteInput ?? msg.cacheCreation ?? 0;
usage.cacheHitInput += hit;
usage.cacheMissInput += msg.cacheMissInput ?? Math.max(0, (msg.input ?? 0) - hit - write);
usage.contextRemaining = msg.contextRemaining;
}
export function appendUserPrompt(
state: MakaPiTranscriptState,
text: string,
messageId: string,
transient = false,
): void {
const entry = {
kind: 'user',
messageId,
text,
...(transient ? { transient: true } : {}),
} as const;
const existingIndex = state.entries.findIndex(
(candidate) => candidate.kind === 'user' && candidate.messageId === messageId,
);
if (existingIndex >= 0) {
state.entries[existingIndex] = entry;
return;
}
state.entries.push(entry);
}
export function retireCancelledTransientMessages(
state: MakaPiTranscriptState,
cancelledMessageIds: readonly string[],
): void {
const cancelled = new Set(cancelledMessageIds);
if (cancelled.size === 0) return;
state.entries = state.entries.filter(
(entry) => entry.kind !== 'user' || entry.transient !== true || !cancelled.has(entry.messageId),
);
}
export function appendTurnFailureToTranscript(state: MakaPiTranscriptState, error: unknown): void {
clearPendingInteractions(state);
state.entries.push({
kind: 'notice',
level: 'error',
text: error instanceof Error ? error.message : String(error),
});
}
export function refreshRunningShellRunElapsed(
state: MakaPiTranscriptState,
now = Date.now(),
): boolean {
let found = false;
for (const entry of state.entries) {
if (
entry.kind !== 'tool' ||
entry.result?.kind !== 'shell_run' ||
makaPiToolPresentationStatus(entry) !== 'running'
)
continue;
entry.durationMs = Math.max(0, now - entry.result.startedAt);
found = true;
}
return found;
}
export function applyShellRunViewUpdateToTranscript(
state: MakaPiTranscriptState,
update: ShellRunUpdate,
options?: {
/**
* Whether a running → settled flip appends a transcript-tail notice.
* Default true for live updates. Hydration catch-up (`listShellRunUpdates`)
* passes false: replaying durable state is not a live event, and the notice
* is never persisted, so announcing catch-up would re-announce on every
* session attach.
*/
announceSettle?: boolean;
},
): boolean {
const tool = findToolEntry(state, update.sourceToolCallId);
const wasLive = isLiveShellRunCard(tool);
const applied = applyShellRunUpdateToTranscript(state, update.sourceToolCallId, update.result);
if (
tool &&
tool.userOwned !== true &&
wasLive &&
isSettledShellRunCard(tool) &&
options?.announceSettle !== false
) {
pushShellRunSettledNotice(state, tool);
}
if (
!tool ||
!isShellRunToolCard(tool) ||
tool.result?.kind !== 'shell_run' ||
tool.result.ref !== update.result.ref ||
tool.result.revision !== update.result.revision ||
!isActiveShellRunStatus(tool.result.status)
)
return applied;
const shellRunSource =
update.ownership.kind === 'local'
? undefined
: update.ownership.kind === 'source_owned'
? 'source_owned'
: 'unavailable';
if (tool.shellRunSource === shellRunSource) return applied;
tool.shellRunSource = shellRunSource;
return true;
}
export function applyShellRunUpdateToTranscript(
state: MakaPiTranscriptState,
sourceToolCallId: string,
update: Extract<ToolResultContent, { kind: 'shell_run' }>,
): boolean {
const tool = findToolEntry(state, sourceToolCallId);
if (!tool || !isShellRunToolCard(tool)) return false;
if (tool.result?.kind === 'shell_run' && tool.result.ref !== update.ref) return false;
return applyShellRunResult(tool, update);
}
/** Adds a local-only card for a `!<command>` resource without creating a model turn. */
export function appendUserCommandToTranscript(
state: MakaPiTranscriptState,
input: { commandId: string; command: string; result: ShellRunSnapshotResult },
): void {
state.entries.push({
kind: 'tool',
toolUseId: input.commandId,
toolName: 'User command',
title: 'User command',
input: { command: input.command },
result: input.result,
resultVersion: 1,
progress: createProgressBuffer(),
outputDeltas: createOutputBuffer(),
callStatus: toolResultActivityStatus(
input.result.status === 'failed' || input.result.status === 'timed_out',
input.result,
),
expanded: true,
userOwned: true,
});
}
export function replaceTranscriptWithStoredMessages(
state: MakaPiTranscriptState,
messages: readonly StoredMessage[],
options: { preserveClientLocalEntries?: boolean } = {},
): void {
const durableMessageIds = new Set(messages.map((message) => message.id));
const durableEntries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages));
const durableEntryIds = new Set(durableEntries.map(transcriptEntryId).filter(Boolean));
// Client-local entries have no durable counterpart to arrive in `messages`:
// a transient user row still waiting for its canonical message, and every
// notice the client itself wrote (recap, skill card, error). A replacement
// that dropped them would erase what the client just told the user.
const isClientLocal = (entry: MakaPiTranscriptEntry): boolean =>
entry.kind === 'notice' ||
(entry.kind === 'tool' && entry.userOwned === true) ||
(entry.kind === 'user' && entry.transient === true && !durableMessageIds.has(entry.messageId));
// A preserved entry keeps its place relative to the durable entry it
// followed. With no durable entry ahead of it it stays at the head, unless
// something durable preceded it — then the tail is where it belongs.
const transientEntriesByBoundary = new Map<number, MakaPiTranscriptEntry[]>();
if (options.preserveClientLocalEntries) {
state.entries.forEach((entry, index) => {
if (!isClientLocal(entry)) return;
const priorEntries = state.entries.slice(0, index);
const previousDurableId = priorEntries
.map(transcriptEntryId)
.reverse()
.find((messageId) => messageId !== undefined && durableEntryIds.has(messageId));
const previousIndex = previousDurableId
? durableEntries.findIndex(
(candidate) => transcriptEntryId(candidate) === previousDurableId,
)
: -1;
const hadPrecedingEntry = priorEntries.some((candidate) => !isClientLocal(candidate));
const boundary =
previousIndex >= 0 ? previousIndex + 1 : hadPrecedingEntry ? durableEntries.length : 0;
const grouped = transientEntriesByBoundary.get(boundary);
if (grouped) grouped.push(entry);
else transientEntriesByBoundary.set(boundary, [entry]);
});
}
state.entries = [];
for (let boundary = 0; boundary <= durableEntries.length; boundary += 1) {
state.entries.push(...(transientEntriesByBoundary.get(boundary) ?? []));
if (boundary < durableEntries.length) state.entries.push(durableEntries[boundary]!);
}
clearPendingInteractions(state);
state.expandAllTools = false;
state.expandAllThinking = false;
// The old entries are gone; no position is known until the next render, and
// until then the toggles must not touch anything (a replacement entry could
// render above the still-scrolled viewport). viewportTop is left to the next
// layout render: when the replacement changes lines above it, pi-tui
// full-redraws and the layout's shadow diff resets the estimate to match;
// when the replacement is a pure truncation or identical content, pi-tui
// keeps its viewport and so does the estimate.
state.renderGeometry.entryFirstLine = undefined;
state.usage = { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 };
// Queues are per-active-run; a switched/reset session has none pending.
state.steering = [];
state.followup = [];
for (const msg of messages) {
if (msg.type === 'token_usage') accumulateUsage(state.usage, msg);
}
}
function transcriptEntryId(entry: MakaPiTranscriptEntry): string | undefined {
switch (entry.kind) {
case 'user':
case 'assistant':
case 'thinking':
return entry.messageId;
case 'tool':
return entry.toolUseId;
default:
return undefined;
}
}
export function hasRunningUserCommand(state: MakaPiTranscriptState): boolean {
return state.entries.some(
(entry) => entry.kind === 'tool' && entry.userOwned === true && isLiveShellRunCard(entry),
);
}
/**
* Fill durable tool details that are intentionally absent from Runtime Host
* live events without applying session-switch reset semantics.
*/
export function hydrateToolsWithStoredMessages(
state: MakaPiTranscriptState,
turnId: string,
messages: readonly StoredMessage[],
): boolean {
const turnMessages = messages.filter((message) => message.turnId === turnId);
const durableTools = new Map(
foldStoredShellRunChildren(storedMessagesToTranscriptEntries(turnMessages))
.filter(
(entry): entry is Extract<MakaPiTranscriptEntry, { kind: 'tool' }> => entry.kind === 'tool',
)
.map((entry) => [entry.toolUseId, entry]),
);
let changed = false;
for (const entry of state.entries) {
if (entry.kind !== 'tool' || entry.turnId !== turnId) continue;
const durable = durableTools.get(entry.toolUseId);
if (!durable) continue;
entry.toolName = durable.toolName;
entry.title = durable.title;
entry.input = structuredClone(durable.input);
entry.callStatus = mergeToolCallStatus(entry.callStatus, durable.callStatus);
if (
durable.result?.kind === 'shell_run' &&
durable.callStatus !== 'errored' &&
entry.toolName === 'Bash'
) {
applyShellRunResult(entry, structuredClone(durable.result));
} else if (durable.result !== undefined && entry.result === undefined) {
entry.result = structuredClone(durable.result);
entry.resultVersion += 1;
if (durable.durationMs !== undefined) entry.durationMs = durable.durationMs;
} else if (durable.durationMs !== undefined && entry.durationMs === undefined) {
entry.durationMs = durable.durationMs;
}
changed = true;
}
return changed;
}
function mergeToolCallStatus(
current: ToolActivityStatus,
durable: ToolActivityStatus,
): ToolActivityStatus {
return current === 'running' ? durable : current;
}
/**
* True when the entry will render inside the live viewport, or has not been
* rendered yet (a fresh entry first appears at the tail, inside the viewport).
* Entries above the viewport sit in terminal scrollback, which ANSI terminals
* cannot rewrite: resizing one forces pi-tui's differential renderer into a
* full redraw that clears pre-Maka scrollback and resets the user's scroll
* position (#1097), so the global toggles leave them untouched.
*/
function entryInLiveViewport(state: MakaPiTranscriptState, entry: MakaPiTranscriptEntry): boolean {
const geometry = state.renderGeometry;
// No positions at all (fresh state, or replaced and not yet rendered): safe
// only while the viewport has never scrolled.
if (geometry.entryFirstLine === undefined) return geometry.viewportTop === 0;
const firstLine = geometry.entryFirstLine.get(entry);
return firstLine === undefined || firstLine >= geometry.viewportTop;
}
/**
* True while entry positions are unknown but the viewport has scrolled (a
* wholesale replacement not yet re-rendered): a toggle could rewrite lines
* above pi-tui's real viewport, so it must do nothing until the next render.
*
* Unknown positions with viewportTop === 0 are deliberately NOT inert: while
* the viewport has never scrolled, no line sits in scrollback and pi-tui's
* differential render (`firstChanged < viewportTop`) can never full-redraw,
* so toggling everything — including entries awaiting their first render —
* is physically safe.
*/
function togglesInert(state: MakaPiTranscriptState): boolean {
return state.renderGeometry.entryFirstLine === undefined && state.renderGeometry.viewportTop > 0;
}
/**
* Toggle every tool card in the live viewport at once and flip the default for
* future cards; false when the session has no tool card at all or the toggles
* are inert pending a render.
*
* A collapse that strands expanded cards above the viewport (their heads sit
* in scrollback, #1134) appends a notice naming them and offering the #4011
* second-press escape: the runner arms a confirm window whenever
* hasExpandedEntriesAboveViewport still holds after the toggle. A partial
* collapse says so too — the keypress is never silent about what it skipped.
*/
export function toggleAllToolExpansion(state: MakaPiTranscriptState): boolean {
return toggleExpansion(state, 'tool');
}
/**
* Toggle every thinking entry in the live viewport at once and flip the
* default for future entries; false when there is no thinking at all or the
* toggles are inert pending a render. Same head-scrolled contract and #4011
* second-press escape as toggleAllToolExpansion.
*/
export function toggleAllThinkingExpansion(state: MakaPiTranscriptState): boolean {
return toggleExpansion(state, 'thinking');
}
/**
* True when an entry of the kind above the live viewport remains expanded —
* the stranded blocks the #4011 confirmed collapse exists for. False while
* the toggles are inert (positions unknown after a wholesale replacement).
*/
export function hasExpandedEntriesAboveViewport(
state: MakaPiTranscriptState,
kind: ExpansionEntryKind,
): boolean {
if (togglesInert(state)) return false;
return expansionCandidates(state, kind).some(
(entry) => entry.expanded && !entryInLiveViewport(state, entry),
);
}
/**
* Appends the visible, explicit offer for the one deliberate full-redraw
* escape hatch. The runner owns the time window; this helper keeps both the
* first offer and an expired offer on the same copy authority.
*/
export function appendExpansionCollapseConfirmation(
state: MakaPiTranscriptState,
kind: ExpansionEntryKind,
): boolean {
if (!hasExpandedEntriesAboveViewport(state, kind)) return false;
const copy = EXPANSION_KIND_COPY[kind];
const stuck = expansionCandidates(state, kind).filter(
(entry) => entry.expanded && !entryInLiveViewport(state, entry),
);
state.entries.push({
kind: 'notice',
level: 'info',
text: `${stuck.length} ${stuck.length === 1 ? copy.singular : copy.plural} above the view stayed expanded in scrollback — press ${copy.key} again within ${EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS / 1000}s to collapse them too (this redraws the screen and clears pre-session scrollback). New ${copy.newOutput} starts collapsed.`,
});
return true;
}
/**
* Apply the current expansion default to every entry of the kind, including
* entries above the live viewport whose rendered lines sit in scrollback.
* This is #4011's confirmed second press: a true return means lines above
* the viewport change, so the caller MUST follow with pi-tui's
* `requestRender(true)` — a scrollback-clearing full redraw that re-anchors
* the viewport at the tail. (The differential path would reach the same
* redraw via `firstChanged < viewportTop`; forcing it keeps renderer and the
* layout's viewport shadow in agreement by construction.)
* This is intentionally the only expansion mutation that bypasses the
* unknown-geometry guard: its caller immediately forces that wholesale
* redraw, which resets the renderer's prior geometry before it re-renders.
*/
export function applyExpansionDefaultToAll(
state: MakaPiTranscriptState,
kind: ExpansionEntryKind,
): boolean {
const expanded = kind === 'tool' ? state.expandAllTools : state.expandAllThinking;
let changed = false;
for (const entry of expansionCandidates(state, kind)) {
if (entry.expanded === expanded) continue;
entry.expanded = expanded;
changed = true;
}
return changed;
}
export type ExpansionEntryKind = 'tool' | 'thinking';
/**
* How close together two identical expansion-toggle presses read as the
* confirmed "collapse the stranded blocks above the viewport" gesture (#4011).
* Lives beside the notice copy so the offer text and the runner's confirm
* window share one authority and cannot drift.
*/
export const EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS = 2_000;
const EXPANSION_KIND_COPY: Record<
ExpansionEntryKind,
{
key: string;
singular: string;
plural: string;
noTargetsNotice: (expand: boolean) => string;
newOutput: string;
}
> = {
tool: {
key: 'Ctrl+O',
singular: 'tool card',
plural: 'tool cards',
newOutput: 'tool output',
noTargetsNotice: (expand) =>
`No tool card in view to toggle — cards above stay as rendered in scrollback. New tool output starts ${expand ? 'expanded' : 'collapsed'}.`,
},
thinking: {
key: 'Ctrl+T',
singular: 'thinking block',
plural: 'thinking blocks',
newOutput: 'thinking',
noTargetsNotice: (expand) =>
`No thinking in view to toggle — thinking above stays as rendered in scrollback. New thinking starts ${expand ? 'expanded' : 'collapsed'}.`,
},
};
function expansionCandidates(
state: MakaPiTranscriptState,
kind: ExpansionEntryKind,
): Array<MakaPiToolEntry | MakaPiThinkingEntry> {
return kind === 'tool'
? state.entries.filter(
(entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.userOwned !== true,
)
: state.entries.filter(
(entry): entry is MakaPiThinkingEntry =>
entry.kind === 'thinking' && Boolean(entry.text.trim()),
);
}
function toggleExpansion(state: MakaPiTranscriptState, kind: ExpansionEntryKind): boolean {
if (togglesInert(state)) return false;
const candidates = expansionCandidates(state, kind);
if (candidates.length === 0) return false;
const expand = kind === 'tool' ? !state.expandAllTools : !state.expandAllThinking;
if (kind === 'tool') state.expandAllTools = expand;
else state.expandAllThinking = expand;
const targets = candidates.filter((entry) => entryInLiveViewport(state, entry));
for (const entry of targets) entry.expanded = expand;
const stuck = candidates.filter((entry) => entry.expanded !== expand);
const copy = EXPANSION_KIND_COPY[kind];
// The confirm offer exists for collapses only: expanded-above blocks are the
// screen-reclaiming pain (#4011), while collapsed-above blocks are compact
// and harmless in scrollback — and arming on expand would make a quick
// expand-then-collapse pair read the second press as "expand everything".
if (!expand && stuck.length > 0) {
appendExpansionCollapseConfirmation(state, kind);
} else if (targets.length === 0) {
state.entries.push({ kind: 'notice', level: 'info', text: copy.noTargetsNotice(expand) });
}
return true;
}
export async function submitCompactToTranscript(input: {
state: MakaPiTranscriptState;
driver: Pick<MakaSessionDriver, 'compactSession'>;
onChange?: () => void;
}): Promise<void> {
let outcome: Extract<SessionEvent, { type: 'complete' }>['contextCompactionOutcome'];
try {
for await (const event of input.driver.compactSession()) {
if (event.type === 'complete') outcome = event.contextCompactionOutcome;
if (event.type === 'token_usage') accumulateUsage(input.state.usage, event);
else applyMakaSessionEventToTranscript(input.state, event);
input.onChange?.();
}
if (outcome) {
input.state.entries.push({
kind: 'notice',
level: outcome.kind === 'failed' ? 'error' : 'info',
text:
outcome.kind === 'compacted'
? 'Context compacted.'
: outcome.kind === 'unchanged'
? 'Nothing to compact.'
: `Context compaction failed: ${outcome.reason}.`,
});
input.onChange?.();
}
} catch (error) {
input.state.entries.push({
kind: 'notice',
level: 'error',
text: error instanceof Error ? error.message : String(error),
});
input.onChange?.();
}
}
export function applyMakaSessionEventToTranscript(
state: MakaPiTranscriptState,
event: SessionEvent,
): void {
if (
event.type === 'text_delta' ||
event.type === 'text_complete' ||
event.type === 'thinking_delta' ||
event.type === 'thinking_complete' ||
event.type === 'tool_start' ||
event.type === 'error' ||
event.type === 'abort' ||
event.type === 'complete'
) {
state.providerRetry = undefined;
}
switch (event.type) {
case 'text_delta':
appendAssistantText(state, event.messageId, event.text);
break;
case 'text_complete':
if (!setAssistantText(state, event.messageId, event.text) && event.text) {
appendAssistantText(state, event.messageId, event.text);
}
break;
case 'thinking_delta':
appendThinking(state, event.messageId, event.text);
break;
case 'thinking_complete':
if (event.text) setThinking(state, event.messageId, event.text);
break;
case 'tool_start': {
// A Read / StopBackgroundTask aimed at a ref a visible Bash card owns is
// internal polling of that run: it never gets a row, so an active polling
// loop cannot flicker cards in and out of the transcript. The result
// folds into the parent at tool_result. A poll is folded only when its
// parent card already carries the run's shell_run result — otherwise it
// renders normally and the tool_result fold below still applies.
const ref = event.shellRunRef ?? readArgsRef(event.args);
const suppressed =
(event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') &&
!!ref &&
!!findShellRunParent(state, ref, event.toolUseId);
state.entries.push({
kind: 'tool',
turnId: event.turnId,
toolUseId: event.toolUseId,
toolName: event.toolName,
...(event.displayName ? { title: event.displayName } : {}),
...(event.intent ? { intent: event.intent } : {}),
// Live Runtime Host frames omit full args; the bounded wire preview
// still lets the compact row name the call. The turn-end reconcile
// replaces it with the durable full args.
input: projectToolActivityArgs(event.toolName, event.args ?? event.argsPreview),
resultVersion: 0,
progress: createProgressBuffer(),
outputDeltas: createOutputBuffer(),
callStatus: 'running',
expanded: state.expandAllTools,
...(suppressed ? { suppressed: true } : {}),
});
break;
}
case 'tool_result': {
const tool = findToolEntry(state, event.toolUseId);
if (tool?.suppressed && event.contentOmitted && !event.isError) {
state.entries.splice(state.entries.indexOf(tool), 1);
break;
}
const shellRun = event.content.kind === 'shell_run' ? event.content : undefined;
const parent = shellRun
? findShellRunParent(state, shellRun.ref, event.toolUseId)
: undefined;
if (tool && parent && shellRun && !event.isError) {
applyLiveShellRunResultToParent(state, parent, shellRun);
if (tool.toolName === 'Read' || tool.toolName === 'StopBackgroundTask') {
state.entries.splice(state.entries.indexOf(tool), 1);
} else {
applyOwnShellRunResult(tool, shellRun, event.durationMs);
}
break;
}
if (tool) {
if (tool.suppressed) unsuppressToolAtTail(state, tool);
tool.callStatus = toolResultActivityStatus(event.isError, event.content);
if (shellRun) {
if (tool.toolName === 'Bash') {
applyShellRunResult(tool, shellRun);
} else {
applyOwnShellRunResult(tool, shellRun, event.durationMs);
}
} else {
if (!(event.contentOmitted && tool.result?.kind === 'shell_run')) {
tool.durationMs = event.durationMs;
}
if (!event.contentOmitted) {
tool.result = event.content;
tool.resultVersion += 1;
}
}
} else {
state.entries.push({
kind: 'tool',
turnId: event.turnId,
toolUseId: event.toolUseId,
toolName: event.toolUseId,
input: undefined,
progress: createProgressBuffer(),
outputDeltas: createOutputBuffer(),
...(!event.contentOmitted ? { result: event.content } : {}),
resultVersion: event.contentOmitted ? 0 : 1,
durationMs: event.durationMs,
callStatus: toolResultActivityStatus(event.isError, event.content),
expanded: state.expandAllTools,
});
}
break;
}
case 'tool_progress': {
const tool = findToolEntry(state, event.toolUseId);
if (tool) {
const progress =
typeof event.chunk === 'string'
? event.chunk
: event.chunk.text
? `[${event.chunk.kind}] ${event.chunk.text}`
: '';
if (progress) tool.progress.append(progress);
}
break;
}
case 'tool_result_preview':
// Live-only open-facts for desktop Open; TUI has no mid-flight Open surface.
break;
case 'tool_output_delta': {
const tool = findToolEntry(state, event.toolUseId);
if (tool && (event.chunk || event.redacted)) {
tool.outputDeltas.append({
seq: event.seq,
stream: event.stream,
chunk: event.chunk,
redacted: event.redacted,
});
}
break;
}
case 'sandbox_boundary_request':
enqueuePendingInteraction(state, event);
break;
case 'user_question_request':
enqueuePendingInteraction(state, event);
break;
case 'form_request':
enqueuePendingInteraction(state, event);
break;
case 'sandbox_boundary_decision_ack':
{
const request = findPendingInteraction(state, event.requestId);
if (request?.type === 'sandbox_boundary_request') {
completePendingInteraction(state, event.requestId);
state.entries.push({
kind: 'notice',
level: 'info',
text: `Access ${event.decision === 'allow' ? 'expanded' : 'unchanged'}`,
});
}
}
break;
case 'user_question_answer_ack':
completePendingInteraction(state, event.requestId);
break;
case 'form_answer_ack':
completePendingInteraction(state, event.requestId);
break;
case 'plan_submitted':
state.entries.push({
kind: 'notice',
level: 'info',
text: `Plan submitted: ${event.title}`,
});
break;
case 'steering_message':
// A user interjection injected mid-turn; render it in place as a user turn.
appendUserPrompt(
state,
event.content.displayText ?? event.content.text,
event.messageId,
state.entries.some(
(entry) =>
entry.kind === 'user' &&
entry.messageId === event.messageId &&
entry.transient === true,
),
);
break;
case 'message_admission':
if (event.outcome === 'retracted') {
state.entries = state.entries.filter(
(entry) =>
entry.kind !== 'user' ||
entry.transient !== true ||
entry.messageId !== event.messageId,
);
}
break;
case 'queue_update':
// Authoritative snapshot from the runtime; mirror it for the pending bar.
state.steering = [...event.steering];
state.followup = [...event.followup];
break;
case 'provider_retry':
state.providerRetry = { event, receivedAtMs: Date.now() };
break;
case 'token_usage': {
accumulateUsage(state.usage, event);
const notice = contextBudgetOutcomeNotice(event.contextBudget);
if (notice) {
state.entries.push({
kind: 'notice',
level: notice.level,
text: notice.text,
});
}
break;
}
case 'error':
clearPendingInteractions(state);
dropSuppressedTools(state);
state.entries.push({
kind: 'notice',
level: 'error',
text: event.message,
});
break;
case 'abort':
clearPendingInteractions(state);
dropSuppressedTools(state);
state.entries.push({
kind: 'notice',
level: 'info',
text: `Stopped: ${event.reason}`,
});
break;
case 'complete':
// The turn is over; any unresolved interaction is no longer actionable.
clearPendingInteractions(state);
dropSuppressedTools(state);
if (event.stopReason === 'max_tokens') {
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Stopped: max tokens',
});
}
if (event.stopReason === 'step_limit') {
state.entries.push({ kind: 'notice', level: 'info', text: STEP_LIMIT_NOTICE_TEXT });
}
break;
}
}
function storedMessagesToTranscriptEntries(
messages: readonly StoredMessage[],
): MakaPiTranscriptEntry[] {
const entries: MakaPiTranscriptEntry[] = [];
const resultsByToolUseId = new Map(
messages
.filter(
(message): message is Extract<StoredMessage, { type: 'tool_result' }> =>
message.type === 'tool_result',
)
.map((message) => [message.toolUseId, message]),
);
const turnStatusById = new Map(
deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]),
);
for (const message of messages) {
switch (message.type) {
case 'user':
if (message.origin?.kind === 'legacy_automation') {
entries.push({ kind: 'legacy_automation', text: message.displayText ?? message.text });
} else if (message.origin?.kind === 'goal') {
entries.push({ kind: 'goal_continuation', text: message.displayText ?? message.text });
} else {
entries.push({
kind: 'user',
messageId: message.id,
text: message.displayText ?? message.text,
});
}
break;
case 'assistant': {
// Stored thinking happened before the reply text, so it resumes above it.
const thinking = message.thinking?.text;
if (thinking?.trim()) {
entries.push({
kind: 'thinking',
messageId: message.id,
text: thinking,
expanded: false,
});
}
entries.push({ kind: 'assistant', messageId: message.id, text: message.text });
break;
}
case 'tool_call':
entries.push(
storedToolToTranscriptEntry(
message,
resultsByToolUseId.get(message.id),
turnStatusById.get(message.turnId),
),
);
break;
case 'system_note': {
const entry = systemNoteToTranscriptEntry(message);
if (entry) entries.push(entry);
break;
}
case 'tool_result':
case 'permission_decision':
case 'token_usage':
case 'turn_state':
break;
}
}
return entries;
}
function storedToolToTranscriptEntry(
call: Extract<StoredMessage, { type: 'tool_call' }>,
result: Extract<StoredMessage, { type: 'tool_result' }> | undefined,
turnStatus: ReturnType<typeof deriveTurnRecords>[number]['status'] | undefined,
): MakaPiToolEntry {
const entry: MakaPiToolEntry = {
kind: 'tool',
toolUseId: call.id,
toolName: call.toolName,
...(call.displayName ? { title: call.displayName } : {}),
input: projectToolActivityArgs(call.toolName, call.args),
progress: createProgressBuffer(),
outputDeltas: createOutputBuffer(),
...(result ? { result: result.content } : {}),
resultVersion: result ? 1 : 0,
...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
callStatus: result
? toolResultActivityStatus(result.isError, result.content)
: unfinishedToolActivityStatus(turnStatus),
expanded: false,
};
// A failed call keeps its error status and raw payload: applying the shell_run
// as the card's own result would let a still-running or settled payload
// overwrite the error and swallow the failure on replay. This mirrors the live
// tool_result path, which forces `error` for any errored shell_run result, and
// is what lets the stored fold below recognize an errored poll by its status.
if (result?.content.kind === 'shell_run' && !result.isError)
applyOwnShellRunResult(entry, result.content);
return entry;
}
function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTranscriptEntry[] {
const folded: MakaPiTranscriptEntry[] = [];
for (const entry of entries) {
// An errored poll never folds: its failed payload must not mutate the parent
// and its error card must survive replay, mirroring the live path's "failure
// is never swallowed" invariant.
if (
entry.kind === 'tool' &&
entry.result?.kind === 'shell_run' &&
entry.callStatus !== 'errored'
) {
const shellRun = entry.result;
const parent = [...folded]
.reverse()
.find(
(candidate): candidate is MakaPiToolEntry =>
candidate.kind === 'tool' &&
candidate.toolName === 'Bash' &&
candidate.result?.kind === 'shell_run' &&
candidate.result.ref === shellRun.ref,
);
if (parent) {
applyShellRunResult(parent, shellRun);
if (entry.toolName === 'Read' || entry.toolName === 'StopBackgroundTask') continue;
}
}
folded.push(entry);
}
return folded;
}
export type MakaPiToolPresentationStatus =
| 'running'
| 'done'
| 'error'
| 'failed'
| 'aborted'
| 'detached'
| 'unavailable';
export function makaPiToolPresentationStatus(entry: MakaPiToolEntry): MakaPiToolPresentationStatus {
if (entry.result?.kind === 'subagent') return SUBAGENT_PRESENTATION_STATUS[entry.result.status];
if (entry.result?.kind === 'shell_run') {
if (entry.callStatus === 'errored') return 'error';
if (entry.toolName === 'WriteStdin') {
return entry.result.operation?.kind === 'pty_control' && entry.result.operation.failed
? 'error'
: 'done';
}
if (isActiveShellRunStatus(entry.result.status)) {
return entry.shellRunSource === 'source_owned'
? 'detached'
: entry.shellRunSource === 'unavailable'
? 'unavailable'
: 'running';
}
return SHELL_RUN_PRESENTATION_STATUS[entry.result.status];
}
return CALL_PRESENTATION_STATUS[entry.callStatus];
}
const CALL_PRESENTATION_STATUS = {
running: 'running',
completed: 'done',
errored: 'error',
interrupted: 'aborted',
} as const satisfies Record<ToolActivityStatus, MakaPiToolPresentationStatus>;
const SUBAGENT_PRESENTATION_STATUS = {
completed: 'done',
failed: 'failed',
cancelled: 'aborted',
running: 'running',
waiting_for_user: 'running',
} as const satisfies Record<
Extract<ToolResultContent, { kind: 'subagent' }>['status'],
MakaPiToolPresentationStatus
>;
const SHELL_RUN_PRESENTATION_STATUS = {
starting: 'running',
running: 'running',
completed: 'done',
cancelled: 'aborted',
failed: 'failed',
timed_out: 'failed',
orphaned: 'failed',
} as const satisfies Record<
Extract<ToolResultContent, { kind: 'shell_run' }>['status'],
MakaPiToolPresentationStatus
>;
function applyShellRunResult(
entry: MakaPiToolEntry,
result: Extract<ToolResultContent, { kind: 'shell_run' }>,
): boolean {
const current = entry.result?.kind === 'shell_run' ? entry.result : undefined;
const merged = mergeShellRunStateWithDiagnostics(current, result, 'cli.transcript');
if (!merged.changed) return false;
entry.result = merged.result;
entry.durationMs = Math.max(
0,
(merged.result.completedAt ?? merged.result.updatedAt) - merged.result.startedAt,
);
entry.resultVersion += 1;
return true;
}
function applyOwnShellRunResult(
entry: MakaPiToolEntry,
result: Extract<ToolResultContent, { kind: 'shell_run' }>,
operationDurationMs = entry.durationMs,
): void {
entry.result = result;
if (entry.toolName === 'WriteStdin') {
entry.durationMs = operationDurationMs;
} else {
entry.durationMs = Math.max(0, (result.completedAt ?? result.updatedAt) - result.startedAt);
}
entry.resultVersion += 1;
}
function systemNoteToTranscriptEntry(
message: SystemNoteMessage,
): MakaPiTranscriptEntry | undefined {
const text = systemNoteText(message);
if (!text) return undefined;
return {
kind: 'notice',
level: message.kind === 'error' ? 'error' : 'info',
text,
};
}
function contextBudgetOutcomeNotice(
contextBudget: ContextBudgetDiagnostic | undefined,
): { level: 'info' | 'error'; text: string } | undefined {
const failedOpen = contextBudgetFailureNoticeText(contextBudget);
if (failedOpen) return { level: 'error', text: failedOpen };
const replaced = contextBudgetNoticeText(contextBudget);
if (replaced) return { level: 'info', text: replaced };
return undefined;
}
function contextBudgetNoticeText(
contextBudget: ContextBudgetDiagnostic | undefined,
): string | undefined {
const decision = contextBudget?.compactionDecisions?.find(
(candidate) => candidate.decision === 'replaced',
);
if (!contextBudget || !decision) return undefined;
const kind = decision.boundaryKind ?? 'context';
const coveredTurns = decision.coveredTurns;
const coveredEvents = decision.coveredRuntimeEvents;
const savedTokens =
decision.estimatedTokensSaved ??
tokenDelta(contextBudget.estimatedTokensBefore, contextBudget.estimatedTokensAfter);
const parts = [`Context compacted: ${kind}`];
if (coveredTurns !== undefined || coveredEvents !== undefined) {
parts.push(`${coveredTurns ?? '?'} turns / ${coveredEvents ?? '?'} events`);
}
if (savedTokens !== undefined && savedTokens > 0)
parts.push(`saved ~${Math.round(savedTokens)} tokens`);
return `${parts.join('; ')}.`;
}
function contextBudgetFailureNoticeText(
contextBudget: ContextBudgetDiagnostic | undefined,
): string | undefined {
const decision = contextBudget?.compactionDecisions?.find(
(candidate) => candidate.decision === 'failedOpen',
);
const reason = decision?.failOpenReason ?? decision?.reason;
if (!decision || !reason) return undefined;
return `Context compaction skipped: ${reason}.`;
}
function tokenDelta(before: number | undefined, after: number | undefined): number | undefined {
if (before === undefined || after === undefined) return undefined;
return Math.max(0, before - after);
}
function systemNoteText(message: SystemNoteMessage): string | undefined {
// Retired kinds are still decoded off legacy transcript rows, and none of
// them ever had a line here worth reading.
if (!isRuntimeSystemNoteKind(message.kind)) return undefined;
switch (message.kind) {
case 'context_compacted':
return 'Context compacted to keep this task within the model window.';
case 'context_compaction_failed_open':
return 'Context summary failed; the session continued without a new summary.';
case 'context_provider_dropping': {
const data = message.data as
| { inputTokens?: unknown; priorInputTokens?: unknown }
| undefined;
const used = typeof data?.inputTokens === 'number' ? data.inputTokens : undefined;
const prior = typeof data?.priorInputTokens === 'number' ? data.priorInputTokens : undefined;
if (used === undefined || prior === undefined)
return "After content was appended, the provider-reported input token count did not grow; context may have been truncated or rewritten. If this persists, check that the model's actual context capacity and the connection settings agree.";
return `After content was appended, the provider-reported input token count did not grow; context may have been truncated or rewritten (${used} tokens versus ${prior} before). If this persists, check that the model's actual context capacity and the connection settings agree.`;
}
case 'context_overflow_after_compaction':
return 'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.';
case 'context_reported_window_exceeded': {
const data = message.data as
| { usedTokens?: unknown; reportedContextWindow?: unknown }
| undefined;
const used = typeof data?.usedTokens === 'number' ? data.usedTokens : undefined;
const reported =
typeof data?.reportedContextWindow === 'number' ? data.reportedContextWindow : undefined;
if (used === undefined || reported === undefined) {
return 'This exchange ran past the context window this model reports, and the provider accepted it anyway.';
}
return `This exchange used about ${used} tokens, past the ${reported} this model reports, and the provider accepted it without complaint. Nothing is declared, so Maka does not compact on its own; declare a context window to have it compact first.`;
}
case 'context_window_overrun': {
const data = message.data as
| { usedTokens?: unknown; declaredContextWindow?: unknown }
| undefined;
const used = typeof data?.usedTokens === 'number' ? data.usedTokens : undefined;
const declared =
typeof data?.declaredContextWindow === 'number' ? data.declaredContextWindow : undefined;
if (used === undefined || declared === undefined) {
return 'This exchange ran past the context window declared for this model.';
}
return `This exchange used about ${used} tokens against the declared window of ${declared}: the reply needed more room than was left. Maka compacts before the next request; raise the window if the replies should stay whole.`;
}
case 'context_window_suggestion': {
const data = message.data as
| { suggestedContextWindow?: unknown; declaredContextWindow?: unknown }
| undefined;
const tokens =
typeof data?.suggestedContextWindow === 'number' ? data.suggestedContextWindow : undefined;
const declared =
typeof data?.declaredContextWindow === 'number' ? data.declaredContextWindow : undefined;
if (tokens === undefined) return 'The provider rejected this request as too large.';
return declared === undefined
? `The provider rejected this request. No context window is declared for this model; the last accepted request was about ${tokens} tokens — declare that as the window so Maka compacts first.`
: `The provider rejected this request at about ${tokens} tokens, below the declared window of ${declared}. The declaration is likely larger than the provider's window; consider lowering it to ${tokens}.`;
}
case 'step_limit':
return STEP_LIMIT_NOTICE_TEXT;
}
}
export function renderMakaPiTranscript(
state: MakaPiTranscriptState,
metadata: MakaPiTranscriptMetadata,
width: number,
): string[] {
const safeWidth = Math.max(1, width);
const lines: string[] = [];
// A fresh session (no history, nothing pending) opens on a welcome block so the
// first screen greets and orients instead of showing an empty pane. Once the
// first prompt lands, entries take over and it never renders again.
if (state.entries.length === 0 && !state.pendingInteraction) {
return renderWelcomeBlock(safeWidth, metadata.uiLocale ?? 'en');
}
const entryFirstLine = new Map<MakaPiTranscriptEntry, number>();
const viewportTop = state.renderGeometry.viewportTop;
let previousVisibleEntry: MakaPiTranscriptEntry | undefined;
for (let i = 0; i < state.entries.length; i += 1) {
const entry = state.entries[i]!;
if (entry.kind === 'tool' && entry.suppressed) {
entryFirstLine.set(entry, lines.length);
continue;
}
// A blank gap separates human-facing boundaries (user/assistant/thinking/
// notice) and the edges of a tool stack; only consecutive tool entries (the
// agent-work stack) have no blank line between them. Thinking reads as
// model output, so it gets the same blank-line breathing room as assistant
// text rather than packing against the tool rows.
const continuesStack = entry.kind === 'tool' && previousVisibleEntry?.kind === 'tool';
if (!continuesStack) lines.push('');
entryFirstLine.set(entry, lines.length);
// An entry that sits entirely above the live viewport is in terminal
// scrollback — freeze its rendered lines (#1135). An entry that straddles
// the boundary (first line in scrollback, tail still visible) must still
// re-render: append-only entries (assistant text, tool deltas) only change
// the visible tail, and pi-tui's `firstChanged` will be inside the
// viewport, so no full redraw is triggered. An entry with a zero-line
// cache (e.g. blank thinking) is still off-screen if its first line is
// above the viewport — it must not suddenly produce lines in scrollback.
const cachedLines = transcriptEntryRenderCache.get(entry);
const entryHeight = cachedLines?.lines.length ?? 0;
const fullyOffScreen =
lines.length < viewportTop &&
(entryHeight === 0 || lines.length + entryHeight <= viewportTop);
lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen));
previousVisibleEntry = entry;
}
state.renderGeometry.entryFirstLine = entryFirstLine;
if (state.pendingInteraction?.type === 'sandbox_boundary_request') {
lines.push('');
lines.push(...renderSandboxBoundaryPrompt(state.pendingInteraction, safeWidth));
}
return lines;
}
export function completePendingInteraction(
state: MakaPiTranscriptState,
requestId: string,
): boolean {
if (state.pendingInteraction?.requestId === requestId) {
state.pendingInteraction = state.queuedInteractions.shift();
return true;
}
const index = state.queuedInteractions.findIndex((request) => request.requestId === requestId);
if (index < 0) return false;
state.queuedInteractions.splice(index, 1);
return true;
}
export function activeSandboxBoundaryRequest(
state: MakaPiTranscriptState,
): SandboxBoundaryRequestEvent | undefined {
return state.pendingInteraction?.type === 'sandbox_boundary_request'
? state.pendingInteraction
: undefined;
}
export function activeUserQuestionRequest(
state: MakaPiTranscriptState,
): UserQuestionRequestEvent | undefined {
return state.pendingInteraction?.type === 'user_question_request'
? state.pendingInteraction
: undefined;
}
export function activeFormRequest(state: MakaPiTranscriptState): FormRequestEvent | undefined {
return state.pendingInteraction?.type === 'form_request' ? state.pendingInteraction : undefined;
}
function enqueuePendingInteraction(
state: MakaPiTranscriptState,
request: MakaPiPendingInteraction,
): void {
if (findPendingInteraction(state, request.requestId)) return;
if (!state.pendingInteraction) state.pendingInteraction = request;
else state.queuedInteractions.push(request);
}
function findPendingInteraction(
state: MakaPiTranscriptState,
requestId: string,
): MakaPiPendingInteraction | undefined {
if (state.pendingInteraction?.requestId === requestId) return state.pendingInteraction;
return state.queuedInteractions.find((request) => request.requestId === requestId);
}
function clearPendingInteractions(state: MakaPiTranscriptState): void {
state.pendingInteraction = undefined;
state.queuedInteractions = [];
}
function dropSuppressedTools(state: MakaPiTranscriptState): void {
state.entries = state.entries.filter((entry) => entry.kind !== 'tool' || !entry.suppressed);
}
/**
* Per-entry render cache. The transcript re-renders on every keystroke and
* stream delta, but only the tail entry actually changes; caching the rendered
* lines of unchanged entries avoids rebuilding a `Markdown` instance per block
* on each pass. Keyed by entry identity (a fresh entry object is a cache miss);
* the signature busts the cache when anything that affects the entry's rendered
* lines changes (its growing text, tool status, width, or an expansion toggle).
*/
interface TranscriptEntryRender {
signature: string;
lines: string[];
/** Width the cached lines were rendered at, for off-screen freeze matching. */
width: number;
}
const transcriptEntryRenderCache = new WeakMap<MakaPiTranscriptEntry, TranscriptEntryRender>();
// Returns the cached line array by reference on a hit — callers must treat it as
// read-only (copy the lines into their own buffer rather than mutating in place),
// or a later render would serve corrupted content for that entry. The only
// caller, renderMakaPiTranscript, spreads the lines into its own buffer.
function renderTranscriptEntryMemoized(
entry: MakaPiTranscriptEntry,
width: number,
offScreen: boolean,
): string[] {
// Off-screen entries live in terminal scrollback, which is immutable: any
// change to their rendered lines forces pi-tui's differential renderer into a
// scrollback-clearing full redraw (#1135). Serving the cached render keeps
// the display consistent with what's already in the terminal. The underlying
// entry state still updates — only the visual output is frozen. A width
// change already triggered a pi-tui full redraw (re-anchoring viewportTop to
// the tail), so a stale-width cache won't be served.
if (offScreen) {
const cached = transcriptEntryRenderCache.get(entry);
if (cached && cached.width === width) return cached.lines;
}
const signature = transcriptEntrySignature(entry, width);
const cached = transcriptEntryRenderCache.get(entry);
if (cached && cached.signature === signature) return cached.lines;
const lines = renderTranscriptEntryBlock(entry, width);
transcriptEntryRenderCache.set(entry, { signature, lines, width });
return lines;
}
function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number): string[] {
// Keep the conversation stream inside a one-cell gutter. The editor owns
// the full terminal width, so this makes the two surfaces align without
// changing any of the individual block renderers' internal prefixes.
const contentWidth = Math.max(1, width - 2);
const lines = (() => {
switch (entry.kind) {
case 'user':
return renderUserBlock(entry.text, contentWidth);
case 'legacy_automation':
return renderLegacyAutomationBlock(entry.text, contentWidth);
case 'goal_continuation':
return renderGoalContinuationBlock(entry.text, contentWidth);
case 'assistant':
return renderAssistantBlock(entry.text, contentWidth);
case 'thinking':
return renderThinkingBlock(entry, contentWidth, entry.expanded);
case 'tool':
return renderToolBlock(entry, contentWidth, entry.expanded);
case 'notice':
return renderNotice(entry, contentWidth);
}
})();
// Markdown preserves a final blank paragraph. It should not become part of
// the block's vertical footprint because renderMakaPiTranscript already
// inserts the single separator row between entries.
let end = lines.length;
while (end > 0 && isBlankTranscriptLine(lines[end - 1]!)) end -= 1;
return lines.slice(0, end).map((line) => {
if (isBlankTranscriptLine(line)) return '';
return fitLine(` ${line}`, width);
});
}
function isBlankTranscriptLine(line: string): boolean {
return line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').trim().length === 0;
}
function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): string {
switch (entry.kind) {
// User text is immutable, so length is a safe change key.
case 'user':
return `user|${width}|${entry.text.length}`;
case 'legacy_automation':
return `legacy_automation|${width}|${entry.text}`;
case 'goal_continuation':
return `goal_continuation|${width}|${entry.text}`;
case 'assistant':
// text_complete authoritatively replaces streamed text, including with a
// same-length final, so the full value must participate in the cache key.
return `assistant|${width}|${entry.text}`;
case 'thinking':
// Not just the length: `thinking_complete` can replace the streamed text
// in place with a same-length final, which a length-only key would miss and
// then serve stale reasoning from the cache. Key on the full text.
return `thinking|${width}|${entry.expanded ? 1 : 0}|${entry.text}`;
case 'notice':
return `notice|${width}|${entry.level}|${entry.text.length}`;
case 'tool':
// A tool entry mutates in place as it runs: its derived presentation and
// duration change, progress/output deltas append, and resultVersion
// advances whenever durable detail or a resource revision is accepted.
// Count those facts instead of duplicating the result rendering contract.
return [
'tool',
width,
entry.expanded ? 1 : 0,
makaPiToolPresentationStatus(entry),
entry.durationMs ?? '',
entry.title ?? entry.toolName,
entry.progress.version,
entry.outputDeltas.version,
entry.resultVersion,
].join('|');
}
}
/**
* The one CLI label for a permission mode, shared by the status line, the
* picker header, and the mode-change notice (#1611). `explore` is a real
* boundary a resumed session can be in, so it must be nameable here; legacy
* `execute` has no boundary of its own and reads as Auto, as does anything
* else this metadata ever carries.
*/
export function permissionModeLabel(mode: string): string {
if (mode === 'bypass') return 'Full access';
if (mode === 'explore') return 'Read only';
return 'Auto';
}
export function renderMakaPiStatusLine(metadata: MakaPiTranscriptMetadata, width: number): string {
const safeWidth = Math.max(1, width);
if (metadata.sideConversation?.view === 'side') {
return fitLine(ansi.dim(sideConversationStatusLineText(metadata.sideConversation)), safeWidth);
}
const sep = ansi.dim(' · ');
// #3421: segments carry a dropRank so overflow drops whole low-value
// segments instead of cutting the chain mid-token from the right.
// Lower ranks drop first; segments without a rank never drop:
// title, permission mode and goal are safety-relevant, ctx is the
// context budget, model is the session's identity.
const parts: MakaPiStatusLineSegment[] = [
{
text: ansi.bold(metadata.title),
compactRank: 1,
shortenedText: ansi.bold(fitLine(metadata.title, 7)),
},
{
text: ansi.dim(permissionModeLabel(metadata.permissionMode)),
compactRank: 4,
shortenedText: ansi.dim(compactPermissionModeLabel(metadata.permissionMode)),
},
{
text: ansi.dim(metadata.model),
compactRank: 0,
shortenedText: ansi.dim(fitLine(metadata.model, 11)),
},
];
if (metadata.sideConversation?.view === 'parent') {
parts.push({
text: ansi.dim(sideConversationStatusLineText(metadata.sideConversation)),
dropRank: 4,
});
}
// #1064: omit thinking:default — it is noise before the user explicitly
// changes the level. Only a non-default, explicitly set level shows.
if (metadata.thinkingLevel) {
parts.push({ text: ansi.dim(`thinking:${metadata.thinkingLevel}`), dropRank: 3 });
}
if (metadata.orchestrationMode === 'swarm') {
parts.push({ text: ansi.accent('swarm'), dropRank: 4 });
} else if (metadata.orchestrationMode === 'graph') {
parts.push({ text: ansi.accent('graph'), dropRank: 4 });
}
// An autonomous goal burns tokens between prompts; it must never be
// invisible. Terminal goals show nothing (the desktop chip hides them too).
if (metadata.goal && isLiveGoalStatus(metadata.goal.status)) {
const text = goalStatusLineText(metadata.goal, Date.now());
const compactStatus =
metadata.goal.status === 'active' ? '' : metadata.goal.status === 'paused' ? 'p' : 'w';
const compactText = `g${compactStatus}${metadata.goal.iterations}/${metadata.goal.maxIterations}`;
// paused gets warning salience: the loop stopped burning but stays armed
// and resumable, which the user must not miss. waiting is a normal
// transient between turns, so it stays dim like the other chrome.
parts.push({
text:
metadata.goal.status === 'active'
? ansi.accent(text)
: metadata.goal.status === 'paused'
? ansi.yellow(text)
: ansi.dim(text),
compactRank: 3,
shortenedText:
metadata.goal.status === 'active'
? ansi.accent(fitLine(compactText, 8))
: metadata.goal.status === 'paused'
? ansi.yellow(fitLine(compactText, 8))
: ansi.dim(fitLine(compactText, 8)),
});
}
const usage = metadata.usage;
// ctx segment: only show "used" when contextRemaining is available, since
// token_usage.input is a billing-cumulative sum across tool-loop steps,
// not the last request's context size. Using it as a proxy for "used"
// would produce misleading percentages (potentially >100%).
const contextRemaining = usage?.contextRemaining;
if (metadata.modelContextWindow !== undefined && contextRemaining !== undefined) {
const used = Math.max(0, metadata.modelContextWindow - contextRemaining);
const pct = Math.round((used / metadata.modelContextWindow) * 100);
// #1064: color warning — yellow >80%, red >95%, dim otherwise.
const ctxColor = pct > 95 ? ansi.red : pct > 80 ? ansi.yellow : ansi.dim;
parts.push({
text: ctxColor(
`ctx ${formatTokenCount(used)}/${formatTokenCount(metadata.modelContextWindow)} ${pct}%`,
),
compactRank: 2,
shortenedText: ctxColor(`c${pct}%`),
});
} else if (metadata.modelContextWindow !== undefined) {
// #3371: the window is known but no usage has arrived yet (fresh session,
// or the provider doesn't report per-step input tokens). Degrade
// explicitly, pi-style, instead of hiding the segment silently — the user
// can then tell "not measured yet" apart from "window unknown".
parts.push({
text: ansi.dim(`ctx ?/${formatTokenCount(metadata.modelContextWindow)}`),
compactRank: 2,
shortenedText: ansi.dim('c?'),
});
}
if (usage) {
if (usage.costUsd > 0) {
parts.push({ text: ansi.dim(`$${formatCost(usage.costUsd)}`), dropRank: 1 });
}
const totalCache = usage.cacheHitInput + usage.cacheMissInput;
if (totalCache > 0) {
const hitRate = Math.round((usage.cacheHitInput / totalCache) * 100);
parts.push({ text: ansi.dim(`cache ${hitRate}%`), dropRank: 0 });
}
}
parts.push({ text: ansi.dim(metadata.connectionSlug), dropRank: 2 });
// #1064: shorten cwd to ~-relative path instead of the full path.
const cwd = shortenCwd(metadata.cwd);
// cwd degrades progressively (full → basename → dropped), after every
// ranked segment above but before the final truncation fallback. A drive
// root (C:\) or filesystem root has no useful basename — empty, or the
// path itself — so it drops directly instead of rendering an empty
// segment after the separator.
const cwdBase = basename(cwd);
parts.push({
text: ansi.dim(cwd),
dropRank: 5,
shortenedText: cwdBase === '' || cwdBase === cwd ? undefined : ansi.dim(cwdBase),
});
return fitStatusLine(parts, sep, safeWidth);
}
interface MakaPiStatusLineSegment {
text: string;
/** Overflow drops whole segments lowest-rank-first; undefined never drops. */
dropRank?: number;
/** Critical segments shorten in this order after every droppable segment is gone. */
compactRank?: number;
/** Progressive fallback tried before this segment is dropped entirely. */
shortenedText?: string;
}
function fitStatusLine(segments: MakaPiStatusLineSegment[], sep: string, width: number): string {
const compactSep = ansi.dim(' ');
let activeSep = sep;
const lineWidth = (segs: MakaPiStatusLineSegment[]): number =>
visibleWidth(segs.map((segment) => segment.text).join(activeSep));
let kept = segments;
// Drop whole low-value segments, lowest rank first, re-checking after each
// rank so the fewest possible segments are sacrificed.
while (lineWidth(kept) > width) {
const dropRanks = kept.flatMap((segment) =>
segment.dropRank !== undefined ? [segment.dropRank] : [],
);
if (dropRanks.length > 0) {
const lowest = Math.min(...dropRanks);
// A droppable segment with a shortened form degrades before disappearing.
const shorten = kept.find(
(segment) => segment.dropRank === lowest && segment.shortenedText !== undefined,
);
if (shorten) {
kept = kept.map((segment) =>
segment === shorten
? { ...segment, text: segment.shortenedText ?? segment.text, shortenedText: undefined }
: segment,
);
} else {
kept = kept.filter((segment) => segment.dropRank !== lowest);
}
continue;
}
const compactRanks = kept.flatMap((segment) =>
segment.compactRank !== undefined && segment.shortenedText !== undefined
? [segment.compactRank]
: [],
);
if (compactRanks.length === 0) break;
const nextRank = Math.min(...compactRanks);
const shorten = kept.find(
(segment) => segment.compactRank === nextRank && segment.shortenedText !== undefined,
);
if (!shorten) break;
kept = kept.map((segment) =>
segment === shorten
? { ...segment, text: segment.shortenedText ?? segment.text, shortenedText: undefined }
: segment,
);
// Once critical values compact, reclaim separator chrome too. At widths
// below the compact critical seam, fitLine remains the honest fallback.
activeSep = compactSep;
}
return fitLine(kept.map((segment) => segment.text).join(activeSep), width);
}
function compactPermissionModeLabel(mode: string): string {
if (mode === 'bypass') return 'Full';
if (mode === 'explore') return 'Read';
return 'Auto';
}
function sideConversationStatusLineText(
side: NonNullable<MakaPiTranscriptMetadata['sideConversation']>,
): string {
if (side.view === 'parent') return 'Ctrl+/ for side';
const status = side.parentStatus?.replaceAll('_', ' ');
return [
'Side from main thread',
...(status ? [`main ${status}`] : []),
'Ctrl+/ to switch',
'Ctrl+C to close',
].join(' · ');
}
/**
* One-line activity strip shown between the transcript and the editor.
* Renders `Working… <elapsed>` while a turn runs, or a blank reserved row when idle
* so the layout does not jump when a turn starts or ends.
*/
export function renderMakaPiActivityStrip(
metadata: MakaPiTranscriptMetadata,
width: number,
): string {
const safeWidth = Math.max(1, width);
if (metadata.providerRetry) {
const { event: retry, receivedAtMs } = metadata.providerRetry;
const text =
retry.phase === 'scheduled'
? `Retrying in ${formatRetryCountdown(retry, receivedAtMs)} (${retry.attempt}/${retry.maxAttempts})`
: `Retrying (${retry.attempt}/${retry.maxAttempts})`;
return fitLine(ansi.dim(text), safeWidth);
}
if (metadata.turnElapsedMs === undefined) return '';
return fitLine(ansi.dim(`Working… ${formatElapsedDuration(metadata.turnElapsedMs)}`), safeWidth);
}
/**
* Remaining wait for a scheduled provider retry, ticked against the client's
* own receipt time so the strip counts down on the 1s heartbeat instead of
* pinning the original delay for the whole sleep. The computation itself is
* shared with the desktop banner in `@maka/core/provider-retry-countdown`.
* Long provider-mandated waits (a subscription quota window can be hours)
* render as `4h 28m 3s` via the shared duration formatter rather than a raw
* five-digit second count.
*/
function formatRetryCountdown(retry: ProviderRetryScheduledEvent, receivedAtMs: number): string {
const seconds = providerRetryDisplaySeconds(retry, Date.now() - receivedAtMs);
return formatElapsedDuration(seconds * 1_000);
}
function formatElapsedDuration(elapsedMs: number): string {
let remainingSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
const units = [
['d', 86_400],
['h', 3_600],
['m', 60],
] as const;
const parts: string[] = [];
for (const [suffix, secondsPerUnit] of units) {
const value = Math.floor(remainingSeconds / secondsPerUnit);
if (value > 0) {
parts.push(`${value}${suffix}`);
remainingSeconds %= secondsPerUnit;
}
}
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
return parts.join(' ');
}
/**
* Pending-queue bar shown above the editor while messages are queued. Each
* steering message reads `Steering: <text>` (injected into the running turn at
* the next step boundary); each followup reads `Queued: <text>` (opens the next
* turn). A trailing hint reminds the user that alt+↑ takes them back to edit.
* Renders nothing when both queues are empty.
*/
interface TuiPendingQueueCopy {
readonly steeringLabel: string;
readonly queuedLabel: string;
readonly requeueHint: string;
}
const TUI_PENDING_QUEUE_COPY = resolveUiMessageCatalog(
defineUiMessageCatalog<TuiPendingQueueCopy>()(TUI_COPY_RESOURCES['pending-queue']),
);
export function renderMakaPiPendingQueue(
state: MakaPiTranscriptState,
width: number,
platform: NodeJS.Platform,
locale: UiLocale,
): string[] {
if (state.steering.length === 0 && state.followup.length === 0) {
return [];
}
const copy = TUI_PENDING_QUEUE_COPY[locale];
const safeWidth = Math.max(1, width);
const steering = state.steering;
const followup = state.followup;
const lines: string[] = [];
for (const text of steering) {
lines.push(
fitLine(`${ansi.accent(copy.steeringLabel)} ${ansi.dim(firstLinePreview(text))}`, safeWidth),
);
}
for (const text of followup) {
lines.push(
fitLine(`${ansi.dim(copy.queuedLabel)} ${ansi.dim(firstLinePreview(text))}`, safeWidth),
);
}
lines.push(fitLine(ansi.dim(renderTuiShortcutCopy(copy.requeueHint, platform)), safeWidth));
return lines;
}
/**
* First non-empty line of a queued message, trimmed for a one-line preview.
*
* `limitText` appends its truncation suffix behind a newline, so its output is
* multi-line whenever the cap trips. Each element the pending-bar returns must
* occupy exactly one terminal row — pi-tui writes them between explicit \r\n
* separators and counts one row each, so an embedded newline shifts every
* later row down while the diff accounting still believes one row was written
* (#3824). `fitLine` cannot catch it: visibleWidth treats controls as
* zero-width. Sibling call sites collapse the same output the same way.
*/
function firstLinePreview(text: string): string {
const line =
text
.split('\n')
.map((part) => part.trim())
.find((part) => part.length > 0) ?? '';
return collapseToSingleLine(limitText(line, 200));
}
/**
* Shorten an absolute path to a `~`-relative form for the statusline.
* `/Users/alice/workspace/project` → `~/workspace/project`.
* Falls back to the original path if it is not under the home directory.
* Comparison runs through `path.relative`, so Windows profile paths and
* case-only differences shorten as well; the remainder keeps its native
* separators (`~/Videos\Clips` on Windows).
*/
export function shortenCwd(cwd: string, homeDir?: string): string {
const home = homeDir ?? homedir();
if (!home) return cwd;
const rel = relative(home, cwd);
if (rel === '') return '~';
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) {
return cwd;
}
return `~/${rel}`;
}
function formatCost(costUsd: number): string {
if (costUsd < 0.01) return '<0.01';
return costUsd.toFixed(2);
}
function appendAssistantText(state: MakaPiTranscriptState, messageId: string, text: string): void {
const last = state.entries[state.entries.length - 1];
if (last?.kind === 'assistant' && last.messageId === messageId) {
last.text += text;
return;
}
state.entries.push({ kind: 'assistant', messageId, text });
}
function setAssistantText(state: MakaPiTranscriptState, messageId: string, text: string): boolean {
for (let index = state.entries.length - 1; index >= 0; index -= 1) {
const entry = state.entries[index];
if (entry?.kind === 'assistant' && entry.messageId === messageId) {
entry.text = text;
return true;
}
}
return false;
}
function appendThinking(state: MakaPiTranscriptState, messageId: string, text: string): void {
const last = state.entries[state.entries.length - 1];
if (last?.kind === 'thinking' && last.messageId === messageId) {
last.text += text;
return;
}
state.entries.push({ kind: 'thinking', messageId, text, expanded: state.expandAllThinking });
}
function setThinking(state: MakaPiTranscriptState, messageId: string, text: string): void {
// thinking_complete can arrive after the reply text or tool events; replace
// the streamed entry wherever it sits instead of appending a duplicate.
for (let index = state.entries.length - 1; index >= 0; index -= 1) {
const entry = state.entries[index];
if (entry?.kind === 'thinking' && entry.messageId === messageId) {
entry.text = text;
return;
}
}
state.entries.push({ kind: 'thinking', messageId, text, expanded: state.expandAllThinking });
}
// Thinking stays collapsed to a one-line marker by default so reasoning
// never floods the scrollback; Ctrl+T expands every thinking entry on demand.
function renderThinkingBlock(
entry: MakaPiThinkingEntry,
width: number,
expanded: boolean,
): string[] {
if (!entry.text.trim()) return [];
if (!expanded) return [fitLine(ansi.dim('Thinking…'), width)];
const lines = [fitLine(ansi.dim('Thinking'), width)];
lines.push(...renderIndented(entry.text, width, 2).map((line) => fitLine(ansi.dim(line), width)));
return lines;
}
type MakaPiAssistantEntry = Extract<MakaPiTranscriptEntry, { kind: 'assistant' }>;
type MakaPiThinkingEntry = Extract<MakaPiTranscriptEntry, { kind: 'thinking' }>;
export type MakaPiToolEntry = Extract<MakaPiTranscriptEntry, { kind: 'tool' }>;
type MakaPiNoticeEntry = Extract<MakaPiTranscriptEntry, { kind: 'notice' }>;
function findToolEntry(
state: MakaPiTranscriptState,
toolUseId: string,
): MakaPiToolEntry | undefined {
return [...state.entries]
.reverse()
.find(
(entry): entry is MakaPiToolEntry => entry.kind === 'tool' && entry.toolUseId === toolUseId,
);
}
function unsuppressToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntry): void {
tool.suppressed = undefined;
const index = state.entries.indexOf(tool);
if (index < 0 || index === state.entries.length - 1) return;
state.entries.splice(index, 1);
state.entries.push(tool);
}
function isShellRunToolCard(tool: MakaPiToolEntry): boolean {
return tool.toolName === 'Bash' || tool.userOwned === true;
}
function createProgressBuffer(): BoundedChunkBuffer<string> {
return new BoundedChunkBuffer({
maxChars: LIVE_TOOL_BUFFER_MAX_CHARS,
maxChunks: LIVE_TOOL_BUFFER_MAX_CHUNKS,
textOf: (chunk) => chunk,
withText: (_chunk, text) => text,
});
}
function createOutputBuffer(): BoundedChunkBuffer<MakaPiToolOutputDelta> {
return new BoundedChunkBuffer({
maxChars: LIVE_TOOL_BUFFER_MAX_CHARS,
maxChunks: LIVE_TOOL_BUFFER_MAX_CHUNKS,
textOf: (delta) => delta.chunk,
withText: (delta, chunk) => ({ ...delta, chunk }),
sequence: (delta) => delta.seq,
});
}
function findShellRunParent(
state: MakaPiTranscriptState,
ref: string,
childToolUseId: string,
): MakaPiToolEntry | undefined {
return [...state.entries]
.reverse()
.find(
(entry): entry is MakaPiToolEntry =>
entry.kind === 'tool' &&
entry.toolName === 'Bash' &&
entry.toolUseId !== childToolUseId &&
entry.result?.kind === 'shell_run' &&
entry.result.ref === ref,
);
}
/** The runtime-resource ref a tool call is aimed at, when the args carry one. */
function readArgsRef(args: unknown): string | undefined {
const ref =
args !== null && typeof args === 'object'
? ((args as { path?: unknown }).path ?? (args as { ref?: unknown }).ref)
: undefined;
if (typeof ref !== 'string' || !ref) return undefined;
try {
return resolveReadInput({ path: ref }).path;
} catch {
return undefined;
}
}
/**
* A card whose run resource is still `running`. The transition is keyed on the
* resource status, not the presentation status: an inherited run is shown as
* `detached` while its resource keeps running, and its settle must still
* announce. Replay stays silent via the `announceSettle: false` hydration option
* and because stored replay never routes through the notice path.
*/
function isLiveShellRunCard(entry: MakaPiToolEntry | undefined): boolean {
return entry?.result?.kind === 'shell_run' && isActiveShellRunStatus(entry.result.status);
}
/**
* Apply a live result to a parent Bash card, announcing a running → settled
* transition exactly once. Shared by both poll paths (folded at tool_start and
* the tool_result fold) so a settle observed through the model's polling
* notifies the same way as the event-driven update.
*/
function applyLiveShellRunResultToParent(
state: MakaPiTranscriptState,
parent: MakaPiToolEntry,
result: Extract<ToolResultContent, { kind: 'shell_run' }>,
): void {
const wasLive = isLiveShellRunCard(parent);
applyShellRunResult(parent, result);
if (wasLive && isSettledShellRunCard(parent)) pushShellRunSettledNotice(state, parent);
}
function isSettledShellRunCard(entry: MakaPiToolEntry): boolean {
return entry.result?.kind === 'shell_run' && !isActiveShellRunStatus(entry.result.status);
}
/**
* Announce a live running → settled transition at the transcript tail: the
* card flip itself happens wherever the card sits in the scrollback, which is
* usually off-screen by the time a long task ends. Only live transitions fire
* — a run first seen settled (own result, stored replay) stays silent, so a
* settle reported twice (event + folded poll) notifies exactly once.
*/
function pushShellRunSettledNotice(state: MakaPiTranscriptState, entry: MakaPiToolEntry): void {
const result = entry.result?.kind === 'shell_run' ? entry.result : undefined;
if (!result) return;
const failed =
result.status === 'failed' || result.status === 'timed_out' || result.status === 'orphaned';
const verb =
result.status === 'completed'
? 'completed'
: result.status === 'cancelled'
? 'stopped'
: result.status === 'timed_out'
? 'timed out'
: result.status;
const parts: string[] = [];
if (result.exitCode !== undefined) parts.push(`exit ${result.exitCode}`);
const secs = Math.round((entry.durationMs ?? 0) / 1000);
if (secs >= 1) parts.push(`${secs}s`);
const suffix = parts.length > 0 ? ` (${parts.join(' · ')})` : '';
const failure =
failed && result.failureMessage ? ` — ${result.failureMessage.split('\n', 1)[0]}` : '';
state.entries.push({
kind: 'notice',
level: failed ? 'error' : 'info',
text: `Background task ${verb}: ${result.cmd.split('\n', 1)[0]}${suffix}${failure}`,
});
}
/** A user turn: a dim `>` quote prefix per line, no speaker label. */
function renderUserBlock(text: string, width: number): string[] {
if (!text.trim()) return [];
const prefix = ansi.dim('>');
// renderIndented reserves a 2-column gutter; reuse it and swap the two
// leading spaces for `> ` so wrapped lines stay aligned under the prefix.
return renderIndented(text, width, 2).map((line) => fitLine(`${prefix} ${line.slice(2)}`, width));
}
/** Provenance header + indented body for non-human-authored prompts. */
function renderProvenanceBlock(
label: string,
accent: boolean,
text: string,
width: number,
): string[] {
if (!text.trim()) return [];
const styled = accent ? ansi.accent(label) : ansi.dim(label);
return [
fitLine(styled, width),
...renderIndented(text, width, 2).map((line) => fitLine(line, width)),
];
}
function renderLegacyAutomationBlock(text: string, width: number): string[] {
return renderProvenanceBlock('Legacy Automation (history only)', false, text, width);
}
function renderGoalContinuationBlock(text: string, width: number): string[] {
return renderProvenanceBlock('Goal continuation (autonomous)', true, text, width);
}
/** An assistant turn: bare markdown prose, no speaker label or indent. */
function renderAssistantBlock(text: string, width: number): string[] {
if (!text.trim()) return [];
return new Markdown(text, 0, 0, markdownTheme, undefined, { preserveOrderedListMarkers: true })
.render(width)
.map((line) => fitLine(line, width));
}
function renderNotice(entry: MakaPiNoticeEntry, width: number): string[] {
const label = entry.level === 'error' ? ansi.red('Error') : ansi.dim('Note');
return renderIndented(`${label}: ${entry.text}`, width, 0).map((line) => fitLine(line, width));
}
// Shown on a fresh, empty session. Greets with the branded maka wordmark and a
// short tagline, then points at the command-center entry points (direct input,
// /session, /model, /setup) — enough to start without reading docs.
// Five-line lowercase ASCII maka wordmark in Maka blue (#1098, #3661). Pure ASCII so it
// renders under any locale; stored without trailing spaces so the welcome lines
// and their tests agree after rtrim. A terminal too narrow to fit it falls back
// to a single `maka` line — see renderWelcomeBlock.
const MAKA_WORDMARK_LINES = [
' _',
' _ __ ___ __ _| | ____ _',
" | '_ ` _ \\ / _` | |/ / _` |",
' | | | | | | (_| | < (_| |',
' |_| |_| |_|\\__,_|_|\\_\\__,_|',
];
const MAKA_WORDMARK_WIDTH = Math.max(...MAKA_WORDMARK_LINES.map((line) => line.length));
function renderWelcomeBlock(width: number, locale: UiLocale): string[] {
// The branded home greets with the maka wordmark, a short localized tagline,
// and the command-center entry points (direct input, /session,
// /model, /setup) so a fresh session shows the main actions without typing
// `/`. The active model and connection live in the statusline, so the
// welcome does not repeat them.
const copy = getTuiPrimaryGuidance(locale).welcome;
const hints: [string, string][] = [
['/session', copy.session],
['/model', copy.model],
['/setup', copy.setup],
];
const keyWidth = Math.max(...hints.map(([key]) => key.length));
const lines: string[] = [];
if (width < MAKA_WORDMARK_WIDTH) {
lines.push(fitLine(ansi.accent('maka'), width));
} else {
for (const line of MAKA_WORDMARK_LINES) {
lines.push(fitLine(ansi.accent(line), width));
}
}
lines.push('');
lines.push(fitLine(ansi.dim(copy.tagline), width));
lines.push('');
lines.push(fitLine(` ${copy.start}`, width));
for (const [key, description] of hints) {
lines.push(fitLine(ansi.dim(` ${key.padEnd(keyWidth)} ${description}`), width));
}
return lines;
}
function renderSandboxBoundaryPrompt(
request: SandboxBoundaryRequestEvent,
width: number,
): string[] {
const lines = [
fitLine(ansi.yellow('Allow access outside the workspace?'), width),
...renderIndented(request.justification, width, 2),
];
for (const entry of request.expansion.filesystem?.entries ?? []) {
lines.push(...renderIndented(`${entry.access} ${entry.scope} ${entry.path}`, width, 2));
}
if (request.expansion.network?.enabled) {
lines.push(...renderIndented('network enabled', width, 2));
}
lines.push(
fitLine(
`${ansi.bold('y')}${ansi.dim('/Enter allow for this task')} ${ansi.bold('n')}${ansi.dim('/Esc deny')}`,
width,
),
);
return lines;
}