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 docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ full output, hiding the preview; the idiom and the arrow are unchanged.

The prompt box's border carries the metadata that would otherwise cost a
titlebar row: the model label sits right-aligned in the top rule as
`profile · model · effort` (empty segments omitted), and a
`profile · model · effort · mode` (empty segments omitted), and a
compact `mcp !` sits immediately left of it when any MCP server still needs
authorization (`/mcp` is the surface that names them), painted in
`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is
Expand Down
68 changes: 67 additions & 1 deletion src/tui/components/prompt-action-bar-label.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test";
import { composePromptActionBarModelLabel } from "./prompt-action-bar-label.js";
import {
composePromptActionBarModelLabel,
yoloModeLabel,
} from "./prompt-action-bar-label.js";

describe("composePromptActionBarModelLabel", () => {
test("omits profile when unset", () => {
Expand Down Expand Up @@ -44,4 +47,67 @@ describe("composePromptActionBarModelLabel", () => {
expect(composePromptActionBarModelLabel({ profile: "" })).toBeUndefined();
expect(composePromptActionBarModelLabel({ model: "" })).toBeUndefined();
});

test("appends a permission mode segment when set", () => {
expect(
composePromptActionBarModelLabel({ model: "gpt-5", mode: "yolo" }),
).toBe("gpt-5 · yolo");
expect(
composePromptActionBarModelLabel({
profile: "work",
model: "gpt-5",
effort: "high",
mode: "yolo",
}),
).toBe("work · gpt-5 · high · yolo");
});

test("omits an empty permission mode segment", () => {
expect(composePromptActionBarModelLabel({ model: "gpt-5", mode: "" })).toBe(
"gpt-5",
);
expect(composePromptActionBarModelLabel({ mode: "yolo" })).toBe("yolo");
});
});

describe("yoloModeLabel", () => {
test("returns the yolo segment while permission prompts are skipped", () => {
expect(yoloModeLabel(true)).toBe("yolo");
});

test("returns undefined otherwise so the segment omits", () => {
expect(yoloModeLabel(false)).toBeUndefined();
});
});

describe("toggle label preservation", () => {
test("an effort update keeps the yolo mode segment", () => {
expect(
composePromptActionBarModelLabel({
profile: "work",
model: "gpt-5",
effort: "high",
mode: yoloModeLabel(true),
}),
).toBe("work · gpt-5 · high · yolo");
});

test("a yolo toggle keeps the effort segment", () => {
expect(
composePromptActionBarModelLabel({
profile: "work",
model: "gpt-5",
effort: "high",
mode: yoloModeLabel(true),
}),
).toBe("work · gpt-5 · high · yolo");
expect(
composePromptActionBarModelLabel({
profile: "work",
model: "gpt-5",
effort: "high",
mode: yoloModeLabel(false),
}),
).toBe("work · gpt-5 · high");
});
});
18 changes: 16 additions & 2 deletions src/tui/components/prompt-action-bar-label.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
export const ESSENTIALS_SEPARATOR = " · ";

export interface PromptActionBarModelLabelInput {
profile?: string;
model?: string;
effort?: string;
mode?: string | undefined;
}

/** Right-aligned muted label above the prompt: `profile · model · effort` with omitted empty segments. */
/** Right-aligned muted label above the prompt: `profile · model · effort · mode` with omitted empty segments. */
export function composePromptActionBarModelLabel(
input: PromptActionBarModelLabelInput,
): string | undefined {
Expand All @@ -18,5 +21,16 @@ export function composePromptActionBarModelLabel(
if (input.effort !== undefined && input.effort.length > 0) {
segments.push(input.effort);
}
return segments.length > 0 ? segments.join(" · ") : undefined;
if (input.mode !== undefined && input.mode.length > 0) {
segments.push(input.mode);
}
return segments.length > 0 ? segments.join(ESSENTIALS_SEPARATOR) : undefined;
}

/**
* Trailing label segment while permission prompts are skipped. Undefined
* otherwise, so the segment simply omits.
*/
export function yoloModeLabel(skipsPermissions: boolean): "yolo" | undefined {
return skipsPermissions ? "yolo" : undefined;
}
21 changes: 21 additions & 0 deletions src/tui/components/session-header.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { LOCKUP_WORDMARK } from "../lockup.js";
import { ESSENTIALS_SEPARATOR } from "./prompt-action-bar-label.js";
import { composeSessionHeader } from "./session-header.js";

describe("composeSessionHeader", () => {
test("wordmark alone when no essentials apply", () => {
expect(composeSessionHeader()).toBe(LOCKUP_WORDMARK);
expect(composeSessionHeader({})).toBe(LOCKUP_WORDMARK);
expect(composeSessionHeader({ essentials: "" })).toBe(LOCKUP_WORDMARK);
});

test("wordmark leads the quiet essentials line", () => {
expect(composeSessionHeader({ essentials: "profile · model" })).toBe(
`${LOCKUP_WORDMARK}${ESSENTIALS_SEPARATOR}profile · model`,
);
expect(composeSessionHeader({ essentials: "profile · model · yolo" })).toBe(
`${LOCKUP_WORDMARK}${ESSENTIALS_SEPARATOR}profile · model · yolo`,
);
});
});
22 changes: 22 additions & 0 deletions src/tui/components/session-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { LOCKUP_WORDMARK } from "../lockup.js";
import { ESSENTIALS_SEPARATOR } from "./prompt-action-bar-label.js";

export interface SessionHeaderInput {
/** Quiet session essentials, e.g. `profile · model · effort · mode`. */
essentials?: string | undefined;
}

const WORDMARK = LOCKUP_WORDMARK;

/**
* First among the deferred startup rows once the landing clears: the wordmark
* leading the quiet essentials line. Wordmark alone when nothing applies. A
* re-filed telemetry disclosure still lands ahead of it, and later /yolo or
* effort toggles move the prompt border label only — the header is a startup
* snapshot.
*/
export function composeSessionHeader(input: SessionHeaderInput = {}): string {
const essentials = input.essentials;
if (essentials === undefined || essentials.length === 0) return WORDMARK;
return `${WORDMARK}${ESSENTIALS_SEPARATOR}${essentials}`;
}
20 changes: 19 additions & 1 deletion src/tui/runner/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ import { contextTokensFromUsage } from "../../provider/context-window.js";
import { fleetDigest } from "../../subagent/index.js";
import { renameSession } from "../../session/index.js";
import { truncateSessionLabel } from "../../session/session-label.js";
import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js";
import {
surfaceSystemNotice,
attachClipboardImage,
setPromptModelLabel,
} from "../shell/prompt.js";
import { yoloModeLabel } from "../components/prompt-action-bar-label.js";
import { isCodexProviderName } from "../../config/codex-providers.js";
import { resolveSessionEffort } from "../../provider/reasoning-effort.js";
import type { InferenceErrorLike } from "../../inference-gateway-error.js";
import { terminalProviderFailureMessage } from "../../inference-error-message.js";
import type { InferenceAttemptIdentity } from "./state.js";
Expand Down Expand Up @@ -109,6 +116,17 @@ export function createCommandLayer(
setSkipPermissions: (value: boolean) => {
services.permissionGate.setSkipPermissions(value);
state.config.dangerouslySkipPermissions = value;
const effort = resolveSessionEffort(
state.config.model,
state.config.reasoningEffort,
isCodexProviderName(state.config.providerName),
);
setPromptModelLabel(hostOf(state).shell, {
profile: state.config.providerName,
model: state.config.model,
...(effort !== undefined ? { effort } : {}),
mode: yoloModeLabel(value),
});
void services.globalSettingsWriter.enqueue(async () => {
try {
const result = await persistSkipPermissionsDefault(
Expand Down
2 changes: 2 additions & 0 deletions src/tui/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
providerChoices,
} from "../provider/choices.js";
import { listCommands } from "../commands/registry.js";
import { yoloModeLabel } from "../components/prompt-action-bar-label.js";
import { mountRunnerHost } from "./host.js";
import { assembleTUISession } from "./session.js";
import { createRunLifecycle, finalizeTUIRun } from "./exit.js";
Expand Down Expand Up @@ -145,6 +146,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
profile: state.config.providerName,
model: state.config.model,
...(effort !== undefined ? { effort } : {}),
mode: yoloModeLabel(services.permissionGate.getSkipPermissions()),
};
},
activeModel: () => ({
Expand Down
35 changes: 34 additions & 1 deletion src/tui/runner/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ import {
import { scheduleUpgradeNotice } from "../../upgrade/index.js";
import pkg from "../../../package.json" with { type: "json" };
import { hydrateTasksFromTurns } from "../../agent/director.js";
import { cycleReasoningEffort } from "../../provider/reasoning-effort.js";
import {
cycleReasoningEffort,
resolveSessionEffort,
} from "../../provider/reasoning-effort.js";
import { isCodexProviderName } from "../../config/codex-providers.js";
import { RUNTIME_FLASH_MS } from "../runtime-notices.js";
import {
Expand All @@ -54,6 +57,11 @@ import {
surfaceSystemNotice,
} from "../shell/prompt.js";
import { listPathSuggestions } from "../components/at-mention/list.js";
import {
composePromptActionBarModelLabel,
yoloModeLabel,
} from "../components/prompt-action-bar-label.js";
import { composeSessionHeader } from "../components/session-header.js";
import { listCommands } from "../commands/registry.js";
import type { MCPConnectCallbacks } from "../../agent/tools.js";
import { createRuntimeShutdown } from "./shutdown.js";
Expand Down Expand Up @@ -374,6 +382,7 @@ export function wirePostStartup(
profile: state.config.providerName,
model: state.config.model,
effort: next,
mode: yoloModeLabel(state.config.dangerouslySkipPermissions),
});
setStatusFlash(hostOf(state).shell, `reasoning effort: ${next}`, {
ttlMs: RUNTIME_FLASH_MS,
Expand Down Expand Up @@ -465,6 +474,30 @@ export function wirePostStartup(
);
});

// The branded session header goes first among the deferred startup rows:
// it is the row the deferred queue flushes ahead of every other startup
// notice once the landing clears (a re-filed telemetry disclosure still
// lands ahead of it). A startup snapshot — later /yolo or effort toggles
// move the prompt border label only. While the landing holds, the notice
// strip shows the latest deferred wording; the transcript keeps the full
// order.
const headerEffort = resolveSessionEffort(
state.config.model,
state.config.reasoningEffort,
isCodexProviderName(state.config.providerName),
);
surfaceSystemNotice(
hostOf(state).shell,
composeSessionHeader({
essentials: composePromptActionBarModelLabel({
profile: state.config.providerName,
model: state.config.model,
...(headerEffort !== undefined ? { effort: headerEffort } : {}),
mode: yoloModeLabel(state.config.dangerouslySkipPermissions),
}),
}),
);

// Surface fire-and-forget startup notices now that there is a shell (queued
// above, before `host` existed). Plugin load warnings are NOT notices — they
// drive `plugin !` and `/plugins` instead.
Expand Down
46 changes: 45 additions & 1 deletion src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ import {
steerCount,
type RunState,
} from "../session-queue.js";
import { agentVoicesIn, isCollapsibleRow, type StreamRow } from "../stream.js";
import {
agentVoicesIn,
isCollapsibleRow,
MAIN_AGENT,
type StreamRow,
} from "../stream.js";
import { UI } from "../theme.js";
import {
isDecisionOverlay,
Expand Down Expand Up @@ -1039,6 +1044,8 @@ export function appendTranscript(
opts?: { readonly fg?: string },
): void {
clearLandingMark(shell);
// A raw paint like any other: it breaks a run of identical system echoes.
paintSequence.set(shell, (paintSequence.get(shell) ?? 0) + 1);
shell.lineCount += 1;
shell.transcript.add(
new TextRenderable(shell.renderer as CliRenderer, {
Expand Down Expand Up @@ -1081,6 +1088,36 @@ export function appendObserveStreamRow(
return true;
}

/**
* Startup echoes (model-picker choice, permission notices) can arrive once per
* account or session and read as stutter when they paint back to back. A
* system row identical to the one already on top of the transcript adds
* nothing, so it collapses — but only when nothing painted since that top row
* landed. Observe child rows paint through this same path without touching the
* parent log, so log adjacency alone would swallow repeat farewell rows like
* "left observe" across enter/leave cycles; the sequence check restores them.
*/
const paintSequence = new WeakMap<AppShell, number>();
const systemPushSequence = new WeakMap<AppShell, number>();

function isDuplicateSystemEcho(shell: AppShell, row: StreamRow): boolean {
if (row.role !== "system") return false;
const top = shell.streamLog[shell.streamLog.length - 1];
if (
top === undefined ||
top.role !== "system" ||
top.text !== row.text ||
(top.agent ?? MAIN_AGENT) !== (row.agent ?? MAIN_AGENT) ||
top.meta !== row.meta
) {
return false;
}
// The in-flight call already advanced the sequence, so the top row is
// back-to-back only when it was pushed by the immediately previous paint.
const seq = paintSequence.get(shell) ?? 0;
return systemPushSequence.get(shell) === seq - 1;
}

/**
* Paint + push onto the visible streamLog (child while observing, parent
* otherwise). The paint tree stays 1:1 with the (retention-capped) log —
Expand All @@ -1092,8 +1129,15 @@ export function appendObserveStreamRow(
*/
function paintAppendStreamRow(shell: AppShell, row: StreamRow): void {
clearLandingMark(shell);
const seq = (paintSequence.get(shell) ?? 0) + 1;
paintSequence.set(shell, seq);
if (isDuplicateSystemEcho(shell, row)) {
systemPushSequence.set(shell, seq);
return;
}
const gainedVoice = noteAgentVoice(shell, row);
shell.streamLog.push(row);
if (row.role === "system") systemPushSequence.set(shell, seq);
const baseBefore = shell.streamLogBase;
shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase);
shell.lineCount = shell.streamLog.length;
Expand Down
Loading
Loading