mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 00:01:36 +00:00
fix(tui): keep session modes scoped on switch (#117658)
* fix(tui): keep session footer modes truthful * test(tui): record footer behavior evidence
This commit is contained in:
committed by
GitHub
parent
ac532e141a
commit
08ea9cc31d
@@ -87,6 +87,7 @@ export type TuiSessionList = {
|
||||
| "thinkingLevels"
|
||||
| "fastMode"
|
||||
| "verboseLevel"
|
||||
| "traceLevel"
|
||||
| "reasoningLevel"
|
||||
| "model"
|
||||
| "contextTokens"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Covers formatting helpers used by TUI status and message rendering.
|
||||
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markInboundContextLabel } from "../auto-reply/reply/inbound-context-marker.js";
|
||||
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js";
|
||||
@@ -6,56 +7,106 @@ import {
|
||||
extractContentFromMessage,
|
||||
extractTextFromMessage,
|
||||
extractThinkingFromMessage,
|
||||
formatModelFooter,
|
||||
formatGoalFooter,
|
||||
formatTuiFooter,
|
||||
formatTuiErrorMessage,
|
||||
isCommandMessage,
|
||||
sanitizeRenderableText,
|
||||
} from "./tui-formatters.js";
|
||||
|
||||
describe("formatModelFooter", () => {
|
||||
it("shows a compact model name and its active thinking level", () => {
|
||||
describe("formatTuiFooter", () => {
|
||||
it("shows session modes and the process delivery mode in one compact summary", () => {
|
||||
expect(
|
||||
formatModelFooter({
|
||||
model: "gpt-5.6-sol@openai:setup-64cddea3-938c-431e-be3b-aa47090577c7",
|
||||
formatTuiFooter({
|
||||
agentLabel: "Main",
|
||||
sessionLabel: "work",
|
||||
sessionInfo: {
|
||||
model: "gpt-5.6-sol@openai:setup-64cddea3-938c-431e-be3b-aa47090577c7",
|
||||
fastMode: "auto",
|
||||
verboseLevel: "full",
|
||||
traceLevel: "raw",
|
||||
reasoningLevel: "stream",
|
||||
totalTokens: 1_200,
|
||||
contextTokens: 128_000,
|
||||
},
|
||||
thinkingLevel: "high",
|
||||
deliver: true,
|
||||
}),
|
||||
).toBe("gpt-5.6-sol high");
|
||||
).toBe(
|
||||
"agent Main | session work | gpt-5.6-sol high | fast:auto | verbose full | trace:raw | reasoning:stream | deliver:on | tokens 1.2k/128k (1%)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGoalFooter", () => {
|
||||
it("renders active goal usage", () => {
|
||||
it("keeps disabled session modes hidden while reporting disabled delivery", () => {
|
||||
expect(
|
||||
formatGoalFooter({
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "land PR",
|
||||
status: "active",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokensUsed: 12_000,
|
||||
tokenBudget: 30_000,
|
||||
continuationTurns: 0,
|
||||
formatTuiFooter({
|
||||
agentLabel: "Main",
|
||||
sessionLabel: "main",
|
||||
sessionInfo: { model: "fixture-model" },
|
||||
deliver: false,
|
||||
}),
|
||||
).toBe("Pursuing goal (12k/30k)");
|
||||
).toBe("agent Main | session main | fixture-model | deliver:off | tokens ?");
|
||||
});
|
||||
|
||||
it("wraps the compact summary within the terminal width", () => {
|
||||
const summary = formatTuiFooter({
|
||||
agentLabel: "Main",
|
||||
sessionLabel: "a-long-session-name",
|
||||
sessionInfo: {
|
||||
model: "fixture-provider/a-long-model-name",
|
||||
traceLevel: "raw",
|
||||
reasoningLevel: "stream",
|
||||
},
|
||||
deliver: true,
|
||||
});
|
||||
|
||||
expect(new Text(summary, 1, 0).render(48).every((line) => visibleWidth(line) <= 48)).toBe(true);
|
||||
});
|
||||
|
||||
it("renders active goal usage", () => {
|
||||
const footer = formatTuiFooter({
|
||||
agentLabel: "Main",
|
||||
sessionLabel: "main",
|
||||
sessionInfo: {
|
||||
goal: {
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "land PR",
|
||||
status: "active",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokensUsed: 12_000,
|
||||
tokenBudget: 30_000,
|
||||
continuationTurns: 0,
|
||||
},
|
||||
},
|
||||
deliver: false,
|
||||
});
|
||||
|
||||
expect(footer).toContain("Pursuing goal (12k/30k)");
|
||||
});
|
||||
|
||||
it("renders resumable blocked goals", () => {
|
||||
expect(
|
||||
formatGoalFooter({
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "land PR",
|
||||
status: "blocked",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokensUsed: 0,
|
||||
continuationTurns: 0,
|
||||
}),
|
||||
).toBe("Goal blocked (/goal resume)");
|
||||
const footer = formatTuiFooter({
|
||||
agentLabel: "Main",
|
||||
sessionLabel: "main",
|
||||
sessionInfo: {
|
||||
goal: {
|
||||
schemaVersion: 1,
|
||||
id: "goal-1",
|
||||
objective: "land PR",
|
||||
status: "blocked",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
tokenStart: 0,
|
||||
tokensUsed: 0,
|
||||
continuationTurns: 0,
|
||||
},
|
||||
},
|
||||
deliver: false,
|
||||
});
|
||||
|
||||
expect(footer).toContain("Goal blocked (/goal resume)");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.j
|
||||
import { extractAssistantVisibleText } from "../shared/chat-message-content.js";
|
||||
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
|
||||
import { formatTokenCount } from "../utils/usage-format.js";
|
||||
import type { SessionInfo } from "./tui-types.js";
|
||||
|
||||
const REPLACEMENT_CHAR_RE = /\uFFFD/g;
|
||||
const MAX_TOKEN_CHARS = 32;
|
||||
@@ -35,7 +36,7 @@ const FENCED_CODE_RE = /(```|~~~)[^\n]*\n[\s\S]*?\n\1[^\n]*/g;
|
||||
const INLINE_CODE_RE = /(`+)(?:(?!\1).)+?\1/g;
|
||||
|
||||
/** Keep routing/provider/profile details in session state, not the compact footer. */
|
||||
export function formatModelFooter(params: {
|
||||
function formatModelFooter(params: {
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
}): string {
|
||||
@@ -44,6 +45,39 @@ export function formatModelFooter(params: {
|
||||
return thinkingLevel && thinkingLevel !== "off" ? `${model} ${thinkingLevel}` : model;
|
||||
}
|
||||
|
||||
/** Format the compact TUI footer from authoritative session and process state. */
|
||||
export function formatTuiFooter(params: {
|
||||
agentLabel: string;
|
||||
sessionLabel: string;
|
||||
sessionInfo: SessionInfo;
|
||||
thinkingLevel?: string | null;
|
||||
deliver: boolean;
|
||||
}): string {
|
||||
const { sessionInfo } = params;
|
||||
const fastLabel =
|
||||
sessionInfo.fastMode === "auto" ? "fast:auto" : sessionInfo.fastMode === true ? "fast" : null;
|
||||
const verbose = sessionInfo.verboseLevel ?? "off";
|
||||
const trace = sessionInfo.traceLevel ?? "off";
|
||||
const reasoning = sessionInfo.reasoningLevel ?? "off";
|
||||
const traceLabel = trace === "raw" ? "trace:raw" : trace === "on" ? "trace" : null;
|
||||
const reasoningLabel =
|
||||
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
|
||||
return [
|
||||
`agent ${params.agentLabel}`,
|
||||
`session ${params.sessionLabel}`,
|
||||
formatModelFooter({ model: sessionInfo.model, thinkingLevel: params.thinkingLevel }),
|
||||
formatGoalFooter(sessionInfo.goal),
|
||||
fastLabel,
|
||||
verbose !== "off" ? `verbose ${verbose}` : null,
|
||||
traceLabel,
|
||||
reasoningLabel,
|
||||
`deliver:${params.deliver ? "on" : "off"}`,
|
||||
formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
function hasControlChars(text: string): boolean {
|
||||
for (const char of text) {
|
||||
const code = char.charCodeAt(0);
|
||||
@@ -615,7 +649,7 @@ export function isCommandMessage(message: unknown): boolean {
|
||||
return (message as Record<string, unknown>).command === true;
|
||||
}
|
||||
|
||||
export function formatTokens(total?: number | null, context?: number | null) {
|
||||
function formatTokens(total?: number | null, context?: number | null) {
|
||||
if (total == null && context == null) {
|
||||
return "tokens ?";
|
||||
}
|
||||
@@ -637,7 +671,7 @@ function formatGoalUsage(goal: SessionGoal): string | null {
|
||||
return `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget)}`;
|
||||
}
|
||||
|
||||
export function formatGoalFooter(goal?: SessionGoal): string | null {
|
||||
function formatGoalFooter(goal?: SessionGoal): string | null {
|
||||
if (!goal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
|
||||
const footerModel = process.env.OPENCLAW_TUI_PTY_MODEL;
|
||||
const footerThinkingLevel = process.env.OPENCLAW_TUI_PTY_THINKING_LEVEL;
|
||||
let verboseLevel = process.env.OPENCLAW_TUI_PTY_VERBOSE_LEVEL;
|
||||
let modeTargetTraceLevel: string | undefined;
|
||||
const launchThinkingLevel = process.env.OPENCLAW_TUI_PTY_LAUNCH_THINKING;
|
||||
const initialMessage = process.env.OPENCLAW_TUI_PTY_INITIAL_MESSAGE;
|
||||
const enablePickerFixture = process.env.OPENCLAW_TUI_PTY_PICKER_FIXTURE === "1";
|
||||
@@ -76,15 +77,23 @@ export async function writeTuiPtyFixtureScript(dir: string) {
|
||||
}
|
||||
|
||||
function sessionEntry(key = "main") {
|
||||
const isModeSource = key.endsWith(":mode-source");
|
||||
const isModeTarget = key.endsWith(":mode-target");
|
||||
const entryFastMode = isModeSource ? true : isModeTarget ? undefined : fastMode;
|
||||
const entryVerboseLevel = isModeSource ? "full" : isModeTarget ? undefined : verboseLevel;
|
||||
const entryTraceLevel = isModeSource ? "raw" : isModeTarget ? modeTargetTraceLevel : undefined;
|
||||
const entryReasoningLevel = isModeSource ? "stream" : undefined;
|
||||
return {
|
||||
key,
|
||||
displayName: "Main",
|
||||
model: currentModel,
|
||||
modelProvider: "fixture-provider",
|
||||
contextTokens: 128,
|
||||
fastMode,
|
||||
...(entryFastMode !== undefined ? { fastMode: entryFastMode } : {}),
|
||||
...(currentThinkingLevel ? { thinkingLevel: currentThinkingLevel } : {}),
|
||||
...(verboseLevel ? { verboseLevel } : {}),
|
||||
...(entryVerboseLevel ? { verboseLevel: entryVerboseLevel } : {}),
|
||||
...(entryTraceLevel ? { traceLevel: entryTraceLevel } : {}),
|
||||
...(entryReasoningLevel ? { reasoningLevel: entryReasoningLevel } : {}),
|
||||
thinkingLevels: [],
|
||||
};
|
||||
}
|
||||
@@ -399,10 +408,12 @@ export async function writeTuiPtyFixtureScript(dir: string) {
|
||||
messages: [{ role: "user", content: rapidSwitchMarker + "_HISTORY_MARKER" }],
|
||||
};
|
||||
}
|
||||
const includeSessionInfo =
|
||||
Boolean(footerModel) || sessionKey.endsWith(":mode-source") || sessionKey.endsWith(":mode-target");
|
||||
return {
|
||||
messages: [],
|
||||
fastMode,
|
||||
...(footerModel
|
||||
...(includeSessionInfo
|
||||
? {
|
||||
thinkingLevel: footerThinkingLevel,
|
||||
sessionInfo: sessionEntry(sessionKey),
|
||||
@@ -455,6 +466,9 @@ export async function writeTuiPtyFixtureScript(dir: string) {
|
||||
if (typeof opts.verboseLevel === "string") {
|
||||
verboseLevel = opts.verboseLevel;
|
||||
}
|
||||
if (typeof opts.traceLevel === "string" && opts.key.endsWith(":mode-target")) {
|
||||
modeTargetTraceLevel = opts.traceLevel;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
path: "",
|
||||
@@ -554,7 +568,7 @@ export async function writeTuiPtyFixtureScript(dir: string) {
|
||||
},
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
},
|
||||
deliver: false,
|
||||
deliver: process.env.OPENCLAW_TUI_PTY_DELIVER === "1",
|
||||
thinking: launchThinkingLevel,
|
||||
message: initialMessage,
|
||||
historyLimit: 5,
|
||||
|
||||
@@ -131,10 +131,76 @@ describe.sequential("TUI PTY harness", () => {
|
||||
STARTUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"keeps session modes scoped while trace changes and delivery stays process-owned",
|
||||
async () => {
|
||||
const modeFixture = await startTuiFixture({
|
||||
env: {
|
||||
OPENCLAW_TUI_PTY_DELIVER: "1",
|
||||
OPENCLAW_TUI_PTY_MODEL: "fixture-model",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await modeFixture.run.waitForOutput("deliver:on", STARTUP_TIMEOUT_MS);
|
||||
await modeFixture.run.write("/session agent:main:mode-source\r", { delay: false });
|
||||
await modeFixture.waitForLogEntry(
|
||||
(entry) =>
|
||||
entry.method === "loadHistory" &&
|
||||
objectFieldEquals(entry, "sessionKey", "agent:main:mode-source"),
|
||||
);
|
||||
await modeFixture.run.waitForOutput(
|
||||
"trace:raw | reasoning:stream | deliver:on",
|
||||
STARTUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const targetOutputOffset = modeFixture.run.visibleOutput().length;
|
||||
await modeFixture.run.write("/session agent:main:mode-target\r", { delay: false });
|
||||
await modeFixture.waitForLogEntry(
|
||||
(entry) =>
|
||||
entry.method === "loadHistory" &&
|
||||
objectFieldEquals(entry, "sessionKey", "agent:main:mode-target"),
|
||||
);
|
||||
await modeFixture.run.waitForOutput("session mode-target", STARTUP_TIMEOUT_MS);
|
||||
const targetOutput = modeFixture.run.visibleOutput().slice(targetOutputOffset);
|
||||
expect(targetOutput).toContain("deliver:on");
|
||||
expect(targetOutput).not.toContain("fast:auto");
|
||||
expect(targetOutput).not.toContain("verbose full");
|
||||
expect(targetOutput).not.toContain("trace:raw");
|
||||
expect(targetOutput).not.toContain("reasoning:stream");
|
||||
|
||||
await modeFixture.run.write("/trace on\r", { delay: false });
|
||||
await modeFixture.waitForLogEntry(
|
||||
(entry) =>
|
||||
entry.method === "patchSession" && objectFieldEquals(entry, "traceLevel", "on"),
|
||||
);
|
||||
await modeFixture.run.waitForOutput("trace | deliver:on", STARTUP_TIMEOUT_MS);
|
||||
|
||||
await modeFixture.run.write("delivery proof\r", { delay: false });
|
||||
const sent = await modeFixture.waitForLogEntry(
|
||||
(entry) =>
|
||||
entry.method === "sendChat" && objectFieldEquals(entry, "message", "delivery proof"),
|
||||
);
|
||||
expect(sent.payload).toMatchObject({ deliver: true });
|
||||
console.log(
|
||||
`[behavior-evidence] tui-session-footer ${JSON.stringify({
|
||||
terminal: "real PTY",
|
||||
sourceModesVisible: true,
|
||||
targetModesCleared: true,
|
||||
traceTransitionVisible: true,
|
||||
fixedDeliveryPropagated: true,
|
||||
})}`,
|
||||
);
|
||||
} finally {
|
||||
await modeFixture.cleanup();
|
||||
}
|
||||
},
|
||||
STARTUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"keeps the launch thinking override active across session-level changes",
|
||||
async () => {
|
||||
const footerNeedle = "fixture-provider/fixture-model high | tokens";
|
||||
const footerNeedle = "fixture-provider/fixture-model high | deliver:off | tokens";
|
||||
await thinkingOverrideFixture.run.waitForOutput(footerNeedle, STARTUP_TIMEOUT_MS);
|
||||
await thinkingOverrideFixture.run.waitForOutput(
|
||||
"PTY_RESPONSE: thinking override proof",
|
||||
@@ -167,9 +233,11 @@ describe.sequential("TUI PTY harness", () => {
|
||||
.visibleOutput()
|
||||
.slice(sessionChangeOutputOffset);
|
||||
expect(outputAfterSessionChange).toContain(footerNeedle);
|
||||
expect(outputAfterSessionChange).not.toContain("fixture-provider/fixture-model low | tokens");
|
||||
expect(outputAfterSessionChange).not.toContain(
|
||||
"fixture-provider/fixture-model medium | tokens",
|
||||
"fixture-provider/fixture-model low | deliver:off | tokens",
|
||||
);
|
||||
expect(outputAfterSessionChange).not.toContain(
|
||||
"fixture-provider/fixture-model medium | deliver:off | tokens",
|
||||
);
|
||||
},
|
||||
STARTUP_TEST_TIMEOUT_MS,
|
||||
|
||||
@@ -1264,6 +1264,64 @@ describe("tui session actions", () => {
|
||||
expect(setActivityStatus).toHaveBeenLastCalledWith("idle");
|
||||
});
|
||||
|
||||
it("replaces session-scoped modes when switching to a session without overrides", async () => {
|
||||
const state = createBaseState({
|
||||
currentSessionKey: "agent:main:source",
|
||||
sessionInfo: {
|
||||
fastMode: true,
|
||||
verboseLevel: "full",
|
||||
traceLevel: "raw",
|
||||
reasoningLevel: "stream",
|
||||
},
|
||||
});
|
||||
const loadHistory = vi.fn().mockResolvedValue({
|
||||
sessionInfo: {
|
||||
key: "agent:main:target",
|
||||
sessionId: "session-target",
|
||||
},
|
||||
messages: [],
|
||||
});
|
||||
const { setSession } = createTestSessionActions({
|
||||
client: { listSessions: vi.fn(), loadHistory } as unknown as TuiBackend,
|
||||
state,
|
||||
});
|
||||
|
||||
await setSession("agent:main:target");
|
||||
|
||||
expect(state.sessionInfo).toMatchObject({
|
||||
fastMode: undefined,
|
||||
verboseLevel: undefined,
|
||||
traceLevel: undefined,
|
||||
reasoningLevel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges a same-session mode patch without clearing untouched modes", () => {
|
||||
const state = createBaseState({
|
||||
sessionInfo: {
|
||||
fastMode: true,
|
||||
verboseLevel: "full",
|
||||
traceLevel: "off",
|
||||
reasoningLevel: "stream",
|
||||
},
|
||||
});
|
||||
const { applySessionInfoFromPatch } = createTestSessionActions({ state });
|
||||
|
||||
applySessionInfoFromPatch({
|
||||
ok: true,
|
||||
path: "/sessions/patch",
|
||||
key: "agent:main:main",
|
||||
entry: { traceLevel: "raw" },
|
||||
});
|
||||
|
||||
expect(state.sessionInfo).toMatchObject({
|
||||
fastMode: true,
|
||||
verboseLevel: "full",
|
||||
traceLevel: "raw",
|
||||
reasoningLevel: "stream",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the newer session when an earlier switch's history resolves last", async () => {
|
||||
const historyA = createDeferred<unknown>();
|
||||
const historyB = createDeferred<unknown>();
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
isCommandMessage,
|
||||
} from "./tui-formatters.js";
|
||||
import { readTuiSessionUserMessage } from "./tui-session-events.js";
|
||||
import {
|
||||
clearTuiSessionModeOverrides,
|
||||
sessionInfoUiEquals,
|
||||
type SessionInfoDefaults,
|
||||
type SessionInfoEntry,
|
||||
} from "./tui-session-info.js";
|
||||
import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js";
|
||||
import {
|
||||
getTuiSessionProjection,
|
||||
@@ -26,7 +32,7 @@ import {
|
||||
reduceTuiSessionProjection,
|
||||
} from "./tui-session-projection.js";
|
||||
import * as submit from "./tui-submit-state.js";
|
||||
import type { SessionInfo, TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||
import type { TuiHistoryLoadResult, TuiOptions, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type SessionActionBtwPresenter = {
|
||||
clear: () => void;
|
||||
@@ -51,47 +57,6 @@ type SessionActionContext = {
|
||||
rememberSessionKey?: (sessionKey: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type SessionInfoDefaults = {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
contextTokens?: number | null;
|
||||
thinkingLevels?: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
type SessionInfoEntry = SessionInfo & {
|
||||
key?: string;
|
||||
sessionId?: string;
|
||||
modelOverride?: string;
|
||||
providerOverride?: string;
|
||||
};
|
||||
|
||||
function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
|
||||
return (
|
||||
left.thinkingLevel === right.thinkingLevel &&
|
||||
(left.thinkingLevels === right.thinkingLevels ||
|
||||
JSON.stringify(left.thinkingLevels ?? null) ===
|
||||
JSON.stringify(right.thinkingLevels ?? null)) &&
|
||||
left.fastMode === right.fastMode &&
|
||||
left.verboseLevel === right.verboseLevel &&
|
||||
left.traceLevel === right.traceLevel &&
|
||||
left.reasoningLevel === right.reasoningLevel &&
|
||||
left.model === right.model &&
|
||||
left.modelProvider === right.modelProvider &&
|
||||
left.agentRuntime?.id === right.agentRuntime?.id &&
|
||||
left.agentRuntime?.source === right.agentRuntime?.source &&
|
||||
left.agentRuntime?.fallback === right.agentRuntime?.fallback &&
|
||||
left.contextTokens === right.contextTokens &&
|
||||
left.inputTokens === right.inputTokens &&
|
||||
left.outputTokens === right.outputTokens &&
|
||||
left.totalTokens === right.totalTokens &&
|
||||
left.responseUsage === right.responseUsage &&
|
||||
left.effectiveResponseUsage === right.effectiveResponseUsage &&
|
||||
left.displayName === right.displayName &&
|
||||
(left.goal === right.goal ||
|
||||
JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
export function createSessionActions(context: SessionActionContext) {
|
||||
const {
|
||||
client,
|
||||
@@ -661,6 +626,7 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
setActivityStatus("idle");
|
||||
if (selectionChanged) {
|
||||
state.currentSessionId = null;
|
||||
clearTuiSessionModeOverrides(state.sessionInfo);
|
||||
}
|
||||
// Session keys can move backwards in updatedAt ordering; drop previous session freshness
|
||||
// so refresh data for the newly selected session isn't rejected as stale.
|
||||
|
||||
51
src/tui/tui-session-info.ts
Normal file
51
src/tui/tui-session-info.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { SessionInfo } from "./tui-types.js";
|
||||
|
||||
export type SessionInfoDefaults = {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
contextTokens?: number | null;
|
||||
thinkingLevels?: Array<{ id: string; label: string }>;
|
||||
};
|
||||
|
||||
export type SessionInfoEntry = SessionInfo & {
|
||||
key?: string;
|
||||
sessionId?: string;
|
||||
modelOverride?: string;
|
||||
providerOverride?: string;
|
||||
};
|
||||
|
||||
/** Compare only session facts that change visible TUI behavior. */
|
||||
export function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
|
||||
return (
|
||||
left.thinkingLevel === right.thinkingLevel &&
|
||||
(left.thinkingLevels === right.thinkingLevels ||
|
||||
JSON.stringify(left.thinkingLevels ?? null) ===
|
||||
JSON.stringify(right.thinkingLevels ?? null)) &&
|
||||
left.fastMode === right.fastMode &&
|
||||
left.verboseLevel === right.verboseLevel &&
|
||||
left.traceLevel === right.traceLevel &&
|
||||
left.reasoningLevel === right.reasoningLevel &&
|
||||
left.model === right.model &&
|
||||
left.modelProvider === right.modelProvider &&
|
||||
left.agentRuntime?.id === right.agentRuntime?.id &&
|
||||
left.agentRuntime?.source === right.agentRuntime?.source &&
|
||||
left.agentRuntime?.fallback === right.agentRuntime?.fallback &&
|
||||
left.contextTokens === right.contextTokens &&
|
||||
left.inputTokens === right.inputTokens &&
|
||||
left.outputTokens === right.outputTokens &&
|
||||
left.totalTokens === right.totalTokens &&
|
||||
left.responseUsage === right.responseUsage &&
|
||||
left.effectiveResponseUsage === right.effectiveResponseUsage &&
|
||||
left.displayName === right.displayName &&
|
||||
(left.goal === right.goal ||
|
||||
JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear selection-owned modes so a switch cannot display its predecessor while loading. */
|
||||
export function clearTuiSessionModeOverrides(sessionInfo: SessionInfo): void {
|
||||
sessionInfo.fastMode = undefined;
|
||||
sessionInfo.verboseLevel = undefined;
|
||||
sessionInfo.traceLevel = undefined;
|
||||
sessionInfo.reasoningLevel = undefined;
|
||||
}
|
||||
@@ -44,12 +44,7 @@ import { editorTheme, theme } from "./theme/theme.js";
|
||||
import type { TuiBackend } from "./tui-backend.js";
|
||||
import { createCommandHandlers } from "./tui-command-handlers.js";
|
||||
import { createEventHandlers } from "./tui-event-handlers.js";
|
||||
import {
|
||||
formatGoalFooter,
|
||||
formatModelFooter,
|
||||
formatTuiErrorMessage,
|
||||
formatTokens,
|
||||
} from "./tui-formatters.js";
|
||||
import { formatTuiFooter, formatTuiErrorMessage } from "./tui-formatters.js";
|
||||
import {
|
||||
buildTuiLastSessionScopeKey,
|
||||
readTuiLastSessionKey,
|
||||
@@ -1229,35 +1224,18 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
||||
? `${sessionKeyLabel} (${state.sessionInfo.displayName})`
|
||||
: sessionKeyLabel;
|
||||
const agentLabel = formatAgentLabel(state.currentAgentId);
|
||||
const modelLabel = formatModelFooter({
|
||||
model: state.sessionInfo.model,
|
||||
thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel,
|
||||
});
|
||||
const tokens = formatTokens(
|
||||
state.sessionInfo.totalTokens ?? null,
|
||||
state.sessionInfo.contextTokens ?? null,
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
formatTuiFooter({
|
||||
agentLabel,
|
||||
sessionLabel,
|
||||
sessionInfo: state.sessionInfo,
|
||||
thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel,
|
||||
// Delivery is fixed at launch; session switches and patches cannot change it.
|
||||
deliver: deliverDefault,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const fastLabel =
|
||||
state.sessionInfo.fastMode === "auto"
|
||||
? "fast:auto"
|
||||
: state.sessionInfo.fastMode === true
|
||||
? "fast"
|
||||
: null;
|
||||
const verbose = state.sessionInfo.verboseLevel ?? "off";
|
||||
const reasoning = state.sessionInfo.reasoningLevel ?? "off";
|
||||
const reasoningLabel =
|
||||
reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null;
|
||||
const footerParts = [
|
||||
`agent ${agentLabel}`,
|
||||
`session ${sessionLabel}`,
|
||||
modelLabel,
|
||||
formatGoalFooter(state.sessionInfo.goal),
|
||||
fastLabel,
|
||||
verbose !== "off" ? `verbose ${verbose}` : null,
|
||||
reasoningLabel,
|
||||
tokens,
|
||||
].filter(Boolean);
|
||||
footer.setText(theme.dim(footerParts.join(" | ")));
|
||||
};
|
||||
|
||||
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
||||
|
||||
Reference in New Issue
Block a user