blob: 70db4a52c2735e2700204047d7674eed319fb1f0 [file]
import { randomUUID } from 'node:crypto';
import { basename } from 'node:path';
import {
Key,
ProcessTerminal,
SelectList,
TUI,
isKeyRelease,
isKeyRepeat,
matchesKey,
type Component,
type OverlayHandle,
type SelectItem,
type Terminal,
} from '@earendil-works/pi-tui';
import { PERMISSION_MODES, isPermissionMode, type PermissionMode } from '@maka/core/permission';
import {
isThinkingLevel,
thinkingVariantsForModel,
type ThinkingLevel,
} from '@maka/core/model-thinking';
import { type ModelInfo, type ProviderType } from '@maka/core/llm-connections';
import type { OrchestrationMode } from '@maka/core/orchestration';
import {
projectRevisionLinkedSessionTree,
type SessionSummary,
type ShellRunUpdate,
} from '@maka/core';
import {
buildForeignSessionHandoffMessage,
foreignSessionHandoffDisplayText,
foreignSourceLabel,
type ForeignSessionSummary,
} from '@maka/core/foreign-session';
import type { ForeignSessionStore } from '@maka/storage';
import type { GoalTurnOutcome, SessionActivityLease } from '@maka/runtime';
import type { ModelChoice } from './connection-target.js';
import {
listApiKeyOnboardableProviders,
type MakaOnboardingSurface,
type OnboardingProviderEntry,
} from './onboarding.js';
import type { MakaCliSkillSurface, SessionRecapGenerator } from './runtime-bootstrap.js';
import { AUTO_RECAP_DISPLAY_LIMIT_BYTES, shouldAutoRecap } from './session-recap.js';
import {
listInvocableSkills,
prepareSkillInvocationMessage,
type InvocableSkillEntry,
} from '@maka/runtime';
import { MakaSkillHighlightEditor } from './skill-highlight-editor.js';
import { parseSkillInvocationTokens } from './skill-token.js';
import {
parseGraphCommand,
parseSwarmCommand,
type ParsedGraphCommand,
type ParsedSwarmCommand,
} from '@maka/core';
import type { CliGoalTurnHost } from './cli-goal-continuation.js';
import {
inspectSessionResumeAvailability,
type MakaSessionDriver,
type MakaSessionSwitchResult,
} from './session-driver.js';
import {
appendTurnFailureToTranscript,
appendUserPrompt,
applyMakaSessionEventToTranscript,
createMakaPiTranscriptState,
activePermissionRequest,
activeUserQuestionRequest,
completePendingInteraction,
applyShellRunViewUpdateToTranscript,
replaceTranscriptWithStoredMessages,
submitCompactToTranscript,
toggleAllThinkingExpansion,
toggleAllToolExpansion,
togglePendingPermissionDetails,
type MakaPiTranscriptMetadata,
} from './pi-transcript.js';
import {
runMakaPiTuiTurn,
type MakaPiTuiTurnLifecycle,
type MakaPiTuiTurnRequest,
} from './pi-tui-turn.js';
import { editorTheme, selectListTheme } from './tui-ansi.js';
import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js';
import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js';
import { createShellRunHydrationController } from './shell-run-hydration.js';
import {
AttentionController,
DISABLE_FOCUS_REPORTING,
ENABLE_FOCUS_REPORTING,
FOCUS_IN_SEQUENCE,
FOCUS_OUT_SEQUENCE,
} from './tui-attention.js';
import {
MakaActivityStripComponent,
MakaPendingQueueComponent,
MakaPiLayoutComponent,
MakaStatusLineComponent,
MakaTranscriptComponent,
} from './pi-tui-layout.js';
import {
MakaAutocompleteProvider,
DirectoryPickerOverlay,
ModelSearchOverlay,
OnboardingWizard,
PickerOverlay,
UserQuestionOverlay,
modelPickerItems,
permissionModePickerItems,
skillPickerItems,
thinkingLevelPickerItems,
type MakaSlashCommand,
} from './pi-tui-pickers.js';
export interface MakaPiTuiGoalLifecycle extends MakaPiTuiTurnLifecycle {
bindHost: (host: CliGoalTurnHost) => () => void;
}
export interface MakaPiTuiInput {
title: string;
driver: MakaSessionDriver;
cwd: string;
model: string;
models?: readonly string[];
/**
* Every selectable model across all ready connections. When present, `/model`
* lists these (grouped by connection) and selecting one rebinds the session to
* that connection + model. Falls back to `models` (current connection only)
* when absent.
*/
modelChoices?: readonly ModelChoice[];
connectionSlug: string;
providerType?: ProviderType;
permissionMode: PermissionMode;
/** Maximum context tokens for the active model, for the statusline ctx segment. */
modelContextWindow?: number;
terminal?: Terminal;
/** Starts the CLI process-exit deadline after terminal restore, before outer cleanup. */
onProcessExit?: (exitCode: number, error?: Error) => void;
/**
* How long a prompt turn must run before its completion rings the terminal
* BEL when unfocused. Injectable so tests exercise the long / short split
* without waiting real seconds; defaults to the attention layer's own value.
*/
attentionLongTurnThresholdMs?: number;
subscribeSessionTitleChanges?: (listener: (sessionId: string) => void) => () => void;
subscribeShellRunUpdates?: (listener: (update: ShellRunUpdate) => void) => () => void;
listShellRunUpdates?: (sessionId: string) => Promise<ShellRunUpdate[]>;
/**
* Explicit skill invocation surface (issue #1148). When present, `/skill:<name>`
* tokens are highlighted in the editor, completed by autocomplete, listed by
* `/skill`, and resolved + injected by the CLI at submit time. Omitting it
* disables the whole feature (tests, minimal hosts).
*/
skills?: MakaCliSkillSurface;
/** Mandatory turn ownership shared with CLI Automation and Goal continuation. */
goalLifecycle: MakaPiTuiGoalLifecycle;
/** API-key onboarding surface (#1098). When present, /setup runs the wizard,
* whose listProviders/verify/save calls persist the connection + curated models
* via the host-owned stores. */
onboarding?: MakaOnboardingSurface;
/** First-run mode: auto-open the onboarding wizard on launch instead of
* waiting for /setup (used when the CLI starts with no configured connection). */
firstRun?: boolean;
/**
* One-sentence session recap generator (issue #1055). Powers `/recap` and
* the idle-return auto-recap. Omitting it disables both — `/recap` reports
* unavailability and no auto-recap is ever scheduled.
*/
recap?: SessionRecapGenerator;
/**
* When present, the runner switches onto this session as its first action
* (before entering the interactive loop), reusing the same `switchSession`
* path as `/session <id>`. A failed switch (missing session, stale cwd)
* surfaces as a transcript notice and the runner falls back to the fresh
* session the driver was created with.
*/
resumeSessionId?: string;
/**
* Read-only store of sessions from other coding agents (Claude Code,
* Codex). When present, the session picker lists foreign sessions for the
* current cwd; selecting one distills it into a handoff digest and opens a
* fresh Maka session seeded with it. Omitting it hides the feature.
*/
foreignSessions?: ForeignSessionStore;
}
export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
const terminal = input.terminal ?? new ProcessTerminal();
const tui = new TUI(terminal);
const state = createMakaPiTranscriptState();
let cwd = input.cwd;
let model = input.model;
let connectionSlug = input.connectionSlug;
// Mutable: a cross-connection /model switch rebinds the provider, which changes
// both the connection and the thinking variants the new model supports.
let providerType = input.providerType;
let modelContextWindow = input.modelContextWindow;
let permissionMode = input.permissionMode;
let orchestrationMode = input.driver.getOrchestrationMode?.() ?? 'default';
let thinkingLevel: ThinkingLevel | undefined = undefined;
let thinkingLevels: readonly ThinkingLevel[] = providerType
? thinkingVariantsForModel(providerType, input.model)
: [];
let sessionListScope: 'current' | 'all' = 'current';
let busy = false;
let closed = false;
let currentActivityCompletion: Promise<void> | undefined;
let permissionResponseInFlightRequestId: string | null = null;
// Session recap (issue #1055): an in-flight lock shared by manual and
// automatic recap calls, an activity clock for idle-return detection, a
// watermark so auto-recap fires at most once per newly reached main turn,
// and a sequence counter bumped once per submitted prompt so an idle recap
// can detect it was superseded by a later prompt while it was generating.
let recapInFlight = false;
let lastActivityAt = Date.now();
// Session-scoped watermark: null (or a stale sessionId) is equivalent to a
// fresh session that has never had a recap (count 0). Prevents a recap
// triggered in session A from suppressing the first eligible recap in a
// later session B that happens to reach the same main-turn count.
let recapWatermark: { sessionId: string; mainTurnCount: number } | null = null;
let promptSeq = 0;
const beginActivity = () => {
let finish!: () => void;
const completion = new Promise<void>((resolve) => {
finish = resolve;
});
currentActivityCompletion = completion;
let finished = false;
return {
finish: () => {
if (finished) return;
finished = true;
if (currentActivityCompletion === completion) currentActivityCompletion = undefined;
finish();
},
};
};
let userQuestionInFlight = false;
let userQuestionOverlay: OverlayHandle | undefined;
let userQuestionProgress:
| {
requestId: string;
index: number;
answers: Array<string | null>;
}
| undefined;
let turnRunning = false;
let turnStartedAt: number | undefined;
let interruptRequested = false;
let lastTurnEscapeAt = 0;
let lastIdleEscapeAt = 0;
let lastIdleCtrlCAt = 0;
let unbindGoalHost: (() => void) | undefined;
let resolveClosed: () => void;
let rejectClosed: (error: Error) => void;
const closedPromise = new Promise<void>((resolve, reject) => {
resolveClosed = resolve;
rejectClosed = reject;
});
const metadata = (): MakaPiTranscriptMetadata => ({
title: input.title,
cwd,
model,
connectionSlug,
permissionMode,
orchestrationMode,
thinkingLevel,
thinkingLevels,
sessionId: input.driver.getSessionId(),
busy,
usage: state.usage,
modelContextWindow,
turnElapsedMs: turnStartedAt !== undefined ? Date.now() - turnStartedAt : undefined,
providerRetry: state.providerRetry,
});
const transcript = new MakaTranscriptComponent(state, metadata);
const activityStrip = new MakaActivityStripComponent(metadata);
const pendingQueue = new MakaPendingQueueComponent(state);
const statusLine = new MakaStatusLineComponent(metadata);
// Show the whole slash-command set at once — discoverability is the point of
// the menu. Keep a little headroom above the current command count.
const editor = new MakaSkillHighlightEditor(tui, editorTheme(), {
paddingX: 1,
autocompleteMaxVisible: EDITOR_AUTOCOMPLETE_MAX_VISIBLE,
});
let refreshEditorCwd: ((cwd: string) => void) | undefined;
const editorSurface = new MakaAutocompleteAboveEditorComponent(editor);
const layout = new MakaPiLayoutComponent(
state,
transcript,
activityStrip,
pendingQueue,
editorSurface,
statusLine,
terminal,
);
const attention = new AttentionController(terminal, {
baseTitle: input.title,
...(input.attentionLongTurnThresholdMs !== undefined
? { longTurnThresholdMs: input.attentionLongTurnThresholdMs }
: {}),
});
let sessionTitleVersion = 0;
const setSessionTitle = (title: string) => {
sessionTitleVersion += 1;
attention.setBaseTitle(`${title} (${input.title})`);
};
const requestRender = () => {
transcript.invalidate();
tui.requestRender();
};
const unsubscribeSessionTitleChanges =
input.subscribeSessionTitleChanges?.((sessionId) => {
const refreshVersion = ++sessionTitleVersion;
void input.driver
.listSessions()
.then((sessions) => {
if (
closed ||
input.driver.getSessionId() !== sessionId ||
sessionTitleVersion !== refreshVersion
)
return;
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session) return;
setSessionTitle(session.name);
})
.catch(() => {});
}) ?? (() => {});
const shellRunElapsedTicker = createShellRunElapsedTicker({
state,
onTick: requestRender,
});
// ── Explicit skill invocation (#1148) ────────────────────────────────────
// One cached list feeds autocomplete, the `/skill` picker, and the editor's
// sync highlight validator. The cache is keyed by cwd (project-level skill
// paths move with it) and short-lived; submit-time injection never uses it —
// it does an authoritative scan via prepareSkillInvocation.
const SKILL_LIST_CACHE_MS = 5_000;
let skillListCache: { cacheCwd: string; at: number; entries: InvocableSkillEntry[] } | undefined;
const listSkillsCached = async (
forceRefresh = false,
): Promise<readonly InvocableSkillEntry[]> => {
if (!input.skills) return [];
if (
!forceRefresh &&
skillListCache &&
skillListCache.cacheCwd === cwd &&
Date.now() - skillListCache.at < SKILL_LIST_CACHE_MS
) {
return skillListCache.entries;
}
try {
const entries = await listInvocableSkills(input.skills.source(cwd), input.skills.host);
skillListCache = { cacheCwd: cwd, at: Date.now(), entries };
// The highlight validator must be sync and cheap (one lookup per token
// per render): a flat Set over lowercase ids AND display names, since a
// token resolves by either.
const invocable = new Set<string>();
for (const entry of entries) {
invocable.add(entry.id.toLowerCase());
invocable.add(entry.name.toLowerCase());
}
editor.setSkillTokenValidator((name) => invocable.has(name.toLowerCase()));
requestRender();
return entries;
} catch {
// Listing is best-effort: autocomplete/picker/highlight degrade to
// nothing, and submit-time resolution does its own authoritative scan.
return skillListCache?.cacheCwd === cwd ? skillListCache.entries : [];
}
};
// Warm the highlight validator so tokens light up before the first
// autocomplete or picker open.
void listSkillsCached(true);
const SKILL_INVOCATION_FAILURE_REASON_LABEL: Record<string, string> = {
not_found: '未找到',
disabled: '已禁用',
host_incompatible: '当前主机缺少其依赖的工具',
invalid_name: '名称无效',
too_many_requests: '调用请求过多',
};
interface PreparedSkillPrompt {
disposition: 'passthrough' | 'ready' | 'blocked';
sendText?: string;
loadedNames: string[];
warnings: string[];
}
// Resolve `/skill:<name>` tokens through the shared Runtime contract. Failed
// invocation tokens never reach the model; when all requests fail, Runtime
// returns a bounded receipt and the TUI does not create a provider turn.
const prepareSkillInvocation = async (prompt: string): Promise<PreparedSkillPrompt> => {
if (!input.skills) {
return { disposition: 'passthrough', sendText: prompt, loadedNames: [], warnings: [] };
}
const prepared = await prepareSkillInvocationMessage({
text: prompt,
source: input.skills.source(cwd),
host: input.skills.host,
});
const failed = prepared.skillInvocation.failed;
const failedLabels = failed.map((entry) =>
entry.reason === 'too_many_requests'
? `请求超过 ${entry.requestLimit} 个上限(${SKILL_INVOCATION_FAILURE_REASON_LABEL[entry.reason]})`
: `/skill:${entry.request}(${SKILL_INVOCATION_FAILURE_REASON_LABEL[entry.reason] ?? entry.reason})`,
);
const warnings =
failed.length > 0
? [
`未能加载技能 ${failedLabels.join('、')};${
prepared.disposition === 'blocked'
? '未发起模型请求。'
: '失败的调用标记未发送给模型。'
}`,
]
: [];
return {
disposition: prepared.disposition,
...('sendText' in prepared ? { sendText: prepared.sendText } : {}),
loadedNames: prepared.skillInvocation.loaded.map((skill) => skill.name),
warnings,
};
};
// 1-second heartbeat that re-renders the activity strip's elapsed counter
// while a turn runs. Stopped on turn end and disposed on teardown.
let turnElapsedInterval: ReturnType<typeof setInterval> | undefined;
const startTurnElapsedTicker = () => {
if (turnElapsedInterval) return;
turnElapsedInterval = setInterval(() => requestRender(), 1_000);
turnElapsedInterval.unref();
};
const stopTurnElapsedTicker = () => {
if (turnElapsedInterval) {
clearInterval(turnElapsedInterval);
turnElapsedInterval = undefined;
}
};
const shellRunHydration = createShellRunHydrationController({
driver: input.driver,
applyToTranscript: (update, options) =>
applyShellRunViewUpdateToTranscript(state, update, options),
listShellRunUpdates: input.listShellRunUpdates,
subscribeShellRunUpdates: input.subscribeShellRunUpdates,
onViewChanged: () => {
shellRunElapsedTicker.sync();
requestRender();
},
isClosed: () => closed,
});
const reportError = (error: unknown) => {
state.entries.push({
kind: 'notice',
level: 'error',
text: error instanceof Error ? error.message : String(error),
});
// An error is worth pulling the user back to a background tab.
attention.attentionNeeded();
requestRender();
};
// Control commands (model/session/permission switches) mutate session state.
// Run them through a single serial lock so a prompt submitted mid-switch can
// not race the switch and land on the old session/model/permission mode.
const runControl = async (action: () => Promise<void>): Promise<void> => {
// Refuse nested control actions: an overlay onSelect bypasses editor.onSubmit,
// so without this guard a switch could start while a prompt is still running.
if (busy) return;
busy = true;
const activity = beginActivity();
editor.disableSubmit = true;
terminal.setProgress(true);
attention.controlStarted();
requestRender();
let sessionActivity: SessionActivityLease | undefined;
try {
const sessionId = input.driver.getSessionId();
if (sessionId) sessionActivity = await input.goalLifecycle.activities.acquire(sessionId);
if (closed) return;
await action();
} catch (error) {
reportError(error);
} finally {
sessionActivity?.release();
busy = false;
activity.finish();
editor.disableSubmit = false;
terminal.setProgress(false);
attention.controlEnded();
requestRender();
}
};
const removeProcessHandlers = () => {
process.off('SIGINT', handleSigint);
process.off('SIGTERM', handleSigterm);
process.off('SIGHUP', handleSighup);
process.off('uncaughtException', handleUncaughtException);
process.off('unhandledRejection', handleUnhandledRejection);
};
const restoreTerminal = () => {
removeProcessHandlers();
unbindGoalHost?.();
unbindGoalHost = undefined;
unsubscribeSessionTitleChanges();
shellRunHydration.dispose();
shellRunElapsedTicker.dispose();
stopTurnElapsedTicker();
stopFallbackRetry();
terminal.setProgress(false);
// Drop the busy / attention title marker so the tab is not handed back to
// the shell still marked busy when the session exits.
attention.reset();
// Stop asking the terminal for focus reports before handing it back.
terminal.write(DISABLE_FOCUS_REPORTING);
tui.stop();
};
const beginClose = (error?: Error) => {
if (closed) return;
closed = true;
restoreTerminal();
if (error) rejectClosed(error);
else resolveClosed();
// Runtime stop is best-effort after the shell has its terminal back. A
// double-Escape/Ctrl-C interrupt may already have one in flight; reuse it.
if (!interruptRequested) void input.driver.stop().catch(() => {});
};
const handleProcessExit = (exitCode: number, error?: Error): void => {
process.exitCode = exitCode;
beginClose(input.onProcessExit ? undefined : error);
input.onProcessExit?.(exitCode, error);
};
const beginGracefulClose = () => beginClose();
function handleSigint(): void {
handleProcessExit(128 + 2);
}
function handleSigterm(): void {
handleProcessExit(128 + 15);
}
function handleSighup(): void {
handleProcessExit(128 + 1);
}
function handleUncaughtException(error: Error): void {
handleProcessExit(1, error);
}
function handleUnhandledRejection(reason: unknown): void {
handleProcessExit(1, reason instanceof Error ? reason : new Error(String(reason)));
}
process.once('SIGINT', handleSigint);
process.once('SIGTERM', handleSigterm);
process.once('SIGHUP', handleSighup);
process.once('uncaughtException', handleUncaughtException);
process.once('unhandledRejection', handleUnhandledRejection);
const respondToPendingPermission = (
decision: 'allow' | 'deny',
rememberForTurn = false,
): boolean => {
const request = activePermissionRequest(state);
if (!request || permissionResponseInFlightRequestId !== null) return false;
permissionResponseInFlightRequestId = request.requestId;
// Keep the prompt visible until the driver accepts the response. If it
// rejects, the user can retry with y/n instead of being stuck. A resolved
// call only means the response was submitted; the event stream owns dequeue.
void input.driver
.respondToPermission({
requestId: request.requestId,
decision,
...(decision === 'allow' && request.rememberForTurnAllowed ? { rememberForTurn } : {}),
})
.catch((error) => {
if (permissionResponseInFlightRequestId === request.requestId) {
permissionResponseInFlightRequestId = null;
}
reportError(error);
});
return true;
};
// Refill the editor from a retract result, prepended to any current draft.
// Shared by the interrupt path and the alt+↑ path. The text always comes
// from `driver.retractQueued()` — a synchronous in-process read of the
// runtime's authoritative queues — never from the render mirror, which can
// lag a step-boundary consumption and would resurrect an already-consumed
// steering message for a double execution. Clears the local mirror.
const refillEditorFromQueues = (joined: string) => {
state.steering = [];
state.followup = [];
if (!joined) return;
const draft = editor.getText();
editor.setText(draft ? `${joined}\n\n${draft}` : joined);
};
const requestTurnInterrupt = () => {
if (interruptRequested) return;
interruptRequested = true;
// The convergence window (stop issued, turn not yet terminal) accepts no
// new input: submits would race the abort and could open work the user
// just cancelled. The normal turn finally restores submit; a rejected
// stop restores it here.
editor.disableSubmit = true;
// Retract synchronously from the authoritative queue before stop() clears
// it: only messages still queued come back for re-editing; anything the
// turn already consumed stays consumed (it is in the transcript/ledger).
// CLI-held fallback texts (never reached the runtime) come back too.
refillEditorFromQueues(
[takePendingFallback(), input.driver.retractQueued?.() ?? ''].filter(Boolean).join('\n\n'),
);
requestRender();
void input.driver.stop().catch((error) => {
interruptRequested = false;
editor.disableSubmit = false;
reportError(error);
});
};
// Open a fresh turn from a submitted prompt (idle path). Control actions hold
// `busy`, so a prompt typed mid-switch is ignored rather than racing it.
const submitPrompt = (prompt: string) => {
if (busy || !prompt.trim()) {
requestRender();
return;
}
if (isExitPrompt(prompt)) {
beginGracefulClose();
return;
}
// Captured BEFORE lastActivityAt is refreshed, so the idle gap measures up
// to (not including) this very submission.
const idleMs = Date.now() - lastActivityAt;
editor.addToHistory(prompt);
if (handleSlashCommand(prompt, idleMs)) return;
// First-run has no connection, so the wizard is the only surface. This is
// the single choke point for idle submits (Enter, Alt+Enter, steer
// fallback): reopen the wizard instead of opening a turn against a
// connection-less driver. Slash commands above already routed to the
// command layer (/exit still exits, /help still shows help).
if (input.firstRun) {
void showSetupWizard();
return;
}
// Refreshed only for a prompt that actually opens a turn: a slash command
// (e.g. /help) typed on the way back from idle must not consume the idle
// gap the next real prompt is measuring.
lastActivityAt = Date.now();
// This prompt is about to open a turn, so it counts toward the sequence
// an in-flight idle recap is watching — including when this very prompt
// is the idle-return submission that triggers the recap below.
promptSeq += 1;
maybeTriggerAutoRecap(idleMs);
if (!input.skills || parseSkillInvocationTokens(prompt).length === 0) {
void runAgentTurn({
kind: 'external',
prompt,
sessionId: input.driver.getSessionId(),
});
return;
}
void submitPreparedUserPrompt(prompt);
};
// Resolve skill-invocation tokens, then open the turn. Hold both `busy` and
// `editor.disableSubmit` for the async prep window: pi-tui clears the draft
// before onSubmit, so a second Enter during prep must not be accepted (it
// would be dropped by the busy guard with the draft already gone).
// runAgentTurn re-asserts busy for the turn itself and re-enables submit so
// mid-turn Enter can still steer.
const submitPreparedUserPrompt = async (prompt: string) => {
busy = true;
const preparationActivity = beginActivity();
editor.disableSubmit = true;
let handedOff = false;
try {
const prepared = await prepareSkillInvocation(prompt);
// Prep is async (skill scan). If the TUI closed mid-scan (double Ctrl-C /
// SIGTERM), do not open a turn after the shell is gone.
if (closed) return;
for (const warning of prepared.warnings) {
state.entries.push({ kind: 'notice', level: 'info', text: warning });
}
if (prepared.loadedNames.length > 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: `已加载技能:${prepared.loadedNames.join('、')}`,
});
}
if (prepared.disposition === 'blocked') return;
// Hand off to the turn: runAgentTurn re-asserts busy and re-enables
// submit so mid-turn Enter can steer. Clearing disableSubmit only there
// keeps the prep window closed until the turn owns the flags.
void runAgentTurn({
kind: 'external',
prompt,
sessionId: input.driver.getSessionId(),
...(prepared.sendText !== undefined && prepared.sendText !== prompt
? { sendText: prepared.sendText }
: {}),
});
handedOff = true;
} catch (error) {
if (closed) return;
reportError(error);
} finally {
if (!handedOff) {
busy = false;
editor.disableSubmit = false;
requestRender();
}
// A successful handoff already installed the turn's activity as current;
// releasing preparation now wakes observers into that new busy period.
preparationActivity.finish();
}
};
// Fallback handoff owner. A `fallback` outcome while the turn is running
// means the runtime has no live steering owner YET (the begin window) or
// just lost it; the runtime keeps no record of the text, so the CLI owns
// delivery: retry the SAME enqueue until the owner appears, and flush any
// remainder into the next turn at the turn boundary. Never a bounded wait —
// a normal turn outlives any fixed budget and the text must not vanish.
const FALLBACK_RETRY_MS = 100;
let fallbackRetryTimer: ReturnType<typeof setInterval> | null = null;
const stopFallbackRetry = () => {
if (fallbackRetryTimer === null) return;
clearInterval(fallbackRetryTimer);
fallbackRetryTimer = null;
};
const retryPendingFallback = () => {
if (closed || !turnRunning || state.pendingFallback.length === 0) {
stopFallbackRetry();
return;
}
const remaining: typeof state.pendingFallback = [];
for (const entry of state.pendingFallback) {
const outcome =
entry.enqueue === 'steer'
? input.driver.steer?.(entry.text)
: input.driver.queueMessage?.(entry.text);
if (outcome?.kind !== 'queued') remaining.push(entry);
}
if (remaining.length === state.pendingFallback.length) return;
state.pendingFallback = remaining;
if (remaining.length === 0) stopFallbackRetry();
// The queue mirror updates only from `queue_update` events (single path);
// this render just drops the delivered entries from the fallback list.
requestRender();
};
const deferFallback = (text: string, enqueue: 'steer' | 'queue') => {
state.pendingFallback.push({ text, enqueue });
fallbackRetryTimer ??= setInterval(retryPendingFallback, FALLBACK_RETRY_MS);
requestRender();
};
/** Drain the CLI-held fallback texts (delivery order), stopping the retry loop. */
const takePendingFallback = (): string => {
stopFallbackRetry();
if (state.pendingFallback.length === 0) return '';
const joined = state.pendingFallback.map((entry) => entry.text).join('\n\n');
state.pendingFallback = [];
return joined;
};
// Enter during a turn steers it (inject at the next step boundary); the
// runtime falls back to a fresh turn if the run already ended.
const steerRunningTurn = (text: string) => {
if (!text.trim()) {
requestRender();
return;
}
editor.addToHistory(text);
const outcome = input.driver.steer?.(text);
if (!outcome || outcome.kind === 'fallback') {
if (turnRunning) deferFallback(text, 'steer');
else submitPrompt(text);
return;
}
// Queued: the runtime's `queue_update` event refreshes the mirror.
requestRender();
};
// Alt+Enter: during a turn, queue the text to open the next turn; when idle,
// it submits like Enter.
const handleAltEnter = () => {
// Mirror Enter's control-busy guard BEFORE touching the editor: during a
// control action (busy without a running turn) submitPrompt would drop the
// prompt, so keep the draft in place instead of clearing it into the void.
if (busy && !turnRunning) return;
// Interrupt convergence window: the turn is being stopped, so nothing may
// be queued onto it and no fresh turn may open — keep the draft.
if (interruptRequested) return;
const text = editor.getExpandedText().trim();
if (!text) return;
editor.setText('');
if (!turnRunning) {
submitPrompt(text);
return;
}
editor.addToHistory(text);
const outcome = input.driver.queueMessage?.(text);
if (!outcome || outcome.kind === 'fallback') {
if (turnRunning) deferFallback(text, 'queue');
else submitPrompt(text);
return;
}
// Queued: the runtime's `queue_update` event refreshes the mirror.
requestRender();
};
// Alt+↑: take back every queued message (both queues plus CLI-held fallback
// texts), joined and prepended to the current draft for re-editing.
const retractQueuedMessages = () => {
refillEditorFromQueues(
[takePendingFallback(), input.driver.retractQueued?.() ?? ''].filter(Boolean).join('\n\n'),
);
requestRender();
};
// Onboarding wizard (#1098 UX redesign): one overlay spans provider search
// → API key → model curation, keeping every prompt/verifying/failure/saving/
// success notice beside the input field instead of the transcript entry flow.
let wizardOverlay: OverlayHandle | undefined;
let wizard: OnboardingWizard | undefined;
let wizardProviderType: ProviderType | undefined;
// The user's supplied key from the key step ('' reuses the stored secret for an
// existing connection) and the models from the last verify (cached on save).
// The runner holds them so the wizard stays UI-only; the secret never crosses
// back into the wizard.
let wizardApiKey = '';
let wizardModels: readonly ModelInfo[] = [];
// Authoritative ready model choices for `/model`. A startup snapshot refreshed
// in place after `/setup` saves so newly configured models are immediately
// available — the single source the picker and connection/model lookups read.
let modelChoices = input.modelChoices;
// Monotonic attempt id: each setup submit captures one, and any transition
// that abandons the in-flight attempt (back, re-pick, close) increments it so
// a late verify/save settlement cannot clobber a newer attempt.
let wizardAttempt = 0;
editor.onSubmit = (prompt) => {
if (turnRunning) {
// A quit/exit form typed while a turn is running must close the TUI, not
// steer it into the model as prompt text (review finding on turnRunning
// input routing): check it before handing off to steering.
if (isExitPrompt(prompt)) {
beginGracefulClose();
return;
}
const swarmCommand = parseSwarmCommand(prompt);
if (swarmCommand) {
editor.addToHistory(prompt);
if (swarmCommand.kind === 'status') {
showSwarmStatus();
} else {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Cannot change or start Swarm Mode while a turn is running.',
});
requestRender();
}
return;
}
const graphCommand = parseGraphCommand(prompt);
if (graphCommand) {
editor.addToHistory(prompt);
if (graphCommand.kind === 'status') {
showGraphStatus();
} else {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Cannot change or start Graph Mode while a turn is running.',
});
requestRender();
}
return;
}
steerRunningTurn(prompt);
return;
}
submitPrompt(prompt);
};
// Runs one agent turn through the shared activity/drain lifecycle. Shared by
// user submits, queued follow-ups, and coordinator-owned goal injections.
function runAgentTurn(request: MakaPiTuiTurnRequest): Promise<GoalTurnOutcome> {
busy = true;
const activity = beginActivity();
turnRunning = true;
turnStartedAt = Date.now();
startTurnElapsedTicker();
interruptRequested = false;
lastTurnEscapeAt = 0;
// Re-enable submit after skill-prep's disableSubmit hold: Enter must steer
// a running turn (see editor.onSubmit) instead of being swallowed.
editor.disableSubmit = false;
terminal.setProgress(true);
attention.promptTurnStarted();
requestRender();
let permissionAlerted = false;
const finishTurnUi = () => {
turnRunning = false;
turnStartedAt = undefined;
stopTurnElapsedTicker();
interruptRequested = false;
editor.disableSubmit = false;
terminal.setProgress(false);
attention.promptTurnEnded();
// A turn ending is activity too — resets the idle clock the next
// submission's auto-recap check measures against.
lastActivityAt = Date.now();
};
return runMakaPiTuiTurn({
driver: input.driver,
lifecycle: input.goalLifecycle,
request,
shouldAbort: () => closed || interruptRequested,
onStart: () => {
appendUserPrompt(state, request.prompt);
requestRender();
},
onEvent: (event) => {
applyMakaSessionEventToTranscript(state, event);
if (event.type === 'error') attention.attentionNeeded();
if (
permissionResponseInFlightRequestId !== null &&
activePermissionRequest(state)?.requestId !== permissionResponseInFlightRequestId
) {
permissionResponseInFlightRequestId = null;
}
// A pending decision blocks the turn; ring an unfocused terminal once when
// the prompt first appears (not on every render) so the user is not left
// waiting on a prompt they cannot see.
if (state.pendingInteraction) {
if (!permissionAlerted) {
permissionAlerted = true;
attention.attentionNeeded();
}
} else {
permissionAlerted = false;
}
shellRunElapsedTicker.sync();
syncUserQuestionOverlay();
requestRender();
},
// A turn failing is worth pulling the user back, regardless of how long it
// ran — a quick failure in a background tab would otherwise stay silent.
onFailure: (error) => {
appendTurnFailureToTranscript(state, error);
attention.attentionNeeded();
shellRunElapsedTicker.sync();
syncUserQuestionOverlay();
requestRender();
},
}).then(
(outcome) => {
finishTurnUi();
if (closed) {
busy = false;
activity.finish();
return outcome;
}
// Turn boundary flush: CLI-held fallback texts that never reached the
// runtime (the enqueue retry never found a live owner) are delivered
// FIRST, then queued followups (alt+Enter) — both open the next turn
// before any goal auto-continuation. Consumed here outside the turn
// stream, so clear the local mirror explicitly.
const fallbackText = takePendingFallback();
const followup = input.driver.takePendingFollowup?.();
const nextPrompt = [fallbackText, followup ?? ''].filter(Boolean).join('\n\n');
if (nextPrompt) {
state.steering = [];
state.followup = [];
if (outcome.kind !== 'completed') {
// The turn was aborted or errored: auto-opening a turn would defeat
// the interrupt (or hammer a failure). Keep the undelivered text as
// an editable draft instead, merged ahead of any current draft.
refillEditorFromQueues(nextPrompt);
} else {
// Install the next local activity before resolving the previous one.
// A Goal admission woken by the old activity therefore observes the
// user follow-up as busy instead of racing it for the session.
void runAgentTurn({
kind: 'external',
prompt: nextPrompt,
sessionId: input.driver.getSessionId(),
});
activity.finish();
return outcome;
}
}
busy = false;
activity.finish();
requestRender();
return outcome;
},
(error) => {
finishTurnUi();
busy = false;
activity.finish();
requestRender();
throw error;
},
);
}
try {
unbindGoalHost = input.goalLifecycle.bindHost({
admitTurn: (sessionId, text) => {
if (input.driver.getSessionId() !== sessionId) {
return { kind: 'unavailable', reason: 'TUI is attached to a different session.' };
}
if (busy) {
return { kind: 'busy', whenIdle: currentActivityCompletion! };
}
const sessionActivity = input.goalLifecycle.activities.reserveIfIdle(sessionId)!;
const turnId = randomUUID();
return {
kind: 'prepared',
turnId,
start: () => {
try {
return runAgentTurn({
kind: 'coordinator',
prompt: text,
turnId,
activity: sessionActivity,
});
} catch (error) {
sessionActivity.release();
throw error;
}
},
};
},
});
} catch (error) {
beginClose(error instanceof Error ? error : new Error(String(error)));
return closedPromise;
}
const setModel = async (nextModel: string) => {
await input.driver.setModel(nextModel);
model = nextModel;
const match = modelChoices?.find((choice) => choice.model === nextModel);
if (match) modelContextWindow = match.contextWindow;
thinkingLevel = undefined;
thinkingLevels = providerType ? thinkingVariantsForModel(providerType, nextModel) : [];
state.entries.push({
kind: 'notice',
level: 'info',
text: `Model: ${nextModel}`,
});
requestRender();
};
// Cross-connection /model: rebind the session to the chosen connection + model.
// Updates the provider (and thus the thinking variants) and the status line.
const setModelChoice = async (choice: ModelChoice) => {
await input.driver.setModel(choice.model, choice.connectionSlug);
model = choice.model;
connectionSlug = choice.connectionSlug;
providerType = choice.providerType;
modelContextWindow = choice.contextWindow;
thinkingLevel = undefined;
thinkingLevels = thinkingVariantsForModel(choice.providerType, choice.model);
state.entries.push({
kind: 'notice',
level: 'info',
text: `Model: ${choice.model} (${choice.connectionName || choice.connectionSlug})`,
});
requestRender();
};
const setThinkingLevel = async (nextLevel: ThinkingLevel | undefined) => {
await input.driver.setThinkingLevel(nextLevel);
thinkingLevel = nextLevel;
state.entries.push({
kind: 'notice',
level: 'info',
text: nextLevel ? `Thinking: ${nextLevel}` : 'Thinking: default',
});
requestRender();
};
// Adopt a switch/rewind result: the active session is now `summary` with
// `messages`. Shared by switchSession and rewindToTurn so both land the same
// runner state (model/connection/thinking/transcript/scroll).
const applySwitchResult = async ({
summary,
messages,
}: MakaSessionSwitchResult): Promise<void> => {
cwd = summary.cwd ?? cwd;
setSessionTitle(summary.name);
const previousModel = model;
model = summary.model;
const previousConnectionSlug = connectionSlug;
connectionSlug = summary.llmConnectionSlug;
const matchingChoice = modelChoices?.find(
(choice) => choice.connectionSlug === summary.llmConnectionSlug,
);
providerType =
matchingChoice?.providerType ??
(previousConnectionSlug === summary.llmConnectionSlug ? providerType : undefined);
// Statusline ctx total for the now-active session (review finding: a
// switch/rewind onto a different connection or model left the previous
// session's window in place). Mirrors setModel/setModelChoice's own
// lookup above. An exact match (connection + model) updates the window;
// no match with the target actually changed means the resumed model was
// curated out of modelChoices (a legitimate state for old sessions) —
// clear the window rather than keep showing the previous session's ctx
// total under a different model. No match but the target didn't change
// (e.g. rewind within the same session) leaves the window untouched.
const contextWindowMatch = modelChoices?.find(
(choice) =>
choice.connectionSlug === summary.llmConnectionSlug && choice.model === summary.model,
);
if (contextWindowMatch) {
modelContextWindow = contextWindowMatch.contextWindow;
} else if (
previousConnectionSlug !== summary.llmConnectionSlug ||
previousModel !== summary.model
) {
modelContextWindow = undefined;
}
permissionMode = summary.permissionMode;
orchestrationMode = summary.orchestrationMode ?? 'default';
thinkingLevel = summary.thinkingLevel;
thinkingLevels = providerType ? thinkingVariantsForModel(providerType, summary.model) : [];
refreshEditorCwd?.(cwd);
replaceTranscriptWithStoredMessages(state, messages);
shellRunHydration.reset();
if (input.listShellRunUpdates) {
await shellRunHydration.hydrate(summary.id);
}
shellRunElapsedTicker.sync();
};
// The driver validates the durable cwd before adopting the resumed session.
// A failure leaves the active session untouched and the next prompt still
// lands on the old one.
const switchSession = async (sessionId: string) => {
const result = await input.driver.switchSession(sessionId);
await applySwitchResult(result);
if (result.messages.length === 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: `Resumed session "${result.summary.name}"`,
});
}
requestRender();
};
// Rewind branches the active session to just before the chosen turn and
// switches onto the branch (driver.rewindToTurn), then refills the editor with
// that turn's prompt. The original session is left intact, so this is
// non-destructive and inherits the branch's resume guarantees.
const rewindToTurn = async (turnId: string) => {
const result = await input.driver.rewindToTurn(turnId);
await applySwitchResult(result);
// Refill the editor with the discarded turn's prompt so the user can edit
// and resend it. The picker only arms when the editor is neutral (empty
// draft, no autocomplete), so overwriting the text loses no in-progress work.
editor.setText(result.prompt);
state.entries.push({
kind: 'notice',
level: 'info',
text: '已回退到该轮之前(分支为新会话,原会话保留),该轮 prompt 已回填输入框,可修改后重新发送。',
});
requestRender();
};
const showBottomPicker = (picker: Component): OverlayHandle =>
tui.showOverlay(picker, {
anchor: 'bottom-left',
width: '100%',
maxHeight: Math.max(1, terminal.rows - BOTTOM_PICKER_MARGIN_ROWS),
margin: { bottom: BOTTOM_PICKER_MARGIN_ROWS },
});
const closeUserQuestionOverlay = (): void => {
userQuestionOverlay?.hide();
userQuestionOverlay = undefined;
};
const finishUserQuestion = (requestId: string, answers: Array<string | null>): void => {
if (userQuestionInFlight) return;
const respond = input.driver.respondToUserQuestion;
if (!respond) {
reportError(new Error('User questions are unavailable on this driver.'));
return;
}
userQuestionInFlight = true;
closeUserQuestionOverlay();
void respond
.call(input.driver, { requestId, answers })
.then(() => {
userQuestionInFlight = false;
if (activeUserQuestionRequest(state)?.requestId === requestId) {
completePendingInteraction(state, requestId);
}
userQuestionProgress = undefined;
syncUserQuestionOverlay();
requestRender();
})
.catch((error) => {
userQuestionInFlight = false;
reportError(error);
syncUserQuestionOverlay();
});
};
const showUserQuestion = (): void => {
const request = activeUserQuestionRequest(state);
const progress = userQuestionProgress;
if (!request || !progress || progress.requestId !== request.requestId) return;
const question = request.questions[progress.index];
if (!question) {
finishUserQuestion(request.requestId, progress.answers);
return;
}
closeUserQuestionOverlay();
const advance = (answer: string | null): void => {
progress.answers[progress.index] = answer;
progress.index += 1;
showUserQuestion();
};
userQuestionOverlay = showBottomPicker(
new UserQuestionOverlay(tui, {
title: question.question,
rightLabel: `${progress.index + 1} / ${request.questions.length}`,
hint: '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop',
placeholder: 'Other: type your answer…',
options: question.options,
onSelectOption: (index) => advance(question.options[index]?.label ?? null),
onSubmitText: (value) => advance(value),
onSkip: () => advance(null),
}),
);
};
const syncUserQuestionOverlay = (): void => {
const request = activeUserQuestionRequest(state);
if (!request) {
closeUserQuestionOverlay();
userQuestionProgress = undefined;
return;
}
if (userQuestionInFlight) return;
if (userQuestionProgress?.requestId !== request.requestId) {
userQuestionProgress = {
requestId: request.requestId,
index: 0,
answers: Array.from({ length: request.questions.length }, () => null),
};
showUserQuestion();
}
};
const showSelectPicker = (
title: string,
rightLabel: string,
items: SelectItem[],
onSelect: (item: SelectItem) => void,
options: {
minPrimaryColumnWidth: number;
maxPrimaryColumnWidth: number;
selectedIndex?: number;
hint?: string;
onCancel?: () => void;
},
): void => {
const list = new SelectList(items, 10, selectListTheme(), {
minPrimaryColumnWidth: options.minPrimaryColumnWidth,
maxPrimaryColumnWidth: options.maxPrimaryColumnWidth,
});
if (options.selectedIndex !== undefined) list.setSelectedIndex(options.selectedIndex);
const picker = new PickerOverlay(list, { title, rightLabel, hint: options.hint });
let overlay: OverlayHandle | undefined;
list.onSelect = (item) => {
overlay?.hide();
onSelect(item);
};
list.onCancel = () => {
overlay?.hide();
options.onCancel?.();
};
overlay = showBottomPicker(picker);
};
const closeWizard = (): void => {
wizardAttempt += 1; // drop any in-flight verify/save before clearing the slots
wizardOverlay?.hide();
wizardOverlay = undefined;
wizard = undefined;
wizardProviderType = undefined;
wizardApiKey = '';
wizardModels = [];
};
// Key submit from the wizard. Slash commands route as commands (so /exit
// still escapes the wizard) instead of being stored as an API key; every
// in-flight state stays inside the wizard overlay, never the transcript.
const submitWizardKey = (apiKey: string): void => {
const providerType = wizardProviderType;
if (!providerType || !wizard) return;
if (apiKey.startsWith('/')) {
closeWizard();
handleSlashCommand(apiKey, 0);
return;
}
if (!input.onboarding) {
wizard.setKeyError('Onboarding 不可用:当前运行环境未提供配置入口。');
requestRender();
return;
}
wizardApiKey = apiKey;
const targetWizard = wizard;
const attempt = ++wizardAttempt;
targetWizard.setVerifying();
requestRender();
void input.onboarding.verify({ providerType, apiKey }).then(
(result) => {
if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return;
if (result.kind === 'error') {
// Probe failed: re-arm the key field in place. The host stores nothing
// during verify, so retrying with a corrected key is clean.
wizard.setKeyError(`API key 验证失败:${result.text}。请检查后重新输入。`);
requestRender();
return;
}
wizardModels = result.models;
wizard.setModels(result.models); // advance to the models step
requestRender();
},
(error) => {
if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return;
wizard.setKeyError(`配置失败:${error instanceof Error ? error.message : String(error)}`);
requestRender();
},
);
};
// Models submit from the wizard: persist the curated enabled set, refresh the
// running TUI's authoritative ready model choices, and show an in-frame
// success (first-run closes the TUI so the host re-resolves the new default).
// Setup never appends a transcript Note and never switches the active session.
const submitWizardModels = (enabledModelIds: readonly string[]): void => {
const providerType = wizardProviderType;
if (!providerType || !wizard) return;
if (!input.onboarding) {
wizard.setModelError('Onboarding 不可用:当前运行环境未提供配置入口。');
requestRender();
return;
}
const targetWizard = wizard;
const attempt = ++wizardAttempt;
targetWizard.setSaving();
requestRender();
void input.onboarding
.save({ providerType, apiKey: wizardApiKey, enabledModelIds, models: wizardModels })
.then(
(result) => {
if (result.kind === 'error') {
if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return;
wizard.setModelError(result.text);
requestRender();
return;
}
// Authoritatively refresh the running TUI's ready model choices so the
// newly configured models are immediately available from /model — even
// if the user abandoned the wizard mid-save. Abandonment only drops the
// in-frame success UI, not the background state sync. The active
// session is not switched.
modelChoices = result.modelChoices;
if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return;
if (input.firstRun) {
beginClose();
return;
}
wizard.setSuccess(enabledModelIds.length);
requestRender();
},
(error) => {
if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return;
wizard.setModelError(
`保存失败:${error instanceof Error ? error.message : String(error)}`,
);
requestRender();
},
);
};
const showSetupWizard = async (): Promise<void> => {
let providers: OnboardingProviderEntry[];
if (input.onboarding) {
try {
providers = await input.onboarding.listProviders();
} catch (error) {
state.entries.push({
kind: 'notice',
level: 'info',
text: `无法读取已配置的连接:${error instanceof Error ? error.message : String(error)}`,
});
requestRender();
return;
}
} else {
// No surface (a minimal test host): open with the bare catalog so the
// wizard can report unavailability in-frame at submit instead of throwing.
providers = listApiKeyOnboardableProviders().map((provider) => ({
...provider,
hasConnection: false,
enabledModelIds: [],
}));
}
if (providers.length === 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: '没有可配置的 API key 类供应商。',
});
requestRender();
return;
}
wizardOverlay?.hide();
wizard = new OnboardingWizard(tui, {
providers,
onPickProvider: (providerType) => {
wizardProviderType = providerType;
wizardApiKey = '';
wizardModels = [];
wizardAttempt += 1; // a new pick supersedes any in-flight attempt
requestRender();
},
onSubmitKey: submitWizardKey,
onSubmitModels: submitWizardModels,
onCancel: () => {
closeWizard();
// First-run has no connection to fall back to: cancelling the wizard
// closes the TUI so the host surfaces its missing-default guidance.
if (input.firstRun) beginClose();
},
onBack: () => {
wizardAttempt += 1; // back one level invalidates any in-flight verify/save
requestRender();
},
onClose: () => {
closeWizard();
},
});
wizardOverlay = showBottomPicker(wizard);
};
// One-sentence session recap (issue #1055). Shared by the manual /recap
// command and idle-return auto-recap; both paths route through the same
// in-flight lock so at most one recap call runs at a time.
const runRecap = async (reason: 'manual' | 'idle'): Promise<void> => {
// Captured synchronously on entry, so for the idle path this already
// includes the seq bump from the very prompt that triggered this call
// (submitPrompt bumps promptSeq before invoking maybeTriggerAutoRecap).
// Only a prompt submitted *after* this point — i.e. later than the one
// that triggered the recap — should make the result stale.
const seqAtStart = promptSeq;
// Captured synchronously on entry, before any await: /session, /new, and
// rewind never bump promptSeq, so a session switch mid-generate must be
// caught by comparing sessionIds directly rather than relying on seq.
const sessionIdAtStart = input.driver.getSessionId();
if (!input.recap) {
if (reason === 'manual') {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Recap is not available in this environment.',
});
requestRender();
}
return;
}
if (recapInFlight) {
if (reason === 'manual') {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Recap already running.',
});
requestRender();
}
return;
}
// Locked synchronously, before any await: two /recap invocations
// submitted back-to-back must not both pass the recapInFlight check above
// before either sets it. The rest of the body is one try/finally so every
// early return (including "Nothing to recap yet" and a null session)
// releases the lock.
recapInFlight = true;
try {
const mainTurnCount = (await input.driver.listRewindTargets()).length;
if (reason === 'manual' && mainTurnCount < 1) {
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Nothing to recap yet.',
});
requestRender();
return;
}
if (!sessionIdAtStart) return;
const result = await input.recap.generate(sessionIdAtStart, reason);
// The active session must still be the one this recap started for —
// checked before ANY display (success notice or manual failure notice).
// /session, /new, or a rewind switched the active session while
// generate() was in flight: the session this result belongs to is gone
// from view, so surfacing it (success or error) would land on the wrong
// session. Drop it silently regardless of manual/idle.
if (input.driver.getSessionId() !== sessionIdAtStart) return;
if (!result.ok) {
if (reason === 'manual') {
state.entries.push({
kind: 'notice',
level: 'error',
text: `Recap failed: ${result.error}`,
});
requestRender();
}
return;
}
if (reason === 'idle') {
// Below the display threshold suppresses the notice (still persisted by
// the generator); a prompt submitted after seqAtStart while the call
// was in flight means a later prompt has superseded this recap — drop
// it silently either way.
if (Buffer.byteLength(result.raw, 'utf8') > AUTO_RECAP_DISPLAY_LIMIT_BYTES) return;
if (promptSeq !== seqAtStart) return;
}
state.entries.push({
kind: 'notice',
level: 'info',
text: `Recap: ${result.text}`,
});
requestRender();
} finally {
recapInFlight = false;
}
};
// Fire-and-forget idle-return check: a normal prompt submitted after a long
// enough gap auto-triggers a recap, without blocking the turn it opens.
const maybeTriggerAutoRecap = (idleMs: number): void => {
if (!input.recap) return;
void (async () => {
try {
const sessionId = input.driver.getSessionId();
const mainTurnCount = (await input.driver.listRewindTargets()).length;
const lastRecapMainTurnCount =
sessionId && recapWatermark?.sessionId === sessionId ? recapWatermark.mainTurnCount : 0;
if (!shouldAutoRecap({ idleMs, mainTurnCount, lastRecapMainTurnCount })) return;
if (sessionId) recapWatermark = { sessionId, mainTurnCount };
void runRecap('idle');
} catch {
// Best-effort: auto-recap must never surface an error to the user.
}
})();
};
const compactSession = async () => {
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Compacting context…',
});
requestRender();
await submitCompactToTranscript({
state,
driver: input.driver,
onChange: requestRender,
});
};
const resumeSession = async () => {
if (!input.driver.resumeLatest) {
throw new Error('Safe-boundary resume is unavailable on this runtime.');
}
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Resuming from the latest safe boundary…',
});
requestRender();
for await (const event of input.driver.resumeLatest()) {
applyMakaSessionEventToTranscript(state, event);
shellRunElapsedTicker.sync();
syncUserQuestionOverlay();
requestRender();
}
};
const showSessionList = async () => {
const sessions = await input.driver.listSessions();
const sessionTree = projectRevisionLinkedSessionTree(
sessions,
input.driver.getSessionId() ?? undefined,
);
const projectedSessions = flattenLinkedSessionTree(
sessionTree.roots,
sessionTree.childrenByParentId,
);
// Maka-session availability and the foreign scan are independent I/O; run
// them concurrently so the picker's open latency is the slower of the two,
// not their sum.
const [availabilityEntries, foreignScan] = await Promise.all([
Promise.all(
sessions.map(async (session) => {
return [
session.id,
(await input.driver.getSessionResumeAvailability?.(session)) ??
(await inspectSessionResumeAvailability(session)),
] as const;
}),
),
input.foreignSessions
? input.foreignSessions.listSessions({ cwd }).then(
(summaries) => ({ summaries }),
(error: unknown) => ({ error }),
)
: Promise.resolve({ summaries: [] as ForeignSessionSummary[] }),
]);
const availability = new Map(availabilityEntries);
// Foreign (Claude Code / Codex) sessions for the current cwd, keyed by a
// prefixed select value so they never collide with Maka session ids. A scan
// error is surfaced (not silently swallowed): degrade to no rows but tell
// the user why, so a real store bug isn't mistaken for "no sessions".
const foreignByValue = new Map<string, ForeignSessionSummary>();
if ('error' in foreignScan) {
const detail =
foreignScan.error instanceof Error ? foreignScan.error.message : String(foreignScan.error);
state.entries.push({
kind: 'notice',
level: 'error',
text: `读取外部会话失败:${detail}`,
});
} else {
for (const summary of foreignScan.summaries) {
foreignByValue.set(`foreign:${summary.source}:${summary.id}`, summary);
}
}
const renderScope = (): void => {
const visibleSessions =
sessionListScope === 'current'
? projectedSessions.filter(({ session }) => session.cwd === cwd)
: projectedSessions;
const items: SelectItem[] = visibleSessions.map(({ session, depth }) => {
const state = availability.get(session.id);
const location =
sessionListScope === 'all' && session.cwd ? ` ${basename(session.cwd)}` : '';
const childDetail = session.subagentRuntime
? ` subagent:${session.subagentRuntime.profile} ${session.status}`
: '';
return {
value: session.id,
label: `${depth > 0 ? `${' '.repeat(depth - 1)}↳ ` : ''}${session.name || session.id}`,
description:
state?.available === false
? `${shortSessionId(session.id)} ${state.reason}`
: `${shortSessionId(session.id)}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`,
};
});
// Foreign sessions are cwd-scoped; show them in both scope views (they
// belong to this project) so a Tab toggle never makes them vanish.
for (const [value, summary] of foreignByValue) {
items.push({
value,
label: summary.title,
description: `↩ resume from ${foreignSourceLabel(summary.source)}`,
});
}
const list = new SelectList(items, 10, selectListTheme(), {
minPrimaryColumnWidth: 20,
maxPrimaryColumnWidth: Math.max(20, terminal.columns - 30),
});
let overlay: OverlayHandle | undefined;
list.onSelect = (item) => {
const foreign = foreignByValue.get(item.value);
if (foreign) {
overlay?.hide();
void importForeignSession(foreign);
return;
}
if (availability.get(item.value)?.available === false) return;
overlay?.hide();
void runControl(() => switchSession(item.value));
};
list.onCancel = () => overlay?.hide();
overlay = showBottomPicker(
new PickerOverlay(list, {
title: 'Resume Session',
rightLabel: sessionListScope === 'current' ? 'Current' : 'All',
hint: 'Tab scope · ↑↓ move · Enter select · Esc close',
onInput: (data) => {
if (!matchesKey(data, Key.tab) || isKeyRelease(data) || isKeyRepeat(data)) return false;
sessionListScope = sessionListScope === 'current' ? 'all' : 'current';
overlay?.hide();
renderScope();
return true;
},
}),
);
};
renderScope();
};
const showRewindPicker = async () => {
const targets = await input.driver.listRewindTargets();
if (targets.length === 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: '没有可回退的轮次。',
});
requestRender();
return;
}
const items: SelectItem[] = targets.map((target) => ({
value: target.turnId,
label: target.label,
}));
showSelectPicker(
'Rewind',
'Rewind',
items,
(item) => {
void runControl(() => rewindToTurn(item.value));
},
{
minPrimaryColumnWidth: 24,
maxPrimaryColumnWidth: 48,
hint: '回到选定轮次之前(丢弃该轮及之后,prompt 回填输入框) · enter 选择 / esc 取消',
},
);
};
const newSession = () => {
input.driver.startNewSession();
attention.setBaseTitle(input.title);
shellRunHydration.reset();
// Fresh transcript for the fresh session; the next prompt creates it on disk.
// Leave the transcript empty (no confirmation notice) so /new opens on the
// same welcome block as a cold start — the welcome block is the "fresh
// session, send a prompt to begin" cue. A notice here would make entries
// non-empty and suppress it.
replaceTranscriptWithStoredMessages(state, []);
shellRunElapsedTicker.sync();
requestRender();
};
// Import a foreign (Claude Code / Codex) session: read its digest, open a
// fresh Maka session, and seed the first turn with an untrusted handoff
// envelope. Mirrors submitPreparedUserPrompt: claim `busy` + an activity lease
// SYNCHRONOUSLY before the async read so no other turn (a Goal auto-
// continuation, or a user Enter) can start during it and make the import a
// silent no-op. runAgentTurn re-asserts busy for the turn; on any failure the
// finally releases the lease. The handoff is the model-facing `sendText`; a
// short line shows in the transcript.
const importForeignSession = async (summary: ForeignSessionSummary): Promise<void> => {
if (busy || input.foreignSessions === undefined) return;
busy = true;
const activity = beginActivity();
editor.disableSubmit = true;
let handedOff = false;
try {
const digest = await input.foreignSessions.readDigest(summary);
if (closed) return;
newSession();
void runAgentTurn({
kind: 'external',
prompt: foreignSessionHandoffDisplayText(digest),
sessionId: input.driver.getSessionId(),
sendText: buildForeignSessionHandoffMessage(digest),
});
handedOff = true;
} catch (error) {
if (closed) return;
reportError(error);
} finally {
if (!handedOff) {
busy = false;
editor.disableSubmit = false;
requestRender();
}
activity.finish();
}
};
const showHelp = () => {
// Derive the command list from the registry so /help never drifts from the
// real commands. Keybindings are not commands, so they are listed by hand.
const commands = slashCommands
.map((command) => {
const aliasSuffix =
command.aliases && command.aliases.length > 0
? ` (${command.aliases.map((alias) => `/${alias}`).join(', ')})`
: '';
return ` /${command.name}${aliasSuffix} — ${command.description}`;
})
.join('\n');
const keybindings = [
' Ctrl+O — expand or collapse all tool output',
' Ctrl+T — expand or collapse the latest thinking block',
' Scroll the transcript with your terminal or trackpad',
' Enter (during a turn) — steer: inject a message into the running turn',
' Alt+Enter (during a turn) — queue a message for the next turn',
' Alt+↑ — take queued messages back into the editor to re-edit',
' Esc Esc (during a turn) — interrupt the turn',
' Esc Esc (when idle) — rewind to an earlier turn',
' Ctrl+C — stop the turn, clear input, or press twice to exit',
' Ctrl+D — exit when input is empty',
].join('\n');
state.entries.push({
kind: 'notice',
level: 'info',
text: `Commands\n${commands}\n\nKeybindings\n${keybindings}`,
});
requestRender();
};
const showModelList = () => {
const choices = modelChoices;
// Cross-connection picker when the caller supplied choices across all ready
// connections; otherwise the single-connection list (typed /model, tests).
if (choices && choices.length > 0) {
let overlay: OverlayHandle | undefined;
const picker = new ModelSearchOverlay(tui, {
choices,
current: { model, connectionSlug },
onSelect: (choice) => {
overlay?.hide();
void runControl(() => setModelChoice(choice));
},
onCancel: () => overlay?.hide(),
});
overlay = showBottomPicker(picker);
return;
}
showSelectPicker(
'Select Model',
connectionSlug,
modelPickerItems(model, input.models),
(item) => {
void runControl(() => setModel(item.value));
},
{ minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48 },
);
};
// `/skill` with no arguments: pick from everything the host can invoke right
// now. Picking only inserts the token into the draft — never sends — so the
// user keeps composing (and can add more tokens) before submitting.
const showSkillList = async () => {
const entries = await listSkillsCached(true);
if (closed) return;
if (entries.length === 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: '当前没有可调用的技能。',
});
requestRender();
return;
}
showSelectPicker(
'Invoke Skill',
String(entries.length),
skillPickerItems(entries),
(item) => {
editor.insertTextAtCursor(`/skill:${item.value} `);
requestRender();
},
{ minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 40 },
);
};
const showThinkingLevelList = () => {
const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel);
showSelectPicker(
'Select Thinking Level',
thinkingLevel ?? 'default',
items,
(item) => {
const level = item.value === 'default' ? undefined : (item.value as ThinkingLevel);
if (level !== undefined && !isThinkingLevel(level)) return;
void runControl(() => setThinkingLevel(level));
},
{
minPrimaryColumnWidth: 16,
maxPrimaryColumnWidth: 24,
selectedIndex: items.findIndex((item) => item.value === (thinkingLevel ?? 'default')),
},
);
};
const setPermissionMode = async (mode: PermissionMode) => {
await input.driver.setPermissionMode(mode);
permissionMode = mode;
state.entries.push({
kind: 'notice',
level: 'info',
text: `Permission mode: ${mode}`,
});
requestRender();
};
const showSwarmStatus = () => {
state.entries.push({
kind: 'notice',
level: 'info',
text:
orchestrationMode === 'swarm'
? 'Swarm Mode is on for this session.'
: 'Swarm Mode is off. The main agent may still use agent_swarm opportunistically.',
});
requestRender();
};
const setSwarmMode = async (mode: OrchestrationMode) => {
if (!input.driver.setOrchestrationMode) {
throw new Error('Swarm Mode is unavailable on this session driver.');
}
await input.driver.setOrchestrationMode(mode);
orchestrationMode = mode;
state.entries.push({
kind: 'notice',
level: 'info',
text: mode === 'swarm' ? 'Swarm Mode enabled for this session.' : 'Swarm Mode disabled.',
});
requestRender();
};
const runSwarmCommand = (command: ParsedSwarmCommand, idleMs: number) => {
if (command.kind === 'status') {
showSwarmStatus();
return;
}
if (command.kind === 'set_mode') {
void runControl(() => setSwarmMode(command.mode));
return;
}
if (input.firstRun) {
void showSetupWizard();
return;
}
lastActivityAt = Date.now();
promptSeq += 1;
maybeTriggerAutoRecap(idleMs);
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Using Swarm Mode for this turn only.',
});
void runAgentTurn({
kind: 'external',
prompt: command.task,
sessionId: input.driver.getSessionId(),
turnOrchestration: { mode: 'swarm', source: 'slash_command' },
});
};
const showGraphStatus = () => {
state.entries.push({
kind: 'notice',
level: 'info',
text:
orchestrationMode === 'graph' ? 'Graph Mode is on for this session.' : 'Graph Mode is off.',
});
requestRender();
};
const setGraphMode = async (mode: OrchestrationMode) => {
if (!input.driver.setOrchestrationMode) {
throw new Error('Graph Mode is unavailable on this session driver.');
}
await input.driver.setOrchestrationMode(mode);
orchestrationMode = mode;
state.entries.push({
kind: 'notice',
level: 'info',
text: mode === 'graph' ? 'Graph Mode enabled for this session.' : 'Graph Mode disabled.',
});
requestRender();
};
const runGraphCommand = (command: ParsedGraphCommand, idleMs: number) => {
if (command.kind === 'status') {
showGraphStatus();
return;
}
if (command.kind === 'set_mode') {
void runControl(() => setGraphMode(command.mode));
return;
}
if (input.firstRun) {
void showSetupWizard();
return;
}
lastActivityAt = Date.now();
promptSeq += 1;
maybeTriggerAutoRecap(idleMs);
state.entries.push({
kind: 'notice',
level: 'info',
text: 'Using Graph Mode for this turn only.',
});
void runAgentTurn({
kind: 'external',
prompt: command.task,
sessionId: input.driver.getSessionId(),
turnOrchestration: { mode: 'graph', source: 'slash_command' },
});
};
const moveSession = async (targetCwd: string): Promise<void> => {
if (!input.driver.moveSession) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Moving sessions is not available in this environment.',
});
requestRender();
return;
}
const result = await input.driver.moveSession(targetCwd);
if (!result.changed) {
state.entries.push({
kind: 'notice',
level: 'info',
text: `Session is already at "${result.cwd}".`,
});
requestRender();
return;
}
cwd = result.cwd;
refreshEditorCwd?.(cwd);
const warning =
result.oldCwdDirty === true
? ` Warning: the old directory "${result.previousCwd}" has uncommitted changes.`
: '';
state.entries.push({
kind: 'notice',
level: 'info',
text: `Session moved to "${result.cwd}".${warning}`,
});
requestRender();
};
const showMovePicker = (): void => {
if (!input.driver.moveSession) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Moving sessions is not available in this environment.',
});
requestRender();
return;
}
let overlay: OverlayHandle | undefined;
const picker = new DirectoryPickerOverlay(tui, {
currentCwd: cwd,
basePath: cwd,
onSubmit: (targetCwd) => {
overlay?.hide();
void runControl(() => moveSession(targetCwd));
},
onCancel: () => overlay?.hide(),
});
overlay = showBottomPicker(picker);
};
const showPermissionModeList = () => {
const items = permissionModePickerItems(permissionMode);
showSelectPicker(
'Select Permission Mode',
permissionMode,
items,
(item) => {
if (!isPermissionMode(item.value)) return;
const mode = item.value;
void runControl(() => setPermissionMode(mode));
},
{
minPrimaryColumnWidth: 16,
maxPrimaryColumnWidth: 24,
selectedIndex: items.findIndex((item) => item.value === permissionMode),
},
);
};
const slashCommands: MakaSlashCommand[] = [
{
name: 'compact',
description: 'Compact session context',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /compact',
});
requestRender();
return;
}
void runControl(compactSession);
},
},
{
name: 'exit',
description: 'Exit Maka',
aliases: ['quit'],
run: () => {
beginGracefulClose();
},
},
{
name: 'help',
description: 'Show commands and keybindings',
run: () => {
void runControl(async () => showHelp());
},
},
{
name: 'new',
description: 'Start a new session',
run: () => {
void runControl(async () => newSession());
},
},
{
name: 'skill',
description: 'Invoke a skill (or type /skill:<name> inline)',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /skill,或直接在消息中输入 /skill:<name>',
});
requestRender();
return;
}
void showSkillList();
},
},
{
name: 'setup',
description: 'Set up a model provider (API key)',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /setup',
});
requestRender();
return;
}
void showSetupWizard();
},
},
{
name: 'model',
description: 'Select model',
run: (parts: string[]) => {
if (parts.length === 1) {
showModelList();
return;
}
const nextModel = parts.length === 2 ? parts[1] : undefined;
if (!nextModel) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /model <model-id>',
});
requestRender();
return;
}
void runControl(() => setModel(nextModel));
},
},
{
name: 'move',
description: 'Move current session to another directory',
run: (parts: string[], rawTail?: string) => {
const targetCwd = (rawTail ?? parts.slice(1).join(' ')).trim();
if (targetCwd) {
void runControl(() => moveSession(targetCwd));
return;
}
showMovePicker();
},
},
{
name: 'thinking',
description: 'Set thinking level',
run: (parts: string[]) => {
if (parts.length === 1) {
if (thinkingLevels.length === 0) {
state.entries.push({
kind: 'notice',
level: 'info',
text: '当前模型不支持思考级别切换。',
});
requestRender();
return;
}
showThinkingLevelList();
return;
}
const token = parts.length === 2 ? parts[1] : undefined;
// `off` is a real level now (maps to reasoningEffort:'none' / thinking
// disabled), not a synonym for 默认. Only `default` clears the override.
const level = token === 'default' ? undefined : token;
// Reject levels the current model does not support (P2-1): the picker
// already restricts to `thinkingLevels`, but the typed command path
// must too so the statusbar never advertises a level the runtime drops.
if (level !== undefined && (!isThinkingLevel(level) || !thinkingLevels.includes(level))) {
state.entries.push({
kind: 'notice',
level: 'error',
text:
thinkingLevels.length === 0
? '当前模型不支持思考级别切换。'
: `Usage: /thinking ${['default', ...thinkingLevels].join('|')}`,
});
requestRender();
return;
}
void runControl(() => setThinkingLevel(level));
},
},
{
name: 'permissions',
description: 'Set permission mode',
run: (parts: string[]) => {
if (parts.length === 1) {
showPermissionModeList();
return;
}
const mode = parts.length === 2 ? parts[1] : undefined;
if (!isPermissionMode(mode)) {
state.entries.push({
kind: 'notice',
level: 'error',
text: `Usage: /permissions ${PERMISSION_MODES.join('|')}`,
});
requestRender();
return;
}
void runControl(() => setPermissionMode(mode));
},
},
{
name: 'recap',
description: 'One-sentence recap of the session so far',
run: () => {
void runRecap('manual');
},
},
{
name: 'rename',
description: 'Rename current session',
run: (parts: string[]) => {
const name = parts.slice(1).join(' ').trim();
if (!name) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /rename <new name>',
});
requestRender();
return;
}
void runControl(async () => {
const renamedName = (await input.driver.renameSession(name)) ?? name;
setSessionTitle(renamedName);
state.entries.push({
kind: 'notice',
level: 'info',
text: `Session renamed to "${renamedName}"`,
});
requestRender();
});
},
},
{
name: 'resume',
description: 'Resume latest interrupted run at a safe boundary',
run: (parts: string[]) => {
if (parts.length !== 1) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /resume',
});
requestRender();
return;
}
void runControl(resumeSession);
},
},
{
name: 'rewind',
description: 'Rewind to an earlier turn',
run: () => {
void runControl(showRewindPicker);
},
},
{
name: 'session',
description: 'Resume session',
run: (parts: string[]) => {
if (parts.length === 1) {
void runControl(showSessionList);
return;
}
const sessionId = parts.length === 2 ? parts[1] : undefined;
if (!sessionId) {
state.entries.push({
kind: 'notice',
level: 'error',
text: 'Usage: /session <session-id>',
});
requestRender();
return;
}
void runControl(() => switchSession(sessionId));
},
},
{
name: 'graph',
description: 'Show, enable, disable, or run one Graph turn',
run: (_parts: string[], rawTail: string | undefined, context: { idleMs: number }) => {
const parsed = parseGraphCommand(`/graph${rawTail ? ` ${rawTail}` : ''}`);
if (parsed) runGraphCommand(parsed, context.idleMs);
},
},
{
name: 'swarm',
description: 'Show, enable, disable, or run one Swarm turn',
run: (_parts: string[], rawTail: string | undefined, context: { idleMs: number }) => {
const parsed = parseSwarmCommand(`/swarm${rawTail ? ` ${rawTail}` : ''}`);
if (parsed) runSwarmCommand(parsed, context.idleMs);
},
},
].sort((left, right) => left.name.localeCompare(right.name));
const handleSlashCommand = (prompt: string, idleMs: number): boolean => {
const trimmed = prompt.trim();
const commandToken = trimmed.split(/\s+/, 1)[0] ?? '';
const command = slashCommands.find(
(candidate) =>
`/${candidate.name}` === commandToken ||
candidate.aliases?.some((alias) => `/${alias}` === commandToken),
);
if (!command) return false;
const rawTail = trimmed.slice(commandToken.length).trimStart();
command.run(trimmed.split(/\s+/), rawTail, { idleMs });
return true;
};
refreshEditorCwd = (nextCwd) => {
editor.setAutocompleteProvider(
new MakaAutocompleteProvider(nextCwd, slashCommands, () => listSkillsCached()),
);
};
refreshEditorCwd(cwd);
tui.addInputListener((data) => {
// Once closing has begun, swallow any buffered input that reaches the
// listener while the terminal is being torn down.
if (closed) return { consume: true };
// DEC 1004 focus reports drive the attention layer. Consume them so they
// never reach the editor as stray input; they are not user keystrokes.
if (data === FOCUS_IN_SEQUENCE) {
attention.focusChanged(true);
return { consume: true };
}
if (data === FOCUS_OUT_SEQUENCE) {
attention.focusChanged(false);
return { consume: true };
}
// Kitty keyboard protocol terminals (Ghostty/Kitty) emit separate press and
// release events. pi-tui only filters releases on the focused-component
// path, but this raw listener runs before that, so a release would
// immediately undo a Ctrl+O/Ctrl+T toggle and a single Escape's
// press+release pair could count as a double Escape. We never act on
// releases here; returning undefined lets the TUI apply its own filtering.
if (isKeyRelease(data)) return undefined;
if (
activeUserQuestionRequest(state) &&
turnRunning &&
matchesKey(data, Key.ctrl('c')) &&
!isKeyRepeat(data)
) {
if (interruptRequested) handleProcessExit(0);
else requestTurnInterrupt();
return { consume: true };
}
if (tui.hasOverlay()) return undefined;
// Alt+Enter: queue a followup (during a turn) or submit (when idle). Alt+↑:
// take back the queued messages to re-edit. Neither is an editor binding
// (newline is shift+enter/ctrl+j; history is plain up), so intercepting
// here does not collide with the editor's own keys.
if (matchesKey(data, Key.alt('enter')) && !isKeyRepeat(data)) {
handleAltEnter();
return { consume: true };
}
if (matchesKey(data, Key.alt('up')) && !isKeyRepeat(data)) {
// Always retract from the authority: the render mirror lags the
// queue_update event, so an enqueue followed by Alt+Up in the same
// tick would see an empty mirror while the runtime holds the message.
// Alt+Up is not an editor binding, and an empty retract refill is a
// no-op, so consuming unconditionally loses nothing.
retractQueuedMessages();
return { consume: true };
}
if (matchesKey(data, Key.ctrl('c')) && isKeyRepeat(data)) return { consume: true };
if (!matchesKey(data, Key.ctrl('c'))) lastIdleCtrlCAt = 0;
// The idle rewind gesture requires two *consecutive* Escapes. Any other key
// in between breaks it, so a stale first Escape never pairs with a much later
// one (e.g. `Esc`, type, `Esc`).
if (!matchesKey(data, Key.escape)) lastIdleEscapeAt = 0;
if (matchesKey(data, Key.ctrl('o')) && !isKeyRepeat(data)) {
if (togglePendingPermissionDetails(state)) {
requestRender();
return { consume: true };
}
if (toggleAllToolExpansion(state)) {
requestRender();
return { consume: true };
}
}
if (matchesKey(data, Key.ctrl('t')) && !isKeyRepeat(data)) {
if (toggleAllThinkingExpansion(state)) {
requestRender();
return { consume: true };
}
}
const pendingPermission = activePermissionRequest(state);
if (pendingPermission) {
if (matchesKey(data, 'y') || matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
respondToPendingPermission('allow', false);
return { consume: true };
}
if (matchesKey(data, 'a') && pendingPermission.rememberForTurnAllowed) {
respondToPendingPermission('allow', true);
return { consume: true };
}
if (matchesKey(data, 'n') || matchesKey(data, Key.escape)) {
respondToPendingPermission('deny');
return { consume: true };
}
}
if (turnRunning && matchesKey(data, Key.ctrl('c'))) {
if (interruptRequested) handleProcessExit(0);
else requestTurnInterrupt();
return { consume: true };
}
// Double Escape interrupts the running turn. This must sit below the
// permission branch so Escape keeps meaning "deny" while a prompt is
// pending, and it only arms while a prompt turn is actually running.
if (turnRunning && matchesKey(data, Key.escape)) {
// Once an interrupt is issued, swallow further Escapes until the turn
// ends so a still-settling stop is not requested twice. A rejected stop
// re-arms interruption so the user can retry within the same turn.
if (interruptRequested) return { consume: true };
const now = Date.now();
if (now - lastTurnEscapeAt <= DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS) {
lastTurnEscapeAt = 0;
requestTurnInterrupt();
} else {
lastTurnEscapeAt = now;
}
return { consume: true };
}
// Idle double Escape opens the rewind picker (the same gesture that
// interrupts a running turn). This sits below the turnRunning branch, so it
// only arms when nothing is running. It engages only when the editor has no
// Escape work of its own — empty draft, no autocomplete popup — so the
// editor keeps owning Escape for clearing input and closing autocomplete.
// The first Escape falls through to the editor; only the second, within the
// window, consumes and opens the picker.
if (!busy && !turnRunning && matchesKey(data, Key.escape)) {
const editorNeutral = editor.getText().length === 0 && !editor.isShowingAutocomplete();
if (!editorNeutral) {
lastIdleEscapeAt = 0;
return undefined;
}
const now = Date.now();
if (lastIdleEscapeAt && now - lastIdleEscapeAt <= DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS) {
lastIdleEscapeAt = 0;
void runControl(showRewindPicker);
return { consume: true };
}
lastIdleEscapeAt = now;
return undefined;
}
if (!turnRunning && matchesKey(data, Key.ctrl('c')) && editor.getText().length > 0) {
lastIdleCtrlCAt = 0;
editor.setText('');
requestRender();
return { consume: true };
}
if (!turnRunning && matchesKey(data, Key.ctrl('c'))) {
const now = Date.now();
if (lastIdleCtrlCAt && now - lastIdleCtrlCAt <= DOUBLE_CTRL_C_EXIT_WINDOW_MS) {
lastIdleCtrlCAt = 0;
handleProcessExit(0);
} else {
lastIdleCtrlCAt = now;
state.entries.push({ kind: 'notice', level: 'info', text: 'Press Ctrl+C again to exit.' });
requestRender();
}
return { consume: true };
}
if (matchesKey(data, Key.ctrl('d'))) {
if (busy || turnRunning) return { consume: true };
if (editor.getText().length === 0) {
beginGracefulClose();
return { consume: true };
}
return undefined;
}
return undefined;
});
// Keep older output in the terminal's own scrollback: the transcript is never
// windowed, so when it shrinks (collapsing tool output, a thinking block
// re-wrapping) a full clear would wipe the scrollback the user scrolls through.
// Differential rendering clears the vacated rows without the wipe.
//
// The Ctrl+O / Ctrl+T toggles are viewport-anchored for the same reason: an
// entry above the live viewport lives in terminal scrollback, which cannot
// be rewritten, so resizing it would push pi-tui's differential renderer
// into a scrollback-clearing full redraw (its `firstChanged < viewportTop`
// path). The toggles therefore retarget only entries inside the viewport;
// see entryInLiveViewport in pi-transcript.ts (#1097). A block whose own
// expansion pushed its head above the viewport can consequently never be
// collapsed in place (#1134): the toggles still flip the default and append
// a notice, and the expanded content stays readable in scrollback.
tui.setClearOnShrink(false);
tui.addChild(layout);
tui.setFocus(editorSurface);
try {
tui.start();
// The AttentionController set the initial title in its constructor. Enable
// focus reporting so it learns when the terminal is backgrounded; the input
// listener forwards the `\x1b[I` / `\x1b[O` reports. This must run *after*
// tui.start() puts the terminal in raw mode — otherwise the terminal's reply
// to the enable sequence (a focus-in `\x1b[I`) is echoed by the cooked-mode
// line discipline and leaks onto the screen as a stray `^[[I` on launch.
terminal.write(ENABLE_FOCUS_REPORTING);
if (input.firstRun) void showSetupWizard();
} catch (error) {
beginClose(error instanceof Error ? error : new Error(String(error)));
}
if (input.resumeSessionId) {
void runControl(async () => {
try {
await switchSession(input.resumeSessionId!);
} catch (error) {
state.entries.push({
kind: 'notice',
level: 'error',
text: `Could not resume session ${input.resumeSessionId}: ${error instanceof Error ? error.message : String(error)}. Starting fresh.`,
});
requestRender();
}
});
}
return closedPromise;
}
const BOTTOM_PICKER_MARGIN_ROWS = 4;
// The editor's autocomplete window height. Keep it at least as large as the
// full slash-command menu, so a bare `/` shows every command rather than
// silently clipping the last command.
const EDITOR_AUTOCOMPLETE_MAX_VISIBLE = 24;
function flattenLinkedSessionTree(
roots: readonly SessionSummary[],
childrenByParentId: ReadonlyMap<string, readonly SessionSummary[]>,
): Array<{ session: SessionSummary; depth: number }> {
const flattened: Array<{ session: SessionSummary; depth: number }> = [];
const visit = (session: SessionSummary, depth: number): void => {
flattened.push({ session, depth });
for (const child of childrenByParentId.get(session.id) ?? []) {
visit(child, depth + 1);
}
};
for (const root of roots) visit(root, 0);
return flattened;
}
// A short, stable slice of a session id — enough to tell two same-named
// sessions apart in the picker without showing the full unreadable uuid.
function shortSessionId(id: string): string {
return id.slice(0, 8);
}
// Matches only the four exact "close the TUI" spellings — bare `quit`/`exit`
// and their slash forms — never a prefix or a phrase merely containing one, so
// it can gate both the idle submit path and mid-turn input without swallowing
// an in-turn steering message that happens to mention "quit".
function isExitPrompt(prompt: string): boolean {
const trimmed = prompt.trim();
return trimmed === 'quit' || trimmed === 'exit' || trimmed === '/quit' || trimmed === '/exit';
}
// Two Escapes this close together read as one deliberate "stop the turn".
const DOUBLE_ESCAPE_INTERRUPT_WINDOW_MS = 600;
const DOUBLE_CTRL_C_EXIT_WINDOW_MS = 1_000;