diff --git a/package.json b/package.json index 3bb08349e..4b21ce611 100644 --- a/package.json +++ b/package.json @@ -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.", diff --git a/packages/tui/package.json b/packages/tui/package.json index bdb3bdbed..8e30644ea 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -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", diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 7928402a6..d9e5f9db0 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -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, diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index e1a72a97e..d1e9b2dc6 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -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 { @@ -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; @@ -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({ @@ -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); diff --git a/packages/tui/src/tui/controller/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index 612196e7f..115867263 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -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; diff --git a/packages/tui/src/tui/controller/product/chrome-flow.ts b/packages/tui/src/tui/controller/product/chrome-flow.ts index 5b63bf918..90a089f43 100644 --- a/packages/tui/src/tui/controller/product/chrome-flow.ts +++ b/packages/tui/src/tui/controller/product/chrome-flow.ts @@ -75,6 +75,8 @@ export class TuiChromeFlow { readonly sideConversation?: () => TuiSideConversationSnapshot | undefined; /** Multi-Session state kernel, including the background parent Turn. */ readonly sessionState?: () => Pick; + /** 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'>; @@ -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?.(); diff --git a/packages/tui/src/tui/controller/projection/visible-presentation.ts b/packages/tui/src/tui/controller/projection/visible-presentation.ts index dec0e3197..daf0fcab2 100644 --- a/packages/tui/src/tui/controller/projection/visible-presentation.ts +++ b/packages/tui/src/tui/controller/projection/visible-presentation.ts @@ -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 { @@ -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' && diff --git a/packages/tui/src/tui/controller/run/turn-start-ledger.ts b/packages/tui/src/tui/controller/run/turn-start-ledger.ts new file mode 100644 index 000000000..98b1cfa71 --- /dev/null +++ b/packages/tui/src/tui/controller/run/turn-start-ledger.ts @@ -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(); + + 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); + } +} diff --git a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts index 57d5aa206..7d8e2eb3e 100644 --- a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts +++ b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts @@ -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; @@ -738,8 +743,14 @@ 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 = { @@ -747,7 +758,7 @@ export class TuiRuntimeEventFlow { turnId, controller, afterMsgId, - startedAtMs: timestampMs, + startedAtMs, task: Promise.resolve(), }; // The stream can end before the lifecycle terminal event supplies the end time. diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 0b3429255..dc6a1dbf5 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -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((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((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 { + 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; diff --git a/packages/tui/test/unit/tui/controller/interaction/input-flow.test.ts b/packages/tui/test/unit/tui/controller/interaction/input-flow.test.ts index 5663ccb11..e8d7d7bbf 100644 --- a/packages/tui/test/unit/tui/controller/interaction/input-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/interaction/input-flow.test.ts @@ -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: [] }); diff --git a/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts b/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts index 53ed4182c..49ffec222 100644 --- a/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts +++ b/packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts @@ -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"; @@ -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({ diff --git a/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts b/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts index 7091e204a..f5666385f 100644 --- a/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts +++ b/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts @@ -14,6 +14,7 @@ import { TuiStateStore, } from '../../../../../src/tui/state/index.js'; import { TranscriptStore } from '../../../../../src/tui/transcript/store.js'; +import { TuiTurnStartLedger } from '../../../../../src/tui/controller/run/turn-start-ledger.js'; function lifecycle( type: 'session.start' | 'session.finish' | 'session.error' | 'session.abort', @@ -126,6 +127,7 @@ function createFixture(options?: { }; const settleRuntimeTurnProjection = vi.fn(); const reconcileOwnerHistory = vi.fn(async () => true); + const turnStarts = new TuiTurnStartLedger(); const controller = { snapshot: vi.fn(() => ({ session: { sessionId: 'session-1' }, @@ -134,6 +136,9 @@ function createFixture(options?: { refreshCurrentSessionHistory: vi.fn(async () => undefined), refreshSessionMetadata: vi.fn(async () => undefined), beginRuntimeTurn: vi.fn(), + recordTurnStart: (turnId: string, timestampMs: number) => + turnStarts.record(turnId, timestampMs), + turnStartedAtMs: (turnId: string) => turnStarts.get(turnId), applyRuntimeTurnEvent: vi.fn(), runtimeTurnSettlement: { settle: vi.fn(async () => undefined), @@ -182,6 +187,7 @@ function createFixture(options?: { return { flow, + controller, runtime, runProjection, activeRunFlow, @@ -522,3 +528,35 @@ describe('TuiRuntimeEventFlow stale-run safety net', () => { fixture.flow.stop(); }); }); + +describe('TuiRuntimeEventFlow Turn start ledger', () => { + it('keeps the first observed Turn start when a projection switch re-adopts the Turn', async () => { + const fixture = createFixture(); + + // Submitted while the main Session was visible, then hidden behind a side view. + fixture.controller.recordTurnStart('turn-main', 100); + // Switching back re-adopts the still-running Turn at the switch time. + fixture.flow.adoptRuntimeTurn('session-1', 'turn-main', 5_000); + + await vi.waitFor(() => + expect(fixture.controller.beginRuntimeTurn).toHaveBeenCalledWith('turn-main', 100), + ); + expect(fixture.controller.turnStartedAtMs('turn-main')).toBe(100); + }); + + it('records Turn starts observed for a hidden Session', async () => { + const fixture = createFixture(); + + await handle(fixture.flow, { + ...lifecycle('session.start', 'turn-hidden'), + sessionId: 'session-hidden', + timestampMs: 40, + }); + + expect(fixture.controller.turnStartedAtMs('turn-hidden')).toBe(40); + expect(fixture.controller.beginRuntimeTurn).not.toHaveBeenCalledWith( + 'turn-hidden', + expect.anything(), + ); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index a02b4a037..6a14ccf22 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3059,6 +3059,7 @@ "packages/tui/src/tui/controller/run/queue-flow.ts", "packages/tui/src/tui/controller/run/runtime-turn-settlement.ts", "packages/tui/src/tui/controller/run/turn-settlement.ts", + "packages/tui/src/tui/controller/run/turn-start-ledger.ts", "packages/tui/src/tui/controller/run/turn-submission-retainer.ts", "packages/tui/src/tui/controller/runtime/event-stream-reconciliation.ts", "packages/tui/src/tui/controller/runtime/runtime-error-presentation.ts",