From e998b3b8267aacfc0d37fd553ef75d1429eaba18 Mon Sep 17 00:00:00 2001 From: Ayush Sharma <47772616+ayush5harma@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:39:24 +0530 Subject: [PATCH 01/10] feat(session): show the live session name in the Status tab (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-sdk): send Claude Code's session title to the app - add the session_title_update session update to types.ts and the Rust wire types, read from getSessionInfo().customTitle after every connect, replacement and conversation reset, and after each top-level turn when it changed - drop a read that a later read, a replacement or a close superseded, and refresh the session list when a turn changed the title - strip control characters once in the converter, hold the active session's title as one Option cleared with its identity and on a conversation reset, and name the terminal tab after it - cover the read rules, resume, a launch-time name, /rename, /clear and a conversation reset at the bridge boundary, and the converter and reset handling in Rust tests * feat(ui): show the session name above the composer and in Status - draw a `──── name ─` rule as the editor's top row when the session has a title, cut with an ellipsis to the width, and give it up first on a one-row slot - name the session in the Status tab by the title Claude Code reports instead of the listed custom title, keeping the summary fallback - cover the rule's layout and the Status row in unit tests, and /rename and a Status-tab rename against the fake bridge in PTY tests * fix(agent-sdk): send the new title after a Status-tab rename - read the title back through getSessionInfo() after rename_session and generate_session_title succeed, so the composer rule, tab title and Status tab follow a rename made outside a turn - keep both commands on renameSession() and the SDK's title generation; the session list is still emitted as before - cover a rename and a generated ai-title at the command boundary against transcripts read through the SDK * refactor(app): prefill Status rename and title generation from the live title - start the rename draft from the title the bridge reported instead of the listed session's custom title, which can lag behind it - describe the session for title generation by that title first, then the listed summary and first prompt as before - the Status tab, the composer rule and both actions now read one title; the config tests and the Status-tab PTY test cover the prefill * docs: describe the session name and who owns it - add a Session Name section: /rename and the Status tab change it, the app shows what the session API reports, including after a reset - state that Claude Code generates a title only for a session without one, and that the API's generated-title fallback shows a name where Claude Code's prompt bar shows none - add a session title owner table: Claude Code persists it, the bridge reads and sends it, Rust holds one value that every display reads * refactor(ui): show the session name only in the Status tab - remove the composer rule and its layout hookup; no display placement was agreed before it was added - restore upstream's terminal tab title so it keeps naming the folder - keep one PTY test proving the Status tab shows the name a /rename reports - drop the fake bridge's resume title and rename handler that only the removed tests used * test(agent-sdk): stub the session API instead of writing transcript records - have the SDK stand-in report titles from its own state through getSessionInfo, so no test writes CLI-private custom-title or ai-title records - drive every case through the spawned bridge and assert the NDJSON it emits: resume, a launch-time name after the first turn, /rename, the Status rename and title generation, a conversation reset and /clear - prove a superseded read is dropped with a held read that resolves after a later one starts - prove a failed read is logged and sends nothing - merge session_resume.test.ts into session_title.test.ts, since both tested the same title workflow * refactor(agent-sdk): send the session title after every read - remove the bridge's sentTitle copy, which mirrored the title Rust already holds - send whatever each read returns; Rust stores it idempotently and nothing else reacts to a repeat - keep the titleReads counter so a superseded read is still dropped - remove the per-turn session-list refresh that depended on change detection - cite Claude Code 2.1.288, the version bundled with the pinned SDK * docs: describe the session name by what claude-rs does - say where the name shows (the Status tab), when it is read, and that it is whatever the SDK session API reports - state the generated-title fallback as a property of the API and drop claims about how Claude Code keeps titles - name the SDK as the title's owner and the listing as a separate snapshot refreshed by listSessions - reword the Rust field comments the same way * fix: preserve session titles when live reads lag * test: make SDK bridge fixtures portable * test: align listed session event assertion * test: use a stable SDK history fixture path --- .github/workflows/_agent-sdk.yml | 10 +- agent-sdk/src/bridge.contract.test.ts | 58 ++++- agent-sdk/src/bridge/command_session_data.ts | 3 + agent-sdk/src/bridge/events.ts | 3 + agent-sdk/src/bridge/history.test.ts | 5 +- agent-sdk/src/bridge/message_handlers.ts | 5 + agent-sdk/src/bridge/session_lifecycle.ts | 2 + agent-sdk/src/bridge/session_title.test.ts | 255 +++++++++++++++++++ agent-sdk/src/bridge/session_title.ts | 45 ++++ agent-sdk/src/types.ts | 1 + docs/src/architecture.md | 11 + docs/src/commands.md | 6 + src/agent/model/session.rs | 3 + src/agent/types.rs | 3 + src/app/config/edit.rs | 20 +- src/app/config/tests.rs | 17 +- src/app/connect/type_converters.rs | 33 +++ src/app/events/mod.rs | 9 + src/app/events/session_reset.rs | 2 + src/app/events/tests/client_events.rs | 44 ++++ src/app/events/tests/mod.rs | 4 + src/app/state/session_identity.rs | 11 + src/app/state/session_runtime.rs | 7 + src/ui/config/status.rs | 91 ++++--- tests/fixtures/fake-bridge.js | 24 ++ tests/terminal_resize.rs | 62 ++++- 26 files changed, 679 insertions(+), 55 deletions(-) create mode 100644 agent-sdk/src/bridge/session_title.test.ts create mode 100644 agent-sdk/src/bridge/session_title.ts diff --git a/.github/workflows/_agent-sdk.yml b/.github/workflows/_agent-sdk.yml index d073f578..8ff6f779 100644 --- a/.github/workflows/_agent-sdk.yml +++ b/.github/workflows/_agent-sdk.yml @@ -8,8 +8,12 @@ permissions: jobs: check: - name: Check agent-sdk - runs-on: ubuntu-latest + name: Check agent-sdk (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] defaults: run: working-directory: agent-sdk @@ -29,7 +33,7 @@ jobs: run: npm run build - name: Run tests - run: npm run test + run: node --test 'dist/**/*.test.js' - name: Run lint run: npm run lint diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index 74ebfa4b..1608846f 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -596,6 +596,11 @@ test("production bridge source stays on the public main SDK export", () => { function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedBridge; cleanup: () => Promise } { const directory = mkdtempSync(join(tmpdir(), "claude-rs-ultracode-")); const sdkPath = import.meta.resolve("@anthropic-ai/claude-agent-sdk"); + const sdkPackage = join(dirname(fileURLToPath(sdkPath)), "package.json"); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ version: JSON.parse(readFileSync(sdkPackage, "utf8")).version }), + ); const fixturePath = join(directory, "sdk-fixture.mjs"); writeFileSync(fixturePath, ` export * from ${JSON.stringify(sdkPath)}; @@ -607,17 +612,35 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB return sdkResolveSettings({ ...options, ...(managedSettings ? { serverManagedSettings: JSON.parse(managedSettings) } : {}) }); } function record(value) { if (process.env.STARTUP_JOURNAL) appendFileSync(process.env.STARTUP_JOURNAL, JSON.stringify(value) + "\\n"); } + const saved = new Map(); + function sessionEntries() { + const seeded = JSON.parse(process.env.STARTUP_SESSIONS_FIXTURE ?? "[]"); + const entries = new Map(seeded.map(entry => [entry.sessionId, entry])); + for (const [sessionId, state] of saved) { + const entry = entries.get(sessionId) ?? { sessionId, cwd: state.cwd, lastModified: 1 }; + entries.set(sessionId, { ...entry, ...(Object.hasOwn(state, "title") ? { customTitle: state.title } : {}) }); + } + return [...entries.values()]; + } export async function listSessions(options) { record({ type: "list", options }); - const seeded = JSON.parse(process.env.STARTUP_SESSIONS_FIXTURE ?? "[]"); - return [...seeded, ...[...saved.keys()].map((sessionId, index) => ({ sessionId, cwd: saved.get(sessionId).cwd, lastModified: index + 1 }))].filter(entry => !options?.dir || entry.cwd === options.dir); + return sessionEntries().filter(entry => !options?.dir || entry.cwd === options.dir); + } + export async function getSessionInfo(sessionId, options) { + const entry = sessionEntries().find(candidate => candidate.sessionId === sessionId); + if (!entry || (options?.dir && entry.cwd !== options.dir)) return undefined; + return entry; } export async function getSessionMessages(...args) { return process.env.RESUME_TRANSCRIPT_FIXTURE === "1" ? sdkGetSessionMessages(...args) : []; } export async function importSessionToStore(...args) { if (process.env.RESUME_TRANSCRIPT_FIXTURE === "1") await sdkImportSessionToStore(...args); } - const saved = new Map(); + export async function renameSession(sessionId, title, options) { + const existing = saved.get(sessionId) ?? {}; + saved.set(sessionId, { ...existing, cwd: existing.cwd ?? options?.dir, title }); + } export function query({ prompt, options }) { record({ type: "query", cwd: options.cwd, resume: options.resume, model: options.model, effort: options.effort, permissionMode: options.permissionMode, agent: options.agent }); const state = saved.get(options.resume) ?? { requested: false, effort: "high", cwd: options.cwd }; + if (options.title !== undefined) state.title = options.title; saved.set(options.sessionId ?? options.resume, state); let model = options.model ?? "opus"; let effective = {}; @@ -900,6 +923,35 @@ test("continue with no previous session starts a new session", async () => { } finally { await cleanup(); } }); +test("fixture session info preserves and clears a seeded title", async () => { + const cwd = process.cwd(); + const sessionId = "seeded-session"; + const { bridge, cleanup } = ultracodeFixtureBridge({ + STARTUP_SESSIONS_FIXTURE: JSON.stringify([ + { sessionId, cwd, lastModified: 1, customTitle: "Saved title" }, + ]), + }); + try { + bridge.writeCommand({ command: "resume_session", session_id: sessionId, launch_settings: {} }); + await nextMatching(bridge, event => event.event === "connected"); + const restored = await nextMatching(bridge, event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "session_title_update"; + }); + assert.equal((restored.update as SessionUpdate & { type: "session_title_update" }).title, "Saved title"); + + bridge.writeCommand({ command: "rename_session", session_id: sessionId, title: "", request_id: "clear-title" }); + const cleared = await nextMatching(bridge, event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "session_title_update"; + }); + assert.equal((cleared.update as SessionUpdate & { type: "session_title_update" }).title, ""); + const listed = await nextMatching(bridge, event => event.event === "sessions_listed" && event.request_id === "clear-title"); + const saved = (listed.sessions as BridgeEnvelope[]).find(session => session.session_id === sessionId); + assert.equal(saved?.custom_title, undefined); + } finally { await cleanup(); } +}); + test("spawned bridge reports applied effort at startup, after caps and model changes, per turn and after resume", async () => { const { bridge, cleanup } = ultracodeFixtureBridge({ EFFORT_FIXTURE_CAP: "high" }); const nextEffort = async (value: unknown) => { diff --git a/agent-sdk/src/bridge/command_session_data.ts b/agent-sdk/src/bridge/command_session_data.ts index 2b8be094..0ed4800e 100644 --- a/agent-sdk/src/bridge/command_session_data.ts +++ b/agent-sdk/src/bridge/command_session_data.ts @@ -31,6 +31,7 @@ import { writeEvent, } from "./events.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; +import { emitSessionTitle } from "./session_title.js"; import { refreshCurrentModel, sessionById, @@ -111,6 +112,7 @@ async function generateTitle( session.query, command.description, ); + await emitSessionTitle(session); setSessionListingDir(session.cwd); await emitSessionsList(requestId); } catch (error) { @@ -138,6 +140,7 @@ async function rename( command.title, deps.buildSessionMutationOptions(session.cwd), ); + await emitSessionTitle(session); setSessionListingDir(session.cwd); await emitSessionsList(requestId); } catch (error) { diff --git a/agent-sdk/src/bridge/events.ts b/agent-sdk/src/bridge/events.ts index 9fc3d1c6..8cbcfc54 100644 --- a/agent-sdk/src/bridge/events.ts +++ b/agent-sdk/src/bridge/events.ts @@ -1,3 +1,4 @@ +import { emitSessionTitle } from "./session_title.js"; import { listSessions, type ListSessionsOptions, @@ -389,6 +390,7 @@ export function emitConnectEvent(session: SessionState): void { } } emitAvailableAgentsSnapshot(session); + void emitSessionTitle(session); session.resumeUpdates = undefined; session.restoredInput = undefined; @@ -420,6 +422,7 @@ export function emitSessionReplacedEvent( writeEvent(bridgeEvent, requestId); emitAvailableCommandsSnapshot(session); emitAvailableAgentsSnapshot(session); + void emitSessionTitle(session); if (session.pendingRewindResult) { writeEvent( { ...session.pendingRewindResult, session_id: session.sessionId }, diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts index 10a50d0c..155cd46d 100644 --- a/agent-sdk/src/bridge/history.test.ts +++ b/agent-sdk/src/bridge/history.test.ts @@ -12,7 +12,8 @@ test("resume display restores compaction segments once and preserves SDK branch try { const cwd = join(directory, "project"); const configDir = join(directory, "config"); - const projectDir = join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); + const projectDirName = "history-export-fixture"; + const projectDir = join(configDir, "projects", projectDirName); mkdirSync(cwd, { recursive: true }); mkdirSync(projectDir, { recursive: true }); const sessionId = "11111111-1111-4111-8111-111111111111"; @@ -50,7 +51,7 @@ test("resume display restores compaction segments once and preserves SDK branch const child = spawnSync(process.execPath, ["--input-type=module", "-"], { input: script, encoding: "utf8", - env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: undefined }, + env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: projectDirName }, windowsHide: true, }); assert.equal(child.status, 0, child.stderr); diff --git a/agent-sdk/src/bridge/message_handlers.ts b/agent-sdk/src/bridge/message_handlers.ts index 45335996..abefa588 100644 --- a/agent-sdk/src/bridge/message_handlers.ts +++ b/agent-sdk/src/bridge/message_handlers.ts @@ -3,6 +3,7 @@ import { nativeNotification } from "./notifications.js"; import { elapsedNumber, messageMetadata, turnTiming } from "./presentation_metadata.js"; import { refreshUltracode } from "./ultracode.js"; import { observeSessionEffort, refreshSessionEffort } from "./effort.js"; +import { emitSessionTitle } from "./session_title.js"; import { observeSessionModel } from "./session_model.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { @@ -1569,6 +1570,7 @@ export function handleResultMessage( message.parent_tool_use_id === undefined ) { emitUserMessageStarted(session, message, "result"); + void emitSessionTitle(session); } emitFastModeUpdateIfChanged( session, @@ -1741,6 +1743,9 @@ export function handleSdkMessage( timestamp: trimmedStringField(msg, "timestamp"), user_message_uuid: trimmedStringField(msg, "user_message_uuid"), }); + // The app drops the title with the conversation; send what the API + // reports for the session after the reset. + void emitSessionTitle(session); return; } diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index 1e8c7b3a..07844678 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -224,6 +224,8 @@ export type SessionState = { resumeUpdates?: SessionUpdate[]; restoredInput?: string; pendingRewindResult?: PendingRewindResult; + /** Counts title reads, so only the latest one is sent. */ + titleReads?: number; }; export const sessions = new Map(); diff --git a/agent-sdk/src/bridge/session_title.test.ts b/agent-sdk/src/bridge/session_title.test.ts new file mode 100644 index 00000000..b2431e1b --- /dev/null +++ b/agent-sdk/src/bridge/session_title.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import readline from "node:readline"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type Envelope = Record; + +const SESSION_ID = "11111111-1111-4111-8111-111111111111"; +const CLEARED_SESSION_ID = "22222222-2222-4222-8222-222222222222"; + +/** + * A stand-in for the SDK. Its session API reports titles from its own state, + * which its query changes the way Claude Code 2.1.288 does, as measured: a + * launch-time name and a /rename show only once their turn's result is in, and + * /clear starts a new session id that reports the old title. Every prompt is a + * local command answered with one assistant message and the result. Prompts + * prefixed `/test-` steer the session API: fail the next read, hold the next + * read until a later one starts, or reset the conversation. + */ +function writeSdkFixture(fixturePath: string, cwd: string): void { + const sdkUrl = import.meta.resolve("@anthropic-ai/claude-agent-sdk"); + writeFileSync(fixturePath, ` + export * from ${JSON.stringify(sdkUrl)}; + import { appendFileSync } from "node:fs"; + const cwd = ${JSON.stringify(cwd)}; + const titles = new Map([[${JSON.stringify(SESSION_ID)}, "Saved name"]]); + let failNextRead = false; + let holdNextRead = false; + let releaseHeldRead; + export async function getSessionInfo(sessionId) { + const release = releaseHeldRead; + releaseHeldRead = undefined; + release?.(); + if (failNextRead) { + failNextRead = false; + throw new Error("session info unavailable"); + } + if (holdNextRead) { + holdNextRead = false; + return new Promise((resolve) => { + releaseHeldRead = () => resolve({ sessionId, summary: "Stale read", lastModified: 1, customTitle: "Stale read" }); + }); + } + const title = titles.get(sessionId); + return title === undefined ? undefined : { sessionId, summary: title, lastModified: 1, customTitle: title, cwd }; + } + export async function renameSession(sessionId, title) { + titles.set(sessionId, title); + } + export async function importSessionToStore() {} + export async function getSessionMessages() { + return []; + } + export async function listSessions() { + return [{ sessionId: ${JSON.stringify(SESSION_ID)}, cwd, lastModified: 1 }]; + } + export function query({ prompt, options }) { + appendFileSync(process.env.QUERY_JOURNAL, JSON.stringify({ resume: options.resume }) + "\\n"); + const input = prompt[Symbol.asyncIterator](); + const pending = []; + let sessionId = options.resume; + let replies = 0; + return { + [Symbol.asyncIterator]() { return this; }, + async next() { + if (pending.length === 0) { + const next = await input.next(); + if (next.done) return { done: true, value: undefined }; + const content = next.value.message.content; + const text = typeof content === "string" ? content : content.map((block) => block.text ?? "").join(""); + let reply = ""; + replies += 1; + if (text.startsWith("/rename ")) { + titles.set(sessionId, text.slice("/rename ".length)); + reply = "Session renamed to: " + titles.get(sessionId); + } else if (text === "first turn") { + titles.set(sessionId, "Launch name"); + reply = "ok"; + } else if (text === "/clear") { + const title = titles.get(sessionId); + sessionId = ${JSON.stringify(CLEARED_SESSION_ID)}; + titles.set(sessionId, title); + pending.push({ type: "system", subtype: "init", session_id: sessionId, uuid: "init-" + replies }); + } else if (text === "/test-fail-read") { + failNextRead = true; + } else if (text === "/test-hold-read") { + holdNextRead = true; + } else if (text === "/test-reset-conversation") { + pending.push({ type: "conversation_reset", session_id: sessionId, uuid: "reset-" + replies, new_conversation_id: "conversation-" + replies }); + } else { + reply = "Unknown command: " + text; + } + if (reply) { + pending.push({ type: "assistant", uuid: "reply-" + replies, session_id: sessionId, parent_tool_use_id: null, + message: { id: "local-" + replies, role: "assistant", content: [{ type: "text", text: reply }] } }); + } + pending.push({ type: "result", subtype: "success", uuid: "result-" + replies, session_id: sessionId, parent_tool_use_id: null, + result: reply, user_message_uuid: next.value.uuid, user_message_uuids: [next.value.uuid] }); + } + return { done: false, value: pending.shift() }; + }, + close() {}, + async generateSessionTitle() { + titles.set(sessionId, "Generated name"); + return "Generated name"; + }, + async initializationResult() { return { current_permission_mode: "default", models: [], commands: [], agents: [], account: { apiKeySource: "fixture" }, fast_mode_state: "off" }; }, + async getSettings() { return { applied: { model: "opus", effort: "high", ultracodeAvailable: false, ultracodeRequested: false, ultracode: false } }; }, + async supportedCommands() { return []; }, + }; + } + `); +} + +const isTitleUpdate = (event: Envelope) => + event.event === "session_update" && (event.update as Envelope)?.type === "session_title_update"; +const titleOf = (event: Envelope) => (event.update as Envelope).title; + +test("the bridge sends the session API's title through resume, turns, renames, resets and /clear", async () => { + for (const command of ["resume_session", "create_session"] as const) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "bridge-title-"))); + const cwd = join(directory, "project"); + mkdirSync(cwd); + const journal = join(directory, "query.jsonl"); + const fixturePath = join(directory, "sdk-fixture.mjs"); + // The bridge checks the SDK version beside the module it resolves, which + // is the stand-in here, so it gets the installed SDK's version. + const sdkPackage = join(dirname(fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))), "package.json"); + writeFileSync(join(directory, "package.json"), JSON.stringify({ version: JSON.parse(readFileSync(sdkPackage, "utf8")).version })); + writeSdkFixture(fixturePath, cwd); + const loaderPath = join(directory, "loader.mjs"); + writeFileSync(loaderPath, ` + import { registerHooks } from "node:module"; + registerHooks({ resolve(specifier, context, nextResolve) { + return specifier === "@anthropic-ai/claude-agent-sdk" + ? { url: ${JSON.stringify(pathToFileURL(fixturePath).href)}, shortCircuit: true } + : nextResolve(specifier, context); + } }); + `); + const bridgePath = join(dirname(fileURLToPath(import.meta.url)), "../bridge.js"); + const child = spawn(process.execPath, ["--import", pathToFileURL(loaderPath).href, bridgePath], { + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(directory, "config"), + CLAUDE_CODE_PROJECT_DIR_NAME: undefined, + CLAUDE_RS_BRIDGE_DIAGNOSTICS: "1", + QUERY_JOURNAL: journal, + CLAUDE_CODE_EXECUTABLE: "", + }, + stdio: "pipe", + }); + const output = readline.createInterface({ input: child.stdout }); + const diagnostics = readline.createInterface({ input: child.stderr }); + const events: Envelope[] = []; + const stderrLines: string[] = []; + let onStderr = () => {}; + diagnostics.on("line", (line) => { + stderrLines.push(line); + onStderr(); + }); + const timeout = setTimeout(() => child.kill(), 10_000); + const send = (message: Envelope) => child.stdin.write(`${JSON.stringify(message)}\n`); + const prompt = (uuid: string, text: string) => + send({ command: "prompt", session_id: SESSION_ID, message_uuid: uuid, chunks: [{ kind: "text", value: text }] }); + const lines = output[Symbol.asyncIterator](); + const readUntil = async (done: (event: Envelope) => boolean) => { + for (;;) { + const next = await lines.next(); + assert.ok(!next.done, `${command}: bridge closed: ${JSON.stringify(events)} ${stderrLines.join("\n")}`); + const event = JSON.parse(next.value) as Envelope; + events.push(event); + if (done(event)) return; + } + }; + const titleUpdate = (sessionId: string, title: string) => (event: Envelope) => + isTitleUpdate(event) && event.session_id === sessionId && titleOf(event) === title; + try { + send(command === "resume_session" + ? { command, session_id: SESSION_ID, request_id: "title-resume", launch_settings: {} } + : { command, cwd, resume: SESSION_ID, request_id: "title-resume", launch_settings: {} }); + await readUntil(titleUpdate(SESSION_ID, "Saved name")); + assert.ok(events.some((event) => event.event === "connected"), `${command}: ${JSON.stringify(events)}`); + assert.equal((JSON.parse(readFileSync(journal, "utf8").trim()) as Envelope).resume, SESSION_ID); + + // A failed read is logged, and nothing is sent for it. + const beforeFailure = events.length; + const failureLogged = new Promise((resolve) => { + onStderr = () => { + if (stderrLines.some((line) => line.includes('"event_name":"session_title_read_failed"'))) resolve(); + }; + }); + prompt("prompt-fail", "/test-fail-read"); + await failureLogged; + await readUntil((event) => event.event === "turn_complete"); + assert.deepEqual(events.slice(beforeFailure).filter(isTitleUpdate), [], command); + + // A launch-time name shows once the first turn's result is in. + prompt("prompt-first", "first turn"); + await readUntil(titleUpdate(SESSION_ID, "Launch name")); + + // A read that a later one overtakes is dropped: the held read resolves + // with a stale title only once the /rename turn's read has started. + prompt("prompt-hold", "/test-hold-read"); + await readUntil((event) => event.event === "turn_complete"); + prompt("prompt-rename", "/rename Fresh name"); + await readUntil(titleUpdate(SESSION_ID, "Fresh name")); + + // The Status tab's rename and title generation. + send({ command: "rename_session", session_id: SESSION_ID, title: "Status name", request_id: "status-rename" }); + await readUntil(titleUpdate(SESSION_ID, "Status name")); + send({ command: "generate_session_title", session_id: SESSION_ID, description: "hello", request_id: "status-generate" }); + await readUntil(titleUpdate(SESSION_ID, "Generated name")); + + // The app drops the title with the conversation, so it is sent again. + prompt("prompt-reset", "/test-reset-conversation"); + await readUntil((event) => event.event === "session_update" && (event.update as Envelope).type === "conversation_reset"); + await readUntil(titleUpdate(SESSION_ID, "Generated name")); + + // /clear replaces the session id; the new id's title is sent. + prompt("prompt-clear", "/clear"); + await readUntil(titleUpdate(CLEARED_SESSION_ID, "Generated name")); + assert.ok(events.some((event) => event.event === "session_replaced" && event.session_id === CLEARED_SESSION_ID)); + + const shown = events + .filter(isTitleUpdate) + .map((event) => [event.session_id, titleOf(event)]) + .filter((entry, index, all) => index === 0 || JSON.stringify(entry) !== JSON.stringify(all[index - 1])); + assert.deepEqual( + shown, + [ + [SESSION_ID, "Saved name"], + [SESSION_ID, "Launch name"], + [SESSION_ID, "Fresh name"], + [SESSION_ID, "Status name"], + [SESSION_ID, "Generated name"], + [CLEARED_SESSION_ID, "Generated name"], + ], + command, + ); + } finally { + clearTimeout(timeout); + output.close(); + diagnostics.close(); + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + await new Promise((resolve) => child.once("exit", () => resolve())); + } + rmSync(directory, { recursive: true, force: true }); + } + } +}); diff --git a/agent-sdk/src/bridge/session_title.ts b/agent-sdk/src/bridge/session_title.ts new file mode 100644 index 00000000..c2e9254e --- /dev/null +++ b/agent-sdk/src/bridge/session_title.ts @@ -0,0 +1,45 @@ +import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk"; +import { emitSessionUpdate } from "./events.js"; +import { bridgeLogger, LOG_TARGETS } from "./logger.js"; +import type { SessionState } from "./session_lifecycle.js"; + +/** + * Send the session's title as the public session API reports it, so the app + * never keeps a title of its own making. The app stores it idempotently, so + * every read that returns a title sends it. + * + * Measured on Claude Code 2.1.288 (bundled with the pinned SDK): a launch-time + * `-n` name and a /rename reach `getSessionInfo` only after a turn, so the + * title is read again after every top-level turn and every rename the app + * requests. After /clear the new session id already reports the old title. + * `customTitle` falls back to the generated title after the first turn. + */ +export async function emitSessionTitle(session: SessionState): Promise { + const sessionId = session.sessionId; + const read = (session.titleReads ?? 0) + 1; + session.titleReads = read; + let title: string | undefined; + try { + title = (await getSessionInfo(sessionId, { dir: session.cwd }))?.customTitle; + } catch (error) { + bridgeLogger.warn({ + target: LOG_TARGETS.APP_SESSION, + eventName: "session_title_read_failed", + message: "failed to read the session title", + outcome: "failure", + sessionId, + fields: { error_message: error instanceof Error ? error.message : String(error) }, + }); + return; + } + // A later read, a replacement or a close while reading supersedes this one. + if ( + title === undefined || + session.closing || + session.sessionId !== sessionId || + session.titleReads !== read + ) { + return; + } + emitSessionUpdate(sessionId, { type: "session_title_update", title }); +} diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index bcfeece1..069ae587 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -430,6 +430,7 @@ export type SessionUpdate = error: ApiRetryError; } | { type: "prompt_suggestion_update"; suggestion: string } + | { type: "session_title_update"; title: string } | { type: "runtime_session_state_update"; state: RuntimeSessionState } | ({ type: "settings_parse_error" } & SettingsParseErrorUpdate) | { type: "session_status_update"; status: "requesting" | "idle" } diff --git a/docs/src/architecture.md b/docs/src/architecture.md index b481ba82..e7c0f367 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -114,6 +114,17 @@ Rust keeps execution state on the original `ToolCallInfo`. Its history projectio The bridge announces each live top-level SDK response through the typed `agent_response_started` update before sending its text. A response triggered by a background completion receives a fresh mutable assistant owner even when no user prompt is pending; history replay does not activate live responses. The renderer groups consecutive visible assistant messages under one speaker label, carrying speaker context across content already inserted into terminal history. Streaming ownership, message identities and tool insertion order remain separate from that visual grouping. +### Session title + +| Concern | Owner | Location | +| --- | --- | --- | +| Reporting and changing a session's title | Agent SDK | public `getSessionInfo()`, `renameSession()` | +| Reading the active session's title and sending `session_title_update` | Bridge | `agent-sdk/src/bridge/session_title.ts` | +| Holding the active session's title for the Status tab, its rename draft and title generation | Rust | `src/app/state/session_runtime.rs` | +| Listing snapshot of all sessions, including each `custom_title` | Bridge, held by Rust | `agent-sdk/src/bridge/events.ts`, `recent_sessions` | + +The bridge reads `getSessionInfo().customTitle` after a connect, a replacement, a conversation reset, each top-level turn and each rename or title generation it performs, and sends every title it reads; a read that a later one overtakes is dropped. Rust keeps the active title as one value, strips control characters once in the converter, stores a repeated title idempotently, and clears it when the session id changes or the conversation resets. Neither side derives a title of its own. The session listing is a separate snapshot of all sessions, refreshed by `listSessions()` on connect, replacement and the Status-tab rename and generate actions; the Status tab falls back to it only while the active session has no title. `customTitle` falls back to a generated title, so an unnamed session shows that title after its first turn. + ### Notifications | Concern | Owner | Location | diff --git a/docs/src/commands.md b/docs/src/commands.md index fd0caf68..4fe63296 100644 --- a/docs/src/commands.md +++ b/docs/src/commands.md @@ -66,6 +66,12 @@ Use: to inspect the current session's full command list. The output includes app-owned commands and SDK-advertised commands, with descriptions when the SDK provides them. +## Session Name + +The Status tab shows the session name. Its rename draft (`r`) starts from that name, and title generation (`g`) uses it as the description when there is one. + +The name is whatever the Agent SDK's session API reports as the session's title; claude-rs keeps no title of its own. It is read when a session connects or resumes, when the session is replaced, when the conversation resets, after each turn, and after a Status-tab rename or title generation. A `/rename` typed in the composer therefore shows once its turn completes. For a session nobody named, the API reports a generated title instead, so such a session shows that title after its first turn. + Commands that Claude Code runs locally, such as `/rename`, `/color` or `/context`, reply in the chat under the command as they do in Claude Code. `/color` runs in Claude Code and its reply shows, but Claude Rust does not draw a session colour. ## Session Commands diff --git a/src/agent/model/session.rs b/src/agent/model/session.rs index 2f47ef3a..a8e6b014 100644 --- a/src/agent/model/session.rs +++ b/src/agent/model/session.rs @@ -306,6 +306,9 @@ pub enum SessionUpdate { error: ApiRetryError, }, PromptSuggestionUpdate(String), + /// The session's title as the SDK session API reports it; `None` when + /// nothing printable is left. + SessionTitleUpdate(Option), RuntimeSessionStateUpdate(RuntimeSessionState), SettingsParseError { file: Option, diff --git a/src/agent/types.rs b/src/agent/types.rs index 5ed03391..c90a4b30 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -722,6 +722,9 @@ pub enum SessionUpdate { PromptSuggestionUpdate { suggestion: String, }, + SessionTitleUpdate { + title: String, + }, RuntimeSessionStateUpdate { state: RuntimeSessionState, }, diff --git a/src/app/config/edit.rs b/src/app/config/edit.rs index ef7e2b59..638246fd 100644 --- a/src/app/config/edit.rs +++ b/src/app/config/edit.rs @@ -440,16 +440,10 @@ fn confirm_confirmation_overlay(app: &mut App) { } pub(super) fn open_session_rename_overlay(app: &mut App) { - let Some(session_id) = app.session_runtime.session_id.as_ref() else { + if app.session_runtime.session_id.is_none() { return; - }; - let session_id = session_id.to_string(); - let draft = app - .recent_sessions - .iter() - .find(|session| session.session_id == session_id) - .and_then(|session| session.custom_title.clone()) - .unwrap_or_default(); + } + let draft = app.active_session_custom_title().unwrap_or_default().to_owned(); app.config.replace_overlay(ConfigOverlayState::SessionRename(text_input_overlay_state( draft, SessionRenameOverlayState::from_text_input, @@ -575,11 +569,11 @@ fn char_to_byte_index(text: &str, char_index: usize) -> usize { } fn session_title_generation_description(app: &App, session_id: &str) -> Option { - let session = app.recent_sessions.iter().find(|session| session.session_id == session_id)?; + let listed = app.recent_sessions.iter().find(|session| session.session_id == session_id); [ - session.custom_title.as_deref(), - Some(session.summary.as_str()), - session.first_prompt.as_deref(), + app.active_session_custom_title(), + listed.map(|session| session.summary.as_str()), + listed.and_then(|session| session.first_prompt.as_deref()), ] .into_iter() .flatten() diff --git a/src/app/config/tests.rs b/src/app/config/tests.rs index 2f291f6d..cbf8ff42 100644 --- a/src/app/config/tests.rs +++ b/src/app/config/tests.rs @@ -33,6 +33,8 @@ fn app_with_status_connection() -> (App, crate::agent::client::CommandReceiver) let mut app = App::test_default(); let rx = attach_test_connection(&mut app); app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("session-1")); + // The live title the bridge reported; the listed one may lag behind it. + app.session_runtime.session_title = Some("Current custom title".to_owned()); app.config.active_tab = ConfigTab::Status; app.recent_sessions = vec![crate::app::RecentSessionInfo { session_id: "session-1".to_owned(), @@ -41,7 +43,7 @@ fn app_with_status_connection() -> (App, crate::agent::client::CommandReceiver) file_size_bytes: 0, cwd: Some("/test".to_owned()), git_branch: None, - custom_title: Some("Current custom title".to_owned()), + custom_title: Some("Stale listed title".to_owned()), first_prompt: Some("First prompt".to_owned()), }]; (app, rx) @@ -557,6 +559,18 @@ fn status_tab_r_opens_session_rename_overlay() { assert_eq!(app.config.session_rename_overlay().map(|overlay| overlay.cursor), Some(20)); } +#[test] +fn status_rename_uses_listed_unicode_title_while_live_title_is_unavailable() { + let (mut app, _rx) = app_with_status_connection(); + app.session_runtime.session_title = None; + let listed = "\u{1f980}".repeat(20); + app.recent_sessions[0].custom_title = Some(listed.clone()); + handle_key(&mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + let overlay = app.config.session_rename_overlay().expect("rename draft"); + assert_eq!(overlay.draft, listed); + assert_eq!(overlay.cursor, 20); +} + #[tokio::test(flavor = "current_thread")] async fn activating_usage_tab_starts_refresh_lifecycle() { tokio::task::LocalSet::new() @@ -687,6 +701,7 @@ fn status_tab_g_generates_session_title_from_current_title_fallback() { #[test] fn status_tab_g_requires_existing_session_metadata() { let (mut app, mut rx) = app_with_status_connection(); + app.session_runtime.session_title = None; app.recent_sessions[0].custom_title = None; app.recent_sessions[0].summary.clear(); app.recent_sessions[0].first_prompt = None; diff --git a/src/app/connect/type_converters.rs b/src/app/connect/type_converters.rs index 39d60082..11b7ec40 100644 --- a/src/app/connect/type_converters.rs +++ b/src/app/connect/type_converters.rs @@ -522,6 +522,15 @@ pub(super) fn map_session_update(update: types::SessionUpdate) -> Option { Some(model::SessionUpdate::PromptSuggestionUpdate(suggestion)) } + types::SessionUpdate::SessionTitleUpdate { title } => { + // A title is user text that the Status tab writes to the + // terminal, so control characters are stripped here, once. + let title: String = title.chars().filter(|ch| !ch.is_control()).collect(); + let title = title.trim(); + Some(model::SessionUpdate::SessionTitleUpdate( + (!title.is_empty()).then(|| title.to_owned()), + )) + } types::SessionUpdate::RuntimeSessionStateUpdate { state } => { Some(model::SessionUpdate::RuntimeSessionStateUpdate(match state { types::RuntimeSessionState::Idle => model::RuntimeSessionState::Idle, @@ -1371,6 +1380,30 @@ mod tests { ); } + #[test] + fn session_title_updates_decode_from_the_bridge_wire() { + let decode = |json: &str| { + map_session_update(serde_json::from_str::(json).expect("wire")) + }; + assert_eq!( + decode(r#"{"type":"session_title_update","title":" probe-e2e "}"#), + Some(model::SessionUpdate::SessionTitleUpdate(Some("probe-e2e".to_owned()))) + ); + assert_eq!( + decode(r#"{"type":"session_title_update","title":" "}"#), + Some(model::SessionUpdate::SessionTitleUpdate(None)) + ); + assert_eq!( + decode(r#"{"type":"session_title_update","title":"a\u001b]52;c;eA==\u0007b"}"#), + Some(model::SessionUpdate::SessionTitleUpdate(Some("a]52;c;eA==b".to_owned()))) + ); + // C1 controls such as ST (U+009C) and DEL are stripped as well. + assert_eq!( + decode(r#"{"type":"session_title_update","title":"name\u009c\u007f\u001b[2J"}"#), + Some(model::SessionUpdate::SessionTitleUpdate(Some("name[2J".to_owned()))) + ); + } + #[test] fn map_session_update_converts_task_state_update() { let update: types::SessionUpdate = serde_json::from_value(serde_json::json!({ diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 15917221..4c0bc6b9 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -502,6 +502,15 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) { error, ); } + model::SessionUpdate::SessionTitleUpdate(title) => { + if let Some(id) = app.session_runtime.session_id.as_ref() + && let Some(listed) = + app.recent_sessions.iter_mut().find(|session| session.session_id == id.as_str()) + { + listed.custom_title.clone_from(&title); + } + app.session_runtime.session_title = title; + } model::SessionUpdate::PromptSuggestionUpdate(suggestion) => { app.session_runtime.prompt_suggestion = (!suggestion.trim().is_empty()).then_some(suggestion); diff --git a/src/app/events/session_reset.rs b/src/app/events/session_reset.rs index 85be59d6..23f2463b 100644 --- a/src/app/events/session_reset.rs +++ b/src/app/events/session_reset.rs @@ -86,6 +86,8 @@ pub(super) fn reset_for_conversation( } fn clear_cached_active_session_title(app: &mut App) { + // The bridge sends the title the session reports after the reset. + app.session_runtime.session_title = None; let Some(session_id) = app.session_runtime.session_id.as_ref() else { return }; let Some(session) = app.recent_sessions.iter_mut().find(|session| session.session_id == session_id.as_str()) diff --git a/src/app/events/tests/client_events.rs b/src/app/events/tests/client_events.rs index 0f5110b8..ff79565d 100644 --- a/src/app/events/tests/client_events.rs +++ b/src/app/events/tests/client_events.rs @@ -257,6 +257,9 @@ fn connected_resets_session_scoped_view_data() { session_id: "old-session".into(), kind: crate::app::config::PendingSessionTitleChangeKind::Generate, }); + // The previous session's name; the bridge sends the connected one's. + app.session_runtime.session_id = Some(model::SessionId::new("old-session")); + app.session_runtime.session_title = Some("Old session name".to_owned()); handle_client_event(&mut app, connected_event("claude-updated")); @@ -269,6 +272,7 @@ fn connected_resets_session_scoped_view_data() { assert!(app.plugins.installed.is_empty()); assert!(app.plugins.last_inventory_refresh_at.is_none()); assert!(app.config.pending_session_title_change.is_none()); + assert!(app.session_runtime.session_title.is_none()); } #[test] @@ -1830,3 +1834,43 @@ fn ultracode_lifecycle_preserves_conversation_state_and_rejects_stale_sessions() handle_client_event(&mut app, ClientEvent::ConnectionFailed("closed".to_owned().into())); assert!(app.session_runtime.ultracode.is_none()); } + +#[test] +fn session_title_follows_the_child_until_another_session_connects() { + let mut app = make_test_app(); + app.session_runtime.session_id = Some(model::SessionId::new("current-session")); + let update = + |update| ClientEvent::SessionUpdate { session_id: "current-session".to_owned(), update }; + + handle_client_event( + &mut app, + update(model::SessionUpdate::SessionTitleUpdate(Some("probe-e2e".to_owned()))), + ); + assert_eq!(app.session_runtime.session_title.as_deref(), Some("probe-e2e")); + + // Reconnecting the same session keeps it. + app.session_runtime.activate_session(model::SessionId::new("current-session")); + assert_eq!(app.session_runtime.session_title.as_deref(), Some("probe-e2e")); + + app.session_runtime.activate_session(model::SessionId::new("other-session")); + assert_eq!(app.session_runtime.session_title, None); +} + +#[test] +fn title_update_clears_the_matching_listed_title() { + let mut app = make_test_app(); + app.session_runtime.session_id = Some(model::SessionId::new("active")); + let mut listed = listed_session("active", "Independent summary"); + listed.custom_title = Some("Old name".to_owned()); + handle_client_event(&mut app, ClientEvent::SessionsListed { sessions: vec![listed] }); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate { + session_id: "active".to_owned(), + update: model::SessionUpdate::SessionTitleUpdate(None), + }, + ); + assert_eq!(app.active_session_custom_title(), None); + assert_eq!(app.recent_sessions[0].custom_title, None); + assert_eq!(app.recent_sessions[0].summary, "Independent summary"); +} diff --git a/src/app/events/tests/mod.rs b/src/app/events/tests/mod.rs index 3c49785c..963d9289 100644 --- a/src/app/events/tests/mod.rs +++ b/src/app/events/tests/mod.rs @@ -3852,6 +3852,7 @@ fn conversation_reset_mounts_a_fresh_transcript_without_dropping_session_invento session_id: "test-session".to_owned(), kind: crate::app::config::PendingSessionTitleChangeKind::Generate, }); + app.session_runtime.session_title = Some("Old conversation name".to_owned()); handle_client_event( &mut app, @@ -3889,6 +3890,9 @@ fn conversation_reset_mounts_a_fresh_transcript_without_dropping_session_invento assert_eq!(other_session.summary, "Other conversation title"); assert_eq!(other_session.first_prompt.as_deref(), Some("prompt Other conversation title")); assert!(app.config.pending_session_title_change.is_none()); + // A reset drops the shown name with the conversation; the bridge sends + // the title the session reports after it. + assert_eq!(app.session_runtime.session_title, None); assert_eq!(app.status, AppStatus::Ready); } diff --git a/src/app/state/session_identity.rs b/src/app/state/session_identity.rs index 54da6478..763e4353 100644 --- a/src/app/state/session_identity.rs +++ b/src/app/state/session_identity.rs @@ -4,6 +4,17 @@ use super::prelude::*; impl App { + pub(crate) fn active_session_custom_title(&self) -> Option<&str> { + self.session_runtime.session_title.as_deref().or_else(|| { + let id = self.session_runtime.session_id.as_ref()?; + self.recent_sessions + .iter() + .find(|session| session.session_id == id.as_str())? + .custom_title + .as_deref() + }) + } + #[must_use] pub fn active_turn_assistant_idx(&self) -> Option { self.turn.assistant_message_idx.filter(|&idx| { diff --git a/src/app/state/session_runtime.rs b/src/app/state/session_runtime.rs index e81fefbf..ae17ceec 100644 --- a/src/app/state/session_runtime.rs +++ b/src/app/state/session_runtime.rs @@ -40,6 +40,9 @@ pub struct SessionRuntimeState { pub last_rate_limit_update: Option, /// Account info from the bridge status snapshot (email, org, subscription). pub account_info: Option, + /// The active session's title, as the bridge last read it through the + /// Agent SDK's session API. + pub session_title: Option, } impl Default for SessionRuntimeState { @@ -62,6 +65,7 @@ impl Default for SessionRuntimeState { prompt_suggestion: None, last_rate_limit_update: None, account_info: None, + session_title: None, } } } @@ -85,6 +89,8 @@ impl SessionRuntimeState { pub(crate) fn activate_session(&mut self, session_id: model::SessionId) { if self.session_id.as_ref() != Some(&session_id) { self.ultracode = None; + // The bridge sends a replacement session's own title after it connects. + self.session_title = None; } self.last_resumable_session_id = Some(session_id.clone()); self.session_id = Some(session_id); @@ -99,6 +105,7 @@ impl SessionRuntimeState { pub fn clear_identity(&mut self) { self.ultracode = None; + self.session_title = None; self.session_id = None; self.conversation_id = None; self.current_model = None; diff --git a/src/ui/config/status.rs b/src/ui/config/status.rs index 4826dd60..3f4f1d55 100644 --- a/src/ui/config/status.rs +++ b/src/ui/config/status.rs @@ -93,32 +93,21 @@ pub(crate) fn status_lines(app: &App) -> Vec> { } fn derive_session_name(app: &App) -> String { - if let Some(ref sid) = app.session_runtime.session_id { - let sid_str = sid.to_string(); - if let Some(session) = app.recent_sessions.iter().find(|s| s.session_id == sid_str) { - if let Some(ref title) = session.custom_title - && !title.trim().is_empty() - { - return title.clone(); - } - if !session.summary.trim().is_empty() { - let summary = &session.summary; - return if summary.len() > 60 { - format!("{}...", &summary[..57]) - } else { - summary.clone() - }; - } - if let Some(ref prompt) = session.first_prompt - && !prompt.trim().is_empty() - { - return if prompt.len() > 60 { - format!("{}...", &prompt[..57]) - } else { - prompt.clone() - }; - } - } + if let Some(title) = app.active_session_custom_title() { + return title.to_owned(); + } + if let Some(sid) = app.session_runtime.session_id.as_ref() + && let Some(session) = app.recent_sessions.iter().find(|s| s.session_id == sid.as_str()) + && let Some(summary) = [Some(session.summary.as_str()), session.first_prompt.as_deref()] + .into_iter() + .flatten() + .find(|text| !text.trim().is_empty()) + { + return if summary.chars().count() > 60 { + format!("{}...", summary.chars().take(57).collect::()) + } else { + summary.to_owned() + }; } "(unnamed session)".to_owned() } @@ -201,21 +190,61 @@ mod tests { } #[test] - fn status_lines_uses_custom_title() { + fn status_lines_name_the_session_by_its_live_title() { let mut app = App::test_default(); app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("test-sess-1")); app.recent_sessions = vec![crate::app::RecentSessionInfo { session_id: "test-sess-1".to_owned(), - summary: String::new(), + summary: "Listed summary".to_owned(), last_modified_ms: 0, file_size_bytes: 0, cwd: None, git_branch: None, - custom_title: Some("My Custom Title".to_owned()), + custom_title: Some("Stale listed title".to_owned()), first_prompt: None, }]; - let text = lines_to_string(&status_lines(&app)); - assert!(text.contains("My Custom Title")); + let name_row = |app: &App| { + status_lines(app) + .iter() + .map(std::string::ToString::to_string) + .find(|line| line.contains("Session name")) + .expect("session name row") + }; + assert!(name_row(&app).contains("Stale listed title")); + app.session_runtime.session_title = Some("probe-e2e".to_owned()); + assert!(name_row(&app).contains("probe-e2e")); + } + + #[test] + fn status_name_fallback_preserves_unicode_and_uses_only_the_active_session() { + let mut app = App::test_default(); + app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("active")); + let unicode = "\u{1f980}".repeat(20); + app.recent_sessions = vec![crate::app::RecentSessionInfo { + session_id: "active".to_owned(), + summary: unicode.clone(), + last_modified_ms: 0, + file_size_bytes: 0, + cwd: None, + git_branch: None, + custom_title: Some(unicode.clone()), + first_prompt: None, + }]; + let name_row = |app: &App| { + status_lines(app) + .into_iter() + .map(|line| line.to_string()) + .find(|line| line.starts_with("Session name:")) + .expect("name row") + }; + assert_eq!(name_row(&app), format!("Session name: {unicode}")); + app.recent_sessions[0].custom_title = None; + assert_eq!(name_row(&app), format!("Session name: {unicode}")); + app.recent_sessions[0].summary.clear(); + app.recent_sessions[0].first_prompt = Some("\u{1f980}".repeat(61)); + assert_eq!(name_row(&app), format!("Session name: {}...", "\u{1f980}".repeat(57))); + app.recent_sessions[0].session_id = "other".to_owned(); + assert_eq!(name_row(&app), "Session name: (unnamed session)"); } #[test] diff --git a/tests/fixtures/fake-bridge.js b/tests/fixtures/fake-bridge.js index 22c0f9b1..c3b07f97 100644 --- a/tests/fixtures/fake-bridge.js +++ b/tests/fixtures/fake-bridge.js @@ -312,6 +312,20 @@ function streamReply(messageUuid) { } } +// The bridge's translation of a /rename turn: the reply, the result, then +// the title it reads back once the result is in. +function sessionNameReply(message) { + const text = message.chunks.map(chunk => chunk.value).join(''); + const update = update => send({ event: 'session_update', session_id: SESSION, update }); + send({ event: 'user_message_started', session_id: SESSION, message_uuid: message.message_uuid, source: 'command_lifecycle' }); + const [command, ...args] = text.trim().split(/\s+/); + if (command === '/rename') { + update({ type: 'agent_message_chunk', content: { type: 'text', text: `Session renamed to: ${args.join(' ')}` } }); + } + send({ event: 'turn_complete', session_id: SESSION }); + if (command === '/rename') update({ type: 'session_title_update', title: args.join(' ') }); +} + readline .createInterface({ input: process.stdin }) .on('line', raw => { @@ -363,6 +377,12 @@ readline restored_input: null, }); if (SCENARIO.startsWith('resize-') || SCENARIO.startsWith('background-')) send({ event: 'status_snapshot', session_id: SESSION, account: { subscription_type: 'Fixture subscription' } }); + if (SCENARIO === 'session-name') { + // As the real bridge does after connect: the SDK-advertised commands. + send({ event: 'session_update', session_id: SESSION, update: { type: 'available_commands_update', commands: [ + { name: 'rename', description: 'Rename the current conversation', input_hint: '[name]' }, + ] } }); + } if (SCENARIO === 'disconnect-during-auth' && message.command === 'create_session') { const timer = setInterval(() => { if (fs.existsSync(RELEASE_FILE)) { @@ -374,6 +394,10 @@ readline } break; case 'prompt': + if (SCENARIO === 'session-name') { + sessionNameReply(message); + break; + } if (active) { pending.push(message.message_uuid); send({ event: 'user_message_queued', session_id: SESSION, message_uuid: message.message_uuid }); diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index afa37525..a6558ab0 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -566,9 +566,46 @@ impl TerminalTest { } fn submit(&mut self, text: &str, visible: &str) { + self.submit_with_entry(text, visible, Self::paste); + } + + fn submit_command(&mut self, text: &str) { + self.submit_with_entry(text, text, |test, text| { + if cfg!(windows) { + // ConPTY's key-burst path reordered this command on CI + // (2026-10-09). Model ordinary typing for the name test. + let mut prefix = String::new(); + for ch in text.chars() { + let offset = std::fs::read(test.temp.path().join("runtime.log")) + .expect("runtime log") + .len(); + test.send(ch.encode_utf8(&mut [0; 4]).as_bytes()); + prefix.push(ch); + // Screen contents trim trailing blanks; the next prefix + // and final command still verify the space between words. + test.wait_composer_text(offset, prefix.trim_end()); + std::thread::sleep(Duration::from_millis(80)); + } + } else { + test.paste(text); + } + }); + } + + fn submit_with_entry( + &mut self, + text: &str, + visible: &str, + enter: impl FnOnce(&mut Self, &str), + ) { let log_offset = std::fs::read(self.temp.path().join("runtime.log")).expect("runtime log").len(); - self.paste(text); + enter(self, text); + self.wait_composer_text(log_offset, visible); + self.submit_draft(); + } + + fn wait_composer_text(&mut self, log_offset: usize, visible: &str) { // The overview, tips, and transcript can already mention the command. // Check the editor region from a subsequent draw. ConPTY's native cursor // can remain on the spinner instead of the editor. @@ -592,7 +629,6 @@ impl TerminalTest { }; test.screen().lines().skip(top).take(height).any(|line| line.contains(visible)) }); - self.submit_draft(); } fn submit_draft(&mut self) { @@ -2059,6 +2095,28 @@ fn notifications_follow_focus_saved_categories_and_sdk_delivery_provenance_in_a_ assert_eq!(bell["span"]["tool_call_id"], "push-1"); } +/// The Status tab's "Session name" row, once it shows `name`. +fn status_name_row(test: &TerminalTest, name: &str) -> Option { + test.screen() + .lines() + .find(|line| line.contains("Session name") && line.contains(name)) + .map(str::to_owned) +} + +#[test] +fn status_tab_shows_the_session_name_after_rename() { + let mut test = TerminalTest::start("session-name", 3); + test.submit_command("/rename probe-e2e"); + test.wait_turn_finished("Session renamed to: probe-e2e"); + test.assert_prompts(&["/rename probe-e2e"]); + + // The fake bridge lists no sessions, so only the title it sent after the + // turn can name the session here. + test.submit("/status", "/status"); + test.wait_for("the new name in Status", |test| status_name_row(test, "probe-e2e")); + test.shutdown(); +} + /// A terminal outside Windows sends Ctrl+V as one press byte and no release. /// Without a display server the clipboard cannot be opened, and the app has to /// say so: silence means the key never reached the clipboard path. From 70c04efa369e0a151fe2d119ffc5b6be8143d20e Mon Sep 17 00:00:00 2001 From: Ayush Sharma <47772616+ayush5harma@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:59:52 +0530 Subject: [PATCH 02/10] fix(agent-sdk): run top-level bridge tests (#471) * test(agent-sdk): keep the SDK resolvable for the bridge version check - Redirect only ESM imports of @anthropic-ai/claude-agent-sdk to the stub in the contract-test loader. - Leave require.resolve alone so the bridge version check reads the real installed package.json. - Fix 16 spawned-bridge tests that failed with "Agent SDK version check failed" and connection_failed. - Make them runnable by the next commit, which stops the glob from hiding them (#446). * fix(agent-sdk): run top-level bridge tests - Quote the test glob so node expands dist/**/*.test.js instead of sh reading ** as *. - Run dist/bridge.test.js and dist/bridge.contract.test.js in CI: 500 tests instead of 167. - Use escaped double quotes so npm on Windows (cmd.exe, which keeps single quotes literal) passes the same pattern. - Fixes #446. --- agent-sdk/package.json | 2 +- agent-sdk/src/bridge.contract.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/agent-sdk/package.json b/agent-sdk/package.json index 1b920866..e1aa6751 100644 --- a/agent-sdk/package.json +++ b/agent-sdk/package.json @@ -14,7 +14,7 @@ "knip": "knip --include files,dependencies,unlisted,unresolved", "audit": "node ./scripts/audit-direct-deps.mjs", "audit:raw": "npm audit --audit-level=moderate", - "test": "npm run build && node --test dist/**/*.test.js" + "test": "npm run build && node --test \"dist/**/*.test.js\"" }, "engines": { "node": ">=24" diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index 1608846f..00273044 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -733,8 +733,12 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB const loaderPath = join(directory, "loader.mjs"); writeFileSync(loaderPath, ` import { registerHooks } from "node:module"; + // Only ESM imports are redirected. The bridge's version check calls + // require.resolve on the SDK to read its package.json; redirecting that + // into the temp fixture directory made the check fail with "unable to + // resolve installed ... package.json" before any session started. registerHooks({ resolve(specifier, context, nextResolve) { - return specifier === "@anthropic-ai/claude-agent-sdk" + return specifier === "@anthropic-ai/claude-agent-sdk" && !context.conditions.includes("require") ? { url: ${JSON.stringify(pathToFileURL(fixturePath).href)}, shortCircuit: true } : nextResolve(specifier, context); } }); From e95318b8dc63223a45bcd2d8f4f8983b9649e469 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:25:13 +0200 Subject: [PATCH 03/10] ci(issues): automate triage and duplicate handling (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(issues): automate triage and duplicate handling - manage triage labels when issues open, reopen, and close - restrict triaged and duplicate commands to collaborators with write access - preserve triage lifecycle for dependency monitor issues - document commands and add permission, closure, and retry checks * ci(issues): keep queued triage events and document label owners - set `queue: max` so pending issue events are no longer replaced by newer ones - list the files that name `status: triage` in the label rename note --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/scripts/issue-triage.cjs | 63 +++++++ .github/scripts/issue-triage.test.cjs | 185 +++++++++++++++++++++ .github/workflows/dependency-monitor.yml | 3 + .github/workflows/issue-triage.yml | 37 +++++ .github/workflows/pr.yml | 19 +++ CONTRIBUTING.md | 17 +- 8 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/issue-triage.cjs create mode 100644 .github/scripts/issue-triage.test.cjs create mode 100644 .github/workflows/issue-triage.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d109314b..944a27f4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,7 +1,7 @@ name: Bug Report description: Report a bug or unexpected behavior title: "[Bug]: " -labels: ["type: bug", "status: triage"] +labels: ["type: bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3db5012e..449d334a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,7 +1,7 @@ name: Feature Request description: Suggest a new feature or improvement title: "[Feature]: " -labels: ["type: feature", "status: triage"] +labels: ["type: feature"] body: - type: markdown attributes: diff --git a/.github/scripts/issue-triage.cjs b/.github/scripts/issue-triage.cjs new file mode 100644 index 00000000..6f1ccd85 --- /dev/null +++ b/.github/scripts/issue-triage.cjs @@ -0,0 +1,63 @@ +const TRIAGE_LABEL = "status: triage"; + +module.exports = async function runIssueTriage({ github, context }) { + const event = context.payload; + if (event.issue.pull_request) return; + + let command; + if (context.eventName === "issue_comment") { + if (event.action !== "created" || event.comment.user.type === "Bot") return; + command = event.comment.body.trim().match(/^\/(triaged|duplicate #([1-9]\d*))$/); + if (!command) return; + + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: event.comment.user.login, + }); + if (!["write", "admin"].includes(data.permission)) return; + } else if (context.eventName !== "issues" || !["opened", "reopened", "closed"].includes(event.action)) { + return; + } + + const source = { ...context.repo, issue_number: event.issue.number }; + const { data: issue } = await github.rest.issues.get(source); + const removeTriage = async () => { + if (!issue.labels.some(label => label.name === TRIAGE_LABEL)) return; + try { + await github.rest.issues.removeLabel({ ...source, name: TRIAGE_LABEL }); + } catch (error) { + if (error.status !== 404) throw error; + } + }; + + if (context.eventName === "issues") { + if (event.action === "closed") { + if (issue.state === "closed") await removeTriage(); + } else if (issue.state === "open" && !issue.labels.some(label => label.name === TRIAGE_LABEL)) { + await github.rest.issues.addLabels({ ...source, labels: [TRIAGE_LABEL] }); + } + return; + } + + if (command[1] === "triaged" || issue.state === "closed") { + await removeTriage(); + return; + } + + const targetNumber = Number(command[2]); + if (!Number.isSafeInteger(targetNumber) || targetNumber === issue.number) { + throw new Error("The duplicate target must be a different issue in this repository."); + } + const { data: target } = await github.rest.issues.get({ ...context.repo, issue_number: targetNumber }); + if (target.pull_request) throw new Error("The duplicate target must be an issue, not a pull request."); + + await github.rest.issues.update({ + ...source, + state: "closed", + state_reason: "duplicate", + duplicate_issue_id: target.id, + headers: { "X-GitHub-Api-Version": "2026-03-10" }, + }); + // GITHUB_TOKEN mutations do not trigger the issue-closed workflow. + await removeTriage(); +}; diff --git a/.github/scripts/issue-triage.test.cjs b/.github/scripts/issue-triage.test.cjs new file mode 100644 index 00000000..3079fb51 --- /dev/null +++ b/.github/scripts/issue-triage.test.cjs @@ -0,0 +1,185 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const runIssueTriage = require("./issue-triage.cjs"); + +function fixture({ permission = "write", state = "open", triage = true, targetState = "open", targetPr = false } = {}) { + const source = { + number: 40, id: 90040, state, assignees: [{ login: "owner" }], + labels: [{ name: "type: bug" }, { name: "area: tui" }, ...(triage ? [{ name: "status: triage" }] : [])], + }; + const target = { number: 7, id: 812345, state: targetState, ...(targetPr ? { pull_request: {} } : {}) }; + const calls = []; + const github = { rest: { repos: { + async getCollaboratorPermissionLevel(args) { + calls.push(["permission", args]); + return { data: { permission } }; + }, + }, issues: { + async get(args) { + calls.push(["get", args]); + const issue = args.issue_number === 40 ? source : args.issue_number === 7 ? target : undefined; + if (!issue) throw Object.assign(new Error("not found"), { status: 404 }); + return { data: structuredClone(issue) }; + }, + async addLabels(args) { + calls.push(["add", args]); + for (const name of args.labels) { + if (!source.labels.some(label => label.name === name)) source.labels.push({ name }); + } + }, + async removeLabel(args) { + calls.push(["remove", args]); + source.labels = source.labels.filter(label => label.name !== args.name); + }, + async update(args) { + calls.push(["update", args]); + Object.assign(source, { state: args.state, state_reason: args.state_reason, duplicate_issue_id: args.duplicate_issue_id }); + }, + } } }; + const context = { + repo: { owner: "maintainer", repo: "project" }, + eventName: "issues", + payload: { action: "opened", issue: { number: 40 }, comment: { body: "/triaged", user: { login: "reviewer", type: "User" } } }, + }; + return { + github, context, source, target, calls, + mutations: () => calls.filter(([method]) => ["add", "remove", "update"].includes(method)), + run: () => runIssueTriage({ github, context }), + command(body) { context.eventName = "issue_comment"; context.payload.action = "created"; context.payload.comment.body = body; }, + }; +} + +for (const action of ["opened", "reopened"]) { + test(`${action} queues assessment without changing existing classification or assignment`, async () => { + const f = fixture({ triage: false }); + f.context.payload.action = action; + await f.run(); + assert.deepEqual(f.source.labels.map(label => label.name), ["type: bug", "area: tui", "status: triage"]); + assert.deepEqual(f.source.assignees, [{ login: "owner" }]); + await f.run(); + assert.equal(f.mutations().length, 1, "replayed event must not write another label"); + }); +} + +test("closure clears only triage and a delayed closure cannot clear a reopened issue", async () => { + for (const state of ["closed", "open"]) { + const f = fixture({ state }); + f.context.payload.action = "closed"; + await f.run(); + assert.deepEqual(f.source.labels.map(label => label.name), state === "closed" ? ["type: bug", "area: tui"] : ["type: bug", "area: tui", "status: triage"]); + } +}); + +test("a delayed opened event cannot retriage an already closed issue", async () => { + const f = fixture({ state: "closed", triage: false }); + await f.run(); + assert.deepEqual(f.mutations(), []); +}); + +test("triaged requires actual write access and never changes ownership or closes the issue", async () => { + for (const permission of ["write", "admin", "read", "none"]) { + const f = fixture({ permission }); + f.command("/triaged"); + f.context.payload.comment.author_association = "CONTRIBUTOR"; + await f.run(); + const allowed = ["write", "admin"].includes(permission); + assert.equal(f.source.labels.some(label => label.name === "status: triage"), !allowed); + assert.equal(f.source.state, "open"); + assert.deepEqual(f.source.assignees, [{ login: "owner" }]); + assert.equal(f.calls.some(([method]) => method === "update"), false); + } +}); + +test("untrusted users cannot close an issue with a valid duplicate command", async () => { + const f = fixture({ permission: "read" }); + f.command("/duplicate #7"); + f.context.payload.comment.author_association = "MEMBER"; + await f.run(); + assert.deepEqual(f.mutations(), []); + assert.equal(f.source.state, "open"); +}); + +test("permission lookup failure cannot fall back to trusting author association", async () => { + const f = fixture(); + f.command("/duplicate #7"); + f.context.payload.comment.author_association = "OWNER"; + f.github.rest.repos.getCollaboratorPermissionLevel = async () => { throw new Error("permission service unavailable"); }; + await assert.rejects(f.run(), /permission service unavailable/); + assert.deepEqual(f.mutations(), []); +}); + +test("quoted commands, code, prose, malformed targets and edited comments do not run", async () => { + for (const body of ["> /triaged", "`/triaged`", "```\n/duplicate #7\n```", "Example:\n/duplicate #7", "/duplicate #0", "/duplicate owner/other#7", "/duplicate #7; echo unsafe", "/duplicate #7\n/triaged"]) { + const f = fixture(); + f.command(body); + await f.run(); + assert.deepEqual(f.calls, [], body); + } + const f = fixture(); + f.command("/duplicate #7"); + f.context.payload.action = "edited"; + await f.run(); + assert.deepEqual(f.calls, []); +}); + +test("bot comments and PR threads make no API calls", async () => { + for (const kind of ["bot", "pr"]) { + const f = fixture(); + f.command("/duplicate #7"); + if (kind === "bot") f.context.payload.comment.user.type = "Bot"; + else f.context.payload.issue.pull_request = {}; + await f.run(); + assert.deepEqual(f.calls, []); + } +}); + +test("duplicate closure records the canonical issue database ID and preserves other labels", async () => { + for (const targetState of ["open", "closed"]) { + const f = fixture({ targetState }); + f.command("/duplicate #7"); + await f.run(); + assert.equal(f.source.state, "closed"); + assert.equal(f.source.state_reason, "duplicate"); + assert.equal(f.source.duplicate_issue_id, 812345, "database ID must not be the issue number 7"); + assert.deepEqual(f.source.labels.map(label => label.name), ["type: bug", "area: tui"]); + assert.deepEqual(f.calls.find(([method]) => method === "update")[1], { + owner: "maintainer", repo: "project", issue_number: 40, + state: "closed", state_reason: "duplicate", duplicate_issue_id: 812345, + headers: { "X-GitHub-Api-Version": "2026-03-10" }, + }); + await f.run(); + assert.equal(f.calls.filter(([method]) => method === "update").length, 1); + } +}); + +test("self, nonexistent, unsafe integer and PR targets leave the source untouched", async () => { + for (const [body, targetPr] of [["/duplicate #40", false], ["/duplicate #999", false], ["/duplicate #9007199254740992", false], ["/duplicate #7", true]]) { + const f = fixture({ targetPr }); + f.command(body); + await assert.rejects(f.run()); + assert.deepEqual(f.mutations(), []); + assert.equal(f.source.state, "open"); + } +}); + +test("a failed duplicate write does not remove triage or change classification", async () => { + const f = fixture(); + f.command("/duplicate #7"); + f.github.rest.issues.update = async () => { throw new Error("write failed"); }; + await assert.rejects(f.run(), /write failed/); + assert.equal(f.source.state, "open"); + assert.deepEqual(f.mutations(), []); +}); + +test("retry after successful closure repairs failed label cleanup without reclosing", async () => { + const f = fixture(); + f.command("/duplicate #7"); + const remove = f.github.rest.issues.removeLabel; + f.github.rest.issues.removeLabel = async () => { throw new Error("cleanup failed"); }; + await assert.rejects(f.run(), /cleanup failed/); + assert.equal(f.source.state, "closed"); + f.github.rest.issues.removeLabel = remove; + await f.run(); + assert.deepEqual(f.source.labels.map(label => label.name), ["type: bug", "area: tui"]); + assert.equal(f.calls.filter(([method]) => method === "update").length, 1); +}); diff --git a/.github/workflows/dependency-monitor.yml b/.github/workflows/dependency-monitor.yml index 8f6998ce..ec9fdc9f 100644 --- a/.github/workflows/dependency-monitor.yml +++ b/.github/workflows/dependency-monitor.yml @@ -71,6 +71,7 @@ jobs: if [[ "${CLEAN}" == "true" ]]; then if [[ -n "${existing_issue}" ]]; then + gh issue edit "${existing_issue}" --repo "${REPO}" --remove-label "status: triage" gh issue close "${existing_issue}" \ --repo "${REPO}" \ --reason completed \ @@ -123,6 +124,7 @@ jobs: --repo "${REPO}" \ --title "${issue_title}" \ --label "type: security" \ + --label "status: triage" \ --body-file "${issue_file}" fi @@ -238,5 +240,6 @@ jobs: --title "${issue_title}" \ --label "type: dependencies" \ --label "area: agent-sdk" \ + --label "status: triage" \ --body-file "${issue_file}" fi diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 00000000..46cbe9ca --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,37 @@ +name: Issue Triage + +on: + issues: + types: [opened, reopened, closed] + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: issue-triage-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false + # Keep every pending event; the default drops all but the newest one. + queue: max + +jobs: + triage: + name: Manage issue triage + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Apply issue action + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const runIssueTriage = require('./.github/scripts/issue-triage.cjs'); + await runIssueTriage({ github, context }); diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b67cf72d..bf284138 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -270,6 +270,22 @@ jobs: dist-install/*.zip if-no-files-found: warn + issue-triage-checks: + name: Issue triage checks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: "24" + + - name: Check triage and duplicate commands + run: node --test .github/scripts/issue-triage.test.cjs + duplicate-code: name: Duplicate Code runs-on: ubuntu-latest @@ -352,6 +368,7 @@ jobs: - cargo-deny-advisories - agent-sdk-check - npm-package-layout + - issue-triage-checks - duplicate-code - codeql steps: @@ -368,6 +385,7 @@ jobs: CARGO_DENY_ADVISORIES: ${{ needs.cargo-deny-advisories.result }} AGENT_SDK: ${{ needs.agent-sdk-check.result }} NPM_PACKAGE_LAYOUT: ${{ needs.npm-package-layout.result }} + ISSUE_TRIAGE_CHECKS: ${{ needs.issue-triage-checks.result }} DUPLICATE_CODE: ${{ needs.duplicate-code.result }} CODEQL: ${{ needs.codeql.result }} run: | @@ -398,6 +416,7 @@ jobs: require_success "terminal-tests" "$TERMINAL_TESTS" require_success "lockfile" "$LOCKFILE" require_success "cargo-deny-policy" "$CARGO_DENY_POLICY" + require_success "issue-triage-checks" "$ISSUE_TRIAGE_CHECKS" require_success "duplicate-code" "$DUPLICATE_CODE" require_success "codeql" "$CODEQL" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b550b62..e8148c2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,11 +76,18 @@ update that also carries a security fix, which takes `type: dependencies` and `good first issue` and `help wanted` stay unprefixed because GitHub uses them to populate the repository contribution page. -Issue templates apply `type:` and `status: triage` automatically, Dependabot -applies `type: dependencies` plus the matching `area:` label from -`.github/dependabot.yml`, and the weekly `Dependency Monitor` workflow applies -`type: security` to the advisory issue it opens. Renaming any of those labels -requires updating that config in the same change. +`status: triage` means an issue awaits initial maintainer assessment. Remove it after assessing the report and deciding its scope and classification; assignment and other labels do not establish that review is complete. + +The `Issue Triage` workflow adds `status: triage` when an issue opens or reopens, including issues created through the CLI or API, and removes it on closure. Repository workflows using `GITHUB_TOKEN` apply that lifecycle themselves because those events do not trigger another workflow. Refreshing an existing issue does not restore triage. + +Collaborators with write access can use these standalone issue comments: + +- `/triaged`: Remove `status: triage` after assessment. This does not assign the issue or approve an implementation. +- `/duplicate #123`: Close the current issue as a duplicate of issue #123 in this repository, record GitHub's duplicate relationship, and remove triage. The target must be an existing issue, not a PR or the current issue; it may already be closed. + +These commands ignore quoted text, code blocks, bot comments and PR threads. They make no changes for users without write access. Duplicate handling requires an explicit maintainer decision; the bot does not infer similarity or post acknowledgement comments. + +Issue templates apply `type:` automatically, Dependabot applies `type: dependencies` plus the matching `area:` label from `.github/dependabot.yml`, and the weekly `Dependency Monitor` workflow applies `type: security` to the advisory issue it opens. `status: triage` is named in `.github/scripts/issue-triage.cjs` and `.github/workflows/dependency-monitor.yml`. Renaming any of those labels requires updating that config in the same change. ## Development Setup From 5ad2460d490aa0d0246eed01d13b472463d64725 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:44:35 +0200 Subject: [PATCH 04/10] feat(tui): add fullscreen Git diff viewer (#476) * feat(tui): add fullscreen Git diff viewer - add /diff with HEAD, staged, and unstaged comparisons - show an expanded file tree, colored statistics, diffs, and full file contents - support keyboard navigation, AltGr hunk shortcuts, and responsive footer hints - preserve selection and pane focus when refreshing changed files - document controls and add unit and terminal workflow coverage * chore(build): set Cargo compilation limit to eight jobs - allow up to eight concurrent Cargo compilation jobs - document resource tuning and per-command overrides - explain the separate control for test execution threads --- .cargo/config.toml | 4 +- CONTRIBUTING.md | 6 +- docs/src/commands.md | 23 ++ src/app/connect/mod.rs | 1 + src/app/diff_view/git.rs | 289 +++++++++++++++ src/app/diff_view/mod.rs | 326 +++++++++++++++++ src/app/diff_view/tests.rs | 719 +++++++++++++++++++++++++++++++++++++ src/app/events/mod.rs | 7 +- src/app/mod.rs | 2 + src/app/slash/catalog.rs | 20 +- src/app/slash/executors.rs | 4 + src/app/slash/tests.rs | 2 + src/app/state/app.rs | 2 + src/app/view/mod.rs | 6 +- src/app/view/tests.rs | 1 + src/ui/diff.rs | 83 ++++- src/ui/git_diff.rs | 373 +++++++++++++++++++ src/ui/mod.rs | 2 + tests/terminal_resize.rs | 77 ++++ 19 files changed, 1934 insertions(+), 13 deletions(-) create mode 100644 src/app/diff_view/git.rs create mode 100644 src/app/diff_view/mod.rs create mode 100644 src/app/diff_view/tests.rs create mode 100644 src/ui/git_diff.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index d11a8771..8e99e9df 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,3 @@ [build] -# Keep compiler and linker memory from multiplying across CPU-count build jobs. -jobs = 1 +# Balance build parallelism against peak compiler and linker memory use. +jobs = 8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8148c2e..a2872d7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,11 @@ runtime layout, and manual bridge overrides. These match the core checks in `.github/workflows/pr.yml`: -The repository's `.cargo/config.toml` limits compilation to one job to reduce peak compiler and linker memory. Test execution remains parallel by default. Run checks sequentially; override compilation concurrency explicitly with `-j N` when needed. +The repository's `.cargo/config.toml` allows up to eight concurrent compilation jobs for builds, Clippy, and test compilation. This is a starting point for balancing speed and peak compiler/linker memory use, not a measured optimum or a universal Cargo recommendation. Without a configured limit, Cargo defaults to the number of logical CPUs. See [Cargo's build job settings](https://doc.rust-lang.org/cargo/reference/config.html#buildjobs). + +Override the default per command with `-j N`, for example `cargo clippy -j 4 --all-targets --all-features -- -D warnings`, or set `CARGO_BUILD_JOBS` in your environment to override the repository setting. Lower the limit if builds exhaust memory or cause swapping; increase it if memory and CPU capacity allow, and compare timings for the same workload. Run checks sequentially to avoid competing builds. + +Compilation jobs do not control test execution threads. Tests run in parallel by default; use `cargo test --all-features -- --test-threads=1` when serial test execution is required. The terminal regression CI job sets this explicitly. See [Cargo's test options](https://doc.rust-lang.org/cargo/commands/cargo-test.html#miscellaneous-options). ```bash # Formatting diff --git a/docs/src/commands.md b/docs/src/commands.md index 4fe63296..62a27082 100644 --- a/docs/src/commands.md +++ b/docs/src/commands.md @@ -17,6 +17,7 @@ Use `/docs commands` in the app to render the live merged command list into chat | `/sandbox` | `/sandbox` | Open sandbox settings without toggling sandboxing. | | `/hooks` | `/hooks` | Inspect and edit saved hook configuration without executing hooks. | | `/copy` | `/copy` | Copy the last completed response, or choose a code block from it. | +| `/diff` | `/diff` | Review Git changes and full file contents without changing files or the index. | | `/docs` | `/docs ` | Render command, shortcut, model, mode, or subagent help into chat. | | `/agent` | `/agent ` | Change the main-thread agent for the active session. Applies on the next turn. | | `/effort` | `/effort ` | Change thinking effort for the active session. | @@ -46,6 +47,28 @@ Space inserts a literal separator without accepting the highlighted suggestion. The command menu includes the full merged inventory and scrolls as you move through it. SDK commands remain available after `/clear` changes the session identifier. In SDK sessions that advertise `/clear [name]`, `/new` and `/reset` are aliases; the optional name labels the conversation you are leaving. `/new-session` is the separate app command that starts a fresh bridge session. App command definitions take precedence when the SDK advertises the same name. +## Reviewing Git changes + +`/diff` opens a fullscreen, read-only Git viewer for the repository containing the current directory. The left pane shows changed files in an expanded tree; folders are not selectable. Each file shows its status and line counts, with additions in green and removals in red; zero counts are hidden. Totals appear above the tree. The right pane previews the selected file. Binary files, submodules, unavailable files, clean repositories, and Git read failures are shown explicitly. Repositories without a first commit are supported. + +Press `s` to choose a comparison with Up/Down and Enter; Escape cancels. HEAD compares the last commit with current files, Staged compares the last commit with the Git index, and Unstaged compares the index with current files. HEAD and Unstaged also include untracked files that Git does not ignore. This view shows repository changes, not session or turn history. + +Refresh keeps the current pane focused and retains the selected change when it remains available. If that change disappears, the next remaining file takes its place, or the previous file when the selection was at the end. If no changes remain, the viewer clears the old preview and stays open with an empty-state message. + +| Key | Action | +| --- | --- | +| Up/Down | Select files in the left pane or scroll content in the right pane. | +| Enter or Right | Focus the content pane. | +| Left or Escape in the content pane | Return focus to the file tree. | +| Tab / Shift+Tab | Select the next/previous file without changing the focused pane. | +| `v` | Toggle the diff and full file contents. Staged shows the indexed version; deleted files show their contents before deletion. | +| `[` | Jump to the previous diff hunk (changed block). | +| `]` | Jump to the next diff hunk (changed block). | +| `r` | Refresh, retaining the selected file when it remains in the comparison. | +| Escape in the file tree | Return to the conversation. | +| Ctrl+C | Close the viewer and return to the conversation. | +| Ctrl+Q | Quit the app. | + ## Copying responses `/copy` copies the last completed assistant response as its original Markdown, without thinking or tool output. Responses loaded from session history can be copied too. If the response contains code blocks, choose Full response or a code block with Up/Down, then Enter; Escape cancels. Enable Skip the /copy picker in `/config` to copy the complete Markdown response directly. Responses without code blocks copy directly. If the clipboard is unavailable, the picker stays open so you can retry. The command can copy an earlier completed response while a new response is still running; it does not submit a model request. diff --git a/src/app/connect/mod.rs b/src/app/connect/mod.rs index 20d18a52..886bedc5 100644 --- a/src/app/connect/mod.rs +++ b/src/app/connect/mod.rs @@ -162,6 +162,7 @@ pub fn create_app(cli: &Cli) -> App { session_picker: SessionPickerState::default(), chat_render: super::ChatRenderState::default(), copy_picker: None, + diff_view: None, mention: None, committed_mentions: Vec::new(), file_index: super::file_index::FileIndexState::default(), diff --git a/src/app/diff_view/git.rs b/src/app/diff_view/git.rs new file mode 100644 index 00000000..e4654ca2 --- /dev/null +++ b/src/app/diff_view/git.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Read-only Git comparisons. Git owns tracked-file patches and statistics. + +use anyhow::{Context, bail}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Comparison { + #[default] + Head, + Staged, + Unstaged, +} + +impl Comparison { + pub(crate) const ALL: [Self; 3] = [Self::Head, Self::Staged, Self::Unstaged]; + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Head => "HEAD", + Self::Staged => "Staged", + Self::Unstaged => "Unstaged", + } + } + + pub(crate) fn description(self) -> &'static str { + match self { + Self::Head => "HEAD -> working tree + untracked files", + Self::Staged => "HEAD -> index", + Self::Unstaged => "Index -> working tree + untracked files", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct FileChange { + pub path: String, + pub previous_path: Option, + pub status: char, + pub counts: Option<(usize, usize)>, + old_mode: String, + new_mode: String, + old_oid: String, + new_oid: String, +} + +#[derive(Debug)] +pub(crate) struct Snapshot { + pub root: PathBuf, + pub comparison: Comparison, + pub files: Vec, + pub(super) head: String, +} + +#[derive(Debug)] +pub(crate) struct FileContent { + pub patch: String, + pub full_text: Option, + pub notice: Option, + pub deleted: bool, +} + +fn command(cwd: &Path) -> Command { + let mut command = Command::new("git"); + command + .current_dir(cwd) + .args([ + "--no-pager", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + "diff.suppressBlankEmpty=false", + ]) + .env("GIT_LITERAL_PATHSPECS", "1") + .stdin(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + command +} + +fn checked(output: Output) -> anyhow::Result> { + if !output.status.success() { + bail!("Git read failed: {}", String::from_utf8_lossy(&output.stderr).trim()); + } + Ok(output.stdout) +} + +fn run(cwd: &Path, args: &[&str]) -> anyhow::Result> { + checked(command(cwd).args(args).output().context("Cannot run Git")?) +} + +fn text(bytes: Vec) -> anyhow::Result { + String::from_utf8(bytes).context("Git returned a path or patch that is not UTF-8") +} + +fn diff_command(root: &Path, comparison: Comparison, head: &str) -> Command { + let mut command = command(root); + command.args([ + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--find-renames", + "--output-indicator-new=+", + "--output-indicator-old=-", + "--output-indicator-context= ", + ]); + match comparison { + Comparison::Head => { + command.arg(head); + } + Comparison::Staged => { + command.arg("--cached").arg(head); + } + Comparison::Unstaged => {} + } + command +} + +pub(crate) fn snapshot(cwd: &Path, comparison: Comparison) -> anyhow::Result { + let root = text(run(cwd, &["rev-parse", "--show-toplevel"])?)?; + let root = PathBuf::from(root.strip_suffix('\n').unwrap_or(&root)); + let head = command(&root).args(["rev-parse", "--verify", "--quiet", "HEAD"]).output()?; + let head = if head.status.success() { + text(head.stdout)?.trim().to_owned() + } else if head.status.code() == Some(1) { + // hash-object without -w calculates the empty-tree ID without writing an object. + text(run(&root, &["hash-object", "-t", "tree", "--stdin"])?)?.trim().to_owned() + } else { + checked(head)?; + bail!("Cannot resolve HEAD"); + }; + let output = diff_command(&root, comparison, &head) + .args(["--raw", "--numstat", "-z", "--no-abbrev", "--"]) + .output()?; + let mut files = parse_changes(&checked(output)?)?; + if comparison != Comparison::Staged { + for path in run(&root, &["ls-files", "--others", "--exclude-standard", "-z"])? + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let path = std::str::from_utf8(path).context("Untracked path is not UTF-8")?.to_owned(); + let counts = working_file(&root.join(&path)) + .ok() + .and_then(|bytes| decode_file(bytes).ok()) + .map(|content| (content.lines().count(), 0)); + files.push(FileChange { + path, + previous_path: None, + status: '?', + counts, + old_mode: "000000".into(), + new_mode: "100644".into(), + old_oid: String::new(), + new_oid: String::new(), + }); + } + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(Snapshot { root, comparison, files, head }) +} + +fn parse_changes(bytes: &[u8]) -> anyhow::Result> { + let mut tokens = bytes.split(|byte| *byte == 0).filter(|token| !token.is_empty()); + let mut files = BTreeMap::new(); + while let Some(token) = tokens.next() { + let token = std::str::from_utf8(token).context("Git change metadata is not UTF-8")?; + if let Some(header) = token.strip_prefix(':') { + let header: Vec<_> = header.split_whitespace().collect(); + let [old_mode, new_mode, old_oid, new_oid, status] = header.as_slice() else { + bail!("Invalid Git change metadata"); + }; + let mut status = status.chars().next().context("Missing Git file status")?; + let first = next_path(&mut tokens)?; + let (path, previous_path) = if matches!(status, 'R' | 'C') { + (next_path(&mut tokens)?, Some(first)) + } else { + (first, None) + }; + if files.get(&path).is_some_and(|file: &FileChange| file.status == 'U') { + status = 'U'; + } + files.insert( + path.clone(), + FileChange { + path, + previous_path, + status, + counts: None, + old_mode: (*old_mode).into(), + new_mode: (*new_mode).into(), + old_oid: (*old_oid).into(), + new_oid: (*new_oid).into(), + }, + ); + } else { + let parts: Vec<_> = token.splitn(3, '\t').collect(); + let [added, removed, path] = parts.as_slice() else { + bail!("Invalid Git line statistics"); + }; + let path = if path.is_empty() { + let _old_path = next_path(&mut tokens)?; + next_path(&mut tokens)? + } else { + (*path).to_owned() + }; + let file = files.get_mut(&path).context("Git statistics have no matching file")?; + if *added != "-" && *removed != "-" { + file.counts = Some((added.parse()?, removed.parse()?)); + } + } + } + Ok(files.into_values().collect()) +} + +fn next_path<'a>(tokens: &mut impl Iterator) -> anyhow::Result { + Ok(std::str::from_utf8(tokens.next().context("Missing Git path")?) + .context("Git path is not UTF-8")? + .to_owned()) +} + +fn working_file(path: &Path) -> anyhow::Result> { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("Cannot read {}", path.display()))?; + if metadata.file_type().is_symlink() { + return Ok(std::fs::read_link(path)?.into_os_string().into_encoded_bytes()); + } + if !metadata.is_file() { + bail!("{} is not a regular file", path.display()); + } + std::fs::read(path).with_context(|| format!("Cannot read {}", path.display())) +} + +fn decode_file(bytes: Vec) -> anyhow::Result { + if bytes.contains(&0) { + bail!("Binary file; no text preview available."); + } + String::from_utf8(bytes).context("File is not UTF-8; no text preview available.") +} + +pub(crate) fn content( + root: &Path, + comparison: Comparison, + head: &str, + file: &FileChange, +) -> anyhow::Result { + let deleted = file.new_mode == "000000" && file.status != 'U'; + let bytes = if file.old_mode == "160000" || file.new_mode == "160000" { + None + } else if deleted { + Some(run(root, &["cat-file", "blob", &file.old_oid])?) + } else if comparison == Comparison::Staged && file.status != 'U' { + Some(run(root, &["cat-file", "blob", &file.new_oid])?) + } else { + Some(working_file(&root.join(&file.path))?) + }; + let (full_text, mut notice) = match bytes.map(decode_file) { + Some(Ok(text)) => (Some(text), None), + Some(Err(error)) => (None, Some(error.to_string())), + None => (None, Some("Submodule: the diff shows its commit change.".into())), + }; + if file.status == 'U' && full_text.is_some() { + notice = Some("Unmerged file: showing working-tree conflict markers.".into()); + } + let patch = if file.status == '?' { + full_text.as_ref().map_or_else(String::new, |new| { + similar::TextDiff::from_lines("", new) + .unified_diff() + .header("/dev/null", &file.path) + .to_string() + }) + } else { + let mut command = diff_command(root, comparison, head); + command.arg("--patch").arg("--"); + if let Some(previous) = &file.previous_path { + command.arg(previous); + } + command.arg(&file.path); + String::from_utf8_lossy(&checked(command.output()?)?).into_owned() + }; + Ok(FileContent { patch, full_text, notice, deleted }) +} diff --git a/src/app/diff_view/mod.rs b/src/app/diff_view/mod.rs new file mode 100644 index 00000000..cadae7b9 --- /dev/null +++ b/src/app/diff_view/mod.rs @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod git; +#[cfg(test)] +mod tests; + +use crate::app::{App, FullscreenView, SurfaceMode, view}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +pub(crate) use git::{Comparison, FileChange, FileContent, Snapshot}; +use ratatui::text::Line; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; + +enum ReadRequest { + Files { cwd: PathBuf, comparison: Comparison }, + Content { root: PathBuf, comparison: Comparison, head: String, file: FileChange }, +} + +enum ReadResult { + Files(Result), + Content(Result), +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Loading { + Files, + Content, +} + +pub(crate) struct RenderedContent { + pub width: u16, + pub full_file: bool, + pub lines: Vec>, + pub hunks: Vec, +} + +pub(crate) struct DiffView { + pub comparison: Comparison, + pub snapshot: Option, + pub selected: usize, + pub tree_scroll: usize, + pub content_focus: bool, + pub full_file: bool, + pub scroll: [usize; 2], + pub viewport_height: usize, + pub comparison_picker: Option, + pub content: Option, + pub rendered: Option, + pub loading: Option, + pub error: Option, + cwd: PathBuf, + preferred_change: Option<(String, char)>, + request_id: u64, + request_tx: Option>, + result_rx: Receiver<(u64, ReadResult)>, +} + +impl DiffView { + fn new(cwd: PathBuf) -> Self { + let (request_tx, request_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let worker = std::thread::Builder::new().name("git-diff-reader".into()).spawn(move || { + while let Ok(request) = request_rx.recv() { + // Superseded reads queued while Git was busy need not run. + let (id, request) = request_rx.try_iter().last().unwrap_or(request); + let result = match request { + ReadRequest::Files { cwd, comparison } => ReadResult::Files( + git::snapshot(&cwd, comparison).map_err(|error| format!("{error:#}")), + ), + ReadRequest::Content { root, comparison, head, file } => ReadResult::Content( + git::content(&root, comparison, &head, &file) + .map_err(|error| format!("{error:#}")), + ), + }; + if result_tx.send((id, result)).is_err() { + break; + } + } + }); + let (request_tx, error) = match worker { + Ok(_) => (Some(request_tx), None), + Err(error) => (None, Some(format!("Cannot start Git reader: {error}"))), + }; + Self { + comparison: Comparison::Head, + snapshot: None, + selected: 0, + tree_scroll: 0, + content_focus: false, + full_file: false, + scroll: [0; 2], + viewport_height: 1, + comparison_picker: None, + content: None, + rendered: None, + loading: None, + error, + cwd, + preferred_change: None, + request_id: 0, + request_tx, + result_rx, + } + } + + pub(crate) fn selected_file(&self) -> Option<&FileChange> { + self.snapshot.as_ref()?.files.get(self.selected) + } + + fn request(&mut self, request: ReadRequest, loading: Loading) { + self.request_id = self.request_id.wrapping_add(1); + self.loading = None; + if let Some(sender) = &self.request_tx { + if sender.send((self.request_id, request)).is_ok() { + self.loading = Some(loading); + self.error = None; + } else { + self.error = Some("Git reader stopped. Close and reopen /diff to retry.".into()); + } + } + } + + fn refresh(&mut self) { + if let Some(file) = self.selected_file() { + self.preferred_change = Some((file.path.clone(), file.status)); + } + self.snapshot = None; + self.clear_content(); + self.request( + ReadRequest::Files { cwd: self.cwd.clone(), comparison: self.comparison }, + Loading::Files, + ); + } + + fn clear_content(&mut self) { + self.content = None; + self.rendered = None; + self.scroll = [0; 2]; + } + + fn read_selection(&mut self) { + self.clear_content(); + let Some(snapshot) = &self.snapshot else { return }; + let Some(file) = snapshot.files.get(self.selected) else { return }; + let request = ReadRequest::Content { + root: snapshot.root.clone(), + comparison: snapshot.comparison, + head: snapshot.head.clone(), + file: file.clone(), + }; + self.request(request, Loading::Content); + } + + fn select(&mut self, next: usize) { + let count = self.snapshot.as_ref().map_or(0, |snapshot| snapshot.files.len()); + if count == 0 { + return; + } + let next = next.min(count.saturating_sub(1)); + if next != self.selected { + self.selected = next; + self.read_selection(); + } + } + + fn poll(&mut self) -> bool { + let mut changed = false; + loop { + let (id, result) = match self.result_rx.try_recv() { + Ok(result) => result, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + if self.loading.take().is_some() { + self.error = + Some("Git reader stopped. Close and reopen /diff to retry.".into()); + changed = true; + } + break; + } + }; + changed |= self.apply_read_result(id, result); + } + changed + } + + fn apply_read_result(&mut self, id: u64, result: ReadResult) -> bool { + if id != self.request_id { + return false; + } + self.loading = None; + match result { + ReadResult::Files(Ok(snapshot)) => { + self.selected = self + .preferred_change + .take() + .and_then(|(path, status)| { + snapshot + .files + .iter() + .position(|file| file.path == path && file.status == status) + .or_else(|| snapshot.files.iter().position(|file| file.path == path)) + }) + .unwrap_or(self.selected.min(snapshot.files.len().saturating_sub(1))); + self.snapshot = Some(snapshot); + self.read_selection(); + } + ReadResult::Content(Ok(content)) => { + self.content = Some(content); + } + ReadResult::Files(Err(error)) | ReadResult::Content(Err(error)) => { + self.error = Some(error); + } + } + true + } + + fn scroll_content(&mut self, delta: isize) { + let index = usize::from(self.full_file); + self.scroll[index] = self.scroll[index].saturating_add_signed(delta); + } + + fn jump_hunk(&mut self, forward: bool) { + if self.full_file { + return; + } + let Some(rendered) = &self.rendered else { return }; + let current = self.scroll[0]; + let next = if forward { + rendered.hunks.iter().find(|row| **row > current) + } else { + rendered.hunks.iter().rev().find(|row| **row < current) + }; + if let Some(row) = next { + self.scroll[0] = *row; + } + } +} + +pub(crate) fn open(app: &mut App) { + let mut state = DiffView::new(PathBuf::from(&app.cwd_raw)); + state.refresh(); + app.diff_view = Some(Box::new(state)); + view::set_fullscreen_view(app, FullscreenView::Diff); +} + +pub(crate) fn poll(app: &mut App) { + if is_active(app) && app.diff_view.as_deref_mut().is_some_and(DiffView::poll) { + app.request_fullscreen_repaint(); + } +} + +pub(crate) fn handle_key(app: &mut App, key: KeyEvent) { + if super::keys::is_ctrl_char_shortcut(key, 'q') { + app.request_shutdown(); + return; + } + if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && !(matches!(key.code, KeyCode::Char(_)) + && super::keys::is_printable_text_modifiers(key.modifiers)) + { + return; + } + let Some(state) = &mut app.diff_view else { return }; + if let Some(selected) = &mut state.comparison_picker { + match key.code { + KeyCode::Up => *selected = selected.saturating_sub(1), + KeyCode::Down => { + *selected = (*selected + 1).min(Comparison::ALL.len().saturating_sub(1)); + } + KeyCode::Enter => { + let comparison = Comparison::ALL[*selected]; + state.comparison_picker = None; + if comparison != state.comparison { + state.comparison = comparison; + state.refresh(); + } + } + KeyCode::Esc | KeyCode::Char('s') => state.comparison_picker = None, + _ => {} + } + return; + } + let count = state.snapshot.as_ref().map_or(0, |snapshot| snapshot.files.len()); + match key.code { + KeyCode::Esc if !state.content_focus => { + view::set_chat_surface(app); + } + KeyCode::Esc | KeyCode::Left => state.content_focus = false, + KeyCode::Enter | KeyCode::Right => state.content_focus = true, + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { + state.select(if state.selected == 0 { + count.saturating_sub(1) + } else { + state.selected - 1 + }); + } + KeyCode::Tab => { + state.select(if state.selected + 1 < count { state.selected + 1 } else { 0 }); + } + KeyCode::BackTab => state.select(if state.selected == 0 { + count.saturating_sub(1) + } else { + state.selected - 1 + }), + KeyCode::Up if state.content_focus => state.scroll_content(-1), + KeyCode::Down if state.content_focus => state.scroll_content(1), + KeyCode::Up => state.select(state.selected.saturating_sub(1)), + KeyCode::Down => state.select(state.selected + 1), + KeyCode::Char('v') => { + state.full_file = !state.full_file; + state.rendered = None; + } + KeyCode::Char('s') => { + state.comparison_picker = + Comparison::ALL.iter().position(|scope| *scope == state.comparison); + } + KeyCode::Char('r') => state.refresh(), + KeyCode::Char(']') => state.jump_hunk(true), + KeyCode::Char('[') => state.jump_hunk(false), + _ => {} + } +} + +pub(crate) fn is_active(app: &App) -> bool { + app.surface_mode == SurfaceMode::Fullscreen(FullscreenView::Diff) +} diff --git a/src/app/diff_view/tests.rs b/src/app/diff_view/tests.rs new file mode 100644 index 00000000..496e6585 --- /dev/null +++ b/src/app/diff_view/tests.rs @@ -0,0 +1,719 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::app::events::handle_terminal_event; +use crossterm::event::Event; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::fmt::Write as _; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Duration; + +struct Repo(tempfile::TempDir); + +impl Repo { + fn new() -> Self { + let repo = Self(tempfile::tempdir().expect("fixture directory")); + repo.git(&["init", "-q"]); + repo.git(&["config", "core.autocrlf", "false"]); + repo.git(&["config", "commit.gpgsign", "false"]); + repo.git(&["config", "user.name", "Diff test"]); + repo.git(&["config", "user.email", "diff@example.invalid"]); + // A viewer must not execute the user's external diff program. + repo.git(&["config", "diff.external", "must-not-execute-external-diff"]); + repo.git(&["config", "diff.outputIndicatorNew", ">"]); + repo.git(&["config", "diff.outputIndicatorOld", "<"]); + repo.git(&["config", "diff.outputIndicatorContext", "|"]); + repo.git(&["config", "diff.suppressBlankEmpty", "true"]); + repo + } + + fn path(&self) -> &Path { + self.0.path() + } + + fn git(&self, args: &[&str]) -> Vec { + let output = Command::new("git") + .current_dir(self.path()) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .expect("fixture Git"); + assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); + output.stdout + } + + fn write(&self, path: &str, bytes: impl AsRef<[u8]>) { + let path = self.path().join(path); + std::fs::create_dir_all(path.parent().expect("fixture parent")).expect("create parents"); + std::fs::write(path, bytes).expect("write fixture"); + } + + fn commit(&self) { + self.git(&["add", "."]); + self.git(&["-c", "core.hooksPath=", "commit", "-qm", "fixture baseline"]); + } +} + +fn file<'a>(snapshot: &'a Snapshot, path: &str) -> &'a FileChange { + snapshot.files.iter().find(|file| file.path == path).expect("expected changed file") +} + +fn contents(snapshot: &Snapshot, path: &str) -> FileContent { + git::content(&snapshot.root, snapshot.comparison, &snapshot.head, file(snapshot, path)) + .expect("read changed file") +} + +#[test] +fn comparisons_show_the_correct_versions_and_never_modify_the_worktree_or_index() { + let repo = Repo::new(); + repo.write("src/change.txt", "base\nshared\n"); + repo.write("deleted.txt", "deleted baseline\n"); + repo.write("old name.txt", "unchanged rename\n"); + repo.commit(); + repo.write("src/change.txt", "base\nstaged\n"); + std::fs::remove_file(repo.path().join("deleted.txt")).expect("delete fixture"); + std::fs::rename(repo.path().join("old name.txt"), repo.path().join("new name.txt")) + .expect("rename fixture"); + repo.git(&["add", "-A"]); + repo.write("src/change.txt", "base\nstaged\nworking\n"); + repo.write("new ünicode.txt", "untracked one\nuntracked two\n"); + repo.write("binary.dat", [0, 1, 2, 255]); + let index_before = std::fs::read(repo.path().join(".git/index")).expect("index fixture"); + let status_before = repo.git(&["--no-optional-locks", "status", "--porcelain=v1", "-z"]); + + let head = git::snapshot(&repo.path().join("src"), Comparison::Head) + .expect("HEAD comparison from a subdirectory"); + assert_eq!(file(&head, "src/change.txt").counts, Some((2, 1))); + assert_eq!(file(&head, "new ünicode.txt").counts, Some((2, 0))); + assert_eq!(file(&head, "deleted.txt").counts, Some((0, 1))); + assert_eq!(file(&head, "new name.txt").previous_path.as_deref(), Some("old name.txt")); + assert_eq!(file(&head, "new name.txt").status, 'R'); + assert_eq!(file(&head, "binary.dat").counts, None); + let changed = contents(&head, "src/change.txt"); + assert!(changed.patch.contains("-shared\n")); + assert!(changed.patch.contains("+staged\n+working\n")); + assert_eq!(changed.full_text.as_deref(), Some("base\nstaged\nworking\n")); + let deleted = contents(&head, "deleted.txt"); + assert!(deleted.deleted); + assert_eq!(deleted.full_text.as_deref(), Some("deleted baseline\n")); + assert!(contents(&head, "new name.txt").patch.contains("rename to new name.txt")); + assert!(contents(&head, "binary.dat").full_text.is_none()); + + let staged = git::snapshot(repo.path(), Comparison::Staged).expect("staged comparison"); + assert_eq!( + staged.files.iter().map(|file| file.path.as_str()).collect::>(), + ["deleted.txt", "new name.txt", "src/change.txt"] + ); + assert_eq!(file(&staged, "src/change.txt").counts, Some((1, 1))); + let changed = contents(&staged, "src/change.txt"); + assert_eq!(changed.full_text.as_deref(), Some("base\nstaged\n")); + assert!(!changed.patch.contains("working")); + + let unstaged = git::snapshot(repo.path(), Comparison::Unstaged).expect("unstaged comparison"); + assert_eq!( + unstaged.files.iter().map(|file| file.path.as_str()).collect::>(), + ["binary.dat", "new ünicode.txt", "src/change.txt"] + ); + assert_eq!(file(&unstaged, "src/change.txt").counts, Some((1, 0))); + let changed = contents(&unstaged, "src/change.txt"); + assert!(changed.patch.contains(" staged\n+working\n")); + assert!(!changed.patch.contains("-shared")); + assert_eq!( + std::fs::read(repo.path().join(".git/index")).expect("index after reads"), + index_before + ); + assert_eq!(repo.git(&["--no-optional-locks", "status", "--porcelain=v1", "-z"]), status_before); + assert_eq!( + std::fs::read(repo.path().join("src/change.txt")).expect("worktree after reads"), + b"base\nstaged\nworking\n" + ); + assert_eq!( + std::fs::read(repo.path().join("binary.dat")).expect("binary after reads"), + [0, 1, 2, 255] + ); +} + +#[test] +fn unborn_clean_and_non_git_directories_have_distinct_results() { + let repo = Repo::new(); + repo.write("first.txt", "staged first\n"); + repo.git(&["add", "."]); + repo.write("first.txt", "working first\n"); + let head = git::snapshot(repo.path(), Comparison::Head).expect("unborn HEAD"); + assert_eq!(file(&head, "first.txt").counts, Some((1, 0))); + assert_eq!(contents(&head, "first.txt").full_text.as_deref(), Some("working first\n")); + let staged = git::snapshot(repo.path(), Comparison::Staged).expect("unborn staged"); + assert_eq!(contents(&staged, "first.txt").full_text.as_deref(), Some("staged first\n")); + repo.commit(); + for comparison in Comparison::ALL { + assert!(git::snapshot(repo.path(), comparison).expect("clean repo").files.is_empty()); + } + let outside = tempfile::tempdir().expect("non-Git directory"); + assert!(git::snapshot(outside.path(), Comparison::Head).is_err()); +} + +fn key(app: &mut App, code: KeyCode) { + handle_terminal_event(app, Event::Key(KeyEvent::new(code, KeyModifiers::NONE))); +} + +fn settle(app: &mut App) { + let state = app.diff_view.as_mut().expect("diff view"); + // Wait for correlated worker responses; no timing sleeps or rendered success text. + while state.loading.is_some() { + let (id, result) = + state.result_rx.recv_timeout(Duration::from_secs(20)).expect("Git response"); + state.apply_read_result(id, result); + } +} + +fn draw(app: &mut App, width: u16, height: u16) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal"); + terminal.draw(|frame| crate::ui::render_fullscreen_surface(frame, app)).expect("draw diff"); + terminal.backend().buffer().clone() +} + +fn region(buffer: &Buffer, area: Rect) -> String { + (area.y..area.bottom()) + .map(|y| (area.x..area.right()).map(|x| buffer[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") +} + +fn open_repo(repo: &Repo) -> App { + let mut app = App::test_default(); + app.cwd_raw = repo.path().to_string_lossy().into_owned(); + app.status = crate::app::AppStatus::Running; + let (connection, mut commands) = crate::agent::client::AgentConnection::test_channel(); + app.session_runtime.conn = Some(std::rc::Rc::new(connection)); + app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("active-session")); + app.input.set_text("/diff"); + crate::app::input_submit::submit_input(&mut app); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::Diff)); + assert!(app.input.text().is_empty()); + assert!(commands.try_recv().is_err(), "Git review must not send SDK commands"); + settle(&mut app); + app +} + +#[test] +fn keyboard_flow_selects_only_files_and_scrolls_the_focused_pane() { + let repo = Repo::new(); + repo.write("src/nested/a.txt", "unchanged context\nfirst old\n"); + repo.write("src/z.txt", "second old\n"); + repo.commit(); + let long_file = format!( + "unchanged context\n{}", + (0..80).fold(String::new(), |mut text, n| { + writeln!(text, "first new {n}").expect("fixture text"); + text + }) + ); + repo.write("src/nested/a.txt", &long_file); + repo.write("src/z.txt", "second new\n"); + let mut app = open_repo(&repo); + let buffer = draw(&mut app, 120, 20); + let left = region(&buffer, Rect::new(1, 3, 38, 13)); + assert!(left.contains("src/") && left.contains("nested/")); + let selected_y = (3..16) + .find(|y| region(&buffer, Rect::new(1, *y, 38, 1)).contains("a.txt")) + .expect("file row"); + assert_eq!(buffer[(5, selected_y)].bg, crate::ui::theme::RUST_ORANGE); + assert!(region(&buffer, Rect::new(41, 3, 78, 13)).contains("first new 0")); + assert!(!region(&buffer, Rect::new(41, 3, 78, 13)).contains("second new")); + + key(&mut app, KeyCode::Down); + settle(&mut app); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "src/z.txt"); + let buffer = draw(&mut app, 120, 20); + assert!(region(&buffer, Rect::new(41, 3, 78, 13)).contains("second new")); + key(&mut app, KeyCode::Up); + settle(&mut app); + key(&mut app, KeyCode::Right); + draw(&mut app, 120, 20); + for _ in 0..14 { + key(&mut app, KeyCode::Down); + } + let scrolled = draw(&mut app, 120, 20); + assert!(!region(&scrolled, Rect::new(41, 3, 78, 13)).contains("first new 0")); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "src/nested/a.txt"); + key(&mut app, KeyCode::Tab); + settle(&mut app); + assert!(app.diff_view.as_ref().unwrap().content_focus); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "src/z.txt"); + key(&mut app, KeyCode::BackTab); + settle(&mut app); + key(&mut app, KeyCode::Char('v')); + let full = draw(&mut app, 120, 20); + assert!(region(&full, Rect::new(41, 3, 78, 13)).contains("unchanged context")); + assert!(!region(&full, Rect::new(41, 3, 78, 13)).contains("@@")); + for _ in 0..90 { + key(&mut app, KeyCode::Down); + } + let end = draw(&mut app, 120, 20); + assert!(region(&end, Rect::new(41, 3, 78, 13)).contains("first new 79")); + key(&mut app, KeyCode::Left); + key(&mut app, KeyCode::Down); + settle(&mut app); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "src/z.txt"); + key(&mut app, KeyCode::Esc); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert!(app.diff_view.is_none()); + assert_eq!(app.status, crate::app::AppStatus::Running); +} + +#[test] +fn comparison_selection_and_refresh_keep_the_selected_file_and_show_the_selected_version() { + let repo = Repo::new(); + repo.write("b.txt", "base\n"); + repo.commit(); + repo.write("b.txt", "staged\n"); + repo.git(&["add", "."]); + repo.write("b.txt", "working\n"); + let mut app = open_repo(&repo); + key(&mut app, KeyCode::Char('s')); + key(&mut app, KeyCode::Down); + key(&mut app, KeyCode::Enter); + settle(&mut app); + key(&mut app, KeyCode::Char('v')); + let buffer = draw(&mut app, 100, 18); + let content = region(&buffer, Rect::new(34, 3, 65, 11)); + assert!(content.contains("staged")); + assert!(!content.contains("working")); + key(&mut app, KeyCode::Char('s')); + key(&mut app, KeyCode::Down); + key(&mut app, KeyCode::Esc); + assert_eq!(app.diff_view.as_ref().unwrap().comparison, Comparison::Staged); + repo.write("a.txt", "new staged file\n"); + repo.git(&["add", "a.txt"]); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "b.txt"); + assert_eq!(app.diff_view.as_ref().unwrap().selected, 1); + // Remove the selected staged change. Refresh must select an existing file, not retain a stale row. + repo.write("c.txt", "another staged file\n"); + repo.git(&["add", "c.txt"]); + repo.git(&["reset", "-q", "HEAD", "--", "b.txt"]); + key(&mut app, KeyCode::Char('r')); + key(&mut app, KeyCode::Down); // No tree rows are selectable while the read is pending. + settle(&mut app); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "c.txt"); + repo.git(&["reset", "-q", "HEAD", "--", "c.txt"]); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "a.txt"); +} + +#[test] +fn a_superseded_file_read_cannot_replace_the_current_selection() { + let repo = Repo::new(); + repo.write("a.txt", "first\n"); + repo.write("b.txt", "second\n"); + let mut app = open_repo(&repo); + let state = app.diff_view.as_ref().unwrap(); + let stale_id = state.request_id; + key(&mut app, KeyCode::Down); + let state = app.diff_view.as_mut().unwrap(); + let accepted = state.apply_read_result( + stale_id, + ReadResult::Content(Ok(FileContent { + patch: "obsolete first file".into(), + full_text: Some("obsolete first file".into()), + notice: None, + deleted: false, + })), + ); + assert!(!accepted); + settle(&mut app); + let buffer = draw(&mut app, 100, 18); + let content = region(&buffer, Rect::new(34, 3, 65, 11)); + assert!(content.contains("second")); + assert!(!content.contains("obsolete first file")); +} + +#[test] +fn empty_binary_and_failed_file_reads_are_visible_and_refresh_can_recover() { + let repo = Repo::new(); + let mut app = open_repo(&repo); + let buffer = draw(&mut app, 100, 18); + assert!(region(&buffer, Rect::new(34, 3, 65, 11)).contains("No changes")); + repo.write("a.dat", [0, 1, 255]); + repo.write("b.txt", "readable\n"); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + key(&mut app, KeyCode::Char('v')); + let buffer = draw(&mut app, 100, 18); + assert!(region(&buffer, Rect::new(34, 3, 65, 11)).contains("Binary file")); + std::fs::remove_file(repo.path().join("b.txt")).expect("remove before selection"); + key(&mut app, KeyCode::Down); + settle(&mut app); + let buffer = draw(&mut app, 100, 18); + let content = region(&buffer, Rect::new(34, 3, 65, 11)); + assert!(content.contains("Cannot read") && content.contains("b.txt")); + repo.write("b.txt", "recovered\n"); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + let buffer = draw(&mut app, 100, 18); + assert!(region(&buffer, Rect::new(34, 3, 65, 11)).contains("recovered")); + // Ctrl+C must close even while content or the comparison picker owns focus. + key(&mut app, KeyCode::Right); + key(&mut app, KeyCode::Char('s')); + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + ); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert!(app.diff_view.is_none()); + assert!(!app.shutdown_requested()); +} + +#[test] +fn a_short_tree_keeps_the_selected_file_and_its_statistics_visible_at_narrow_widths() { + let repo = Repo::new(); + for index in 0..15 { + repo.write(&format!("deep/nested/folder/file{index:02}.txt"), "new line\n"); + } + let mut app = open_repo(&repo); + for _ in 0..14 { + key(&mut app, KeyCode::Down); + } + settle(&mut app); + for (width, height) in [(48, 12), (60, 12), (120, 20)] { + let buffer = draw(&mut app, width, height); + let tree_width = (width / 3).clamp(18, 48); + let tree = region(&buffer, Rect::new(1, 3, tree_width - 2, height - 7)); + // The selected row must be onscreen and highlighted; folder rows may scroll away. + let selected = (3..height - 2) + .find(|y| buffer[(1, *y)].bg == crate::ui::theme::RUST_ORANGE) + .expect("visible selected row"); + let selected_text = region(&buffer, Rect::new(1, selected, tree_width - 2, 1)); + assert!(selected_text.contains("+1")); + assert!(!selected_text.contains("-0")); + assert!(tree.contains("file")); + assert!( + region(&buffer, Rect::new(tree_width + 1, 3, width - tree_width - 2, height - 7)) + .contains("new line") + ); + assert!(region(&buffer, Rect::new(0, height - 4, width, 4)).contains("v diff/full")); + } +} + +#[test] +fn sidebar_counts_show_only_nonzero_changes_in_their_colors_even_when_selected() { + use ratatui::style::Color; + + let repo = Repo::new(); + repo.write("b-removed.txt", "one\ntwo\nthree\n"); + repo.write("c-mixed.txt", "before\n"); + repo.write("old.txt", "same contents\n"); + repo.commit(); + repo.write("a-added.txt", "one\ntwo\n"); + std::fs::remove_file(repo.path().join("b-removed.txt")).expect("deleted fixture"); + repo.write("c-mixed.txt", "after\n"); + repo.git(&["mv", "old.txt", "d-renamed.txt"]); + repo.write("e-binary.dat", [0, 1, 255]); + repo.write("f-empty.txt", ""); + let mut app = open_repo(&repo); + + // Check every file's own row with additions selected, then removals selected. + for selected_name in ["a-added.txt", "b-removed.txt"] { + let buffer = draw(&mut app, 120, 24); + for (name, added, removed) in [ + ("a-added.txt", "+2", ""), + ("b-removed.txt", "", "-3"), + ("c-mixed.txt", "+1", "-1"), + ("d-renamed.txt", "", ""), + ("f-empty.txt", "", ""), + ] { + let y = (3..20) + .find(|y| region(&buffer, Rect::new(1, *y, 38, 1)).contains(name)) + .expect("file visible in sidebar"); + let row = region(&buffer, Rect::new(1, y, 38, 1)); + assert!(!row.contains("+0") && !row.contains("-0"), "{name}: {row}"); + for (count, color) in [(added, Color::Green), (removed, Color::Red)] { + if count.is_empty() { + continue; + } + let x = u16::try_from(row.find(count).expect("nonzero count beside its file")) + .expect("sidebar column") + + 1; + for col in x..x + u16::try_from(count.len()).expect("count width") { + assert_eq!(buffer[(col, y)].fg, color, "{name}: {count}"); + assert_ne!(buffer[(col, y)].bg, crate::ui::theme::RUST_ORANGE); + } + } + if added.is_empty() && removed.is_empty() { + assert!(row.trim_end().ends_with(name), "zero-change file has no counts"); + } + if name == selected_name { + assert_eq!(buffer[(1, y)].bg, crate::ui::theme::RUST_ORANGE); + } + } + let binary_y = (3..20) + .find(|y| region(&buffer, Rect::new(1, *y, 38, 1)).contains("e-binary.dat")) + .expect("binary row"); + assert!(region(&buffer, Rect::new(1, binary_y, 38, 1)).contains("binary/-")); + key(&mut app, KeyCode::Down); + settle(&mut app); + } +} + +#[test] +fn footer_uses_one_row_when_it_fits_and_wraps_without_losing_controls() { + let repo = Repo::new(); + repo.write("file.txt", "changed line\n"); + let mut app = open_repo(&repo); + let wide = draw(&mut app, 200, 14); + let footer = region(&wide, Rect::new(0, 13, 200, 1)); + for control in ["Up/Down", "[ prev hunk", "] next hunk", "Ctrl+Q quit"] { + assert!(footer.contains(control), "all hints fit on one row: {control}"); + } + assert_eq!(wide[(0, 12)].symbol(), "└", "the file pane uses the row above the footer"); + + let narrow = draw(&mut app, 48, 14); + let pane_bottom = + (3..13).find(|y| narrow[(0, *y)].symbol() == "└").expect("bottom of file pane"); + assert!(pane_bottom < 12, "narrow terminals need multiple hint rows"); + let footer = (pane_bottom + 1..14) + .map(|y| region(&narrow, Rect::new(0, y, 48, 1)).trim().to_owned()) + .collect::>() + .join(" "); + for control in ["Up/Down", "[ prev hunk", "] next hunk", "Ctrl+Q quit"] { + assert!(footer.contains(control), "wrapped footer retains {control}"); + } +} + +#[test] +fn hunk_navigation_accepts_layout_modifiers_and_preserves_file_line_numbers_and_selection() { + let repo = Repo::new(); + let original = (1..=90).fold(String::new(), |mut text, line| { + if line == 67 { + writeln!(text).expect("blank context line"); + } else { + writeln!(text, "original line {line}").expect("fixture text"); + } + text + }); + repo.write("hunks.txt", &original); + repo.commit(); + repo.write( + "hunks.txt", + original + .replace("original line 8\n", "first changed line\n") + .replace("original line 70\n", "second changed line\n"), + ); + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::SHIFT, + KeyModifiers::CONTROL | KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + ] { + let mut app = open_repo(&repo); + draw(&mut app, 120, 16); + key(&mut app, KeyCode::Right); + // Ctrl and Alt alone remain shortcuts, not text produced by AltGr. + for shortcut in [KeyModifiers::CONTROL, KeyModifiers::ALT] { + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char(']'), shortcut)), + ); + let unchanged = draw(&mut app, 120, 16); + let content = region(&unchanged, Rect::new(41, 3, 78, 9)); + assert!(content.contains("first changed line")); + assert!(!content.contains("second changed line")); + } + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Char(']'), modifiers))); + let second = draw(&mut app, 120, 16); + let content = region(&second, Rect::new(41, 3, 78, 9)); + let changed_row = content + .lines() + .find(|line| line.contains("second changed line")) + .expect("second hunk visible with bracket's layout modifiers"); + assert!(changed_row.trim_start().starts_with("70")); + assert!(!content.contains("first changed line")); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Char('['), modifiers))); + let first = draw(&mut app, 120, 16); + assert!(region(&first, Rect::new(41, 3, 78, 9)).contains("first changed line")); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "hunks.txt"); + } +} + +#[test] +fn hunk_navigation_reaches_later_hunks_even_when_the_entire_diff_fits_on_screen() { + let repo = Repo::new(); + let original = (1..=120).fold(String::new(), |mut text, line| { + writeln!(text, "original {line}").expect("fixture text"); + text + }); + repo.write("hunks.txt", &original); + repo.commit(); + repo.write( + "hunks.txt", + original + .replace("original 10\n", "first edit\n") + .replace("original 50\n", "middle edit\n") + .replace("original 100\n", "last edit\n"), + ); + let mut app = open_repo(&repo); + let initial = draw(&mut app, 120, 45); + let content = region(&initial, Rect::new(41, 3, 78, 38)); + for edit in ["first edit", "middle edit", "last edit"] { + assert!(content.contains(edit), "all hunks initially fit: {edit}"); + } + key(&mut app, KeyCode::Right); + for (code, visible, hidden) in [ + (']', "middle edit", "first edit"), + (']', "last edit", "middle edit"), + ('[', "middle edit", "first edit"), + ('[', "first edit", "last edit"), + ] { + key(&mut app, KeyCode::Char(code)); + let jumped = draw(&mut app, 120, 45); + assert!(region(&jumped, Rect::new(41, 3, 78, 1)).contains("@@")); + let top_hunk = region(&jumped, Rect::new(41, 3, 78, 8)); + assert!(top_hunk.contains(visible)); + assert!(!top_hunk.contains(hidden)); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().path, "hunks.txt"); + } +} + +#[test] +fn ctrl_q_quits_from_the_diff_but_altgr_q_does_not() { + let repo = Repo::new(); + let mut app = open_repo(&repo); + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL | KeyModifiers::ALT)), + ); + assert!(!app.shutdown_requested()); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::Diff)); + handle_terminal_event( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)), + ); + assert!(app.shutdown_requested()); +} + +#[test] +fn an_unmerged_index_is_not_presented_as_a_deleted_staged_file() { + use std::io::Write; + let repo = Repo::new(); + repo.write("conflict.txt", "base\n"); + repo.commit(); + let oid = String::from_utf8(repo.git(&["rev-parse", "HEAD:conflict.txt"])).expect("blob ID"); + let oid = oid.trim(); + let entries = format!( + "0 {}\tconflict.txt\n100644 {oid} 1\tconflict.txt\n100644 {oid} 2\tconflict.txt\n100644 {oid} 3\tconflict.txt\n", + "0".repeat(oid.len()) + ); + let mut child = Command::new("git") + .current_dir(repo.path()) + .args(["update-index", "--index-info"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("seed unmerged index"); + child + .stdin + .take() + .expect("index input") + .write_all(entries.as_bytes()) + .expect("write index stages"); + assert!(child.wait_with_output().expect("index response").status.success()); + repo.write("conflict.txt", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> other\n"); + for comparison in [Comparison::Staged, Comparison::Unstaged] { + let snapshot = git::snapshot(repo.path(), comparison).expect("unmerged comparison"); + assert_eq!(file(&snapshot, "conflict.txt").status, 'U'); + let content = contents(&snapshot, "conflict.txt"); + assert!(!content.deleted); + assert!(content.full_text.as_deref().unwrap().contains("<<<<<<< HEAD")); + assert!(content.notice.as_deref().unwrap().contains("Unmerged")); + } +} + +#[test] +fn refresh_preserves_the_untracked_copy_when_a_tracked_deletion_has_the_same_path() { + let repo = Repo::new(); + repo.write("file.txt", "committed contents\n"); + repo.commit(); + repo.git(&["rm", "--cached", "file.txt"]); + repo.write("file.txt", "untracked contents\n"); + let mut app = open_repo(&repo); + key(&mut app, KeyCode::Tab); + settle(&mut app); + key(&mut app, KeyCode::Char('v')); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + let buffer = draw(&mut app, 100, 18); + assert_eq!(app.diff_view.as_ref().unwrap().selected_file().unwrap().status, '?'); + let content = region(&buffer, Rect::new(34, 3, 65, 11)); + assert!(content.contains("untracked contents")); + assert!(!content.contains("committed contents")); + let summary = region(&buffer, Rect::new(0, 0, 100, 1)); + assert_eq!(summary.split_whitespace().find_map(|word| word.parse::().ok()), Some(1)); +} + +#[test] +fn refreshing_after_all_changes_are_removed_clears_the_preview_and_keeps_tree_focus() { + let repo = Repo::new(); + repo.write("file.txt", "baseline\n"); + repo.commit(); + repo.write("file.txt", "temporary change\n"); + let mut app = open_repo(&repo); + let changed = draw(&mut app, 100, 18); + assert!(region(&changed, Rect::new(34, 3, 65, 11)).contains("temporary change")); + repo.write("file.txt", "baseline\n"); + key(&mut app, KeyCode::Char('r')); + settle(&mut app); + let clean = draw(&mut app, 100, 18); + let state = app.diff_view.as_ref().unwrap(); + assert!(!state.content_focus); + assert!(state.selected_file().is_none()); + assert!(region(&clean, Rect::new(1, 3, 31, 11)).contains("No changes")); + let content = region(&clean, Rect::new(34, 3, 65, 11)); + assert!(content.contains("No changes")); + assert!(!content.contains("temporary change")); + // Navigation on an empty tree must remain harmless and Esc must still close. + for code in [KeyCode::Up, KeyCode::Down, KeyCode::Tab, KeyCode::BackTab] { + key(&mut app, code); + } + key(&mut app, KeyCode::Esc); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert!(app.diff_view.is_none()); +} + +#[test] +fn refreshing_an_undone_open_file_selects_its_successor_and_keeps_content_focus() { + let repo = Repo::new(); + for name in ["a", "b", "c", "d"] { + repo.write(&format!("{name}.txt"), format!("baseline {name}\n")); + } + repo.commit(); + for name in ["a", "b", "c", "d"] { + repo.write(&format!("{name}.txt"), format!("changed {name}\n")); + } + let mut app = open_repo(&repo); + key(&mut app, KeyCode::Down); + settle(&mut app); + key(&mut app, KeyCode::Enter); + let before = draw(&mut app, 100, 18); + assert!(region(&before, Rect::new(34, 3, 65, 11)).contains("changed b")); + repo.write("b.txt", "baseline b\n"); + key(&mut app, KeyCode::Char('r')); + key(&mut app, KeyCode::Down); // The loading tree has no selectable rows yet. + settle(&mut app); + let after = draw(&mut app, 100, 18); + assert!(app.diff_view.as_ref().unwrap().content_focus); + let selected_row = (3..14) + .find(|y| after[(1, *y)].bg == crate::ui::theme::RUST_ORANGE) + .expect("selected file row"); + assert!(region(&after, Rect::new(1, selected_row, 31, 1)).contains("c.txt")); + let content = region(&after, Rect::new(34, 3, 65, 11)); + assert!(content.contains("changed c")); + assert!(!content.contains("changed b")); + assert_eq!(after[(33, 2)].fg, crate::ui::theme::RUST_ORANGE, "content pane retains focus"); +} diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 4c0bc6b9..6196dc40 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -255,6 +255,10 @@ fn dispatch_key_by_view(app: &mut App, key: crossterm::event::KeyEvent) -> Termi super::copy::handle_key(app, key); TerminalEventOutcome::handled(true) } + SurfaceMode::Fullscreen(FullscreenView::Diff) => { + super::diff_view::handle_key(app, key); + TerminalEventOutcome::handled(true) + } SurfaceMode::Fullscreen(FullscreenView::Config) => { super::config::handle_key(app, key); TerminalEventOutcome::handled(true) @@ -311,7 +315,8 @@ fn dispatch_paste_by_view(app: &mut App, text: &str) -> bool { FullscreenView::Trusted | FullscreenView::SessionPicker | FullscreenView::Update - | FullscreenView::Copy, + | FullscreenView::Copy + | FullscreenView::Diff, ) => false, } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 4463c85c..585410d3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod config; mod connect; pub(crate) mod copy; mod dialog; +pub(crate) mod diff_view; mod events; pub(crate) mod file_index; mod focus; @@ -241,6 +242,7 @@ async fn run_tui_loop( if !app.shutdown_requested() { app.tick_git_context(now); + diff_view::poll(app); session_runtime::tick_context_usage_refresh(app, now); questions::tick_idle_timeout(app, now); app.tick_activity(now); diff --git a/src/app/slash/catalog.rs b/src/app/slash/catalog.rs index 5cdfc327..e7bb64a6 100644 --- a/src/app/slash/catalog.rs +++ b/src/app/slash/catalog.rs @@ -15,6 +15,7 @@ pub(crate) enum AppSlashCommand { Hooks, Copy, + Diff, Docs, Agent, Effort, @@ -155,6 +156,14 @@ pub(crate) const APP_SLASH_COMMANDS: &[AppSlashCommandSpec] = &[ long_description: "Copy the last finished assistant response or choose a code block. Configure Skip the /copy picker to copy the full response directly.", args: NO_ARGS, }, + AppSlashCommandSpec { + command: AppSlashCommand::Diff, + name: "/diff", + usage: "Usage: /diff", + short_description: "Review Git changes", + long_description: "Open the read-only Git diff and file viewer with HEAD, staged, and unstaged comparisons.", + args: NO_ARGS, + }, AppSlashCommandSpec { command: AppSlashCommand::Docs, name: "/docs", @@ -329,6 +338,7 @@ impl AppSlashCommand { Self::Hooks => "/hooks", Self::Copy => "/copy", + Self::Diff => "/diff", Self::Docs => "/docs", Self::Agent => "/agent", Self::Effort => "/effort", @@ -356,13 +366,8 @@ impl AppSlashCommand { pub(crate) fn submission_class(self, args: &[&str]) -> SubmissionClass { match self { - Self::Btw => { - if args.is_empty() { - SubmissionClass::Invalid - } else { - SubmissionClass::Informational - } - } + Self::Btw if args.is_empty() => SubmissionClass::Invalid, + Self::Btw => SubmissionClass::Informational, Self::Cancel => { if args.is_empty() { SubmissionClass::TurnControl @@ -371,6 +376,7 @@ impl AppSlashCommand { } } Self::Config + | Self::Diff | Self::Memory | Self::Permissions | Self::Sandbox diff --git a/src/app/slash/executors.rs b/src/app/slash/executors.rs index ba8a8143..34748114 100644 --- a/src/app/slash/executors.rs +++ b/src/app/slash/executors.rs @@ -53,6 +53,10 @@ pub(crate) fn try_handle_submission(app: &mut App, submission: &ResolvedSubmissi crate::app::copy::open(app); true } + AppSlashCommand::Diff => { + crate::app::diff_view::open(app); + true + } AppSlashCommand::Docs => handle_docs_submit(app, &args), AppSlashCommand::Agent => handle_agent_submit(app, &args), AppSlashCommand::Effort => handle_effort_submit(app, &args), diff --git a/src/app/slash/tests.rs b/src/app/slash/tests.rs index e34ceea2..b076f459 100644 --- a/src/app/slash/tests.rs +++ b/src/app/slash/tests.rs @@ -134,6 +134,7 @@ fn resolved_submission_is_the_active_turn_policy_authority() { assert_eq!(classify("/cancel"), SubmissionClass::TurnControl); for input in [ "/config", + "/diff", "/memory", "/permissions", "/sandbox", @@ -166,6 +167,7 @@ fn resolved_submission_is_the_active_turn_policy_authority() { } for input in [ "/cancel extra", + "/diff extra", "/docs nope", "/plugins extra", "/resume one two", diff --git a/src/app/state/app.rs b/src/app/state/app.rs index 1c1b74a5..fb17fe27 100644 --- a/src/app/state/app.rs +++ b/src/app/state/app.rs @@ -12,6 +12,7 @@ pub(crate) struct PendingSessionResume { pub struct App { pub surface_mode: SurfaceMode, pub(crate) copy_picker: Option, + pub(crate) diff_view: Option>, pub(crate) terminal_lifecycle: TerminalLifecycleState, pub(crate) terminal_child_cancel: Option>, pub(crate) surface_dirty: SurfaceDirtyState, @@ -259,6 +260,7 @@ impl App { session_picker: SessionPickerState::default(), chat_render: ChatRenderState::default(), copy_picker: None, + diff_view: None, mention: None, committed_mentions: Vec::new(), file_index: file_index::FileIndexState::default(), diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b815e1fb..29cfecf4 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -12,6 +12,7 @@ pub enum FullscreenView { SessionPicker, Update, Copy, + Diff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -66,7 +67,7 @@ pub(crate) fn dismiss_fullscreen_on_interrupt(app: &mut App) { FullscreenView::Copy => { app.copy_picker = None; } - FullscreenView::Config | FullscreenView::Trusted => {} + FullscreenView::Diff | FullscreenView::Config | FullscreenView::Trusted => {} FullscreenView::SessionPicker => { app.startup.resolve_session_picker(); app.session_picker.turn_session_id = None; @@ -80,6 +81,9 @@ pub(crate) fn dismiss_fullscreen_on_interrupt(app: &mut App) { } fn clear_transient_view_state(app: &mut App) { + if app.surface_mode == SurfaceMode::Fullscreen(FullscreenView::Diff) { + app.diff_view = None; + } app.paste.clear_all_sessions(); app.pending_submit = None; app.mention = None; diff --git a/src/app/view/tests.rs b/src/app/view/tests.rs index e0f347a2..ad1bc300 100644 --- a/src/app/view/tests.rs +++ b/src/app/view/tests.rs @@ -140,6 +140,7 @@ fn surface_mode_reports_fullscreen_view_only_for_fullscreen_modes() { fn interrupt_dismisses_every_fullscreen_surface_to_chat() { for fullscreen in [ FullscreenView::Config, + FullscreenView::Diff, FullscreenView::Trusted, FullscreenView::SessionPicker, FullscreenView::Update, diff --git a/src/ui/diff.rs b/src/ui/diff.rs index 5c04729c..d5d54a20 100644 --- a/src/ui/diff.rs +++ b/src/ui/diff.rs @@ -140,7 +140,88 @@ fn render_raw_diff_line(line: &str) -> Line<'static> { Line::from(Span::styled(line.to_owned(), style)) } -fn render_diff_count_header(added: usize, removed: usize) -> Option> { +/// Wrap Git's own patch while retaining file line numbers and hunk locations. +pub(crate) fn render_repository_patch(text: &str, width: u16) -> (Vec>, Vec) { + let mut lines = Vec::new(); + let mut hunks = Vec::new(); + let mut position = None; + let number_width = text + .lines() + .filter_map(hunk_position) + .map(|(old, new)| old.max(new)) + .max() + .unwrap_or(1) + .saturating_add(text.lines().count()) + .to_string() + .len(); + for raw in text.lines() { + if raw.starts_with("diff ") { + position = None; + } + // The viewer's pane title already identifies the file. Keep its small + // viewport for changes rather than Git's repeated path/index headers. + if position.is_none() + && (raw.starts_with("diff --git ") + || raw.starts_with("index ") + || raw.starts_with("--- ") + || raw.starts_with("+++ ")) + { + continue; + } + if raw.starts_with("@@") { + hunks.push(lines.len()); + position = hunk_position(raw); + } else if let Some((old, new)) = &mut position { + let row = match raw.chars().next() { + Some('+') => { + let row = Some((*new, "+")); + *new += 1; + row + } + Some('-') => { + let row = Some((*old, "-")); + *old += 1; + row + } + Some(' ') => { + let row = Some((*new, " ")); + *old += 1; + *new += 1; + row + } + _ => None, + }; + if let Some((number, marker)) = row { + let styled = render_raw_diff_line(raw); + let style = styled.spans[0].style; + lines.extend(render_wrapped_diff_row( + Some(number), + number_width, + marker, + &raw[1..], + style, + usize::from(width).saturating_sub(number_width + 5).max(1), + )); + continue; + } + } + let styled = render_raw_diff_line(raw); + lines.extend(wrap_styled_chunks( + &[StyledChunk { text: raw.to_owned(), style: styled.spans[0].style }], + usize::from(width).max(1), + )); + } + (lines, hunks) +} + +fn hunk_position(line: &str) -> Option<(usize, usize)> { + let mut parts = line.strip_prefix("@@ ")?.split_whitespace(); + let old = parts.next()?.strip_prefix('-')?.split(',').next()?.parse().ok()?; + let new = parts.next()?.strip_prefix('+')?.split(',').next()?.parse().ok()?; + Some((old, new)) +} + +pub(crate) fn render_diff_count_header(added: usize, removed: usize) -> Option> { if added == 0 && removed == 0 { return None; } diff --git a/src/ui/git_diff.rs b/src/ui/git_diff.rs new file mode 100644 index 00000000..32afc935 --- /dev/null +++ b/src/ui/git_diff.rs @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::app::App; +use crate::app::diff_view::{Comparison, DiffView, Loading, RenderedContent}; +use crate::ui::wrap::{StyledChunk, display_width, wrap_styled_chunks}; +use crate::ui::{diff, highlight, theme}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use std::collections::BTreeSet; +use std::path::Path; + +pub(crate) fn render(frame: &mut Frame, app: &mut App) { + let Some(state) = &mut app.diff_view else { return }; + let area = frame.area(); + if area.width < 48 || area.height < 10 { + frame.render_widget(Paragraph::new("Resize to at least 48 columns and 10 rows to view Git changes.\nEsc back/close | Ctrl+Q quit").wrap(Wrap { trim: false }), area); + return; + } + let help = "Up/Down navigate | Enter/Right read | Left/Esc back | Tab/Shift+Tab files | v diff/full file | s comparison | r refresh | [ prev hunk | ] next hunk | Ctrl+Q quit"; + let help_height = + u16::try_from(Paragraph::new(help).wrap(Wrap { trim: false }).line_count(area.width)) + .unwrap_or(5) + .min(area.height.saturating_sub(5)); + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(3), Constraint::Length(help_height)]) + .split(area); + render_summary(frame, sections[0], state); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length((area.width / 3).clamp(18, 48)), Constraint::Min(1)]) + .split(sections[1]); + render_tree(frame, columns[0], state); + render_content(frame, columns[1], state); + frame.render_widget( + Paragraph::new(help).style(Style::default().fg(theme::DIM)).wrap(Wrap { trim: false }), + sections[2], + ); + if let Some(selected) = state.comparison_picker { + render_comparison_picker(frame, area, selected); + } +} + +fn render_summary(frame: &mut Frame, area: Rect, state: &DiffView) { + let (count, added, removed, binary) = + state.snapshot.as_ref().map_or((0, 0, 0, 0), |snapshot| { + snapshot.files.iter().fold( + ( + snapshot.files.iter().map(|file| &file.path).collect::>().len(), + 0, + 0, + 0, + ), + |(count, added, removed, binary), file| { + file.counts.map_or((count, added, removed, binary + 1), |(a, r)| { + (count, added + a, removed + r, binary) + }) + }, + ) + }); + let totals = if state.loading == Some(Loading::Files) { + "Loading Git changes...".into() + } else { + format!( + "{count} {} +{added} -{removed}{}", + if count == 1 { "file" } else { "files" }, + if binary == 0 { String::new() } else { format!(" {binary} binary/unavailable") } + ) + }; + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + format!("Git diff | {} | ", state.comparison.label()), + Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), + ), + Span::raw(totals), + ]), + Line::from(Span::styled( + state.comparison.description(), + Style::default().fg(theme::DIM), + )), + ]), + area, + ); +} + +struct TreeRow { + label: String, + depth: usize, + file_index: Option, + counts: Line<'static>, +} + +fn tree_rows(state: &DiffView) -> Vec { + let mut folders = BTreeSet::new(); + let mut rows = Vec::new(); + if let Some(snapshot) = &state.snapshot { + for (index, file) in snapshot.files.iter().enumerate() { + let parts: Vec<_> = file.path.split('/').collect(); + for depth in 0..parts.len().saturating_sub(1) { + if folders.insert(parts[..=depth].join("/")) { + rows.push(TreeRow { + label: format!("{}/", visible(parts[depth])), + depth, + file_index: None, + counts: Line::default(), + }); + } + } + rows.push(TreeRow { + label: format!( + "{} {}", + file.status, + visible(parts.last().copied().unwrap_or(&file.path)) + ), + depth: parts.len().saturating_sub(1), + file_index: Some(index), + counts: file.counts.map_or_else( + || Line::from("binary/-"), + |(added, removed)| { + diff::render_diff_count_header(added, removed).unwrap_or_default() + }, + ), + }); + } + } + rows +} + +fn pane(title: String, focused: bool) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(if focused { theme::RUST_ORANGE } else { theme::DIM })) +} + +fn render_tree(frame: &mut Frame, area: Rect, state: &mut DiffView) { + let block = pane(" Files ".into(), !state.content_focus); + let inner = block.inner(area); + frame.render_widget(block, area); + let rows = tree_rows(state); + let selected_row = + rows.iter().position(|row| row.file_index == Some(state.selected)).unwrap_or(0); + let height = usize::from(inner.height); + state.tree_scroll = state.tree_scroll.min(rows.len().saturating_sub(height)); + if selected_row < state.tree_scroll { + state.tree_scroll = selected_row; + } + if selected_row >= state.tree_scroll + height { + state.tree_scroll = (selected_row + 1).saturating_sub(height); + } + let lines: Vec<_> = rows + .iter() + .skip(state.tree_scroll) + .take(height) + .map(|row| { + let selected = row.file_index == Some(state.selected); + let style = if selected { + Style::default() + .bg(theme::RUST_ORANGE) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else if row.file_index.is_none() { + Style::default().fg(theme::DIM) + } else { + Style::default() + }; + let counts_width = row.counts.width(); + let width = usize::from(inner.width); + let label_width = width.saturating_sub(counts_width + usize::from(counts_width > 0)); + let indent = " ".repeat((row.depth * 2).min(label_width / 3)); + let label = + format!("{indent}{}", fit(&row.label, label_width.saturating_sub(indent.len()))); + let gap = width.saturating_sub(display_width(&label) + counts_width); + let mut spans = vec![Span::styled(format!("{label}{}", " ".repeat(gap)), style)]; + spans.extend(row.counts.spans.iter().cloned()); + Line::from(spans) + }) + .collect(); + if rows.is_empty() { + let message = if state.loading == Some(Loading::Files) { + "Loading..." + } else if state.error.is_some() { + "Git unavailable" + } else { + "No changes" + }; + frame.render_widget(Paragraph::new(message).wrap(Wrap { trim: false }), inner); + } else { + frame.render_widget(Paragraph::new(lines), inner); + } +} + +fn render_content(frame: &mut Frame, area: Rect, state: &mut DiffView) { + let mode = if state.full_file { "Full file" } else { "Diff" }; + let path = state.selected_file().map_or_else(String::new, |file| visible(&file.path)); + let deleted = state.content.as_ref().is_some_and(|content| content.deleted); + let status = state.selected_file().map_or(' ', |file| file.status); + let title = format!( + " {mode} | {status} {path}{} ", + if deleted && state.full_file { " (before deletion)" } else { "" } + ); + let block = pane(title, state.content_focus); + let inner = block.inner(area); + frame.render_widget(block, area); + state.viewport_height = usize::from(inner.height).max(1); + if let Some(error) = &state.error { + frame.render_widget( + Paragraph::new(error.as_str()) + .style(Style::default().fg(theme::STATUS_ERROR)) + .wrap(Wrap { trim: false }), + inner, + ); + return; + } + if state.loading.is_some() { + frame.render_widget( + Paragraph::new("Loading...").style(Style::default().fg(theme::DIM)), + inner, + ); + return; + } + let Some(content) = &state.content else { + frame.render_widget( + Paragraph::new("No changes in this comparison.").wrap(Wrap { trim: false }), + inner, + ); + return; + }; + if state + .rendered + .as_ref() + .is_none_or(|cache| cache.width != inner.width || cache.full_file != state.full_file) + { + let (mut lines, hunks) = if state.full_file { + ( + content + .full_text + .as_ref() + .map_or_else(Vec::new, |text| full_file_lines(text, &path, inner.width)), + Vec::new(), + ) + } else { + diff::render_repository_patch(&content.patch, inner.width) + }; + if lines.is_empty() { + lines.push(Line::from( + content + .notice + .clone() + .unwrap_or_else(|| "No textual changes (file metadata changed).".into()), + )); + } else if let Some(notice) = &content.notice { + if state.full_file { + lines.insert(0, Line::from(notice.clone())); + } else { + lines.push(Line::from(notice.clone())); + } + } + state.rendered = + Some(RenderedContent { width: inner.width, full_file: state.full_file, lines, hunks }); + } + if let Some(rendered) = &state.rendered { + let scroll = &mut state.scroll[usize::from(state.full_file)]; + // Every hunk may start at the top, while ordinary scrolling stops at EOF. + let end = rendered.lines.len().saturating_sub(state.viewport_height); + let last_hunk = rendered.hunks.last().copied().unwrap_or(0); + *scroll = (*scroll).min(end.max(last_hunk)); + // Slice instead of Paragraph::scroll so files can exceed u16::MAX rows. + let lines = rendered + .lines + .iter() + .skip(*scroll) + .take(state.viewport_height) + .cloned() + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); + } +} + +fn full_file_lines(text: &str, path: &str, width: u16) -> Vec> { + let number_width = text.lines().count().max(1).to_string().len(); + let content_width = usize::from(width).saturating_sub(number_width + 2).max(1); + let highlighted = + highlight::highlight_code(text, Path::new(path).extension().and_then(|ext| ext.to_str())); + highlighted + .into_iter() + .enumerate() + .flat_map(|(index, line)| { + let chunks = line + .spans + .into_iter() + .map(|span| StyledChunk { text: span.content.into_owned(), style: span.style }) + .collect::>(); + let mut rows = wrap_styled_chunks(&chunks, content_width); + if rows.is_empty() { + rows.push(Line::default()); + } + rows.into_iter() + .enumerate() + .map(move |(row, mut line)| { + let number = if row == 0 { + format!("{:>number_width$} ", index + 1) + } else { + " ".repeat(number_width + 2) + }; + line.spans.insert(0, Span::styled(number, Style::default().fg(theme::DIM))); + line + }) + .collect::>() + }) + .collect() +} + +fn render_comparison_picker(frame: &mut Frame, area: Rect, selected: usize) { + let width = area.width.min(64); + let popup = Rect::new( + area.x + (area.width - width) / 2, + area.y + area.height.saturating_sub(7) / 2, + width, + 7.min(area.height), + ); + frame.render_widget(Clear, popup); + let block = pane(" Comparison | Enter select | Esc cancel ".into(), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let lines = Comparison::ALL + .iter() + .enumerate() + .map(|(index, comparison)| { + Line::from(Span::styled( + format!( + "{} {}: {}", + if index == selected { ">" } else { " " }, + comparison.label(), + comparison.description() + ), + if index == selected { + Style::default().fg(Color::Black).bg(theme::RUST_ORANGE) + } else { + Style::default() + }, + )) + }) + .collect::>(); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn visible(text: &str) -> String { + text.chars().map(|ch| if ch.is_control() { '\u{fffd}' } else { ch }).collect() +} + +fn fit(text: &str, width: usize) -> String { + if display_width(text) <= width { + return text.into(); + } + let mut result = String::new(); + for ch in text.chars() { + if display_width(&result) + unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) >= width + { + break; + } + result.push(ch); + } + if width > 0 { + result.push('…'); + } + result +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bd6650cb..bfc7b866 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ mod copy; mod diff; mod document_table; pub(crate) mod footer_rows; +mod git_diff; pub(crate) mod help; mod highlight; pub(crate) mod host_tips; @@ -44,6 +45,7 @@ pub fn render_fullscreen_surface(frame: &mut Frame, app: &mut App) { } SurfaceMode::Fullscreen(FullscreenView::Update) => update::render(frame, app), SurfaceMode::Fullscreen(FullscreenView::Copy) => copy::render(frame, app), + SurfaceMode::Fullscreen(FullscreenView::Diff) => git_diff::render(frame, app), SurfaceMode::Chat => { debug_assert!(false, "chat is rendered by the inline terminal session"); } diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index a6558ab0..8766dfa7 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -1591,6 +1591,83 @@ fn width_and_height_resizes_preserve_unicode_multiline_draft_and_exact_submissio test.shutdown(); } +#[test] +fn git_diff_loads_refreshes_and_returns_to_chat_without_submitting_a_prompt() { + let mut test = TerminalTest::start("hold-success", 8); + test.submit("go", "go"); + test.wait_journal("reply-held"); + test.wait_screen("streamed line 8"); + let project = test.temp.path().join("project"); + let initialized = std::process::Command::new("git") + .current_dir(&project) + .args(["init", "-q"]) + .output() + .expect("initialize Git fixture"); + assert!(initialized.status.success()); + std::fs::create_dir(project.join("src")).expect("source directory"); + for (name, content) in + [("a", "DIFF_A_CONTENT\n"), ("b", "DIFF_B_CONTENT\n"), ("d", "DIFF_D_CONTENT\n")] + { + std::fs::write(project.join(format!("src/{name}.txt")), content) + .expect("untracked fixture"); + } + std::fs::write( + project.join("src/c.txt"), + format!("DIFF_C_FIRST\n{}DIFF_C_LAST\n", "middle line\n".repeat(80)), + ) + .expect("long fixture"); + let right_pane = |test: &TerminalTest, first_col: usize, rows: usize| { + let output = test.output.lock().expect("output lock"); + let screen = output.parser.screen(); + let (height, width) = screen.size(); + (3..height.min(u16::try_from(rows + 3).expect("test rows"))) + .map(|row| { + (u16::try_from(first_col).expect("test column")..width - 1) + .filter_map(|col| screen.cell(row, col)) + .map(vt100::Cell::contents) + .collect::() + }) + .collect::>() + .join("\n") + }; + test.submit("/diff", "/diff"); + test.wait_until("selected file A loaded in the diff pane", |test| { + right_pane(test, 30, 32).contains("DIFF_A_CONTENT") + }); + assert!(test.output.lock().expect("output lock").parser.screen().alternate_screen()); + test.send(b"\x1b[B\x1b[C"); // Select B and focus its content. + test.wait_until("selected file B loaded in the diff pane", |test| { + right_pane(test, 30, 32).contains("DIFF_B_CONTENT") + }); + std::fs::remove_file(project.join("src/b.txt")).expect("undo file B addition"); + test.send(b"r"); + test.wait_until("refresh selects C and removes B's preview", |test| { + let content = right_pane(test, 30, 32); + content.contains("DIFF_C_FIRST") && !content.contains("DIFF_B_CONTENT") + }); + // Down must scroll C, not select D: content focus must survive the refresh. + test.send("\x1b[B".repeat(90).as_bytes()); + test.wait_until("file C scrolled to its last line", |test| { + right_pane(test, 30, 32).contains("DIFF_C_LAST") + }); + test.resize_and_wait_for_draw(25, 120); + test.send("\x1b[B".repeat(90).as_bytes()); + test.wait_until("file C remains navigable after resize", |test| { + right_pane(test, 41, 19).contains("DIFF_C_LAST") + }); + assert!(!project.join(".git/index").exists(), "review must not create the Git index"); + test.send(b"\x03"); + test.wait_screen("streamed line 8"); + assert!(!test.output.lock().expect("output lock").parser.screen().alternate_screen()); + test.assert_prompts(&["go"]); + test.release(); + test.wait_journal("turn_complete"); + test.submit("after diff", "after diff"); + test.wait_screen("reply 2 started"); + test.assert_prompts(&["go", "after diff"]); + test.shutdown(); +} + #[test] fn fullscreen_resize_and_repeated_return_preserve_chat_and_next_submission() { let mut test = TerminalTest::start("hold-success", 8); From a1f83ed48d013ca3b660042122618d487cdcebd8 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:00:21 +0200 Subject: [PATCH 05/10] feat(resume): unify session picking and normalize saved history (#477) * feat(resume): unify session selection in the fullscreen picker - route /resume through the shared picker and remove inline session arguments - search sessions by name, ID, and branch without case sensitivity - preserve selection across metadata refreshes and handle empty results - improve fork previews with aligned wrapping and a scrollable message reader - document the workflow and add functional and rendering regression tests * fix(resume): normalize internal records across history and fork selection - share transcript classification between resumed history and fork selection - render saved commands readably and exclude internal records from fork points - preserve script text, XML, whitespace, and user message boundaries - cover valid and malformed records with unit and SDK fixture tests --- agent-sdk/src/bridge.test.ts | 2 +- agent-sdk/src/bridge.ts | 30 +- agent-sdk/src/bridge/history.test.ts | 33 +- agent-sdk/src/bridge/history.ts | 62 +- .../bridge/transcript_presentation.test.ts | 147 +++++ .../src/bridge/transcript_presentation.ts | 109 ++++ docs/src/commands.md | 14 +- src/app/events/client/session.rs | 3 +- src/app/events/mod.rs | 7 +- src/app/events/session.rs | 37 +- src/app/events/session_reset.rs | 8 + src/app/events/tests/client_events.rs | 123 ++++ src/app/events/tests/mod.rs | 94 ++- src/app/events/tests/slash_input.rs | 116 ++-- src/app/input_submit.rs | 2 +- src/app/session_picker.rs | 321 ++++++++-- src/app/slash/candidates.rs | 58 -- src/app/slash/catalog.rs | 15 +- src/app/slash/executors.rs | 50 +- src/app/slash/navigation.rs | 4 +- src/app/slash/tests.rs | 51 +- src/app/state/types.rs | 17 +- src/app/tests.rs | 35 - src/app/view/mod.rs | 1 + src/ui/session_picker.rs | 605 +++++++++++++++--- tests/slash_input_flow.rs | 15 +- 26 files changed, 1436 insertions(+), 523 deletions(-) create mode 100644 agent-sdk/src/bridge/transcript_presentation.test.ts create mode 100644 agent-sdk/src/bridge/transcript_presentation.ts diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index 3a0e8aab..a8630e03 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -2234,7 +2234,7 @@ test("rewindTargetsFromSessionMessages filters user text messages with UUIDs", ( { uuid: "user-1", first_text: "first prompt line", - input_text: "first prompt\nline", + input_text: " first prompt\nline ", index: 0, }, ]); diff --git a/agent-sdk/src/bridge.ts b/agent-sdk/src/bridge.ts index f1e0c04e..45b789c9 100644 --- a/agent-sdk/src/bridge.ts +++ b/agent-sdk/src/bridge.ts @@ -39,6 +39,7 @@ import { type SessionState, } from "./bridge/session_lifecycle.js"; import { mapSessionMessagesToUpdates } from "./bridge/history.js"; +import { messageText, transcriptPresentation } from "./bridge/transcript_presentation.js"; import { emitAvailableAgentsIfChanged, mapAvailableAgents, @@ -155,37 +156,16 @@ function userTextPartsFromSessionMessage( message: SessionMessage, ): UserTextParts | undefined { const record = message as unknown as Record; - if (record.type !== "user") { + if (record.type !== "user" || transcriptPresentation(message).kind !== "conversation") { return undefined; } const payload = record.message; if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return undefined; } - const content = (payload as Record).content; - if (typeof content === "string") { - const firstText = normalizeRewindTargetText(content); - return firstText ? { firstText, inputText: content } : undefined; - } - if (!Array.isArray(content)) { - return undefined; - } - const textBlocks: string[] = []; - for (const block of content) { - if (!block || typeof block !== "object" || Array.isArray(block)) { - continue; - } - const blockRecord = block as Record; - if (blockRecord.type === "text" && typeof blockRecord.text === "string") { - textBlocks.push(blockRecord.text); - } - } - const firstText = textBlocks - .map(normalizeRewindTargetText) - .find((text) => text.length > 0); - return firstText - ? { firstText, inputText: textBlocks.join("\n").trim() } - : undefined; + const inputText = messageText(payload as Record); + const firstText = normalizeRewindTargetText(inputText); + return firstText ? { firstText, inputText } : undefined; } export function rewindTargetsFromSessionMessages( diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts index 155cd46d..b92b0a02 100644 --- a/agent-sdk/src/bridge/history.test.ts +++ b/agent-sdk/src/bridge/history.test.ts @@ -4,7 +4,6 @@ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; -import type { SessionUpdate } from "../types.js"; import { mapSessionMessagesToUpdates } from "./history.js"; test("resume display restores compaction segments once and preserves SDK branch filtering", () => { @@ -28,8 +27,11 @@ test("resume display restores compaction segments once and preserves SDK branch add("boundary-1", null, "system", undefined, { subtype: "compact_boundary", logicalParentUuid: "answer-1", compactMetadata: { trigger: "auto", preservedMessages: { anchorUuid: "summary-1", uuids: ["tool", "result", "answer-1"] } } }); add("summary-1", "boundary-1", "user", { role: "user", content: "Internal summary one" }, { isCompactSummary: true }); add("prompt-2", "summary-1", "user", { role: "user", content: [{ type: "text", text: "Second prompt" }] }); - add("discarded", "prompt-2", "assistant", { id: "discarded-branch", role: "assistant", content: [{ type: "text", text: "Discarded answer" }] }); - add("answer-2", "prompt-2", "assistant", { id: "assistant-3", role: "assistant", content: [{ type: "text", text: "Second answer" }] }); + add("command-caveat", "prompt-2", "user", { role: "user", content: "Internal context only" }, { isMeta: true }); + add("rename", "command-caveat", "user", { role: "user", content: "/rename\nrename\nreview-444" }); + add("color", "rename", "user", { role: "user", content: "/color\ncolor\nblue" }); + add("discarded", "color", "assistant", { id: "discarded-branch", role: "assistant", content: [{ type: "text", text: "Discarded answer" }] }); + add("answer-2", "color", "assistant", { id: "assistant-3", role: "assistant", content: [{ type: "text", text: "Second answer" }] }); add("boundary-2", null, "system", undefined, { subtype: "compact_boundary", logicalParentUuid: "answer-2", compactMetadata: { trigger: "auto", preservedSegment: { headUuid: "prompt-2", anchorUuid: "summary-2", tailUuid: "answer-2" } } }); add("summary-2", "boundary-2", "user", { role: "user", content: [{ type: "text", text: "Internal summary two" }] }, { isCompactSummary: true }); add("prompt-3", "summary-2", "user", { role: "user", content: "Third prompt" }); @@ -42,10 +44,12 @@ test("resume display restores compaction segments once and preserves SDK branch writeFileSync(transcript, before); const script = ` import { getSessionTranscriptMessages, mapSessionMessagesToUpdates } from ${JSON.stringify(new URL("./history.js", import.meta.url).href)}; + import { rewindTargetsFromSessionMessages } from ${JSON.stringify(new URL("../bridge.js", import.meta.url).href)}; const messages = await getSessionTranscriptMessages(${JSON.stringify(sessionId)}); const updates = mapSessionMessagesToUpdates(messages); console.log(JSON.stringify({ - uuids: messages.map(message => message.uuid), updates + uuids: messages.map(message => message.uuid), updates, + targets: rewindTargetsFromSessionMessages(messages).map(target => [target.uuid, target.input_text]) })); `; const child = spawnSync(process.execPath, ["--input-type=module", "-"], { @@ -56,9 +60,10 @@ test("resume display restores compaction segments once and preserves SDK branch }); assert.equal(child.status, 0, child.stderr); const result = JSON.parse(child.stdout); - assert.deepEqual(result.uuids, ["prompt-1", "tool", "result", "answer-1", "boundary-1", "summary-1", "prompt-2", "answer-2", "boundary-2", "summary-2", "prompt-3", "answer-3"]); + assert.deepEqual(result.uuids, ["prompt-1", "tool", "result", "answer-1", "boundary-1", "summary-1", "prompt-2", "rename", "color", "answer-2", "boundary-2", "summary-2", "prompt-3", "answer-3"]); const chunks = result.updates.filter((update: { type: string }) => update.type === "user_message_chunk" || update.type === "agent_message_chunk"); - assert.deepEqual(chunks.map((update: { content: { text: string } }) => update.content.text), ["First prompt", "First answer", "Second prompt", "Second answer", "Third prompt", "Third answer"]); + assert.deepEqual(chunks.map((update: { content: { text: string } }) => update.content.text), ["First prompt", "First answer", "Second prompt", "/rename review-444", "/color blue", "Second answer", "Third prompt", "Third answer"]); + assert.deepEqual(result.targets, [["prompt-3", "Third prompt"], ["prompt-2", "Second prompt"], ["prompt-1", "First prompt"]]); const tools = result.updates.filter((update: { type: string }) => update.type === "tool_call"); assert.equal(tools.length, 1); assert.equal(tools[0].tool_call.tool_call_id, "read-1"); @@ -168,12 +173,17 @@ test("resume handles many unclosed notification and field tags without blocking "bad" + "a".repeat(200_000) + "", "validcompleted" + "abc".repeat(200_000) + "", ]; + const brokenCommand = "/renamerename".repeat(100_000); const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; const updates = mapSessionMessagesToUpdates([ ...broken.map((content, index) => ({ ...base, type: "user", uuid: "broken-" + index, origin: { kind: "task-notification" }, message: { role: "user", content } })), + { ...base, type: "user", uuid: "broken-command", message: { role: "user", content: brokenCommand } }, { ...base, type: "user", uuid: "after", message: { role: "user", content: "Still restored" } }, ]); - process.stdout.write(JSON.stringify(updates)); + process.stdout.write(JSON.stringify({ + chunks: updates.filter(update => update.type === "user_message_chunk").map(update => ({ uuid: update.source_message_uuid, preserved: update.content.text === brokenCommand, tail: update.source_message_uuid === "after" ? update.content : undefined })), + tasks: updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks), + })); `, encoding: "utf8", timeout: 10_000, @@ -181,9 +191,12 @@ test("resume handles many unclosed notification and field tags without blocking }); assert.equal(child.error, undefined, String(child.error)); assert.equal(child.status, 0, child.stderr); - const updates = JSON.parse(child.stdout) as SessionUpdate[]; - assert.deepEqual(updates.filter(update => update.type === "user_message_chunk").map(update => [update.source_message_uuid, update.content]), [["after", { type: "text", text: "Still restored" }]]); - assert.deepEqual(updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks).map(task => [task.task_id, task.status, task.source_tool_call_id, task.description]), [["valid", "completed", undefined, undefined]]); + const result = JSON.parse(child.stdout); + assert.deepEqual(result.chunks, [ + { uuid: "broken-command", preserved: true }, + { uuid: "after", preserved: false, tail: { type: "text", text: "Still restored" } }, + ]); + assert.deepEqual(result.tasks.map((task: { task_id: string; status: string; source_tool_call_id?: string; description?: string }) => [task.task_id, task.status, task.source_tool_call_id, task.description]), [["valid", "completed", undefined, undefined]]); }); test("resume display maps plain string messages and hides internal compact summaries", () => { diff --git a/agent-sdk/src/bridge/history.ts b/agent-sdk/src/bridge/history.ts index c7056671..1ec91eb7 100644 --- a/agent-sdk/src/bridge/history.ts +++ b/agent-sdk/src/bridge/history.ts @@ -14,6 +14,7 @@ import type { ToolCall, } from "../types.js"; import { asRecordOrNull } from "./shared.js"; +import { messageCandidates, transcriptPresentation } from "./transcript_presentation.js"; import { applyFieldsToBase, normalizeToolCallUpdateFields, toolAcceptsTaskLifecycle, toolNameFromMeta } from "./tool_calls.js"; import { taskLifecyclePatch, upsertTask } from "./tasks.js"; import { linkTaskToolUse } from "./task_links.js"; @@ -76,19 +77,6 @@ function nonEmptyTrimmed(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function messageCandidates(raw: unknown): Record[] { - const candidates: Record[] = []; - const topLevel = asRecordOrNull(raw); - if (topLevel) { - candidates.push(topLevel); - const nested = asRecordOrNull(topLevel.message); - if (nested) { - candidates.push(nested); - } - } - return candidates; -} - function pushResumeTaskSystemUpdate( updates: SessionUpdate[], tasksById: Map, @@ -302,23 +290,23 @@ export function mapSessionMessagesToUpdates( for (const entry of messages) { const record = asRecordOrNull(entry); - const origin = asRecordOrNull(record?.origin); - // Background-task inputs belong to Claude's context, not the visible user transcript. - // Peer and scheduled messages carry a subkind and remain eligible for display. - if (record?.isCompactSummary === true) continue; - if ( - entry.type === "user" && origin?.kind === "task-notification" && - origin.subkind === undefined && - (origin.producer === undefined || origin.producer === "session-task") - ) { - const notification = resumedTaskNotification(entry.message); + const presentation = transcriptPresentation(entry); + if (presentation.kind === "hidden") continue; + if (presentation.kind === "task_notification") { + const notification = presentation.notification; if (notification) pushResumeTaskSystemUpdate(updates, tasksById, taskToolUseIds, toolCalls, notification); continue; } + if (presentation.kind === "command_output") { + if (presentation.text.trim()) updates.push({ type: "system_notice_update", severity: presentation.severity, message: presentation.text }); + continue; + } const fallbackRole = entry.type === "assistant" ? "assistant" : "user"; const entrySourceMessageUuid = typeof entry.uuid === "string" ? entry.uuid : undefined; - const candidates = messageCandidates(entry.message); + const candidates = presentation.kind === "command" + ? [{ role: "user", content: presentation.text }] + : messageCandidates(entry.message); if (entry.type === "system") { for (const message of candidates) { if (message.subtype === "notification") { @@ -433,29 +421,3 @@ export function restoreResumedToolState(session: SessionState, updates: SessionU if (tool && !["completed", "failed", "killed"].includes(tool.status)) linkTaskToolUse(session, task.task_id, tool.tool_call_id); } } - -// Literal searches avoid retrying overlapping suffixes when a closing tag is missing. -function notificationTagContent(text: string, name: string): string | undefined { - const opening = `<${name}>`; - const start = text.indexOf(opening); - if (start === -1) return undefined; - const bodyStart = start + opening.length; - const end = text.indexOf(``, bodyStart); - return end === -1 ? undefined : text.slice(bodyStart, end); -} - -function resumedTaskNotification(raw: unknown): Record | undefined { - for (const message of messageCandidates(raw)) { - const content = typeof message.content === "string" ? message.content : Array.isArray(message.content) - ? message.content.map(block => asRecordOrNull(block)?.text ?? "").join("\n") : ""; - const body = notificationTagContent(content, "task-notification"); - if (!body) continue; - const field = (name: string) => notificationTagContent(body, name)?.trim(); - const taskId = field("task-id"); - const status = field("status"); - if (taskId && (status === "completed" || status === "failed" || status === "stopped")) { - return { subtype: "task_notification", task_id: taskId, status, tool_use_id: field("tool-use-id"), summary: field("summary"), output_file: field("output-file") }; - } - } - return undefined; -} diff --git a/agent-sdk/src/bridge/transcript_presentation.test.ts b/agent-sdk/src/bridge/transcript_presentation.test.ts new file mode 100644 index 00000000..d5920a4b --- /dev/null +++ b/agent-sdk/src/bridge/transcript_presentation.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildRewindConversationPlan, rewindTargetsFromSessionMessages } from "../bridge.js"; +import { mapSessionMessagesToUpdates } from "./history.js"; + +const rename = "/rename\nrename\nreview-444"; +const color = "/color\ncolor\nblue"; + +function user(uuid: string, content: unknown, extra = {}) { + return { type: "user" as const, uuid, session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null, message: { role: "user", content }, ...extra }; +} + +test("resume shows complete command envelopes as commands while fork offers only conversation prompts", () => { + const messages = [ + user("before", "First prompt"), + user("caveat", "Internal context only", { isMeta: true }), + user("rename", rename), + user("color", [{ type: "text", text: color.slice(0, color.indexOf("")) }, { type: "text", text: color.slice(color.indexOf("")) }]), + user("combined", `${rename}${color}`), + user("no-args", "/helphelp"), + user("literal-args", "/renamerenamereview 444 & more"), + user("after", "Continue with literal XML"), + ]; + const original = structuredClone(messages); + const updates = mapSessionMessagesToUpdates(messages); + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk").map(update => [update.source_message_uuid, (update.content.type === "text" ? update.content.text : update.content)]), [ + ["before", "First prompt"], + ["rename", "/rename review-444"], + ["color", "/color blue"], + ["combined", "/rename review-444\n/color blue"], + ["no-args", "/help"], + ["literal-args", "/rename review 444 & more"], + ["after", "Continue with literal XML"], + ]); + assert.deepEqual(rewindTargetsFromSessionMessages(messages).map(target => [target.uuid, target.input_text]), [ + ["after", "Continue with literal XML"], ["before", "First prompt"], + ]); + const fork = buildRewindConversationPlan(messages, "after"); + assert.ok(fork); + assert.equal(fork.resumeSessionAtUuid, "literal-args"); + assert.deepEqual(fork.retainedMessages.map(message => message.uuid), ["before", "caveat", "rename", "color", "combined", "no-args", "literal-args"]); + assert.deepEqual(fork.retainedMessages[2].message, { role: "user", content: rename }); + assert.deepEqual(fork.retainedMessages[3].message, { role: "user", content: messages[3].message.content }); + assert.equal(buildRewindConversationPlan(messages, "rename"), null); + assert.deepEqual(messages, original); +}); + +test("command output becomes a notice with literal content and never becomes a fork point", () => { + const messages = [ + user("stdout", "Saved XML & text\nSecond line"), + user("stderr", [{ type: "text", text: "Command failed" }]), + user("empty", ""), + user("after", "Next prompt"), + ]; + const updates = mapSessionMessagesToUpdates(messages); + assert.deepEqual(updates.filter(update => update.type === "system_notice_update"), [ + { type: "system_notice_update", severity: "info", message: "Saved XML & text\nSecond line" }, + { type: "system_notice_update", severity: "error", message: "Command failed" }, + ]); + assert.deepEqual(rewindTargetsFromSessionMessages(messages).map(target => target.uuid), ["after"]); +}); + +test("XML examples, unknown tags and incomplete command records remain readable and eligible for forks", () => { + const samples = [ + "Keep this XML", + `Explain this record:\n${rename}`, + `\`\`\`xml\n${rename}\n\`\`\``, + `${rename}\nKeep this trailing explanation`, + "/rename", + "/renamerenameunfinished", + "not a commandrenametext", + "unfinished output", + "A user-authored example without SDK metadata", + "Do not guess", + ]; + const messages = samples.map((text, index) => user(`sample-${index}`, text)); + messages.push(user("human-command-example", rename, { origin: { kind: "human" } })); + messages.push(user("peer-command-example", color, { origin: { kind: "peer" } })); + const expected = [...samples, rename, color]; + const updates = mapSessionMessagesToUpdates(messages); + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk").map(update => (update.content.type === "text" ? update.content.text : update.content)), expected); + assert.deepEqual(rewindTargetsFromSessionMessages(messages).map(target => target.input_text), [...expected].reverse()); + const assistant = { ...user("assistant", rename), type: "assistant" as const, message: { role: "assistant", content: rename } }; + assert.deepEqual(mapSessionMessagesToUpdates([assistant]).filter(update => update.type === "agent_message_chunk").map(update => (update.content.type === "text" ? update.content.text : update.content)), [rename]); +}); + +test("resume restores internal task outcomes while fork retains human, peer and scheduled messages", () => { + const notification = "task-1completedFinished"; + const messages = [ + user("internal", notification, { origin: { kind: "task-notification", producer: "session-task" } }), + user("malformed-internal", "unfinished", { origin: { kind: "task-notification" } }), + user("summary", "Internal summary", { isCompactSummary: true }), + user("human", notification, { origin: { kind: "human" } }), + user("peer", "Peer request", { origin: { kind: "task-notification", subkind: "peer-send-message" } }), + user("scheduled", "Scheduled request", { origin: { kind: "task-notification", subkind: "scheduled-trigger" } }), + ]; + const updates = mapSessionMessagesToUpdates(messages); + assert.deepEqual(updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks).map(task => [task.task_id, task.status, task.description]), [["task-1", "completed", "Finished"]]); + assert.deepEqual(rewindTargetsFromSessionMessages(messages).map(target => target.uuid), ["scheduled", "peer", "human"]); +}); + +test("script prompts round-trip through resume and fork without losing XML, whitespace or quoted SDK tags", () => { + const scripts = [ + " # keep leading indentation\n printf 'keep trailing whitespace' \n\n", + "#!/bin/sh\nprintf '%s\\n' 'plain script without XML'\n", + "#!/bin/sh\ncat <<'XML'\n\n src/main.rs\n\nXML\n", + "$document = @'\r\n\r\n & A ]]>\r\n\r\n'@\r\nWrite-Output $document\r\n", + "const view = `

Hello

`;\nconsole.log(view);\n", + "fn main() {\n let example = r#\"completed\"#;\n println!(\"{example}\");\n}\n", + `#!/bin/sh\ncat <<'EXAMPLE'\n${rename}\nEXAMPLE\n`, + "```python\nprint('/rename')\nprint('keep me')\n```", + "# incomplete XML is intentional test input\nprintf '%s' ''", + ]; + for (const script of scripts) { + for (const content of [ + script, + [{ type: "text", text: script }], + [{ type: "text", text: script.slice(0, 9) }, { type: "text", text: script.slice(9) }], + ]) { + const messages = [user("script", content)]; + const before = structuredClone(messages); + const updates = mapSessionMessagesToUpdates(messages); + const chunks = updates.filter(update => update.type === "user_message_chunk"); + assert.ok(chunks.every(update => update.content.type === "text")); + assert.equal(chunks.map(update => update.content.type === "text" ? update.content.text : "").join(""), script); + assert.deepEqual(rewindTargetsFromSessionMessages(messages).map(target => target.input_text), [script]); + assert.deepEqual(messages, before); + } + } +}); + +test("recognizing command-shaped text never drops image attachments or tool outcomes", () => { + const messages = [ + { ...user("tool", ""), type: "assistant" as const, message: { role: "assistant", content: [{ type: "tool_use", id: "read", name: "Read", input: { file_path: "fixture.xml" } }] } }, + user("result", [{ type: "text", text: rename }, { type: "tool_result", tool_use_id: "read", content: "file content" }]), + user("attachment", [{ type: "text", text: color }, { type: "image", source: { type: "base64", media_type: "image/png", data: "fixture" } }]), + ]; + const updates = mapSessionMessagesToUpdates(messages); + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk").map(update => update.content), [ + { type: "text", text: rename }, { type: "text", text: color }, { type: "text", text: "[image]" }, + ]); + const results = updates.filter(update => update.type === "tool_call_update"); + assert.equal(results.length, 1); + assert.equal(results[0].tool_call_update.tool_call_id, "read"); + assert.equal(results[0].tool_call_update.fields.status, "completed"); + assert.equal(results[0].tool_call_update.fields.raw_output, "file content"); +}); diff --git a/agent-sdk/src/bridge/transcript_presentation.ts b/agent-sdk/src/bridge/transcript_presentation.ts new file mode 100644 index 00000000..d10852ee --- /dev/null +++ b/agent-sdk/src/bridge/transcript_presentation.ts @@ -0,0 +1,109 @@ +import { asRecordOrNull } from "./shared.js"; + +type TranscriptPresentation = + | { kind: "conversation" } + | { kind: "hidden" } + | { kind: "task_notification"; notification?: Record } + | { kind: "command"; text: string } + | { kind: "command_output"; text: string; severity: "info" | "error" }; + +export function messageCandidates(raw: unknown): Record[] { + const record = asRecordOrNull(raw); + if (!record) return []; + const nested = asRecordOrNull(record.message); + return nested ? [record, nested] : [record]; +} + +export function messageText(message: Record): string { + const content = message.content; + if (typeof content === "string") return content; + return Array.isArray(content) + ? content.flatMap(block => { + const record = asRecordOrNull(block); + return record?.type === "text" && typeof record.text === "string" ? [record.text] : []; + }).join("") + : ""; +} + +// Literal searches keep malformed or very large records linear to scan. Tag bodies +// remain literal: command arguments and task summaries may themselves contain XML. +function tagAt(text: string, offset: number, name: string): { body: string; end: number } | undefined { + const opening = `<${name}>`; + if (!text.startsWith(opening, offset)) return undefined; + const start = offset + opening.length; + const closing = ``; + const end = text.indexOf(closing, start); + return end < 0 ? undefined : { body: text.slice(start, end), end: end + closing.length }; +} + +function tagContent(text: string, name: string): string | undefined { + const offset = text.indexOf(`<${name}>`); + return offset < 0 ? undefined : tagAt(text, offset, name)?.body; +} + +function taskNotification(raw: unknown): Record | undefined { + for (const message of messageCandidates(raw)) { + const body = tagContent(messageText(message), "task-notification"); + if (!body) continue; + const field = (name: string) => tagContent(body, name)?.trim(); + const taskId = field("task-id"); + const status = field("status"); + if (taskId && (status === "completed" || status === "failed" || status === "stopped")) { + return { subtype: "task_notification", task_id: taskId, status, tool_use_id: field("tool-use-id"), summary: field("summary"), output_file: field("output-file") }; + } + } + return undefined; +} + +function skipWhitespace(text: string, start: number): number { + let offset = start; + while (offset < text.length && /\s/u.test(text[offset])) offset++; + return offset; +} + +function commandText(text: string): string | undefined { + const commands: string[] = []; + let offset = 0; + while (offset < text.length) { + const name = tagAt(text, offset, "command-name"); + if (!name || !/^\/[^\s<>]+$/u.test(name.body.trim())) return undefined; + const message = tagAt(text, skipWhitespace(text, name.end), "command-message"); + if (!message) return undefined; + const args = tagAt(text, skipWhitespace(text, message.end), "command-args"); + if (!args) return undefined; + const argument = args.body.trim(); + commands.push(`${name.body.trim()}${argument ? ` ${argument}` : ""}`); + offset = skipWhitespace(text, args.end); + } + return commands.length ? commands.join("\n") : undefined; +} + +/** Classify SDK transcript records once for history display and fork eligibility. + * This only projects saved content; it never changes model context or executes commands. + */ +export function transcriptPresentation(raw: unknown): TranscriptPresentation { + const record = asRecordOrNull(raw); + if (record?.isCompactSummary === true) return { kind: "hidden" }; + if (record?.type !== "user") return { kind: "conversation" }; + const origin = asRecordOrNull(record.origin); + if (origin?.kind === "task-notification" && origin.subkind === undefined && + (origin.producer === undefined || origin.producer === "session-task")) { + return { kind: "task_notification", notification: taskNotification(record.message) }; + } + // Explicitly authored and external messages retain their literal content, + // including examples that happen to use Claude's internal tag names. + if (origin) return { kind: "conversation" }; + for (const message of messageCandidates(record.message)) { + if (Array.isArray(message.content) && message.content.some(block => asRecordOrNull(block)?.type !== "text")) continue; + const text = messageText(message).trim(); + const caveat = tagAt(text, 0, "local-command-caveat"); + if (record.isMeta === true && caveat?.end === text.length) return { kind: "hidden" }; + const command = commandText(text); + if (command !== undefined) return { kind: "command", text: command }; + for (const [tag, severity] of [["local-command-stdout", "info"], ["local-command-stderr", "error"]] as const) { + const output = tagAt(text, 0, tag); + if (output?.end === text.length) return { kind: "command_output", text: output.body, severity }; + } + } + return { kind: "conversation" }; +} diff --git a/docs/src/commands.md b/docs/src/commands.md index 62a27082..11e7a76c 100644 --- a/docs/src/commands.md +++ b/docs/src/commands.md @@ -34,19 +34,29 @@ Use `/docs commands` in the app to render the live merged command list into chat | `/mode` | `/mode ` | Switch to a mode advertised by the active session. | | `/model` | `/model ` | Switch to a model advertised by the active session. | | `/new-session` | `/new-session` | Start a fresh bridge session in the current folder. | -| `/resume` | `/resume [session_id]` | Open the session picker, or resume a supplied session id. | +| `/resume` | `/resume` | Search and select a session in the fullscreen picker. | | `/rewind` | `/rewind ` | Restore conversation, code, or both to a previous user message. | Enter completes the selected slash command or argument and submits it when ready. Tab completes without submitting. Exact command names appear ahead of substring matches; arrow keys can select a different match. App commands that still need required arguments stay in the composer; commands with argument choices show those choices, while `/btw` continues as ordinary question text. An empty suggestion menu allows Enter to submit the typed input for normal validation. Editing the draft cancels a pending submission. Editing, completion, and submission keys cannot change or send the draft while pasted text is awaiting insertion, including when suggestions are open or keys are remapped. Suggestions refresh after the pasted text is inserted. -Completing the name of a command with no arguments or optional arguments closes the popup and returns focus to the draft. Optional arguments remain supported: `/resume` opens the session picker, and `/resume ` resumes directly. Pressing Tab again requests argument completion explicitly. When the SDK provides only an argument hint, Tab displays that hint without changing or sending the draft; Enter sends the typed command for SDK validation. SDK hints are descriptive text, so their brackets never determine whether the app allows submission. Empty hints and `none` or `[none]` do not produce argument help. +Completing the name of a command with no arguments or optional arguments closes the popup and returns focus to the draft. For commands accepting arguments, pressing Tab again requests argument completion explicitly. When the SDK provides only an argument hint, Tab displays that hint without changing or sending the draft; Enter sends the typed command for SDK validation. SDK hints are descriptive text, so their brackets never determine whether the app allows submission. Empty hints and `none` or `[none]` do not produce argument help. Space inserts a literal separator without accepting the highlighted suggestion. It ends command-name completion and continues argument assistance only when the app requires arguments, or when you explicitly requested argument help. There is no special Space dismissal state. Escape dismisses the current menu; argument edits, cursor movement within arguments, and metadata refreshes keep it closed. Tab requests completion again, and editing the command name starts a new completion interaction. A command is recognized only at the start of the draft, allowing leading whitespace; slashes on later lines remain literal prompt or argument text. The command menu includes the full merged inventory and scrolls as you move through it. SDK commands remain available after `/clear` changes the session identifier. In SDK sessions that advertise `/clear [name]`, `/new` and `/reset` are aliases; the optional name labels the conversation you are leaving. `/new-session` is the separate app command that starts a fresh bridge session. App command definitions take precedence when the SDK advertises the same name. +## Resuming sessions + +`/resume` opens the same fullscreen session picker used by `claude-rs resume` at startup. Type to filter by session name, session ID, or branch name; search matches partial text without case sensitivity, and pasted text also searches. Up/Down selects a matching session, Enter resumes it, and Escape closes the picker without switching sessions. Backspace edits the search. The scrollable list shows the SDK-provided title, last activity, branch when available, session ID, and a marker for the current session. All available sessions for the project directory are searchable. + +Refreshing session metadata keeps the selected session by ID when it still matches. If it disappears or stops matching, the next result at that position is selected, or the previous result at the end. An empty result list stays open and cannot resume an unrelated session; edit the search or press Escape. Right activates the `fork` action and opens the message picker; Left/Escape returns to the filtered session list. Message previews wrap to the terminal width, preserve paragraph breaks, and show up to four text lines with a `more` indicator for longer messages. Up/Down selects a message, and Enter forks before it. Right opens the selected message for full-text reading with Up/Down scrolling; Left/Escape returns to the message list. + +Session selection happens inside the picker; `/resume` has no inline session suggestions or chat arguments. Ordinary command-name completion still completes `/resu` to `/resume`. For direct resume by ID from the terminal, use `claude-rs resume `. + +Resumed history displays saved Claude command records as readable commands, such as `/rename review-444`, and command output as notices. Internal command context, compaction summaries, and background-task notifications are excluded from the fork list. Ordinary conversation XML remains literal, and saved session content is unchanged. + ## Reviewing Git changes `/diff` opens a fullscreen, read-only Git viewer for the repository containing the current directory. The left pane shows changed files in an expanded tree; folders are not selectable. Each file shows its status and line counts, with additions in green and removals in red; zero counts are hidden. Totals appear above the tree. The right pane previews the selected file. Binary files, submodules, unavailable files, clean repositories, and Git read failures are shown explicitly. Repositories without a first commit are supported. diff --git a/src/app/events/client/session.rs b/src/app/events/client/session.rs index 942981d1..d60a69f7 100644 --- a/src/app/events/client/session.rs +++ b/src/app/events/client/session.rs @@ -138,8 +138,7 @@ fn apply_rewind_targets( super::super::notices::emit_system_notice(app, SystemSeverity::Error, message); } app.sdk_inventory.rewind_targets_error = error; - app.session_picker.turn_selected = 0; - app.session_picker.turn_scroll_offset = 0; + app.session_picker.reset_turn_navigation(); crate::app::slash::sync_with_cursor(app); } diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 6196dc40..d6af06ba 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -311,9 +311,11 @@ fn dispatch_paste_by_view(app: &mut App, text: &str) -> bool { false } SurfaceMode::Fullscreen(FullscreenView::Config) => super::config::handle_paste(app, text), + SurfaceMode::Fullscreen(FullscreenView::SessionPicker) => { + super::session_picker::paste_search(app, text) + } SurfaceMode::Fullscreen( FullscreenView::Trusted - | FullscreenView::SessionPicker | FullscreenView::Update | FullscreenView::Copy | FullscreenView::Diff, @@ -508,6 +510,8 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) { ); } model::SessionUpdate::SessionTitleUpdate(title) => { + let selected = super::session_picker::selected_session(app) + .map(|session| session.session_id.clone()); if let Some(id) = app.session_runtime.session_id.as_ref() && let Some(listed) = app.recent_sessions.iter_mut().find(|session| session.session_id == id.as_str()) @@ -515,6 +519,7 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) { listed.custom_title.clone_from(&title); } app.session_runtime.session_title = title; + super::session_picker::reconcile_selection(app, selected.as_deref()); } model::SessionUpdate::PromptSuggestionUpdate(suggestion) => { app.session_runtime.prompt_suggestion = diff --git a/src/app/events/session.rs b/src/app/events/session.rs index 7e35971e..4805cb87 100644 --- a/src/app/events/session.rs +++ b/src/app/events/session.rs @@ -3,7 +3,6 @@ use super::super::connect::take_connection_slot; use super::super::connect::{SessionStartReason, start_new_session}; use super::super::state::RecentSessionInfo; -use super::super::view::{self, FullscreenView}; use super::super::{App, AppStatus, LoginHint, SystemSeverity}; use super::push_system_message_with_severity; use super::session_reset::{ChatResetKind, load_resume_history, reset_for_new_session}; @@ -103,9 +102,7 @@ pub(super) fn handle_sessions_listed_event( ) { let session_count = sessions.len(); let pending_title_change = app.config.pending_session_title_change.take(); - let selected_session_id = app - .recent_sessions - .get(app.session_picker.selected) + let selected_session_id = super::super::session_picker::selected_session(app) .map(|session| session.session_id.clone()); let had_pending_title_change = pending_title_change.is_some(); app.recent_sessions = sessions @@ -144,7 +141,10 @@ pub(super) fn handle_sessions_listed_event( } } app.startup.mark_recent_sessions_loaded(); - reconcile_session_picker_selection(app, selected_session_id.as_deref()); + if app.recent_sessions.is_empty() { + app.startup.complete_launch(); + } + super::super::session_picker::reconcile_selection(app, selected_session_id.as_deref()); maybe_open_startup_session_picker(app); tracing::info!( target: crate::logging::targets::APP_SESSION, @@ -682,29 +682,6 @@ pub(super) fn apply_session_cwd(app: &mut App, cwd_raw: String) { app.reconcile_trust_state_from_preferences_and_cwd(); } -fn reconcile_session_picker_selection(app: &mut App, selected_session_id: Option<&str>) { - let session_count = super::super::session_picker::picker_session_count(app); - if session_count == 0 { - app.startup.complete_launch(); - app.session_picker.selected = 0; - app.session_picker.scroll_offset = 0; - return; - } - - if let Some(session_id) = selected_session_id - && let Some(idx) = - app.recent_sessions.iter().position(|session| session.session_id == session_id) - && idx < session_count - { - app.session_picker.selected = idx; - } else { - app.session_picker.selected = - app.session_picker.selected.min(session_count.saturating_sub(1)); - } - app.session_picker.scroll_offset = - app.session_picker.scroll_offset.min(app.session_picker.selected); -} - fn maybe_open_startup_session_picker(app: &mut App) { if app.update_prompt.is_some() { return; @@ -724,9 +701,7 @@ fn maybe_open_startup_session_picker(app: &mut App) { return; } - app.session_picker.selected = app.session_picker.selected.min(session_count - 1); - app.session_picker.scroll_offset = 0; - view::set_fullscreen_view(app, FullscreenView::SessionPicker); + super::super::session_picker::open(app); } #[cfg(test)] diff --git a/src/app/events/session_reset.rs b/src/app/events/session_reset.rs index 23f2463b..2cf8eaf8 100644 --- a/src/app/events/session_reset.rs +++ b/src/app/events/session_reset.rs @@ -193,6 +193,14 @@ fn append_resume_user_message_chunk(app: &mut App, chunk: &model::ContentChunk) if let Some(last) = app.transcript.messages.last_mut() && matches!(last.role, MessageRole::User) + && chunk.source_message_uuid.as_deref().is_none_or(|uuid| { + last.blocks.iter().all(|block| match block { + MessageBlock::Text(text) => { + text.source_message_uuids.is_empty() || text.has_source_message_uuid(uuid) + } + _ => true, + }) + }) { if let Some(MessageBlock::Text(block)) = last.blocks.last_mut() { block.text.push_str(&text.text); diff --git a/src/app/events/tests/client_events.rs b/src/app/events/tests/client_events.rs index ff79565d..9db093c4 100644 --- a/src/app/events/tests/client_events.rs +++ b/src/app/events/tests/client_events.rs @@ -1874,3 +1874,126 @@ fn title_update_clears_the_matching_listed_title() { assert_eq!(app.recent_sessions[0].custom_title, None); assert_eq!(app.recent_sessions[0].summary, "Independent summary"); } + +#[test] +fn filtered_picker_refresh_preserves_identity_and_handles_disappearing_results() { + let (mut app, mut rx) = app_with_bridge_connection(); + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![ + listed_session("hidden", "Other"), + listed_session("a", "Feature alpha"), + listed_session("b", "Feature beta"), + listed_session("c", "Feature gamma"), + ], + }, + ); + crate::app::session_picker::open(&mut app); + handle_terminal_event(&mut app, Event::Paste("feature".into())); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); + assert_eq!( + crate::app::session_picker::selected_session(&app).expect("selected beta").session_id, + "b" + ); + + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![ + listed_session("c", "Feature gamma"), + listed_session("hidden", "Other"), + listed_session("a", "Feature alpha"), + listed_session("b", "Feature beta renamed"), + ], + }, + ); + assert_eq!(app.session_picker.selected, 2); + assert_eq!( + crate::app::session_picker::selected_session(&app) + .expect("same beta after refresh") + .session_id, + "b" + ); + assert_eq!(app.session_picker.query, "feature"); + + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![ + listed_session("hidden", "Other"), + listed_session("a", "Feature alpha"), + listed_session("c", "Feature gamma"), + ], + }, + ); + assert_eq!( + crate::app::session_picker::selected_session(&app) + .expect("previous result at end") + .session_id, + "c" + ); + handle_client_event( + &mut app, + ClientEvent::SessionsListed { sessions: vec![listed_session("hidden", "Other")] }, + ); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); + assert_eq!(app.session_picker.selected, 0); + assert_eq!(app.session_picker.scroll_offset, 0); + assert!(rx.try_recv().is_err(), "no nonmatching session may be resumed"); + + handle_client_event( + &mut app, + ClientEvent::SessionsListed { sessions: vec![listed_session("new", "Feature new")] }, + ); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); + assert!(matches!(rx.try_recv().expect("resume new match").command, + crate::agent::wire::BridgeCommand::ResumeSession { session_id, .. } if session_id == "new")); +} + +#[test] +fn removing_the_inspected_session_returns_to_the_session_list() { + let (mut app, mut rx) = app_with_bridge_connection(); + handle_client_event( + &mut app, + ClientEvent::SessionsListed { sessions: vec![listed_session("old", "Old session")] }, + ); + crate::app::session_picker::open(&mut app); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))); + rx.try_recv().expect("history request"); + handle_client_event(&mut app, ClientEvent::SessionsListed { sessions: Vec::new() }); + assert!(app.session_picker.turn_session_id.is_none()); + assert!(!app.sdk_inventory.rewind_targets_in_flight); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn live_title_update_preserves_filtered_selection_when_an_earlier_result_stops_matching() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.session_runtime.session_id = Some(model::SessionId::new("active")); + handle_client_event( + &mut app, + ClientEvent::SessionsListed { + sessions: vec![ + listed_session("active", "Feature active"), + listed_session("chosen", "Feature chosen"), + ], + }, + ); + crate::app::session_picker::open(&mut app); + handle_terminal_event(&mut app, Event::Paste("feature".into())); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); + handle_client_event( + &mut app, + ClientEvent::SessionUpdate { + session_id: "active".into(), + update: model::SessionUpdate::SessionTitleUpdate(Some("Unrelated title".into())), + }, + ); + assert_eq!(app.session_picker.selected, 0); + handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); + assert!(matches!(rx.try_recv().expect("resume selected match").command, + crate::agent::wire::BridgeCommand::ResumeSession { session_id, .. } if session_id == "chosen")); +} diff --git a/src/app/events/tests/mod.rs b/src/app/events/tests/mod.rs index 963d9289..83b87078 100644 --- a/src/app/events/tests/mod.rs +++ b/src/app/events/tests/mod.rs @@ -1287,6 +1287,92 @@ fn startup_resume_history_renders_from_canonical_messages() { assert!(!session_overview_has_welcome(&app)); } +#[test] +fn resume_history_preserves_user_message_boundaries_and_joins_only_same_source_chunks() { + for startup in [true, false] { + let mut app = make_test_app(); + let history_updates = [ + ("rename", "/rename "), + ("rename", "review-444"), + ("color", "/color blue"), + ("prompt", "Explain literal XML"), + ] + .into_iter() + .map(|(uuid, text)| { + model::SessionUpdate::UserMessageChunk( + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new(text))) + .source_message_uuid(Some(uuid.to_owned())), + ) + }) + .collect(); + let event = if startup { + ClientEvent::Connected { + session_id: model::SessionId::new("resumed"), + cwd: "/resumed".into(), + current_model: test_current_model("new-model"), + available_models: Vec::new(), + mode: None, + fast_mode_state: model::FastModeState::Off, + fast_mode_disabled_reason: None, + ultracode: None, + history_updates, + } + } else { + ClientEvent::SessionReplaced { + session_id: model::SessionId::new("resumed"), + cwd: "/resumed".into(), + current_model: test_current_model("new-model"), + available_models: Vec::new(), + mode: None, + fast_mode_state: model::FastModeState::Off, + fast_mode_disabled_reason: None, + ultracode: None, + history_updates, + restored_input: None, + } + }; + handle_client_event(&mut app, event); + let users = app + .transcript + .messages + .iter() + .filter(|message| matches!(message.role, MessageRole::User)) + .map(|message| match message.blocks.as_slice() { + [MessageBlock::Text(text)] => { + (text.source_message_uuids.clone(), text.text.clone()) + } + _ => panic!("expected one complete user text block"), + }) + .collect::>(); + assert_eq!( + users, + vec![ + (vec!["rename".to_owned()], "/rename review-444".to_owned()), + (vec!["color".to_owned()], "/color blue".to_owned()), + (vec!["prompt".to_owned()], "Explain literal XML".to_owned()), + ] + ); + let rows = crate::ui::inline_chat_rows::serialize_live_rows_with_boundaries_excluding( + &mut app, + 80, + &std::collections::BTreeSet::new(), + ); + let text = rows + .rows() + .iter() + .map(|line| line.to_string().trim_end().to_owned()) + .collect::>(); + for message in + ["/rename review-444", "/color blue", "Explain literal XML"] + { + assert!( + text.windows(2).any(|rows| rows == ["User", message]), + "separate user heading and text for {message}, startup={startup}" + ); + } + } +} + #[test] fn startup_resume_history_allows_immediate_prompt_submit() { let (mut app, mut rx) = app_with_bridge_connection(); @@ -3669,14 +3755,16 @@ fn trusted_view_ignores_paste_events() { } #[test] -fn session_picker_ignores_paste_events() { +fn session_picker_paste_edits_search_without_changing_the_chat_draft() { let mut app = make_test_app(); app.surface_mode = SurfaceMode::Fullscreen(FullscreenView::SessionPicker); - handle_terminal_event(&mut app, Event::Paste("blocked".into())); + app.input.set_text("unfinished question"); + handle_terminal_event(&mut app, Event::Paste("session-id".into())); assert!(app.paste.pending_text.is_empty()); - assert!(app.input.is_empty()); + assert_eq!(app.input.text(), "unfinished question"); + assert_eq!(app.session_picker.query, "session-id"); } #[test] diff --git a/src/app/events/tests/slash_input.rs b/src/app/events/tests/slash_input.rs index 24876546..ceafcb3b 100644 --- a/src/app/events/tests/slash_input.rs +++ b/src/app/events/tests/slash_input.rs @@ -320,7 +320,7 @@ fn invalid_arguments_reach_usage_feedback_without_dispatch_or_input_capture() { } #[test] -fn optional_resume_opens_the_picker_without_selecting_a_suggested_session() { +fn resume_opens_the_picker_without_selecting_a_session() { for input in ["/resume", "/resume "] { let (mut app, mut rx) = app_with_bridge_connection(); app.recent_sessions = vec![RecentSessionInfo { @@ -343,9 +343,9 @@ fn optional_resume_opens_the_picker_without_selecting_a_suggested_session() { let (mut app, mut rx) = app_with_bridge_connection(); draft(&mut app, "/resume manually-supplied-id"); enter(&mut app); - assert!(matches!(commands(&mut rx).as_slice(), - [BridgeCommand::ResumeSession { session_id, .. }] if session_id == "manually-supplied-id" - )); + assert!(commands(&mut rx).is_empty()); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert!(canonical_messages_contain_text(&app, "Usage: /resume")); } #[test] @@ -651,39 +651,29 @@ fn optional_sdk_command_completion_and_named_execution_preserve_exact_input() { } #[test] -fn optional_resume_completion_opens_picker_and_explicit_tab_can_resume_a_suggestion() { - for explicit_id in [false, true] { - let (mut app, mut rx) = app_with_bridge_connection(); - app.recent_sessions = vec![RecentSessionInfo { - session_id: "recent-session".into(), - summary: "Recent".into(), - last_modified_ms: 1, - file_size_bytes: 1, - cwd: None, - git_branch: None, - custom_title: None, - first_prompt: None, - }]; - draft(&mut app, "/resu"); - key(&mut app, KeyCode::Tab); - assert_eq!(app.input.text(), "/resume "); - assert!(app.slash.visible().is_none()); - assert!(commands(&mut rx).is_empty()); - if explicit_id { - key(&mut app, KeyCode::Tab); - assert_eq!(app.input.text(), "/resume recent-session "); - assert!(app.pending_submit.is_none()); - } - enter(&mut app); - if explicit_id { - assert!( - matches!(commands(&mut rx).as_slice(), [BridgeCommand::ResumeSession { session_id, .. }] if session_id == "recent-session") - ); - } else { - assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); - assert!(commands(&mut rx).is_empty()); - } - } +fn resume_command_completion_never_inserts_a_session_id() { + let (mut app, mut rx) = app_with_bridge_connection(); + app.recent_sessions = vec![RecentSessionInfo { + session_id: "recent-session".into(), + summary: "Recent".into(), + last_modified_ms: 1, + file_size_bytes: 1, + cwd: None, + git_branch: None, + custom_title: None, + first_prompt: None, + }]; + draft(&mut app, "/resu"); + key(&mut app, KeyCode::Tab); + assert_eq!(app.input.text(), "/resume "); + assert!(app.slash.visible().is_none()); + key(&mut app, KeyCode::Tab); + assert_eq!(app.input.text().trim(), "/resume"); + assert!(app.slash.visible().is_none()); + assert!(commands(&mut rx).is_empty()); + enter(&mut app); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); + assert!(commands(&mut rx).is_empty()); } #[tokio::test(flavor = "current_thread")] @@ -824,3 +814,55 @@ fn custom_space_and_tab_bindings_take_precedence_over_default_completion() { assert!(commands(&mut rx).is_empty()); } } + +#[test] +fn resume_window_searches_names_ids_and_branches_and_dispatches_only_the_selected_session() { + for query in ["FEATURE", "MATCH-", "LEASE/"] { + let (mut app, mut rx) = app_with_bridge_connection(); + app.recent_sessions = [ + ("hidden", "Other conversation", None), + ("empty-branch", "Another conversation", Some("")), + ("match-a", "Feature alpha", Some("release/alpha")), + ("match-b", "Feature beta", Some("Release/beta")), + ] + .into_iter() + .map(|(id, title, branch)| RecentSessionInfo { + session_id: id.into(), + summary: title.into(), + last_modified_ms: 0, + file_size_bytes: 0, + cwd: None, + git_branch: branch.map(str::to_owned), + custom_title: Some(title.into()), + first_prompt: None, + }) + .collect(); + draft(&mut app, "/resume"); + enter(&mut app); + type_text(&mut app, query); + key(&mut app, KeyCode::Down); + assert!(commands(&mut rx).is_empty(), "{query}"); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); + enter(&mut app); + assert!( + matches!(commands(&mut rx).as_slice(), + [BridgeCommand::ResumeSession { session_id, .. }] if session_id == "match-b"), + "{query}" + ); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + } +} + +#[test] +fn cancelling_resume_search_preserves_the_current_session_and_draft() { + let (mut app, mut rx) = app_with_bridge_connection(); + let session_id = app.session_runtime.session_id.clone(); + crate::app::session_picker::open(&mut app); + app.input.set_text("unfinished question"); + handle_terminal_event(&mut app, Event::Paste("missing-session".into())); + key(&mut app, KeyCode::Esc); + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert_eq!(app.session_runtime.session_id, session_id); + assert_eq!(app.input.text(), "unfinished question"); + assert!(commands(&mut rx).is_empty()); +} diff --git a/src/app/input_submit.rs b/src/app/input_submit.rs index bef662f6..a256a81b 100644 --- a/src/app/input_submit.rs +++ b/src/app/input_submit.rs @@ -1238,7 +1238,7 @@ mod tests { let [MessageBlock::Notice(notice)] = app.transcript.messages[0].blocks.as_slice() else { panic!("expected inline usage notice"); }; - assert_eq!(notice.text.text, "Usage: /resume [session_id]"); + assert_eq!(notice.text.text, "Usage: /resume"); assert!(!notice.text.text.contains("between agent turns")); assert!(rx.try_recv().is_err()); } diff --git a/src/app/session_picker.rs b/src/app/session_picker.rs index 43758f27..bd1d407c 100644 --- a/src/app/session_picker.rs +++ b/src/app/session_picker.rs @@ -4,13 +4,64 @@ use super::connect::{begin_resume_session, begin_resume_session_at}; use super::events::push_system_message_with_severity; use super::view; -use super::{App, AppStatus, SystemSeverity}; +use super::{App, AppStatus, RecentSessionInfo, SessionPickerState, SystemSeverity}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -pub(crate) const MAX_PICKER_SESSIONS: usize = 10; +pub(crate) fn open(app: &mut App) { + app.session_picker = SessionPickerState::default(); + let current = app.session_runtime.session_id.as_ref().map(ToString::to_string); + reconcile_selection(app, current.as_deref()); + view::set_fullscreen_view(app, super::FullscreenView::SessionPicker); +} + +pub(crate) fn session_title(session: &RecentSessionInfo) -> &str { + session + .custom_title + .as_deref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| session.first_prompt.as_deref().filter(|value| !value.trim().is_empty())) + .or_else(|| (!session.summary.trim().is_empty()).then_some(session.summary.as_str())) + .unwrap_or(&session.session_id) +} + +pub(crate) fn picker_sessions(app: &App) -> impl Iterator { + let query = app.session_picker.query.trim().to_lowercase(); + app.recent_sessions.iter().filter(move |session| { + query.is_empty() + || session_title(session).to_lowercase().contains(&query) + || session.session_id.to_lowercase().contains(&query) + || session + .git_branch + .as_deref() + .is_some_and(|branch| branch.to_lowercase().contains(&query)) + }) +} + +pub(crate) fn selected_session(app: &App) -> Option<&RecentSessionInfo> { + picker_sessions(app).nth(app.session_picker.selected) +} + +pub(crate) fn reconcile_selection(app: &mut App, previous_id: Option<&str>) { + let selected = previous_id + .and_then(|id| picker_sessions(app).position(|session| session.session_id == id)) + .unwrap_or_else(|| { + app.session_picker.selected.min(picker_session_count(app).saturating_sub(1)) + }); + app.session_picker.selected = selected; + app.session_picker.scroll_offset = app.session_picker.scroll_offset.min(selected); + + if app + .session_picker + .turn_session_id + .as_ref() + .is_some_and(|id| !app.recent_sessions.iter().any(|session| &session.session_id == id)) + { + close_turn_selection(app); + } +} pub(crate) fn picker_session_count(app: &App) -> usize { - app.recent_sessions.len().min(MAX_PICKER_SESSIONS) + picker_sessions(app).count() } pub(crate) fn startup_picker_is_loading(app: &App) -> bool { @@ -21,8 +72,24 @@ pub(crate) fn picker_turn_count(app: &App) -> usize { app.sdk_inventory.rewind_targets.len() } +fn edit_query(app: &mut App, edit: impl FnOnce(&mut String)) { + let previous = selected_session(app).map(|session| session.session_id.clone()); + edit(&mut app.session_picker.query); + app.session_picker.selected = 0; + app.session_picker.scroll_offset = 0; + reconcile_selection(app, previous.as_deref()); +} + +pub(crate) fn paste_search(app: &mut App, text: &str) -> bool { + if startup_picker_is_loading(app) || app.session_picker.turn_session_id.is_some() { + return false; + } + edit_query(app, |query| query.extend(text.chars().filter(|ch| !ch.is_control()))); + true +} + pub fn handle_key(app: &mut App, key: KeyEvent) { - if is_ctrl(key, 'q') { + if super::keys::is_ctrl_char_shortcut(key, 'q') { app.request_shutdown(); return; } @@ -36,21 +103,26 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } - let session_count = picker_session_count(app); - if session_count == 0 { - if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { - app.startup.resolve_session_picker(); - app.startup.complete_launch(); - view::set_chat_surface(app); - } + if matches!(key.code, KeyCode::Char(ch) if !ch.is_control()) + && super::keys::is_printable_text_modifiers(key.modifiers) + || matches!((key.code, key.modifiers), (KeyCode::Backspace, KeyModifiers::NONE)) + { + edit_query(app, |query| match key.code { + KeyCode::Char(ch) => query.push(ch), + KeyCode::Backspace => { + query.pop(); + } + _ => {} + }); return; } + let session_count = picker_session_count(app); match (key.code, key.modifiers) { - (KeyCode::Up | KeyCode::Char('k'), KeyModifiers::NONE) => { + (KeyCode::Up, KeyModifiers::NONE) => { app.session_picker.selected = app.session_picker.selected.saturating_sub(1); } - (KeyCode::Down | KeyCode::Char('j'), KeyModifiers::NONE) => { + (KeyCode::Down, KeyModifiers::NONE) => { app.session_picker.selected = (app.session_picker.selected + 1).min(session_count.saturating_sub(1)); } @@ -68,6 +140,24 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { } fn handle_turn_key(app: &mut App, key: KeyEvent) { + if app.session_picker.turn_reading { + match (key.code, key.modifiers) { + (KeyCode::Esc | KeyCode::Left, KeyModifiers::NONE) => { + app.session_picker.turn_reading = false; + } + (KeyCode::Up, KeyModifiers::NONE) => { + app.session_picker.turn_read_scroll = + app.session_picker.turn_read_scroll.saturating_sub(1); + } + (KeyCode::Down, KeyModifiers::NONE) => { + app.session_picker.turn_read_scroll = + app.session_picker.turn_read_scroll.saturating_add(1); + } + (KeyCode::Enter, KeyModifiers::NONE) => activate_turn_selection(app), + _ => {} + } + return; + } if matches!((key.code, key.modifiers), (KeyCode::Esc | KeyCode::Left, KeyModifiers::NONE)) { close_turn_selection(app); return; @@ -90,18 +180,16 @@ fn handle_turn_key(app: &mut App, key: KeyEvent) { (KeyCode::Home, _) => app.session_picker.turn_selected = 0, (KeyCode::End, _) => app.session_picker.turn_selected = turn_count.saturating_sub(1), (KeyCode::Enter, KeyModifiers::NONE) => activate_turn_selection(app), + (KeyCode::Right, KeyModifiers::NONE) => { + app.session_picker.turn_reading = true; + app.session_picker.turn_read_scroll = 0; + } _ => {} } } fn open_turn_selection(app: &mut App) { - let Some(session_id) = app - .recent_sessions - .iter() - .take(MAX_PICKER_SESSIONS) - .nth(app.session_picker.selected) - .map(|session| session.session_id.clone()) - else { + let Some(session_id) = selected_session(app).map(|session| session.session_id.clone()) else { return; }; let Some(conn) = app.session_runtime.conn.clone() else { @@ -109,8 +197,7 @@ fn open_turn_selection(app: &mut App) { }; app.session_picker.turn_session_id = Some(session_id.clone()); - app.session_picker.turn_selected = 0; - app.session_picker.turn_scroll_offset = 0; + app.session_picker.reset_turn_navigation(); app.sdk_inventory.rewind_targets.clear(); app.sdk_inventory.rewind_targets_session_id = None; app.sdk_inventory.rewind_targets_error = None; @@ -129,8 +216,7 @@ fn open_turn_selection(app: &mut App) { fn close_turn_selection(app: &mut App) { app.session_picker.turn_session_id = None; - app.session_picker.turn_selected = 0; - app.session_picker.turn_scroll_offset = 0; + app.session_picker.reset_turn_navigation(); app.sdk_inventory.clear_rewind_targets(); } @@ -166,9 +252,7 @@ fn activate_turn_selection(app: &mut App) { } fn activate_selection(app: &mut App) { - let Some(session) = - app.recent_sessions.iter().take(MAX_PICKER_SESSIONS).nth(app.session_picker.selected) - else { + let Some(session) = selected_session(app) else { return; }; let session_id = session.session_id.clone(); @@ -197,10 +281,6 @@ fn activate_selection(app: &mut App) { view::set_chat_surface(app); } -fn is_ctrl(key: KeyEvent, ch: char) -> bool { - matches!(key.code, KeyCode::Char(c) if c == ch) && key.modifiers == KeyModifiers::CONTROL -} - #[cfg(test)] mod tests { use super::handle_key; @@ -292,7 +372,7 @@ mod tests { } #[test] - fn space_is_ignored_to_avoid_accidental_destructive_navigation() { + fn space_searches_without_opening_message_navigation() { let mut app = picker_app(); let (connection, mut rx) = AgentConnection::test_channel(); app.session_runtime.conn = Some(Rc::new(connection)); @@ -302,6 +382,108 @@ mod tests { assert!(app.session_picker.turn_session_id.is_none()); assert!(!app.sdk_inventory.rewind_targets_in_flight); assert!(rx.try_recv().is_err()); + assert_eq!(app.session_picker.query, " "); + } + + #[test] + fn search_matches_visible_title_and_id_without_losing_the_selected_session() { + let mut app = picker_app(); + app.recent_sessions[0].custom_title = Some("Other".into()); + app.recent_sessions[1].custom_title = Some("Überblick".into()); + app.session_picker.selected = 1; + + for ch in "ÜBER".chars() { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!( + super::picker_sessions(&app) + .map(|session| session.session_id.as_str()) + .collect::>(), + vec!["session-2"] + ); + assert_eq!(app.session_picker.selected, 0); + + for _ in 0..4 { + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + assert_eq!(app.session_picker.selected, 1); + assert!(super::paste_search(&mut app, "SESSION-2")); + assert_eq!( + super::selected_session(&app).map(|session| session.session_id.as_str()), + Some("session-2") + ); + } + + #[test] + fn no_matches_cannot_resume_or_fork_and_backspace_restores_results() { + let mut app = picker_app(); + let (connection, mut rx) = AgentConnection::test_channel(); + app.session_runtime.conn = Some(Rc::new(connection)); + + handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + for code in [KeyCode::Down, KeyCode::End, KeyCode::Enter, KeyCode::Right] { + handle_key(&mut app, KeyEvent::new(code, KeyModifiers::NONE)); + } + assert!(rx.try_recv().is_err()); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); + assert!(app.session_picker.turn_session_id.is_none()); + handle_key(&mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(rx.try_recv().expect("resume after clearing search").command, + BridgeCommand::ResumeSession { session_id, .. } if session_id == "session-1")); + } + + #[test] + fn filtered_message_navigation_uses_matching_session_and_restores_search() { + let mut app = picker_app(); + let (connection, mut rx) = AgentConnection::test_channel(); + app.session_runtime.conn = Some(Rc::new(connection)); + assert!(super::paste_search(&mut app, "Second")); + handle_key(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert!(matches!(rx.try_recv().expect("history request").command, + BridgeCommand::GetRewindTargets { session_id } if session_id == "session-2")); + assert!(!super::paste_search(&mut app, "ignored")); + handle_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(app.session_picker.query, "Second"); + assert_eq!( + super::selected_session(&app).map(|session| session.session_id.as_str()), + Some("session-2") + ); + } + + #[test] + fn search_follows_the_same_title_fallbacks_as_the_display() { + for (custom, prompt, summary, query) in [ + (Some("Named"), Some("Prompt"), "Summary", "named"), + (Some(" "), Some("Prompt"), "Summary", "prompt"), + (None, Some(" "), "Summary", "summary"), + (None, None, " ", "session-1"), + ] { + let mut app = picker_app(); + app.recent_sessions.truncate(1); + app.recent_sessions[0].custom_title = custom.map(str::to_owned); + app.recent_sessions[0].first_prompt = prompt.map(str::to_owned); + app.recent_sessions[0].summary = summary.into(); + assert!(super::paste_search(&mut app, query)); + assert_eq!( + super::selected_session(&app).map(|session| session.session_id.as_str()), + Some("session-1") + ); + } + } + + #[test] + fn german_keyboard_text_is_searchable_without_triggering_shortcuts() { + let mut app = picker_app(); + for (ch, modifiers) in [ + ('j', KeyModifiers::NONE), + ('k', KeyModifiers::NONE), + ('@', KeyModifiers::CONTROL | KeyModifiers::ALT), + ] { + handle_key(&mut app, KeyEvent::new(KeyCode::Char(ch), modifiers)); + } + assert_eq!(app.session_picker.query, "jk@"); + assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); } #[test] @@ -320,36 +502,51 @@ mod tests { } #[test] - fn enter_on_turn_sends_resume_at_without_changing_plain_resume_command() { - let mut app = picker_app(); - let (connection, mut rx) = AgentConnection::test_channel(); - app.session_runtime.conn = Some(Rc::new(connection)); - app.session_picker.turn_session_id = Some("session-1".to_owned()); - app.sdk_inventory.rewind_targets = vec![crate::agent::model::RewindTarget { - uuid: "user-2".to_owned(), - first_text: "second prompt".to_owned(), - input_text: "second prompt".to_owned(), - index: 3, - previous_assistant_uuid: Some("assistant-1".to_owned()), - resume_anchor_uuid: Some("assistant-1".to_owned()), - }]; - - handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.surface_mode, SurfaceMode::Chat); - assert_eq!(app.pending_session_resume_id(), Some("session-1")); - let operation_id = - app.pending_resume_at_operation_id().expect("pending resume-at operation"); - let envelope = rx.try_recv().expect("resume-at command"); - assert_eq!(envelope.request_id.as_deref(), Some(operation_id)); - assert!(matches!( - envelope.command, - BridgeCommand::ResumeSessionAt { - session_id, - target_user_message_id, - .. - } if session_id == "session-1" && target_user_message_id == "user-2" - )); + fn enter_on_message_or_reader_forks_before_the_selected_message() { + for reading in [false, true] { + let mut app = picker_app(); + let (connection, mut rx) = AgentConnection::test_channel(); + app.session_runtime.conn = Some(Rc::new(connection)); + app.session_picker.turn_session_id = Some("session-1".to_owned()); + app.sdk_inventory.rewind_targets = vec![crate::agent::model::RewindTarget { + uuid: "user-2".to_owned(), + first_text: "second prompt".to_owned(), + input_text: "second prompt".to_owned(), + index: 3, + previous_assistant_uuid: Some("assistant-1".to_owned()), + resume_anchor_uuid: Some("assistant-1".to_owned()), + }]; + + app.sdk_inventory.rewind_targets.insert( + 0, + crate::agent::model::RewindTarget { + uuid: "other-message".into(), + first_text: "Other prompt".into(), + input_text: "Other prompt".into(), + index: 5, + previous_assistant_uuid: None, + resume_anchor_uuid: None, + }, + ); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.session_picker.turn_reading = reading; + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.surface_mode, SurfaceMode::Chat); + assert_eq!(app.pending_session_resume_id(), Some("session-1")); + let operation_id = + app.pending_resume_at_operation_id().expect("pending resume-at operation"); + let envelope = rx.try_recv().expect("resume-at command"); + assert_eq!(envelope.request_id.as_deref(), Some(operation_id)); + assert!(matches!( + envelope.command, + BridgeCommand::ResumeSessionAt { + session_id, + target_user_message_id, + .. + } if session_id == "session-1" && target_user_message_id == "user-2" + )); + } } #[test] diff --git a/src/app/slash/candidates.rs b/src/app/slash/candidates.rs index bb1fa8c2..b212c4af 100644 --- a/src/app/slash/candidates.rs +++ b/src/app/slash/candidates.rs @@ -9,7 +9,6 @@ use super::{ }; use crate::app::App; use crate::app::dialog::DialogState; -use std::time::{SystemTime, UNIX_EPOCH}; const MAX_ARGUMENT_CANDIDATES: usize = 50; @@ -153,7 +152,6 @@ fn is_builtin_variable_input_command(command_name: &str) -> bool { | AppSlashCommand::Effort | AppSlashCommand::Mode | AppSlashCommand::Model - | AppSlashCommand::Resume | AppSlashCommand::Rewind ) }) @@ -318,48 +316,6 @@ fn agent_argument_candidates(app: &App) -> Vec { candidates } -fn now_epoch_seconds() -> i64 { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), - Err(_) => 0, - } -} - -fn format_relative_age(epoch_seconds: i64) -> String { - let now_seconds = now_epoch_seconds(); - let delta_seconds = if now_seconds >= epoch_seconds { - now_seconds - epoch_seconds - } else { - epoch_seconds - now_seconds - }; - - if delta_seconds < 5 * 60 { - return "<5m".to_owned(); - } - if delta_seconds < 60 * 60 { - return format!("{}m", delta_seconds / 60); - } - if delta_seconds < 24 * 60 * 60 { - return format!("{}h", delta_seconds / (60 * 60)); - } - - let total_hours = delta_seconds / (60 * 60); - let days = total_hours / 24; - let hours = total_hours % 24; - format!("{days}d {hours}h") -} - -fn session_age_label(last_modified_ms: Option) -> String { - let Some(last_modified_ms) = last_modified_ms else { - return "--".to_owned(); - }; - let epoch = i64::try_from(last_modified_ms / 1_000).ok(); - let Some(epoch) = epoch else { - return "--".to_owned(); - }; - format_relative_age(epoch) -} - pub(super) fn argument_candidates( app: &App, command_name: &str, @@ -373,20 +329,6 @@ pub(super) fn argument_candidates( "/docs" | "/ultracode" | "/thinking" | "/fast" => static_argument_candidates(command_name), "/agent" => agent_argument_candidates(app), "/effort" => effort_argument_candidates(app), - "/resume" => app - .recent_sessions - .iter() - .map(|session| { - let summary = session.summary.trim(); - let summary = if summary.is_empty() { "(no summary)" } else { summary }; - let age = session_age_label(Some(session.last_modified_ms)); - SlashCandidate { - insert_value: session.session_id.clone(), - primary: format!("{age} - {summary}"), - secondary: Some(session.session_id.clone()), - } - }) - .collect(), "/rewind" => { if arg_index == 1 { return rewind_restore_mode_candidates(); diff --git a/src/app/slash/catalog.rs b/src/app/slash/catalog.rs index e7bb64a6..df6f1ac8 100644 --- a/src/app/slash/catalog.rs +++ b/src/app/slash/catalog.rs @@ -306,9 +306,9 @@ pub(crate) const APP_SLASH_COMMANDS: &[AppSlashCommandSpec] = &[ AppSlashCommandSpec { command: AppSlashCommand::Resume, name: "/resume", - usage: "Usage: /resume [session_id]", - short_description: "Resume a session by ID", - long_description: "Open the session picker, or resume a manually supplied session ID.", + usage: "Usage: /resume", + short_description: "Open the session picker", + long_description: "Search and select a session in the fullscreen resume picker.", args: NO_ARGS, }, AppSlashCommandSpec { @@ -406,7 +406,7 @@ impl AppSlashCommand { SubmissionClass::Invalid } } - Self::Compact | Self::Login | Self::Logout | Self::NewSession => { + Self::Compact | Self::Login | Self::Logout | Self::NewSession | Self::Resume => { if args.is_empty() { SubmissionClass::TurnExclusive } else { @@ -446,13 +446,6 @@ impl AppSlashCommand { SubmissionClass::Invalid } } - Self::Resume => { - if args.len() <= 1 { - SubmissionClass::TurnExclusive - } else { - SubmissionClass::Invalid - } - } Self::Rewind => { if matches!(args, [_, "both" | "conversation" | "code"]) { SubmissionClass::TurnExclusive diff --git a/src/app/slash/executors.rs b/src/app/slash/executors.rs index 34748114..a6199b70 100644 --- a/src/app/slash/executors.rs +++ b/src/app/slash/executors.rs @@ -9,13 +9,9 @@ use super::{ }; use crate::agent::events::ClientEvent; use crate::agent::types::RewindRestoreMode; -use crate::app::connect::{ - SessionStartReason, begin_resume_session, begin_rewind, start_new_session, -}; +use crate::app::connect::{SessionStartReason, begin_rewind, start_new_session}; use crate::app::events::push_submission_feedback; -use crate::app::{ - App, AppStatus, FullscreenView, ReleaseReason, SessionPickerState, SystemSeverity, view, -}; +use crate::app::{App, AppStatus, ReleaseReason, SystemSeverity}; use std::fmt::Write as _; use std::path::Path; use std::process::{ExitStatus, Stdio}; @@ -73,7 +69,7 @@ pub(crate) fn try_handle_submission(app: &mut App, submission: &ResolvedSubmissi AppSlashCommand::Mode => handle_mode_submit(app, &args), AppSlashCommand::Model => handle_model_submit(app, &args), AppSlashCommand::NewSession => handle_new_session_submit(app), - AppSlashCommand::Resume => handle_resume_submit(app, &args), + AppSlashCommand::Resume => handle_resume_submit(app), AppSlashCommand::Rewind => handle_rewind_submit(app, &args), } } @@ -763,44 +759,12 @@ fn handle_new_session_submit(app: &mut App) -> bool { true } -fn handle_resume_submit(app: &mut App, args: &[&str]) -> bool { - if args.is_empty() { - push_user_message(app, "/resume"); - if require_connection(app, "Cannot open resume picker: not connected yet.").is_none() { - return true; - } - let current_session_id = - app.session_runtime.session_id.as_ref().map(crate::agent::model::SessionId::as_str); - let selected = current_session_id - .and_then(|session_id| { - app.recent_sessions - .iter() - .take(crate::app::session_picker::MAX_PICKER_SESSIONS) - .position(|session| session.session_id == session_id) - }) - .unwrap_or(0); - app.session_picker = SessionPickerState { selected, ..SessionPickerState::default() }; - view::set_fullscreen_view(app, FullscreenView::SessionPicker); +fn handle_resume_submit(app: &mut App) -> bool { + push_user_message(app, "/resume"); + if require_connection(app, "Cannot open resume picker: not connected yet.").is_none() { return true; } - let [session_id_arg] = args else { - unreachable!("validated /resume arguments must contain at most one value"); - }; - let session_id = *session_id_arg; - - push_user_message(app, format!("/resume {session_id}")); - let Some(conn) = require_connection(app, "Cannot resume session: not connected yet.") else { - return true; - }; - - set_command_pending(app, &format!("Resuming session {session_id}..."), None); - let session_id = session_id.to_owned(); - if let Err(e) = begin_resume_session(app, &conn, session_id) { - crate::app::events::handle_local_slash_command_error( - app, - &format!("Failed to run /resume: {e}"), - ); - } + crate::app::session_picker::open(app); true } diff --git a/src/app/slash/navigation.rs b/src/app/slash/navigation.rs index 4d96f5b2..6bcd5437 100644 --- a/src/app/slash/navigation.rs +++ b/src/app/slash/navigation.rs @@ -182,8 +182,8 @@ pub fn prepare_submit(app: &mut App) -> bool { return true; } - // An empty argument token is a suggestion, not user input. In particular, - // /resume must open its picker instead of silently selecting a recent ID. + // An empty argument token is a suggestion, not user input. Optional + // arguments must not silently consume an advertised value on Enter. let submission = ResolvedSubmission::resolve(app.input.text()); if matches!(slash.context, SlashContext::Argument { .. }) && slash.query.is_empty() diff --git a/src/app/slash/tests.rs b/src/app/slash/tests.rs index b076f459..a9e33f6d 100644 --- a/src/app/slash/tests.rs +++ b/src/app/slash/tests.rs @@ -1175,16 +1175,16 @@ fn variable_command_argument_mode_stays_active_without_matches() { #[test] fn confirm_selection_replaces_only_active_argument_token() { let mut app = App::test_default(); - app.input.set_text("/resume old-id trailing"); - let _ = app.input.set_cursor(0, "/resume old-id".chars().count()); + app.input.set_text("/model old-id trailing"); + let _ = app.input.set_cursor(0, "/model old-id".chars().count()); app.slash.show(SlashState { trigger_row: 0, - trigger_col: 8, + trigger_col: 7, query: "old-id".to_owned(), context: SlashContext::Argument { - command: "/resume".to_owned(), + command: "/model".to_owned(), arg_index: 0, - token_range: (8, 14), + token_range: (7, 13), }, candidates: vec![SlashCandidate { insert_value: "new-id".to_owned(), @@ -1197,7 +1197,7 @@ fn confirm_selection_replaces_only_active_argument_token() { confirm_selection(&mut app); - assert_eq!(app.input.text(), "/resume new-id trailing"); + assert_eq!(app.input.text(), "/model new-id trailing"); } #[tokio::test(flavor = "current_thread")] @@ -1243,6 +1243,7 @@ fn resume_without_id_opens_fullscreen_picker() { crate::app::SurfaceMode::Fullscreen(crate::app::FullscreenView::SessionPicker) )); let last = app.transcript.messages.last().expect("resume command message"); + assert!(matches!(last.role, MessageRole::User)); let MessageBlock::Text(block) = last.blocks.first().expect("text block") else { panic!("expected text block"); }; @@ -1293,7 +1294,7 @@ fn resume_with_extra_args_returns_usage() { let Some(MessageBlock::Text(block)) = last.blocks.first() else { panic!("expected text block"); }; - assert_eq!(block.text, "Usage: /resume [session_id]"); + assert_eq!(block.text, "Usage: /resume"); } #[test] @@ -1370,42 +1371,6 @@ fn rewind_with_cached_target_sends_bridge_command() { assert_eq!(restore_mode, crate::agent::types::RewindRestoreMode::Conversation); } -#[test] -fn resume_command_is_rendered_as_user_message() { - let mut app = App::test_default(); - - let consumed = try_handle_submit(&mut app, "/resume abc-123"); - assert!(consumed); - assert!(app.transcript.messages.len() >= 2); - - let Some(first) = app.transcript.messages.first() else { - panic!("expected user message"); - }; - assert!(matches!(first.role, MessageRole::User)); - let Some(MessageBlock::Text(block)) = first.blocks.first() else { - panic!("expected text block"); - }; - assert_eq!(block.text, "/resume abc-123"); -} - -#[tokio::test(flavor = "current_thread")] -async fn resume_sets_command_pending_when_connected() { - tokio::task::LocalSet::new() - .run_until(async { - let mut app = App::test_default(); - let mut rx = attach_test_connection(&mut app); - - let consumed = try_handle_submit(&mut app, "/resume abc-123"); - assert!(consumed); - assert!(matches!(app.status, AppStatus::CommandPending)); - assert_eq!(app.pending_session_resume_id(), Some("abc-123")); - - tokio::task::yield_now().await; - assert!(rx.try_recv().is_ok()); - }) - .await; -} - #[tokio::test(flavor = "current_thread")] async fn mode_sets_command_pending_and_mode_update_restores_ready() { tokio::task::LocalSet::new() diff --git a/src/app/state/types.rs b/src/app/state/types.rs index 68eebd39..e10f6618 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -73,7 +73,9 @@ pub struct RecentSessionInfo { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SessionPickerState { - /// Index of the currently highlighted session in `app.recent_sessions`. + /// Search text for session titles, IDs, and branch names. + pub query: String, + /// Index of the currently highlighted session in the filtered results. pub selected: usize, /// Scroll offset for when the list exceeds the visible area. pub scroll_offset: usize, @@ -83,6 +85,19 @@ pub struct SessionPickerState { pub turn_selected: usize, /// Scroll offset for the turn list. pub turn_scroll_offset: usize, + /// Whether the selected message is open for full-text reading. + pub turn_reading: bool, + /// Wrapped line offset within the message reader. + pub turn_read_scroll: usize, +} + +impl SessionPickerState { + pub(crate) fn reset_turn_navigation(&mut self) { + self.turn_selected = 0; + self.turn_scroll_offset = 0; + self.turn_reading = false; + self.turn_read_scroll = 0; + } } #[derive(Debug, Clone, PartialEq, Default)] diff --git a/src/app/tests.rs b/src/app/tests.rs index dce4aa0b..9323a09a 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -522,41 +522,6 @@ fn model_selection_arms_submit_on_the_first_enter() { assert!(app.pending_submit.is_some()); } -#[test] -fn resume_selection_with_explicit_tab_then_enter_arms_submit() { - let mut app = App::test_default(); - app.recent_sessions = vec![RecentSessionInfo { - session_id: "session-1".to_owned(), - summary: "Session one".to_owned(), - last_modified_ms: 1, - file_size_bytes: 1, - cwd: None, - git_branch: None, - custom_title: None, - first_prompt: None, - }]; - app.input.set_text("/resume se"); - let _ = app.input.set_cursor(0, "/resume se".chars().count()); - crate::app::slash::sync_with_cursor(&mut app); - - events::handle_terminal_event( - &mut app, - Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), - ); - - assert!(app.pending_submit.is_none()); - assert_eq!(app.input.text(), "/resume session-1 "); - assert!(app.slash.visible().is_none()); - assert_eq!(app.focus_owner(), FocusOwner::Input); - - events::handle_terminal_event( - &mut app, - Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - ); - - assert!(app.pending_submit.is_some()); -} - #[test] fn paste_event_cancels_deferred_submit_snapshot() { let mut app = App::test_default(); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 29cfecf4..44181378 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -71,6 +71,7 @@ pub(crate) fn dismiss_fullscreen_on_interrupt(app: &mut App) { FullscreenView::SessionPicker => { app.startup.resolve_session_picker(); app.session_picker.turn_session_id = None; + app.session_picker.reset_turn_navigation(); } FullscreenView::Update => { app.update_prompt = None; diff --git a/src/ui/session_picker.rs b/src/ui/session_picker.rs index cfe7f50e..1e65068e 100644 --- a/src/ui/session_picker.rs +++ b/src/ui/session_picker.rs @@ -9,9 +9,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::theme; +use crate::app::session_picker::{picker_sessions, session_title}; -const SESSION_DESCRIPTION: &str = "Recent sessions for this project directory."; +const SESSION_DESCRIPTION: &str = + "Recent sessions for this project. Search by name, ID, or branch."; const TURN_DESCRIPTION: &str = "The original session will not change. The selected message and everything after it will be omitted from the fork."; +const TURN_CONTENT_INDENT: &str = " "; pub fn render(frame: &mut Frame, app: &mut App) { let area = frame.area(); @@ -26,13 +29,16 @@ pub fn render(frame: &mut Frame, app: &mut App) { let inner = area.inner(Margin { vertical: 1, horizontal: 2 }); let description = if selecting_turn { TURN_DESCRIPTION } else { SESSION_DESCRIPTION }; let description_height = wrapped_line_count(description, inner.width); + let footer = footer_text(app); + let footer_height = wrapped_line_count(footer, inner.width); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Length(description_height), + Constraint::Length(if selecting_turn { 0 } else { 2 }), Constraint::Min(3), - Constraint::Length(1), + Constraint::Length(footer_height), ]) .split(inner); @@ -53,52 +59,75 @@ pub fn render(frame: &mut Frame, app: &mut App) { .wrap(Wrap { trim: false }), chunks[1], ); + if !selecting_turn { + let matching = crate::app::session_picker::picker_session_count(app); + frame.render_widget( + Paragraph::new(format!( + "Search: {} ({matching}/{})", + app.session_picker.query, + app.recent_sessions.len() + )) + .style(Style::default().fg(theme::RUST_ORANGE)), + chunks[2], + ); + } + + render_body(frame, chunks[3], app); + frame.render_widget( + Paragraph::new(footer).style(Style::default().fg(theme::DIM)).wrap(Wrap { trim: false }), + chunks[4], + ); +} + +fn render_body(frame: &mut Frame, area: Rect, app: &mut App) { + let selecting_turn = app.session_picker.turn_session_id.is_some(); if selecting_turn && app.sdk_inventory.rewind_targets_in_flight { frame.render_widget( Paragraph::new("Loading session messages...") .style(Style::default().fg(theme::DIM)) .wrap(Wrap { trim: false }), - chunks[2], + area, ); } else if selecting_turn && app.sdk_inventory.rewind_targets_error.is_some() { frame.render_widget( Paragraph::new(app.sdk_inventory.rewind_targets_error.as_deref().unwrap_or_default()) .style(Style::default().fg(theme::STATUS_ERROR)) .wrap(Wrap { trim: false }), - chunks[2], + area, ); } else if selecting_turn && crate::app::session_picker::picker_turn_count(app) == 0 { frame.render_widget( Paragraph::new("No resumable user messages found in this session.") .style(Style::default().fg(theme::DIM)) .wrap(Wrap { trim: false }), - chunks[2], + area, ); + } else if selecting_turn && app.session_picker.turn_reading { + render_turn_reader(frame, area, app); } else if selecting_turn { - render_turn_list(frame, chunks[2], app); + render_turn_list(frame, area, app); } else if crate::app::session_picker::startup_picker_is_loading(app) { frame.render_widget( Paragraph::new("Loading recent sessions...") .style(Style::default().fg(theme::DIM)) .wrap(Wrap { trim: false }), - chunks[2], + area, ); } else if crate::app::session_picker::picker_session_count(app) == 0 { frame.render_widget( - Paragraph::new("No recent sessions found for this directory.") - .style(Style::default().fg(theme::DIM)) - .wrap(Wrap { trim: false }), - chunks[2], + Paragraph::new(if app.recent_sessions.is_empty() { + "No recent sessions found for this directory." + } else { + "No sessions match your search." + }) + .style(Style::default().fg(theme::DIM)) + .wrap(Wrap { trim: false }), + area, ); } else { - render_session_list(frame, chunks[2], app); + render_session_list(frame, area, app); } - - frame.render_widget( - Paragraph::new(footer_text(app)).style(Style::default().fg(theme::DIM)), - chunks[3], - ); } fn wrapped_line_count(text: &str, width: u16) -> u16 { @@ -107,48 +136,113 @@ fn wrapped_line_count(text: &str, width: u16) -> u16 { .max(1) } -fn render_turn_list(frame: &mut Frame, area: Rect, app: &mut App) { - let visible_count = usize::from(area.height.max(1)); - let turn_count = crate::app::session_picker::picker_turn_count(app); - let max_offset = turn_count.saturating_sub(visible_count); - app.session_picker.turn_scroll_offset = app.session_picker.turn_scroll_offset.min(max_offset); - if app.session_picker.turn_selected < app.session_picker.turn_scroll_offset { - app.session_picker.turn_scroll_offset = app.session_picker.turn_selected; - } - if app.session_picker.turn_selected >= app.session_picker.turn_scroll_offset + visible_count { - app.session_picker.turn_scroll_offset = - app.session_picker.turn_selected + 1 - visible_count; - } +fn turn_text( + target: &crate::agent::model::RewindTarget, + number: usize, + selected: bool, + width: u16, +) -> Text<'static> { + let marker = if selected { ">" } else { " " }; + let heading_style = Style::default() + .fg(if selected { theme::RUST_ORANGE } else { theme::DIM }) + .add_modifier(Modifier::BOLD); + let plain = super::highlight::strip_ansi(&target.input_text); + let plain = plain + .chars() + .filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t')) + .collect::(); + let prefix = [super::wrap::StyledChunk { + text: format!("{marker:>(); + frame.render_widget(Paragraph::new(Text::from(visible)), area); +} + +fn render_turn_list(frame: &mut Frame, area: Rect, app: &mut App) { + const MAX_PREVIEW_LINES: usize = 4; + let count = app.sdk_inventory.rewind_targets.len(); + // Reserve a row for the expansion hint when the viewport is short. + let preview_limit = usize::from(area.height).saturating_sub(1).clamp(1, MAX_PREVIEW_LINES + 1); + let previews = app + .sdk_inventory + .rewind_targets .iter() .enumerate() - .map(|(idx, target)| { - let selected = start + idx == app.session_picker.turn_selected; - let style = if selected { - Style::default().fg(ratatui::style::Color::White).bg(theme::RUST_ORANGE) - } else { - Style::default() - }; - let marker = if selected { ">" } else { " " }; - Line::from(Span::styled( - format!( - "{marker} Msg. {} - {}", - turn_count.saturating_sub(start + idx), - truncate(&target.first_text, 60) - ), - style, - )) + .map(|(index, target)| { + let text = turn_text( + target, + count - index, + index == app.session_picker.turn_selected, + area.width, + ); + let full_height = text.lines.len(); + let paragraph = Paragraph::new(text); + let more = full_height > preview_limit; + let preview_height = full_height.min(preview_limit); + (paragraph, preview_height, more) }) .collect::>(); - frame.render_widget(Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), area); + let heights = + previews.iter().map(|(_, height, more)| height + usize::from(*more)).collect::>(); + let selected = app.session_picker.turn_selected; + let mut start = app.session_picker.turn_scroll_offset.min(selected); + let mut occupied = + heights[start..=selected].iter().map(|height| height + 1).sum::().saturating_sub(1); + while start < selected && occupied > usize::from(area.height) { + occupied = occupied.saturating_sub(heights[start] + 1); + start += 1; + } + app.session_picker.turn_scroll_offset = start; + let mut y = area.y; + for (paragraph, preview_height, more) in previews.into_iter().skip(start) { + let height = + u16::try_from(preview_height).unwrap_or(u16::MAX).min(area.bottom().saturating_sub(y)); + if height == 0 { + break; + } + frame.render_widget(paragraph, Rect::new(area.x, y, area.width, height)); + y = y.saturating_add(height); + if more && y < area.bottom() { + frame.render_widget( + Paragraph::new(format!("{TURN_CONTENT_INDENT}... more - Right to read")) + .style(Style::default().fg(theme::DIM)), + Rect::new(area.x, y, area.width, 1), + ); + y = y.saturating_add(1); + } + y = y.saturating_add(1); + } } fn render_session_list(frame: &mut Frame, area: Rect, app: &mut App) { - let lines_per_item = 2; - let visible_count = usize::from((area.height / lines_per_item).max(1)); + let visible_count = usize::from((area.height / 2).max(1)); let session_count = crate::app::session_picker::picker_session_count(app); let max_offset = session_count.saturating_sub(visible_count); app.session_picker.scroll_offset = app.session_picker.scroll_offset.min(max_offset); @@ -161,8 +255,8 @@ fn render_session_list(frame: &mut Frame, area: Rect, app: &mut App) { let start = app.session_picker.scroll_offset; let end = (start + visible_count).min(session_count); - let mut lines = Vec::with_capacity((end - start) * usize::from(lines_per_item)); - for (idx, session) in app.recent_sessions[start..end].iter().enumerate() { + let mut lines = Vec::with_capacity((end - start) * 2); + for (idx, session) in picker_sessions(app).skip(start).take(end - start).enumerate() { let selected = start + idx == app.session_picker.selected; let base_style = if selected { Style::default().fg(ratatui::style::Color::White).bg(theme::RUST_ORANGE) @@ -178,20 +272,26 @@ fn render_session_list(frame: &mut Frame, area: Rect, app: &mut App) { let current_label = if current { " [current]" } else { "" }; lines.push(session_line( area.width, - &format!("{marker} {}{current_label}", display_primary(session)), + &format!("{marker}{current_label} {}", display_primary(session)), base_style, selected, )); - if start + idx + 1 < end { - lines.push(Line::default()); - } + let metadata = + session.git_branch.as_deref().filter(|branch| !branch.is_empty()).map_or_else( + || session.session_id.clone(), + |branch| format!("{branch} | {}", session.session_id), + ); + lines.push(Line::from(Span::styled( + fit_with_ellipsis(&format!(" {metadata}"), usize::from(area.width)), + if selected { base_style } else { Style::default().fg(theme::DIM) }, + ))); } - frame.render_widget(Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), area); + frame.render_widget(Paragraph::new(Text::from(lines)), area); } fn session_line(width: u16, left: &str, base_style: Style, selected: bool) -> Line<'static> { - const FULL_ACTION: &str = "messages ›"; + const FULL_ACTION: &str = "fork ›"; const COMPACT_ACTION: &str = "›"; let available_width = usize::from(width); @@ -221,35 +321,17 @@ fn session_line(width: u16, left: &str, base_style: Style, selected: bool) -> Li fn footer_text(app: &App) -> &'static str { if crate::app::session_picker::startup_picker_is_loading(app) { "Preparing session picker | Ctrl+Q to quit" + } else if app.session_picker.turn_reading { + "Up/Down scroll | Enter to fork before message | Left/Esc back to messages | Ctrl+Q to quit" } else if app.session_picker.turn_session_id.is_some() { - "Enter to fork before message | Left/Esc back | Ctrl+Q to quit" + "Up/Down select | Right read message | Enter to fork before message | Left/Esc back | Ctrl+Q to quit" } else { - "Enter to resume | Right choose message | Esc close | Ctrl+Q to quit" + "Type to search | Up/Down select | Enter to resume | Right choose fork point | Backspace edit | Esc close | Ctrl+Q to quit" } } fn display_primary(session: &RecentSessionInfo) -> String { - format!("{} - {}", format_relative_age(session.last_modified_ms), display_title(session)) -} - -fn display_title(session: &RecentSessionInfo) -> String { - let title = session - .custom_title - .as_deref() - .filter(|value| !value.trim().is_empty()) - .or_else(|| session.first_prompt.as_deref().filter(|value| !value.trim().is_empty())) - .or_else(|| { - let summary = session.summary.trim(); - (!summary.is_empty()).then_some(summary) - }) - .unwrap_or(&session.session_id); - truncate(title, 60) -} - -fn truncate(value: &str, max_chars: usize) -> String { - let mut chars = value.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { format!("{truncated}...") } else { truncated } + format!("{} - {}", format_relative_age(session.last_modified_ms), session_title(session)) } fn fit_with_ellipsis(value: &str, max_width: usize) -> String { @@ -386,16 +468,350 @@ mod tests { } #[test] - fn limits_picker_to_ten_recent_sessions() { + fn scrolls_to_sessions_beyond_the_first_ten() { let mut app = App::test_default(); app.surface_mode = SurfaceMode::Fullscreen(FullscreenView::SessionPicker); app.recent_sessions = (1..=11).map(|idx| session(&format!("s{idx}"), &format!("Session {idx}"))).collect(); - let text = draw_text_with_size(&mut app, 80, 30); + crate::app::session_picker::handle_key( + &mut app, + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::End, + crossterm::event::KeyModifiers::NONE, + ), + ); + let text = draw_text_with_size(&mut app, 80, 14); + + let rows = text.lines().collect::>(); + let selected = rows + .iter() + .position(|row| row.contains("> just now - Session 11")) + .expect("last session visible and selected"); + assert!(rows[selected + 1].contains("main | s11")); + assert!(!text.contains("Session 1 ")); + assert!(app.session_picker.scroll_offset > 0); + } - assert!(text.contains("Session 10")); - assert!(!text.contains("Session 11")); + #[test] + fn filtered_rows_associate_selection_with_the_correct_metadata() { + let mut app = App::test_default(); + app.recent_sessions = + vec![session("hidden-id", "Unrelated"), session("visible-id", "Target")]; + app.recent_sessions[1].git_branch = Some("feature/resume".into()); + app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("visible-id")); + crate::app::session_picker::open(&mut app); + crate::app::session_picker::paste_search(&mut app, "target"); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("terminal"); + terminal.draw(|frame| render(frame, &mut app)).expect("draw"); + let buffer = terminal.backend().buffer(); + let rows = buffer + .content + .chunks(100) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>(); + let selected = + rows.iter().position(|row| row.contains("Target")).expect("matching session"); + assert!(rows[selected].contains("> [current] just now - Target")); + assert!(rows[selected + 1].contains("feature/resume | visible-id")); + assert_eq!( + buffer[(2, u16::try_from(selected).expect("row"))].bg, + super::theme::RUST_ORANGE + ); + assert_eq!( + buffer[(2, u16::try_from(selected + 1).expect("row"))].bg, + super::theme::RUST_ORANGE + ); + assert!(!rows.iter().any(|row| row.contains("hidden-id") || row.contains("Unrelated"))); + } + + #[test] + fn empty_search_results_clear_previous_session_rows_and_show_recovery_controls() { + let mut app = App::test_default(); + app.recent_sessions = vec![session("s1", "First Session")]; + crate::app::session_picker::open(&mut app); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("terminal"); + terminal.draw(|frame| render(frame, &mut app)).expect("initial draw"); + crate::app::session_picker::paste_search(&mut app, "missing"); + terminal.draw(|frame| render(frame, &mut app)).expect("filtered draw"); + let rows = terminal + .backend() + .buffer() + .content + .chunks(80) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>(); + assert!(rows.iter().any(|row| row.contains("No sessions match"))); + assert!(!rows.iter().any(|row| row.contains("First Session") || row.contains("main | s1"))); + assert!(rows.iter().any(|row| row.contains("Backspace edit"))); + } + + #[test] + fn narrow_rows_keep_the_current_marker_and_fork_action_inside_the_session_row() { + let mut app = App::test_default(); + app.recent_sessions = vec![session("long-id", &"界".repeat(100))]; + app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("long-id")); + crate::app::session_picker::open(&mut app); + let text = draw_text_with_size(&mut app, 40, 20); + let rows = text.lines().collect::>(); + let selected = + rows.iter().position(|row| row.contains("> [current]")).expect("selected session"); + assert!(rows[selected].contains("fork ›")); + assert!(rows[selected + 1].contains("main | long-id")); + assert!(rows.iter().any(|row| row.contains("Esc close"))); + } + + fn fork_target(id: &str, input: String) -> crate::agent::model::RewindTarget { + crate::agent::model::RewindTarget { + uuid: id.into(), + first_text: "Summary only".into(), + input_text: input, + index: 0, + previous_assistant_uuid: None, + resume_anchor_uuid: None, + } + } + + fn first_content_column(buffer: &ratatui::buffer::Buffer, row: usize) -> Option { + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .nth(row) + .expect("rendered row") + .iter() + .enumerate() + .skip(2) + .take(width.saturating_sub(4)) + .find_map(|(column, cell)| { + cell.symbol().chars().any(|ch| !ch.is_whitespace()).then_some(column) + }) + } + + #[test] + fn fork_previews_align_headings_body_continuations_and_more_hints() { + for width in [40, 80, 120] { + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + app.sdk_inventory.rewind_targets = vec![ + fork_target("selected", "\u{754c} alpha beta gamma ".repeat(80)), + fork_target("earlier", "Earlier message".into()), + ]; + let mut terminal = Terminal::new(TestBackend::new(width, 30)).expect("terminal"); + terminal.draw(|frame| render(frame, &mut app)).expect("draw"); + let buffer = terminal.backend().buffer(); + let rows = buffer + .content + .chunks(usize::from(width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect::()) + .collect::>(); + let heading = + rows.iter().position(|row| row.contains("> Message 2")).expect("selected heading"); + assert_eq!(buffer[(4, u16::try_from(heading).expect("row"))].symbol(), "M"); + for row in heading + 1..=heading + 5 { + assert_eq!( + first_content_column(buffer, row), + Some(4), + "body or expansion hint at width {width}, row {row}" + ); + } + assert!(rows[heading + 5].contains("more")); + let earlier = + rows.iter().position(|row| row.contains("Message 1")).expect("unselected heading"); + assert_eq!(first_content_column(buffer, earlier), Some(4)); + assert_eq!(first_content_column(buffer, earlier + 1), Some(4)); + assert!(rows[earlier + 1].contains("Earlier message")); + } + } + + #[test] + fn fork_reader_uses_the_heading_gutter_without_clipping_wrapped_message_text() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let input = "0123456789".repeat(30); + for width in [30, 50, 80] { + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + app.sdk_inventory.rewind_targets = vec![fork_target("message", input.clone())]; + crate::app::session_picker::handle_key( + &mut app, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + let mut terminal = Terminal::new(TestBackend::new(width, 50)).expect("terminal"); + terminal.draw(|frame| render(frame, &mut app)).expect("draw"); + let buffer = terminal.backend().buffer(); + let rows = buffer.content.chunks(usize::from(width)).collect::>(); + let heading = rows + .iter() + .position(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .contains("> Message 1") + }) + .expect("heading"); + let content_width = usize::from(width) - 6; + let mut displayed = String::new(); + for (row, cells) in + rows.iter().enumerate().skip(heading + 1).take(input.len().div_ceil(content_width)) + { + assert_eq!(first_content_column(buffer, row), Some(4)); + let text = cells[4..usize::from(width) - 2] + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + displayed.push_str(text.trim_end()); + } + assert_eq!( + displayed, input, + "wrapped text must not disappear behind the right margin at width {width}" + ); + terminal.backend_mut().resize(width, 20); + terminal.draw(|frame| render(frame, &mut app)).expect("resized draw"); + for _ in 0..3 { + crate::app::session_picker::handle_key( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + terminal.draw(|frame| render(frame, &mut app)).expect("scroll"); + } + let buffer = terminal.backend().buffer(); + let mut message_rows = 0; + for (row_index, row) in buffer.content.chunks(usize::from(width)).enumerate() { + if row[4].symbol().chars().any(|ch| ch.is_ascii_digit()) { + message_rows += 1; + assert_eq!( + first_content_column(buffer, row_index), + Some(4), + "resized reader at width {width}" + ); + } + } + assert!(message_rows > 0, "message text must remain visible after resizing"); + if width == 30 { + assert!( + app.session_picker.turn_read_scroll > 0, + "exercise alignment after scrolling" + ); + } + } + } + + #[test] + fn fork_messages_wrap_the_full_input_and_preserve_paragraph_spacing() { + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + app.sdk_inventory.rewind_targets = vec![fork_target( + "user-1", + format!( + "{}continuation-marker\n\nSecond-paragraph", + "Read this message carefully. ".repeat(2) + ), + )]; + let text = draw_text_with_size(&mut app, 60, 25); + let rows = text.lines().collect::>(); + let heading = + rows.iter().position(|row| row.contains("> Message 1")).expect("message heading"); + let continuation = rows + .iter() + .position(|row| row.contains("continuation-marker")) + .expect("text after the former 60-character cutoff"); + let second = + rows.iter().position(|row| row.contains("Second-paragraph")).expect("second paragraph"); + assert!(continuation > heading + 1, "long input must wrap"); + assert_eq!(second, continuation + 2, "paragraph break must remain visible"); + assert!(!text.contains("Summary only")); + } + + #[test] + fn fork_preview_removes_terminal_sequences_without_changing_the_stored_prompt() { + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + let input = "\u{1b}[31mRed\u{1b}[0m plain\u{1b}]52;c;payload\u{7}\n\nKeep paragraphs"; + app.sdk_inventory.rewind_targets = vec![fork_target("user-1", input.into())]; + let text = draw_text_with_size(&mut app, 80, 20); + let rows = text.lines().collect::>(); + let heading = + rows.iter().position(|row| row.contains("> Message 1")).expect("message heading"); + assert!(rows[heading + 1].contains("Red plain")); + assert!(rows[heading + 3].contains("Keep paragraphs")); + assert!(!rows[heading + 1].contains("31m") && !rows[heading + 1].contains("payload")); + assert_eq!(app.sdk_inventory.rewind_targets[0].input_text, input); + } + + #[test] + fn thousand_character_messages_have_four_line_previews_and_keep_other_messages_visible() { + for width in [40, 80, 120] { + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + let input = format!("{}END-OF-MESSAGE", "Long message text. ".repeat(60)); + assert!(input.len() > 1_000); + app.sdk_inventory.rewind_targets = + vec![fork_target("user-2", input), fork_target("user-1", "Earlier prompt".into())]; + let text = draw_text_with_size(&mut app, width, 30); + let rows = text.lines().collect::>(); + let heading = rows + .iter() + .position(|row| row.contains("> Message 2")) + .expect("long message heading"); + assert!( + rows[heading + 5].contains("more"), + "four text rows then expansion hint: {width}" + ); + assert!( + rows[heading + 7].contains("Message 1"), + "next message must remain visible: {width}" + ); + assert!(rows[heading + 8].contains("Earlier prompt")); + assert!(!rows.iter().any(|row| row.contains("END-OF-MESSAGE"))); + } + } + + #[test] + fn long_fork_message_reader_reaches_the_end_and_returns_to_the_same_selection() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = App::test_default(); + app.session_picker.turn_session_id = Some("s1".into()); + let input = (1..=40).fold(String::new(), |mut text, line| { + use std::fmt::Write as _; + writeln!(text, "Detail line {line}: extended text of this message") + .expect("write fixture"); + text + }); + app.sdk_inventory.rewind_targets = + vec![fork_target("user-2", input), fork_target("user-1", "Earlier message".into())]; + crate::app::session_picker::handle_key( + &mut app, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert!(app.session_picker.turn_reading); + for _ in 0..80 { + crate::app::session_picker::handle_key( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + let _ = draw_text_with_size(&mut app, 70, 16); + } + let text = draw_text_with_size(&mut app, 70, 16); + let rows = text.lines().collect::>(); + assert!( + rows.iter().any(|row| row.contains("Detail line 40")), + "end of long input must be reachable" + ); + assert!( + !rows + .iter() + .any(|row| row.contains("Detail line 1 ") || row.contains("Earlier message")) + ); + assert_eq!(app.session_picker.turn_selected, 0); + assert!(app.session_picker.turn_read_scroll > 0); + crate::app::session_picker::handle_key( + &mut app, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert!(!app.session_picker.turn_reading); + let text = draw_text_with_size(&mut app, 70, 16); + assert!(text.lines().any(|row| row.contains("> Message 2"))); + assert!(text.lines().any(|row| row.contains("Detail line 1"))); } #[test] @@ -425,13 +841,18 @@ mod tests { let text = draw_text(&mut app); assert!(text.contains("Fork Before Message")); - assert!(text.contains("> Msg. 2 - Resume from this message")); - assert!(text.contains(" Msg. 1 - Start from the beginning")); + let rows = text.lines().collect::>(); + let selected = + rows.iter().position(|row| row.contains("> Message 2")).expect("selected message"); + assert!(rows[selected + 1].contains("Resume from this message")); + let earlier = + rows.iter().position(|row| row.contains("Message 1")).expect("earlier message"); + assert!(rows[earlier + 1].contains("Start from the beginning")); assert!(text.contains("Left/Esc back")); } #[test] - fn marks_the_current_session_and_exposes_message_navigation() { + fn marks_the_current_session_and_exposes_fork_navigation() { let mut app = App::test_default(); app.surface_mode = SurfaceMode::Fullscreen(FullscreenView::SessionPicker); app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("s1")); @@ -444,9 +865,9 @@ mod tests { .lines() .find(|line| line.contains("Current Session")) .expect("current session row"); - let action_column = session_row.find("messages ›").expect("message action"); - assert!(action_column > 50, "message action should be aligned to the right: {session_row}"); - assert!(text.contains("Right choose message")); + let action_column = session_row.find("fork ›").expect("fork action"); + assert!(action_column > 50, "fork action should be aligned to the right: {session_row}"); + assert!(text.contains("Right choose fork point")); } #[test] diff --git a/tests/slash_input_flow.rs b/tests/slash_input_flow.rs index d7fc015a..b161d3c0 100644 --- a/tests/slash_input_flow.rs +++ b/tests/slash_input_flow.rs @@ -22,6 +22,7 @@ const NO_ARGUMENT_COMMANDS: &[&str] = &[ "/login", "/logout", "/new-session", + "/resume", ]; const REQUIRED_ARGUMENT_COMMANDS: &[(&str, &str)] = &[ @@ -94,7 +95,7 @@ fn exact_names_win_over_competing_plugins_for_every_app_command_and_sdk_alias() .iter() .copied() .chain(REQUIRED_ARGUMENT_COMMANDS.iter().map(|(command, _)| *command)) - .chain(["/resume", "/clear", "/new", "/reset", "/ping", "/deploy", "/ship"]) + .chain(["/clear", "/new", "/reset", "/ping", "/deploy", "/ship"]) { for code in [KeyCode::Tab, KeyCode::Enter] { let mut app = app(); @@ -179,18 +180,6 @@ fn complete_argument_commands_submit_on_the_first_enter() { } } -#[test] -fn optional_resume_argument_can_be_omitted_or_supplied() { - for text in ["/resume", "/resume ", "/resume manually-supplied-id"] { - let mut app = app(); - draft(&mut app, text); - - key(&mut app, KeyCode::Enter); - - assert_eq!(submitted_text(&app).trim(), text.trim()); - } -} - #[test] fn session_commands_and_aliases_submit_with_or_without_argument_hints() { for text in [ From 4a56fc7f93bf80f95f90b9509df19a6e1d9f6d27 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:38:09 +0200 Subject: [PATCH 06/10] feat(chat): unify message layout and stabilize Markdown lists (#478) * feat(chat): unify message layout and assistant response ownership - render full-width gray user cards with shared composer padding - align assistant text and tools behind a single response marker - keep empty responses invisible and separate live output from history replay - centralize response ownership for submitted, queued, and autonomous turns - add lifecycle and rendering regression coverage * fix(markdown): preserve list structure through wrapping and streaming - replace list marker heuristics with parsed nesting and indentation - preserve paragraph spacing, inline styles, and nested code and tables - keep rendered and copied text intact across cache splits - add adversarial coverage for streaming and scrollback commits --- src/app/events/mod.rs | 14 +- src/app/events/session_reset.rs | 6 +- src/app/events/streaming.rs | 68 +- src/app/events/tests/assistant_lifecycle.rs | 396 +++++++++ src/app/events/tests/mod.rs | 16 +- src/app/events/tool_calls.rs | 54 +- src/app/events/turn.rs | 8 +- src/app/input_submit.rs | 19 +- src/app/mod.rs | 1 + src/app/state/messages.rs | 13 +- src/app/state/mod.rs | 1 + src/app/state/session_identity.rs | 31 + src/app/state/turn.rs | 10 + src/app/terminal_runtime/chat_session.rs | 42 +- src/ui/activity_rows.rs | 25 +- src/ui/document_table.rs | 16 +- src/ui/inline_chat_rows.rs | 902 ++++++++++++-------- src/ui/input.rs | 20 +- src/ui/live_rows/segments.rs | 2 +- src/ui/markdown.rs | 270 ++++-- src/ui/markdown/lists.rs | 424 +++++++++ src/ui/message.rs | 116 +-- src/ui/message_layout.rs | 105 +++ src/ui/message_rows.rs | 92 +- src/ui/mod.rs | 1 + src/ui/theme.rs | 1 + src/ui/tool_call/standard.rs | 52 +- src/ui/tool_call/tests.rs | 4 +- src/ui/wrap.rs | 204 +---- tests/integration/state_transitions.rs | 38 +- tests/terminal_resize.rs | 110 ++- 31 files changed, 2061 insertions(+), 1000 deletions(-) create mode 100644 src/app/events/tests/assistant_lifecycle.rs create mode 100644 src/ui/markdown/lists.rs create mode 100644 src/ui/message_layout.rs diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index d6af06ba..047c6bd8 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -18,7 +18,7 @@ mod turn; use super::{ App, AppStatus, ChatMessage, ChatMessageId, FullscreenView, InvalidationLevel, MessageBlock, MessageRole, PendingCommandAck, SurfaceMode, SystemSeverity, TerminalSize, TerminalSizeChange, - TextBlock, + TextBlock, TranscriptUpdateSource, }; use crate::agent::model; #[cfg(all(test, target_os = "macos"))] @@ -336,14 +336,18 @@ fn handle_session_update_event(app: &mut App, update: model::SessionUpdate) { | model::SessionUpdate::ToolCallUpdate(_) | model::SessionUpdate::TranscriptRetraction(_) ); - handle_session_update(app, update); + handle_session_update(app, update, TranscriptUpdateSource::Live); if needs_history_retention { app.enforce_history_retention_tracked(); } } #[allow(clippy::too_many_lines)] -fn handle_session_update(app: &mut App, update: model::SessionUpdate) { +fn handle_session_update( + app: &mut App, + update: model::SessionUpdate, + source: TranscriptUpdateSource, +) { match update { model::SessionUpdate::ConversationReset { new_conversation_id, @@ -359,9 +363,9 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) { ), model::SessionUpdate::AgentMessageChunk(chunk) => { compaction::finish_inferred(app, true); - streaming::handle_agent_message_chunk(app, chunk); + streaming::handle_agent_message_chunk(app, chunk, source); } - model::SessionUpdate::ToolCall(tc) => tool_calls::handle_tool_call(app, tc), + model::SessionUpdate::ToolCall(tc) => tool_calls::handle_tool_call(app, tc, source), model::SessionUpdate::ToolCallUpdate(tcu) => { tool_updates::handle_tool_call_update_session(app, &tcu); } diff --git a/src/app/events/session_reset.rs b/src/app/events/session_reset.rs index 2cf8eaf8..305029ab 100644 --- a/src/app/events/session_reset.rs +++ b/src/app/events/session_reset.rs @@ -263,7 +263,11 @@ pub(super) fn load_resume_history(app: &mut App, history_updates: &[model::Sessi app.clear_active_turn_assistant(); append_resume_user_message_chunk(app, chunk); } - _ => super::handle_session_update(app, update.clone()), + _ => super::handle_session_update( + app, + update.clone(), + super::TranscriptUpdateSource::HistoryReplay, + ), } } for message in &mut app.transcript.messages { diff --git a/src/app/events/streaming.rs b/src/app/events/streaming.rs index 91d654c1..fa29cada 100644 --- a/src/app/events/streaming.rs +++ b/src/app/events/streaming.rs @@ -2,24 +2,32 @@ // Copyright 2025 Simon Peter Rothgang use super::super::{ - App, ChatMessage, MessageBlock, MessageRole, TextBlock, TextBlockSpacing, TextSplitDecision, - TextSplitKind, default_cache_split_policy, find_text_split, + App, MessageBlock, TextBlock, TextBlockSpacing, TextSplitDecision, TextSplitKind, + TranscriptUpdateSource, default_cache_split_policy, find_text_split, }; use crate::agent::model; /// A live SDK response can start after a background notification without a user prompt. /// Give it a fresh mutable owner instead of extending already committed text. pub(super) fn begin_live_assistant_response(app: &mut App) { - if !matches!(app.status, super::AppStatus::Ready | super::AppStatus::Error) { + let starts_response = match app.status { + super::AppStatus::Ready | super::AppStatus::Error => true, + super::AppStatus::Running | super::AppStatus::Thinking => { + app.active_turn_assistant_idx().is_none() + } + super::AppStatus::Connecting | super::AppStatus::CommandPending => false, + }; + if !starts_response { return; } - app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); - app.bind_active_turn_assistant_to_tail(); - app.status = super::AppStatus::Running; - app.begin_turn_activity(std::time::Instant::now()); + app.start_assistant_response(); } -pub(super) fn handle_agent_message_chunk(app: &mut App, chunk: model::ContentChunk) { +pub(super) fn handle_agent_message_chunk( + app: &mut App, + chunk: model::ContentChunk, + source: TranscriptUpdateSource, +) { let model::ContentBlock::Text(text) = chunk.content else { return; }; @@ -27,36 +35,13 @@ pub(super) fn handle_agent_message_chunk(app: &mut App, chunk: model::ContentChu if text.text.is_empty() { return; } - if let Some(owner_idx) = app.active_turn_assistant_idx() - && let Some(owner) = app.transcript.messages.get_mut(owner_idx) - { - append_agent_stream_text( - &mut owner.blocks, - &text.text, - chunk.source_message_uuid.as_deref(), - ); - app.sync_after_message_blocks_changed(owner_idx); - return; - } - - if let Some(last) = app.transcript.messages.last_mut() - && matches!(last.role, MessageRole::Assistant) - { - append_agent_stream_text( - &mut last.blocks, - &text.text, - chunk.source_message_uuid.as_deref(), - ); - let last_idx = app.transcript.messages.len().saturating_sub(1); - app.bind_active_turn_assistant(last_idx); - app.sync_after_message_blocks_changed(last_idx); - return; - } - - let mut blocks = Vec::new(); - append_agent_stream_text(&mut blocks, &text.text, chunk.source_message_uuid.as_deref()); - app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, blocks, None)); - app.bind_active_turn_assistant_to_tail(); + let owner_idx = app.ensure_assistant_output_owner(source); + append_agent_stream_text( + &mut app.transcript.messages[owner_idx].blocks, + &text.text, + chunk.source_message_uuid.as_deref(), + ); + app.sync_after_message_blocks_changed(owner_idx); } pub(super) fn append_agent_stream_text( @@ -125,7 +110,7 @@ fn completed_text_block( source_message_uuids: Vec, ) -> MessageBlock { let trailing_spacing = match split.kind { - TextSplitKind::Generic => TextBlockSpacing::None, + TextSplitKind::Generic => TextBlockSpacing::Continuation, TextSplitKind::ParagraphBoundary => TextBlockSpacing::ParagraphBreak, }; MessageBlock::Text( @@ -146,7 +131,7 @@ pub(super) fn find_text_block_split_index(text: &str) -> Option { #[cfg(test)] mod tests { - use super::handle_agent_message_chunk; + use super::{TranscriptUpdateSource, handle_agent_message_chunk}; use crate::agent::model; use crate::app::{App, ChatMessage, MessageBlock, MessageRole, TextBlockSpacing}; @@ -162,6 +147,7 @@ mod tests { model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new( "first\n\nsecond", ))), + TranscriptUpdateSource::Live, ); assert_eq!(app.active_turn_assistant_idx(), Some(0)); @@ -192,6 +178,7 @@ mod tests { &mut app, model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new(text))) .source_message_uuid(Some(source_message_uuid.to_owned())), + TranscriptUpdateSource::Live, ); } @@ -217,6 +204,7 @@ mod tests { &mut app, model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new(markdown))) .source_message_uuid(Some("context-assistant-1".to_owned())), + TranscriptUpdateSource::Live, ); let text_blocks = app.transcript.messages[0] diff --git a/src/app/events/tests/assistant_lifecycle.rs b/src/app/events/tests/assistant_lifecycle.rs new file mode 100644 index 00000000..2fbabae3 --- /dev/null +++ b/src/app/events/tests/assistant_lifecycle.rs @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::agent::client::CommandReceiver; +use crate::agent::wire::BridgeCommand; +use crate::app::HistoryOutputId; +use crate::ui::inline_chat_rows::{ + LiveRowBoundaryKind, serialize_live_rows_with_boundaries_excluding, +}; +use pretty_assertions::assert_eq; +use std::collections::BTreeSet; + +fn connected_app() -> (App, CommandReceiver) { + let mut app = App::test_default(); + let (connection, receiver) = crate::agent::client::AgentConnection::test_channel(); + app.session_runtime.conn = Some(Rc::new(connection)); + app.session_runtime.session_id = Some(model::SessionId::new("test-session")); + app.status = AppStatus::Ready; + (app, receiver) +} + +fn submit(app: &mut App, receiver: &mut CommandReceiver, source: &str) -> String { + app.input.set_text(source); + crate::app::input_submit::submit_input(app); + while let Ok(envelope) = receiver.try_recv() { + if let BridgeCommand::Prompt { message_uuid, chunks, .. } = envelope.command { + assert_eq!(chunks[0].value.as_str(), Some(source)); + return message_uuid; + } + } + panic!("submitted prompt must reach the bridge"); +} + +fn update(app: &mut App, update: model::SessionUpdate) { + handle_client_event(app, session_update(update)); +} + +fn text(value: &str) -> model::SessionUpdate { + model::SessionUpdate::AgentMessageChunk(model::ContentChunk::new(model::ContentBlock::Text( + model::TextContent::new(value), + ))) +} + +pub(super) fn assert_owner_has_no_rows(app: &mut App, owner: usize) { + for width in [80, 12, 80] { + let rendered = serialize_live_rows_with_boundaries_excluding(app, width, &BTreeSet::new()); + assert!(rendered.segments().iter().all(|segment| segment.msg_idx != owner)); + assert_eq!( + rendered.rows().len(), + rendered + .segments() + .iter() + .map(|segment| segment.end_row - segment.start_row) + .sum::(), + "an invisible owner must not leave unowned padding rows" + ); + } +} + +#[test] +fn accepted_prompt_reserves_one_invisible_owner_until_the_first_visible_event() { + for (first, expected_kind, expected_text) in [ + ( + model::SessionUpdate::AgentActivityUpdate(model::AgentActivityPhase::Thinking), + LiveRowBoundaryKind::AssistantThinking, + "Thinking…", + ), + (text("First text"), LiveRowBoundaryKind::AssistantText, "First text"), + ( + model::SessionUpdate::ToolCall( + model::ToolCall::new("first-tool", "First tool") + .status(model::ToolCallStatus::InProgress), + ), + LiveRowBoundaryKind::AssistantTool, + "First tool", + ), + ] { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "Question"); + let owner = app.active_turn_assistant_idx().unwrap(); + let owner_id = app.transcript.messages[owner].id; + assert_eq!(app.status, AppStatus::Running); + assert!(app.turn.activity.is_some()); + assert!(app.transcript.messages[owner].blocks.is_empty()); + update(&mut app, model::SessionUpdate::AgentResponseStarted); + update(&mut app, model::SessionUpdate::AgentResponseStarted); + update(&mut app, text("")); + assert_eq!(app.active_turn_assistant_idx(), Some(owner)); + assert_owner_has_no_rows(&mut app, owner); + + update(&mut app, first); + let rendered = + serialize_live_rows_with_boundaries_excluding(&mut app, 80, &BTreeSet::new()); + let content = rendered + .segments() + .iter() + .find(|segment| segment.msg_idx == owner && segment.kind == expected_kind) + .expect("first output belongs to the reserved response"); + assert!(content.ids.contains(&HistoryOutputId::AssistantLabel(owner_id))); + assert!(rendered.segment_rows(content).unwrap().iter().any(|row| { + row.to_string().contains(expected_text) && row.to_string().contains('●') + })); + assert_eq!(app.transcript.messages[owner].id, owner_id); + assert_eq!(app.transcript.messages.len(), 2); + } +} + +#[test] +fn a_complete_live_reply_without_a_stream_start_cannot_extend_committed_history() { + for result in [ + text("Local command result"), + model::SessionUpdate::ToolCall( + model::ToolCall::new("new-tool", "New tool result") + .status(model::ToolCallStatus::Completed), + ), + ] { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "Previous question"); + update(&mut app, text("Finished answer")); + let previous = app.active_turn_assistant_idx().unwrap(); + let previous_id = app.transcript.messages[previous].id; + handle_client_event(&mut app, turn_complete(None)); + let history = serialize_live_rows_with_boundaries_excluding(&mut app, 80, &BTreeSet::new()); + let committed = + history.segments().iter().flat_map(|segment| segment.ids.iter().cloned()).collect(); + update(&mut app, result); + let owner = app.active_turn_assistant_idx().unwrap(); + assert_ne!(app.transcript.messages[owner].id, previous_id); + let remaining = serialize_live_rows_with_boundaries_excluding(&mut app, 80, &committed); + assert!(remaining.segments().iter().any(|segment| segment.msg_idx == owner)); + let MessageBlock::Text(old) = &app.transcript.messages[previous].blocks[0] else { + panic!("previous answer"); + }; + assert_eq!(old.text, "Finished answer"); + assert_eq!(app.transcript.messages[previous].blocks.len(), 1); + assert_eq!(app.status, AppStatus::Ready, "complete output must not invent a running turn"); + assert!(app.turn.activity.is_none()); + } +} + +#[test] +fn autonomous_start_after_a_running_signal_never_reopens_the_finished_response() { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "Previous question"); + update(&mut app, text("Finished answer")); + let previous = app.active_turn_assistant_idx().unwrap(); + let previous_id = app.transcript.messages[previous].id; + handle_client_event(&mut app, turn_complete(None)); + update( + &mut app, + model::SessionUpdate::RuntimeSessionStateUpdate(model::RuntimeSessionState::Running), + ); + assert_eq!(app.active_turn_assistant_idx(), None); + update(&mut app, model::SessionUpdate::AgentResponseStarted); + let owner = app.active_turn_assistant_idx().expect("autonomous response owns fresh output"); + assert_ne!(app.transcript.messages[owner].id, previous_id); + assert_owner_has_no_rows(&mut app, owner); + update(&mut app, model::SessionUpdate::AgentResponseStarted); + update(&mut app, text("Background answer")); + assert_eq!(app.active_turn_assistant_idx(), Some(owner)); + let MessageBlock::Text(old) = &app.transcript.messages[previous].blocks[0] else { + panic!("previous answer"); + }; + assert_eq!(old.text, "Finished answer"); + let MessageBlock::Text(new) = &app.transcript.messages[owner].blocks[0] else { + panic!("background answer"); + }; + assert_eq!(new.text, "Background answer"); +} + +#[test] +fn metadata_whitespace_and_hidden_tools_do_not_create_an_empty_message_frame() { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "Question"); + let owner = app.active_turn_assistant_idx().unwrap(); + app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( + "showMessageTimestamps", + serde_json::json!(true), + )); + app.config.snapshot.as_mut().unwrap().values.extend( + crate::agent::settings::SettingsSnapshot::test_value( + "showTurnDuration", + serde_json::json!(true), + ) + .values, + ); + update( + &mut app, + model::SessionUpdate::MessageMetadata { + role: "assistant".into(), + timestamp: "2026-10-15T13:00:00Z".into(), + source_message_uuid: Some("first-response".into()), + }, + ); + update( + &mut app, + model::SessionUpdate::TurnTiming { duration_ms: 42.0, api_duration_ms: Some(40.0) }, + ); + update(&mut app, text("\n \t\n")); + let mut hidden = model::ToolCall::new("internal-tool", "Hidden child tool") + .status(model::ToolCallStatus::InProgress); + hidden.meta = Some(serde_json::json!({"claudeCode": {"parentToolUseId": "parent-agent"}})); + update(&mut app, model::SessionUpdate::ToolCall(hidden)); + assert_owner_has_no_rows(&mut app, owner); + + crate::app::events::push_submission_feedback( + &mut app, + SystemSeverity::Warning, + "Visible notice", + ); + let rendered = serialize_live_rows_with_boundaries_excluding(&mut app, 80, &BTreeSet::new()); + let notice = rendered + .segments() + .iter() + .find(|segment| { + segment.msg_idx == owner && segment.kind == LiveRowBoundaryKind::AssistantNotice + }) + .unwrap(); + assert!(rendered.segment_rows(notice).unwrap().iter().any(|row| { + row.to_string().contains("Visible notice") && row.to_string().contains('●') + })); + assert!(!app.transcript.messages[owner].timing.timestamp.unwrap().observed); +} + +#[test] +fn failed_admission_preserves_the_draft_without_creating_user_or_assistant_messages() { + let (mut app, receiver) = connected_app(); + drop(receiver); + app.input.set_text("Keep this exact draft\n\nwith paragraphs"); + let draft = app.input.snapshot(); + crate::app::input_submit::submit_input(&mut app); + assert_eq!(app.input.snapshot(), draft); + assert!( + app.transcript + .messages + .iter() + .all(|message| matches!(message.role, MessageRole::System(_))) + ); + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.turn.activity.is_none()); +} + +#[test] +fn queued_start_changes_owner_only_once_and_preserves_the_previous_response() { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "First question"); + update(&mut app, text("First answer")); + let first_owner = app.active_turn_assistant_idx().unwrap(); + let first_id = app.transcript.messages[first_owner].id; + let queued = submit(&mut app, &mut receiver, "Second question"); + assert_eq!(app.active_turn_assistant_idx(), Some(first_owner)); + assert_eq!(app.transcript.messages.len(), 2); + for uuid in ["unknown", queued.as_str(), queued.as_str()] { + handle_client_event( + &mut app, + ClientEvent::UserMessageStarted { + session_id: "test-session".into(), + message_uuid: uuid.into(), + source: crate::agent::types::UserMessageStartSource::StreamEvent, + }, + ); + } + let second_owner = app.active_turn_assistant_idx().unwrap(); + assert_ne!(app.transcript.messages[second_owner].id, first_id); + assert_eq!(app.transcript.messages.len(), 4); + assert_owner_has_no_rows(&mut app, second_owner); + update(&mut app, text("Second answer")); + let MessageBlock::Text(first) = &app.transcript.messages[first_owner].blocks[0] else { + panic!("first answer"); + }; + assert_eq!(first.text, "First answer"); + let MessageBlock::Text(second) = &app.transcript.messages[second_owner].blocks[0] else { + panic!("second answer"); + }; + assert_eq!(second.text, "Second answer"); +} + +#[test] +fn empty_response_exit_removes_its_owner_without_consuming_the_next_draft() { + for outcome in ["complete", "error", "cancel"] { + let (mut app, mut receiver) = connected_app(); + submit(&mut app, &mut receiver, "Question"); + let owner = app.active_turn_assistant_idx().unwrap(); + let owner_id = app.transcript.messages[owner].id; + app.input.set_text("Next unsent draft"); + let draft = app.input.snapshot(); + if outcome == "cancel" { + crate::app::input_submit::request_cancel(&mut app).unwrap(); + } + assert_owner_has_no_rows(&mut app, owner); + let event = if outcome == "error" { + ClientEvent::TurnError { + session_id: "test-session".into(), + message: "Fixture failure".into(), + queued_turn_count: Some(0), + api_error_status: None, + terminal_reason: None, + } + } else { + turn_complete(None) + }; + handle_client_event(&mut app, event); + assert!(app.transcript.messages.iter().all(|message| message.id != owner_id)); + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.turn.activity.is_none()); + assert_eq!(app.input.snapshot(), draft); + if outcome == "error" { + assert!(app.transcript.messages.iter().any(|message| { + matches!(message.role, MessageRole::System(_)) + && matches!(&message.blocks[0], MessageBlock::Text(block) if block.text.contains("Fixture failure")) + })); + } + } +} + +#[test] +fn list_rendering_survives_sdk_chunks_cache_splits_and_scrollback_commits() { + fn take_text( + app: &mut App, + excluded: &mut BTreeSet, + stable_only: bool, + ) -> Vec { + let rendered = serialize_live_rows_with_boundaries_excluding(app, 36, excluded); + let mut rows = Vec::new(); + for segment in rendered.segments() { + if segment.kind != LiveRowBoundaryKind::AssistantText + || (stable_only && segment.end_row > rendered.stable_row_count()) + { + continue; + } + rows.extend( + rendered + .segment_rows(segment) + .unwrap() + .iter() + .map(|row| row.to_string().trim_end().to_owned()), + ); + excluded.extend(segment.ids.iter().cloned()); + } + rows + } + + let long_source = format!( + "Introduction.\n\n9. Parent item.\n\n {}\n\n - Child with 日本語😀.\n10. Next item.\n\nFinal paragraph.", + "Long continuation with useful detail. ".repeat(130) + ); + for source in [long_source, "Introduction.\n\n9) Parent item.\n\n - Child item.\n\n Child continuation.\n10) Next item.\n\nFinal paragraph.".to_owned()] { + let chars = source.chars().collect::>(); + let mut whole = connected_app().0; + whole.status = AppStatus::Ready; + update(&mut whole, model::SessionUpdate::AgentResponseStarted); + update(&mut whole, text(&source)); + handle_client_event(&mut whole, turn_complete(None)); + assert_eq!( + crate::app::copy::choices(&whole)[0].text, + source, + "cache splits must preserve the original Markdown source for copying" + ); + let expected = take_text(&mut whole, &mut BTreeSet::new(), false); + assert!( + expected.iter().any(|row| row.contains("Introduction.")), + "fixture must deliver output through the session boundary" + ); + + let chunk_sizes = if chars.len() < 512 { [1, 17, chars.len()] } else { [17, 113, chars.len()] }; + for chunk_size in chunk_sizes { + let mut app = connected_app().0; + app.status = AppStatus::Ready; + update(&mut app, model::SessionUpdate::AgentResponseStarted); + let mut committed = Vec::new(); + let mut excluded = BTreeSet::new(); + let mut committed_intro_while_running = false; + for chunk in chars.chunks(chunk_size) { + update(&mut app, text(&chunk.iter().collect::())); + committed.extend(take_text(&mut app, &mut excluded, true)); + committed_intro_while_running |= + committed.iter().any(|row| row.contains("Introduction.")); + } + assert!( + committed_intro_while_running, + "ordinary completed paragraphs should still reach scrollback during a long list" + ); + handle_client_event(&mut app, turn_complete(None)); + committed.extend(take_text(&mut app, &mut excluded, false)); + assert_eq!( + crate::app::copy::choices(&app)[0].text, + source, + "SDK chunk size {chunk_size} must preserve the copied Markdown exactly" + ); + assert_eq!( + committed, expected, + "SDK chunk size {chunk_size} changed the displayed list or duplicated committed rows" + ); + } + } +} diff --git a/src/app/events/tests/mod.rs b/src/app/events/tests/mod.rs index 83b87078..e131539b 100644 --- a/src/app/events/tests/mod.rs +++ b/src/app/events/tests/mod.rs @@ -19,6 +19,7 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use tokio::sync::oneshot; +mod assistant_lifecycle; mod client_events; mod clipboard_paste; mod slash_input; @@ -48,6 +49,7 @@ fn autonomous_response_owns_fresh_text_and_preserves_an_existing_live_owner() { assert_eq!(app.active_turn_assistant_idx(), Some(1)); let active_id = app.transcript.messages[1].id; assert!(app.turn.activity.is_some()); + assistant_lifecycle::assert_owner_has_no_rows(&mut app, 1); for text in ["The", " background task finished completely."] { handle_client_event( &mut app, @@ -1245,16 +1247,6 @@ fn startup_resume_history_renders_from_canonical_messages() { assert!(canonical_messages_contain_text(&app, "startup assistant reply")); assert!(live_rows_contain_text(&mut app, "startup user line")); assert!(live_rows_contain_text(&mut app, "startup assistant reply")); - let rows = crate::ui::inline_chat_rows::serialize_live_rows_with_boundaries_excluding( - &mut app, - 80, - &std::collections::BTreeSet::new(), - ); - let texts: Vec<_> = - rows.rows().iter().map(|line| line.to_string().trim_end().to_owned()).collect(); - assert!( - texts.windows(4).any(|rows| rows == ["User", "startup user line", "", "second paragraph"]) - ); let user = app .transcript .messages @@ -1366,8 +1358,8 @@ fn resume_history_preserves_user_message_boundaries_and_joins_only_same_source_c ["/rename review-444", "/color blue", "Explain literal XML"] { assert!( - text.windows(2).any(|rows| rows == ["User", message]), - "separate user heading and text for {message}, startup={startup}" + text.iter().any(|row| row == &format!(" > {message}")), + "separate user marker and text for {message}, startup={startup}" ); } } diff --git a/src/app/events/tool_calls.rs b/src/app/events/tool_calls.rs index 9fec2738..926b1972 100644 --- a/src/app/events/tool_calls.rs +++ b/src/app/events/tool_calls.rs @@ -1,14 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 Simon Peter Rothgang -use super::super::{ - App, BlockCache, ChatMessage, InvalidationLevel, MessageBlock, MessageRole, ToolCallInfo, - ToolCallScope, -}; +use super::super::{App, BlockCache, InvalidationLevel, MessageBlock, ToolCallInfo, ToolCallScope}; use super::tool_updates::raw_output_to_terminal_text; use crate::agent::model; +use crate::app::TranscriptUpdateSource; -pub(super) fn handle_tool_call(app: &mut App, tc: model::ToolCall) { +pub(super) fn handle_tool_call(app: &mut App, tc: model::ToolCall, source: TranscriptUpdateSource) { let id_str = tc.tool_call_id.clone(); let sdk_tool_name = resolve_sdk_tool_name(tc.kind, tc.meta.as_ref()); let parent_tool_use_id = parent_tool_use_id_from_meta(tc.meta.as_ref()); @@ -19,7 +17,7 @@ pub(super) fn handle_tool_call(app: &mut App, tc: model::ToolCall) { let tool_info = build_tool_info_from_tool_call(app, tc, sdk_tool_name, &scope); log_command_started(app, &tool_info); log_terminal_spawned(app, &tool_info, "initial"); - upsert_tool_call_into_assistant_message(app, tool_info); + upsert_tool_call_into_assistant_message(app, tool_info, source); app.sync_tool_call_history(&id_str); crate::app::tasks::refresh_task_tool_displays(app); @@ -137,7 +135,11 @@ fn build_tool_info_from_tool_call( tool_info } -pub(super) fn upsert_tool_call_into_assistant_message(app: &mut App, tool_info: ToolCallInfo) { +fn upsert_tool_call_into_assistant_message( + app: &mut App, + tool_info: ToolCallInfo, + source: TranscriptUpdateSource, +) { let existing_pos = app.lookup_tool_call(&tool_info.id); if let Some((mi, bi)) = existing_pos { @@ -145,38 +147,12 @@ pub(super) fn upsert_tool_call_into_assistant_message(app: &mut App, tool_info: return; } - if let Some(msg_idx) = app.active_turn_assistant_idx() - && let Some(owner) = app.transcript.messages.get_mut(msg_idx) - { - let block_idx = owner.blocks.len(); - let tc_id = tool_info.id.clone(); - owner.blocks.push(MessageBlock::ToolCall(Box::new(tool_info))); - app.sync_after_message_blocks_changed(msg_idx); - app.index_tool_call(tc_id, msg_idx, block_idx); - return; - } - - let msg_idx = app.transcript.messages.len().saturating_sub(1); - if app.transcript.messages.last().is_some_and(|m| matches!(m.role, MessageRole::Assistant)) { - if let Some(last) = app.transcript.messages.last_mut() { - let block_idx = last.blocks.len(); - let tc_id = tool_info.id.clone(); - last.blocks.push(MessageBlock::ToolCall(Box::new(tool_info))); - app.bind_active_turn_assistant(msg_idx); - app.sync_after_message_blocks_changed(msg_idx); - app.index_tool_call(tc_id, msg_idx, block_idx); - } - } else { - let tc_id = tool_info.id.clone(); - let new_idx = app.transcript.messages.len(); - app.push_message_tracked(ChatMessage::new( - MessageRole::Assistant, - vec![MessageBlock::ToolCall(Box::new(tool_info))], - None, - )); - app.bind_active_turn_assistant(new_idx); - app.index_tool_call(tc_id, new_idx, 0); - } + let msg_idx = app.ensure_assistant_output_owner(source); + let block_idx = app.transcript.messages[msg_idx].blocks.len(); + let tc_id = tool_info.id.clone(); + app.transcript.messages[msg_idx].blocks.push(MessageBlock::ToolCall(Box::new(tool_info))); + app.sync_after_message_blocks_changed(msg_idx); + app.index_tool_call(tc_id, msg_idx, block_idx); } fn update_existing_tool_call(app: &mut App, mi: usize, bi: usize, tool_info: &ToolCallInfo) { diff --git a/src/app/events/turn.rs b/src/app/events/turn.rs index eb0747d6..13500d84 100644 --- a/src/app/events/turn.rs +++ b/src/app/events/turn.rs @@ -750,13 +750,7 @@ pub(super) fn handle_user_message_started_event( ); user.timing.timestamp = message.timestamp; app.push_message_tracked(user); - app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); - app.bind_active_turn_assistant_to_tail(); - if !matches!(app.status, AppStatus::Thinking | AppStatus::Running) { - app.status = AppStatus::Running; - } - app.begin_turn_activity(Instant::now()); - app.enforce_history_retention_tracked(); + app.start_assistant_response(); } tracing::info!( target: crate::logging::targets::APP_INPUT, diff --git a/src/app/input_submit.rs b/src/app/input_submit.rs index a256a81b..41ef76ed 100644 --- a/src/app/input_submit.rs +++ b/src/app/input_submit.rs @@ -179,12 +179,7 @@ fn send_prompt_turn( )]; app.push_message_tracked(ChatMessage::new(MessageRole::User, user_blocks, None)); - // Reserve the assistant output owner immediately after queue admission. - app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); - app.bind_active_turn_assistant_to_tail(); - app.enforce_history_retention_tracked(); - app.status = AppStatus::Running; - app.begin_turn_activity(std::time::Instant::now()); + app.start_assistant_response(); app.session_runtime.prompt_suggestion = None; crate::app::session_runtime::request_context_usage_refresh(app); @@ -320,7 +315,7 @@ mod tests { } #[test] - fn user_paragraph_spacing_survives_submission_queueing_and_paste_expansion() { + fn submission_queueing_and_paste_expansion_preserve_the_user_source() { for queued in [false, true] { for pasted in [false, true] { let (mut app, mut rx) = app_with_connection(); @@ -387,16 +382,6 @@ mod tests { panic!("expected user text"); }; assert_eq!(block.text, source); - let rows = - crate::ui::inline_chat_rows::serialize_live_rows_with_boundaries_excluding( - &mut app, - 80, - &std::collections::BTreeSet::new(), - ); - let texts: Vec<_> = - rows.rows().iter().map(|line| line.to_string().trim_end().to_owned()).collect(); - let start = texts.iter().position(|line| line == "User").expect("user label"); - assert_eq!(&texts[start..start + 4], ["User", "hello", "", "how are you"]); } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 585410d3..a0ceb9cc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -71,6 +71,7 @@ pub use lifecycle::{ }; pub use service_status_check::start_service_status_check; pub use settings::{AppSettings, UpdatePrompt}; +pub(crate) use state::TranscriptUpdateSource; pub use state::{ ActiveCompaction, App, AppStatus, AutocompleteKind, BlockCache, BtwExchangeBlock, CacheMetrics, ChatMessage, ChatMessageId, ChatRenderState, CompactionState, ComposerRenderState, ExtraUsage, diff --git a/src/app/state/messages.rs b/src/app/state/messages.rs index ad52b801..017c7ebc 100644 --- a/src/app/state/messages.rs +++ b/src/app/state/messages.rs @@ -54,6 +54,7 @@ pub enum HistoryOutputId { AssistantLabel(ChatMessageId), AssistantThinking(ChatMessageId), AssistantDuration(ChatMessageId), + AssistantPadding(ChatMessageId), Block(MessageBlockId), ToolCall(String), ToolInteraction(String), @@ -126,6 +127,8 @@ fn random_welcome_tip_seed() -> u64 { pub enum TextBlockSpacing { #[default] None, + /// An internal cache split in the same source: insert no separator when joining. + Continuation, ParagraphBreak, } @@ -146,7 +149,9 @@ impl TextBlockSpacing { existing.push_str("\n\n"); } } - TextBlockSpacing::None | TextBlockSpacing::ParagraphBreak => {} + TextBlockSpacing::None + | TextBlockSpacing::Continuation + | TextBlockSpacing::ParagraphBreak => {} } } @@ -156,7 +161,7 @@ impl TextBlockSpacing { #[must_use] pub fn blank_lines(self) -> usize { match self { - Self::None => 0, + Self::None | Self::Continuation => 0, Self::ParagraphBreak => 1, } } @@ -167,10 +172,10 @@ pub struct TextBlock { pub source_message_uuids: Vec, pub text: String, pub cache: BlockCache, - /// Explicit visual spacing after this block. + /// Source relationship and visual spacing after this block. /// /// This is used when streaming splits one logical assistant message into - /// multiple cached blocks at paragraph boundaries. Rendering consumes this + /// multiple cached blocks inside paragraphs or at paragraph boundaries. Rendering consumes this /// metadata directly so spacing, height measurement, and scroll skipping all /// agree without mutating source text. pub trailing_spacing: TextBlockSpacing, diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index 42bfea8c..1b6000ee 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -50,6 +50,7 @@ pub(crate) use pending_messages::{ pub use repaint::LayoutInvalidation as InvalidationLevel; pub use repaint::LayoutInvalidation; pub use sdk_inventory::SdkInventoryState; +pub(crate) use session_identity::TranscriptUpdateSource; pub use session_runtime::SessionRuntimeState; pub use startup::StartupState; pub use tool_call_info::{ diff --git a/src/app/state/session_identity.rs b/src/app/state/session_identity.rs index 763e4353..27fcff4f 100644 --- a/src/app/state/session_identity.rs +++ b/src/app/state/session_identity.rs @@ -3,7 +3,38 @@ use super::prelude::*; +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum TranscriptUpdateSource { + Live, + HistoryReplay, +} + impl App { + pub(super) fn reserve_assistant_output_owner(&mut self) -> usize { + let index = self.transcript.messages.len(); + self.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); + self.bind_active_turn_assistant(index); + index + } + + /// History chunks can continue the previous message; fresh live output cannot reopen it. + pub(crate) fn ensure_assistant_output_owner( + &mut self, + source: TranscriptUpdateSource, + ) -> usize { + if let Some(index) = self.active_turn_assistant_idx() { + return index; + } + if source == TranscriptUpdateSource::HistoryReplay + && let Some(index) = self.transcript.messages.len().checked_sub(1) + && matches!(self.transcript.messages[index].role, MessageRole::Assistant) + { + self.bind_active_turn_assistant(index); + return index; + } + self.reserve_assistant_output_owner() + } + pub(crate) fn active_session_custom_title(&self) -> Option<&str> { self.session_runtime.session_title.as_deref().or_else(|| { let id = self.session_runtime.session_id.as_ref()?; diff --git a/src/app/state/turn.rs b/src/app/state/turn.rs index 85832955..65f29372 100644 --- a/src/app/state/turn.rs +++ b/src/app/state/turn.rs @@ -121,6 +121,16 @@ impl TurnState { } impl App { + /// Reserve one output owner. Queue acknowledgements can continue an existing SDK phase. + pub(crate) fn start_assistant_response(&mut self) { + self.reserve_assistant_output_owner(); + if !matches!(self.status, AppStatus::Thinking | AppStatus::Running) { + self.status = AppStatus::Running; + } + self.begin_turn_activity(std::time::Instant::now()); + self.enforce_history_retention_tracked(); + } + #[must_use] pub(crate) fn is_agent_turn_active(&self) -> bool { matches!(self.status, AppStatus::Thinking | AppStatus::Running) diff --git a/src/app/terminal_runtime/chat_session.rs b/src/app/terminal_runtime/chat_session.rs index bf647fa2..2134df5f 100644 --- a/src/app/terminal_runtime/chat_session.rs +++ b/src/app/terminal_runtime/chat_session.rs @@ -1359,7 +1359,7 @@ mod tests { ids: vec![id], msg_idx: 0, block_idx: None, - kind: LiveRowBoundaryKind::AssistantLabel, + kind: LiveRowBoundaryKind::Message, start_row, end_row, commit_ready: true, @@ -1416,7 +1416,7 @@ mod tests { app.input.set_text("hello"); let _ = app.input.set_cursor(0, 5); - let backend = render_textarea_to_test_backend(&mut app, Rect::new(0, 0, 4, 1)); + let backend = render_textarea_to_test_backend(&mut app, Rect::new(0, 0, 0, 1)); assert!(!backend.cursor_visible()); } @@ -1594,7 +1594,7 @@ mod tests { .flat_map(|batch| batch.rows.slice(0..batch.rows.len())) .map(line_text) .collect(); - assert_eq!(text, ["Claude", "completed prefix"]); + assert_eq!(text, ["", " ● completed prefix"]); assert!( !batches .iter() @@ -1634,13 +1634,24 @@ mod tests { .map(line_text) .collect::>(); - assert_eq!(batches.iter().flat_map(|batch| batch.confirm_ids.iter()).count(), 1); - assert!( + assert_eq!( batches .iter() - .any(|batch| batch.confirm_ids.contains(&HistoryOutputId::Block(second_id))) + .flat_map(|batch| batch.confirm_ids.iter().cloned()) + .collect::>(), + BTreeSet::from([ + HistoryOutputId::Block(second_id), + HistoryOutputId::AssistantPadding(message_id) + ]) + ); + assert_eq!( + inserted_text + .iter() + .map(|row| row.trim()) + .filter(|row| !row.is_empty()) + .collect::>(), + ["second paragraph"] ); - assert_eq!(inserted_text, vec!["second paragraph"]); } #[test] @@ -1695,7 +1706,7 @@ mod tests { ); let live = serialize_live_rows_with_boundaries_excluding(&mut app, 80, history.confirmed_ids()); - assert!(live.rows().iter().any(|line| line_text(line) == "After the card.")); + assert!(live.rows().iter().any(|line| line_text(line) == " After the card.")); assert!(!live.rows().iter().any(|line| line_text(line).contains("Claude · BTW"))); let combined: Vec<_> = committed_rows.into_iter().chain(live.rows().iter().cloned()).collect(); @@ -1713,7 +1724,7 @@ mod tests { .map(line_text) .collect(); assert_eq!(replay_text.iter().filter(|line| line.contains("Claude · BTW")).count(), 1); - assert!(!replay_text.iter().any(|line| line == "After the card.")); + assert!(!replay_text.iter().any(|line| line == " After the card.")); history.confirm(replay.confirm_ids); crate::app::events::handle_client_event( @@ -1733,7 +1744,7 @@ mod tests { .flat_map(|batch| batch.rows.slice(0..batch.rows.len())) .map(line_text) .collect(); - assert!(final_rows.iter().any(|line| line == "After the card.")); + assert!(final_rows.iter().any(|line| line == " After the card.")); assert!(!final_rows.iter().any(|line| line.contains("Claude · BTW"))); } @@ -1769,11 +1780,18 @@ mod tests { let remaining_live_text = serialized.rows_excluding_ids(&excluded_ids).iter().map(line_text).collect::>(); - assert!(inserted_text.iter().any(|line| line == "Claude")); + assert!(inserted_text.iter().any(|line| line.starts_with(" ●"))); assert!(inserted_text.iter().any(|line| line.contains("Paste freeze"))); assert!(inserted_text.iter().any(|line| line.contains("Scrollback destroyed"))); assert!(!inserted_text.iter().any(|line| line.trim_start().starts_with('|'))); - assert_eq!(remaining_live_text, vec!["streaming tail remains mutable"]); + assert_eq!( + remaining_live_text + .iter() + .map(|row| row.trim()) + .filter(|row| !row.is_empty()) + .collect::>(), + ["streaming tail remains mutable"] + ); } #[test] diff --git a/src/ui/activity_rows.rs b/src/ui/activity_rows.rs index ff290d30..79c86047 100644 --- a/src/ui/activity_rows.rs +++ b/src/ui/activity_rows.rs @@ -11,6 +11,7 @@ use crate::app::{ App, activity::{ActivityHeading, ActivityLabel}, }; +use crate::ui::message_layout::MessageLayout; use crate::ui::{SpinnerState, theme}; /// Composer-only rows: these have no transcript identity or history boundary. @@ -58,18 +59,12 @@ fn railed_rows(line: Line<'static>, width: u16, wrap: bool) -> Vec if width == 0 { return Vec::new(); } - let rail_style = Style::default().fg(theme::RUST_ORANGE); - if width <= 2 { - return vec![Line::from(Span::styled("│", rail_style))]; - } - let body_rows = crate::ui::wrap::wrap_lines_to_physical_rows(&[line], width - 2); + let layout = MessageLayout::new(width); + let body_rows = crate::ui::wrap::wrap_lines_to_physical_rows(&[line], layout.content_width); body_rows .into_iter() .take(if wrap { usize::MAX } else { 1 }) - .map(|mut row| { - row.spans.insert(0, Span::styled("│ ", rail_style)); - row - }) + .map(|row| layout.row(row, Some(("│", theme::RUST_ORANGE)), None)) .collect() } @@ -96,11 +91,9 @@ mod tests { let rows = build_activity_rows(&app, width, now); assert!(rows.len() >= 2); assert!(rows.iter().all(|row| row.width() <= usize::from(width))); - assert!(rows.iter().all(|row| row.to_string().starts_with('│'))); - assert!(rows.iter().all(|row| row.spans[0].style.fg == Some(theme::RUST_ORANGE))); - if width > 2 { - assert!(rows[0].to_string().starts_with("│ ◆ ")); - assert!(rows[1].to_string().starts_with("│ Tip: ")); + if width >= 5 { + assert!(rows[0].to_string().starts_with(" │ ◆ ")); + assert!(rows[1].to_string().starts_with(" │ Tip: ")); assert_eq!(rows[0].spans[1].style.fg, Some(theme::RUST_ORANGE)); } } @@ -111,7 +104,7 @@ mod tests { app.turn.cancel_requested = true; let rows = build_activity_rows(&app, 80, now); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].to_string().trim_end(), "│ ◆ Cancelling…"); + assert_eq!(rows[0].to_string().trim_end(), " │ ◆ Cancelling…"); app.turn.reset_for_turn_exit(); app.status = AppStatus::Ready; assert!(build_activity_rows(&app, 80, now).is_empty()); @@ -133,7 +126,7 @@ mod tests { }, ); let rows = build_activity_rows(&app, 80, now); - assert_eq!(rows[0].to_string().trim_end(), "Claude"); + assert_eq!(rows[0].to_string().trim_end(), " ●"); assert!(rows[1].to_string().contains("Compacting")); assert!(app.transcript.messages.is_empty()); app.turn.compaction.finish(); diff --git a/src/ui/document_table.rs b/src/ui/document_table.rs index b50b8b71..9d7fd112 100644 --- a/src/ui/document_table.rs +++ b/src/ui/document_table.rs @@ -118,7 +118,7 @@ pub fn render_markdown_with_tables( if chunk.trim().is_empty() { continue; } - out.extend(markdown::render_markdown_safe(&chunk, bg)); + out.extend(markdown::render_markdown_at_width(&chunk, width, bg)); } MarkdownBlock::Table(table) => { if !out.is_empty() { @@ -142,20 +142,30 @@ fn split_markdown_tables(text: &str) -> Vec { let mut blocks = Vec::new(); let mut parser = Parser::new_ext(text, parser_options()).into_offset_iter().peekable(); let mut text_start = 0usize; + let mut depth = 0usize; loop { let Some((event, range)) = parser.next() else { break; }; - if let Event::Start(Tag::Table(alignments)) = event { + if let Event::Start(Tag::Table(alignments)) = &event + && depth == 0 + { if text_start < range.start { blocks.push(MarkdownBlock::Text(text[text_start..range.start].to_owned())); } let mut table_end = range.end; - let table = parse_table_ast(alignments, &mut parser, &mut table_end); + let table = parse_table_ast(alignments.clone(), &mut parser, &mut table_end); blocks.push(MarkdownBlock::Table(table)); text_start = table_end; + } else { + // Nested tables belong to their container's layout and available width. + match event { + Event::Start(_) => depth += 1, + Event::End(_) => depth = depth.saturating_sub(1), + _ => {} + } } } diff --git a/src/ui/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs index 0ed12cf9..291d1e21 100644 --- a/src/ui/inline_chat_rows.rs +++ b/src/ui/inline_chat_rows.rs @@ -12,13 +12,14 @@ use crate::ui::live_rows::segments::{ }; pub(crate) use crate::ui::live_rows::segments::{LiveRowBoundaryKind, SerializedLiveRows}; use crate::ui::message::{MessageRenderContext, SpinnerState, render_text_block_cached}; +use crate::ui::message_layout::{self, MessageLayout}; use crate::ui::message_rows::{ MessageRowSegment, build_user_system_message_rows, render_btw_exchange_lines, }; use crate::ui::theme; use crate::ui::tool_call; use crate::ui::welcome; -use crate::ui::wrap::{wrap_lines_to_physical_rows, wrap_markdown_lines_to_physical_rows}; +use crate::ui::wrap::wrap_lines_to_physical_rows; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use std::collections::BTreeSet; @@ -172,14 +173,35 @@ fn render_user_system_live_rows( &mut app.transcript.messages[msg_idx], message_render_context(current_mode_id, width), ); - if let Some(timestamp) = timestamp + let is_user = matches!(app.transcript.messages[msg_idx].role, MessageRole::User); + if is_user { + if let Some(timestamp) = timestamp + && let Some(MessageRowSegment::Lines { lines }) = rendered.segments.first_mut() + && let Some(padding) = lines.first_mut() + { + *padding = message_layout::timestamp_row(×tamp, width, Some(theme::USER_MSG_BG)); + } + } else if let Some(timestamp) = timestamp && let Some(MessageRowSegment::Lines { lines }) = rendered.segments.first_mut() && let Some(label) = lines.first_mut() { label.spans.push(Span::styled(format!(" · {timestamp}"), Style::default().fg(theme::DIM))); } + let rows = if is_user { + // User cards already contain wrapped, framed physical rows. + rendered + .segments + .into_iter() + .flat_map(|segment| match segment { + MessageRowSegment::Blank => vec![Line::default()], + MessageRowSegment::Lines { lines } => lines, + }) + .collect() + } else { + segments_to_physical_rows(&rendered.segments, width) + }; RenderedMessageRows::message( - segments_to_physical_rows(&rendered.segments, width, false), + rows, LiveRowBoundary { ids, msg_idx, @@ -209,17 +231,12 @@ fn render_assistant_live_rows( let active_mutable = active_assistant_message_is_mutable(app, msg_idx); let message_id = app.transcript.messages[msg_idx].id; let presentation = app.activity_presentation(std::time::Instant::now()); - let owns_activity = presentation.as_ref().is_some_and(|presentation| { - presentation.heading == crate::app::activity::ActivityHeading::Assistant(msg_idx) - }); // The traversal also remembers excluded static content and skips invisible messages. // Message ownership and output IDs remain independent of visual speaker grouping. let label_visibility = if previous_block_kind == Some(TopLevelInlineBlockKind::Assistant) || excluded_ids.contains(&HistoryOutputId::AssistantLabel(message_id)) { AssistantLabelVisibility::Hidden - } else if owns_activity { - AssistantLabelVisibility::WithActivity } else { AssistantLabelVisibility::WithContent }; @@ -233,24 +250,9 @@ fn render_assistant_live_rows( active_mutable, ); let selection = select_unexcluded_assistant_items(items, excluded_ids); - let duration_pending = - crate::app::presentation::duration_label(app, &app.transcript.messages[msg_idx]).is_some() - && !excluded_ids - .contains(&HistoryOutputId::AssistantDuration(app.transcript.messages[msg_idx].id)); - if selection.items.is_empty() - && !duration_pending - && !show_thinking - && label_visibility != AssistantLabelVisibility::WithActivity - { - return if selection.had_body_content { - RenderedMessageRows::skipped_transcript_content() - } else { - RenderedMessageRows::empty() - }; - } - let skipped_static_body = selection.skipped_body_before_rendered_content; let spinner = SpinnerState::for_app(app); + let content_width = MessageLayout::new(width).content_width; let mut rendered = render_assistant_rows(AssistantRowsRequest { app: Some(app), message_id, @@ -260,12 +262,12 @@ fn render_assistant_live_rows( width, spinner, show_thinking, - label_visibility, - leading_blank_lines: 0, has_prior_assistant_content: skipped_static_body, }); - if let Some(label) = - crate::app::presentation::duration_label(app, &app.transcript.messages[msg_idx]) + let has_visible_output = !rendered.rows.is_empty() || skipped_static_body; + if has_visible_output + && let Some(label) = + crate::app::presentation::duration_label(app, &app.transcript.messages[msg_idx]) { let ids = vec![HistoryOutputId::AssistantDuration(message_id)]; if !ids_are_excluded(&ids, excluded_ids) { @@ -280,10 +282,16 @@ fn render_assistant_live_rows( }); rendered.rows.extend(wrap_lines_to_physical_rows( &[Line::from(Span::styled(label, Style::default().fg(theme::DIM)))], - width, + content_width, )); } } + frame_assistant_rows(app, msg_idx, width, label_visibility, &mut rendered); + rendered.had_transcript_content |= skipped_static_body; + if has_visible_output && !excluded_ids.contains(&HistoryOutputId::AssistantPadding(message_id)) + { + append_assistant_bottom_padding(&mut rendered, message_id, msg_idx, width, !active_mutable); + } tracing::debug!( target: crate::logging::targets::APP_RENDER, event_name = "inline_chat_assistant_block_built", @@ -300,6 +308,29 @@ fn render_assistant_live_rows( rendered } +fn append_assistant_bottom_padding( + rendered: &mut RenderedMessageRows, + message_id: ChatMessageId, + msg_idx: usize, + width: u16, + commit_ready: bool, +) { + // Keep the trailing gap live until completion, even after a tool or text prefix is committed. + rendered.had_transcript_content = true; + rendered.boundaries.push(LiveRowBoundary { + ids: vec![HistoryOutputId::AssistantPadding(message_id)], + msg_idx, + block_idx: None, + kind: LiveRowBoundaryKind::AssistantPadding, + start_row: rendered.rows.len(), + commit_ready, + }); + rendered.rows.extend( + std::iter::repeat_with(|| MessageLayout::new(width).row(Line::default(), None, None)) + .take(usize::from(message_layout::VERTICAL_PADDING)), + ); +} + fn append_rendered_live_message( rows: &mut Vec>, row_boundaries: &mut Vec, @@ -331,7 +362,6 @@ fn append_rendered_live_message( struct AssistantRenderSelection { items: Vec, skipped_body_before_rendered_content: bool, - had_body_content: bool, } fn select_unexcluded_assistant_items( @@ -340,11 +370,9 @@ fn select_unexcluded_assistant_items( ) -> AssistantRenderSelection { let mut selected = Vec::with_capacity(items.len()); let mut skipped_body_before_rendered_content = false; - let mut had_body_content = false; let mut rendered_body_seen = false; for item in items { - had_body_content = true; if ids_are_excluded(&item.ids, excluded_ids) { if !rendered_body_seen { skipped_body_before_rendered_content = true; @@ -356,11 +384,7 @@ fn select_unexcluded_assistant_items( selected.push(item); } - AssistantRenderSelection { - items: selected, - skipped_body_before_rendered_content, - had_body_content, - } + AssistantRenderSelection { items: selected, skipped_body_before_rendered_content } } fn welcome_output_ids(message: &ChatMessage) -> Vec { @@ -551,8 +575,10 @@ impl PendingAssistantTextRun { } } - const fn can_merge(&self, commit_ready: bool) -> bool { - !self.commit_ready && !commit_ready + fn can_merge(&self, next: &TextBlock, commit_ready: bool) -> bool { + (!self.commit_ready && !commit_ready) + || self.trailing_spacing == TextBlockSpacing::Continuation + || assistant_list_crosses_handoff(&self.text, self.trailing_spacing, next) } fn append( @@ -609,7 +635,7 @@ fn assistant_render_items_from_message( let commit_ready = assistant_text_block_commit_ready(message, block_idx, active_tail_block_idx); if let Some(pending) = pending_text.as_mut() - && pending.can_merge(commit_ready) + && pending.can_merge(text, commit_ready) { let merged_commit_ready = assistant_text_run_commit_ready( message, @@ -767,14 +793,15 @@ fn append_detached_tool_interactions( commit_ready: false, }); rows.push(Line::default()); - rows.extend(render_canonical_tool_rows( + let content = render_canonical_tool_rows( app, msg_idx, block_idx, - message_render_context(current_mode_id, width), + message_render_context(current_mode_id, MessageLayout::new(width).content_width), spinner, true, - )); + ); + rows.extend(message_layout::frame_rows(content, width, None, None, (false, false), &[])); } } @@ -837,8 +864,9 @@ fn assistant_text_run_handoff_requires_markdown_context( return false; }; - assistant_text_handoff_continues_open_table(&text, next) - || assistant_text_handoff_continues_list_item(&text, trailing_spacing, next) + trailing_spacing == TextBlockSpacing::Continuation + || assistant_text_handoff_continues_open_table(&text, next) + || assistant_list_crosses_handoff(&text, trailing_spacing, next) || markdown_source_has_open_fence(&text) || markdown_source_has_open_inline_delimiter(&text) } @@ -873,19 +901,15 @@ fn assistant_text_handoff_continues_open_table(current: &str, next: &TextBlock) starts_with_markdown_table_row(&next.text) } -fn assistant_text_handoff_continues_list_item( +fn assistant_list_crosses_handoff( current: &str, - trailing_spacing: TextBlockSpacing, + spacing: TextBlockSpacing, next: &TextBlock, ) -> bool { - trailing_spacing == TextBlockSpacing::None - && markdown_source_ends_with_list_item(current) - && markdown_source_starts_with_list_continuation(&next.text) -} - -fn markdown_source_starts_with_list_continuation(text: &str) -> bool { - markdown_source_boundary_line(text, BoundaryLine::First) - .is_some_and(|line| !markdown_source_line_is_list_item(line)) + let boundary = current.len(); + let mut source = current.to_owned(); + spacing.append_source(&mut source, &next.text); + super::markdown::list_crosses_boundary(&source, boundary) } fn next_visible_assistant_text(message: &ChatMessage, block_idx: usize) -> Option<&TextBlock> { @@ -963,7 +987,6 @@ fn leading_blank_lines_between( enum AssistantLabelVisibility { Hidden, WithContent, - WithActivity, } struct AssistantRowsRequest<'a> { @@ -975,8 +998,6 @@ struct AssistantRowsRequest<'a> { width: u16, spinner: SpinnerState, show_thinking: bool, - label_visibility: AssistantLabelVisibility, - leading_blank_lines: usize, has_prior_assistant_content: bool, } @@ -988,17 +1009,13 @@ struct AssistantThinkingMeta { } fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessageRows { - if request.items.is_empty() - && !request.show_thinking - && request.label_visibility != AssistantLabelVisibility::WithActivity - { + let content_width = MessageLayout::new(request.width).content_width; + if request.items.is_empty() && !request.show_thinking { return RenderedMessageRows::empty(); } let mut rows = Vec::new(); let mut boundaries = Vec::new(); - rows.extend(std::iter::repeat_with(Line::default).take(request.leading_blank_lines)); - append_assistant_label_rows(&mut rows, &mut boundaries, &request); let mut state = AssistantInlineLayoutState { has_body_content: request.has_prior_assistant_content, @@ -1007,12 +1024,11 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa let thinking = request.show_thinking.then_some(AssistantThinkingMeta { message_id: request.message_id, msg_idx: request.msg_idx, - width: request.width, + width: content_width, spinner: request.spinner, }); - let mut items = request.items.into_iter().peekable(); - while let Some(item) = items.next() { + for item in request.items { let boundary = AssistantBoundaryMeta { ids: item.ids, msg_idx: item.msg_idx, @@ -1023,9 +1039,9 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa let item_leading_blank_lines = item.leading_blank_lines; match item.item { AssistantRenderItem::Text(block) => { - let trailing_gap = assistant_text_trailing_gap(&block, items.peek()); + let trailing_gap = block.trailing_blank_lines(); let rendered = - render_assistant_text_block(block, request.width, !state.has_visible_content); + render_assistant_text_block(block, content_width, !state.has_visible_content); if !rendered.is_empty() { append_rendered_assistant_item( &mut rows, @@ -1039,9 +1055,9 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa } } AssistantRenderItem::Notice(block) => { - let trailing_gap = assistant_text_trailing_gap(&block.text, items.peek()); + let trailing_gap = block.text.trailing_blank_lines(); let rendered = - render_assistant_notice_block(block, request.width, !state.has_visible_content); + render_assistant_notice_block(block, content_width, !state.has_visible_content); if !rendered.is_empty() { append_rendered_assistant_item( &mut rows, @@ -1081,7 +1097,7 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa app, msg_idx, block_idx, - message_render_context(request.current_mode_id, request.width), + message_render_context(request.current_mode_id, content_width), request.spinner, false, ), @@ -1090,7 +1106,7 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa } } - finish_assistant_rows(rows, boundaries, &state, thinking, request.label_visibility) + finish_assistant_rows(rows, boundaries, &state, thinking) } fn finish_assistant_rows( @@ -1098,12 +1114,8 @@ fn finish_assistant_rows( mut boundaries: Vec, state: &AssistantInlineLayoutState, thinking: Option, - label_visibility: AssistantLabelVisibility, ) -> RenderedMessageRows { - if !state.has_visible_content - && thinking.is_none() - && label_visibility != AssistantLabelVisibility::WithActivity - { + if !state.has_visible_content && thinking.is_none() { return RenderedMessageRows::empty(); } if let Some(meta) = thinking { @@ -1130,88 +1142,6 @@ fn finish_assistant_rows( RenderedMessageRows::rendered(trim_trailing_blank_rows(rows), boundaries) } -fn assistant_text_trailing_gap( - block: &TextBlock, - next_item: Option<&AssistantRenderItemSpec>, -) -> usize { - let gap = block.trailing_blank_lines(); - if gap == 0 { - return 0; - } - - let next_text = next_item.and_then(assistant_item_text); - if text_boundary_touches_markdown_list(&block.text, next_text) { 0 } else { gap } -} - -fn assistant_item_text(item: &AssistantRenderItemSpec) -> Option<&TextBlock> { - match &item.item { - AssistantRenderItem::Text(block) => Some(block), - AssistantRenderItem::Notice(block) => Some(&block.text), - AssistantRenderItem::CanonicalBtw { .. } | AssistantRenderItem::CanonicalTool { .. } => { - None - } - } -} - -fn text_boundary_touches_markdown_list(current: &str, next: Option<&TextBlock>) -> bool { - markdown_source_ends_with_list_item(current) - || next.is_some_and(|block| markdown_source_starts_with_list_item(&block.text)) -} - -fn markdown_source_starts_with_list_item(text: &str) -> bool { - markdown_source_boundary_line(text, BoundaryLine::First) - .is_some_and(markdown_source_line_is_list_item) -} - -fn markdown_source_ends_with_list_item(text: &str) -> bool { - markdown_source_boundary_line(text, BoundaryLine::Last) - .is_some_and(markdown_source_line_is_list_item) -} - -#[derive(Clone, Copy)] -enum BoundaryLine { - First, - Last, -} - -fn markdown_source_boundary_line(text: &str, boundary: BoundaryLine) -> Option<&str> { - let mut in_fenced_code = false; - let mut first = None; - let mut last = None; - - for line in text.lines() { - let trimmed = line.trim(); - let is_fence = trimmed.starts_with("```") || trimmed.starts_with("~~~"); - let in_code_line = in_fenced_code || is_fence; - if is_fence { - in_fenced_code = !in_fenced_code; - } - if in_code_line || trimmed.is_empty() { - continue; - } - first.get_or_insert(line); - last = Some(line); - } - - match boundary { - BoundaryLine::First => first, - BoundaryLine::Last => last, - } -} - -fn markdown_source_line_is_list_item(line: &str) -> bool { - let trimmed = line.trim_start(); - trimmed.starts_with("- ") - || trimmed.starts_with("* ") - || trimmed.starts_with("+ ") - || markdown_source_starts_with_ordered_list_marker(trimmed) -} - -fn markdown_source_starts_with_ordered_list_marker(text: &str) -> bool { - let digit_count = text.bytes().take_while(u8::is_ascii_digit).count(); - digit_count > 0 && text[digit_count..].starts_with(". ") -} - fn markdown_source_has_open_fence(text: &str) -> bool { let mut in_fenced_code = false; for line in text.lines() { @@ -1255,43 +1185,66 @@ fn markdown_source_has_open_inline_delimiter(text: &str) -> bool { in_code || strong_asterisk_open || strong_underscore_open } -fn append_assistant_label_rows( - rows: &mut Vec>, - boundaries: &mut Vec, - request: &AssistantRowsRequest<'_>, +fn frame_assistant_rows( + app: &App, + msg_idx: usize, + width: u16, + visibility: AssistantLabelVisibility, + rendered: &mut RenderedMessageRows, ) { - if request.label_visibility == AssistantLabelVisibility::Hidden { + let show_marker = visibility != AssistantLabelVisibility::Hidden; + if rendered.rows.is_empty() { return; } - - let first_commit_ready = request.items.first().is_some_and(|item| item.commit_ready) - && request.app.as_deref().is_none_or(|app| { - !app.config.show_message_timestamps_effective() - || app.transcript.messages[request.msg_idx] - .timing - .timestamp - .is_some_and(|timestamp| !timestamp.observed) - || !active_assistant_message_is_mutable(app, request.msg_idx) - }); - let label_start = rows.len().saturating_sub(request.leading_blank_lines); - boundaries.push(LiveRowBoundary { - ids: vec![HistoryOutputId::AssistantLabel(request.message_id)], - msg_idx: request.msg_idx, - block_idx: None, - kind: LiveRowBoundaryKind::AssistantLabel, - start_row: label_start, - commit_ready: first_commit_ready, - }); - let mut label = assistant_role_label_line(); - if let Some(app) = request.app.as_deref() - && let Some(timestamp) = crate::app::presentation::timestamp_label( - app, - &app.transcript.messages[request.msg_idx], - ) + // Side-question cards already own a full-width frame and their own role heading. + let full_width_rows = rendered + .boundaries + .iter() + .enumerate() + .filter(|(_, boundary)| boundary.kind == LiveRowBoundaryKind::AssistantBtw) + .map(|(index, boundary)| { + boundary.start_row + ..rendered + .boundaries + .get(index + 1) + .map_or(rendered.rows.len(), |next| next.start_row) + }) + .collect::>(); + if show_marker { + let label_id = HistoryOutputId::AssistantLabel(app.transcript.messages[msg_idx].id); + let timestamp_ready = !app.config.show_message_timestamps_effective() + || app.transcript.messages[msg_idx] + .timing + .timestamp + .is_some_and(|timestamp| !timestamp.observed) + || !active_assistant_message_is_mutable(app, msg_idx); + if let Some(first) = rendered.boundaries.first_mut() { + first.ids.push(label_id); + first.commit_ready &= timestamp_ready; + } + } + for boundary in &mut rendered.boundaries { + boundary.start_row += + usize::from(show_marker) * usize::from(message_layout::VERTICAL_PADDING); + } + if let Some(first) = rendered.boundaries.first_mut() { + first.start_row = 0; + } + rendered.rows = message_layout::frame_rows( + std::mem::take(&mut rendered.rows), + width, + show_marker.then_some((theme::ASSISTANT_MESSAGE_MARKER, theme::ROLE_ASSISTANT)), + None, + (show_marker, false), + &full_width_rows, + ); + if show_marker + && let Some(timestamp) = + crate::app::presentation::timestamp_label(app, &app.transcript.messages[msg_idx]) + && let Some(padding) = rendered.rows.first_mut() { - label.spans.push(Span::styled(format!(" · {timestamp}"), Style::default().fg(theme::DIM))); + *padding = message_layout::timestamp_row(×tamp, width, None); } - rows.extend(wrap_lines_to_physical_rows(&[label], request.width)); } fn append_rendered_assistant_item( @@ -1355,7 +1308,7 @@ fn render_assistant_text_block( } else { lines }; - wrap_markdown_lines_to_physical_rows(&lines, width) + wrap_lines_to_physical_rows(&lines, width) } fn trim_trailing_blank_rows(mut rows: Vec>) -> Vec> { @@ -1460,7 +1413,7 @@ fn render_canonical_tool_rows( pub(crate) fn assistant_role_label_line() -> Line<'static> { Line::from(vec![ratatui::text::Span::styled( - "Claude", + format!(" {}", theme::ASSISTANT_MESSAGE_MARKER), Style::default().fg(theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), )]) } @@ -1477,20 +1430,13 @@ fn line_is_blank(line: &Line<'_>) -> bool { line.spans.iter().all(|span| span.content.as_ref().chars().all(char::is_whitespace)) } -fn segments_to_physical_rows( - segments: &[MessageRowSegment], - width: u16, - skip_first_segment: bool, -) -> Vec> { +fn segments_to_physical_rows(segments: &[MessageRowSegment], width: u16) -> Vec> { let mut rows = Vec::new(); - for (idx, segment) in segments.iter().enumerate() { - if skip_first_segment && idx == 0 { - continue; - } + for segment in segments { match segment { MessageRowSegment::Blank => rows.push(Line::default()), MessageRowSegment::Lines { lines } => { - rows.extend(wrap_markdown_lines_to_physical_rows(lines, width)); + rows.extend(wrap_lines_to_physical_rows(lines, width)); } } } @@ -1545,7 +1491,7 @@ mod tests { use crate::agent::model; use crate::app::{ App, AppStatus, BlockCache, ChatMessage, ChatMessageId, HistoryOutputId, MessageBlock, - MessageRole, NoticeBlock, TextBlock, TextBlockSpacing, ToolCallInfo, + MessageRole, TextBlock, TextBlockSpacing, ToolCallInfo, }; use ratatui::text::Line; use std::collections::BTreeSet; @@ -1580,13 +1526,21 @@ mod tests { ids: vec![HistoryOutputId::AssistantLabel(ChatMessageId::new())], msg_idx: 0, block_idx: None, - kind: LiveRowBoundaryKind::AssistantLabel, + kind: LiveRowBoundaryKind::Message, start_row, end_row, commit_ready, } } + fn assert_assistant_marker(rows: &[Line<'static>], expected: bool) { + assert_eq!( + line_texts(rows).iter().filter(|row| row.trim_start().starts_with('\u{25cf}')).count(), + usize::from(expected), + "the active response owns one marker; waiting or absent responses own none" + ); + } + #[test] fn serialized_live_rows_skip_invalid_segment_ranges() { let rows = vec![Line::from("first"), Line::from("second")]; @@ -1618,6 +1572,185 @@ mod tests { ) } + fn rendered_buffer(rows: &[Line<'static>], width: u16) -> ratatui::buffer::Buffer { + use ratatui::widgets::Widget; + let area = ratatui::layout::Rect::new(0, 0, width, u16::try_from(rows.len()).unwrap()); + let mut buffer = ratatui::buffer::Buffer::empty(area); + ratatui::widgets::Paragraph::new(rows.to_vec()).render(area, &mut buffer); + buffer + } + + #[test] + fn user_card_fills_every_cell_and_aligns_text_images_and_paragraphs_with_the_composer() { + for width in [30, 80, 120] { + let mut app = App::test_default(); + let mut message = user_text_message("alpha\n\nomega"); + message + .blocks + .push(MessageBlock::ImageAttachment(crate::app::ImageAttachmentBlock::new(2))); + app.transcript.messages.push(message); + let serialized = serialize_all_rows_with_boundaries(&mut app, width); + let buffer = rendered_buffer(serialized.rows(), width); + assert_eq!(buffer.area.height, 6, "two padding rows, three text rows and an image row"); + for row in 0..6 { + for column in 0..width { + assert_eq!( + buffer[(column, row)].bg, + crate::ui::theme::USER_MSG_BG, + "({column}, {row}) at width {width}" + ); + } + } + assert_eq!(buffer[(1, 1)].symbol(), ">"); + assert_eq!(buffer[(1, 1)].fg, crate::ui::theme::DIM); + assert_eq!(buffer[(3, 1)].symbol(), "a"); + assert_eq!(buffer[(3, 3)].symbol(), "o"); + assert_eq!(buffer[(3, 4)].symbol(), "["); + for row in [0, 2, 5] { + assert!((0..width).all(|column| buffer[(column, row)].symbol() == " ")); + } + let geometry = crate::ui::input::compute_render_geometry( + ratatui::layout::Rect::new(0, 0, width, 3), + 0, + ); + assert_eq!(geometry.text.x, 3); + let MessageBlock::Text(source) = &app.transcript.messages[0].blocks[0] else { + panic!("user text"); + }; + assert_eq!(source.text, "alpha\n\nomega"); + } + } + + #[test] + fn long_user_cards_preserve_unicode_and_all_lines_after_resize_without_tinting_the_reply() { + let source = (1..=24) + .map(|number| format!("界e\u{301} item{number:02}")) + .collect::>() + .join("\n"); + let mut app = App::test_default(); + app.transcript.messages.push(user_text_message(&source)); + app.transcript.messages.push(assistant_text_message("Reply")); + + for width in [80, 8, 12, 80] { + let serialized = serialize_all_rows_with_boundaries(&mut app, width); + let user = serialized.segments().iter().find(|segment| segment.msg_idx == 0).unwrap(); + let user_rows = serialized.segment_rows(user).unwrap(); + assert!(user_rows.len() >= 26, "submitted text must not inherit the editor height cap"); + assert!(serialized.rows().iter().all(|row| row.width() <= usize::from(width))); + let buffer = rendered_buffer(serialized.rows(), width); + let mut visible = String::new(); + for row in 0..u16::try_from(user_rows.len()).unwrap() { + let mut column = 0; + while column < width { + let cell = &buffer[(column, row)]; + assert_eq!(cell.bg, crate::ui::theme::USER_MSG_BG); + if column >= 3 { + visible.push_str(cell.symbol()); + } + // Wide glyphs paint their continuation cells; those buffer slots are unused. + column += u16::try_from(unicode_width::UnicodeWidthStr::width(cell.symbol())) + .unwrap() + .max(1); + } + } + assert_eq!( + visible.chars().filter(|character| !character.is_whitespace()).collect::(), + source.chars().filter(|character| !character.is_whitespace()).collect::(), + "no clipped, missing or duplicated text at width {width}" + ); + for row in u16::try_from(user_rows.len()).unwrap()..buffer.area.height { + assert!( + (0..width) + .all(|column| { buffer[(column, row)].bg == ratatui::style::Color::Reset }) + ); + } + let MessageBlock::Text(text) = &app.transcript.messages[0].blocks[0] else { + panic!("user text"); + }; + assert_eq!(text.text, source); + } + } + + #[test] + fn agent_marker_and_tools_share_the_content_column_without_a_card_background() { + for tool_first in [false, true] { + let mut app = App::test_default(); + let mut blocks = vec![tool_call_block("layout-tool", false)]; + if !tool_first { + blocks.insert(0, MessageBlock::Text(TextBlock::from_complete("Opening text"))); + } + blocks.push(MessageBlock::Text(TextBlock::from_complete("Final text"))); + app.transcript.messages.push(assistant_blocks_message(blocks)); + let serialized = serialize_all_rows_with_boundaries(&mut app, 80); + let buffer = rendered_buffer(serialized.rows(), 80); + assert_eq!(buffer[(1, 1)].symbol(), "\u{25cf}"); + assert_eq!(buffer[(1, 1)].fg, crate::ui::theme::ROLE_ASSISTANT); + let text = line_texts(serialized.rows()); + let tool = text.iter().position(|row| row.contains("Child Tool")).unwrap(); + assert_eq!( + buffer[(3, u16::try_from(tool).unwrap())].symbol(), + crate::ui::theme::ICON_COMPLETED + ); + let final_text = text.iter().position(|row| row.contains("Final text")).unwrap(); + assert_eq!(buffer[(3, u16::try_from(final_text).unwrap())].symbol(), "F"); + if !tool_first { + assert_eq!(buffer[(3, 1)].symbol(), "O"); + } + assert!(buffer.content.iter().all(|cell| cell.bg == ratatui::style::Color::Reset)); + assert_eq!(text.iter().filter(|row| row.contains('\u{25cf}')).count(), 1); + assert!(text.first().unwrap().is_empty()); + assert!(text.last().unwrap().is_empty()); + } + } + + #[test] + fn committed_agent_prefix_owns_its_marker_but_bottom_padding_waits_for_completion() { + let mut app = App::test_default(); + app.transcript.messages.push(assistant_blocks_message(vec![ + MessageBlock::Text( + TextBlock::from_complete("Stable prefix") + .with_trailing_spacing(TextBlockSpacing::ParagraphBreak), + ), + MessageBlock::Text(TextBlock::from_complete("Streaming tail")), + ])); + app.bind_active_turn_assistant(0); + app.status = AppStatus::Running; + let first = serialize_all_rows_with_boundaries(&mut app, 40); + let stable = &first.rows()[..first.stable_row_count()]; + assert!(line_texts(stable).iter().any(|row| row == " \u{25cf} Stable prefix")); + let excluded = first + .segments() + .iter() + .filter(|segment| segment.commit_ready) + .flat_map(|segment| segment.ids.iter().cloned()) + .collect::>(); + assert!(excluded.contains(&HistoryOutputId::AssistantLabel(app.transcript.messages[0].id))); + assert!( + !excluded.contains(&HistoryOutputId::AssistantPadding(app.transcript.messages[0].id)) + ); + let tail = serialize_live_rows_with_boundaries_excluding(&mut app, 40, &excluded); + assert!( + !line_texts(tail.rows()) + .iter() + .any(|row| row.contains('\u{25cf}') || row.contains("Stable prefix")) + ); + assert!(line_texts(tail.rows()).iter().any(|row| row == " Streaming tail")); + app.status = AppStatus::Ready; + let complete = serialize_live_rows_with_boundaries_excluding(&mut app, 40, &excluded); + assert_eq!(complete.stable_row_count(), complete.rows().len()); + let padding = complete + .segments() + .iter() + .find(|segment| { + segment + .ids + .contains(&HistoryOutputId::AssistantPadding(app.transcript.messages[0].id)) + }) + .unwrap(); + assert!(padding.commit_ready); + assert_eq!(complete.segment_rows(padding).unwrap().len(), 1); + } + fn assistant_message() -> ChatMessage { ChatMessage::new(MessageRole::Assistant, Vec::new(), None) } @@ -1686,8 +1819,12 @@ mod tests { .find(|segment| segment.ids.contains(&HistoryOutputId::ToolCall(id.to_owned()))) .expect("tool has its own transcript segment"); let rows = rendered.segment_rows(segment).unwrap(); - assert_eq!(rows.len(), 1, "title-only tool must stay on one row"); - assert!(line_text(&rows[0]).ends_with(expected)); + assert_eq!( + rows.iter().filter(|row| !line_text(row).trim().is_empty()).count(), + 1, + "title-only tool has one content row" + ); + assert!(rows.iter().any(|row| line_text(row).ends_with(expected))); assert!(segment.commit_ready); } let bytes = app.enforce_render_cache_budget().total_before_bytes; @@ -1866,46 +2003,35 @@ mod tests { } #[test] - fn live_rows_do_not_start_with_synthetic_blank_row() { - let mut app = App::test_default(); - app.transcript.messages.push(assistant_text_message("hi")); - - let rows = serialize_live_rows(&mut app, 120); - - assert!(rows.first().is_some_and(|line| !line_text(line).trim().is_empty())); - assert_eq!(line_text(&rows[0]), "Claude"); - } - - #[test] - fn live_rows_render_user_row_while_assistant_streams() { - let mut app = App::test_default(); - app.push_message_tracked(user_text_message("hello")); - app.transcript.messages.push(assistant_text_message("still streaming")); - - let rows = serialize_live_rows(&mut app, 120); - let text = line_texts(&rows); - - assert_eq!(text, vec!["User", "hello", "Claude", "still streaming"]); - } - - #[test] - fn user_paragraph_gap_survives_physical_wrapping_while_assistant_streams() { + fn user_paragraphs_stay_separate_from_the_streaming_reply_after_resize() { let mut app = App::test_default(); app.push_message_tracked(user_text_message("hello hello hello hello\n\nhow are you")); - app.transcript.messages.push(assistant_text_message("still streaming")); + app.transcript.messages.push(assistant_text_message("live reply")); + app.bind_active_turn_assistant(1); + app.status = AppStatus::Running; for width in [80, 12, 80] { - let rows = serialize_live_rows(&mut app, width); - let texts: Vec<_> = - line_texts(&rows).into_iter().map(|line| line.trim_end().to_owned()).collect(); - let assistant = - texts.iter().position(|line| line == "Claude").expect("assistant label"); - let expected = if width == 12 { - vec!["User", "hello hello", "hello hello", "", "how are you"] - } else { - vec!["User", "hello hello hello hello", "", "how are you"] - }; - assert_eq!(&texts[..assistant], expected, "width={width}"); + let serialized = serialize_all_rows_with_boundaries(&mut app, width); + let user = serialized.segments().iter().find(|segment| segment.msg_idx == 0).unwrap(); + assert!(user.commit_ready); + let user_text = line_texts(serialized.segment_rows(user).unwrap()); + let paragraphs = user_text + .iter() + .map(|row| row.chars().skip(3).collect::()) + .collect::>(); + let paragraphs = paragraphs + .split(|row| row.trim().is_empty()) + .filter(|paragraph| !paragraph.is_empty()) + .map(|paragraph| paragraph.join(" ")) + .collect::>(); + assert_eq!(paragraphs, ["hello hello hello hello", "how are you"]); + let reply = line_texts(&serialized.rows()[serialized.stable_row_count()..]) + .into_iter() + .map(|row| row.chars().skip(3).collect::()) + .filter(|row| !row.is_empty()) + .collect::>() + .join(" "); + assert_eq!(reply, "live reply"); } } @@ -1931,41 +2057,10 @@ mod tests { "narrow rows should wrap more physical rows; narrow={narrow_text:?}, wide={wide_text:?}" ); assert_eq!(compact_text(&narrow_rows), compact_text(&wide_rows)); - assert!(narrow_text.contains("User")); - assert!(narrow_text.contains("Claude")); - assert!(wide_text.contains("User")); - assert!(wide_text.contains("Claude")); - } - - #[test] - fn live_row_boundaries_stop_stable_prefix_before_active_assistant() { - let mut app = App::test_default(); - app.push_message_tracked(user_text_message("hello")); - app.transcript.messages.push(assistant_text_message("still streaming")); - app.bind_active_turn_assistant(1); - app.status = AppStatus::Running; - - let serialized = serialize_all_rows_with_boundaries(&mut app, 120); - - assert_eq!(serialized.stable_row_count(), 2); - } - - #[test] - fn active_assistant_commits_completed_text_before_streaming_tail() { - let mut app = App::test_default(); - app.transcript.messages.push(assistant_blocks_message(vec![ - MessageBlock::Text(TextBlock::from_complete("prefix")), - MessageBlock::Text(TextBlock::from_complete("tail")), - ])); - app.bind_active_turn_assistant(0); - app.status = AppStatus::Running; - - let serialized = serialize_all_rows_with_boundaries(&mut app, 120); - let stable_text = line_texts(&serialized.rows()[..serialized.stable_row_count()]); - let mutable_text = line_texts(&serialized.rows()[serialized.stable_row_count()..]); - - assert_eq!(stable_text, vec!["Claude", "prefix"]); - assert_eq!(mutable_text, vec!["tail"]); + assert!(narrow_text.contains(" > ")); + assert!(narrow_text.contains(" ● ")); + assert!(wide_text.contains(" > ")); + assert!(wide_text.contains(" ● ")); } #[test] @@ -1988,7 +2083,7 @@ mod tests { let stable_text = line_texts(&serialized.rows()[..serialized.stable_row_count()]); let mutable_text = line_texts(&serialized.rows()[serialized.stable_row_count()..]); - assert!(stable_text.iter().any(|line| line == "Claude")); + assert!(stable_text.iter().any(|line| line.starts_with(" ●"))); assert!(stable_text.iter().any(|line| line.contains("Child Tool"))); assert!(mutable_text.iter().any(|line| line.contains("Child Tool"))); assert!(mutable_text.iter().any(|line| line.contains("select"))); @@ -2005,7 +2100,19 @@ mod tests { let serialized = serialize_all_rows_with_boundaries(&mut app, 120); - assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + let tool = serialized + .segments() + .iter() + .find(|segment| segment.ids.contains(&HistoryOutputId::ToolCall("done-tool".into()))) + .unwrap(); + assert!(tool.commit_ready); + assert!( + serialized + .segment_rows(tool) + .unwrap() + .iter() + .any(|row| line_text(row).contains("Child Tool")) + ); } #[test] @@ -2028,7 +2135,7 @@ mod tests { assert_eq!(serialized.stable_row_count(), 0); assert_eq!( serialized.first_mutable_boundary_kind(), - Some(LiveRowBoundaryKind::AssistantLabel) + Some(LiveRowBoundaryKind::AssistantTool) ); } @@ -2076,20 +2183,6 @@ mod tests { assert!(rows.iter().any(|line| line.contains("Actual web output"))); } - #[test] - fn live_rows_render_committed_assistant_prefix_before_live_tail() { - let mut app = App::test_default(); - app.transcript.messages.push(assistant_blocks_message(vec![ - MessageBlock::Text(TextBlock::from_complete("prefix")), - MessageBlock::Text(TextBlock::from_complete("tail")), - ])); - - let rows = serialize_live_rows(&mut app, 120); - let text = line_texts(&rows); - - assert_eq!(text, vec!["Claude", "prefix", "tail"]); - } - #[test] fn live_adjacent_text_blocks_preserve_paragraph_gap() { let mut app = App::test_default(); @@ -2103,11 +2196,15 @@ mod tests { let rows = serialize_live_rows(&mut app, 120); - assert_eq!(line_texts(&rows), vec!["Claude", "line 1: ready", "", "line 2: ready"]); + let text = line_texts(&rows); + let first = text.iter().position(|row| row.ends_with("line 1: ready")).unwrap(); + let second = text.iter().position(|row| row.ends_with("line 2: ready")).unwrap(); + assert_eq!(second - first, 2); + assert!(text[first + 1].trim().is_empty()); } #[test] - fn live_assistant_text_suppresses_paragraph_gap_before_list_block() { + fn live_assistant_text_preserves_paragraph_gap_before_list_block() { let mut app = App::test_default(); app.transcript.messages.push(assistant_blocks_message(vec![ MessageBlock::Text( @@ -2119,11 +2216,15 @@ mod tests { let rows = serialize_live_rows(&mut app, 120); - assert_eq!(line_texts(&rows), vec!["Claude", "Intro", " - One", " - Two"]); + let text = line_texts(&rows); + let intro = text.iter().position(|row| row.ends_with("Intro")).unwrap(); + assert!(text[intro + 1].trim().is_empty()); + assert!(text[intro + 2].trim_start().starts_with("- One")); + assert!(text[intro + 3].trim_start().starts_with("- Two")); } #[test] - fn live_assistant_text_suppresses_paragraph_gap_after_list_block() { + fn live_assistant_text_preserves_paragraph_gap_after_list_block() { let mut app = App::test_default(); app.transcript.messages.push(assistant_blocks_message(vec![ MessageBlock::Text( @@ -2135,7 +2236,10 @@ mod tests { let rows = serialize_live_rows(&mut app, 120); - assert_eq!(line_texts(&rows), vec!["Claude", " - One", " - Two", "Outro"]); + let text = line_texts(&rows); + let last_item = text.iter().position(|row| row.trim() == "- Two").unwrap(); + assert!(text[last_item + 1].trim().is_empty()); + assert_eq!(text[last_item + 2].trim(), "Outro"); } #[test] @@ -2147,37 +2251,10 @@ mod tests { let rows = serialize_live_rows(&mut app, 120); - assert_eq!( - line_texts(&rows), - vec!["Claude", "line 1: ready", "line 2: ready", "line 3: ready"] - ); - } - - #[test] - fn live_rows_render_assistant_notice_from_messages() { - let mut app = App::test_default(); - app.transcript.messages.push(assistant_blocks_message(vec![MessageBlock::Notice( - NoticeBlock::from_complete(crate::app::SystemSeverity::Warning, "watch this"), - )])); - - let rows = serialize_live_rows(&mut app, 120); - let text = line_texts(&rows); - - assert_eq!(text, vec!["Claude", "watch this"]); - } - - #[test] - fn live_rows_render_visible_tool_from_canonical_message_block() { - let mut app = App::test_default(); - app.transcript - .messages - .push(assistant_blocks_message(vec![tool_call_block("tool-1", false)])); - - let rows = serialize_live_rows(&mut app, 120); let text = line_texts(&rows); - - assert!(text.iter().any(|line| line == "Claude")); - assert!(text.iter().any(|line| line.contains("Child Tool"))); + let first = text.iter().position(|row| row.ends_with("line 1: ready")).unwrap(); + assert_eq!(text[first + 1].trim(), "line 2: ready"); + assert_eq!(text[first + 2].trim(), "line 3: ready"); } #[test] @@ -2201,7 +2278,10 @@ mod tests { serialize_live_rows_with_boundaries_excluding(&mut app, 120, &excluded_ids); let text = line_texts(serialized.rows()); - assert_eq!(text, vec!["second paragraph"]); + assert_eq!( + text.iter().map(|row| row.trim()).filter(|row| !row.is_empty()).collect::>(), + ["second paragraph"] + ); assert!( serialized .segments() @@ -2267,11 +2347,11 @@ mod tests { let text = line_texts(&rows); assert!( - text.iter().any(|line| line.starts_with(" 3. Keymap survey")), + text.iter().any(|line| line.starts_with(" ● 3. Keymap survey")), "missing ordered list prefix: {text:?}" ); assert!( - text.iter().any(|line| line.starts_with(" keymap system")), + text.iter().any(|line| line.starts_with(" ") && !line.trim().is_empty()), "list continuation lost hanging indent: {text:?}" ); } @@ -2300,11 +2380,54 @@ mod tests { assert_eq!(serialized.stable_row_count(), 0); assert!( - remaining_text.iter().any(|line| line.starts_with(" 3. Keymap survey")), + remaining_text.iter().any(|line| line.starts_with(" ● 3. Keymap survey")), "missing ordered list prefix: {remaining_text:?}" ); } + #[test] + fn streamed_list_paragraphs_keep_numbering_and_never_commit_an_incomplete_list() { + let pieces = [ + "9. Parent item.\n\n", + " Continuation paragraph.\n\n", + " - Nested child.\n", + "10. Following item.", + ]; + for width in [32, 80] { + let mut streamed = App::test_default(); + streamed.transcript.messages.push(assistant_blocks_message(Vec::new())); + streamed.bind_active_turn_assistant(0); + streamed.status = AppStatus::Running; + for piece in pieces { + streamed.transcript.messages[0] + .blocks + .push(MessageBlock::Text(TextBlock::from_complete(piece))); + let partial = serialize_all_rows_with_boundaries(&mut streamed, width); + assert_eq!( + partial.stable_row_count(), + 0, + "list context must stay mutable until the list ends: {piece:?}" + ); + } + let mut whole = App::test_default(); + whole.transcript.messages.push(assistant_text_message(&pieces.concat())); + whole.bind_active_turn_assistant(0); + whole.status = AppStatus::Running; + assert_eq!( + line_texts(&serialize_live_rows(&mut streamed, width)), + line_texts(&serialize_live_rows(&mut whole, width)), + "stream boundaries must not change list structure" + ); + streamed.status = AppStatus::Ready; + whole.status = AppStatus::Ready; + assert_eq!( + line_texts(&serialize_live_rows(&mut streamed, width)), + line_texts(&serialize_live_rows(&mut whole, width)), + "completion must preserve the list" + ); + } + } + #[test] fn active_assistant_inline_bold_handoff_keeps_prefix_mutable_until_closed() { let first = TextBlock::from_complete("This is **bold"); @@ -2406,7 +2529,7 @@ mod tests { serialize_live_rows_with_boundaries_excluding(&mut app, 120, &excluded_ids); let text = line_texts(serialized.rows()); - assert!(!text.iter().any(|line| line == "Claude")); + assert!(!text.iter().any(|line| line.starts_with(" ●"))); assert!(!text.iter().any(|line| line.contains("Done Tool"))); assert!(text.iter().any(|line| line.contains("Running Tool"))); assert_eq!(serialized.stable_row_count(), 0); @@ -2417,45 +2540,41 @@ mod tests { } #[test] - fn empty_assistant_heading_is_paired_with_activity_independently_of_tips() { + fn empty_assistant_reserves_no_transcript_space_independently_of_activity_and_tips() { let mut app = App::test_default(); app.transcript.messages.push(user_text_message("request")); app.transcript.messages.push(assistant_message()); app.bind_active_turn_assistant(1); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.status = AppStatus::Running; app.begin_turn_activity(std::time::Instant::now()); for width in [32, 120, 32] { let serialized = serialize_all_rows_with_boundaries(&mut app, width); - assert_eq!(line_texts(serialized.rows()), ["User", "request", "Claude"]); - assert_eq!(serialized.stable_row_count(), 2); - assert_eq!( - serialized.first_mutable_boundary_kind(), - Some(LiveRowBoundaryKind::AssistantLabel) - ); + assert_assistant_marker(serialized.rows(), false); + assert!(serialized.segments().iter().all(|segment| segment.msg_idx == 0)); + let stable = &serialized.rows()[..serialized.stable_row_count()]; + assert_assistant_marker(stable, false); + assert!(line_texts(stable).iter().any(|row| row.ends_with("request"))); + assert_eq!(serialized.first_mutable_boundary_kind(), None); assert!(app.transcript.messages[1].blocks.is_empty()); } app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( "spinnerTipsEnabled", serde_json::json!(false), )); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request", "Claude"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.config.snapshot = None; - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request", "Claude"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.session_runtime.runtime_session_state = Some(model::RuntimeSessionState::RequiresAction); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.session_runtime.runtime_session_state = Some(model::RuntimeSessionState::Running); app.pending_interaction_ids.push("permission".into()); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.pending_interaction_ids.clear(); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request", "Claude"]); - let label_id = HistoryOutputId::AssistantLabel(app.transcript.messages[1].id); - let excluded = BTreeSet::from([label_id]); - let serialized = serialize_live_rows_with_boundaries_excluding(&mut app, 80, &excluded); - assert_eq!(line_texts(serialized.rows()), ["User", "request"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); app.status = AppStatus::Ready; - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 80), false); } #[test] @@ -2467,10 +2586,8 @@ mod tests { app.begin_turn_activity(std::time::Instant::now()); for width in [32, 120, 32] { let serialized = serialize_all_rows_with_boundaries(&mut app, width); - assert_eq!(line_texts(serialized.rows()), ["Claude"]); - assert_eq!(serialized.stable_row_count(), 0); - assert_eq!(serialized.segments().len(), 1); - assert_eq!(serialized.segments()[0].kind, LiveRowBoundaryKind::AssistantLabel); + assert!(serialized.rows().is_empty()); + assert!(serialized.segments().is_empty()); app.turn.compaction.begin(); assert!(app.transcript.messages[0].blocks.is_empty()); } @@ -2503,13 +2620,18 @@ mod tests { ); for width in [32, 120, 32] { let serialized = serialize_all_rows_with_boundaries(&mut app, width); + assert_eq!( + line_texts(serialized.rows()).iter().any(|row| row.contains("Thinking…")), + phase == model::AgentActivityPhase::Thinking + ); if phase == model::AgentActivityPhase::Thinking { - assert_eq!(line_texts(serialized.rows()), ["Claude", "◆ Thinking…"]); + assert_assistant_marker(serialized.rows(), true); assert_eq!(serialized.stable_row_count(), 0); assert_eq!(serialized.rows_excluding_ids(&BTreeSet::new()), serialized.rows()); assert!(serialized.segments().iter().all(|segment| !segment.commit_ready)); } else { - assert_eq!(line_texts(serialized.rows()), ["Claude"]); + assert!(serialized.rows().is_empty()); + assert!(serialized.segments().is_empty()); } let activity = crate::ui::activity_rows::build_activity_rows( &app, @@ -2532,15 +2654,24 @@ mod tests { crate::ui::activity_rows::build_activity_rows(&app, 120, std::time::Instant::now()); assert_eq!(activity.len(), 1); assert!(line_text(&activity[0]).contains(&verb)); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 120)), ["Claude", "◆ Thinking…"]); + assert_assistant_marker(&serialize_live_rows(&mut app, 120), true); + assert!( + line_texts(&serialize_live_rows(&mut app, 120)) + .iter() + .any(|row| row.contains("Thinking\u{2026}")) + ); app.pending_interaction_ids.push("permission".into()); assert!(serialize_live_rows(&mut app, 120).is_empty()); app.pending_interaction_ids.clear(); app.turn.cancel_requested = true; - assert_eq!(line_texts(&serialize_live_rows(&mut app, 120)), ["Claude"]); + let cancelling = serialize_live_rows(&mut app, 120); + assert!(cancelling.is_empty()); + assert!(!line_texts(&cancelling).iter().any(|row| row.contains("Thinking…"))); app.turn.cancel_requested = false; app.turn.compaction.begin(); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 120)), ["Claude"]); + let compacting = serialize_live_rows(&mut app, 120); + assert!(compacting.is_empty()); + assert!(!line_texts(&compacting).iter().any(|row| row.contains("Thinking…"))); app.turn.reset_for_turn_exit(); app.status = AppStatus::Ready; assert!(serialize_live_rows(&mut app, 120).is_empty()); @@ -2568,14 +2699,17 @@ mod tests { "spinnerTipsEnabled", serde_json::json!(false), )); - assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["Claude"]); + assert!(serialize_live_rows(&mut app, 80).is_empty()); for update in [ model::SessionUpdate::AgentActivityUpdate(model::AgentActivityPhase::Thinking), model::SessionUpdate::AgentActivityUpdate(model::AgentActivityPhase::Working), ] { session_update(&mut app, update); let rows = line_texts(&serialize_live_rows(&mut app, 80)); - assert_eq!(rows.iter().filter(|row| row.as_str() == "Claude").count(), 1); + assert_eq!( + rows.iter().filter(|row| row.starts_with(" \u{25cf}")).count(), + usize::from(app.status == AppStatus::Thinking) + ); assert_eq!( rows.iter().any(|row| row.contains("Thinking…")), app.status == AppStatus::Thinking @@ -2589,7 +2723,7 @@ mod tests { ), ); let tool_rows = line_texts(&serialize_live_rows(&mut app, 80)); - assert_eq!(tool_rows.iter().filter(|row| row.as_str() == "Claude").count(), 1); + assert_eq!(tool_rows.iter().filter(|row| row.starts_with(" ●")).count(), 1); assert!(tool_rows.iter().any(|row| row.contains("Read"))); session_update( &mut app, @@ -2606,14 +2740,14 @@ mod tests { ); let serialized = serialize_all_rows_with_boundaries(&mut app, 80); let output = line_texts(serialized.rows()); - assert_eq!(output.iter().filter(|row| row.as_str() == "Claude").count(), 1); - assert!(output.iter().any(|row| row == "Actual response")); + assert_eq!(output.iter().filter(|row| row.starts_with(" ●")).count(), 1); + assert!(output.iter().any(|row| row == " Actual response")); let excluded = BTreeSet::from([HistoryOutputId::AssistantLabel(app.transcript.messages[0].id)]); app.config.snapshot = None; let after_enable = serialize_live_rows_with_boundaries_excluding(&mut app, 80, &excluded); - assert!(!line_texts(after_enable.rows()).iter().any(|row| row == "Claude")); - assert!(line_texts(after_enable.rows()).iter().any(|row| row == "Actual response")); + assert!(!line_texts(after_enable.rows()).iter().any(|row| row.starts_with(" ●"))); + assert!(line_texts(after_enable.rows()).iter().any(|row| row == " Actual response")); app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( "spinnerTipsEnabled", serde_json::json!(false), @@ -2627,8 +2761,8 @@ mod tests { }, ); let completed = line_texts(&serialize_live_rows(&mut app, 80)); - assert_eq!(completed.iter().filter(|row| row.as_str() == "Claude").count(), 1); - assert!(completed.iter().any(|row| row == "Actual response")); + assert_eq!(completed.iter().filter(|row| row.starts_with(" ●")).count(), 1); + assert!(completed.iter().any(|row| row == " Actual response")); session_update( &mut app, model::SessionUpdate::AgentActivityUpdate(model::AgentActivityPhase::Thinking), @@ -2670,7 +2804,8 @@ mod tests { assert!( serialize_live_rows_with_boundaries_excluding(&mut app, 120, &excluded) .rows() - .is_empty() + .iter() + .all(|row| line_text(row).trim().is_empty()) ); app.status = AppStatus::Ready; let completed = serialize_all_rows_with_boundaries(&mut app, 120); @@ -2686,7 +2821,7 @@ mod tests { let rows = serialize_live_rows(&mut app, 120); let text = line_texts(&rows); - let assistant_pos = text.iter().position(|line| line == "streaming").expect("assistant"); + let assistant_pos = text.iter().position(|line| line == " ● streaming").expect("assistant"); let system_pos = text.iter().position(|line| line == "during turn").expect("system"); assert!(assistant_pos < system_pos); @@ -2814,7 +2949,9 @@ mod tests { let serialized = serialize_all_rows_with_boundaries(&mut app, 120); - assert!(line_texts(serialized.rows()).iter().any(|line| line == "queued while connecting")); + assert!( + line_texts(serialized.rows()).iter().any(|line| line == " > queued while connecting") + ); assert_eq!(serialized.stable_row_count(), 0); } @@ -2843,7 +2980,10 @@ mod tests { let rows = serialize_live_rows(&mut app, width); let text = line_texts(&rows); - assert!(text.iter().any(|line| line == "Claude"), "missing label at width {width}"); + assert!( + text.iter().any(|line| line.starts_with(" ●")), + "missing label at width {width}" + ); assert!( text.iter().any(|line| line.contains("Child Tool")), "missing tool title at width {width}: {text:?}" @@ -2870,7 +3010,10 @@ mod tests { let rows = serialize_live_rows(&mut app, width); let text = line_texts(&rows); - assert!(text.iter().any(|line| line == "Claude"), "missing label at width {width}"); + assert!( + text.iter().any(|line| line.starts_with(" ●")), + "missing label at width {width}" + ); assert!( text.iter().any(|line| line.contains("Child Tool")), "missing tool title at width {width}: {text:?}" @@ -2938,7 +3081,10 @@ mod tests { let rows = serialize_live_rows(&mut app, width); let text = line_texts(&rows); - assert!(text.iter().any(|line| line == "Claude"), "missing label at width {width}"); + assert!( + text.iter().any(|line| line.starts_with(" ●")), + "missing label at width {width}" + ); assert!( text.iter().any(|line| line.contains("Child Tool")), "missing tool title at width {width}: {text:?}" diff --git a/src/ui/input.rs b/src/ui/input.rs index ed15c9ff..feb02af2 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -12,14 +12,7 @@ use tui_textarea::{CursorRenderMode, TextArea}; use super::autocomplete; -/// Symmetric horizontal padding inside the colored input field. -const INPUT_HORIZONTAL_PAD: u16 = 1; - -/// Vertical padding inside the colored input field when enough rows are available. -const INPUT_VERTICAL_PAD: u16 = 1; - -/// Prompt column width: "❯ " = 2 columns (icon + space) -const PROMPT_WIDTH: u16 = 2; +use super::message_layout::{MessageLayout, VERTICAL_PADDING as INPUT_VERTICAL_PAD}; /// Maximum input content height (lines) before the surrounding vertical padding is added. const MAX_INPUT_HEIGHT: u16 = 12; @@ -89,14 +82,16 @@ pub(crate) fn compute_render_geometry(area: Rect, hint_lines: u16) -> InputRende let vertical_pad = if input_main_area.height > INPUT_VERTICAL_PAD * 2 { INPUT_VERTICAL_PAD } else { 0 }; + let layout = MessageLayout::new(input_main_area.width); let content = Rect { - x: input_main_area.x.saturating_add(INPUT_HORIZONTAL_PAD), + x: input_main_area.x.saturating_add(layout.horizontal_padding), y: input_main_area.y.saturating_add(vertical_pad), - width: input_main_area.width.saturating_sub(INPUT_HORIZONTAL_PAD * 2), + width: input_main_area.width.saturating_sub(layout.horizontal_padding * 2), height: input_main_area.height.saturating_sub(vertical_pad * 2), }; let [prompt, text] = - Layout::horizontal([Constraint::Length(PROMPT_WIDTH), Constraint::Min(1)]).areas(content); + Layout::horizontal([Constraint::Length(layout.marker_width), Constraint::Min(1)]) + .areas(content); InputRenderGeometry { field: input_main_area, prompt, text } } @@ -222,8 +217,7 @@ fn slash_command_range(line: &str) -> Option<(usize, usize)> { /// Called by the layout to allocate the correct input area height. pub fn visual_line_count(app: &mut App, area_width: u16) -> u16 { let hint = hint_line_count(app); - let content_width = - area_width.saturating_sub(INPUT_HORIZONTAL_PAD * 2).saturating_sub(PROMPT_WIDTH); + let content_width = MessageLayout::new(area_width).content_width; let input_lines = app.input.measure_visual_lines(content_width, MAX_INPUT_HEIGHT); hint.saturating_add(input_lines).saturating_add(INPUT_VERTICAL_PAD * 2) } diff --git a/src/ui/live_rows/segments.rs b/src/ui/live_rows/segments.rs index 2d1821ec..89490420 100644 --- a/src/ui/live_rows/segments.rs +++ b/src/ui/live_rows/segments.rs @@ -129,13 +129,13 @@ impl LiveRowBoundary { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum LiveRowBoundaryKind { Message, - AssistantLabel, AssistantThinking, AssistantText, AssistantNotice, AssistantBtw, AssistantTool, AssistantDuration, + AssistantPadding, } pub(crate) fn ids_are_excluded( diff --git a/src/ui/markdown.rs b/src/ui/markdown.rs index 9e259c45..c8232fff 100644 --- a/src/ui/markdown.rs +++ b/src/ui/markdown.rs @@ -1,12 +1,50 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 Simon Peter Rothgang +mod lists; + use crate::failure; +use pulldown_cmark::{Event, Options, Parser, Tag}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; pub(super) fn render_markdown_safe(text: &str, bg: Option) -> Vec> { - render_markdown_safe_with(text, bg, render_with_tui_markdown) + render_markdown_at_width(text, u16::MAX, bg) +} + +/// List rows are laid out while parser ownership is available, before becoming plain rows. +pub(super) fn render_markdown_at_width( + text: &str, + width: u16, + bg: Option, +) -> Vec> { + render_markdown_safe_with(text, bg, |text, bg| lists::render(text, width, bg)) +} + +fn parser_options() -> Options { + // Match the dialect used by tui-markdown for non-list blocks. + Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TABLES + | Options::ENABLE_TASKLISTS + | Options::ENABLE_HEADING_ATTRIBUTES + | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS + | Options::ENABLE_SUPERSCRIPT + | Options::ENABLE_SUBSCRIPT + | Options::ENABLE_MATH + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_DEFINITION_LIST + | Options::ENABLE_GFM +} + +fn list_ranges(source: &str) -> impl Iterator> + '_ { + Parser::new_ext(source, parser_options()) + .into_offset_iter() + .filter_map(|(event, range)| matches!(event, Event::Start(Tag::List(_))).then_some(range)) +} + +/// A boundary inside one parsed list cannot be frozen or rendered independently. +pub(super) fn list_crosses_boundary(source: &str, boundary: usize) -> bool { + list_ranges(source).any(|range| range.start < boundary && range.end > boundary) } fn render_markdown_safe_with(text: &str, bg: Option, renderer: F) -> Vec> @@ -28,7 +66,7 @@ where fn render_with_tui_markdown(text: &str, bg: Option) -> Vec> { let rendered = tui_markdown::from_str(text); - let lines = rendered + rendered .lines .into_iter() .map(|line| { @@ -45,71 +83,7 @@ fn render_with_tui_markdown(text: &str, bg: Option) -> Vec> if let Some(bg_color) = bg { line.style.bg(bg_color) } else { line.style }; Line::from(owned_spans).style(line_style) }) - .collect(); - normalize_list_spacing(lines) -} - -fn normalize_list_spacing(lines: Vec>) -> Vec> { - let list_lines = rendered_list_line_flags(&lines); - let mut normalized = Vec::with_capacity(lines.len()); - - for (idx, line) in lines.into_iter().enumerate() { - if line_is_blank(&line) { - let before_list = list_lines.get(idx + 1).copied().unwrap_or(false); - let after_list = idx > 0 && list_lines.get(idx - 1).copied().unwrap_or(false); - if before_list || after_list { - continue; - } - } - - if list_lines[idx] { - normalized.push(indent_line(line, " ")); - } else { - normalized.push(line); - } - } - - normalized -} - -fn rendered_list_line_flags(lines: &[Line<'_>]) -> Vec { - let mut in_fenced_code = false; - let mut flags = Vec::with_capacity(lines.len()); - - for line in lines { - let text = line_text(line); - let starts_fence = text.trim_start().starts_with("```"); - let in_code_line = in_fenced_code || starts_fence; - flags.push(!in_code_line && rendered_line_is_list_item(&text)); - if starts_fence { - in_fenced_code = !in_fenced_code; - } - } - - flags -} - -fn rendered_line_is_list_item(text: &str) -> bool { - let trimmed = text.trim_start(); - trimmed.starts_with("- ") || starts_with_ordered_list_marker(trimmed) -} - -fn starts_with_ordered_list_marker(text: &str) -> bool { - let digit_count = text.bytes().take_while(u8::is_ascii_digit).count(); - digit_count > 0 && text[digit_count..].starts_with(". ") -} - -fn indent_line(mut line: Line<'static>, indent: &'static str) -> Line<'static> { - line.spans.insert(0, Span::styled(indent, line.style)); - line -} - -fn line_is_blank(line: &Line<'_>) -> bool { - line.spans.iter().all(|span| span.content.trim().is_empty()) -} - -fn line_text(line: &Line<'_>) -> String { - line.spans.iter().map(|span| span.content.as_ref()).collect() + .collect() } fn plain_text_fallback(text: &str, bg: Option) -> Vec> { @@ -121,6 +95,9 @@ fn plain_text_fallback(text: &str, bg: Option) -> Vec> { #[cfg(test)] mod tests { + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|span| span.content.as_ref()).collect() + } use super::*; use std::panic::catch_unwind; @@ -128,6 +105,155 @@ mod tests { lines.iter().map(line_text).collect() } + fn document_rows(source: &str, width: u16) -> Vec { + let lines = crate::ui::document_table::render_markdown_with_tables(source, width, None); + crate::ui::wrap::wrap_lines_to_physical_rows(&lines, width) + .iter() + .map(|line| line_text(line).trim_end().to_owned()) + .collect() + } + + #[test] + fn list_continuation_paragraphs_and_nested_items_retain_their_parent() { + let rows = document_rows( + "3. First paragraph.\n\n Second paragraph.\n\n - Child item.\n\n Child continuation.\n\n Parent again.\n4. Next item.", + 60, + ); + for (text, column) in [ + ("Second paragraph.", 5), + ("- Child item.", 5), + ("Child continuation.", 7), + ("Parent again.", 5), + ] { + let row = rows.iter().find(|row| row.trim() == text).expect("list content survives"); + assert_eq!(row.find(text), Some(column), "wrong parent alignment: {rows:?}"); + } + let first = rows.iter().position(|row| row.contains("First paragraph.")).unwrap(); + let second = rows.iter().position(|row| row.contains("Second paragraph.")).unwrap(); + assert!( + rows[first + 1..second].iter().any(String::is_empty), + "separate item paragraphs must stay separate: {rows:?}" + ); + assert!(rows.iter().any(|row| row.trim() == "4. Next item.")); + } + + #[test] + fn ordered_and_task_item_wrapping_use_the_actual_body_column() { + for (source, prefix, column) in [ + ("9) alpha beta gamma delta epsilon\n1) zeta eta theta iota kappa", "9.", 5), + ("10. alpha beta gamma delta epsilon", "10.", 6), + ("- [x] alpha beta gamma delta epsilon", "- [x]", 8), + ] { + let rows = document_rows(source, 22); + let first = + rows.iter().position(|row| row.trim_start().starts_with(prefix)).expect("marker"); + let continuation = &rows[first + 1]; + assert!(!continuation.trim().is_empty()); + assert_eq!(continuation.len() - continuation.trim_start().len(), column, "{rows:?}"); + assert!(rows.iter().all(|row| crate::ui::wrap::display_width(row) <= 22)); + if source.starts_with("9)") { + assert!( + rows.iter().any(|row| row.trim_start().starts_with("10. zeta")), + "number sequentially from the declared start: {rows:?}" + ); + } + } + } + + #[test] + fn code_and_escaped_markers_do_not_acquire_list_indentation() { + for source in [ + "~~~text\n- literal marker\n~~~", + " - indented code", + "\\- escaped marker", + "3a ordinary text", + ] { + let rows = document_rows(source, 60); + let content = rows + .iter() + .find(|row| { + row.contains("marker") || row.contains("ordinary") || row.contains("indented") + }) + .expect("literal content"); + assert!( + !content.starts_with(" "), + "non-list source acquired list padding: {source:?}: {rows:?}" + ); + } + } + + #[test] + fn nested_list_wrapping_preserves_inline_styles_links_and_unicode() { + let lines = crate::ui::document_table::render_markdown_with_tables( + "1. Parent\n - **Bold** and *italic* [docs](https://example.com) 日本語😀 words words words", + 28, + Some(Color::Blue), + ); + let text = lines.iter().map(line_text).collect::>(); + let content = text.join(" "); + for required in ["Bold", "italic", "https://example.com", "日本語😀"] { + assert!(content.contains(required), "lost inline content: {text:?}"); + } + for (word, modifier) in + [("Bold", ratatui::style::Modifier::BOLD), ("italic", ratatui::style::Modifier::ITALIC)] + { + assert!( + lines.iter().flat_map(|line| &line.spans).any(|span| span.content.contains(word) + && span.style.add_modifier.contains(modifier)), + "lost {word} formatting" + ); + } + assert!(lines.iter().all(|line| crate::ui::wrap::line_display_width(line) <= 28)); + assert!( + lines + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.bg == Some(Color::Blue)) + ); + let child = text.iter().position(|row| row.contains("- Bold")).expect("nested item"); + for row in &text[child + 1..] { + assert_eq!( + row.find(|ch: char| !ch.is_whitespace()), + Some(7), + "child continuation lost its parent: {text:?}" + ); + } + } + + #[test] + fn table_and_code_inside_a_list_keep_the_parent_column() { + let rows = document_rows( + "1. Parent\n\n | Name | Value |\n | --- | --- |\n | Alpha | Beta |\n\n ~~~text\n - literal code\n indented code\n ~~~\n\n Tail paragraph.", + 60, + ); + for word in ["Name", "Alpha", "- literal code", "Tail paragraph."] { + let row = rows.iter().find(|row| row.contains(word)).expect("nested content survives"); + assert!(row.starts_with(" "), "content escaped its parent: {rows:?}"); + } + let code = rows.iter().find(|row| row.contains("- literal code")).unwrap(); + assert_eq!(code.find('-'), Some(5), "code marker must stay literal"); + let indented = rows.iter().find(|row| row.contains("indented code")).unwrap(); + assert_eq!( + indented.find("indented code"), + Some(9), + "code indentation must survive inside its parent list" + ); + } + + #[test] + fn quoted_lists_keep_one_quote_prefix_on_wrapped_and_continued_rows() { + let rows = document_rows( + "> Introduction\n> continued\n>\n> 1. Parent item with enough words to wrap onto another row\n>\n> Continuation paragraph\n>\n> - Child item\n>\n> End quote", + 30, + ); + for row in rows.iter().filter(|row| !row.trim().is_empty()) { + assert!(row.starts_with("> "), "quote ownership lost: {rows:?}"); + assert_eq!(row.matches('>').count(), 1, "quote prefixes duplicated: {rows:?}"); + } + assert!(rows.iter().any(|row| row.contains("Child item"))); + assert!(rows.iter().any(|row| row.contains("End quote"))); + } + #[test] fn render_markdown_safe_handles_common_and_edge_case_inputs_without_panicking() { let inputs = [ @@ -164,24 +290,24 @@ mod tests { } #[test] - fn unordered_lists_use_indentation_instead_of_boundary_blank_lines() { + fn unordered_lists_preserve_surrounding_paragraph_separators() { let lines = render_markdown_safe("Intro\n\n- One\n- Two\n\nOutro", None); - assert_eq!(rendered_text(&lines), vec!["Intro", " - One", " - Two", "Outro"]); + assert_eq!(rendered_text(&lines), vec!["Intro", "", " - One", " - Two", "", "Outro"]); } #[test] - fn ordered_lists_use_indentation_instead_of_boundary_blank_lines() { + fn ordered_lists_preserve_surrounding_paragraph_separators() { let lines = render_markdown_safe("Intro\n\n1. One\n2. Two\n\nOutro", None); - assert_eq!(rendered_text(&lines), vec!["Intro", " 1. One", " 2. Two", "Outro"]); + assert_eq!(rendered_text(&lines), vec!["Intro", "", " 1. One", " 2. Two", "", "Outro"]); } #[test] fn nested_lists_keep_relative_indentation() { let lines = render_markdown_safe("- Parent\n - Child", None); - assert_eq!(rendered_text(&lines), vec![" - Parent", " - Child"]); + assert_eq!(rendered_text(&lines), vec![" - Parent", " - Child"]); } #[test] diff --git a/src/ui/markdown/lists.rs b/src/ui/markdown/lists.rs new file mode 100644 index 00000000..f2609255 --- /dev/null +++ b/src/ui/markdown/lists.rs @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! List layout retains parser ownership through paragraphs, nesting and wrapping. + +use std::ops::Range; + +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use tui_markdown::{DefaultStyleSheet, StyleSheet}; + +use super::super::wrap::{ + StyledChunk, display_width, wrap_lines_to_physical_rows, wrap_styled_chunks_with_hanging_prefix, +}; + +enum Node<'a> { + Block(Tag<'a>, Range, Vec), + Inline(Event<'a>, Range), +} + +impl Node<'_> { + fn range(&self) -> &Range { + match self { + Self::Block(_, range, _) | Self::Inline(_, range) => range, + } + } + + fn contains_list(&self) -> bool { + match self { + Self::Block(Tag::List(_), _, _) => true, + Self::Block(_, _, children) => children.iter().any(Self::contains_list), + Self::Inline(_, _) => false, + } + } +} + +fn nodes<'a>( + events: &mut impl Iterator, Range)>, +) -> (Vec>, usize) { + let mut out = Vec::new(); + let mut end = 0; + while let Some((event, range)) = events.next() { + end = range.end; + match event { + Event::Start(tag) => { + let (children, block_end) = nodes(events); + out.push(Node::Block(tag, range.start..block_end, children)); + } + Event::End(_) => break, + _ => out.push(Node::Inline(event, range)), + } + } + (out, end) +} + +pub(super) fn render(source: &str, width: u16, bg: Option) -> Vec> { + let (nodes, _) = + nodes(&mut Parser::new_ext(source, super::parser_options()).into_offset_iter()); + let mut out = Vec::new(); + render_document(&nodes, source, width, bg, "", &mut out); + out +} + +fn render_document( + nodes: &[Node<'_>], + source: &str, + width: u16, + bg: Option, + prefix: &str, + out: &mut Vec>, +) { + let mut plain_start = None; + let mut plain_end = 0; + let mut section_start = 0; + for node in nodes { + if !prefix.is_empty() && !node.contains_list() { + if let Node::Block(Tag::CodeBlock(kind), _, content) = node { + out.extend(prefix_block_lines( + code_lines(kind, content), + prefix, + prefix, + width, + bg, + )); + } else if let Node::Block(Tag::BlockQuote(_), _, children) = node { + render_document(children, source, width, bg, &format!("{prefix}> "), out); + } else { + let mut spans = Vec::new(); + inline_spans(node, DefaultStyleSheet.blockquote(), &mut spans); + out.extend(prefix_lines(vec![Line::from(spans)], prefix, prefix, width, bg)); + } + continue; + } + if !node.contains_list() { + if plain_start.is_none() { + preserve_separator(source, node.range().start, bg, out); + } + plain_start.get_or_insert(section_start); + plain_end = node.range().end; + continue; + } + if let Some(start) = plain_start.take() { + out.extend(super::render_with_tui_markdown(&source[start..plain_end], bg)); + } + preserve_separator(source, node.range().start, bg, out); + match node { + Node::Block(Tag::List(start), _, items) => { + render_list(items, *start, source, width, bg, &format!("{prefix} "), out); + } + Node::Block(Tag::BlockQuote(_), _, children) => { + render_document(children, source, width, bg, &format!("{prefix}> "), out); + } + Node::Block(_, _, children) => { + render_document(children, source, width, bg, prefix, out); + } + Node::Inline(_, _) => {} + } + section_start = node.range().end; + } + if let Some(start) = plain_start { + out.extend(super::render_with_tui_markdown(&source[start..plain_end], bg)); + } +} + +fn preserve_separator(source: &str, start: usize, bg: Option, out: &mut Vec>) { + let blank_line_before = + source[..start].rsplit('\n').take_while(|line| line.trim().is_empty()).take(2).count() == 2; + if blank_line_before + && out + .last() + .is_some_and(|line| line.spans.iter().any(|span| !span.content.trim().is_empty())) + { + out.push( + Line::default().style(bg.map_or(Style::default(), |color| Style::default().bg(color))), + ); + } +} + +fn render_list( + items: &[Node<'_>], + mut number: Option, + source: &str, + width: u16, + bg: Option, + indent: &str, + out: &mut Vec>, +) { + for node in items { + let Node::Block(Tag::Item, _, children) = node else { + continue; + }; + let marker = number.map_or_else(|| "- ".to_owned(), |value| format!("{value}. ")); + let ordered = number.is_some(); + let marker_row = out.len(); + number = number.map(|value| value.saturating_add(1)); + let mut prefix = format!("{indent}{marker}"); + if let Some(checked) = first_task_marker(children) { + prefix.push_str(if checked { "[x] " } else { "[ ] " }); + } + let body_prefix = + format!("{indent}{}", " ".repeat(display_width(&prefix) - display_width(indent))); + let mut first = true; + let mut inline = Vec::new(); + for child in children { + match child { + Node::Block(Tag::List(start), _, nested) => { + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + if first { + out.push(Line::from(prefix.trim_end().to_owned())); + first = false; + } + render_list(nested, *start, source, width, bg, &body_prefix, out); + } + Node::Block(Tag::Paragraph, _, paragraph) => { + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + if !first { + out.push(Line::default()); + } + inline.extend(paragraph); + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + } + Node::Block(Tag::CodeBlock(kind), _, content) => { + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + if !first { + out.push(Line::default()); + } + let lines = code_lines(kind, content); + out.extend(prefix_block_lines( + lines, + if first { &prefix } else { &body_prefix }, + &body_prefix, + width, + bg, + )); + first = false; + } + Node::Block(Tag::Table(_), range, _) => { + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + let table = source[range.clone()] + .lines() + .map(str::trim_start) + .collect::>() + .join("\n"); + let inner = width.saturating_sub( + u16::try_from(display_width(&body_prefix)).unwrap_or(u16::MAX), + ); + let lines = super::super::document_table::render_markdown_with_tables( + &table, inner, bg, + ); + out.extend(prefix_block_lines( + lines, + if first { &prefix } else { &body_prefix }, + &body_prefix, + width, + bg, + )); + first = false; + } + Node::Block(Tag::BlockQuote(_), _, quoted) => { + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + if first { + out.push(Line::from(prefix.trim_end().to_owned())); + first = false; + } + render_document(quoted, source, width, bg, &format!("{body_prefix}> "), out); + } + node => inline.push(node), + } + } + flush_inline(&mut inline, &prefix, &body_prefix, &mut first, width, bg, out); + if first { + out.push(Line::from(prefix.trim_end().to_owned())); + } + if ordered + && let Some(span) = out.get_mut(marker_row).and_then(|line| line.spans.first_mut()) + { + span.style = span.style.patch(DefaultStyleSheet.list_marker()); + } + } +} + +fn code_lines(kind: &CodeBlockKind<'_>, content: &[Node<'_>]) -> Vec> { + let text = content + .iter() + .filter_map(|node| match node { + Node::Inline(Event::Text(text), _) => Some(text.as_ref()), + _ => None, + }) + .collect::(); + let language = match kind { + CodeBlockKind::Fenced(info) => info.as_ref(), + CodeBlockKind::Indented => "", + }; + let mut lines = vec![Line::from(format!("```{language}"))]; + lines.extend(super::super::highlight::highlight_code( + &text, + (!language.is_empty()).then_some(language), + )); + lines.push(Line::from("```")); + lines +} + +fn first_task_marker(nodes: &[Node<'_>]) -> Option { + match nodes.first()? { + Node::Inline(Event::TaskListMarker(checked), _) => Some(*checked), + Node::Block(Tag::Paragraph, _, children) => first_task_marker(children), + _ => None, + } +} + +fn flush_inline( + nodes: &mut Vec<&Node<'_>>, + prefix: &str, + continuation: &str, + first: &mut bool, + width: u16, + bg: Option, + out: &mut Vec>, +) { + if nodes.is_empty() { + return; + } + let mut spans = Vec::new(); + for node in nodes.drain(..) { + inline_spans(node, Style::default(), &mut spans); + } + if spans.is_empty() { + return; + } + out.extend(prefix_lines( + vec![Line::from(spans)], + if *first { prefix } else { continuation }, + continuation, + width, + bg, + )); + *first = false; +} + +fn inline_spans(node: &Node<'_>, style: Style, spans: &mut Vec>) { + let styles = DefaultStyleSheet; + match node { + Node::Block(tag, _, children) => { + let style = match tag { + Tag::Strong | Tag::Heading { .. } => style.add_modifier(Modifier::BOLD), + Tag::Emphasis => style.add_modifier(Modifier::ITALIC), + Tag::Subscript | Tag::Superscript => { + style.add_modifier(Modifier::DIM | Modifier::ITALIC) + } + Tag::Strikethrough => style.add_modifier(Modifier::CROSSED_OUT), + Tag::Link { .. } => style.patch(styles.link()), + Tag::Image { .. } => style.patch(styles.image_alt()), + _ => style, + }; + if matches!(tag, Tag::Image { .. }) { + spans.push(Span::styled("[img] ", style)); + } + for child in children { + inline_spans(child, style, spans); + } + if let Tag::Link { dest_url, .. } = tag { + spans.push(Span::styled(format!(" ({dest_url})"), style)); + } + if let Tag::Image { dest_url, .. } = tag + && children.is_empty() + { + spans.push(Span::styled(dest_url.to_string(), style)); + } + } + Node::Inline(event, _) => { + let (text, style) = match event { + Event::Text(text) => (text.to_string(), style), + Event::Code(text) => (text.to_string(), style.patch(styles.code())), + Event::SoftBreak => (" ".to_owned(), style), + Event::HardBreak => ("\n".to_owned(), style), + Event::Html(text) | Event::InlineHtml(text) => { + (text.to_string(), style.patch(styles.html())) + } + Event::InlineMath(text) => (format!("${text}$"), style.patch(styles.math_inline())), + Event::DisplayMath(text) => { + (format!("$${text}$$"), style.patch(styles.math_display())) + } + Event::FootnoteReference(text) => { + (format!("[{text}]"), style.patch(styles.footnote_ref())) + } + Event::Rule => ("---".to_owned(), style), + _ => return, + }; + spans.push(Span::styled(text, style)); + } + } +} + +fn prefix_lines( + lines: Vec>, + first: &str, + continuation: &str, + width: u16, + bg: Option, +) -> Vec> { + let style = bg.map_or(Style::default(), |color| Style::default().bg(color)); + let mut out = Vec::new(); + for (index, line) in lines.into_iter().enumerate() { + let prefix = if index == 0 { first } else { continuation }; + let chunks = line + .spans + .into_iter() + .map(|span| StyledChunk { + text: span.content.into_owned(), + style: line.style.patch(span.style).patch(style), + }) + .collect::>(); + let mut rows = wrap_styled_chunks_with_hanging_prefix( + &[StyledChunk { text: prefix.to_owned(), style }], + &chunks, + usize::from(width), + style, + ); + if display_width(prefix) < usize::from(width) { + for row in rows.iter_mut().skip(1) { + if let Some(span) = row.spans.first_mut() { + span.content = continuation.to_owned().into(); + } + } + } + out.extend(rows); + } + out +} + +/// Code and pre-laid-out tables retain their own leading whitespace. +fn prefix_block_lines( + lines: Vec>, + first: &str, + continuation: &str, + width: u16, + bg: Option, +) -> Vec> { + let prefix_width = u16::try_from(display_width(first)).unwrap_or(u16::MAX); + if prefix_width >= width { + return prefix_lines(lines, first, continuation, width, bg); + } + let style = bg.map_or(Style::default(), |color| Style::default().bg(color)); + wrap_lines_to_physical_rows(&lines, width - prefix_width) + .into_iter() + .enumerate() + .map(|(index, mut line)| { + line.spans.insert( + 0, + Span::styled( + if index == 0 { first.to_owned() } else { continuation.to_owned() }, + style, + ), + ); + if let Some(color) = bg { + line.style = line.style.bg(color); + for span in &mut line.spans { + span.style = span.style.bg(color); + } + } + line + }) + .collect() +} diff --git a/src/ui/message.rs b/src/ui/message.rs index 608b870d..9b5390de 100644 --- a/src/ui/message.rs +++ b/src/ui/message.rs @@ -5,7 +5,7 @@ use crate::app::ChatMessage; use crate::app::{BlockCache, TextBlock}; use crate::ui::tool_call; -use crate::ui::wrap::wrap_markdown_lines_to_physical_rows; +use crate::ui::wrap::wrap_lines_to_physical_rows; use ratatui::style::Color; use ratatui::text::Line; @@ -79,10 +79,23 @@ fn render_message_rows( /// Handles variations: `#Title`, `# Title`, ` ## Title `, etc. /// Links are left as-is -- `tui_markdown` handles `[title](url)` natively. fn preprocess_markdown(text: &str) -> String { + let code_ranges = pulldown_cmark::Parser::new(text) + .into_offset_iter() + .filter_map(|(event, range)| { + matches!(event, pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(_))) + .then_some(range) + }) + .collect::>(); let mut result = String::with_capacity(text.len()); - for line in text.lines() { + let mut offset = 0; + for source_line in text.split_inclusive('\n') { + let line = source_line.trim_end_matches(['\r', '\n']); + let in_code = code_ranges + .iter() + .any(|range| range.start < offset + source_line.len() && range.end > offset); + offset += source_line.len(); let trimmed = line.trim(); - if trimmed.starts_with('#') { + if !in_code && trimmed.starts_with('#') { // Strip all leading '#' characters let after_hashes = trimmed.trim_start_matches('#'); // Extract heading content (trim spaces between # and text, and trailing) @@ -92,6 +105,7 @@ fn preprocess_markdown(text: &str) -> String { if !result.is_empty() && !result.ends_with("\n\n") { result.push('\n'); } + result.push_str(&line[..line.len() - line.trim_start().len()]); result.push_str("**"); result.push_str(content); result.push_str("**\n"); @@ -119,8 +133,8 @@ pub(super) fn render_text_cached( out: &mut Vec>, ) { // Fast path only when the cached lines were measured at this width. - // Markdown tables produce width-dependent logical lines before paragraph - // wrapping, so a fresh cache from another width is not safe to reuse. + // Lists and tables resolve width-dependent geometry before final row wrapping, + // so a fresh cache from another width is not safe to reuse. if cache.height_at(width).is_some() && let Some(cached_lines) = cache.get() { @@ -137,7 +151,7 @@ pub(super) fn render_text_cached( // Store in the full block cache with wrapped height. // For streaming messages this will be invalidated on the next chunk, // but for completed messages it persists. - let h = wrap_markdown_lines_to_physical_rows(&fresh, width).len(); + let h = wrap_lines_to_physical_rows(&fresh, width).len(); cache.store(fresh); cache.set_height(h, width); if let Some(stored) = cache.get() { @@ -202,6 +216,27 @@ mod tests { assert_eq!(result, input); } + #[test] + fn list_headings_and_code_survive_the_actual_message_pipeline() { + let source = "1. Parent\n\n ## Child heading\n\n ~~~text\n # literal code\n ~~~\n\n Tail paragraph."; + let mut cache = BlockCache::default(); + let mut lines = Vec::new(); + render_text_cached(source, &mut cache, 60, None, true, &mut lines); + for (text, column) in [("Child heading", 5), ("# literal code", 5), ("Tail paragraph.", 5)] + { + let row = lines + .iter() + .map(Line::to_string) + .find(|row| row.contains(text)) + .expect("message content survives preprocessing"); + assert_eq!( + row.find(text), + Some(column), + "nested message content lost its owner: {row:?}" + ); + } + } + #[test] fn preprocess_mixed_headings_and_text() { let input = "# Title\nSome text\n## Subtitle\nMore text"; @@ -418,40 +453,40 @@ mod tests { } fn render_lines_to_strings(lines: &[Line<'static>]) -> Vec { - lines + lines.iter().map(|line| line.to_string().trim_end().to_owned()).collect() + } + + fn user_paragraphs(lines: &[Line<'static>]) -> Vec { + let content = lines .iter() - .map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect()) + .map(|line| line.to_string().chars().skip(3).collect::().trim_end().to_owned()) + .collect::>(); + content + .split(String::is_empty) + .filter(|paragraph| !paragraph.is_empty()) + .map(|paragraph| paragraph.join(" ")) .collect() } #[test] fn user_paragraph_spacing_survives_cache_reuse_and_resize() { for (source, expected) in [ - ("hello\n\nhow are you", vec!["User", "hello", "", "how are you"]), - ("hello\nhow are you", vec!["User", "hello", "how are you"]), - ("one\n\ntwo\n\nthree", vec!["User", "one", "", "two", "", "three"]), - ("hello\n\n\nhow are you", vec!["User", "hello", "", "how are you"]), - ("hello\n \t\nhow are you", vec!["User", "hello", "", "how are you"]), - ("こんにちは\n\n😀 hello", vec!["User", "こんにちは", "", "😀 hello"]), + ("hello\n\nhow are you", vec!["hello", "how are you"]), + ("hello\nhow are you", vec!["hello how are you"]), + ("one\n\ntwo\n\nthree", vec!["one", "two", "three"]), + ("hello\n\n\nhow are you", vec!["hello", "how are you"]), + ("hello\n \t\nhow are you", vec!["hello", "how are you"]), + ("こんにちは\n\n😀 hello", vec!["こんにちは", "😀 hello"]), ] { let mut msg = make_text_message(MessageRole::User, source); - for width in [80, 80, 12, 80] { + for width in [80, 80, 16, 80] { let mut lines = Vec::new(); render_message(&mut msg, width, &mut lines); - assert_eq!( - render_lines_to_strings(&lines), - expected, - "source={source:?}, width={width}" - ); + assert_eq!(user_paragraphs(&lines), expected, "source={source:?}, width={width}"); let MessageBlock::Text(block) = &msg.blocks[0] else { panic!("expected user text"); }; assert_eq!(block.text, source); - assert!( - lines[1..] - .iter() - .all(|line| line.style.bg == Some(crate::ui::theme::USER_MSG_BG)) - ); } } } @@ -469,33 +504,8 @@ mod tests { let mut after = Vec::new(); render_message(&mut msg, 80, &mut after); - assert_eq!(render_lines_to_strings(&after), vec!["User", "hello", "", "how are you"]); - } - - #[test] - fn user_markdown_boundaries_retain_existing_document_spacing() { - for (source, expected) in [ - ("before\n\n# Heading\n\nafter", vec!["User", "before", "", "Heading", "", "after"]), - ( - "intro\n\n- one\n- two\n\noutro", - vec!["User", "intro", " - one", " - two", "outro"], - ), - ( - "before\n\n```text\none\n\nthree\n```\n\nafter", - vec!["User", "before", "", "```text", "one", "", "three", "```", "", "after"], - ), - ("\n\nhello\n\n", vec!["User", "hello"]), - ("", vec!["User"]), - ] { - let mut msg = make_text_message(MessageRole::User, source); - let mut lines = Vec::new(); - render_message(&mut msg, 80, &mut lines); - let text: Vec<_> = render_lines_to_strings(&lines) - .into_iter() - .map(|line| line.trim_end().to_owned()) - .collect(); - assert_eq!(text, expected, "source={source:?}"); - } + assert_eq!(user_paragraphs(&before), ["hello"]); + assert_eq!(user_paragraphs(&after), ["hello", "how are you"]); } #[test] @@ -515,7 +525,7 @@ mod tests { assert!(!wide_rendered.iter().any(|line| line.contains("Name:"))); let mut narrow_lines = Vec::new(); - render_message(&mut msg, 12, &mut narrow_lines); + render_message(&mut msg, 16, &mut narrow_lines); let narrow_rendered = render_lines_to_strings(&narrow_lines); assert!(narrow_rendered.iter().any(|line| line.contains("Name:"))); assert!(narrow_rendered.iter().any(|line| line.contains("Description"))); diff --git a/src/ui/message_layout.rs b/src/ui/message_layout.rs new file mode 100644 index 00000000..997e986d --- /dev/null +++ b/src/ui/message_layout.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; + +use super::{theme, wrap}; + +pub(crate) const VERTICAL_PADDING: u16 = 1; +const HORIZONTAL_PADDING: u16 = 1; +const MARKER_WIDTH: u16 = 2; + +/// Shared geometry for the composer and transcript. Tiny terminals keep a text cell. +#[derive(Clone, Copy)] +pub(crate) struct MessageLayout { + pub horizontal_padding: u16, + pub marker_width: u16, + pub content_width: u16, +} + +impl MessageLayout { + pub(crate) const fn new(width: u16) -> Self { + let horizontal_padding = if width >= 5 { HORIZONTAL_PADDING } else { 0 }; + let marker_width = if width >= 3 { MARKER_WIDTH } else { 0 }; + Self { + horizontal_padding, + marker_width, + content_width: width.saturating_sub(horizontal_padding * 2 + marker_width), + } + } + + pub(crate) fn row( + self, + mut content: Line<'static>, + marker: Option<(&str, Color)>, + background: Option, + ) -> Line<'static> { + let style = background.map_or(Style::default(), |color| Style::default().bg(color)); + let mut spans = vec![Span::styled(" ".repeat(usize::from(self.horizontal_padding)), style)]; + if self.marker_width > 0 { + let (marker, color) = marker.unwrap_or((" ", theme::DIM)); + spans.push(Span::styled(format!("{marker} "), style.fg(color))); + } + if let Some(color) = background { + content.style = content.style.bg(color); + for span in &mut content.spans { + span.style = span.style.bg(color); + } + } + spans.extend(content.spans); + let row = Line::from(spans).style(content.style.patch(style)); + let width = self.horizontal_padding * 2 + self.marker_width + self.content_width; + wrap::pad_line_to_width(row, usize::from(width), style) + } +} + +pub(crate) fn frame_rows( + mut content: Vec>, + width: u16, + marker: Option<(&str, Color)>, + background: Option, + padding: (bool, bool), + full_width_rows: &[std::ops::Range], +) -> Vec> { + if width == 0 { + return Vec::new(); + } + if content.is_empty() { + content.push(Line::default()); + } + let first = content + .iter() + .position(|line| line.spans.iter().any(|span| !span.content.trim().is_empty())) + .unwrap_or(0); + let layout = MessageLayout::new(width); + let blank = || layout.row(Line::default(), None, background); + let mut rows = Vec::with_capacity(content.len() + 2); + if padding.0 { + rows.extend(std::iter::repeat_with(blank).take(usize::from(VERTICAL_PADDING))); + } + rows.extend(content.into_iter().enumerate().map(|(index, line)| { + if full_width_rows.iter().any(|range| range.contains(&index)) { + line + } else { + layout.row(line, if index == first { marker } else { None }, background) + } + })); + if padding.1 { + rows.extend(std::iter::repeat_with(blank).take(usize::from(VERTICAL_PADDING))); + } + rows +} + +pub(crate) fn timestamp_row( + timestamp: &str, + width: u16, + background: Option, +) -> Line<'static> { + let layout = MessageLayout::new(width); + let text = wrap::wrap_plain(timestamp, usize::from(layout.content_width)) + .into_iter() + .next() + .unwrap_or_default(); + let content = Line::from(Span::styled(text, Style::default().fg(theme::DIM))); + layout.row(content, None, background) +} diff --git a/src/ui/message_rows.rs b/src/ui/message_rows.rs index e34cb57f..5850bbf8 100644 --- a/src/ui/message_rows.rs +++ b/src/ui/message_rows.rs @@ -11,6 +11,7 @@ use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use super::message::{MessageRenderContext, render_text_block_cached}; +use super::message_layout::{self, MessageLayout}; pub(crate) struct MessageRows { pub segments: Vec, @@ -48,12 +49,41 @@ pub(crate) fn build_user_system_message_rows( return rows; } - if !msg.blocks.iter().any(|block| matches!(block, MessageBlock::BtwExchange(_))) { - rows.push_lines(vec![role_label_line(&msg.role)]); + if matches!(msg.role, MessageRole::System(_)) + && !msg.blocks.iter().any(|block| matches!(block, MessageBlock::BtwExchange(_))) + { + rows.push_lines(vec![system_role_label_line(system_severity_from_role(&msg.role))]); } match msg.role { - MessageRole::User => append_user_blocks(msg, render_context.width, &mut rows), + MessageRole::User => { + let width = render_context.width; + append_user_blocks(msg, MessageLayout::new(width).content_width, &mut rows); + let mut physical = Vec::new(); + for segment in rows.segments { + match segment { + MessageRowSegment::Blank => physical.push(Line::default()), + MessageRowSegment::Lines { lines } => { + physical.extend(super::wrap::wrap_lines_to_physical_rows( + &lines, + MessageLayout::new(width).content_width, + )); + } + } + } + return MessageRows { + segments: vec![MessageRowSegment::Lines { + lines: message_layout::frame_rows( + physical, + width, + Some((">", theme::DIM)), + Some(theme::USER_MSG_BG), + (true, true), + &[], + ), + }], + }; + } MessageRole::System(_) => append_system_blocks(msg, render_context.width, &mut rows), MessageRole::Assistant | MessageRole::Welcome => {} } @@ -73,9 +103,9 @@ fn append_user_blocks(msg: &mut ChatMessage, width: u16, rows: &mut MessageRows) } MessageBlock::ImageAttachment(img) => { let label = if img.count == 1 { - " [img] 1 image attached ".to_owned() + "[img] 1 image attached".to_owned() } else { - format!(" [img] {} images attached ", img.count) + format!("[img] {} images attached", img.count) }; rows.push_lines(vec![Line::from(Span::styled( label, @@ -189,7 +219,7 @@ pub(super) fn render_btw_exchange_lines( fn render_btw_markdown(text: &str, width: u16) -> Vec> { let logical = super::document_table::render_markdown_with_tables(text, width, None); - let mut physical = crate::ui::wrap::wrap_markdown_lines_to_physical_rows(&logical, width); + let mut physical = crate::ui::wrap::wrap_lines_to_physical_rows(&logical, width); for line in &mut physical { for span in &mut line.spans { if span.style.bg.is_none_or(|color| color == Color::Reset) { @@ -285,17 +315,6 @@ fn notice_block_lines( lines } -fn role_label_line(role: &MessageRole) -> Line<'static> { - match role { - MessageRole::User => Line::from(Span::styled( - "User", - Style::default().fg(theme::DIM).add_modifier(Modifier::BOLD), - )), - MessageRole::System(_) => system_role_label_line(system_severity_from_role(role)), - MessageRole::Assistant | MessageRole::Welcome => Line::default(), - } -} - fn system_role_label_line(severity: SystemSeverity) -> Line<'static> { let (label, color) = match severity { SystemSeverity::Info => ("Info", theme::DIM), @@ -332,8 +351,8 @@ fn tint_lines(lines: &mut [Line<'static>], color: Color) { mod tests { use super::build_user_system_message_rows; use crate::app::{ - BtwExchangeBlock, ChatMessage, ImageAttachmentBlock, MessageBlock, MessageRole, - NoticeBlock, SystemSeverity, TextBlock, TextBlockSpacing, + BtwExchangeBlock, ChatMessage, MessageBlock, MessageRole, NoticeBlock, SystemSeverity, + TextBlock, }; use crate::ui::message::MessageRenderContext; use ratatui::text::Line; @@ -342,10 +361,6 @@ mod tests { MessageRenderContext::new(None, 80) } - fn user_message(blocks: Vec) -> ChatMessage { - ChatMessage::new(MessageRole::User, blocks, None) - } - fn system_message(blocks: Vec, severity: SystemSeverity) -> ChatMessage { ChatMessage::new(MessageRole::System(Some(severity)), blocks, None) } @@ -367,37 +382,6 @@ mod tests { out } - #[test] - fn user_text_blocks_preserve_header_and_spacing_behavior() { - let mut msg = user_message(vec![ - MessageBlock::Text(TextBlock::from_complete("First paragraph")), - MessageBlock::Text( - TextBlock::from_complete("Second paragraph") - .with_trailing_spacing(TextBlockSpacing::ParagraphBreak), - ), - ]); - - let rows = build_user_system_message_rows(&mut msg, render_context()); - let texts = segment_texts(&rows); - - assert_eq!(texts.first().expect("header"), "User"); - assert!(texts.iter().any(|line| line.contains("First paragraph"))); - assert!(texts.iter().any(|line| line.contains("Second paragraph"))); - assert!(texts.iter().any(String::is_empty)); - } - - #[test] - fn user_image_attachment_renders_attachment_row() { - let mut msg = - user_message(vec![MessageBlock::ImageAttachment(ImageAttachmentBlock::new(2))]); - - let rows = build_user_system_message_rows(&mut msg, render_context()); - let texts = segment_texts(&rows); - - assert_eq!(texts.first().expect("header"), "User"); - assert!(texts.iter().any(|line| line.contains("2 images attached"))); - } - #[test] fn system_notice_blocks_serialize_as_text_like_with_tint() { let mut msg = system_message( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bfc7b866..67c642ce 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod input_rows; pub(crate) mod live_rows; mod markdown; mod message; +pub(crate) mod message_layout; mod message_rows; mod session_picker; pub(crate) mod spinner_verbs; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 51657751..29d5e3f5 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -11,6 +11,7 @@ pub const ULTRACODE_ACCENT: Color = Color::Rgb(180, 140, 255); // UI chrome pub const DIM: Color = Color::DarkGray; pub const PROMPT_CHAR: &str = "\u{276f}"; +pub const ASSISTANT_MESSAGE_MARKER: &str = "\u{25cf}"; pub const SEPARATOR_CHAR: &str = "\u{2500}"; // Role header colors diff --git a/src/ui/tool_call/standard.rs b/src/ui/tool_call/standard.rs index 69326c04..5617883f 100644 --- a/src/ui/tool_call/standard.rs +++ b/src/ui/tool_call/standard.rs @@ -61,7 +61,7 @@ pub(super) fn render_tool_call_title( }; let mut title_spans = - vec![Span::styled(format!(" {icon} "), Style::default().fg(icon_color)), kind_icon_span]; + vec![Span::styled(format!("{icon} "), Style::default().fg(icon_color)), kind_icon_span]; if tc.is_execute_tool() { title_spans.push(Span::styled( format!("{} ", kind_label.label.as_ref()), @@ -345,7 +345,7 @@ fn render_tool_content(tc: &ToolCallInfo, width: u16) -> Vec> { match content { model::ToolCallContent::Diff(diff) => { if is_plan_file_path(&diff.path) { - lines.extend(render_plan_content(&diff.new_text)); + lines.extend(render_plan_content(&diff.new_text, width)); } else { let raw = render_diff(diff, width.saturating_sub(DIFF_BODY_INDENT_WIDTH)); let raw = cap_write_diff_lines(raw); @@ -353,14 +353,14 @@ fn render_tool_content(tc: &ToolCallInfo, width: u16) -> Vec> { } } model::ToolCallContent::McpResource(resource) => { - lines.extend(render_mcp_resource_content(tc, resource)); + lines.extend(render_mcp_resource_content(tc, resource, width)); } model::ToolCallContent::ResourceLink(link) => { lines.extend(render_mcp_resource_link_content(link)); } model::ToolCallContent::Content(c) => { if let model::ContentBlock::Text(text) = &c.content { - render_text_content(tc, &text.text, &mut lines); + render_text_content(tc, &text.text, width, &mut lines); } } model::ToolCallContent::Terminal(_) => {} @@ -610,9 +610,9 @@ fn cap_read_content_lines(lines: Vec>) -> Vec> { out } -fn render_plan_content(text: &str) -> Vec> { +fn render_plan_content(text: &str, width: u16) -> Vec> { let md_source = strip_outer_code_fence(text); - markdown::render_markdown_safe(&md_source, None) + markdown::render_markdown_at_width(&md_source, width, None) .into_iter() .map(|line| { let owned: Vec> = line @@ -625,15 +625,20 @@ fn render_plan_content(text: &str) -> Vec> { .collect() } -fn render_text_content(tc: &ToolCallInfo, text: &str, lines: &mut Vec>) { +fn render_text_content(tc: &ToolCallInfo, text: &str, width: u16, lines: &mut Vec>) { if is_read_tool(tc) { - render_read_text_content(tc, text, lines); + render_read_text_content(tc, text, width, lines); } else { - render_generic_text_content(tc, text, lines); + render_generic_text_content(tc, text, width, lines); } } -fn render_read_text_content(tc: &ToolCallInfo, text: &str, lines: &mut Vec>) { +fn render_read_text_content( + tc: &ToolCallInfo, + text: &str, + width: u16, + lines: &mut Vec>, +) { let fenced_language = outer_code_fence_language(text); let stripped = strip_outer_code_fence(text); if let Some(failed_lines) = render_failed_tool_text_content(tc.status, &stripped) { @@ -645,7 +650,7 @@ fn render_read_text_content(tc: &ToolCallInfo, text: &str, lines: &mut Vec>) { +fn render_generic_text_content( + tc: &ToolCallInfo, + text: &str, + width: u16, + lines: &mut Vec>, +) { let stripped = strip_outer_code_fence(text); if let Some(failed_lines) = render_failed_tool_text_content(tc.status, &stripped) { lines.extend(failed_lines); @@ -785,24 +795,29 @@ fn render_generic_text_content(tc: &ToolCallInfo, text: &str, lines: &mut Vec
  • >) { +fn render_mcp_resource_text_content( + tc: &ToolCallInfo, + text: &str, + width: u16, + lines: &mut Vec>, +) { let stripped = strip_outer_code_fence(text); if let Some(failed_lines) = render_failed_tool_text_content(tc.status, &stripped) { lines.extend(failed_lines); return; } if is_markdown_file(&tc.title) { - render_markdown_content(&stripped, lines); + render_markdown_content(&stripped, width, lines); } else { lines.extend(highlight::plain_text_lines(&stripped)); } } -fn render_markdown_content(text: &str, lines: &mut Vec>) { - for line in markdown::render_markdown_safe(text, None) { +fn render_markdown_content(text: &str, width: u16, lines: &mut Vec>) { + for line in markdown::render_markdown_at_width(text, width, None) { let owned: Vec> = line.spans.into_iter().map(|s| Span::styled(s.content.into_owned(), s.style)).collect(); lines.push(Line::from(owned)); @@ -846,10 +861,11 @@ fn outer_code_fence_language(text: &str) -> Option<&str> { fn render_mcp_resource_content( tc: &ToolCallInfo, resource: &model::McpResource, + width: u16, ) -> Vec> { let mut lines = Vec::new(); if let Some(text) = resource.text.as_deref() { - render_mcp_resource_text_content(tc, text, &mut lines); + render_mcp_resource_text_content(tc, text, width, &mut lines); } if let Some(blob_saved_to) = &resource.blob_saved_to { let saved_path = blob_saved_to.to_string_lossy().into_owned(); diff --git a/src/ui/tool_call/tests.rs b/src/ui/tool_call/tests.rs index e921ba44..a2df2a2b 100644 --- a/src/ui/tool_call/tests.rs +++ b/src/ui/tool_call/tests.rs @@ -297,7 +297,7 @@ fn tool_title_cache_reuses_markdown_through_animation_resize_and_body_updates() assert!(spans_width(&rows[0].spans) <= usize::from(width)); assert_eq!( rows[0].spans[0].content, - format!(" {} ", crate::ui::SpinnerState::Animated(frame).icon()) + format!("{} ", crate::ui::SpinnerState::Animated(frame).icon()) ); } assert_eq!(warnings.load(Ordering::Relaxed), 1, "unchanged titles must not be reparsed"); @@ -313,7 +313,7 @@ fn tool_title_cache_reuses_markdown_through_animation_resize_and_body_updates() crate::ui::SpinnerState::Animated(0), &mut completed, ); - assert_eq!(completed[0].spans[0].content, format!(" {} ", theme::ICON_COMPLETED)); + assert_eq!(completed[0].spans[0].content, format!("{} ", theme::ICON_COMPLETED)); assert_eq!( warnings.load(Ordering::Relaxed), 1, diff --git a/src/ui/wrap.rs b/src/ui/wrap.rs index c0dd66b1..6b44c52f 100644 --- a/src/ui/wrap.rs +++ b/src/ui/wrap.rs @@ -74,59 +74,6 @@ pub(crate) fn wrap_lines_to_physical_rows( (0..area.height).map(|row| buffer_row_to_line(&buffer, area, row)).collect() } -#[must_use] -pub(crate) fn wrap_markdown_lines_to_physical_rows( - lines: &[Line<'static>], - width: u16, -) -> Vec> { - if lines.is_empty() { - return Vec::new(); - } - - let mut rows = Vec::new(); - let mut in_fenced_code = false; - for line in lines { - let text = line_text(line); - let starts_fence = - text.trim_start().starts_with("```") || text.trim_start().starts_with("~~~"); - let in_code_line = in_fenced_code || starts_fence; - rows.extend(wrap_markdown_line_to_physical_rows(line, width, in_code_line)); - if starts_fence { - in_fenced_code = !in_fenced_code; - } - } - rows -} - -fn wrap_markdown_line_to_physical_rows( - line: &Line<'static>, - width: u16, - in_fenced_code: bool, -) -> Vec> { - if in_fenced_code { - return wrap_lines_to_physical_rows(std::slice::from_ref(line), width); - } - - let Some(body_start) = markdown_list_body_start(&line_text(line)) else { - return wrap_lines_to_physical_rows(std::slice::from_ref(line), width); - }; - if width == 0 { - return vec![Line::default()]; - } - - let (marker, body) = split_line_chunks_at_byte(line, body_start); - let marker_width = marker.iter().map(|chunk| display_width(&chunk.text)).sum::(); - let width = usize::from(width); - if marker_width == 0 || marker_width >= width { - return wrap_lines_to_physical_rows( - std::slice::from_ref(line), - u16::try_from(width).unwrap_or(u16::MAX), - ); - } - - wrap_styled_chunks_with_hanging_prefix(&marker, &body, width, line.style) -} - #[must_use] pub(crate) fn wrap_styled_chunks(chunks: &[StyledChunk], width: usize) -> Vec> { if width == 0 || chunks.is_empty() { @@ -229,82 +176,6 @@ pub(crate) fn wrap_styled_chunks_with_hanging_prefix( rows } -fn split_line_chunks_at_byte( - line: &Line<'static>, - split_at: usize, -) -> (Vec, Vec) { - let mut left = Vec::new(); - let mut right = Vec::new(); - let mut seen = 0usize; - - for span in &line.spans { - let text = span.content.as_ref(); - let span_start = seen; - let span_end = seen + text.len(); - if span_end <= split_at { - left.push(StyledChunk { text: text.to_owned(), style: span.style }); - } else if span_start >= split_at { - right.push(StyledChunk { text: text.to_owned(), style: span.style }); - } else { - let local_split = split_at - span_start; - let (prefix, suffix) = text.split_at(local_split); - if !prefix.is_empty() { - left.push(StyledChunk { text: prefix.to_owned(), style: span.style }); - } - if !suffix.is_empty() { - right.push(StyledChunk { text: suffix.to_owned(), style: span.style }); - } - } - seen = span_end; - } - - (left, right) -} - -fn markdown_list_body_start(text: &str) -> Option { - let bytes = text.as_bytes(); - let mut idx = bytes.iter().take_while(|byte| **byte == b' ').count(); - if idx >= bytes.len() { - return None; - } - - if matches!(bytes[idx], b'-' | b'*' | b'+') - && bytes.get(idx + 1).is_some_and(|byte| *byte == b' ') - { - idx += 2; - if task_list_marker_len(&bytes[idx..]).is_some() { - idx += 4; - } - return Some(idx); - } - - let digit_start = idx; - while bytes.get(idx).is_some_and(u8::is_ascii_digit) { - idx += 1; - } - if idx > digit_start - && bytes.get(idx).is_some_and(|byte| *byte == b'.') - && bytes.get(idx + 1).is_some_and(|byte| *byte == b' ') - { - return Some(idx + 2); - } - - None -} - -fn task_list_marker_len(bytes: &[u8]) -> Option { - (bytes.len() >= 4 - && bytes[0] == b'[' - && matches!(bytes[1], b' ' | b'x' | b'X') - && bytes[2] == b']' - && bytes[3] == b' ') - .then_some(4) -} - -fn line_text(line: &Line<'_>) -> String { - line.spans.iter().map(|span| span.content.as_ref()).collect() -} - #[must_use] pub(crate) fn pad_line_to_width( mut line: Line<'static>, @@ -506,6 +377,9 @@ fn buffer_row_to_line(buf: &Buffer, area: Rect, row: u16) -> Line<'static> { #[cfg(test)] mod tests { + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|span| span.content.as_ref()).collect() + } use super::*; use pretty_assertions::assert_eq; use ratatui::style::Modifier; @@ -534,7 +408,7 @@ mod tests { assert_eq!(line_text(&rows[0]).trim_end(), "日本語 😀 end"); assert_eq!(rows[0].width(), 16); assert!(rows[0].spans[0].style.add_modifier.contains(Modifier::ITALIC)); - let wrapped = wrap_markdown_lines_to_physical_rows(&[Line::from("日本語😀日本語")], 6); + let wrapped = wrap_lines_to_physical_rows(&[Line::from("日本語😀日本語")], 6); assert!(wrapped.iter().all(|row| row.width() <= 6)); assert_eq!( wrapped.iter().map(|row| line_text(row).trim_end().to_owned()).collect::(), @@ -583,74 +457,4 @@ mod tests { vec!["XX one", " two"] ); } - - #[test] - fn markdown_ordered_list_wraps_with_hanging_indent() { - let rows = wrap_markdown_lines_to_physical_rows( - &[Line::from(" 3. Keymap survey - mapping the keymap system")], - 32, - ); - - assert_eq!( - rows.into_iter() - .map(|line| line - .spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::()) - .collect::>(), - vec![" 3. Keymap survey - mapping the", " keymap system"] - ); - } - - #[test] - fn markdown_task_list_wraps_under_task_marker() { - let rows = wrap_markdown_lines_to_physical_rows( - &[Line::from(" - [x] completed task with extra words")], - 24, - ); - - let text = rows - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::() - .trim_end() - .to_owned() - }) - .collect::>(); - - assert_eq!(text, vec![" - [x] completed task", " with extra words"]); - } - - #[test] - fn markdown_list_wrapping_skips_fenced_code_lines() { - let rows = wrap_markdown_lines_to_physical_rows( - &[ - Line::from("```md"), - Line::from("- code line that wraps without hanging indent"), - Line::from("```"), - ], - 18, - ); - - let text = rows - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::() - .trim_end() - .to_owned() - }) - .collect::>(); - - assert_eq!( - text, - vec!["```md", "- code line that", "wraps without", "hanging indent", "```"] - ); - } } diff --git a/tests/integration/state_transitions.rs b/tests/integration/state_transitions.rs index e0bd1d86..0639eedc 100644 --- a/tests/integration/state_transitions.rs +++ b/tests/integration/state_transitions.rs @@ -176,27 +176,37 @@ async fn error_then_new_turn_recovers() { // --- Message accumulation --- #[tokio::test] -async fn chunks_across_turns_append_to_last_assistant_message() { +async fn chunks_join_within_a_response_and_never_modify_a_finished_response() { let mut app = test_app(); // First turn - let c1 = model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new("Turn 1"))); - send_client_event(&mut app, session_update(model::SessionUpdate::AgentMessageChunk(c1))); + for text in ["Turn ", "1"] { + let chunk = + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new(text))); + send_client_event(&mut app, session_update(model::SessionUpdate::AgentMessageChunk(chunk))); + } + let first_id = app.transcript.messages[0].id; send_client_event(&mut app, turn_complete()); assert_eq!(app.transcript.messages.len(), 1); - // Second turn: chunks append to the last assistant message (no user message between turns) - let c2 = model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new("Turn 2"))); - send_client_event(&mut app, session_update(model::SessionUpdate::AgentMessageChunk(c2))); - - // Still one message - consecutive assistant chunks always merge - assert_eq!(app.transcript.messages.len(), 1); - if let MessageBlock::Text(block) = - &app.transcript.messages.last().expect("message").blocks.last().expect("block") - { - assert!(block.text.contains("Turn 1"), "first turn text present"); - assert!(block.text.contains("Turn 2"), "second turn text appended"); + // Complete SDK replies can arrive without a streaming-start event or another user message. + for text in ["Turn ", "2"] { + let chunk = + model::ContentChunk::new(model::ContentBlock::Text(model::TextContent::new(text))); + send_client_event(&mut app, session_update(model::SessionUpdate::AgentMessageChunk(chunk))); } + + assert_eq!(app.transcript.messages.len(), 2); + assert_eq!(app.transcript.messages[0].id, first_id); + assert_ne!(app.transcript.messages[1].id, first_id); + let MessageBlock::Text(first) = &app.transcript.messages[0].blocks[0] else { + panic!("first response"); + }; + let MessageBlock::Text(second) = &app.transcript.messages[1].blocks[0] else { + panic!("second response"); + }; + assert_eq!(first.text, "Turn 1"); + assert_eq!(second.text, "Turn 2"); } #[tokio::test] diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 8766dfa7..5230cdd8 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -9,6 +9,15 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +fn assistant_marker(row: &str) -> bool { + row.trim_start().starts_with('\u{25cf}') +} + +fn assistant_body(row: &str) -> &str { + let text = row.trim(); + text.strip_prefix("\u{25cf} ").unwrap_or(text) +} + const COLS: u16 = 87; const SHORT_ROWS: u16 = 38; const TALL_ROWS: u16 = 55; @@ -397,14 +406,18 @@ impl TerminalTest { fn diagnostics(&self) -> String { let output = self.output.lock().expect("output lock"); + let log = std::fs::read_to_string(self.temp.path().join("runtime.log")).unwrap_or_default(); + let paste = log + .lines() + .filter(|line| line.contains("\"target\":\"app.paste\"")) + .collect::>() + .join("\n"); format!( - "{}\nRaw output tail:\n{}\nRuntime log tail:\n{}", + "{}\nRaw output tail:\n{}\nRuntime log tail:\n{}\nPaste events:\n{}", output.parser.screen().contents(), tail(&String::from_utf8_lossy(&output.raw), 2000), - tail( - &std::fs::read_to_string(self.temp.path().join("runtime.log")).unwrap_or_default(), - 6000 - ) + tail(&log, 6000), + tail(&paste, 6000), ) } @@ -946,9 +959,9 @@ fn background_result_labels_follow_the_visible_speaker_through_replay_and_cancel } let assert_group = |test: &TerminalTest| { let rows = test.transcript_rows(); - let first = rows.iter().position(|row| row.trim() == "Claude").expect("speaker label"); + let first = rows.iter().position(|row| assistant_marker(row)).expect("speaker label"); let last = rows.iter().position(|row| row.contains("OUTPUT_2")).expect("second result"); - assert_eq!(rows[first..=last].iter().filter(|row| row.trim() == "Claude").count(), 1); + assert_eq!(rows[first..=last].iter().filter(|row| assistant_marker(row)).count(), 1); let positions: Vec<_> = ["LAUNCH_1", "LAUNCH_2", "LAUNCH_3", "Foreground reply", "RESULT_1", "RESULT_2"] .into_iter() @@ -987,8 +1000,8 @@ fn background_result_labels_follow_the_visible_speaker_through_replay_and_cancel .position(|row| row.contains("CANCEL_EMPTY_REPLY")) .expect("cancelled user message"); let result = rows.iter().position(|row| row.contains("RESULT_3")).expect("later result"); - assert_eq!(rows[normal..cancelled].iter().filter(|row| row.trim() == "Claude").count(), 1); - assert_eq!(rows[cancelled..=result].iter().filter(|row| row.trim() == "Claude").count(), 1); + assert_eq!(rows[normal..cancelled].iter().filter(|row| assistant_marker(row)).count(), 1); + assert_eq!(rows[cancelled..=result].iter().filter(|row| assistant_marker(row)).count(), 1); test.assert_prompts(&["BACKGROUND_LABELS", "NORMAL_FOLLOWUP", "CANCEL_EMPTY_REPLY"]); test.shutdown(); } @@ -1027,8 +1040,8 @@ fn background_completion_replies_keep_streamed_tails_mutable_and_preserve_normal test.wait_journal(&format!("finish-{number}")); test.wait_turn_finished(full); let rows = test.transcript_rows(); - assert_eq!(rows.iter().filter(|row| row.trim() == full).count(), 1); - assert!(!rows.iter().any(|row| row.trim() == prefix)); + assert_eq!(rows.iter().filter(|row| assistant_body(row) == full).count(), 1); + assert!(!rows.iter().any(|row| assistant_body(row) == prefix)); } test.submit_draft(); test.wait_turn_finished("Normal conversation continues completely."); @@ -1040,7 +1053,7 @@ fn background_completion_replies_keep_streamed_tails_mutable_and_preserve_normal "Short task 2 (task-two) finished with exit code 0.", "Normal conversation continues completely.", ] { - assert_eq!(rows.iter().filter(|row| row.trim() == full).count(), 1); + assert_eq!(rows.iter().filter(|row| assistant_body(row) == full).count(), 1); } test.assert_prompts(&["BACKGROUND_STREAM", "NORMAL_AFTER_COMPLETIONS"]); test.shutdown(); @@ -1202,21 +1215,21 @@ fn concurrent_resize_typing_and_paste_deliver_every_prompt_exactly_once() { } #[test] -fn tips_setting_preserves_immediate_activity_and_heading_through_real_config_saves() { +fn tips_setting_preserves_activity_without_empty_messages_through_real_config_saves() { let mut test = TerminalTest::start("hold-activity-off", 3); test.submit("NO_OUTPUT_YET", "NO_OUTPUT_YET"); test.wait_journal("reply-held"); - test.wait_until("disabled tips retain immediate activity and assistant heading", |test| { + test.wait_until("disabled tips retain activity without an empty assistant frame", |test| { let screen = test.screen(); screen.contains("NO_OUTPUT_YET") - && screen.lines().filter(|line| line.trim() == "Claude").count() == 1 - && screen.lines().any(|line| line.starts_with("│ ◆ ")) + && !screen.lines().any(assistant_marker) + && screen.lines().any(|line| line.trim_start().starts_with("│ ◆ ")) && !screen.contains("│ Tip:") }); let activity_header = test .screen() .lines() - .find(|line| line.starts_with("│ ◆ ")) + .find(|line| line.trim_start().starts_with("│ ◆ ")) .expect("immediate activity header") .trim_end() .to_owned(); @@ -1235,35 +1248,38 @@ fn tips_setting_preserves_immediate_activity_and_heading_through_real_config_sav test.send(b"\x1b"); test.wait_until("only tips follow the acknowledged setting", |test| { let screen = test.screen(); - screen.lines().any(|line| line.trim() == "NO_OUTPUT_YET") - && screen.lines().filter(|line| line.trim() == "Claude").count() == 1 + screen.lines().any(|line| line.trim().strip_prefix("> ") == Some("NO_OUTPUT_YET")) + && !screen.lines().any(assistant_marker) && screen.lines().any(|line| line.trim_end() == activity_header) && screen.contains("│ Tip:") == enabled }); test.resize_and_wait_for_draw(25, 61); test.resize_and_wait_for_draw(SHORT_ROWS, COLS); - test.wait_until("resize retains activity and heading independently of tips", |test| { - let screen = test.screen(); - screen.lines().filter(|line| line.trim() == "Claude").count() == 1 - && screen.lines().any(|line| line.trim_end() == activity_header) - && screen.contains("│ Tip:") == enabled - }); + test.wait_until( + "resize retains activity without an empty response independently of tips", + |test| { + let screen = test.screen(); + !screen.lines().any(assistant_marker) + && screen.lines().any(|line| line.trim_end() == activity_header) + && screen.contains("│ Tip:") == enabled + }, + ); } test.signal("requires_action"); - test.wait_until("waiting hides activity and its empty heading", |test| { + test.wait_until("waiting hides activity", |test| { let screen = test.screen(); - !screen.lines().any(|line| line.trim() == "Claude") + !screen.lines().any(assistant_marker) && !screen.contains(&activity_header) && !screen.contains("│ Tip:") }); test.signal("running"); let resumed = test.wait_screen_with("│ Tip:", &[&activity_header]); - assert_eq!(resumed.lines().filter(|line| line.trim() == "Claude").count(), 1); + assert!(!resumed.lines().any(assistant_marker)); test.signal("done"); - test.wait_until("completion removes the temporary heading", |test| { + test.wait_until("completion removes transient activity", |test| { let screen = test.screen(); screen.contains("[READY]") - && !screen.lines().any(|line| line.trim() == "Claude") + && !screen.lines().any(assistant_marker) && !screen.contains(&activity_header) && !screen.contains("│ Tip:") }); @@ -1290,21 +1306,23 @@ fn activity_stays_above_queue_and_out_of_scrollback_through_wait_resize_and_comp test.wait_journal("reply-held"); let initial_screen = test.wait_screen_with("│ ◆ ", &["│ Tip:", "START_ACTIVITY"]); let initial_rows: Vec<_> = initial_screen.lines().map(str::trim_end).collect(); - let heading = - initial_rows.iter().position(|line| *line == "Claude").expect("assistant heading"); - let activity = initial_rows.iter().position(|line| line.starts_with("│ ◆ ")).expect("activity"); - assert_eq!(initial_rows.iter().filter(|line| **line == "Claude").count(), 1); + assert!(!initial_rows.iter().any(|line| assistant_marker(line))); + let activity = initial_rows + .iter() + .position(|line| line.trim_start().starts_with("\u{2502} \u{25c6} ")) + .expect("activity"); assert!( initial_rows.iter().position(|line| line.contains("START_ACTIVITY")).expect("user message") - < heading + < activity ); - assert!(heading < activity, "tip text mentioning Claude must not count as the heading"); assert!(!initial_screen.contains("Thinking…")); let activity_header = initial_rows[activity].to_owned(); test.signal("thinking"); // Thinking retains the activity verb. let thinking = test.wait_screen_with("◆ Thinking…", &[&activity_header]); assert_eq!(thinking.matches("Thinking…").count(), 1); + let thinking_row = thinking.lines().find(|line| line.contains("Thinking\u{2026}")).unwrap(); + assert!(assistant_marker(thinking_row)); test.submit("QUEUED_ACTIVITY", "QUEUED_ACTIVITY"); test.paste("DRAFT_ACTIVITY"); let screen = test.wait_screen_with( @@ -1540,8 +1558,13 @@ fn resizing_a_streamed_reply_preserves_rendering_and_clean_shutdown() { // whole transcript. Terminal padding can leave spaces on visually blank rows. test.wait_until("submitted paragraph gap", |test| { let transcript = test.transcript_rows(); - let rows: Vec<_> = transcript.iter().map(|row| row.trim_end()).collect(); - rows.windows(3).any(|rows| rows == ["hello", "", "how are you"]) + let Some(first) = transcript.iter().position(|row| row.trim() == "> hello") else { + return false; + }; + let Some(second) = transcript.iter().position(|row| row.trim() == "how are you") else { + return false; + }; + second > first + 1 && transcript[first + 1..second].iter().all(|row| row.trim().is_empty()) }); test.assert_prompts(&["hello\n\nhow are you"]); @@ -1804,7 +1827,16 @@ fn guided_hook_creation_survives_resize_and_saves_a_complete_hook_with_existing_ #[test] fn unicode_pastes_cross_the_placeholder_boundary_without_payload_loss() { - let mut test = TerminalTest::start("stream", 3); + let mut test = TerminalTest::start_with_options( + "stream", + 3, + None, + &[ + "--log-filter", + "warn,app.lifecycle=debug,app.render=debug,bridge.protocol=debug,app.paste=debug", + ], + false, + ); let mut expected = Vec::new(); for chars in [999, 1000, 1001] { let prefix = "Unicode 界 🦀\n"; From 7cbb1025c9a102def9165b57e41f9255211da346 Mon Sep 17 00:00:00 2001 From: Ayush Sharma <47772616+ayush5harma@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:26:08 +0530 Subject: [PATCH 07/10] feat(ui): animate the welcome crab while connecting (#479) * feat(ui): animate the welcome crab while connecting - change the crab's eyes and legs through four poses while the first session connects, paced by the spinner frame at about 192 ms per pose - wave and blink once for 1.2 s after the first connection of the process, then settle on the resting pose - keep the crab still under prefersReducedMotion, on terminals too narrow to show it, for later connections, and once the transcript holds more than the welcome - hold the welcome back from scrollback until the hello ends and redraw through that window, with one closing draw that commits the rested welcome - record the first connection in StartupState and cover the poses, hello window, redraw scheduling and reconnection with pinned-clock tests * docs: describe the welcome crab animation - say in usage when the crab moves and when it stays still - list the welcome crab beside the spinners that Reduce motion already covers --- docs/src/settings.md | 2 +- docs/src/usage.md | 2 + src/app/events/session.rs | 1 + src/app/events/tests/client_events.rs | 21 +++ src/app/mod.rs | 45 +++-- src/app/state/startup.rs | 14 ++ src/app/tests.rs | 144 ++++++++++++++ src/ui/inline_chat_rows.rs | 259 ++++++++++++++++++++++---- src/ui/mod.rs | 1 + src/ui/welcome.rs | 249 +++++++++++++++++++++++-- 10 files changed, 672 insertions(+), 66 deletions(-) diff --git a/docs/src/settings.md b/docs/src/settings.md index d6266e2d..af086c0c 100644 --- a/docs/src/settings.md +++ b/docs/src/settings.md @@ -232,7 +232,7 @@ Every row has a description in the app. This section covers behavior that needs | Question timeout | `askUserQuestionTimeout` | Never / 60 seconds / 5 minutes / 10 minutes | User only. See below. | | Dialog expiry | `dialogExpiry` | 60 seconds / 5 minutes / 10 minutes / Never | User only. See below. | | Respect .gitignore | `respectGitignore` | On / Off | Hides ignored files from the file picker. Applies immediately. | -| Reduce motion | `prefersReducedMotion` | On / Off | Shows a static `◆` in place of animated spinners. Applies immediately. | +| Reduce motion | `prefersReducedMotion` | On / Off | Shows a static `◆` in place of animated spinners and keeps the welcome crab still. Applies immediately. | General also holds the settings described under [Presentation and scrolling](#presentation-and-scrolling), [Notifications](#notifications) and [Automatic updates](#automatic-updates). diff --git a/docs/src/usage.md b/docs/src/usage.md index 9868abb3..d89fe8bd 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -6,6 +6,8 @@ Start a new session in the current directory: claude-rs ``` +While the session connects, the crab beside the welcome overview scuttles and looks around; on the first connect of the process it waves once, then stays still. The `prefersReducedMotion` setting keeps it still throughout, once claude-rs has read that setting from the connected session. + Start with an initial prompt: ```bash diff --git a/src/app/events/session.rs b/src/app/events/session.rs index 4805cb87..936ef9a3 100644 --- a/src/app/events/session.rs +++ b/src/app/events/session.rs @@ -58,6 +58,7 @@ pub(super) fn handle_connected_client_event(app: &mut App, event: ConnectedEvent if let Some(slot) = take_connection_slot() { app.session_runtime.conn = Some(slot.conn); } + app.startup.mark_connected(std::time::Instant::now()); apply_session_cwd(app, cwd); reset_for_new_session( app, diff --git a/src/app/events/tests/client_events.rs b/src/app/events/tests/client_events.rs index 9db093c4..332dbc67 100644 --- a/src/app/events/tests/client_events.rs +++ b/src/app/events/tests/client_events.rs @@ -140,6 +140,27 @@ fn connected_updates_cwd_and_clears_resuming_marker() { assert_eq!(welcome.cwd, "/changed"); } +#[test] +fn the_first_connection_of_the_process_is_recorded_for_the_welcome_hello() { + let mut app = make_test_app(); + assert!(app.startup.first_connected_at().is_none()); + + handle_client_event(&mut app, connected_event("claude-first")); + + assert!(app.startup.first_connected_at().is_some()); +} + +#[test] +fn a_later_connection_leaves_the_recorded_first_connection_unchanged() { + let mut app = make_test_app(); + let first = std::time::Instant::now(); + app.startup.mark_connected(first); + + handle_client_event(&mut app, connected_event("claude-later")); + + assert_eq!(app.startup.first_connected_at(), Some(first)); +} + #[test] fn connected_reconciles_trust_for_new_cwd() { let mut app = make_test_app(); diff --git a/src/app/mod.rs b/src/app/mod.rs index a0ceb9cc..810e3f4d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -275,20 +275,9 @@ async fn run_tui_loop( terminal_runtime.sync_surface(app)?; // Phase 3: render once (only when something changed) - let is_animating = !app.shutdown_requested() - && !app.config.prefers_reduced_motion_effective() - && (app.activity_presentation(now).is_some() - || matches!(app.status, AppStatus::Connecting | AppStatus::CommandPending) - || app.btw.has_active() - || app.transcript.messages.iter().flat_map(|message| &message.blocks).any(|block| { - matches!(block, MessageBlock::ToolCall(tool) if !tool.hidden_unless_focused_interaction() && matches!(tool.status, model::ToolCallStatus::InProgress | model::ToolCallStatus::Pending)) - })); + let is_animating = schedule_animation_frame(app, now); if is_animating { - advance_spinner_frame(app, Instant::now()); tab_title::update_tab_title(app); - app.request_active_surface_repaint(); - } else { - app.spinner_last_advance_at = None; } // Update tab title on non-animating state transitions (Ready, Error). if !is_animating && app.surface_dirty.active_surface_needs_draw(app.terminal_lifecycle) { @@ -574,6 +563,38 @@ async fn shutdown_connection_with_interrupts(app: &mut App, events: &mut Termina } } +/// Whether this frame shows motion and so must be redrawn on the next tick. +/// It is false at idle, so an idle app schedules no redraws. +fn frame_is_animating(app: &App, now: Instant) -> bool { + !app.shutdown_requested() + && !app.config.prefers_reduced_motion_effective() + && (app.activity_presentation(now).is_some() + || matches!(app.status, AppStatus::Connecting | AppStatus::CommandPending) + || app.btw.has_active() + || crate::ui::ferris_hello_live(app, now) + || app.transcript.messages.iter().flat_map(|message| &message.blocks).any(|block| { + matches!(block, MessageBlock::ToolCall(tool) if !tool.hidden_unless_focused_interaction() && matches!(tool.status, model::ToolCallStatus::InProgress | model::ToolCallStatus::Pending)) + })) +} + +/// Advances the spinner and requests the next draw while the frame animates. +/// While the welcome is the whole transcript, an animation that stops +/// requests one more draw even if nothing else changed: the welcome crab's +/// hello ends on the clock alone, and that draw is the one that commits the +/// rested welcome. Returns whether the frame animates. +fn schedule_animation_frame(app: &mut App, now: Instant) -> bool { + let is_animating = frame_is_animating(app, now); + if is_animating { + advance_spinner_frame(app, now); + app.request_active_surface_repaint(); + } else if app.spinner_last_advance_at.take().is_some() + && crate::ui::welcome_is_whole_transcript(app) + { + app.request_active_surface_repaint(); + } + is_animating +} + fn advance_spinner_frame(app: &mut App, now: Instant) { if app.config.prefers_reduced_motion_effective() { app.spinner_last_advance_at = None; diff --git a/src/app/state/startup.rs b/src/app/state/startup.rs index c60d6f98..e1f23e8a 100644 --- a/src/app/state/startup.rs +++ b/src/app/state/startup.rs @@ -3,6 +3,7 @@ use crate::StartupLaunch; use std::path::PathBuf; +use std::time::Instant; /// Bootstrap state resolved from CLI flags and consumed while the app /// transitions from launch to a connected session. @@ -18,6 +19,9 @@ pub struct StartupState { phase: StartupPhase, session_options: Option, initial_prompt: Option, + /// When the first session of the process connected. Later sessions + /// (`/new`, resume) never move it. + first_connected_at: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -54,6 +58,16 @@ impl StartupState { } } + /// Records a connection; only the first one of the process is kept. + pub fn mark_connected(&mut self, now: Instant) { + self.first_connected_at.get_or_insert(now); + } + + #[must_use] + pub fn first_connected_at(&self) -> Option { + self.first_connected_at + } + pub fn session_options(&self) -> Option<&crate::SessionOptions> { self.session_options.as_ref() } diff --git a/src/app/tests.rs b/src/app/tests.rs index 9323a09a..fae81756 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -586,3 +586,147 @@ fn reduced_motion_stops_animation_and_reset_resumes_it() { assert_ne!(app.spinner_frame, frame); assert_ne!(crate::ui::SpinnerState::for_app(&app).icon(), "\u{25C6}"); } + +/// A ready app whose first connect was at `connected`, with the welcome as +/// its whole transcript and the settings snapshot acknowledged. +fn welcomed_app(connected: Instant, reduced_motion: bool) -> App { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.startup.mark_connected(connected); + app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( + "prefersReducedMotion", + serde_json::json!(reduced_motion), + )); + app.transcript.messages.push(ChatMessage::welcome("1.2.3", "Pro", "/demo", "session-1")); + app.chat_render.observe_terminal_size(110, 34); + app +} + +/// Runs one frame-loop scheduling step at `at` and reports whether it asked +/// for a draw, clearing the request for the next step. +fn scheduled_draw(app: &mut App, at: Instant) -> bool { + schedule_animation_frame(app, at); + std::mem::take(&mut app.surface_dirty.chat.repaint) +} + +#[test] +fn welcome_hello_redraws_through_its_window_and_ends_with_exactly_one_more_draw() { + let connected = Instant::now(); + let mut app = welcomed_app(connected, false); + app.surface_dirty.chat.repaint = false; + + assert!(scheduled_draw(&mut app, connected + Duration::from_millis(100))); + assert!(scheduled_draw(&mut app, connected + Duration::from_millis(1_199))); + assert!( + scheduled_draw(&mut app, connected + Duration::from_millis(1_200)), + "the draw that commits the rested welcome" + ); + assert!(!scheduled_draw(&mut app, connected + Duration::from_millis(1_216))); + assert!(!scheduled_draw(&mut app, connected + Duration::from_secs(60)), "idle stays idle"); +} + +#[test] +fn a_welcome_held_back_without_a_wave_still_gets_the_draw_that_commits_it() { + let connected = Instant::now(); + let mut app = welcomed_app(connected, false); + app.config.snapshot = None; + app.surface_dirty.chat.repaint = false; + + assert!(scheduled_draw(&mut app, connected + Duration::from_millis(100))); + assert!( + scheduled_draw(&mut app, connected + Duration::from_millis(1_200)), + "the window closed with no wave drawn; this draw commits the welcome" + ); + assert!(!scheduled_draw(&mut app, connected + Duration::from_millis(1_216))); +} + +#[test] +fn a_narrow_terminal_neither_holds_the_welcome_nor_schedules_draws() { + let connected = Instant::now(); + // 36 columns is the width at which the overview drops the crab column. + let mut app = welcomed_app(connected, false); + app.chat_render.observe_terminal_size(36, 34); + app.surface_dirty.chat.repaint = false; + + assert!(!scheduled_draw(&mut app, connected + Duration::from_millis(100))); + assert!(!scheduled_draw(&mut app, connected + Duration::from_millis(1_200))); +} + +#[test] +fn the_hello_does_not_redraw_a_fullscreen_view_over_the_welcome() { + let connected = Instant::now(); + let mut app = welcomed_app(connected, false); + app.terminal_lifecycle = + TerminalLifecycleState::Running(SurfaceMode::Fullscreen(FullscreenView::SessionPicker)); + + assert!(!frame_is_animating(&app, connected + Duration::from_millis(100))); +} + +#[test] +fn reduced_motion_schedules_no_welcome_redraws() { + let connected = Instant::now(); + let mut app = welcomed_app(connected, true); + assert!(!frame_is_animating(&app, connected + Duration::from_millis(100))); + + app.status = AppStatus::Connecting; + assert!(!frame_is_animating(&app, connected + Duration::from_millis(100))); +} + +#[test] +fn a_stopped_animation_requests_no_extra_draw_once_the_transcript_has_more_than_the_welcome() { + let mut app = App::test_default(); + app.status = AppStatus::Connecting; + app.transcript.messages.push(ChatMessage::welcome("1.2.3", "Pro", "/demo", "session-1")); + app.transcript.messages.push(ChatMessage::new( + MessageRole::User, + vec![MessageBlock::Text(crate::app::TextBlock::from_complete("hello"))], + None, + )); + let start = Instant::now(); + app.surface_dirty.chat.repaint = false; + assert!(scheduled_draw(&mut app, start), "Connecting animates"); + + app.status = AppStatus::Ready; + + assert!(!scheduled_draw(&mut app, start + Duration::from_millis(16))); +} + +/// The welcome's rows as text, from the real inline row serializer. +fn rendered_welcome_text(app: &mut App) -> String { + let rows = crate::ui::inline_chat_rows::serialize_live_rows_with_boundaries_excluding( + app, + 120, + &std::collections::BTreeSet::new(), + ); + rows.rows() + .iter() + .map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect::()) + .collect::>() + .join("\n") +} + +#[test] +fn the_connecting_crab_changes_pose_every_192_ms_on_the_real_loop_tick() { + let start = Instant::now(); + let mut app = App::test_default(); + app.status = AppStatus::Connecting; + app.transcript.messages.push(ChatMessage::welcome("1.2.3", "-", "/demo", "-")); + let mut shown = rendered_welcome_text(&mut app); + let mut changes_ms = Vec::new(); + + // The loop ticks every 16 ms; the spinner that paces the crab advances at + // most once per 30 ms, so once per two ticks. + for tick in 0..=70_u32 { + let elapsed = EVENT_LOOP_TICK_INTERVAL * tick; + schedule_animation_frame(&mut app, start + elapsed); + let now_shown = rendered_welcome_text(&mut app); + if now_shown != shown { + changes_ms.push(elapsed.as_millis()); + shown = now_shown; + } + } + + // The first advance is on the first tick, so the first pose is shorter; + // after that a pose lasts 6 spinner frames of 32 ms. + assert_eq!(changes_ms, [160, 352, 544, 736, 928, 1120]); +} diff --git a/src/ui/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs index 291d1e21..f89222af 100644 --- a/src/ui/inline_chat_rows.rs +++ b/src/ui/inline_chat_rows.rs @@ -23,6 +23,7 @@ use crate::ui::wrap::wrap_lines_to_physical_rows; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use std::collections::BTreeSet; +use std::time::Instant; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TopLevelInlineBlockKind { @@ -42,6 +43,18 @@ pub(crate) fn serialize_live_rows_with_boundaries_excluding( app: &mut App, width: u16, excluded_ids: &BTreeSet, +) -> SerializedLiveRows { + serialize_live_rows_at(app, width, excluded_ids, Instant::now()) +} + +/// Serializes the live rows as of `now`: one clock reading per pass, so the +/// welcome crab's pose and its commit decision agree, and the rows committed +/// to scrollback are exactly the rows this pass drew. +fn serialize_live_rows_at( + app: &mut App, + width: u16, + excluded_ids: &BTreeSet, + now: Instant, ) -> SerializedLiveRows { let current_mode_id = app.session_runtime.mode.as_ref().map(|mode| mode.current_mode_id.clone()); @@ -61,6 +74,7 @@ pub(crate) fn serialize_live_rows_with_boundaries_excluding( width, excluded_ids, previous_block_kind, + now, }, ); @@ -90,6 +104,7 @@ struct LiveRowsRenderContext<'a> { width: u16, excluded_ids: &'a BTreeSet, previous_block_kind: Option, + now: Instant, } fn render_live_message_rows( @@ -100,7 +115,7 @@ fn render_live_message_rows( ) -> RenderedMessageRows { match role { MessageRole::Welcome => { - render_welcome_live_rows(app, msg_idx, context.width, context.excluded_ids) + render_welcome_live_rows(app, msg_idx, context.width, context.excluded_ids, context.now) } MessageRole::User | MessageRole::System(_) => render_user_system_live_rows( app, @@ -125,15 +140,17 @@ fn render_welcome_live_rows( msg_idx: usize, width: u16, excluded_ids: &BTreeSet, + now: Instant, ) -> RenderedMessageRows { let ids = welcome_output_ids(&app.transcript.messages[msg_idx]); - let commit_ready = message_commit_ready(&app.transcript.messages[msg_idx]); if ids_are_excluded(&ids, excluded_ids) { return RenderedMessageRows::skipped_transcript_content(); } + let commit_ready = welcome::welcome_block(&app.transcript.messages[msg_idx]) + .is_some_and(|welcome| welcome::welcome_commit_ready(app, welcome, width, now)); RenderedMessageRows::message( - serialize_welcome_message(app, msg_idx, width), + serialize_welcome_message(app, msg_idx, width, now), LiveRowBoundary { ids, msg_idx, @@ -404,7 +421,12 @@ fn welcome_output_ids(message: &ChatMessage) -> Vec { .unwrap_or_else(|| vec![HistoryOutputId::Message(message.id)]) } -fn serialize_welcome_message(app: &App, msg_idx: usize, width: u16) -> Vec> { +fn serialize_welcome_message( + app: &App, + msg_idx: usize, + width: u16, + now: Instant, +) -> Vec> { if !app.show_session_overview { return Vec::new(); } @@ -417,7 +439,7 @@ fn serialize_welcome_message(app: &App, msg_idx: usize, width: u16) -> Vec TopLevelInlineBlockKind { @@ -429,47 +451,19 @@ const fn message_block_kind(role: &MessageRole) -> TopLevelInlineBlockKind { } } -fn message_commit_ready(message: &ChatMessage) -> bool { - match &message.role { - MessageRole::Welcome => welcome_message_commit_ready(message), - MessageRole::User | MessageRole::System(_) | MessageRole::Assistant => true, - } -} - -fn welcome_message_commit_ready(message: &ChatMessage) -> bool { - message - .blocks - .iter() - .find_map(|block| match block { - MessageBlock::Welcome(welcome) => Some(welcome), - MessageBlock::Text(_) - | MessageBlock::BtwExchange(_) - | MessageBlock::Notice(_) - | MessageBlock::ToolCall(_) - | MessageBlock::ToolResult { .. } - | MessageBlock::ImageAttachment(_) - | MessageBlock::UserDialog(_) => None, - }) - .is_some_and(|welcome| { - welcome_value_ready(&welcome.subscription) && welcome_value_ready(&welcome.session_id) - }) -} - -fn welcome_value_ready(value: &str) -> bool { - !value.trim().is_empty() && value != "-" -} - fn serialize_compact_welcome_entry( app: &App, entry: &WelcomeBlock, width: u16, + now: Instant, ) -> Vec> { let heading = Line::from(Span::styled( "Overview", Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD), )); let mut lines = wrap_lines_to_physical_rows(&[heading], width); - lines.extend(welcome::overview_lines(entry, Some(status_label(app)), width)); + let ferris = welcome::ferris_frame(welcome::ferris_phase(app, entry, width, now)); + lines.extend(welcome::overview_lines(entry, Some(status_label(app)), ferris, width)); lines } @@ -1458,7 +1452,7 @@ fn preview_rows(rows: &[Line<'static>], limit: usize) -> String { #[cfg(test)] mod tests { use super::{ - LiveRowBoundaryKind, LiveRowSegment, SerializedLiveRows, + LiveRowBoundaryKind, LiveRowSegment, SerializedLiveRows, serialize_live_rows_at, serialize_live_rows_with_boundaries_excluding, }; @@ -2955,6 +2949,197 @@ mod tests { assert_eq!(serialized.stable_row_count(), 0); } + // The crab fixtures are written out here rather than taken from + // welcome.rs, so a changed frame table fails these tests. + const REST_CRAB: [&str; 4] = + [r" _~^~^~_ ", r"\) / o o \ (/ ", r" '_ - _' ", r" / '-----' \ "]; + const EYES_LEFT_ROW: &str = r"\) / o o \ (/ "; + const EYES_RIGHT_ROW: &str = r"\) / o o \ (/ "; + const LEGS_B_ROW: &str = r" \ '-----' / "; + const WAVE_TOP_ROW: &str = r"\) _~^~^~_ (/ "; + + /// The four crab cells of the rendered welcome: the art sits after the + /// two-column left padding, 16 columns wide. + fn crab_rows(rows: &[Line<'_>]) -> Vec { + let text = line_texts(rows); + let Some(start) = text.iter().position(|line| line.contains("_~^~^~_")) else { + return Vec::new(); + }; + text[start..] + .iter() + .take(4) + .map(|line| format!("{line:<18}").chars().skip(2).take(16).collect()) + .collect() + } + + /// The hello is timed from the first connect, pinned to this instant, and + /// every render passes an explicit `now`, so no test depends on how fast + /// it runs. + struct CrabClock(std::time::Instant); + + impl CrabClock { + fn new() -> Self { + Self(std::time::Instant::now()) + } + + fn at(&self, millis: u64) -> std::time::Instant { + self.0 + std::time::Duration::from_millis(millis) + } + } + + fn rows_at(app: &mut App, now: std::time::Instant) -> SerializedLiveRows { + serialize_live_rows_at(app, 120, &BTreeSet::new(), now) + } + + /// A welcome whose values are still loading, `spinner_frame` frames into + /// the connecting loop. + fn connecting_app(spinner_frame: usize) -> App { + let mut app = App::test_default(); + app.status = AppStatus::Connecting; + app.spinner_frame = spinner_frame; + app.transcript.messages.push(ChatMessage::welcome("1.2.3", "-", "/workspace/demo", "-")); + app + } + + /// A ready welcome whose first connect is the clock's base instant, with + /// the settings snapshot acknowledged. + fn connected_app(clock: &CrabClock) -> App { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + app.startup.mark_connected(clock.0); + app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( + "prefersReducedMotion", + serde_json::json!(false), + )); + app.transcript.messages.push(ChatMessage::welcome( + "1.2.3", + "Pro", + "/workspace/demo", + "session-123", + )); + app + } + + fn reduce_motion(app: &mut App) { + app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value( + "prefersReducedMotion", + serde_json::json!(true), + )); + } + + #[test] + fn connecting_crab_steps_through_poses_by_spinner_frame() { + let clock = CrabClock::new(); + let mut app = connecting_app(0); + let now = clock.at(0); + let crab_at = |app: &mut App, spinner_frame: usize| { + app.spinner_frame = spinner_frame; + crab_rows(rows_at(app, now).rows()) + }; + + let first = crab_at(&mut app, 0); + let last_of_first = crab_at(&mut app, 5); + let second = crab_at(&mut app, 6); + let third = crab_at(&mut app, 12); + let wrapped = crab_at(&mut app, 24); + + assert_eq!(first.len(), 4, "{first:?}"); + assert_eq!(last_of_first, first, "a pose lasts 6 spinner frames"); + assert_ne!(first, second); + assert_eq!(first[1], EYES_LEFT_ROW); + assert_eq!(second[3], LEGS_B_ROW); + assert_eq!(third[1], EYES_RIGHT_ROW); + assert_eq!(wrapped, first, "the loop repeats after four poses"); + } + + #[test] + fn hello_waves_while_holding_the_welcome_back_from_scrollback() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + + let serialized = rows_at(&mut app, clock.at(0)); + + assert_eq!(crab_rows(serialized.rows())[0], WAVE_TOP_ROW); + assert_eq!(serialized.stable_row_count(), 0); + let last_instant = rows_at(&mut app, clock.at(1_199)); + assert_eq!(crab_rows(last_instant.rows()), REST_CRAB, "the hello ends at rest"); + assert_eq!(last_instant.stable_row_count(), 0, "still held inside the window"); + } + + #[test] + fn crab_rests_and_commits_when_the_hello_window_closes() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + + let serialized = rows_at(&mut app, clock.at(1_200)); + + assert_eq!(crab_rows(serialized.rows()), REST_CRAB); + assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + } + + #[test] + fn a_later_connection_does_not_replay_the_hello() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + // `/new` or a resume connects again with the welcome as the whole + // transcript, long after the first hello ended. + app.startup.mark_connected(clock.at(60_000)); + + let serialized = rows_at(&mut app, clock.at(60_100)); + + assert_eq!(crab_rows(serialized.rows()), REST_CRAB); + assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + } + + #[test] + fn a_narrow_terminal_shows_no_crab_and_does_not_hold_the_welcome_back() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + + let narrow = serialize_live_rows_at(&mut app, 36, &BTreeSet::new(), clock.at(0)); + + assert!(crab_rows(narrow.rows()).is_empty(), "no crab column at 36 columns"); + assert_eq!(narrow.stable_row_count(), narrow.rows().len(), "nothing to wait for"); + } + + #[test] + fn a_prompt_during_the_hello_snaps_the_crab_to_rest_and_commits_it() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + app.push_message_tracked(user_text_message("typed first")); + + let serialized = rows_at(&mut app, clock.at(100)); + + assert_eq!(crab_rows(serialized.rows()), REST_CRAB); + assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + } + + #[test] + fn hello_waits_at_rest_until_the_motion_preference_is_known() { + let clock = CrabClock::new(); + let mut app = connected_app(&clock); + app.config.snapshot = None; + + let serialized = rows_at(&mut app, clock.at(100)); + + assert_eq!(crab_rows(serialized.rows()), REST_CRAB); + assert_eq!(serialized.stable_row_count(), 0, "held until the hello window closes"); + } + + #[test] + fn reduced_motion_keeps_the_crab_at_rest() { + let clock = CrabClock::new(); + let mut connecting = connecting_app(0); + reduce_motion(&mut connecting); + assert_eq!(crab_rows(rows_at(&mut connecting, clock.at(0)).rows()), REST_CRAB); + + let mut hello = connected_app(&clock); + reduce_motion(&mut hello); + let serialized = rows_at(&mut hello, clock.at(100)); + assert_eq!(crab_rows(serialized.rows()), REST_CRAB); + assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + } + #[test] fn hidden_canonical_tool_renders_no_rows_or_label() { let mut app = App::test_default(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 67c642ce..648a80d6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,6 +32,7 @@ mod welcome; mod wrap; pub use message::SpinnerState; +pub(crate) use welcome::{ferris_hello_live, welcome_is_whole_transcript}; use crate::app::App; use crate::app::{FullscreenView, SurfaceMode}; diff --git a/src/ui/welcome.rs b/src/ui/welcome.rs index ca2901eb..4b9b0ea3 100644 --- a/src/ui/welcome.rs +++ b/src/ui/welcome.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 Simon Peter Rothgang -use crate::app::WelcomeBlock; +use crate::app::{ + App, AppStatus, ChatMessage, MessageBlock, SurfaceMode, TerminalLifecycleState, WelcomeBlock, +}; use crate::ui::host_tips::HOST_TIPS; use crate::ui::theme; use crate::ui::wrap::{ @@ -10,9 +12,175 @@ use crate::ui::wrap::{ }; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use std::time::{Duration, Instant}; + +/// One pose of the welcome crab: four rows, each as wide as the rest pose's, +/// so the overview text beside it never shifts. +pub(crate) type FerrisFrame = [&'static str; 4]; + +const SHELL: &str = r" _~^~^~_ "; +const EYES: &str = r"\) / o o \ (/ "; +const EYES_LEFT: &str = r"\) / o o \ (/ "; +const EYES_RIGHT: &str = r"\) / o o \ (/ "; +const EYES_BLINK: &str = r"\) / - - \ (/ "; +const MOUTH: &str = r" '_ - _' "; +const SMILE: &str = r" '_ u _' "; +const LEGS_A: &str = r" / '-----' \ "; +const LEGS_B: &str = r" \ '-----' / "; +const WAVE_SHELL: &str = r"\) _~^~^~_ (/ "; +const WAVE_EYES: &str = r" \ / o o \ / "; +const WAVE_BLINK: &str = r" \ / - - \ / "; + +pub(crate) const FERRIS_REST: FerrisFrame = [SHELL, EYES, MOUTH, LEGS_A]; +const FERRIS_REST_BLINK: FerrisFrame = [SHELL, EYES_BLINK, MOUTH, LEGS_A]; +const FERRIS_WAVE: FerrisFrame = [WAVE_SHELL, WAVE_EYES, SMILE, LEGS_A]; +const FERRIS_WAVE_BLINK: FerrisFrame = [WAVE_SHELL, WAVE_BLINK, SMILE, LEGS_A]; + +/// Scuttles in place and looks around while the session connects. +const CONNECTING_FRAMES: [FerrisFrame; 4] = [ + [SHELL, EYES_LEFT, MOUTH, LEGS_A], + [SHELL, EYES, MOUTH, LEGS_B], + [SHELL, EYES_RIGHT, MOUTH, LEGS_A], + [SHELL, EYES, MOUTH, LEGS_B], +]; +/// Spinner frames per connecting pose, like `PULSE_FRAME_DIVISOR` in +/// `tab_title.rs`. The spinner advances once per two 16 ms loop ticks (about +/// 32 ms), so a pose lasts about 192 ms. +const CONNECTING_FRAME_DIVISOR: usize = 6; + +/// Played once, after the first session of the process connects. It ends on +/// the rest pose, so the last frame drawn inside the window is already final. +const HELLO_FRAMES: [FerrisFrame; 5] = + [FERRIS_WAVE, FERRIS_WAVE_BLINK, FERRIS_WAVE, FERRIS_REST_BLINK, FERRIS_REST]; +const HELLO_BEAT: Duration = Duration::from_millis(240); +const HELLO_WINDOW: Duration = Duration::from_millis(1_200); + +/// What the welcome crab is doing at one instant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FerrisPhase { + Rest, + Connecting { spinner_frame: usize }, + Hello { elapsed: Duration }, +} + +/// The crab's phase, derived from app state and the clock; the only stored +/// input is the instant of the first connection. `width` is the width the +/// welcome is rendered at. +pub(crate) fn ferris_phase( + app: &App, + welcome: &WelcomeBlock, + width: u16, + now: Instant, +) -> FerrisPhase { + if !motion_allowed(app) { + return FerrisPhase::Rest; + } + // A welcome whose values are loading is never commit-ready, so the loop + // can never be frozen into scrollback mid-step. + if app.status == AppStatus::Connecting && !welcome_values_ready(welcome) { + return FerrisPhase::Connecting { spinner_frame: app.spinner_frame }; + } + hello_beat(app, width, now).map_or(FerrisPhase::Rest, |elapsed| FerrisPhase::Hello { elapsed }) +} + +/// Whether the frame loop must keep redrawing for the welcome hello: while +/// its window holds the welcome back from scrollback and a chat surface shows +/// it. The draw after the window closes is the one that commits the welcome, +/// so the loop redraws through the window even before the motion preference +/// is known and the wave cannot start yet. The connecting loop needs nothing +/// here: the Connecting status already redraws. +pub(crate) fn ferris_hello_live(app: &App, now: Instant) -> bool { + let chat_visible = matches!( + app.terminal_lifecycle, + TerminalLifecycleState::Running(SurfaceMode::Chat) | TerminalLifecycleState::Bootstrapping + ); + chat_visible && hello_window(app, app.chat_render.terminal_size().width, now).is_some() +} + +/// Whether the welcome rows may be committed to scrollback, where they can +/// never be redrawn. They are held back while the hello window is open, so a +/// committed welcome is always final and always at rest. +pub(crate) fn welcome_commit_ready( + app: &App, + welcome: &WelcomeBlock, + width: u16, + now: Instant, +) -> bool { + welcome_values_ready(welcome) && hello_window(app, width, now).is_none() +} + +/// The time into the first-connect hello while it holds the welcome back: +/// the crab is visible at `width`, the welcome is the whole transcript (a +/// prompt, a notice or resumed history snaps the crab to rest) and the window +/// since the first connection has not closed. Later connects of the process +/// never reopen it. +fn hello_window(app: &App, width: u16, now: Instant) -> Option { + if !motion_allowed(app) + || !ferris_column_visible(usize::from(width)) + || !welcome_is_whole_transcript(app) + { + return None; + } + let elapsed = now.saturating_duration_since(app.startup.first_connected_at()?); + (elapsed < HELLO_WINDOW).then_some(elapsed) +} + +/// The time into the hello while the crab waves. The preference arrives with +/// the settings snapshot, which is requested only after the first connect, so +/// the wave starts partway through its window if the snapshot is late and is +/// skipped if it comes after the window. +fn hello_beat(app: &App, width: u16, now: Instant) -> Option { + hello_window(app, width, now).filter(|_| app.config.snapshot.is_some()) +} + +/// Whether the transcript holds nothing but the welcome. +pub(crate) fn welcome_is_whole_transcript(app: &App) -> bool { + matches!(app.transcript.messages.as_slice(), [message] if welcome_block(message).is_some()) +} + +fn motion_allowed(app: &App) -> bool { + !app.config.prefers_reduced_motion_effective() + && !app.shutdown_requested() + && app.show_session_overview +} + +/// The pose for a phase. +pub(crate) fn ferris_frame(phase: FerrisPhase) -> &'static FerrisFrame { + match phase { + FerrisPhase::Rest => &FERRIS_REST, + FerrisPhase::Connecting { spinner_frame } => { + let pose = spinner_frame / CONNECTING_FRAME_DIVISOR; + CONNECTING_FRAMES.get(pose % CONNECTING_FRAMES.len()).unwrap_or(&FERRIS_REST) + } + // Past the last beat the hello holds its final pose, which is rest. + FerrisPhase::Hello { elapsed } => { + HELLO_FRAMES.get(beat_index(elapsed, HELLO_BEAT)).unwrap_or(&FERRIS_REST) + } + } +} + +fn beat_index(elapsed: Duration, interval: Duration) -> usize { + let beats = elapsed.as_millis() / interval.as_millis().max(1); + usize::try_from(beats).unwrap_or(usize::MAX) +} + +pub(crate) fn welcome_block(message: &ChatMessage) -> Option<&WelcomeBlock> { + message.blocks.iter().find_map(|block| match block { + MessageBlock::Welcome(welcome) => Some(welcome), + MessageBlock::Text(_) + | MessageBlock::BtwExchange(_) + | MessageBlock::Notice(_) + | MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } + | MessageBlock::ImageAttachment(_) + | MessageBlock::UserDialog(_) => None, + }) +} + +fn welcome_values_ready(welcome: &WelcomeBlock) -> bool { + !welcome_value_missing(&welcome.subscription) && !welcome_value_missing(&welcome.session_id) +} -const FERRIS_ART: &[&str] = - &[r" _~^~^~_ ", r"\) / o o \ (/ ", r" '_ - _' ", r" / '-----' \ "]; const FERRIS_LEFT_PADDING: &str = " "; const FERRIS_TEXT_GAP: usize = 2; const MIN_INLINE_FIELD_VALUE_WIDTH: usize = 8; @@ -21,6 +189,7 @@ const WELCOME_FIELD_LABELS: &[&str] = &["Version", "Subscription", "Cwd", "Sessi pub(crate) fn overview_lines( block: &WelcomeBlock, loading_status: Option<&str>, + ferris: &FerrisFrame, width: u16, ) -> Vec> { let width = usize::from(width); @@ -41,9 +210,8 @@ pub(crate) fn overview_lines( }; let ferris_width = ferris_column_width(); - let overview_offset = ferris_width.saturating_add(FERRIS_TEXT_GAP); - let overview_width = width.saturating_sub(overview_offset); - let side_by_side = overview_width >= minimum_overview_width(); + let overview_width = width.saturating_sub(ferris_width.saturating_add(FERRIS_TEXT_GAP)); + let side_by_side = ferris_column_visible(width); let text_width = if side_by_side { overview_width } else { width }; let text_rows = overview_text_rows( block, @@ -53,7 +221,7 @@ pub(crate) fn overview_lines( text_width, ); let mut lines = if side_by_side { - join_column_lines(ferris_rows(), text_rows, ferris_width, FERRIS_TEXT_GAP) + join_column_lines(ferris_rows(ferris), text_rows, ferris_width, FERRIS_TEXT_GAP) } else { text_rows }; @@ -100,11 +268,18 @@ fn welcome_field_lines( fn ferris_column_width() -> usize { display_width(FERRIS_LEFT_PADDING) - .saturating_add(FERRIS_ART.iter().map(|line| display_width(line)).max().unwrap_or(0)) + .saturating_add(FERRIS_REST.iter().map(|line| display_width(line)).max().unwrap_or(0)) +} + +/// Whether a row `width` columns wide shows the crab beside the overview; a +/// narrower one shows the overview text alone. +fn ferris_column_visible(width: usize) -> bool { + let overview_offset = ferris_column_width().saturating_add(FERRIS_TEXT_GAP); + width.saturating_sub(overview_offset) >= minimum_overview_width() } -fn ferris_rows() -> Vec> { - FERRIS_ART +fn ferris_rows(ferris: &FerrisFrame) -> Vec> { + ferris .iter() .map(|art| { Line::from(Span::styled( @@ -140,14 +315,55 @@ pub(crate) fn selected_tip(block: &WelcomeBlock) -> &'static str { #[cfg(test)] mod tests { - use super::{HOST_TIPS, Line, overview_lines}; + use super::{ + CONNECTING_FRAMES, FERRIS_REST, FerrisPhase, HELLO_FRAMES, HOST_TIPS, Line, ferris_frame, + overview_lines, + }; use crate::app::{ChatMessage, MessageBlock}; - use crate::ui::wrap::line_display_width; + use crate::ui::wrap::{display_width, line_display_width}; + use std::time::Duration; + + /// The static crab claude-rs drew before the animation, written out so + /// the rest pose cannot drift from it. + const STATIC_CRAB: [&str; 4] = + [r" _~^~^~_ ", r"\) / o o \ (/ ", r" '_ - _' ", r" / '-----' \ "]; fn line_text(line: &Line<'_>) -> String { line.spans.iter().map(|span| span.content.as_ref()).collect() } + #[test] + fn every_crab_frame_keeps_the_rest_pose_geometry() { + assert_eq!(FERRIS_REST, STATIC_CRAB); + for frame in CONNECTING_FRAMES.iter().chain(HELLO_FRAMES.iter()) { + for (row, rest_row) in frame.iter().zip(STATIC_CRAB) { + assert!(row.is_ascii(), "{row:?}"); + assert_eq!(display_width(row), 16, "{row:?}"); + assert_eq!(display_width(row), display_width(rest_row), "{row:?}"); + } + } + assert_eq!(HELLO_FRAMES.last(), Some(&FERRIS_REST), "the hello ends at rest"); + } + + #[test] + fn frames_follow_the_spinner_and_the_hello_clock() { + let at = |ms| Duration::from_millis(ms); + let connecting = |frame| ferris_frame(FerrisPhase::Connecting { spinner_frame: frame }); + let hello = |ms| ferris_frame(FerrisPhase::Hello { elapsed: at(ms) }); + + assert_eq!(connecting(0)[1], r"\) / o o \ (/ "); + assert_eq!(connecting(5)[1], r"\) / o o \ (/ "); + assert_eq!(connecting(6)[3], r" \ '-----' / "); + assert_eq!(connecting(12)[1], r"\) / o o \ (/ "); + assert_eq!(connecting(24), connecting(0), "the loop repeats after four poses"); + assert_eq!(hello(0)[0], r"\) _~^~^~_ (/ "); + assert_eq!(hello(240)[1], r" \ / - - \ / "); + assert_eq!(hello(720)[1], r"\) / - - \ (/ "); + assert_eq!(*hello(960), STATIC_CRAB); + assert_eq!(*hello(60_000), STATIC_CRAB); + assert_eq!(*ferris_frame(FerrisPhase::Rest), STATIC_CRAB); + } + #[test] fn overview_lines_render_expected_fields() { let mut message = ChatMessage::welcome(env!("CARGO_PKG_VERSION"), "-", "/cwd", "-"); @@ -155,7 +371,7 @@ mod tests { panic!("expected welcome block"); }; block.tip_seed = 16; - let lines: Vec = overview_lines(block, None, 120) + let lines: Vec = overview_lines(block, None, &FERRIS_REST, 120) .into_iter() .map(|line| line.spans.into_iter().map(|s| s.content).collect()) .collect(); @@ -180,7 +396,7 @@ mod tests { }; block.tip_seed = 7; - let lines = overview_lines(block, None, 110); + let lines = overview_lines(block, None, &FERRIS_REST, 110); let text = lines.iter().map(line_text).collect::>(); let tip_row = text.iter().position(|line| line.contains("Tips: Start")).expect("tip row"); @@ -201,7 +417,8 @@ mod tests { panic!("expected welcome block"); }; - let text = overview_lines(block, None, 70).iter().map(line_text).collect::>(); + let text = + overview_lines(block, None, &FERRIS_REST, 70).iter().map(line_text).collect::>(); let cwd_row = text.iter().position(|line| line.contains("Cwd: alpha")).expect("cwd row"); assert_eq!(text[cwd_row].find("Cwd:"), Some(20)); @@ -216,7 +433,7 @@ mod tests { }; block.tip_seed = 7; - let lines = overview_lines(block, None, 36); + let lines = overview_lines(block, None, &FERRIS_REST, 36); let text = lines.iter().map(line_text).collect::>(); let tip_row = text.iter().position(|line| line.starts_with("Tips: Start")).expect("tip row"); From 6cd5b8e83c4d99c4b03dc289c1cd624bfd0d12fd Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:13:21 +0200 Subject: [PATCH 08/10] feat(ui): mark sent user messages with the composer prompt glyph (#480) - render the transcript user-message marker from theme::PROMPT_CHAR instead of a hardcoded ">" - keep the transcript marker dim so sent messages stay distinct from the orange composer prompt - update transcript rendering, resume history, and terminal resize tests to the new marker --- src/app/events/tests/mod.rs | 2 +- src/ui/inline_chat_rows.rs | 8 ++++---- src/ui/message_rows.rs | 2 +- tests/terminal_resize.rs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/events/tests/mod.rs b/src/app/events/tests/mod.rs index e131539b..339854c0 100644 --- a/src/app/events/tests/mod.rs +++ b/src/app/events/tests/mod.rs @@ -1358,7 +1358,7 @@ fn resume_history_preserves_user_message_boundaries_and_joins_only_same_source_c ["/rename review-444", "/color blue", "Explain literal XML"] { assert!( - text.iter().any(|row| row == &format!(" > {message}")), + text.iter().any(|row| row == &format!(" ❯ {message}")), "separate user marker and text for {message}, startup={startup}" ); } diff --git a/src/ui/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs index f89222af..af855e5d 100644 --- a/src/ui/inline_chat_rows.rs +++ b/src/ui/inline_chat_rows.rs @@ -1595,7 +1595,7 @@ mod tests { ); } } - assert_eq!(buffer[(1, 1)].symbol(), ">"); + assert_eq!(buffer[(1, 1)].symbol(), "❯"); assert_eq!(buffer[(1, 1)].fg, crate::ui::theme::DIM); assert_eq!(buffer[(3, 1)].symbol(), "a"); assert_eq!(buffer[(3, 3)].symbol(), "o"); @@ -2051,9 +2051,9 @@ mod tests { "narrow rows should wrap more physical rows; narrow={narrow_text:?}, wide={wide_text:?}" ); assert_eq!(compact_text(&narrow_rows), compact_text(&wide_rows)); - assert!(narrow_text.contains(" > ")); + assert!(narrow_text.contains(" ❯ ")); assert!(narrow_text.contains(" ● ")); - assert!(wide_text.contains(" > ")); + assert!(wide_text.contains(" ❯ ")); assert!(wide_text.contains(" ● ")); } @@ -2944,7 +2944,7 @@ mod tests { let serialized = serialize_all_rows_with_boundaries(&mut app, 120); assert!( - line_texts(serialized.rows()).iter().any(|line| line == " > queued while connecting") + line_texts(serialized.rows()).iter().any(|line| line == " ❯ queued while connecting") ); assert_eq!(serialized.stable_row_count(), 0); } diff --git a/src/ui/message_rows.rs b/src/ui/message_rows.rs index 5850bbf8..c7746ede 100644 --- a/src/ui/message_rows.rs +++ b/src/ui/message_rows.rs @@ -76,7 +76,7 @@ pub(crate) fn build_user_system_message_rows( lines: message_layout::frame_rows( physical, width, - Some((">", theme::DIM)), + Some((theme::PROMPT_CHAR, theme::DIM)), Some(theme::USER_MSG_BG), (true, true), &[], diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 5230cdd8..60649000 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -1248,7 +1248,7 @@ fn tips_setting_preserves_activity_without_empty_messages_through_real_config_sa test.send(b"\x1b"); test.wait_until("only tips follow the acknowledged setting", |test| { let screen = test.screen(); - screen.lines().any(|line| line.trim().strip_prefix("> ") == Some("NO_OUTPUT_YET")) + screen.lines().any(|line| line.trim().strip_prefix("❯ ") == Some("NO_OUTPUT_YET")) && !screen.lines().any(assistant_marker) && screen.lines().any(|line| line.trim_end() == activity_header) && screen.contains("│ Tip:") == enabled @@ -1558,7 +1558,7 @@ fn resizing_a_streamed_reply_preserves_rendering_and_clean_shutdown() { // whole transcript. Terminal padding can leave spaces on visually blank rows. test.wait_until("submitted paragraph gap", |test| { let transcript = test.transcript_rows(); - let Some(first) = transcript.iter().position(|row| row.trim() == "> hello") else { + let Some(first) = transcript.iter().position(|row| row.trim() == "❯ hello") else { return false; }; let Some(second) = transcript.iter().position(|row| row.trim() == "how are you") else { From 1c257544879ee8f2cd658f130682e991810ac0c8 Mon Sep 17 00:00:00 2001 From: srothgan <141313976+srothgan@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:02:50 +0200 Subject: [PATCH 09/10] feat(footer): add configurable single-row footer (#481) * feat(footer): add configurable single-row footer - add General dialog for field visibility, alignment, and ordering - autosave user layouts with queued edits, reset, and failure recovery - render independent fields with priority notices and narrow-terminal fitting - add persistence, keyboard, rendering, and resize regression coverage - document footer controls and preference storage * test(agent-sdk): isolate startup failure contract setup - test missing executable errors without enumerating session history - verify session-list correlation in the initialization contract - isolate both bridge fixtures from user profiles and repository state --- agent-sdk/src/bridge.contract.test.ts | 29 +- agent-sdk/src/bridge/settings_catalog.ts | 1 + agent-sdk/src/bridge/settings_layout.test.ts | 3 +- agent-sdk/src/bridge/settings_layout.ts | 3 +- agent-sdk/src/bridge/settings_service.test.ts | 36 ++ agent-sdk/src/bridge/settings_values.ts | 6 +- docs/src/settings.md | 25 +- src/app/config/edit.rs | 19 +- src/app/config/footer/mod.rs | 189 +++++++ src/app/config/footer/tests.rs | 443 +++++++++++++++ src/app/config/mcp/snapshot.rs | 1 + src/app/config/mod.rs | 2 + src/app/config/overlays.rs | 1 + src/app/config/service/mod.rs | 11 +- src/app/config/state.rs | 33 +- src/app/footer.rs | 216 +++++++ src/app/mod.rs | 1 + src/app/slash/tests.rs | 2 +- src/ui/config/footer.rs | 135 +++++ src/ui/config/mod.rs | 18 +- src/ui/config/settings.rs | 37 +- src/ui/config/settings_overlay.rs | 4 + src/ui/footer_rows.rs | 526 ++++++++---------- tests/fixtures/settings-ui-catalog.json | 18 + tests/terminal_resize.rs | 5 +- 25 files changed, 1429 insertions(+), 335 deletions(-) create mode 100644 src/app/config/footer/mod.rs create mode 100644 src/app/config/footer/tests.rs create mode 100644 src/app/footer.rs create mode 100644 src/ui/config/footer.rs diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index 00273044..3b9e4d9f 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -400,12 +400,13 @@ test("bridge process emits connection_failed for malformed JSON", async () => { }); test("bridge process initializes with stable capability envelope", async () => { - const bridge = new SpawnedBridge(); + const directory = mkdtempSync(join(tmpdir(), "claude-rs-initialize-")); + const bridge = new SpawnedBridge({ CLAUDE_CONFIG_DIR: directory }); try { bridge.writeCommand({ request_id: "req-init", command: "initialize", - cwd: process.cwd(), + cwd: directory, }); const envelope = await bridge.nextEnvelope(); @@ -429,6 +430,9 @@ test("bridge process initializes with stable capability envelope", async () => { supports_resume_session: true, }, }); + const sessions = await nextMatching(bridge, event => event.event === "sessions_listed"); + assert.equal(sessions.request_id, "req-init"); + assert.deepEqual(sessions.sessions, []); assert.ok( bridge.stderrLines.every((line) => { const parsed = JSON.parse(line) as { schema?: unknown }; @@ -437,6 +441,7 @@ test("bridge process initializes with stable capability envelope", async () => { ); } finally { await bridge.stop(); + rmSync(directory, { recursive: true, force: true }); } }); @@ -495,28 +500,17 @@ test("bridge process exits cleanly on shutdown", async () => { }); test("bridge process reports actionable session initialization failure", async () => { - const missingClaudeExecutable = join( - tmpdir(), - `claude-rs-missing-claude-code-${process.pid}`, - ); + const directory = mkdtempSync(join(tmpdir(), "claude-rs-missing-executable-")); + const missingClaudeExecutable = join(directory, "missing-claude-code"); const bridge = new SpawnedBridge({ + CLAUDE_CONFIG_DIR: directory, CLAUDE_CODE_EXECUTABLE: missingClaudeExecutable, }); try { - bridge.writeCommand({ - request_id: "req-init", - command: "initialize", - cwd: process.cwd(), - }); - assertProtocolEvent(await bridge.nextEnvelope(), "initialized"); - const sessions = await bridge.nextEnvelope(); - assertProtocolEvent(sessions, "sessions_listed"); - assert.equal(sessions.request_id, "req-init"); - bridge.writeCommand({ request_id: "req-create", command: "create_session", - cwd: process.cwd(), + cwd: directory, launch_settings: {}, }); @@ -534,6 +528,7 @@ test("bridge process reports actionable session initialization failure", async ( ); } finally { await bridge.stop(); + rmSync(directory, { recursive: true, force: true }); } }); diff --git a/agent-sdk/src/bridge/settings_catalog.ts b/agent-sdk/src/bridge/settings_catalog.ts index 6d8dc0e3..b394d0e0 100644 --- a/agent-sdk/src/bridge/settings_catalog.ts +++ b/agent-sdk/src/bridge/settings_catalog.ts @@ -13,6 +13,7 @@ const DEFINITIONS: Definition[] = [ ["timeFormat", "Time format", "Clock format for message times. Custom strftime patterns in settings files are preserved.", "string", ["auto", "12-hour", "24-hour", "24-hour-utc"]], ["presentation.showStatusInTerminalTab", "Show activity in tab title", "Show a busy/idle icon beside the folder name in the terminal tab title. Off keeps just the folder name.", "boolean", undefined, ["user"]], ["presentation.copyFullResponse", "Skip the /copy picker", "Copy the entire last response directly instead of choosing the response or a code block.", "boolean", undefined, ["user"]], + ["presentation.footer", "Footer", "Choose visible footer fields, their left or right alignment, and their order. Permission and authentication notices remain at the far right. Applies immediately.", "json", undefined, ["user"]], ["notifications.actionsRequired", "Notify when input is needed", "Alert when a permission, question or dialog is waiting. Only while this terminal is unfocused. Default: On.", "boolean", undefined, ["user"]], ["notifications.modelDirected", "Notify when Claude requests it", "Allow local alerts from proactive PushNotification tool results when available. Only while this terminal is unfocused. Default: On. Mobile push is configured separately.", "boolean", undefined, ["user"]], ["notifications.turnComplete", "Notify when a turn finishes", "Alert after an active turn finishes successfully. Only while this terminal is unfocused. Default: On.", "boolean", undefined, ["user"]], diff --git a/agent-sdk/src/bridge/settings_layout.test.ts b/agent-sdk/src/bridge/settings_layout.test.ts index 600f45f9..1b72e560 100644 --- a/agent-sdk/src/bridge/settings_layout.test.ts +++ b/agent-sdk/src/bridge/settings_layout.test.ts @@ -13,7 +13,8 @@ test("every control has exactly one pane and structured metadata survives the cr assert.deepEqual(fixture.categories, SETTINGS_CATEGORIES); for (const setting of catalog) { assert.equal(SETTINGS_CATEGORIES.filter(category => category.id === setting.category).length, 1, setting.id); - if (setting.kind === "json" || setting.kind === "string_list") assert.ok(setting.editor, setting.id); + // The footer uses the host's field picker rather than the SDK settings form. + if ((setting.kind === "json" || setting.kind === "string_list") && setting.id !== "presentation.footer") assert.ok(setting.editor, setting.id); } for (const setting of fixture.catalog) assert.deepEqual(setting, catalog.find(candidate => candidate.id === setting.id)); const hooks = catalog.find(setting => setting.id === "hooks")?.editor; diff --git a/agent-sdk/src/bridge/settings_layout.ts b/agent-sdk/src/bridge/settings_layout.ts index 0491e7ef..faee3a35 100644 --- a/agent-sdk/src/bridge/settings_layout.ts +++ b/agent-sdk/src/bridge/settings_layout.ts @@ -34,6 +34,7 @@ const CATEGORY_SETTINGS: Record = { "prefersReducedMotion", "presentation.showStatusInTerminalTab", "presentation.copyFullResponse", + "presentation.footer", "respectGitignore", "preferredNotifChannel", "notifications.actionsRequired", @@ -152,6 +153,6 @@ export function settingsLayout(id: string, kind: SettingDescriptor["kind"], desc if (!category) throw new Error(`Setting has no category: ${id}`); return { category, - ...(kind === "string_list" ? { editor: list(id === "permissions.allow" || id === "permissions.ask" || id === "permissions.deny" ? rule : id === "httpHookAllowedEnvVars" ? envName : { ...text, description }) } : kind === "json" ? { editor: OBJECT_EDITORS[id] } : {}), + ...(kind === "string_list" ? { editor: list(id === "permissions.allow" || id === "permissions.ask" || id === "permissions.deny" ? rule : id === "httpHookAllowedEnvVars" ? envName : { ...text, description }) } : kind === "json" && OBJECT_EDITORS[id] ? { editor: OBJECT_EDITORS[id] } : {}), }; } diff --git a/agent-sdk/src/bridge/settings_service.test.ts b/agent-sdk/src/bridge/settings_service.test.ts index 5faefe92..2ee34904 100644 --- a/agent-sdk/src/bridge/settings_service.test.ts +++ b/agent-sdk/src/bridge/settings_service.test.ts @@ -63,6 +63,42 @@ function mutation(snapshot: SettingsSnapshot, id: string, scope: SettingsScope, return { context: snapshot.context, id, scope, expected_revision: previous.revision, operation: value === undefined ? "remove" : "set", ...(value === undefined ? {} : { value }) }; } +test("footer layout round-trips through app preferences with scoped saves, conflicts and reset", async () => { + const appFile = path.join(root, "app-settings.json"); + const retained = { updates: { skipped_version: "keep" }, presentation: { copyFullResponse: true, future: 17 } }; + await fs.writeFile(appFile, JSON.stringify(retained)); + await write("user", { language: "German" }); + const inspect = () => inspectSettings(cwd, [], [], appFile); + const initial = await inspect(); + const setting = initial.catalog.find(setting => setting.id === "presentation.footer"); + assert.ok(setting); + assert.equal(setting.category, "general"); + assert.equal(setting.application, "host"); + assert.deepEqual(setting.writable_scopes, ["user"]); + const layout = [ + { id: "gitBranch", visible: true, side: "right" }, + { id: "model", visible: false, side: "left" }, + ]; + const saved = await mutateSetting(cwd, mutation(initial, setting.id, "user", layout), [], [], appFile); + assert.equal(saved.persistence, "saved"); + assert.equal(saved.application, "host"); + assert.deepEqual(saved.snapshot?.values.find(value => value.id === setting.id)?.value, layout); + assert.deepEqual(JSON.parse(await fs.readFile(appFile, "utf8")), { ...retained, presentation: { ...retained.presentation, footer: layout } }); + const profile = process.env.CLAUDE_CONFIG_DIR; + assert.ok(profile); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(profile, "settings.json"), "utf8")), { language: "German" }); + for (const scope of ["project", "local"] as const) { + assert.equal((await mutateSetting(cwd, mutation(await inspect(), setting.id, scope, layout), [], [], appFile)).persistence, "failure"); + } + const stale = mutation(await inspect(), setting.id, "user", layout); + await fs.writeFile(appFile, JSON.stringify({ ...retained, presentation: { ...retained.presentation, footer: [] } })); + assert.equal((await mutateSetting(cwd, stale, [], [], appFile)).persistence, "conflict"); + assert.deepEqual((await inspect()).values.find(value => value.id === setting.id)?.value, []); + const reset = await mutateSetting(cwd, mutation(await inspect(), setting.id, "user"), [], [], appFile); + assert.equal(reset.persistence, "saved"); + assert.deepEqual(JSON.parse(await fs.readFile(appFile, "utf8")), retained); +}); + test("presentation preferences share targeted saves while retaining their distinct storage and scopes", async () => { const appFile = path.join(root, "app-settings.json"); await write("user", { presentation: { copyFullResponse: true }, showMessageTimestamps: false, future: 17 }); diff --git a/agent-sdk/src/bridge/settings_values.ts b/agent-sdk/src/bridge/settings_values.ts index 2a47ceaa..6d61c09d 100644 --- a/agent-sdk/src/bridge/settings_values.ts +++ b/agent-sdk/src/bridge/settings_values.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { HOOK_EVENTS, resolveSettings } from "@anthropic-ai/claude-agent-sdk"; import type { Json, SettingDescriptor, SettingsEditorSchema } from "../types.js"; +import { isAppSetting } from "./settings_catalog.js"; // Project only fields the editor owns for isolated native validation. Persist the // original value: extra object fields must not be stripped from the user's file. @@ -49,7 +50,7 @@ export async function validateSettingValue(setting: SettingDescriptor, value: Js } break; case "json": - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a JSON object."); + if (value === null || typeof value !== "object" || (!isAppSetting(setting) && Array.isArray(value))) throw new Error("Expected a JSON object or supported app preference array."); break; } if (!setting.allows_custom && !setting.options.includes(value)) throw new Error("Choose one of the available options."); @@ -65,7 +66,8 @@ export async function validateSettingValue(setting: SettingDescriptor, value: Js const fields = value as Record; if ((fields.caCertPath === undefined) !== (fields.caKeyPath === undefined)) throw new Error("Supply both certificate and key paths, or neither."); } - if (!["string_list", "json"].includes(setting.kind)) return; + // App preference semantics belong to the host, not the Claude settings schema. + if (isAppSetting(setting) || !["string_list", "json"].includes(setting.kind)) return; // The public SDK exposes resolution, not its schema. Resolve an isolated // candidate through that schema, without changing profiles or executing hooks. diff --git a/docs/src/settings.md b/docs/src/settings.md index af086c0c..c1174aa4 100644 --- a/docs/src/settings.md +++ b/docs/src/settings.md @@ -80,7 +80,7 @@ Each save goes to one scope. Press `s` in the list to cycle the save scope throu | Project | `./.claude/settings.json` | Defaults shared with the repository. | | Local | `./.claude/settings.local.json` | Private defaults for this project. | | Managed | Organization policy | Shown for information. Settings restricted by policy are read-only. | -| App preferences | `claude-code-rust/settings.json` in the OS config directory | This app's own preferences: tab-title activity, the `/copy` picker, notification categories and automatic updates. Always saved for the user. | +| App preferences | `claude-code-rust/settings.json` in the OS config directory | This app's own preferences: footer layout, tab-title activity, the `/copy` picker, notification categories and automatic updates. Always saved for the user. | Not every setting can be saved at every scope. A setting that the selected scope cannot hold is read-only until you switch scope; [Settings by pane](#settings-by-pane) lists the restrictions. @@ -163,7 +163,7 @@ While typing in a field: | `Ctrl+J` | Insert a line break. | | `Up`, `Down`, `Left`, `Right` | Choose a value when the field offers fixed choices. | -Rules that apply to every editor: +Rules for these structured editors: - Edits stay in a draft until `Ctrl+S`. Accepting a field does not write the file. - Many lists merge across scopes. Removing the last entry saves an explicit empty list, and entries from other scopes still apply. Reset removes the setting from this scope instead. @@ -317,6 +317,27 @@ The first five are Claude settings and can be saved at User, Project or Local sc **Scrolling.** `Page Up` pauses following and reads earlier output; `Page Down` reads later output. `Ctrl+End` jumps to the latest output and resumes following when Auto-scroll is On. With Auto-scroll Off it jumps once and then holds that position. The reading position survives new output and window resizing. The mouse wheel belongs to the terminal's own scrollback unless the terminal forwards mouse events to the app. +## Footer + +Open `/config` → General → Footer with `Enter` or `Space`. The chat footer uses one row with independently ordered left- and right-aligned groups. Changes save automatically; the preview updates immediately and the running footer changes after each confirmed save. The field-position counter appears in the dialog's top-right corner. The settings row shows `Default layout` until a layout is saved, then `Custom layout`. + +| Key | Action | +| --- | --- | +| `Up`, `Down` | Select a field. | +| `Space` | Toggle visibility, preserving the field's side and position. | +| `Left`, `Right` | Switch between Left and Right; append the field to the destination group. | +| `Ctrl+Up`, `Ctrl+Down` | Move the field within its group; selection follows it. | +| `Ctrl+R` | Remove the saved layout and restore defaults. | +| `Esc` | Close after any pending changes finish saving. | + +You can keep editing while a save is in progress. Later edits are saved next, using the confirmed file revision. A failed save or conflict keeps your edits in the dialog and shows an error; `Enter` retries the save, and `Esc` discards the unsaved edits and closes. An interrupted session reports unconfirmed edits as unsaved and asks you to reopen the editor. + +Permission mode, model, effort, Ultracode, fast mode, working directory, Git branch and context remaining are independent fields. Hiding the model does not hide effort or Ultracode; hiding the directory does not hide the branch. Fields appear only when their session data applies: effort requires a model that supports it, Ultracode requires verified effective state, and branch requires a Git branch. + +Permission and startup MCP-authentication notices remain at the far right and take priority over configured fields. On narrow terminals, paths and branches shorten first, then later fields are omitted, with the left group preceding the right group for overflow priority. The footer never wraps. + +The layout is a user-level app preference saved as the `presentation.footer` array in `claude-code-rust/settings.json` in the OS config directory. Each entry has an `id`, a `visible` boolean and a `side` of `left` or `right`; array order determines reading order within each group. The field IDs are `permissionMode`, `model`, `effort`, `ultracode`, `fastMode`, `directory`, `gitBranch` and `contextRemaining`. Omitted fields remain available in the dialog but are hidden. An empty array hides all optional fields. Removing the setting restores the built-in layout. + ## Notifications Notifications alert only while the terminal reports that it is unfocused. A terminal without focus reporting stays silent. These General settings apply immediately: diff --git a/src/app/config/edit.rs b/src/app/config/edit.rs index 638246fd..a3f051b0 100644 --- a/src/app/config/edit.rs +++ b/src/app/config/edit.rs @@ -20,7 +20,10 @@ pub(super) fn activate_setting(app: &mut App, setting: &SettingDescriptor) { return; }; let scope = app.config.selected_scope; - if !setting.writable_at(scope) && setting.editor.is_none() { + if !setting.writable_at(scope) + && setting.editor.is_none() + && setting.id != crate::app::footer::SETTING_ID + { app.config.last_error = Some( setting .unavailable @@ -32,6 +35,19 @@ pub(super) fn activate_setting(app: &mut App, setting: &SettingDescriptor) { let Some(value) = snapshot.scoped(&setting.id, scope) else { return; }; + if setting.id == crate::app::footer::SETTING_ID { + let editor = SettingOverlayState { + setting: Box::new(setting.clone()), + scope, + context: snapshot.context.clone(), + revision: value.revision.clone(), + draft: String::new(), + cursor: 0, + structured: None, + }; + super::footer::open(app, editor); + return; + } let initial = value .value .as_ref() @@ -136,6 +152,7 @@ pub(super) fn handle_overlay_key(app: &mut App, key: KeyEvent) { return; } match app.config.overlay.clone() { + Some(ConfigOverlayState::Footer(_)) => super::footer::handle_key(app, key), Some(ConfigOverlayState::Setting(_)) => handle_setting_key(app, key), Some(ConfigOverlayState::SessionRename(_)) => handle_session_rename_overlay_key(app, key), Some(ConfigOverlayState::InstalledPluginActions(_)) => { diff --git a/src/app/config/footer/mod.rs b/src/app/config/footer/mod.rs new file mode 100644 index 00000000..fe7891ab --- /dev/null +++ b/src/app/config/footer/mod.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::{ConfigOverlayState, SettingOverlayState}; +use crate::agent::settings::{SettingsMutation, SettingsOperation}; +use crate::app::{App, footer::FooterLayout}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FooterOverlayState { + pub editor: SettingOverlayState, + // None requests the built-in layout by removing the saved preference. + draft: Option, + // Records edits not yet sent, including edits made during an in-flight save. + save_queued: bool, + close_when_saved: bool, + pub(crate) selected: usize, +} + +impl FooterOverlayState { + pub(crate) fn layout(&self) -> FooterLayout { + self.draft.clone().unwrap_or_default() + } + + pub(crate) fn can_retry(&self) -> bool { + self.save_queued + } + + pub(super) fn queue_save(&mut self) { + self.save_queued = true; + self.close_when_saved = false; + } +} + +pub(super) fn open(app: &mut App, editor: SettingOverlayState) { + let saved = app.config.saved_value(&editor.setting.id); + let parsed = FooterLayout::from_saved(saved); + let error = parsed.as_ref().err().map(|error| format!( + "The saved footer is invalid: {error}. Showing defaults; edit to replace it or Esc to leave it unchanged." + )); + let draft = saved.map(|_| parsed.unwrap_or_default()); + app.config.replace_overlay(ConfigOverlayState::Footer(FooterOverlayState { + editor, + draft, + save_queued: false, + close_when_saved: false, + selected: 0, + })); + if let Some(error) = error { + app.config.set_overlay_error(error); + } +} + +pub(super) fn handle_key(app: &mut App, key: KeyEvent) { + if app.config.setting_overlay().is_some_and(|editor| !editor.setting.writable_at(editor.scope)) + && !matches!( + (key.code, key.modifiers), + ( + KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End | KeyCode::Esc, + KeyModifiers::NONE + ) + ) + { + return; + } + match (key.code, key.modifiers) { + (KeyCode::Esc, KeyModifiers::NONE) => { + let failed = app + .config + .overlay_message + .as_ref() + .filter(|message| message.kind == super::OverlayMessageKind::Error) + .map(|message| message.text.clone()); + let Some(ConfigOverlayState::Footer(overlay)) = &mut app.config.overlay else { + return; + }; + if overlay.save_queued + && app.config.pending_settings_request.is_none() + && let Some(error) = failed + { + app.config.clear_overlay(); + app.config.last_error = Some(format!("Footer changes were not saved: {error}")); + return; + } + if !overlay.save_queued && app.config.pending_settings_request.is_none() { + app.config.clear_overlay(); + } else { + overlay.close_when_saved = true; + save_queued(app); + } + } + (KeyCode::Enter, KeyModifiers::NONE) => save_queued(app), + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + if let Some(ConfigOverlayState::Footer(overlay)) = &mut app.config.overlay { + overlay.draft = None; + overlay.selected = 0; + overlay.queue_save(); + } + save_queued(app); + } + _ => { + let Some(ConfigOverlayState::Footer(overlay)) = &mut app.config.overlay else { + return; + }; + let mut layout = overlay.layout(); + let before = layout.clone(); + match (key.code, key.modifiers) { + (KeyCode::Up, KeyModifiers::NONE) => { + overlay.selected = overlay.selected.saturating_sub(1); + } + (KeyCode::Down, KeyModifiers::NONE) => { + overlay.selected = (overlay.selected + 1).min(layout.fields().len() - 1); + } + (KeyCode::Char(' '), KeyModifiers::NONE) => { + layout.toggle(overlay.selected); + } + (KeyCode::Left | KeyCode::Right, KeyModifiers::NONE) => { + overlay.selected = layout.switch_side(overlay.selected); + } + (KeyCode::Up | KeyCode::Down, KeyModifiers::CONTROL) => { + overlay.selected = + layout.move_field(overlay.selected, key.code == KeyCode::Down); + } + (KeyCode::Home, KeyModifiers::NONE) => overlay.selected = 0, + (KeyCode::End, KeyModifiers::NONE) => { + overlay.selected = layout.fields().len() - 1; + } + _ => {} + } + if layout != before { + overlay.draft = Some(layout); + overlay.queue_save(); + save_queued(app); + } + } + } +} + +fn save_queued(app: &mut App) { + if app.config.pending_settings_request.is_some() { + return; + } + let Some(ConfigOverlayState::Footer(overlay)) = &app.config.overlay else { + return; + }; + if !overlay.save_queued { + return; + } + let value = match overlay.draft.as_ref().map(serde_json::to_value).transpose() { + Ok(value) => value, + Err(error) => { + app.config.set_overlay_error(format!("Cannot save the footer: {error}")); + return; + } + }; + let mutation = SettingsMutation { + context: overlay.editor.context.clone(), + id: overlay.editor.setting.id.clone(), + scope: overlay.editor.scope, + expected_revision: overlay.editor.revision.clone(), + operation: if value.is_some() { SettingsOperation::Set } else { SettingsOperation::Remove }, + value, + }; + super::service::send_mutation(app, mutation); + if app.config.pending_settings_request.is_some() + && let Some(ConfigOverlayState::Footer(overlay)) = &mut app.config.overlay + { + overlay.save_queued = false; + app.config.overlay_message = None; + } +} + +pub(super) fn complete_save(app: &mut App, saved: bool) { + let Some(ConfigOverlayState::Footer(overlay)) = &mut app.config.overlay else { + return; + }; + if !saved { + overlay.queue_save(); + if app.config.overlay_message.is_none() { + app.config.set_overlay_error("The footer could not be saved. Press Enter to retry."); + } + } else if overlay.save_queued { + save_queued(app); + } else if overlay.close_when_saved { + app.config.clear_overlay(); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/app/config/footer/tests.rs b/src/app/config/footer/tests.rs new file mode 100644 index 00000000..66c55b25 --- /dev/null +++ b/src/app/config/footer/tests.rs @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::agent::client::{AgentConnection, CommandReceiver}; +use crate::agent::events::ClientEvent; +use crate::agent::settings::{ + SettingsApplication, SettingsPersistence, SettingsResult, SettingsScope, SettingsSnapshot, +}; +use crate::agent::wire::BridgeCommand; +use crate::app::footer::{FooterFieldId, FooterSide, SETTING_ID}; +use ratatui::{Terminal, backend::TestBackend}; +use serde_json::{Value, json}; +use std::rc::Rc; + +fn snapshot(cwd: &str, value: Option<&Value>, revision: &str) -> SettingsSnapshot { + let fixture: Value = + serde_json::from_str(include_str!("../../../../tests/fixtures/settings-ui-catalog.json")) + .expect("fixture"); + let descriptor = fixture["catalog"] + .as_array() + .expect("catalog") + .iter() + .find(|entry| entry["id"] == SETTING_ID) + .expect("footer descriptor"); + serde_json::from_value(json!({ + "cwd":cwd,"context":"footer-context","catalog":[descriptor], + "categories":[{"id":"general","label":"General","short_label":"General"}], + "sources":[{"scope":"user","path":"app-settings.json","status":"valid", + "values":[{"id":SETTING_ID,"revision":revision,"value":value}]}, + {"scope":"project","path":"project-settings.json","status":"missing", + "values":[{"id":SETTING_ID,"revision":"unset"}]}], + "values":[{"id":SETTING_ID,"value":value,"contributors":["user"],"policy_restricted":false}], + "resolution_sources":[],"provenance":{},"diagnostics":[] + })).expect("snapshot") +} + +fn app() -> (App, CommandReceiver) { + let mut app = App::test_default(); + let (connection, commands) = AgentConnection::test_channel(); + app.session_runtime.conn = Some(Rc::new(connection)); + app.session_runtime.session_id = Some("session-1".into()); + app.surface_mode = crate::app::SurfaceMode::Fullscreen(crate::app::FullscreenView::Config); + app.config.snapshot = Some(snapshot(&app.cwd_raw, None, "r1")); + (app, commands) +} + +fn key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { + let _ = crate::app::handle_terminal_event( + app, + crossterm::event::Event::Key(KeyEvent::new(code, modifiers)), + ); +} + +fn editor(app: &App) -> &FooterOverlayState { + let Some(ConfigOverlayState::Footer(editor)) = &app.config.overlay else { + panic!("footer editor") + }; + editor +} + +fn footer(app: &App) -> String { + crate::ui::footer_rows::serialize_footer_rows(app, 160).rows[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[test] +fn opening_navigation_and_read_only_scope_do_not_write_settings() { + let (mut app, mut commands) = app(); + let before = footer(&app); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::NONE); + assert_eq!(footer(&app), before); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.config.overlay.is_none()); + assert!(commands.try_recv().is_err()); + assert_eq!(footer(&app), before); + app.config.selected_scope = SettingsScope::Project; + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(editor(&app).layout(), FooterLayout::default()); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + key(&mut app, KeyCode::Right, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::CONTROL); + key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL); + assert_eq!(editor(&app).layout(), FooterLayout::default()); + assert!(commands.try_recv().is_err()); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.config.overlay.is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn rapid_edits_autosave_in_order_and_escape_waits_for_the_last_write() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut app, mut commands) = app(); + let original = footer(&app); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + key(&mut app, KeyCode::Right, KeyModifiers::NONE); + assert_eq!(editor(&app).selected, 7); + key(&mut app, KeyCode::Up, KeyModifiers::CONTROL); + assert_eq!(editor(&app).selected, 6); + assert_eq!(editor(&app).layout().fields()[6].id, FooterFieldId::Model); + key(&mut app, KeyCode::Down, KeyModifiers::CONTROL); + key(&mut app, KeyCode::Left, KeyModifiers::NONE); + assert_eq!(editor(&app).selected, 4); + assert_eq!(editor(&app).layout().fields()[4].side, FooterSide::Left); + key(&mut app, KeyCode::Right, KeyModifiers::NONE); + let request = commands.recv_envelope().await.expect("save command"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert_eq!(mutation.id, SETTING_ID); + assert_eq!(mutation.scope, SettingsScope::User); + assert_eq!(mutation.expected_revision, "r1"); + let value = mutation.value.expect("saved layout"); + assert_eq!(value[1], json!({"id":"model","visible":false,"side":"left"})); + assert!(commands.try_recv().is_err(), "one write in flight"); + assert_eq!(footer(&app), original); + assert!(app.config.overlay.is_some(), "wait for save acknowledgement"); + let saved = snapshot(&app.cwd_raw, Some(&value), "r2"); + crate::app::handle_client_event( + &mut app, + ClientEvent::SettingsResultReceived { + session_id: "session-1".into(), + request_id: request.request_id, + result: SettingsResult { + persistence: SettingsPersistence::Saved, + application: SettingsApplication::Host, + snapshot: Some(saved), + error: None, + }, + }, + ); + assert!(app.config.overlay.is_some(), "autosave keeps the editor open"); + assert!(!footer(&app).contains("test-model")); + let request = commands.recv_envelope().await.expect("queued save"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert_eq!(mutation.expected_revision, "r2"); + let value = mutation.value.expect("latest layout"); + assert_eq!(value[7], json!({"id":"model","visible":false,"side":"right"})); + key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL); + assert_eq!(editor(&app).layout(), FooterLayout::default()); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.config.overlay.is_some(), "wait for the last write before closing"); + let saved = snapshot(&app.cwd_raw, Some(&value), "r3"); + super::super::service::apply_settings_result( + &mut app, + request.request_id.as_deref(), + SettingsResult { + persistence: SettingsPersistence::Saved, + application: SettingsApplication::Host, + snapshot: Some(saved), + error: None, + }, + ); + assert!(app.config.overlay.is_some(), "queued reset must finish too"); + let request = commands.recv_envelope().await.expect("queued reset"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert_eq!(mutation.operation, crate::agent::settings::SettingsOperation::Remove); + assert_eq!(mutation.expected_revision, "r3"); + assert!(mutation.value.is_none()); + let reset = snapshot(&app.cwd_raw, None, "r4"); + super::super::service::apply_settings_result( + &mut app, + request.request_id.as_deref(), + SettingsResult { + persistence: SettingsPersistence::Saved, + application: SettingsApplication::Host, + snapshot: Some(reset), + error: None, + }, + ); + assert!(app.config.overlay.is_none()); + assert!(app.config.saved_value(SETTING_ID).is_none()); + assert!(commands.try_recv().is_err()); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn escape_waits_for_autosave_and_reset_removes_the_preference() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut app, mut commands) = app(); + let original = footer(&app); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + let request = commands.recv_envelope().await.expect("automatic save"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert!(app.config.overlay.is_some()); + let saved = snapshot(&app.cwd_raw, mutation.value.as_ref(), "r2"); + super::super::service::apply_settings_result( + &mut app, + request.request_id.as_deref(), + SettingsResult { + persistence: SettingsPersistence::Saved, + application: SettingsApplication::Host, + snapshot: Some(saved), + error: None, + }, + ); + assert!(app.config.overlay.is_none()); + assert!(!footer(&app).contains("test-model")); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL); + let request = commands.recv_envelope().await.expect("reset command"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert_eq!(mutation.operation, crate::agent::settings::SettingsOperation::Remove); + assert!(mutation.value.is_none()); + let reset = snapshot(&app.cwd_raw, None, "r3"); + crate::app::handle_client_event( + &mut app, + ClientEvent::SettingsResultReceived { + session_id: "session-1".into(), + request_id: request.request_id, + result: SettingsResult { + persistence: SettingsPersistence::Saved, + application: SettingsApplication::Host, + snapshot: Some(reset), + error: None, + }, + }, + ); + assert!(app.config.overlay.is_some(), "reset keeps the editor open"); + assert_eq!(footer(&app), original); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.config.overlay.is_none()); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn failed_save_and_conflict_keep_the_draft_and_retry_uses_the_new_revision() { + tokio::task::LocalSet::new() + .run_until(async { + let (mut app, mut commands) = app(); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + let draft = editor(&app).layout(); + for (persistence, revision) in + [(SettingsPersistence::Failure, "r1"), (SettingsPersistence::Conflict, "r2")] + { + let request = commands.recv_envelope().await.expect("save"); + let refreshed = snapshot(&app.cwd_raw, Some(&json!([])), revision); + super::super::service::apply_settings_result( + &mut app, + request.request_id.as_deref(), + SettingsResult { + persistence, + application: SettingsApplication::Blocked, + snapshot: Some(refreshed), + error: Some("Cannot save yet".into()), + }, + ); + assert_eq!(editor(&app).layout(), draft); + assert!(app.config.overlay_message.is_some()); + assert!(commands.try_recv().is_err(), "failed writes do not retry themselves"); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + } + let request = commands.recv_envelope().await.expect("retry"); + let BridgeCommand::MutateSetting { mutation, .. } = request.command else { + panic!("mutation") + }; + assert_eq!(mutation.expected_revision, "r2"); + assert_eq!(mutation.value, Some(serde_json::to_value(draft).expect("draft"))); + }) + .await; +} + +#[test] +fn settings_row_describes_the_active_footer_layout_across_scopes() { + for scope in [SettingsScope::User, SettingsScope::Project] { + for (value, expected) in [ + (None, "Default layout"), + (Some(json!([])), "Custom layout"), + (Some(json!([{"id":"model","visible":true,"side":"middle"}])), "Invalid layout"), + ] { + let (mut app, _) = app(); + app.config.selected_scope = scope; + app.config.snapshot = Some(snapshot(&app.cwd_raw, value.as_ref(), "r1")); + let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("terminal"); + terminal + .draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app)) + .expect("draw"); + let buffer = terminal.backend().buffer(); + let lines: Vec = buffer + .content + .chunks(120) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect(); + let row = lines.iter().find(|line| line.contains("Footer")).expect("footer row"); + assert!(row.contains(expected), "{scope:?}: {row}"); + if value.is_none() { + assert!(lines.iter().any(|line| line.contains("Using default layout"))); + } + } + } +} + +#[test] +fn dialog_keeps_selected_field_controls_and_preview_in_their_regions() { + for (width, height, error) in + [(100, 30, false), (60, 20, false), (30, 12, false), (30, 12, true)] + { + let (mut app, _commands) = app(); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + key(&mut app, KeyCode::Right, KeyModifiers::NONE); + if error { + app.config.set_overlay_error("This value changed since it was displayed. Review the refreshed saved value before retrying."); + } + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal.draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app)).expect("draw"); + let buffer = terminal.backend().buffer(); + let lines: Vec = buffer + .content + .chunks(usize::from(width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect(); + let selected = lines + .iter() + .position(|line| line.contains("Model") && line.contains("Right")) + .expect("selected model row"); + assert!(lines[selected].contains("[ ]")); + assert!(lines[selected].contains('›')); + assert!(lines.iter().any(|line| line.contains("Space")), "toggle control"); + assert!(!lines.iter().any(|line| line.contains("Ctrl+S")), "autosave needs no save key"); + assert!(lines.iter().any(|line| line.contains("Esc")), "cancel control"); + let title = lines.iter().find(|line| line.contains("Footer")).expect("dialog title"); + let counter_column = title.find("8/8").expect("selected-field counter in title"); + let right_border = title.rfind('┐').expect("dialog top-right corner"); + assert_eq!( + title[counter_column..right_border].chars().count(), + 4, + "counter beside the top-right corner" + ); + if width >= 60 { + let preview = + lines.iter().position(|line| line.contains("Preview")).expect("preview label") + 1; + assert!(lines[preview].contains("FAST:OFF")); + assert!(!lines[preview].contains("test-model")); + } + } +} + +#[test] +fn failed_or_interrupted_autosaves_keep_edits_until_explicitly_closed() { + for interrupted in [false, true] { + let (mut app, mut commands) = app(); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + let draft = editor(&app).layout(); + let request = commands.try_recv().expect("automatic save"); + if interrupted { + app.config.invalidate_session(); + } else { + super::super::service::apply_settings_result( + &mut app, + request.request_id.as_deref(), + SettingsResult { + persistence: SettingsPersistence::Failure, + application: SettingsApplication::Blocked, + snapshot: None, + error: Some("Cannot write preferences".into()), + }, + ); + } + assert_eq!(editor(&app).layout(), draft); + assert!(editor(&app).can_retry()); + assert!(app.config.overlay_message.is_some()); + assert!(commands.try_recv().is_err()); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.config.overlay.is_none()); + assert!(app.config.last_error.as_ref().expect("unsaved warning").contains("not saved")); + assert!(commands.try_recv().is_err(), "closing after an error does not retry"); + } +} + +#[test] +fn shrinking_below_the_minimum_keeps_edits_and_shows_a_close_hint() { + let (mut app, _commands) = app(); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + key(&mut app, KeyCode::Down, KeyModifiers::NONE); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + key(&mut app, KeyCode::Right, KeyModifiers::NONE); + let edited = editor(&app).layout(); + for (width, height) in [(20, 8), (60, 20)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal.draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app)).expect("draw"); + let buffer = terminal.backend().buffer(); + let lines: Vec = buffer + .content + .chunks(usize::from(width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect(); + if width == 20 { + assert!(lines.iter().any(|line| line.contains("Esc close"))); + } else { + let selected = lines + .iter() + .find(|line| line.contains("Model") && line.contains("Right")) + .expect("selected field after growing"); + assert!(selected.contains("[ ]") && selected.contains('›')); + } + assert_eq!(editor(&app).layout(), edited); + } +} + +#[test] +fn invalid_layout_is_reported_and_location_changes_block_saving() { + let (mut app, mut commands) = app(); + app.config.snapshot = Some(snapshot( + &app.cwd_raw, + Some(&json!([{"id":"model","visible":true,"side":"middle"}])), + "r1", + )); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(app.config.overlay_message.is_some()); + assert_eq!(editor(&app).layout(), FooterLayout::default()); + key(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(commands.try_recv().is_err(), "inspecting invalid preferences does not replace them"); + key(&mut app, KeyCode::Enter, KeyModifiers::NONE); + app.cwd_raw = "/another-project".into(); + key(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + assert!(commands.try_recv().is_err()); + assert!(app.config.overlay.is_some()); + assert!(app.config.overlay_message.as_ref().expect("error").text.contains("location changed")); +} diff --git a/src/app/config/mcp/snapshot.rs b/src/app/config/mcp/snapshot.rs index 4545d8ab..3997d0a9 100644 --- a/src/app/config/mcp/snapshot.rs +++ b/src/app/config/mcp/snapshot.rs @@ -128,6 +128,7 @@ fn clear_missing_mcp_server_overlays(app: &mut App) { } Some( ConfigOverlayState::Setting(_) + | ConfigOverlayState::Footer(_) | ConfigOverlayState::SessionRename(_) | ConfigOverlayState::Confirmation(_) | ConfigOverlayState::InstalledPluginActions(_) diff --git a/src/app/config/mod.rs b/src/app/config/mod.rs index 453d38c0..a1f4162e 100644 --- a/src/app/config/mod.rs +++ b/src/app/config/mod.rs @@ -4,6 +4,7 @@ mod browse; mod controller; mod edit; +mod footer; mod help; pub(crate) mod hooks; mod mcp; @@ -33,6 +34,7 @@ pub(crate) use mcp::{ }; // Used by the binary UI target, but not by the library target in isolation. pub use browse::{SettingsBrowse, SettingsFocus}; +pub use footer::FooterOverlayState; #[allow(unused_imports)] pub(crate) use mcp::McpCallbackUrlOverlayState; pub use overlays::*; diff --git a/src/app/config/overlays.rs b/src/app/config/overlays.rs index 56c7d16c..08e38789 100644 --- a/src/app/config/overlays.rs +++ b/src/app/config/overlays.rs @@ -169,6 +169,7 @@ pub struct ConfirmationOverlayState { #[derive(Debug, Clone, PartialEq)] pub enum ConfigOverlayState { Setting(SettingOverlayState), + Footer(super::FooterOverlayState), SessionRename(SessionRenameOverlayState), InstalledPluginActions(InstalledPluginActionOverlayState), PluginInstallActions(PluginInstallOverlayState), diff --git a/src/app/config/service/mod.rs b/src/app/config/service/mod.rs index 697d4831..cf3a847a 100644 --- a/src/app/config/service/mod.rs +++ b/src/app/config/service/mod.rs @@ -118,6 +118,10 @@ pub(crate) fn apply_settings_result( }; let owns_editor = app.config.setting_overlay().is_some_and(|editor| pending.owns_editor(editor)); + let footer_save = + owns_editor && matches!(app.config.overlay, Some(super::ConfigOverlayState::Footer(_))); + let saved = + matches!(result.persistence, SettingsPersistence::Saved | SettingsPersistence::Unchanged); let previous_fast_mode = app.config.fast_mode_effective(); let previous_gitignore = app.config.respect_gitignore_effective(); let previous_scroll = app.config.auto_scroll_effective(); @@ -132,7 +136,7 @@ pub(crate) fn apply_settings_result( .as_ref() .map_or(0, |previous| app.config.settings.selected_index(previous)); app.config.settings.reconcile(&snapshot, previous_index); - if result.persistence == SettingsPersistence::Conflict + if (result.persistence == SettingsPersistence::Conflict || (footer_save && saved)) && let Some(editor) = app.config.setting_overlay_mut() && let Some(value) = snapshot.scoped(&editor.setting.id, editor.scope) && owns_editor @@ -169,7 +173,7 @@ pub(crate) fn apply_settings_result( app.config.last_error = result.error; app.config.status_message = match result.persistence { SettingsPersistence::Saved | SettingsPersistence::Unchanged => { - if owns_editor { + if owns_editor && !footer_save { app.config.clear_overlay(); } Some(if result.persistence == SettingsPersistence::Unchanged { @@ -191,6 +195,9 @@ pub(crate) fn apply_settings_result( { app.config.set_overlay_error(error); } + if footer_save { + super::footer::complete_save(app, saved); + } } fn clock_preferences(app: &App) -> (bool, bool, String, Option) { diff --git a/src/app/config/state.rs b/src/app/config/state.rs index e583f38c..768d7780 100644 --- a/src/app/config/state.rs +++ b/src/app/config/state.rs @@ -155,17 +155,17 @@ impl ConfigState { self.settings.selected(self.snapshot.as_ref()?) } pub fn setting_overlay(&self) -> Option<&SettingOverlayState> { - if let Some(ConfigOverlayState::Setting(overlay)) = &self.overlay { - Some(overlay) - } else { - None + match &self.overlay { + Some(ConfigOverlayState::Setting(overlay)) => Some(overlay), + Some(ConfigOverlayState::Footer(overlay)) => Some(&overlay.editor), + _ => None, } } pub fn setting_overlay_mut(&mut self) -> Option<&mut SettingOverlayState> { - if let Some(ConfigOverlayState::Setting(overlay)) = &mut self.overlay { - Some(overlay) - } else { - None + match &mut self.overlay { + Some(ConfigOverlayState::Setting(overlay)) => Some(overlay), + Some(ConfigOverlayState::Footer(overlay)) => Some(&mut overlay.editor), + _ => None, } } @@ -180,6 +180,21 @@ impl ConfigState { } pub fn invalidate_session(&mut self) { + let footer_save_interrupted = + if let Some(ConfigOverlayState::Footer(overlay)) = &mut self.overlay { + if self + .pending_settings_request + .as_ref() + .is_some_and(|pending| pending.owns_editor(&overlay.editor)) + { + overlay.queue_save(); + true + } else { + false + } + } else { + false + }; self.snapshot = None; self.pending_settings_request = None; self.pending_session_title_change = None; @@ -187,6 +202,8 @@ impl ConfigState { self.last_error = None; if self.setting_overlay().is_none() { self.clear_overlay(); + } else if footer_save_interrupted { + self.set_overlay_error("The settings session changed. Unconfirmed footer changes were not saved. Close and reopen the footer editor."); } } diff --git a/src/app/footer.rs b/src/app/footer.rs new file mode 100644 index 00000000..8d04d1eb --- /dev/null +++ b/src/app/footer.rs @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +pub(crate) const SETTING_ID: &str = "presentation.footer"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FooterFieldId { + PermissionMode, + Model, + Effort, + Ultracode, + FastMode, + Directory, + GitBranch, + ContextRemaining, +} + +impl FooterFieldId { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::PermissionMode => "Permission mode", + Self::Model => "Model", + Self::Effort => "Effort", + Self::Ultracode => "Ultracode", + Self::FastMode => "Fast mode", + Self::Directory => "Working directory", + Self::GitBranch => "Git branch", + Self::ContextRemaining => "Context remaining", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FooterSide { + Left, + Right, +} + +impl FooterSide { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Left => "Left", + Self::Right => "Right", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct FooterField { + pub(crate) id: FooterFieldId, + pub(crate) visible: bool, + pub(crate) side: FooterSide, +} + +/// Each field occurs once. Array order is reading order within each side. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub(crate) struct FooterLayout(Vec); + +impl Default for FooterLayout { + fn default() -> Self { + use FooterFieldId as Id; + use FooterSide::{Left, Right}; + Self( + [ + (Id::PermissionMode, Left), + (Id::Model, Left), + (Id::Effort, Left), + (Id::Ultracode, Left), + (Id::FastMode, Left), + (Id::Directory, Right), + (Id::GitBranch, Right), + (Id::ContextRemaining, Right), + ] + .into_iter() + .map(|(id, side)| FooterField { id, visible: true, side }) + .collect(), + ) + } +} + +impl<'de> Deserialize<'de> for FooterLayout { + fn deserialize>(deserializer: D) -> Result { + let mut fields = Vec::::deserialize(deserializer)?; + for (index, field) in fields.iter().enumerate() { + if fields[..index].iter().any(|previous| previous.id == field.id) { + return Err(serde::de::Error::custom(format!( + "{} occurs more than once", + field.id.label() + ))); + } + } + // Omitted fields stay available in the editor without enabling new content. + for mut field in Self::default().0 { + if !fields.iter().any(|entry| entry.id == field.id) { + field.visible = false; + fields.push(field); + } + } + fields.sort_by_key(|field| field.side); + Ok(Self(fields)) + } +} + +impl FooterLayout { + pub(crate) fn from_saved(value: Option<&serde_json::Value>) -> Result { + value.map_or_else(|| Ok(Self::default()), |value| serde_json::from_value(value.clone())) + } + + pub(crate) fn fields(&self) -> &[FooterField] { + &self.0 + } + + pub(crate) fn toggle(&mut self, index: usize) { + if let Some(field) = self.0.get_mut(index) { + field.visible = !field.visible; + } + } + + /// Changing sides appends to the destination group and follows the field. + pub(crate) fn switch_side(&mut self, index: usize) -> usize { + let Some(field) = self.0.get(index) else { + return index; + }; + let side = match field.side { + FooterSide::Left => FooterSide::Right, + FooterSide::Right => FooterSide::Left, + }; + let mut field = self.0.remove(index); + field.side = side; + let next = self.0.iter().take_while(|entry| entry.side <= side).count(); + self.0.insert(next, field); + next + } + + pub(crate) fn move_field(&mut self, index: usize, down: bool) -> usize { + let next = if down { index.checked_add(1) } else { index.checked_sub(1) }; + let Some(next) = next.filter(|&next| { + self.0.get(index).zip(self.0.get(next)).is_some_and(|(a, b)| a.side == b.side) + }) else { + return index; + }; + self.0.swap(index, next); + next + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn saved_order_and_disabled_placement_survive_round_trip() { + let fixture = json!([ + {"id":"effort","visible":false,"side":"right"}, + {"id":"model","visible":true,"side":"left"}, + {"id":"gitBranch","visible":true,"side":"right"} + ]); + let layout = FooterLayout::from_saved(Some(&fixture)).expect("layout"); + let right: Vec<_> = + layout.fields().iter().filter(|f| f.side == FooterSide::Right).collect(); + assert_eq!(right[0].id, FooterFieldId::Effort); + assert!(!right[0].visible); + assert_eq!(right[1].id, FooterFieldId::GitBranch); + assert_eq!(layout.fields().iter().filter(|f| f.visible).count(), 2); + let saved = serde_json::to_value(&layout).expect("serialize"); + assert_eq!(FooterLayout::from_saved(Some(&saved)).expect("reload"), layout); + } + + #[test] + fn empty_layout_disables_fields_and_missing_setting_restores_defaults() { + let empty = FooterLayout::from_saved(Some(&json!([]))).expect("empty layout"); + assert_eq!(empty.fields().len(), 8); + assert!(empty.fields().iter().all(|f| !f.visible)); + assert!( + FooterLayout::from_saved(None).expect("defaults").fields().iter().all(|f| f.visible) + ); + } + + #[test] + fn invalid_fields_and_duplicates_are_rejected() { + for fixture in [ + json!([{"id":"model","visible":true,"side":"middle"}]), + json!([{"id":"unknown","visible":true,"side":"left"}]), + json!([ + {"id":"model","visible":true,"side":"left"}, + {"id":"model","visible":false,"side":"right"} + ]), + ] { + assert!(FooterLayout::from_saved(Some(&fixture)).is_err()); + } + } + + #[test] + fn reordering_stays_within_side_and_switching_appends_to_destination() { + let mut layout = FooterLayout::default(); + assert_eq!(layout.move_field(0, false), 0); + assert_eq!(layout.move_field(4, true), 4); + assert_eq!(layout.move_field(7, true), 7); + let moved = layout.move_field(1, true); + assert_eq!(moved, 2); + assert_eq!(layout.fields()[moved].id, FooterFieldId::Model); + layout.toggle(moved); + let moved = layout.switch_side(moved); + assert_eq!(moved, 7); + assert_eq!(layout.fields()[moved].id, FooterFieldId::Model); + assert!(!layout.fields()[moved].visible); + let moved = layout.switch_side(moved); + assert_eq!(moved, 4); + assert_eq!(layout.fields()[moved].side, FooterSide::Left); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 810e3f4d..47fadccf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod diff_view; mod events; pub(crate) mod file_index; mod focus; +pub(crate) mod footer; mod git_context; mod inline_interactions; pub(crate) mod input; diff --git a/src/app/slash/tests.rs b/src/app/slash/tests.rs index a9e33f6d..31e25b6f 100644 --- a/src/app/slash/tests.rs +++ b/src/app/slash/tests.rs @@ -2020,7 +2020,7 @@ async fn ultracode_composer_wire_events_status_and_footer_workflow() { assert_eq!(app.transcript.messages.len(), transcript_before, "successful change must not add permanent messages"); let footer = crate::ui::footer_rows::serialize_footer_rows(&app, 120); let row: String = footer.rows[0].spans.iter().map(|s| s.content.as_ref()).collect(); - assert!(row.contains("[Opus/High \u{b7} Ultracode]"), "{row}"); + assert!(row.contains("[Opus]") && row.contains("[High]") && row.contains("[Ultracode]"), "{row}"); app.input.set_text("/effort low"); crate::app::input_submit::submit_input(&mut app); diff --git a/src/ui/config/footer.rs b/src/ui/config/footer.rs new file mode 100644 index 00000000..22466e03 --- /dev/null +++ b/src/ui/config/footer.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +use super::common; +use super::overlay::{OverlayChrome, OverlayLayoutSpec, render_overlay_shell}; +use crate::app::{ + App, + config::{ConfigOverlayState, FooterOverlayState}, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Margin, Rect}, + style::Style, + text::Line, + widgets::{Cell, Paragraph, Row, Table, TableState}, +}; +use unicode_width::UnicodeWidthChar; + +pub(super) fn render(frame: &mut Frame, area: Rect, app: &App) { + let Some(ConfigOverlayState::Footer(overlay)) = &app.config.overlay else { + return; + }; + let layout = overlay.layout(); + let compact = area.width < 56 || area.height < 18; + let help = help_text(app, overlay, compact); + // Keep the selected row and controls visible even after a save error. + let compact_message = app.config.overlay_message.as_ref().filter(|_| compact).map(|message| { + let mut used = 0; + let mut text: String = message + .text + .lines() + .next() + .unwrap_or_default() + .chars() + .take_while(|ch| { + used += ch.width().unwrap_or(0); + used <= usize::from(area.width.saturating_sub(7)) + }) + .collect(); + if text.len() < message.text.len() { + text.push_str("..."); + } + crate::app::config::OverlayMessage { kind: message.kind, text } + }); + let shell = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 56, + min_height: 16, + width_percent: 85, + height_percent: 85, + preferred_height: 20, + fullscreen_below: Some((56, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Footer", + subtitle: (!compact).then_some(if app.config.pending_settings_request.is_some() { + "User preference · Saving..." + } else { + "User preference · Changes save automatically" + }), + help: Some(&help), + message: compact_message.as_ref().or(app.config.overlay_message.as_ref()), + }, + ); + let counter = common::position_counter(overlay.selected, layout.fields().len()); + let counter_width = u16::try_from(counter.width()).unwrap_or_default(); + frame.render_widget( + Paragraph::new(Line::from(counter)), + Rect::new( + shell.rect.right().saturating_sub(counter_width + 2), + shell.rect.y, + counter_width, + 1, + ), + ); + let sections = + Layout::vertical([Constraint::Min(2), Constraint::Length(1), Constraint::Length(1)]) + .split(shell.body_area); + let rows = layout.fields().iter().map(|field| { + Row::new([ + Cell::from(if field.visible { "[x]" } else { "[ ]" }), + Cell::from(field.id.label()), + Cell::from(field.side.label()), + ]) + }); + let table = + Table::new(rows, [Constraint::Length(3), Constraint::Min(8), Constraint::Length(5)]) + .column_spacing(1) + .header(Row::new(["", "Field", "Side"]).style(common::title_style())) + .row_highlight_style(common::selection_style(true)) + .highlight_symbol("› "); + let mut state = TableState::default().with_selected(Some(overlay.selected)); + frame.render_stateful_widget(table, sections[0], &mut state); + frame.render_widget( + Paragraph::new("Preview").style(Style::default().fg(super::theme::DIM)), + sections[1], + ); + frame.render_widget( + Paragraph::new(crate::ui::footer_rows::serialize_footer(app, &layout, sections[2].width)), + sections[2], + ); +} + +fn help_text(app: &App, overlay: &FooterOverlayState, compact: bool) -> String { + let read_only = !overlay.editor.setting.writable_at(overlay.editor.scope); + let mut help = if read_only { + "Read-only | Up/Down select" + } else if compact { + "↑/↓ select | Space toggle | ←/→ side | Ctrl+↑/↓ move | Ctrl+R reset" + } else { + "Up/Down select | Space on/off | Left/Right side | Ctrl+Up/Down move | Ctrl+R reset" + } + .to_owned(); + if !read_only + && overlay.can_retry() + && app + .config + .overlay_message + .as_ref() + .is_some_and(|message| message.kind == crate::app::config::OverlayMessageKind::Error) + { + help.push_str(if compact { + " | Enter retry | Esc discard" + } else { + " | Enter retry | Esc discard unsaved" + }); + } else if read_only { + help.push_str(" | Esc back"); + } else { + help.push_str(" | Esc close"); + } + help +} diff --git a/src/ui/config/mod.rs b/src/ui/config/mod.rs index 84f6e492..2a0127f8 100644 --- a/src/ui/config/mod.rs +++ b/src/ui/config/mod.rs @@ -3,6 +3,7 @@ mod action_overlay; mod common; +mod footer; mod help; mod input; mod mcp; @@ -33,11 +34,18 @@ pub fn render(frame: &mut Frame, app: &mut App) { && (app.config.overlay.is_none() || app.config.setting_overlay().is_some()) { frame.render_widget( - Paragraph::new(if app.config.setting_overlay().is_some() { - "Window too small. Resize to view config.\nEsc cancel" - } else { - "Window too small. Resize to view config.\nEsc close" - }) + Paragraph::new( + if app.config.setting_overlay().is_some() + && !matches!( + app.config.overlay, + Some(crate::app::config::ConfigOverlayState::Footer(_)) + ) + { + "Window too small. Resize to view config.\nEsc cancel" + } else { + "Window too small. Resize to view config.\nEsc close" + }, + ) .wrap(Wrap { trim: false }), frame_area, ); diff --git a/src/ui/config/settings.rs b/src/ui/config/settings.rs index a4f79a73..4fd3b3b7 100644 --- a/src/ui/config/settings.rs +++ b/src/ui/config/settings.rs @@ -216,7 +216,8 @@ fn table_rows<'a>( let mut rows = Vec::new(); for (index, setting) in items.iter().enumerate() { let focused = index == selected && app.config.settings.focus == SettingsFocus::Content; - let value = if setting.kind.is_structured() { + let is_footer = setting.id == crate::app::footer::SETTING_ID; + let value = if setting.kind.is_structured() && !is_footer { snapshot .scoped(&setting.id, app.config.selected_scope) .and_then(|value| value.value.as_ref()) @@ -224,6 +225,25 @@ fn table_rows<'a>( snapshot.value(&setting.id) }; let writable = setting.writable_at(app.config.selected_scope); + let summary = if is_footer { + match crate::app::footer::FooterLayout::from_saved(value) { + Ok(_) if value.is_none() => "Default layout", + Ok(_) => "Custom layout", + Err(_) => "Invalid layout", + } + .to_owned() + } else { + value.map_or_else( + || { + if setting.kind.is_structured() { + "Not set here".into() + } else { + "Default".into() + } + }, + display, + ) + }; rows.push( Row::new(vec![ Cell::from(super::common::marker_span(focused)), @@ -232,17 +252,7 @@ fn table_rows<'a>( } else { theme::DIM })), - Cell::from(value.map_or_else( - || { - if setting.kind.is_structured() { - "Not set here".into() - } else { - "Default".into() - } - }, - display, - )) - .style( + Cell::from(summary).style( Style::default() .fg(if value.is_some() && writable { theme::BTW_ACCENT @@ -299,6 +309,9 @@ fn details_lines(app: &App) -> Vec> { "From {} · Not set in {scope}", if other_sources.is_empty() { "another scope" } else { &other_sources } ), + (None, None) if setting.id == crate::app::footer::SETTING_ID => { + "Using default layout".to_owned() + } (None, None) if setting.kind.is_structured() => format!("Not set in {scope}"), (None, None) => "Using Default".to_owned(), }; diff --git a/src/ui/config/settings_overlay.rs b/src/ui/config/settings_overlay.rs index 61ef48bb..70eca481 100644 --- a/src/ui/config/settings_overlay.rs +++ b/src/ui/config/settings_overlay.rs @@ -14,6 +14,10 @@ use ratatui::{ }; pub(super) fn render_setting_overlay(frame: &mut Frame, area: Rect, app: &App) { + if matches!(app.config.overlay, Some(crate::app::config::ConfigOverlayState::Footer(_))) { + super::footer::render(frame, area, app); + return; + } let Some(overlay) = app.config.setting_overlay() else { return; }; diff --git a/src/ui/footer_rows.rs b/src/ui/footer_rows.rs index 0236115e..8bbc7abf 100644 --- a/src/ui/footer_rows.rs +++ b/src/ui/footer_rows.rs @@ -2,190 +2,170 @@ // Copyright 2025 Simon Peter Rothgang use crate::agent::model; +use crate::app::footer::{FooterFieldId, FooterLayout, FooterSide, SETTING_ID}; use crate::app::{App, MessageBlock, MessageRole}; use crate::ui::theme; -use ratatui::buffer::Buffer; -use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Widget}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -const FOOTER_COLUMN_GAP: u16 = 1; -const PRIMARY_ROW_LEFT_MIN_WIDTH: u16 = 24; -const SECONDARY_ROW_LEFT_MIN_WIDTH: u16 = 28; -const MIN_CONTEXT_LOCATION_WIDTH: usize = 10; -const MIN_CONTEXT_BRANCH_WIDTH: usize = 4; const FOOTER_CONTEXT_VALUE: Color = Color::Gray; +const FIELD_SEPARATOR: &str = " · "; type FooterItem = Option<(String, Color)>; pub(crate) struct SerializedFooterRows { - pub rows: [Line<'static>; 2], + pub rows: [Line<'static>; 1], } pub(crate) fn serialize_footer_rows(app: &App, total_width: u16) -> SerializedFooterRows { - let first_row = compose_footer_row( - build_primary_line(app), - footer_primary_hint(app), - total_width, - PRIMARY_ROW_LEFT_MIN_WIDTH, - ); - - let second_hint = footer_secondary_hint(app); - let second_left_width = footer_row_widths( - total_width, - second_hint.as_ref().map(|(text, _)| text.as_str()), - SECONDARY_ROW_LEFT_MIN_WIDTH, - ) - .0; - let second_row = compose_footer_row( - build_context_line(app, usize::from(second_left_width)), - second_hint, - total_width, - SECONDARY_ROW_LEFT_MIN_WIDTH, - ); - - SerializedFooterRows { rows: [first_row, second_row] } + let layout = FooterLayout::from_saved(app.config.saved_value(SETTING_ID)).unwrap_or_default(); + SerializedFooterRows { rows: [serialize_footer(app, &layout, total_width)] } } -fn footer_primary_hint(app: &App) -> FooterItem { - let permission_count = pending_permission_request_count(app); - if permission_count > 0 { - return Some((format!("{permission_count} PEND. PERM."), Color::Yellow)); +/// The dialog preview and chat use exactly the same formatter and width policy. +pub(crate) fn serialize_footer(app: &App, layout: &FooterLayout, width: u16) -> Line<'static> { + let width = usize::from(width); + let notice = footer_notice(app) + .and_then(|(text, color)| fit_footer_right_text(&text, width).map(|text| (text, color))); + let notice_width = notice.as_ref().map(|(text, _)| text.width()); + let mut fields: Vec<_> = layout + .fields() + .iter() + .filter(|field| field.visible) + .filter_map(|field| { + format_field(app, field.id).map(|(text, color)| RenderedField { + id: field.id, + side: field.side, + text, + color, + }) + }) + .collect(); + let mut overflow = fields_width(&fields, notice_width).saturating_sub(width); + // Flexible workspace values shrink before any status badge is omitted. + for field in fields.iter_mut().rev() { + let minimum = match field.id { + FooterFieldId::Directory => 10, + FooterFieldId::GitBranch => 12, + _ => continue, + }; + let reduction = overflow.min(field.text.width().saturating_sub(minimum)); + let target = field.text.width().saturating_sub(reduction); + if reduction > 0 { + field.text = if field.id == FooterFieldId::Directory { + format!( + "Loc: {}", + fit_location_value(&app.cwd, target.saturating_sub(5)).unwrap_or_default() + ) + } else { + fit_footer_right_text(&field.text, target).unwrap_or_default() + }; + overflow = overflow.saturating_sub(reduction); + } } - None -} - -fn footer_mcp_auth_hint(app: &App) -> FooterItem { - let needs_auth_count = mcp_needs_auth_count(app); - (needs_auth_count > 0 && should_show_startup_mcp_hint(app)) - .then(|| (format!("{needs_auth_count} MCP NEEDS AUTH"), Color::Yellow)) -} - -fn footer_context_usage_hint(app: &App) -> FooterItem { - app.session_runtime.session_usage.context_usage_percent.map(|percentage| { - let remaining = 100_u8.saturating_sub(percentage); - (format!("{remaining}%"), FOOTER_CONTEXT_VALUE) - }) -} - -fn footer_secondary_hint(app: &App) -> FooterItem { - footer_mcp_auth_hint(app).or_else(|| footer_context_usage_hint(app)) -} - -fn footer_row_widths( - total_width: u16, - right_text: Option<&str>, - left_min_width: u16, -) -> (u16, u16) { - if total_width == 0 { - return (0, 0); + // Earlier entries take precedence; groups never overlap or wrap. + while fields_width(&fields, notice_width) > width { + fields.pop(); } - - let Some(right_text) = right_text else { - return (total_width, 0); - }; - - let left_min_width = left_min_width.min(total_width); - let available_right = - total_width.saturating_sub(left_min_width).saturating_sub(FOOTER_COLUMN_GAP); - if available_right == 0 { - return (total_width, 0); + let mut left = group_spans(&fields, FooterSide::Left); + let mut right = group_spans(&fields, FooterSide::Right); + if let Some((text, color)) = notice { + if !right.is_empty() { + right.push(Span::styled(FIELD_SEPARATOR, Style::default().fg(theme::DIM))); + } + right.push(Span::styled(text, Style::default().fg(color))); } - - let natural_right_width = u16::try_from(UnicodeWidthStr::width(right_text)).unwrap_or(u16::MAX); - let right_width = natural_right_width.min(available_right); - if right_width == 0 { - return (total_width, 0); + let occupied: usize = left.iter().chain(&right).map(Span::width).sum(); + if !right.is_empty() { + left.push(Span::raw(" ".repeat(width.saturating_sub(occupied)))); + left.extend(right); } + Line::from(left) +} - let left_width = total_width.saturating_sub(right_width).saturating_sub(FOOTER_COLUMN_GAP); - (left_width, right_width) +struct RenderedField { + id: FooterFieldId, + side: FooterSide, + text: String, + color: Color, } -fn compose_footer_row( - left: Line<'static>, - right: FooterItem, - total_width: u16, - left_min_width: u16, -) -> Line<'static> { - if total_width == 0 { - return Line::default(); - } - - let (left_width, right_width) = footer_row_widths( - total_width, - right.as_ref().map(|(text, _)| text.as_str()), - left_min_width, - ); - - let area = Rect::new(0, 0, total_width, 1); - let mut buf = Buffer::empty(area); - let left_area = Rect::new(0, 0, left_width, 1); - Paragraph::new(left).render(left_area, &mut buf); - - if let Some((right_text, right_color)) = right - && right_width > 0 - && let Some(fitted) = fit_footer_right_text(&right_text, usize::from(right_width)) - { - let right_x = left_width.saturating_add(FOOTER_COLUMN_GAP); - let right_area = Rect::new(right_x, 0, right_width, 1); - let line = Line::from(Span::styled(fitted, Style::default().fg(right_color))); - Paragraph::new(line).alignment(Alignment::Right).render(right_area, &mut buf); - } - - buffer_row_to_line(&buf, area, 0) +fn fields_width(fields: &[RenderedField], notice_width: Option) -> usize { + let left_count = fields.iter().filter(|field| field.side == FooterSide::Left).count(); + let right_count = fields.len() - left_count + usize::from(notice_width.is_some()); + fields.iter().map(|field| field.text.width()).sum::() + + notice_width.unwrap_or(0) + + (left_count.saturating_sub(1) + right_count.saturating_sub(1)) * FIELD_SEPARATOR.width() + + usize::from(left_count > 0 && right_count > 0) } -fn build_primary_line(app: &App) -> Line<'static> { - if let Some(ref mode) = app.session_runtime.mode { - let color = mode_color(&mode.current_mode_id); - let (fast_mode_text, fast_mode_color) = - fast_mode_badge(app.session_runtime.fast_mode_state); - let mut spans = Vec::new(); - push_badge(&mut spans, mode.current_mode_name.clone(), color); - if let Some(model_badge) = footer_model_badge(app) { - spans.push(Span::raw(" ")); - spans.extend(model_badge); - } - spans.push(Span::raw(" ")); - push_badge(&mut spans, fast_mode_text.to_owned(), fast_mode_color); - Line::from(spans) - } else { - let (fast_mode_text, fast_mode_color) = - fast_mode_badge(app.session_runtime.fast_mode_state); - let mut spans = Vec::new(); - if let Some((status_text, status_color)) = startup_status_badge(app) { - push_badge(&mut spans, status_text.to_owned(), status_color); - spans.push(Span::raw(" ")); +fn group_spans(fields: &[RenderedField], side: FooterSide) -> Vec> { + let mut spans = Vec::new(); + for field in fields.iter().filter(|field| field.side == side) { + if !spans.is_empty() { + spans.push(Span::styled(FIELD_SEPARATOR, Style::default().fg(theme::DIM))); } - push_badge(&mut spans, fast_mode_text.to_owned(), fast_mode_color); - Line::from(spans) + spans.push(Span::styled(field.text.clone(), Style::default().fg(field.color))); } + spans } -fn push_badge(spans: &mut Vec>, text: String, color: Color) { - spans.push(Span::styled("[", Style::default().fg(color))); - spans.push(Span::styled(text, Style::default().fg(color))); - spans.push(Span::styled("]", Style::default().fg(color))); +fn format_field(app: &App, id: FooterFieldId) -> FooterItem { + let gray = FOOTER_CONTEXT_VALUE; + match id { + FooterFieldId::PermissionMode => app.session_runtime.mode.as_ref().map(|mode| { + (format!("[{}]", mode.current_mode_name), mode_color(&mode.current_mode_id)) + }), + FooterFieldId::Model => app + .session_runtime + .current_model + .as_ref() + .map(|model| (format!("[{}]", model.display_name_short), gray)), + FooterFieldId::Effort => app + .session_runtime + .current_model + .as_ref() + .filter(|model| model.supports_effort) + .and_then(|_| app.session_effort()) + .map(|effort| (format!("[{}]", footer_effort_label(effort)), gray)), + FooterFieldId::Ultracode => app + .session_runtime + .ultracode + .filter(|state| state.effective()) + .map(|_| ("[Ultracode]".to_owned(), theme::ULTRACODE_ACCENT)), + FooterFieldId::FastMode => { + let (text, color) = fast_mode_badge(app.session_runtime.fast_mode_state); + Some((format!("[{text}]"), color)) + } + FooterFieldId::Directory => Some((format!("Loc: {}", app.cwd), gray)), + FooterFieldId::GitBranch => app + .git_branch() + .filter(|branch| !branch.is_empty()) + .map(|branch| (format!("Branch: {branch}"), gray)), + FooterFieldId::ContextRemaining => app + .session_runtime + .session_usage + .context_usage_percent + .map(|used| (format!("Context: {}%", 100_u8.saturating_sub(used)), gray)), + } } -fn footer_model_badge(app: &App) -> Option>> { - let current_model = app.session_runtime.current_model.as_ref()?; - let mut badge = current_model.display_name_short.clone(); - if let Some(effort) = app.session_effort().filter(|_| current_model.supports_effort) { - badge.push('/'); - badge.push_str(footer_effort_label(effort)); - } - let model_style = Style::default().fg(FOOTER_CONTEXT_VALUE); - let mut spans = vec![Span::styled("[", model_style), Span::styled(badge, model_style)]; - if app.session_runtime.ultracode.is_some_and(model::UltracodeState::effective) { - spans.push(Span::styled(" \u{b7} ", model_style)); - spans.push(Span::styled("Ultracode", Style::default().fg(theme::ULTRACODE_ACCENT))); - } - spans.push(Span::styled("]", model_style)); - Some(spans) +fn footer_notice(app: &App) -> FooterItem { + let permissions = pending_permission_request_count(app); + if permissions > 0 { + return Some((format!("{permissions} PEND. PERM."), Color::Yellow)); + } + let auth = mcp_needs_auth_count(app); + if auth > 0 && should_show_startup_mcp_hint(app) { + return Some((format!("{auth} MCP NEEDS AUTH"), Color::Yellow)); + } + app.session_runtime + .mode + .is_none() + .then(|| startup_status_badge(app)) + .flatten() + .map(|(text, color)| (format!("[{text}]"), color)) } const fn footer_effort_label(effort: model::EffortLevel) -> &'static str { @@ -198,51 +178,6 @@ const fn footer_effort_label(effort: model::EffortLevel) -> &'static str { } } -fn build_context_line(app: &App, max_width: usize) -> Line<'static> { - let Some((location_value, branch_value)) = context_values(app, max_width) else { - return Line::default(); - }; - - let mut spans = vec![ - Span::styled("Loc: ", Style::default().fg(theme::DIM)), - Span::styled(location_value, Style::default().fg(FOOTER_CONTEXT_VALUE)), - ]; - - if let Some(branch_value) = branch_value { - spans.push(Span::styled(" (", Style::default().fg(theme::DIM))); - spans.push(Span::styled(branch_value, Style::default().fg(FOOTER_CONTEXT_VALUE))); - spans.push(Span::styled(")", Style::default().fg(theme::DIM))); - } - - Line::from(spans) -} - -fn context_values(app: &App, max_width: usize) -> Option<(String, Option)> { - const LOCATION_LABEL_WIDTH: usize = 5; - const BRANCH_WRAP_WIDTH: usize = 3; - - let location_only_width = max_width.saturating_sub(LOCATION_LABEL_WIDTH); - let branch = app.git_branch().filter(|branch| !branch.is_empty()); - - if let Some(branch) = branch { - let fixed_width = LOCATION_LABEL_WIDTH + BRANCH_WRAP_WIDTH; - let available_values = max_width.saturating_sub(fixed_width); - if available_values >= MIN_CONTEXT_LOCATION_WIDTH + MIN_CONTEXT_BRANCH_WIDTH { - let branch_width = UnicodeWidthStr::width(branch) - .min(available_values.saturating_sub(MIN_CONTEXT_LOCATION_WIDTH)); - let branch_value = fit_footer_right_text(branch, branch_width); - let branch_display_width = - branch_value.as_ref().map_or(0, |value| UnicodeWidthStr::width(value.as_str())); - let location_width = available_values.saturating_sub(branch_display_width); - if let Some(location_value) = fit_location_value(&app.cwd, location_width) { - return Some((location_value, branch_value)); - } - } - } - - fit_location_value(&app.cwd, location_only_width).map(|location_value| (location_value, None)) -} - fn fit_location_value(cwd: &str, max_width: usize) -> Option { if max_width == 0 { return None; @@ -411,53 +346,6 @@ fn startup_status_badge(app: &App) -> Option<(&'static str, Color)> { } } -fn buffer_row_to_line(buf: &Buffer, area: Rect, row: u16) -> Line<'static> { - let y = area.y.saturating_add(row); - let mut cells = Vec::with_capacity(usize::from(area.width)); - for x in 0..area.width { - if let Some(cell) = buf.cell((area.x.saturating_add(x), y)) { - cells.push((cell.symbol().to_owned(), cell.style())); - } - } - - let Some(last_non_blank) = cells - .iter() - .rposition(|(symbol, _)| !symbol.is_empty() && !symbol.chars().all(char::is_whitespace)) - else { - return Line::default(); - }; - - let mut spans = Vec::new(); - let mut current_style: Option