Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimax-code",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"type": "module",
"description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.",
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@minimax/code",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"description": "Minimax Code CLI and TUI product entry.",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
planMode: () => planModeFlow.snapshot(),
sideConversation: () => sessionFlow.sideConversationSnapshot(),
sessionState: () => stateStore.snapshot(),
turnStartedAtMs: (turnId) => controller.turnStartedAtMs(turnId),
welcome,
status,
activity,
Expand Down
14 changes: 14 additions & 0 deletions packages/tui/src/tui/controller/chat-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
import { abortTuiChatTurn } from './run/chat-turn-abort.js';
import { TuiRuntimeTurnSettlement } from './run/runtime-turn-settlement.js';
import { createTuiSettledTurn } from './run/turn-settlement.js';
import { TuiTurnStartLedger } from './run/turn-start-ledger.js';

export type { TuiSubmitStatus } from './chat-controller-support.js';
export type {
Expand Down Expand Up @@ -73,6 +74,8 @@ export class TuiChatController {
private readonly turnProjection: TuiTurnProjection;
private readonly statusMetrics: TuiStatusMetricsFlow;
private readonly outputRate: TuiTurnOutputRate;
/** Survives projection switches so a re-adopted live Turn keeps its original start. */
private readonly turnStarts = new TuiTurnStartLedger();
readonly runtimeTurnSettlement: TuiRuntimeTurnSettlement;
private state: TuiChatSnapshot = { status: 'idle', sessions: [] };
private activeTurn?: TuiActiveTurn;
Expand Down Expand Up @@ -368,6 +371,7 @@ export class TuiChatController {
this.turnProjection.removeOptimisticUserMessage(options.optimisticRequestId);
}
const timestamp = optimisticCell?.createdAtMs ?? this.now();
this.turnStarts.record(turnId, timestamp);
this.outputRate.beginTurn(turnId);
if (!isRetryContinuation) {
this.transcript.upsert({
Expand Down Expand Up @@ -657,6 +661,16 @@ export class TuiChatController {
if (this.transcript.remove(id)) this.notify();
}

/** Records an observed Turn start; returns the earliest start known for that Turn. */
recordTurnStart(turnId: string, timestampMs: number): number {
return this.turnStarts.record(turnId, timestampMs);
}

/** Earliest observed start of a Turn, independent of the currently loaded projection. */
turnStartedAtMs(turnId: string): number | undefined {
return this.turnStarts.get(turnId);
}

beginRuntimeTurn(turnId: string, timestamp: number): void {
this.outputRate.beginTurn(turnId);
this.turnProjection.beginTurn(turnId, timestamp);
Expand Down
7 changes: 7 additions & 0 deletions packages/tui/src/tui/controller/interaction/input-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,13 @@ export class TuiInputFlow {
this.lastEscapeAtMs = 0;
return undefined;
}
if (this.options.isSideModeActive?.()) {
// /edit is outside the side-mode command surface, so double Escape
// must neither advertise nor attempt it in a side conversation.
this.lastEscapeAtMs = 0;
this.options.setHint(undefined);
return undefined;
}
const now = Date.now();
if (this.lastEscapeAtMs > 0 && now - this.lastEscapeAtMs < DOUBLE_ESCAPE_WINDOW_MS) {
this.lastEscapeAtMs = 0;
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/tui/controller/product/chrome-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export class TuiChromeFlow {
readonly sideConversation?: () => TuiSideConversationSnapshot | undefined;
/** Multi-Session state kernel, including the background parent Turn. */
readonly sessionState?: () => Pick<TuiState, 'sessions'>;
/** Earliest observed start of a Turn, kept across Session projection switches. */
readonly turnStartedAtMs?: (turnId: string) => number | undefined;
readonly welcome: PresentationSink<'shell'>;
readonly status: PresentationSink<'shell'>;
readonly activity: PresentationSink<'activity'>;
Expand Down Expand Up @@ -199,6 +201,7 @@ export class TuiChromeFlow {
const sideConversation = this.resolveSideConversationPresentation(snapshot);
return sideConversation ? { sideConversation } : {};
})(),
...(this.options.turnStartedAtMs ? { turnStartedAtMs: this.options.turnStartedAtMs } : {}),
});
const agentCounts = this.options.agentCounts?.();
const backgroundTasks = this.options.backgroundTaskCount?.();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface ResolveTuiVisiblePresentationInput {
readonly planMode?: TuiPlanModeSnapshot;
/** Present while a paired BTW side conversation exists; identifies the visible half. */
readonly sideConversation?: TuiSideConversationPresentation;
/** Earliest observed start of a Turn, kept across Session projection switches. */
readonly turnStartedAtMs?: (turnId: string) => number | undefined;
}

export interface TuiVisiblePresentation {
Expand All @@ -59,7 +61,14 @@ export interface TuiVisiblePresentation {
export function resolveTuiVisiblePresentation(
input: ResolveTuiVisiblePresentationInput,
): TuiVisiblePresentation {
const visibleActivity = resolveVisibleActivity(input);
const resolvedActivity = resolveVisibleActivity(input);
// Anchor the timer to the Turn's real start; the activity line would otherwise
// restart it whenever a projection switch briefly hides the run.
const startedAtMs = resolvedActivity.runId
? input.turnStartedAtMs?.(resolvedActivity.runId)
: undefined;
const visibleActivity =
startedAtMs === undefined ? resolvedActivity : { ...resolvedActivity, startedAtMs };
const activity =
visibleActivity.phase !== 'idle' &&
visibleActivity.phase !== 'error' &&
Expand Down
34 changes: 34 additions & 0 deletions packages/tui/src/tui/controller/run/turn-start-ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* TUI-process memory of when each Runtime Turn started.
*
* Switching projections (most often between a paired BTW side conversation and
* its main Session) reloads the target projection and re-adopts its live Turn.
* Without this ledger the re-adoption time would masquerade as the Turn start,
* resetting the running/loading timer and the settled Turn duration. Turn ids
* are globally unique, so the earliest observation of a Turn wins.
*/
export class TuiTurnStartLedger {
private readonly startedAtMs = new Map<string, number>();

constructor(private readonly capacity = 64) {}

/** Records an observed start and returns the earliest known start for the Turn. */
record(turnId: string, timestampMs: number): number {
const known = this.startedAtMs.get(turnId);
if (!Number.isFinite(timestampMs)) return known ?? timestampMs;
if (known !== undefined && known <= timestampMs) return known;
// Re-insert so eviction stays least-recently-started first.
this.startedAtMs.delete(turnId);
this.startedAtMs.set(turnId, timestampMs);
while (this.startedAtMs.size > this.capacity) {
const oldest = this.startedAtMs.keys().next();
if (oldest.done) break;
this.startedAtMs.delete(oldest.value);
}
return timestampMs;
}

get(turnId: string): number | undefined {
return this.startedAtMs.get(turnId);
}
}
15 changes: 13 additions & 2 deletions packages/tui/src/tui/controller/runtime/runtime-event-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,11 @@ export class TuiRuntimeEventFlow {
}
const currentSessionId = this.options.controller.snapshot().session?.sessionId;
const matchesCurrentSession = Boolean(currentSessionId) && event.sessionId === currentSessionId;
if (event.type === 'session.start' && event.turnId && event.timestampMs !== undefined) {
// Also for hidden Sessions (e.g. the main Session behind a side view), so
// switching back later shows the true elapsed time.
this.options.controller.recordTurnStart(event.turnId, event.timestampMs);
}
let liveTurnDurationMs: number | undefined;
const sessionScopedResult = this.handleSessionScopedEvent(event, matchesCurrentSession);
if (sessionScopedResult === true) return;
Expand Down Expand Up @@ -738,16 +743,22 @@ export class TuiRuntimeEventFlow {
void previousLiveTurn?.task.catch(() => undefined);

const controller = new AbortController();
// Re-adopting a Turn after a projection switch passes the adoption time;
// keep the earliest observed start so the visible timer does not reset.
const startedAtMs =
timestampMs === undefined
? this.options.controller.turnStartedAtMs(turnId)
: this.options.controller.recordTurnStart(turnId, timestampMs);
if (!isSameTurn) {
this.options.controller.beginRuntimeTurn(turnId, timestampMs ?? Date.now());
this.options.controller.beginRuntimeTurn(turnId, startedAtMs ?? Date.now());
this.options.runProjection.markRecoveredTurn(turnId);
}
const liveTurn = {
sessionId,
turnId,
controller,
afterMsgId,
startedAtMs: timestampMs,
startedAtMs,
task: Promise.resolve(),
};
// The stream can end before the lifecycle terminal event supplies the end time.
Expand Down
84 changes: 84 additions & 0 deletions packages/tui/test/unit/tui-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7367,6 +7367,90 @@ describe("createTuiApp", () => {
).toBeGreaterThan(0);
});

it("keeps the main run timer when switching to a side conversation and back", async () => {
let clock = 1_000_000;
const now = vi.spyOn(Date, "now").mockImplementation(() => clock);
const terminal = new FakeTerminal();
terminal.columns = 100;
terminal.rows = 20;
const runtime = createRuntime();
let mainTurnId: string | undefined;
let releaseMainTurn: () => void = () => undefined;
const mainTurnReleased = new Promise<void>((resolve) => {
releaseMainTurn = resolve;
});
const mainSession = {
sessionId: "session-1",
title: "New session",
workspaceDir: "/workspace",
};
const sideSession = {
sessionId: "side-1",
title: "BTW",
workspaceDir: "/workspace",
parentSessionId: "session-1",
purpose: "peek_btw_session",
sessionKind: "peek" as const,
};
vi.mocked(runtime.createSession).mockImplementation(async (input) =>
input?.purpose === sideSession.purpose ? { ...sideSession } : { ...mainSession },
);
vi.mocked(runtime.getSession).mockImplementation(async (sessionId) =>
sessionId === sideSession.sessionId ? { ...sideSession } : { ...mainSession, sessionId },
);
vi.mocked(runtime.getActiveRun).mockImplementation(async (sessionId) => {
const running = sessionId === "session-1" && mainTurnId !== undefined;
return {
schemaVersion: 1 as const,
sessionId,
state: running ? ("running" as const) : ("idle" as const),
...(running ? { turnId: mainTurnId } : {}),
actions: { steer: false },
};
});
vi.mocked(runtime.watchSessionTurn).mockImplementation(
async function* watchSessionTurn(_sessionId, _turnId, signal) {
await new Promise<void>((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
for (const event of [] as TuiStreamEvent[]) yield event;
},
);
vi.mocked(runtime.sendMessage).mockImplementation(
async function* sendMessage(request): AsyncGenerator<TuiStreamEvent> {
mainTurnId = request.turnId;
yield { type: "delta", content: "Working on main" };
await mainTurnReleased;
yield { type: "done" };
},
);
const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" });
app.start();
await app.ready;
try {
void app.submit("main task");
await vi.waitFor(() => expect(mainTurnId).toBeDefined());
clock += 42_000;
await vi.waitFor(() => expect(renderTerminalViewport(app, terminal)).toContain("42s"));

await app.submit("/btw");
await vi.waitFor(() => expect(app.controller.snapshot().session?.sessionId).toBe("side-1"));
clock += 5_000;
await app.submit("/parent");
await vi.waitFor(() =>
expect(app.controller.snapshot().session?.sessionId).toBe("session-1"),
);
clock += 3_000;

// 42s before the switch + 5s in the side view + 3s back on main.
await vi.waitFor(() => expect(renderTerminalViewport(app, terminal)).toMatch(/\b50s\b/u));
} finally {
releaseMainTurn();
await app.stop();
now.mockRestore();
}
});

it("does not call forkSession when /fork is cancelled, unavailable, or fails", async () => {
const scenarios: ReadonlyArray<{
readonly label: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,37 @@ describe("TuiInputFlow steer Draft handling", () => {
expect(harness.abortLiveTurn).not.toHaveBeenCalled();
});

it("neither advertises nor opens Edit on double Escape in an idle side session", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
try {
const harness = createHarness(
"",
{ attachments: [] },
false,
false,
false,
"consumed",
false,
vi.fn(() => false),
undefined,
true,
);

expect(harness.inputListener?.("\u001b")).toBeUndefined();
now.mockReturnValue(1_200);
expect(harness.inputListener?.("\u001b")).toBeUndefined();

expect(harness.setHint).not.toHaveBeenCalledWith(
"Press Esc again to edit the latest message",
);
await Promise.resolve();
expect(harness.commandFlow.submit).not.toHaveBeenCalled();
expect(harness.abortLiveTurn).not.toHaveBeenCalled();
} finally {
now.mockRestore();
}
});

it("uses Pi app semantics: Ctrl+C clears while Escape interrupts a live turn", () => {
const harness = createHarness("keep this Draft", { attachments: [] });

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resolveTuiVisiblePresentation } from "../../../../../src/tui/controller/projection/visible-presentation.js";
import { createTranscriptCell } from "../../../../../src/tui/transcript/model.js";
import { TranscriptStore } from "../../../../../src/tui/transcript/store.js";
Expand Down Expand Up @@ -232,6 +232,25 @@ describe("visible presentation selector", () => {
expect(presentation.composer.hint).toBeUndefined();
});

it("anchors the run timer to the recorded Turn start instead of the projection switch", () => {
const turnStartedAtMs = vi.fn((turnId: string) => (turnId === "turn-1" ? 1_000 : undefined));

expect(
resolve({
snapshot: { ...idleChat, status: "running", activeTurnId: "turn-1" },
currentLiveRunId: "turn-1",
turnStartedAtMs,
}).activity,
).toMatchObject({ phase: "loading", runId: "turn-1", startedAtMs: 1_000 });
expect(
resolve({
snapshot: { ...idleChat, status: "running", activeTurnId: "turn-unknown" },
currentLiveRunId: "turn-unknown",
turnStartedAtMs,
}).activity,
).not.toHaveProperty("startedAtMs");
});

it("keeps a pending Runtime stop more urgent than a transient warning", () => {
expect(
resolve({
Expand Down
Loading
Loading