Skip to content

Commit 564e916

Browse files
authored
fix(tui): keep side-session Esc and main run timer consistent (#423)
In a /btw side conversation, an empty Escape no longer arms the double-Escape /edit shortcut or shows its "Press Esc again to edit" hint, since /edit is unavailable in side conversations. Escape still interrupts a live side response. Switching between the side and main views re-adopted the main Session's live Turn at the switch time, which reset the Running/Loading timer and shortened the settled Turn duration. A Turn start ledger now records the earliest observed start (submission, session.start events including hidden Sessions, and adoption), and both re-adoption and the activity line use it. Bump the root and TUI source versions to 0.6.2.
1 parent ef2f43d commit 564e916

14 files changed

Lines changed: 258 additions & 6 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "minimax-code",
3-
"version": "0.6.1",
3+
"version": "0.6.2",
44
"private": true,
55
"type": "module",
66
"description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.",

‎packages/tui/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@minimax/code",
3-
"version": "0.6.1",
3+
"version": "0.6.2",
44
"private": true,
55
"description": "Minimax Code CLI and TUI product entry.",
66
"type": "module",

‎packages/tui/src/tui/app.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
841841
planMode: () => planModeFlow.snapshot(),
842842
sideConversation: () => sessionFlow.sideConversationSnapshot(),
843843
sessionState: () => stateStore.snapshot(),
844+
turnStartedAtMs: (turnId) => controller.turnStartedAtMs(turnId),
844845
welcome,
845846
status,
846847
activity,

‎packages/tui/src/tui/controller/chat-controller.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type {
4646
import { abortTuiChatTurn } from './run/chat-turn-abort.js';
4747
import { TuiRuntimeTurnSettlement } from './run/runtime-turn-settlement.js';
4848
import { createTuiSettledTurn } from './run/turn-settlement.js';
49+
import { TuiTurnStartLedger } from './run/turn-start-ledger.js';
4950

5051
export type { TuiSubmitStatus } from './chat-controller-support.js';
5152
export type {
@@ -73,6 +74,8 @@ export class TuiChatController {
7374
private readonly turnProjection: TuiTurnProjection;
7475
private readonly statusMetrics: TuiStatusMetricsFlow;
7576
private readonly outputRate: TuiTurnOutputRate;
77+
/** Survives projection switches so a re-adopted live Turn keeps its original start. */
78+
private readonly turnStarts = new TuiTurnStartLedger();
7679
readonly runtimeTurnSettlement: TuiRuntimeTurnSettlement;
7780
private state: TuiChatSnapshot = { status: 'idle', sessions: [] };
7881
private activeTurn?: TuiActiveTurn;
@@ -368,6 +371,7 @@ export class TuiChatController {
368371
this.turnProjection.removeOptimisticUserMessage(options.optimisticRequestId);
369372
}
370373
const timestamp = optimisticCell?.createdAtMs ?? this.now();
374+
this.turnStarts.record(turnId, timestamp);
371375
this.outputRate.beginTurn(turnId);
372376
if (!isRetryContinuation) {
373377
this.transcript.upsert({
@@ -657,6 +661,16 @@ export class TuiChatController {
657661
if (this.transcript.remove(id)) this.notify();
658662
}
659663

664+
/** Records an observed Turn start; returns the earliest start known for that Turn. */
665+
recordTurnStart(turnId: string, timestampMs: number): number {
666+
return this.turnStarts.record(turnId, timestampMs);
667+
}
668+
669+
/** Earliest observed start of a Turn, independent of the currently loaded projection. */
670+
turnStartedAtMs(turnId: string): number | undefined {
671+
return this.turnStarts.get(turnId);
672+
}
673+
660674
beginRuntimeTurn(turnId: string, timestamp: number): void {
661675
this.outputRate.beginTurn(turnId);
662676
this.turnProjection.beginTurn(turnId, timestamp);

‎packages/tui/src/tui/controller/interaction/input-flow.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,13 @@ export class TuiInputFlow {
450450
this.lastEscapeAtMs = 0;
451451
return undefined;
452452
}
453+
if (this.options.isSideModeActive?.()) {
454+
// /edit is outside the side-mode command surface, so double Escape
455+
// must neither advertise nor attempt it in a side conversation.
456+
this.lastEscapeAtMs = 0;
457+
this.options.setHint(undefined);
458+
return undefined;
459+
}
453460
const now = Date.now();
454461
if (this.lastEscapeAtMs > 0 && now - this.lastEscapeAtMs < DOUBLE_ESCAPE_WINDOW_MS) {
455462
this.lastEscapeAtMs = 0;

‎packages/tui/src/tui/controller/product/chrome-flow.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export class TuiChromeFlow {
7575
readonly sideConversation?: () => TuiSideConversationSnapshot | undefined;
7676
/** Multi-Session state kernel, including the background parent Turn. */
7777
readonly sessionState?: () => Pick<TuiState, 'sessions'>;
78+
/** Earliest observed start of a Turn, kept across Session projection switches. */
79+
readonly turnStartedAtMs?: (turnId: string) => number | undefined;
7880
readonly welcome: PresentationSink<'shell'>;
7981
readonly status: PresentationSink<'shell'>;
8082
readonly activity: PresentationSink<'activity'>;
@@ -199,6 +201,7 @@ export class TuiChromeFlow {
199201
const sideConversation = this.resolveSideConversationPresentation(snapshot);
200202
return sideConversation ? { sideConversation } : {};
201203
})(),
204+
...(this.options.turnStartedAtMs ? { turnStartedAtMs: this.options.turnStartedAtMs } : {}),
202205
});
203206
const agentCounts = this.options.agentCounts?.();
204207
const backgroundTasks = this.options.backgroundTaskCount?.();

‎packages/tui/src/tui/controller/projection/visible-presentation.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export interface ResolveTuiVisiblePresentationInput {
4848
readonly planMode?: TuiPlanModeSnapshot;
4949
/** Present while a paired BTW side conversation exists; identifies the visible half. */
5050
readonly sideConversation?: TuiSideConversationPresentation;
51+
/** Earliest observed start of a Turn, kept across Session projection switches. */
52+
readonly turnStartedAtMs?: (turnId: string) => number | undefined;
5153
}
5254

5355
export interface TuiVisiblePresentation {
@@ -59,7 +61,14 @@ export interface TuiVisiblePresentation {
5961
export function resolveTuiVisiblePresentation(
6062
input: ResolveTuiVisiblePresentationInput,
6163
): TuiVisiblePresentation {
62-
const visibleActivity = resolveVisibleActivity(input);
64+
const resolvedActivity = resolveVisibleActivity(input);
65+
// Anchor the timer to the Turn's real start; the activity line would otherwise
66+
// restart it whenever a projection switch briefly hides the run.
67+
const startedAtMs = resolvedActivity.runId
68+
? input.turnStartedAtMs?.(resolvedActivity.runId)
69+
: undefined;
70+
const visibleActivity =
71+
startedAtMs === undefined ? resolvedActivity : { ...resolvedActivity, startedAtMs };
6372
const activity =
6473
visibleActivity.phase !== 'idle' &&
6574
visibleActivity.phase !== 'error' &&
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* TUI-process memory of when each Runtime Turn started.
3+
*
4+
* Switching projections (most often between a paired BTW side conversation and
5+
* its main Session) reloads the target projection and re-adopts its live Turn.
6+
* Without this ledger the re-adoption time would masquerade as the Turn start,
7+
* resetting the running/loading timer and the settled Turn duration. Turn ids
8+
* are globally unique, so the earliest observation of a Turn wins.
9+
*/
10+
export class TuiTurnStartLedger {
11+
private readonly startedAtMs = new Map<string, number>();
12+
13+
constructor(private readonly capacity = 64) {}
14+
15+
/** Records an observed start and returns the earliest known start for the Turn. */
16+
record(turnId: string, timestampMs: number): number {
17+
const known = this.startedAtMs.get(turnId);
18+
if (!Number.isFinite(timestampMs)) return known ?? timestampMs;
19+
if (known !== undefined && known <= timestampMs) return known;
20+
// Re-insert so eviction stays least-recently-started first.
21+
this.startedAtMs.delete(turnId);
22+
this.startedAtMs.set(turnId, timestampMs);
23+
while (this.startedAtMs.size > this.capacity) {
24+
const oldest = this.startedAtMs.keys().next();
25+
if (oldest.done) break;
26+
this.startedAtMs.delete(oldest.value);
27+
}
28+
return timestampMs;
29+
}
30+
31+
get(turnId: string): number | undefined {
32+
return this.startedAtMs.get(turnId);
33+
}
34+
}

‎packages/tui/src/tui/controller/runtime/runtime-event-flow.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,11 @@ export class TuiRuntimeEventFlow {
527527
}
528528
const currentSessionId = this.options.controller.snapshot().session?.sessionId;
529529
const matchesCurrentSession = Boolean(currentSessionId) && event.sessionId === currentSessionId;
530+
if (event.type === 'session.start' && event.turnId && event.timestampMs !== undefined) {
531+
// Also for hidden Sessions (e.g. the main Session behind a side view), so
532+
// switching back later shows the true elapsed time.
533+
this.options.controller.recordTurnStart(event.turnId, event.timestampMs);
534+
}
530535
let liveTurnDurationMs: number | undefined;
531536
const sessionScopedResult = this.handleSessionScopedEvent(event, matchesCurrentSession);
532537
if (sessionScopedResult === true) return;
@@ -738,16 +743,22 @@ export class TuiRuntimeEventFlow {
738743
void previousLiveTurn?.task.catch(() => undefined);
739744

740745
const controller = new AbortController();
746+
// Re-adopting a Turn after a projection switch passes the adoption time;
747+
// keep the earliest observed start so the visible timer does not reset.
748+
const startedAtMs =
749+
timestampMs === undefined
750+
? this.options.controller.turnStartedAtMs(turnId)
751+
: this.options.controller.recordTurnStart(turnId, timestampMs);
741752
if (!isSameTurn) {
742-
this.options.controller.beginRuntimeTurn(turnId, timestampMs ?? Date.now());
753+
this.options.controller.beginRuntimeTurn(turnId, startedAtMs ?? Date.now());
743754
this.options.runProjection.markRecoveredTurn(turnId);
744755
}
745756
const liveTurn = {
746757
sessionId,
747758
turnId,
748759
controller,
749760
afterMsgId,
750-
startedAtMs: timestampMs,
761+
startedAtMs,
751762
task: Promise.resolve(),
752763
};
753764
// The stream can end before the lifecycle terminal event supplies the end time.

‎packages/tui/test/unit/tui-app.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7367,6 +7367,90 @@ describe("createTuiApp", () => {
73677367
).toBeGreaterThan(0);
73687368
});
73697369

7370+
it("keeps the main run timer when switching to a side conversation and back", async () => {
7371+
let clock = 1_000_000;
7372+
const now = vi.spyOn(Date, "now").mockImplementation(() => clock);
7373+
const terminal = new FakeTerminal();
7374+
terminal.columns = 100;
7375+
terminal.rows = 20;
7376+
const runtime = createRuntime();
7377+
let mainTurnId: string | undefined;
7378+
let releaseMainTurn: () => void = () => undefined;
7379+
const mainTurnReleased = new Promise<void>((resolve) => {
7380+
releaseMainTurn = resolve;
7381+
});
7382+
const mainSession = {
7383+
sessionId: "session-1",
7384+
title: "New session",
7385+
workspaceDir: "/workspace",
7386+
};
7387+
const sideSession = {
7388+
sessionId: "side-1",
7389+
title: "BTW",
7390+
workspaceDir: "/workspace",
7391+
parentSessionId: "session-1",
7392+
purpose: "peek_btw_session",
7393+
sessionKind: "peek" as const,
7394+
};
7395+
vi.mocked(runtime.createSession).mockImplementation(async (input) =>
7396+
input?.purpose === sideSession.purpose ? { ...sideSession } : { ...mainSession },
7397+
);
7398+
vi.mocked(runtime.getSession).mockImplementation(async (sessionId) =>
7399+
sessionId === sideSession.sessionId ? { ...sideSession } : { ...mainSession, sessionId },
7400+
);
7401+
vi.mocked(runtime.getActiveRun).mockImplementation(async (sessionId) => {
7402+
const running = sessionId === "session-1" && mainTurnId !== undefined;
7403+
return {
7404+
schemaVersion: 1 as const,
7405+
sessionId,
7406+
state: running ? ("running" as const) : ("idle" as const),
7407+
...(running ? { turnId: mainTurnId } : {}),
7408+
actions: { steer: false },
7409+
};
7410+
});
7411+
vi.mocked(runtime.watchSessionTurn).mockImplementation(
7412+
async function* watchSessionTurn(_sessionId, _turnId, signal) {
7413+
await new Promise<void>((resolve) => {
7414+
signal.addEventListener("abort", () => resolve(), { once: true });
7415+
});
7416+
for (const event of [] as TuiStreamEvent[]) yield event;
7417+
},
7418+
);
7419+
vi.mocked(runtime.sendMessage).mockImplementation(
7420+
async function* sendMessage(request): AsyncGenerator<TuiStreamEvent> {
7421+
mainTurnId = request.turnId;
7422+
yield { type: "delta", content: "Working on main" };
7423+
await mainTurnReleased;
7424+
yield { type: "done" };
7425+
},
7426+
);
7427+
const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" });
7428+
app.start();
7429+
await app.ready;
7430+
try {
7431+
void app.submit("main task");
7432+
await vi.waitFor(() => expect(mainTurnId).toBeDefined());
7433+
clock += 42_000;
7434+
await vi.waitFor(() => expect(renderTerminalViewport(app, terminal)).toContain("42s"));
7435+
7436+
await app.submit("/btw");
7437+
await vi.waitFor(() => expect(app.controller.snapshot().session?.sessionId).toBe("side-1"));
7438+
clock += 5_000;
7439+
await app.submit("/parent");
7440+
await vi.waitFor(() =>
7441+
expect(app.controller.snapshot().session?.sessionId).toBe("session-1"),
7442+
);
7443+
clock += 3_000;
7444+
7445+
// 42s before the switch + 5s in the side view + 3s back on main.
7446+
await vi.waitFor(() => expect(renderTerminalViewport(app, terminal)).toMatch(/\b50s\b/u));
7447+
} finally {
7448+
releaseMainTurn();
7449+
await app.stop();
7450+
now.mockRestore();
7451+
}
7452+
});
7453+
73707454
it("does not call forkSession when /fork is cancelled, unavailable, or fails", async () => {
73717455
const scenarios: ReadonlyArray<{
73727456
readonly label: string;

0 commit comments

Comments
 (0)